"""Unit: the phase-139 (task 03) "the Reading line shows which lines" frontend contract. No new Python app logic exists for this task — the behavior lives in ``frontend/assets/app.js`` (the ``readRangeSuffix`` helper + the ``appendToolLine`` suffix span + the live ``tool`` branch's ``ev.offset``/``ev.limit`` read + the ``toolAcc`` persistence stamp + the restore pass-through) and ``frontend/assets/shared.js`` (the shared page's parity copy of the suffix + ``addToolLines``). Like the other frontend-adjacent unit files (``test_frontend_tool_states.py`` is the phase-37 precedent, ``test_read_truncation_frontend.py`` the phase-95 one), this module pins the JS markers the story depends on so a silent regression in the suffix wording, the well-formedness guard, the persistence shape, or the shared-page parity is caught without a browser. A small node-driven behavioral check (skipped when node is unavailable, like the phase-10 renderer check in ``test_document_viewer.py``) proves the three suffix shapes, the plain-read line's byte-identity to pre-phase, and the shared-page parity. The E2E gate is the phase's story suite (``tests/e2e/test_read_range_tool_lines.py``). """ from __future__ import annotations import json import shutil import subprocess from pathlib import Path import pytest FRONTEND = Path(__file__).resolve().parents[2] / "frontend" APP_JS = FRONTEND / "assets" / "app.js" SHARED_JS = FRONTEND / "assets" / "shared.js" HAVE_NODE = shutil.which("node") is not None def _js() -> str: return APP_JS.read_text(encoding="utf-8") def _shared_js() -> str: return SHARED_JS.read_text(encoding="utf-8") def _extract(js: str, name: str) -> str: """One top-level function by name — from its ``function NAME`` to its column-0 closing ``}`` (inner braces are indented, so the first ``\\n}\\n`` is the function's own end — the ``test_frontend_tool_states`` extraction convention).""" fn = js.find(f"function {name}") assert fn != -1, f"{name} not found" end = js.find("\n}\n", fn) assert end != -1, f"could not find the end of {name}" return js[fn : end + 2] def _run_node(script: str) -> str: proc = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=60) assert proc.returncode == 0, f"node failed: {proc.stderr}" return proc.stdout # -------------------------------------------------------------------------- # app.js — readRangeSuffix: the single source of truth for the wording # -------------------------------------------------------------------------- def test_read_range_suffix_exists_with_the_pinned_literals() -> None: """readRangeSuffix (app.js): the well-formedness guard mirrors the server's ``_whole_line_number`` (``Number.isInteger(x) && x >= 1`` — bools are not integers in JS), and the three suffix shapes carry the EXACT pinned copy — both present → `` (lines {offset}–{offset + limit - 1})`` (EN-DASH, ASSUMPTION A1: the REQUESTED extent), offset only → `` (from line {offset})``, limit only → `` (first {limit} lines)``, otherwise `` ""``.""" body = _extract(_js(), "readRangeSuffix") assert "Number.isInteger(offset) && offset >= 1" in body, ( "the offset guard mirrors the server's _whole_line_number" ) assert "Number.isInteger(limit) && limit >= 1" in body, ( "the limit guard mirrors the server's _whole_line_number" ) assert "` (lines ${offset}–${offset + limit - 1})`" in body, ( "the both-present shape — the REQUESTED extent, en-dash per the" " pinned copy (ASSUMPTION A1)" ) assert "` (from line ${offset})`" in body, "the offset-only shape" assert "` (first ${limit} lines)`" in body, "the limit-only shape" assert 'return ""' in body, "neither field well-formed → no suffix" def test_read_range_suffix_shape_order() -> None: """The both-present shape wins (it is checked first), then offset-only, then limit-only, then the empty fallback — so a well-formed pair always renders the range, never a one-sided shape.""" body = _extract(_js(), "readRangeSuffix") both = body.find("if (offOk && limOk)") off_only = body.find("if (offOk)") lim_only = body.find("if (limOk)") empty = body.find('return ""') assert -1 < both < off_only < lim_only < empty, ( "both → offset-only → limit-only → empty, in order" ) # -------------------------------------------------------------------------- # app.js — appendToolLine: the suffix span on the Reading line # -------------------------------------------------------------------------- def test_append_tool_line_signature_takes_optional_range() -> None: """appendToolLine takes the CONTAINER as its first param (phase 158, D1 — the caller passes the run's disclosure; the single-container derivation is gone) + the additive ``offset``/``limit`` params (default ``null`` — a pre-phase 3-arg call renders byte-identical), and the read arm renders the suffix through the SAME helper. Phase 146 (task 02) extends the signature one more additive param (``scope = null`` — the scoped grep's document) — the 5-arg pre-phase-146 call shape renders byte-identically.""" body = _extract(_js(), "appendToolLine") assert ( "function appendToolLine(container, name, argument, offset = null, " "limit = null, scope = null)" in _js() ), ("the signature carries the container + the additive " "offset/limit/scope (default null)") assert "readRangeSuffix(offset, limit)" in body, ( "the read arm renders the suffix through the SAME helper" ) assert 'range.className = "read-range"' in body, ( "the suffix rides a .read-range span" ) assert "range.textContent = rangeSuffix" in body, ( "the range is data — textContent, never innerHTML" ) assert "if (rangeSuffix)" in body, ( "the span is appended ONLY when the suffix is non-empty — a" " plain read's line stays byte-identical" ) def test_append_tool_line_suffix_is_a_sibling_after_the_code() -> None: """The .read-range span is a SIBLING appended AFTER the ```` child (the phase-95 truncation-note precedent — append a span, never rewrite the pinned template text), so the line's pinned ``📄 Reading `` + ```` bytes are untouched.""" body = _extract(_js(), "appendToolLine") code_append = body.find("line.appendChild(code)") range_append = body.find("line.appendChild(range)") assert -1 < code_append < range_append, ( "the range span lands AFTER the child" ) # The read arm's pinned template text is untouched (phase-87/117). assert 'line.textContent = "📄 Reading "' in body assert "innerHTML" not in body, ( "no HTML injection surface on tool lines — textContent only" ) # -------------------------------------------------------------------------- # app.js — the live `tool` branch: ev.offset/ev.limit + the toolAcc stamp # -------------------------------------------------------------------------- def test_live_tool_branch_reads_the_range_with_the_guard() -> None: """The live ``tool`` branch reads ``ev.offset`` / ``ev.limit`` with the SAME well-formedness guard (``Number.isInteger(x) && x >= 1`` → ``null`` when absent / malformed), so a non-range frame keeps the exact pre-phase ``{name, argument}`` record shape (the additive ``bor.chat.v1`` JSONB — old records restore byte-identically, no migration).""" js = _js() tool_idx = js.find('ev.type === "tool"') delta_idx = js.find('ev.type === "delta"') branch = js[tool_idx:delta_idx] assert "Number.isInteger(ev.offset) && ev.offset >= 1 ? ev.offset : null" in branch, ( "ev.offset reads through the house guard → null when absent" ) assert "Number.isInteger(ev.limit) && ev.limit >= 1 ? ev.limit : null" in branch, ( "ev.limit reads through the house guard → null when absent" ) # The record carries the fields ONLY when non-null. assert "const toolRecord = { name, argument }" in branch, ( "the base record stays {name, argument}-generic" ) assert "if (frameOffset !== null) toolRecord.offset = frameOffset" in branch, ( "offset joins the record ONLY when non-null" ) assert "if (frameLimit !== null) toolRecord.limit = frameLimit" in branch, ( "limit joins the record ONLY when non-null" ) assert "toolAcc.push(toolRecord)" in branch, "every tool frame is recorded" # Phase 146 (task 02): the scope joins the SAME two pass-throughs. assert ( "const line = appendToolLine(" in branch and "armToolLineClock(wrap, line)" in branch ) def test_live_read_status_carries_the_range_suffix() -> None: """ASSUMPTION A2: the read status label carries the SAME requested-range suffix as the line (``readRangeSuffix`` — the single source of truth); a non-range read renders byte-identical (the helper returns ``""``). The ls labels are untouched; the grep label gains the phase-146 additive scope (`` in `` ONLY when the frame carried one — a bare grep renders the exact pre-phase label). """ js = _js() tool_idx = js.find('ev.type === "tool"') delta_idx = js.find('ev.type === "delta"') branch = js[tool_idx:delta_idx] assert ( "`${brand()} is reading ${argument}${readRangeSuffix(frameOffset, frameLimit)}`" in branch ), "the read status carries the suffix (A2)" # The other labels: the grep scope extension (phase 146) + the # ls labels untouched. assert ( "`${brand()} is searching for ${argument}" "${framePath ? ` in ${framePath}` : \"\"}`" in branch ) assert "`${brand()} is listing documents in ${argument}`" in branch assert "`${brand()} is listing documents`" in branch # -------------------------------------------------------------------------- # app.js — the restore path: the stored range re-renders the suffix # -------------------------------------------------------------------------- def test_restore_path_passes_the_stored_range_through() -> None: """The phase-14 restore path (renderStoredMessage): a stored tool record with ``offset``/``limit`` (the live ``toolAcc`` stamp — additive on ``bor.chat.v1``) re-renders the SAME suffix through the SAME helper; a record saved before the phase (no fields) renders byte-identically (the guard → null → no span).""" js = _js() fn = js.find("function renderStoredMessage") assert fn != -1 end = js.find("function restoreConversation") body = js[fn:end] assert "Number.isInteger(t.offset) && t.offset >= 1 ? t.offset : null" in body, ( "the stored offset reads through the house guard" ) assert "Number.isInteger(t.limit) && t.limit >= 1 ? t.limit : null" in body, ( "the stored limit reads through the house guard" ) assert "appendToolLine(restoreDisc, t.name, arg, tOffset, tLimit, tPath)" in body, ( "the stored range + the phase-146 stored scope ride the SAME helper " "(the caller passes the run's disclosure — phase 158)" ) # -------------------------------------------------------------------------- # shared.js — the parity copy: identical literals + the addToolLines span # -------------------------------------------------------------------------- def test_shared_page_suffix_is_byte_identical_to_app_js() -> None: """shared.js's ``readRangeSuffix`` is the parity copy — IDENTICAL literals and IDENTICAL guard (the phase-50 restore contract: the shared page re-renders saved records pixel-identically to the chat page). The three suffix shapes + the guard must match app.js EXACTLY.""" shared = _extract(_shared_js(), "readRangeSuffix") app = _extract(_js(), "readRangeSuffix") for literal in ( "Number.isInteger(offset) && offset >= 1", "Number.isInteger(limit) && limit >= 1", "` (lines ${offset}–${offset + limit - 1})`", "` (from line ${offset})`", "` (first ${limit} lines)`", 'return ""', ): assert literal in shared, f"shared.js lost the parity literal {literal!r}" assert literal in app, f"app.js lost the parity literal {literal!r}" def test_shared_page_add_tool_lines_renders_the_range_span() -> None: """shared.js's ``addToolLines``: the SAME .read-range span as the chat page's ``appendToolLine`` — a sibling after the ```` child, textContent only, appended ONLY when the suffix is non-empty (a record without the fields renders exactly as today).""" body = _extract(_shared_js(), "addToolLines") assert "readRangeSuffix(t.offset, t.limit)" in body, ( "the shared page renders the suffix from the stored record" ) assert 'range.className = "read-range"' in body assert "range.textContent = rangeSuffix" in body assert "if (rangeSuffix)" in body code_append = body.find("line.appendChild(code)") range_append = body.find("line.appendChild(range)") assert -1 < code_append < range_append, ( "the shared range span lands AFTER the child (parity)" ) assert "innerHTML" not in body, ( "no HTML injection surface on shared tool lines — textContent only" ) def test_no_cdn_added() -> None: """AGENTS.md rule 6: the range suffix adds no external script/link.""" index = (FRONTEND / "index.html").read_text(encoding="utf-8") assert 'src="http' not in index and 'href="http' not in index # -------------------------------------------------------------------------- # behavior (node): the three suffix shapes, the plain line's # byte-identity, and the shared-page parity # -------------------------------------------------------------------------- #: A minimal DOM stub — just enough surface for appendToolLine / #: addToolLines (createElement / querySelector / appendChild / #: insertBefore / textContent / className / setAttribute). Plain strings #: only; every node is snapshotted as a JSON row (the #: test-document-viewer / test-chat-image-questions pattern). _DOM_STUB = """ class El { constructor(tag) { this.tag = tag; this.children = []; this.attrs = {}; this.className = ""; this._text = ""; this.open = false; } setAttribute(k, v) { this.attrs[k] = String(v); } getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; } appendChild(c) { this.children.push(c); return c; } insertBefore(c, ref) { if (!ref) { this.children.push(c); return c; } const i = this.children.indexOf(ref); if (i === -1) this.children.push(c); else this.children.splice(i, 0, c); return c; } _match(el, sel) { if (sel.charAt(0) === ".") return (el.className || "").split(/\\s+/).indexOf(sel.slice(1)) !== -1; if (sel.charAt(0) === "#") return el.id === sel.slice(1); return el.tag === sel; } querySelector(sel) { for (const c of this.children) { if (this._match(c, sel)) return c; const r = c.querySelector(sel); if (r) return r; } return null; } querySelectorAll(sel) { const out = []; for (const c of this.children) { if (this._match(c, sel)) out.push(c); out.push(...c.querySelectorAll(sel)); } return out; } get textContent() { let t = this._text; for (const c of this.children) t += c.textContent; return t; } set textContent(v) { this._text = String(v); this.children = []; } } const document = { createElement: (t) => new El(t) }; function makeWrap() { const wrap = document.createElement("div"); const body = document.createElement("div"); body.className = "msg-body"; const bubble = document.createElement("div"); bubble.className = "bubble"; body.appendChild(bubble); wrap.appendChild(body); return wrap; } function makeDisclosure() { const container = document.createElement("details"); container.className = "tool-calls-disclosure"; const summary = document.createElement("summary"); summary.className = "tool-calls-summary"; container.appendChild(summary); const listEl = document.createElement("div"); listEl.className = "tool-calls"; container.appendChild(listEl); const wrap = makeWrap(); wrap.querySelector(".msg-body").appendChild(container); return { wrap, container }; } function describe(el) { return { text: el.textContent, children: el.children.map( (c) => ({ tag: c.tag, cls: c.className, text: c.textContent }) ), }; } """ @pytest.mark.skipif(not HAVE_NODE, reason="node not available") def test_suffix_shapes_under_node() -> None: """The REAL app.js ``readRangeSuffix`` under node — the three suffix shapes + the malformed fallbacks (the guard: bools / strings / 0 are not whole line numbers ≥ 1; a one-sided well-formed pair renders its one-sided shape; a well-formed pair renders the range).""" fn = _extract(_js(), "readRangeSuffix") cases = [ [42, 30, " (lines 42–71)"], [42, None, " (from line 42)"], [None, 5, " (first 5 lines)"], [None, None, ""], ["x", 5, " (first 5 lines)"], # malformed offset, valid limit [42, "y", " (from line 42)"], # valid offset, malformed limit [0, 5, " (first 5 lines)"], # offset 0 is not ≥ 1 [42, 0, " (from line 42)"], # limit 0 is not ≥ 1 [True, 5, " (first 5 lines)"], # a bool is not an integer [1, 1, " (lines 1–1)"], # the smallest well-formed range ] driver = ( fn + "\nconst cases = " + json.dumps([[c[0], c[1]] for c in cases]) + ";\nconsole.log(JSON.stringify(cases.map((c) => readRangeSuffix(c[0], c[1]))));" ) out = json.loads(_run_node(driver)) assert out == [c[2] for c in cases], out @pytest.mark.skipif(not HAVE_NODE, reason="node not available") def test_plain_line_byte_identical_and_range_line_carries_the_suffix() -> None: """The REAL app.js ``appendToolLine`` under node (against the DOM stub): a PLAIN read (no offset/limit) renders the pre-phase line byte-identical — ``📄 Reading path``, a single ``code`` child, NO ``.read-range`` span — while a RANGE read appends the ``.read-range`` sibling after the ``code`` with the exact suffix.""" suffix = _extract(_js(), "readRangeSuffix") append = _extract(_js(), "appendToolLine") driver = ( _DOM_STUB + "\n" + suffix + "\n" + append + '\nconst ARG = "samedoc/twice.md";\n' + "const a1 = makeDisclosure();\n" + 'appendToolLine(a1.container, "read", ARG);\n' + 'const plain = describe(a1.wrap.querySelector(".tool-call"));\n' + "const a2 = makeDisclosure();\n" + 'appendToolLine(a2.container, "read", ARG, 10, 20);\n' + 'const range = describe(a2.wrap.querySelector(".tool-call"));\n' + "console.log(JSON.stringify({ plain, range }));" ) out = json.loads(_run_node(driver)) plain, range = out["plain"], out["range"] # The plain line: byte-identical to pre-phase — ONE child, no # .read-range span, the exact pinned text. assert plain["text"] == "📄 Reading samedoc/twice.md", plain assert len(plain["children"]) == 1, plain assert plain["children"][0] == {"tag": "code", "cls": "", "text": "samedoc/twice.md"}, plain assert all(c["cls"] != "read-range" for c in plain["children"]), ( "a plain read must carry NO .read-range span" ) # The range line: the child PLUS the .read-range sibling with # the exact suffix (10 + 20 - 1 = 29, en-dash). assert range["text"] == "📄 Reading samedoc/twice.md (lines 10–29)", range assert range["children"][0] == {"tag": "code", "cls": "", "text": "samedoc/twice.md"}, range assert range["children"][1] == { "tag": "span", "cls": "read-range", "text": " (lines 10–29)", }, range @pytest.mark.skipif(not HAVE_NODE, reason="node not available") def test_shared_page_line_parity_under_node() -> None: """The REAL shared.js ``addToolLines`` under node renders the SAME line as app.js's ``appendToolLine`` for the same record (the parity contract — the shared page re-renders saved records pixel-identically to the chat page): identical text and identical children (the ``code`` + the ``.read-range`` span with the same suffix). A record WITHOUT the fields renders the plain line on both pages.""" shared_suffix = _extract(_shared_js(), "readRangeSuffix") add_lines = _extract(_shared_js(), "addToolLines") app_suffix = _extract(_js(), "readRangeSuffix") append = _extract(_js(), "appendToolLine") driver = ( _DOM_STUB + "\n" + app_suffix + "\n" + append + '\nconst ARG = "samedoc/twice.md";\n' # app.js: a range read + a plain read + "const wa = makeDisclosure();\n" + 'appendToolLine(wa.container, "read", ARG, 10, 20);\n' + 'const appRange = describe(wa.wrap.querySelector(".tool-call"));\n' + "const wp = makeDisclosure();\n" + 'appendToolLine(wp.container, "read", ARG);\n' + 'const appPlain = describe(wp.wrap.querySelector(".tool-call"));\n' # shared.js: the SAME record (range + no-fields) through addToolLines + shared_suffix.replace("function readRangeSuffix", "function sharedSuffix") + "\n" + add_lines.replace( "readRangeSuffix(t.offset, t.limit)", "sharedSuffix(t.offset, t.limit)" ) + "const ws = makeWrap();\n" + 'addToolLines(ws, [{ name: "read", argument: ARG, offset: 10, limit: 20 }]);\n' + 'const sharedRange = describe(ws.querySelector(".tool-call"));\n' + "const wn = makeWrap();\n" + 'addToolLines(wn, [{ name: "read", argument: ARG }]);\n' + 'const sharedPlain = describe(wn.querySelector(".tool-call"));\n' + "console.log(JSON.stringify({ appRange, appPlain, sharedRange, sharedPlain }));" ) out = json.loads(_run_node(driver)) # The range line is pixel-identical across the two pages. assert out["sharedRange"] == out["appRange"], ( f"parity broken (range): {out['sharedRange']} vs {out['appRange']}" ) # A record without the fields renders the plain line on both pages. assert out["sharedPlain"] == out["appPlain"], ( f"parity broken (plain): {out['sharedPlain']} vs {out['appPlain']}" ) # And the plain line carries no .read-range span (byte-identical). assert all(c["cls"] != "read-range" for c in out["sharedPlain"]["children"])