Phase 146 final verification pass complete — all green, tree untouched by me.
**Verified (final pass — both tasks already in `complete/`):**
- Scoped grep streams `{"type":"tool","name":"grep","argument":<pattern>,"path":<source/path>}`; bare grep/read/ls frames dump byte-identical (unit matrix in `test_sse_events.py` + builder pins in `test_chat_gate.py` L1364–1448)
- Tool line `🔎 Searching for <pattern> in <source/path>` (two `<code>` spans, `.grep-scope` sibling, no new emoji); bare line byte-identical (unit Node test + E2E)
- Status label "… is searching for X in Y" scoped-only; `path` persisted additively on the saved tool record and re-rendered on `/?chat=<id>` restore; pre-phase records restore byte-identically (E2E tests 2 & 3)
**Results (exact commands):**
- `uv run pytest --cov=app --cov-report=term-missing` → **3419 passed**, `app/` coverage **99%** (>90% ✓)
- `uv run pytest tests/e2e/test_search_tool_scope.py -v --no-cov` → **3 passed** in isolation ✓
- `uv run ruff check . && uv run pyright` → clean (0 errors) ✓
**Completion criteria:** all 6 ✓ (wire shape, UI line, status label, save/restore, gates, no completed-phase behavior change — full suite green includes all prior-phase pins).
**Documentation:** README.md (incl. its phase-146 section) and AGENTS.md reviewed in full against the codebase — verified consistent, no changes needed (`.env.example` ⊆ README config tables; no stale vars; phase-100 72rem rule in AGENTS.md §5 matches shipped CSS; no-CDN/smoke-DB/gpgsign claims all confirmed).
**Notable:** none — no defects found, no live-infrastructure touched, nothing committed per harness protocol.
**Next pending phase:** `147_sync_changed_only`.
511 lines
22 KiB
Python
511 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 gains 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(wrap, name, argument, offset = null, limit = null, "
|
||
"scope = null)" in _js()
|
||
), "the signature carries 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 (
|
||
"appendToolLine(wrap, name, argument, frameOffset, frameLimit, framePath)"
|
||
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(wrap, t.name, arg, tOffset, tLimit, tPath)" in body, (
|
||
"the stored range + the phase-146 stored scope ride the SAME helper"
|
||
)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 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 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 w1 = makeWrap();\n"
|
||
+ 'appendToolLine(w1, "read", ARG);\n'
|
||
+ 'const plain = describe(w1.querySelector(".tool-call"));\n'
|
||
+ "const w2 = makeWrap();\n"
|
||
+ 'appendToolLine(w2, "read", ARG, 10, 20);\n'
|
||
+ 'const range = describe(w2.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 = makeWrap();\n"
|
||
+ 'appendToolLine(wa, "read", ARG, 10, 20);\n'
|
||
+ 'const appRange = describe(wa.querySelector(".tool-call"));\n'
|
||
+ "const wp = makeWrap();\n"
|
||
+ 'appendToolLine(wp, "read", ARG);\n'
|
||
+ 'const appPlain = describe(wp.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"])
|