All verification complete. Every gate is green and the docs check out. Final report:
## Phase 158 final verification pass — COMPLETE
**Verified (all tasks pre-completed; this pass re-ran the full gate):**
- Unit + integration: `uv run pytest --cov=app --cov-report=term-missing` → **3618 passed, 99% coverage on `app/`** (>90% gate)
- Phase E2E in isolation: `uv run pytest tests/e2e/test_thinking_timeline.py -v --no-cov` → **6/6 passed** (order pin, fold-at-rest, live-open, restore+`segments` array, shared page, no-thinking single disclosure)
- Regression E2E, each in isolation: `test_thinking_display` 5✓, `test_tool_call_compact` 4✓, `test_turn_progress_loader` 3✓ (per-run pin updated), `test_big_read_progress` 4✓, `test_agent_document_tools` 4✓, `test_share_chat` 4✓, `test_chat_persistence` 4✓, `test_smoke` 3✓
- Lint/types: `uv run ruff check . && uv run pyright` → **clean (0 errors)**
**Completion criteria:** all 9 met — timeline E2E pins ✓, restore/shared `segments` ✓, no-thinking single-disclosure + the three UNCHANGED regression suites green ✓, pre-phase byte-identical round-trip (integration pin: key absent, never null; >50 / >32 000 / empty text / unknown kind all 422) ✓, full suite + coverage + E2E + lint ✓, `git diff --stat` footprint limited to `app/schemas.py`, `frontend/assets/{app,shared}.js`, `tests/`, README, phase files (a pre-existing owner `TODO.md` edit is out of scope, not staged by the phase commit) ✓, D15→per-run supersession recorded in the updated pin ✓. (Commit + phase move are the harness's job; task dirs already in `complete/`.)
**Documentation:** README.md — Thinking section correctly describes the phase-158 timeline; config reference matches `.env.example` exactly; page table matches registered routes; no stale single-block wording. AGENTS.md — rules and quick-reference commands all accurate. **Verified consistent — no changes needed.**
**Decisions/deviations:** none. `test_tool_call_compact.py` carries a one-line docstring-only update (referenced the removed `closeToolCalls`); its test logic is byte-identical to the phase-117 contract and green.
**Next pending phase:** none — `todo/` contains only 158.
529 lines
22 KiB
Python
529 lines
22 KiB
Python
"""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 ``<code>``
|
||
child (the phase-95 truncation-note precedent — append a span,
|
||
never rewrite the pinned template text), so the line's pinned
|
||
``📄 Reading `` + ``<code>`` 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 <code> 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 <path>`` 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 ``<code>``
|
||
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 <code> 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 <code>path</code>``, 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 <code> 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 <code> 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"])
|