Phase 192 — final verification pass complete. - **Verified (all 4 tasks already complete):** additive `verbatim_block` SSE frame (`VerbatimBlockPiece` → `ChatVerbatimBlockEvent` → chat dispatch, after the round's `tool`/`tool_result`, before the next round); `hydrateVerbatimNow` matched-only mid-turn pass + app.js branch; `dropPlacedTwin` per-locator done/restore dedupe; the dedicated E2E suite. - **Root cause of the rejected attempt:** my own concurrent background `pytest` run deadlocked the harness validation run on the shared Postgres (`psycopg.errors.DeadlockDetected`). No code defect — re-ran every gate **exclusively**. - `uv run pytest -q --cov=app --cov-report=term-missing` → **exit 0**, `TOTAL 7181 16 99%` (>90%) - `uv run pytest tests/e2e/test_verbatim_inline_midturn.py --no-cov` → **6 passed** (isolated) - `uv run pytest tests/e2e/test_verbatim_show_text.py tests/e2e/test_chat_history.py --no-cov` → **18 passed** (`test_chat_history.py` byte-unedited; the phase-182 suite's "no eighth type" pin was tightened, not loosened, to seven + `verbatim_block` with frame-order asserts) - `uv run ruff check .` → All checks passed (exit 0) · `uv run pyright` → **0 errors** - **Completion criteria:** single frame per success + zero per refusal ✅ · `BOR_ANSWER_TEXT=0` wire byte-identical (integration spelled baseline + E2E App B) ✅ · fence hydrates in place / unfenced lands at the call moment, before `done` ✅ · append+fence → 1 panel, two ranges → 2 panels ✅ · restore 1 panel/locator, `done.answer_text` + record key + `verbatim_blocks=N` unchanged ✅ · suite green, coverage 99%, lint/types clean ✅ · no-show_text turn byte-identical ✅ · commit → left to the harness (no git index/history commands run). - **Docs:** README reviewed line-by-line (2399 lines) + AGENTS.md in full; mechanically verified all 86 `BOR_*` settings, every `.env.example` var, table defaults, internal anchors (0 broken), ports 8123/8901, tree depth 1–8, pagination 25/1–100, 17 palette colors, `0029`/`0030` migrations, and every cited test file. Fixed 1 inconsistency: the verbatim "Parity" bullet claimed the live turn/reload/shared page share "the same three globals" — now names the settle-path trio and states that `hydrateVerbatimNow` is the ONE chat-only addition (a shared page has no stream). - **Notable:** `run_agent`'s four repeated piece unions were collapsed into the ONE exported `AgentPiece` alias, so a future piece type is spelled in one place; `test_frontend_edit_message.py`'s `runTurn` byte-pin was regenerated, not weakened. - **Next pending phase:** `193_sync_remaining_count`.
1714 lines
78 KiB
Python
1714 lines
78 KiB
Python
"""Unit: the verbatim panel client and the answer's Copy buttons (phase 182, task 07).
|
||
|
||
The browser half of the story lives in the phase's Playwright suite (task
|
||
08); this module pins the SOURCE contract of ``frontend/assets/verbatim.js``
|
||
and the three client surfaces that consume it, in the established
|
||
``test_frontend_*`` convention — plus a node-driven half (skipped when node
|
||
is unavailable, the phase-44/138/180/182 idiom) that runs the panel's PURE
|
||
logic (caption, gutter, locator match, block guard) against the real file.
|
||
|
||
Why these particular pins (the phase's drift risks, in order):
|
||
|
||
* **ONE locator grammar.** ``markdown.js::borLocator`` is the client's single
|
||
normalizer (task 06); hydration may only compare the server's canonical
|
||
``locator`` against the already-normalized ``data-bor-locator`` with a
|
||
plain string ``===``. A second grammar in a second file is the phase's main
|
||
way to break silently — so this module pins that no normalizer exists in
|
||
``verbatim.js`` (no ``borLocator``, no range regex, no ``parseInt``).
|
||
* **The model's bytes never reach the screen or the clipboard.** The panel's
|
||
text node is ``pre.textContent = block.text`` (server bytes, verbatim) and
|
||
the Copy button copies the same string — never the DOM, never the gutter,
|
||
never the model's re-typed fence body (which task 06 dropped upstream).
|
||
* **The claim is carried by words, not colours** (LOCKED A5): the badge text
|
||
is ``VERBATIM``, the honest state is the pinned sentence ``I could not
|
||
verify this against the knowledge base``, and an ordinary code block gets a
|
||
Copy button with NO badge.
|
||
* **Parity.** The live turn, the reload restore and the anonymous shared page
|
||
call the SAME globals, in the SAME order (hydrate → append the unlocated →
|
||
settle the code blocks, all inside the ``answer_image`` band and BEFORE the
|
||
source clusters), so one answer cannot render three different ways.
|
||
* **The mid-turn pass settles ONLY what it matches** (phase 192, task 02):
|
||
the additive ``verbatim_block`` frame carries ONE block, and
|
||
``hydrateVerbatimNow`` may replace only the pending placeholders that block
|
||
matches — every other placeholder STAYS ``.verbatim-pending`` for the
|
||
``done`` / failure path, because burning a fence for a block that has not
|
||
arrived yet is how a resolved panel turns into "I could not verify this".
|
||
* **One canonical locator, one panel** (phase 192, A3): a mid-turn block that
|
||
no fence named yet is appended at the answer position, and when the answer
|
||
fences it later the hydration drops that appended twin (``dropPlacedTwin``,
|
||
bounded by the ``.msg`` wrap) — the scan is PER LOCATOR, so two honest
|
||
ranges of one file stay two panels.
|
||
* **No panel is ever left lying.** Every terminal path — the ``error`` frame,
|
||
the 300 s guard, the Stop action, the failed-turn funnel — settles the
|
||
leftover placeholders; nothing reads "Retrieving the exact text…" after the
|
||
turn is over.
|
||
* **CSP + the house XSS rule.** Everything is ``createElement`` +
|
||
``textContent``, every handler is ``addEventListener``, and there is no
|
||
``innerHTML``/``onclick=``/``oncopy=`` anywhere in the new client.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import pytest
|
||
|
||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||
ASSETS = FRONTEND / "assets"
|
||
VERBATIM_JS = ASSETS / "verbatim.js"
|
||
APP_JS = ASSETS / "app.js"
|
||
SHARED_JS = ASSETS / "shared.js"
|
||
DOCUMENT_JS = ASSETS / "document.js"
|
||
MARKDOWN_JS = ASSETS / "markdown.js"
|
||
STYLES_CSS = ASSETS / "styles.css"
|
||
INDEX_HTML = FRONTEND / "index.html"
|
||
SHARED_HTML = FRONTEND / "shared.html"
|
||
CONTAINERFILE = FRONTEND.parent / "Containerfile"
|
||
|
||
HAVE_NODE = shutil.which("node") is not None
|
||
|
||
#: The labelled phase-182 CSS region (task 06's markers — task 07 extends
|
||
#: INSIDE it, so the palette-only and contiguity pins keep holding).
|
||
BLOCK_BEGIN = "/* ---------- Verbatim panels (phase 182, task 06) — BEGIN ----------"
|
||
BLOCK_END = "/* ---------- Verbatim panels (phase 182, task 06) — END ---------- */"
|
||
|
||
#: The honest state (pinned copy — task 07's Testing section).
|
||
UNRESOLVED_COPY = "I could not verify this against the knowledge base"
|
||
|
||
#: The badge's WORD (LOCKED A5) and the tooltip that explains it.
|
||
BADGE_TEXT = "VERBATIM"
|
||
BADGE_TITLE = "These bytes are copied from the knowledge base, not written by the model."
|
||
|
||
#: The Copy control's accessible name template — the FULL path, never a
|
||
#: clipped stand-in (the phase-156 clipped-label rule).
|
||
COPY_ARIA = "Copy ${shown} lines from ${identity}"
|
||
|
||
#: The globals task 07 exposes for the two page modules.
|
||
PUBLIC_GLOBALS = (
|
||
"buildVerbatimPanel",
|
||
"hydrateVerbatim",
|
||
"hydrateVerbatimNow", # phase 192 (task 02): the MID-TURN pass
|
||
"appendUnlocatedVerbatim",
|
||
"settleVerbatimPlaceholders",
|
||
"enhanceAnswerCodeBlocks",
|
||
"copyTextWithFallback",
|
||
)
|
||
|
||
|
||
def _text(path: Path) -> str:
|
||
assert path.is_file(), f"missing file: {path}"
|
||
return path.read_text(encoding="utf-8")
|
||
|
||
|
||
def _strip_comments(text: str) -> str:
|
||
"""Comments out — the CSP and literal pins below are about CODE. The
|
||
file's own header documents the very patterns it forbids (``onclick=``,
|
||
``javascript:``), and a prose-blind grep would flag the documentation of
|
||
the rule as the rule."""
|
||
text = re.sub(r"/\*[\s\S]*?\*/", "", text)
|
||
return re.sub(r"(?m)^\s*//.*$", "", text)
|
||
|
||
|
||
def _fn(js: str, name: str) -> str:
|
||
"""The source of a top-level (optionally ``async``) ``function <name>``."""
|
||
start = js.find(f"function {name}(")
|
||
assert start != -1, f"{name}() must exist"
|
||
return js[start : js.find("\n}\n", start) + 4]
|
||
|
||
|
||
def _body(js: str, name: str) -> str:
|
||
return _fn(js, name)
|
||
|
||
|
||
def _doc(js: str, name: str) -> str:
|
||
"""The ``/* … */`` doc comment that sits directly above
|
||
``function <name>`` — the house docstring-pinning convention for the
|
||
clients: the prose IS the contract record, so a refactor that "simplifies"
|
||
the rule away has to delete the sentence in the same change."""
|
||
start = js.find(f"\nfunction {name}(")
|
||
assert start != -1, f"function {name}() must exist"
|
||
open_ = js.rfind("/*", 0, start)
|
||
close = js.find("*/", open_)
|
||
assert open_ != -1 and close != -1 and close < start, (
|
||
f"{name}() must carry the doc comment above it"
|
||
)
|
||
return js[open_ : close]
|
||
|
||
|
||
def _flow(text: str) -> str:
|
||
"""Comment prose as one line — the pins quote the SENTENCE, not its
|
||
wrapping (the `` * `` line leaders come out with the wrap)."""
|
||
lines = [re.sub(r"^\s*\*+\s?", " ", line) for line in text.splitlines()]
|
||
return " ".join(" ".join(lines).split())
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# the client ships, and ships in the right shape
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_verbatim_js_is_a_classic_script_after_markdown_on_both_pages() -> None:
|
||
"""Both pages load it as a PLAIN ``<script>`` immediately AFTER
|
||
``markdown.js`` (whose placeholders it hydrates) and BEFORE their page
|
||
module (which calls it as a global). Absolute paths on ``shared.html``
|
||
(the nested ``/shared/<token>`` route breaks a relative ref), relative on
|
||
``index.html`` exactly like its neighbours. No ``type="module"`` — a
|
||
module would defer past the pages' boot and expose no globals."""
|
||
index = _text(INDEX_HTML)
|
||
shared = _text(SHARED_HTML)
|
||
assert '<script src="assets/verbatim.js"></script>' in index
|
||
assert '<script src="/assets/verbatim.js"></script>' in shared
|
||
assert index.index('src="assets/markdown.js"') < index.index('src="assets/verbatim.js"'), (
|
||
"verbatim.js hydrates markdown.js's placeholders — it loads after it"
|
||
)
|
||
assert index.index('src="assets/verbatim.js"') < index.index(
|
||
'type="module" src="/assets/app.js"'
|
||
), "the globals must exist before app.js evaluates"
|
||
assert shared.index('src="/assets/markdown.js"') < shared.index('src="/assets/verbatim.js"')
|
||
assert shared.index('src="/assets/verbatim.js"') < shared.index(
|
||
'type="module" src="/assets/shared.js"'
|
||
)
|
||
tag = next(line for line in index.splitlines() if "verbatim.js" in line and "<script" in line)
|
||
assert 'type="module"' not in tag, "a classic script — the globals are the API"
|
||
# The document viewer never loads it (a file's own ```bor fence is text).
|
||
document_html = _text(FRONTEND / "document.html")
|
||
assert "verbatim.js" not in document_html
|
||
|
||
|
||
def test_containerfile_produces_it_as_a_minified_classic_script() -> None:
|
||
"""Stage 1 builds it the way it builds ``markdown.js``: ``--minify``
|
||
WITHOUT ``--bundle`` (bundling a classic script would break the global
|
||
exposure), and the source itself stays free of top-level
|
||
``import``/``export``."""
|
||
container = _text(CONTAINERFILE)
|
||
line = next(
|
||
row for row in container.splitlines() if "assets/verbatim.js" in row
|
||
)
|
||
assert "--minify" in line
|
||
assert "--outfile=/out/assets/verbatim.js" in line
|
||
assert "--bundle" not in line, "a classic script is never bundled"
|
||
assert "--format=esm" not in line
|
||
js = _text(VERBATIM_JS)
|
||
assert "\nimport " not in js and "\nexport " not in js
|
||
|
||
|
||
def test_every_public_global_exists() -> None:
|
||
"""The API the two page modules call, as top-level function declarations
|
||
(the ``markdown.js`` exposure pattern — a classic script's top-level
|
||
functions ARE the globals)."""
|
||
js = _text(VERBATIM_JS)
|
||
for name in PUBLIC_GLOBALS:
|
||
assert f"function {name}(" in js, f"verbatim.js must expose {name}"
|
||
|
||
|
||
def test_no_locator_normalizer_is_re_implemented() -> None:
|
||
"""THE phase's drift pin: hydration compares the server's canonical
|
||
``locator`` against the ``data-bor-locator`` markdown.js already
|
||
normalized, with a PLAIN string compare. No grammar here — no
|
||
``borLocator``, no range regex, no en-dash handling, no ``parseInt``, no
|
||
``L``-prefix stripping (a second grammar in a second file is how a
|
||
resolved block stops hydrating)."""
|
||
js = _text(VERBATIM_JS)
|
||
assert "function borLocator" not in js
|
||
assert "parseInt" not in js
|
||
assert "\u2013" not in _fn(js, "matchVerbatimBlock"), "no en-dash parsing here"
|
||
for pattern in ("[Ll]", "L?\\d", "#L", "trimStart", "normalize("):
|
||
assert pattern not in js, f"{pattern} looks like a re-implemented grammar"
|
||
match = _fn(js, "matchVerbatimBlock")
|
||
assert "block.locator === key" in match, "the exact match is a plain string compare"
|
||
hydrate = _fn(js, "hydrateVerbatim")
|
||
assert 'node.getAttribute("data-bor-locator")' in hydrate, (
|
||
"hydration reads the attribute markdown.js wrote — it never re-derives it"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# the panel's contract (LOCKED A2/A5/A7)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_panel_is_a_labelled_group_keyed_by_the_servers_locator() -> None:
|
||
"""``role="group"`` + ``aria-label="Verbatim text from <path>, lines
|
||
N\u2013M"`` + ``data-bor-locator`` = the SERVER's canonical locator (the
|
||
key ``appendUnlocatedVerbatim`` later recognises, which is why a
|
||
re-render never duplicates a panel)."""
|
||
panel = _fn(_text(VERBATIM_JS), "buildVerbatimPanel")
|
||
assert 'root.setAttribute("role", "group")' in panel
|
||
assert 'root.setAttribute("aria-label", verbatimPanelLabel(block))' in panel
|
||
assert "root.dataset.borLocator" in panel
|
||
assert "block.locator" in panel, "the server's locator is the panel's identity"
|
||
label = _fn(_text(VERBATIM_JS), "verbatimPanelLabel")
|
||
assert '"Verbatim text from' in json.dumps(label) or "Verbatim text from" in label
|
||
|
||
|
||
def test_badge_is_a_word_with_the_pinned_explanation() -> None:
|
||
"""LOCKED A5: the claim is the WORD ``VERBATIM`` (never an icon or a
|
||
colour), with the tooltip that says what it means."""
|
||
js = _text(VERBATIM_JS)
|
||
assert f'badge.textContent = "{BADGE_TEXT}"' in js
|
||
assert BADGE_TITLE in js
|
||
assert 'badge.className = "verbatim-badge"' in js
|
||
|
||
|
||
def test_unresolved_state_is_the_pinned_sentence_and_nothing_else() -> None:
|
||
"""The honest state for a locator the server did not resolve: the pinned
|
||
sentence, plus the document link ONLY when the identity parsed into a
|
||
``source/path``. NO badge, NO body, NONE of the model's bytes — and the
|
||
builder creates neither a badge nor a ``<pre>`` node at all, which is
|
||
stronger than hiding one."""
|
||
js = _text(VERBATIM_JS)
|
||
assert f'"{UNRESOLVED_COPY}"' in js
|
||
node = _fn(js, "buildVerbatimUnresolved")
|
||
assert 'root.className = "verbatim verbatim-unresolved"' in node
|
||
assert 'state.className = "verbatim-state"' in node
|
||
assert "verbatim-badge" not in node, "an unresolved panel never badges"
|
||
assert 'createElement("pre")' not in node, "and never has a body"
|
||
assert 'createElement("ol")' not in node, "nor a gutter"
|
||
assert "indexOf(" in node and "slash > 0" in node, (
|
||
"the link appears only when the identity split into a source and a path"
|
||
)
|
||
|
||
|
||
def test_copy_button_names_the_full_path_and_copies_the_servers_bytes() -> None:
|
||
"""The completion criterion at source level: the panel's Copy control is
|
||
built from ``block.text`` (never from the DOM, never with the gutter's
|
||
digits), and its accessible name is ``Copy N lines from <source/path>`` —
|
||
the full path, because the visible label is the CSS clip target
|
||
(phase 156)."""
|
||
js = _text(VERBATIM_JS)
|
||
panel = _fn(js, "buildVerbatimPanel")
|
||
assert COPY_ARIA in panel
|
||
build = panel[panel.index("buildCopyButton(") :]
|
||
assert '"verbatim-copy"' in build
|
||
assert "block.text," in build.split("\n)")[0], (
|
||
"the copied string is `block.text` — the exact bytes the panel shows"
|
||
)
|
||
control = _fn(js, "buildCopyButton")
|
||
assert 'btn.setAttribute("aria-label", ariaLabel)' in control
|
||
assert 'label.textContent = "Copy"' in control
|
||
assert 'label.textContent = "Copied"' in control, "the state is TEXT (WCAG 1.4.1)"
|
||
assert 'btn.classList.add("copied")' in control
|
||
clipboard = _fn(js, "copyTextWithFallback")
|
||
assert "await navigator.clipboard.writeText(text)" in clipboard, (
|
||
"the caller's string goes to the clipboard untouched — no DOM read"
|
||
)
|
||
|
||
|
||
def test_body_is_the_servers_text_with_the_servers_line_numbers() -> None:
|
||
"""The bounded body: the ``<ol>`` gutter (``aria-hidden`` — the range
|
||
rides the panel's label, and the digits are ``user-select: none`` in CSS
|
||
so a selection never drags them into the clipboard) and the ``<pre>``
|
||
whose ``textContent`` is ``block.text``, byte for byte."""
|
||
js = _text(VERBATIM_JS)
|
||
panel = _fn(js, "buildVerbatimPanel")
|
||
assert 'body.className = "verbatim-body"' in panel
|
||
assert 'numbers.className = "verbatim-lines"' in panel
|
||
assert 'numbers.setAttribute("aria-hidden", "true")' in panel
|
||
assert 'pre.className = "verbatim-text"' in panel
|
||
assert "pre.textContent = block.text" in panel
|
||
assert "li.textContent = row.number" in panel
|
||
gutter = _fn(js, "verbatimGutter")
|
||
assert 'split("\\n")' in gutter, "one gutter row per line of the SERVER text"
|
||
|
||
|
||
def test_truncated_block_says_the_rest_is_in_the_document() -> None:
|
||
"""A capped block appends ONE muted line plus the same viewer link — the
|
||
panel never pretends the slice is the whole file."""
|
||
panel = _fn(_text(VERBATIM_JS), "buildVerbatimPanel")
|
||
assert "if (block.truncated)" in panel
|
||
assert 'more.className = "verbatim-more"' in panel
|
||
assert "the rest is in the document" in panel
|
||
assert "buildVerbatimOpenLink(" in panel.split("if (block.truncated)")[1]
|
||
|
||
|
||
def test_open_link_is_a_real_href_with_the_modal_hook() -> None:
|
||
"""The panel links to the document viewer: an always-same-origin
|
||
``/document.html?source=…&path=…`` built with ``encodeURIComponent`` (so a
|
||
path can never turn into ``javascript:``/``data:``), plus the chat page's
|
||
click-to-modal hook through ``opts.onOpen`` — attached with
|
||
``addEventListener``, never an inline attribute."""
|
||
js = _text(VERBATIM_JS)
|
||
link = _fn(js, "buildVerbatimOpenLink")
|
||
assert 'link.className = "verbatim-open"' in link
|
||
assert "link.addEventListener(" in link
|
||
assert "event.preventDefault()" in link
|
||
assert "opts.onOpen(source, path, link)" in link
|
||
href = _fn(js, "verbatimDocumentHref")
|
||
assert "/document.html?source=" in href
|
||
assert "encodeURIComponent(source)" in href and "encodeURIComponent(path)" in href
|
||
code = _strip_comments(js)
|
||
assert "javascript:" not in code and "data:" not in code, (
|
||
"no scheme is ever assembled into a link target"
|
||
)
|
||
|
||
|
||
def test_plain_answer_code_blocks_get_copy_and_never_a_badge() -> None:
|
||
"""``enhanceAnswerCodeBlocks``: every ``<pre>`` in an answer bubble that
|
||
is NOT inside a ``.verbatim`` gets ``.code-block`` + ``.code-block-copy``.
|
||
NO badge and NO caption — verbatim-and-badged OR plainly the model's,
|
||
never a hybrid (LOCKED A5). Idempotent: a ``<pre>`` already inside a
|
||
``.code-block`` is left alone."""
|
||
fn = _fn(_text(VERBATIM_JS), "enhanceAnswerCodeBlocks")
|
||
assert 'box.className = "code-block"' in fn
|
||
assert '"code-block-copy"' in fn
|
||
assert 'pre.closest(".verbatim")' in fn, "a panel owns its own Copy button"
|
||
assert 'parent.classList.contains("code-block")' in fn, "idempotent on re-render"
|
||
assert "verbatim-badge" not in fn
|
||
assert "verbatim-caption" not in fn
|
||
|
||
|
||
def test_hydration_is_idempotent_and_never_throws_on_garbage() -> None:
|
||
"""Only ``.verbatim-pending`` is replaced, so a hydrated panel (or one
|
||
restored from storage) survives a re-run with its ``Copied`` state and
|
||
scroll position; a missing/malformed ``blocks`` settles to the honest
|
||
state instead of throwing."""
|
||
js = _text(VERBATIM_JS)
|
||
hydrate = _fn(js, "hydrateVerbatim")
|
||
assert ".verbatim-pending[data-bor-locator]" in hydrate
|
||
assert (
|
||
"verbatim-unresolved"
|
||
not in hydrate.split("matchVerbatimBlock")[1].split("replaceWith")[1].split("\n")[0]
|
||
), "the unresolved node comes from its own builder"
|
||
assert "replaceWith(" in hydrate
|
||
assert "try {" not in hydrate, "the guards make it total — no exception path"
|
||
assert "verbatimBlocks(blocks)" in hydrate
|
||
guard = _fn(js, "verbatimBlocks")
|
||
assert "Array.isArray(blocks)" in guard, "a missing frame key is not an error"
|
||
|
||
|
||
def test_midturn_hydration_settles_only_the_placeholders_one_block_matches() -> None:
|
||
"""Phase 192 (task 02, A2) — the heart of the mid-turn pass. The additive
|
||
``verbatim_block`` frame carries ONE block, and ``hydrateVerbatimNow``:
|
||
|
||
* walks the SAME pending set as the done pass
|
||
(``.verbatim-pending[data-bor-locator]``, the attribute ``markdown.js``
|
||
normalized — no second grammar);
|
||
* matches through the ONE rule (``matchVerbatimBlock`` with a one-block
|
||
list) so the mid-turn pass and the ``done`` pass can never disagree
|
||
about which fence this block owns;
|
||
* replaces ONLY the matched nodes (``buildVerbatimPanel``) — the unmatched
|
||
stay ``.verbatim-pending``, NEVER the honest unresolved state, which is
|
||
the done/failure path's job (``settleVerbatimPlaceholders``);
|
||
* returns the number settled (``0`` is the caller's signal to append);
|
||
* is total without a ``try`` — a missing root, a root that is not an
|
||
element, or a frame that is not a block all return ``0``."""
|
||
js = _text(VERBATIM_JS)
|
||
fn = _fn(js, "hydrateVerbatimNow")
|
||
assert '.verbatim-pending[data-bor-locator]"' in fn, (
|
||
"the mid-turn walk is the done path's pending set"
|
||
)
|
||
assert 'node.getAttribute("data-bor-locator")' in fn, (
|
||
"the key is the attribute markdown.js wrote — never re-derived"
|
||
)
|
||
assert "matchVerbatimBlock(" in fn, "the ONE match rule, not a mid-turn variant"
|
||
assert "const list = [block]" in fn, "the one-block frame rides the done list shape"
|
||
assert "replaceWith(buildVerbatimPanel(" in fn, "only a MATCH builds a panel"
|
||
assert "buildVerbatimUnresolved" not in fn, (
|
||
"an unmatched fence stays pending mid-turn — settling is the terminal path's job"
|
||
)
|
||
assert "if (!matched) return" in fn, "the unmatched node is left in place"
|
||
assert "settled += 1" in fn and "return settled" in fn, (
|
||
"the count is the caller's append/no-append signal"
|
||
)
|
||
assert 'typeof root.querySelectorAll !== "function"' in fn, "a missing root is 0"
|
||
assert "isVerbatimBlock(block)" in fn, "a malformed frame settles NOTHING"
|
||
assert "try {" not in fn, "the guards make it total — no exception path"
|
||
assert "innerHTML" not in fn
|
||
|
||
|
||
def test_hydration_drops_the_midturn_twin_and_never_an_unresolved_one() -> None:
|
||
"""Phase 192 (A3): a block that arrived mid-turn before its fence was
|
||
appended at the answer position; when its fence hydrates, that twin is
|
||
removed so ONE locator keeps ONE panel and the panel sits where the model
|
||
fenced it. The shape of the scan is the contract:
|
||
|
||
* bounded by the message wrap (the appended panel lives in ``.msg-body``,
|
||
beside the bubble — a bubble-local walk would miss it);
|
||
* ``.verbatim-pending`` and ``.verbatim-unresolved`` are NOT placements
|
||
(the ``appendUnlocatedVerbatim`` exclusion, shared);
|
||
* the key is the ONE ``verbatimPlacedKey`` derivation (the same one the
|
||
append-side scan reads — two derivations is how the dedupe drifts);
|
||
* it compares the canonical LOCATOR (never an identity — two ranges of one
|
||
file are two panels) and never removes the placeholder being hydrated."""
|
||
js = _text(VERBATIM_JS)
|
||
twin = _fn(js, "dropPlacedTwin")
|
||
assert 'host.querySelectorAll(".verbatim[data-bor-locator]")' in twin
|
||
assert 'el.classList.contains("verbatim-pending")' in twin
|
||
assert 'el.classList.contains("verbatim-unresolved")' in twin
|
||
assert 'el.getAttribute("data-bor-locator") !== placedKey' in twin, (
|
||
"PER LOCATOR — an identity scan would collapse two honest ranges"
|
||
)
|
||
assert "verbatimSplitLocator" not in twin and "verbatimIdentity(" not in twin, (
|
||
"no identity fallback in the twin scan"
|
||
)
|
||
assert "if (el === keep) return" in twin, "the hydrating placeholder is never the twin"
|
||
assert "el.remove()" in twin
|
||
hydrate = _fn(js, "hydrateVerbatim")
|
||
assert 'root.closest(".msg")' in hydrate, "the scan is bounded by the message wrap"
|
||
assert "if (block) dropPlacedTwin(host, block, node);" in hydrate, (
|
||
"only a MATCHED placeholder drops a twin"
|
||
)
|
||
# The ONE key derivation, read by both dedupe sides.
|
||
assert js.count("function verbatimPlacedKey(") == 1
|
||
assert "verbatimPlacedKey(block)" in _fn(js, "appendUnlocatedVerbatim")
|
||
|
||
|
||
#: The sentences the phase-192 (A3) prose must keep carrying. They are the
|
||
#: only place a reader of ``verbatim.js`` learns the dedupe's two rules, and
|
||
#: the fastest way to lose a rule is a refactor that drops its documentation
|
||
#: first and its code second.
|
||
TWIN_DOC_SENTENCES = (
|
||
# PER LOCATOR, never per identity — the two-ranges-stay clause.
|
||
"PER LOCATOR, never per identity: two different ranges of one file are"
|
||
" two honest panels",
|
||
# The in-text panel wins over the mid-turn appended twin.
|
||
"The IN-TEXT panel wins",
|
||
)
|
||
|
||
|
||
def test_the_twin_dedupe_docstring_keeps_the_per_locator_clause() -> None:
|
||
"""Phase 192 (task 03, A3): the prose is part of the contract. The
|
||
``dropPlacedTwin`` docstring names the per-LOCATOR rule (and the
|
||
two-honest-ranges clause that follows from it) and why the in-text panel
|
||
wins; ``hydrateVerbatim``'s docstring says the matched placeholder drops
|
||
that twin ("one locator, one panel, and it sits where the model fenced
|
||
it"); and the module header states the same rule for whoever reads the
|
||
file top-down."""
|
||
js = _text(VERBATIM_JS)
|
||
twin = _flow(_doc(js, "dropPlacedTwin"))
|
||
for sentence in TWIN_DOC_SENTENCES:
|
||
assert sentence in twin, f"the docstring lost: {sentence}"
|
||
assert "appendUnlocatedVerbatim" in twin, (
|
||
"the placement exclusion is documented as SHARED with the append side"
|
||
)
|
||
hydrate = _flow(_doc(js, "hydrateVerbatim"))
|
||
assert "dropPlacedTwin" in hydrate
|
||
assert "one locator, one panel, and it sits where the model fenced it" in hydrate
|
||
header = _flow(js[: js.find("*/")])
|
||
assert "ONE CANONICAL LOCATOR, ONE PANEL (phase 192)" in header
|
||
assert "two honest ranges of one file stay two panels" in header
|
||
|
||
|
||
def test_the_twin_scan_bounds_to_the_msg_wrap_or_the_root() -> None:
|
||
"""The scan's host is the message wrap (the appended twin lives in
|
||
``.msg-body``, beside the bubble), and when a root has no ``.msg``
|
||
ancestor the ROOT bounds it — a missing wrap narrows the scan, it never
|
||
switches the dedupe off."""
|
||
hydrate = _fn(_text(VERBATIM_JS), "hydrateVerbatim")
|
||
assert 'root.closest(".msg")' in hydrate
|
||
assert 'root.closest(".msg") : null) || root' in hydrate, (
|
||
"no .msg ancestor → the root bounds the scan"
|
||
)
|
||
assert "document.querySelectorAll" not in hydrate, (
|
||
"never a page-wide scan — one message's twin, one message's panels"
|
||
)
|
||
|
||
|
||
def test_unlocated_blocks_land_in_the_answer_image_band() -> None:
|
||
"""The blocks no placeholder matched (a ``show_text`` call the prose never
|
||
named) append to ``.msg-body`` — the same band as the answer image, below
|
||
the text and above the source clusters, in frame order — and a block
|
||
already placed (a panel carrying its canonical locator) is never
|
||
appended twice."""
|
||
fn = _fn(_text(VERBATIM_JS), "appendUnlocatedVerbatim")
|
||
assert 'wrap.querySelector(".msg-body")' in fn
|
||
assert "host.appendChild(buildVerbatimPanel(block, opts))" in fn
|
||
assert "placed.add(" in fn and "if (placed.has(key)) continue" in fn
|
||
assert "verbatim-unresolved" in fn, (
|
||
"an unresolved placeholder is NOT a placement (the block still belongs on screen)"
|
||
)
|
||
|
||
|
||
def test_terminal_settle_renders_the_honest_state() -> None:
|
||
"""``settleVerbatimPlaceholders`` turns every leftover ``.verbatim-pending``
|
||
into the unresolved node and records the ``kind`` for the next reader."""
|
||
js = _text(VERBATIM_JS)
|
||
fn = _fn(js, "settleVerbatimPlaceholders")
|
||
assert 'root.querySelectorAll(".verbatim-pending")' in fn
|
||
assert "buildVerbatimUnresolved(" in fn
|
||
assert "replaceWith(" in fn, "the pending node is REPLACED, never patched"
|
||
assert 'node.getAttribute("data-bor-locator")' in fn, (
|
||
"the locator the model named rides into the honest state"
|
||
)
|
||
unresolved = _fn(js, "buildVerbatimUnresolved")
|
||
assert 'root.dataset.verbatimState = (opts && opts.kind) || "unverified"' in unresolved, (
|
||
"the settle kind is recorded for the next reader"
|
||
)
|
||
assert "state.textContent = verbatimUnresolvedCopy()" in unresolved
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# CSP + the house XSS rule (the new client, and the new code in the pages)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_no_inline_handlers_and_no_html_anywhere_in_the_client() -> None:
|
||
"""CSP is ``default-src 'self'``: no ``onclick=``/``oncopy=`` attribute
|
||
strings, no inline ``<script>``, no HTML string, no ``innerHTML`` (not
|
||
even ``+=``) in ``verbatim.js`` — every node is ``createElement``, every
|
||
value ``textContent``/``title``/a built ``href``, every handler
|
||
``addEventListener``."""
|
||
code = _strip_comments(_text(VERBATIM_JS))
|
||
for marker in (
|
||
"onclick=",
|
||
"oncopy=",
|
||
"onerror=",
|
||
"onload=",
|
||
".innerHTML",
|
||
".outerHTML",
|
||
"insertAdjacentHTML(",
|
||
"document.write(",
|
||
"eval(",
|
||
"<script",
|
||
):
|
||
assert marker not in code, f"verbatim.js must stay HTML/handler-free: {marker}"
|
||
assert "document.createElement(" in code
|
||
assert code.count("document.createElement(") > 15, (
|
||
"the panel is built node by node (the house rule)"
|
||
)
|
||
assert "addEventListener(" in code
|
||
|
||
|
||
def test_the_pages_new_code_adds_no_inline_handler_or_inner_html() -> None:
|
||
"""The task-07 additions to ``app.js`` / ``shared.js`` obey the same rule:
|
||
no ``innerHTML +=`` anywhere, and the only ``onclick=``-shaped text in the
|
||
two files stays inside their existing comments (none)."""
|
||
for path in (APP_JS, SHARED_JS):
|
||
js = _text(path)
|
||
assert "innerHTML += " not in js, f"{path.name}: no innerHTML concatenation"
|
||
assert "onclick=" not in js
|
||
assert "oncopy=" not in js
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# app.js: the live turn, the save point, the restore, the terminal paths
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _done_branch(js: str) -> str:
|
||
start = js.index('ev.type === "done"')
|
||
end = js.index('ev.type === "tool_result"', start)
|
||
return js[start:end]
|
||
|
||
|
||
def test_the_done_handler_hydrates_before_the_source_clusters() -> None:
|
||
"""The order IS the layout: the answer image, then the hydrated panels,
|
||
then the blocks the answer never located, then the Copy buttons on the
|
||
settled code blocks, then the source clusters (phase-124/130 DOM order —
|
||
the panels sit below the answer text and above the chips)."""
|
||
branch = _done_branch(_text(APP_JS))
|
||
for call in (
|
||
"appendAnswerImage(wrap, ev.answer_image)",
|
||
"hydrateVerbatim(",
|
||
"appendUnlocatedVerbatim(wrap, ev.answer_text || [], VERBATIM_LINK_OPTS)",
|
||
"enhanceAnswerCodeBlocks(wrap)",
|
||
"appendSources(wrap, ev.sources)",
|
||
):
|
||
assert call in branch, f"the done handler must call {call}"
|
||
order = [
|
||
branch.index("appendAnswerImage("),
|
||
branch.index("hydrateVerbatim("),
|
||
branch.index("appendUnlocatedVerbatim("),
|
||
branch.index("enhanceAnswerCodeBlocks("),
|
||
branch.index("appendSources("),
|
||
]
|
||
assert order == sorted(order), "image → panels → unlocated → copy → sources"
|
||
assert "VERBATIM_LINK_OPTS" in branch[branch.index("hydrateVerbatim(") :][:200]
|
||
|
||
|
||
def _midturn_branch(js: str) -> str:
|
||
start = js.index('ev.type === "verbatim_block"')
|
||
end = js.index('ev.type === "error"', start)
|
||
return js[start:end]
|
||
|
||
|
||
def test_the_midturn_frame_handler_hydrates_in_place_and_appends_only_on_zero() -> None:
|
||
"""Phase 192 (task 02, A2): the live turn's ``verbatim_block`` branch.
|
||
|
||
The shape IS the behaviour: hydrate the pending fence FIRST, and reach
|
||
for ``appendUnlocatedVerbatim`` ONLY on the zero-match path — a fence that
|
||
is already on screen keeps its panel where the model put it, and only a
|
||
block no placeholder names lands at the answer position (the
|
||
``answer_image`` band). The branch sits in the same ``readSSE`` chain, after
|
||
``tool_result`` (the server's emission order) and before ``error``.
|
||
|
||
And there is NO client-side kill switch: the server's
|
||
``BOR_ANSWER_TEXT`` gate is the only gate, so a disabled turn simply sends
|
||
no frame (a second, client-side settings check is a drift risk, exactly
|
||
the phase's reasoning for keeping the gate server-side)."""
|
||
js = _text(APP_JS)
|
||
branch = _midturn_branch(js)
|
||
assert "hydrateVerbatimNow(" in branch, "the mid-turn pass settles the fence"
|
||
assert "appendUnlocatedVerbatim(wrap, [ev], VERBATIM_LINK_OPTS)" in branch, (
|
||
"the unmatched block lands at the answer position, ONE opts object"
|
||
)
|
||
assert re.search(r"if \(\w+ === 0\)\s*appendUnlocatedVerbatim\(", branch), (
|
||
"the append is the ZERO-match path only — never alongside an in-place panel"
|
||
)
|
||
order = [branch.index("hydrateVerbatimNow("), branch.index("appendUnlocatedVerbatim(")]
|
||
assert order == sorted(order), "hydrate in place FIRST, append only if nothing matched"
|
||
assert "VERBATIM_LINK_OPTS" in branch[branch.index("hydrateVerbatimNow(") :][:200], (
|
||
"the mid-turn panel navigates exactly like the done panel (chip parity)"
|
||
)
|
||
# No second gate: nothing in the branch reads settings / a flag / the
|
||
# done frame's key — the arrival of the frame is the whole condition.
|
||
code = _strip_comments(branch)
|
||
for gate in ("answer_text", "answerText", "settings", "await ", "fetch("):
|
||
assert gate not in code, f"{gate} looks like a client-side gate (server-only)"
|
||
# The frame type is the ONE new thing the live turn knows (the server
|
||
# sends it between `tool_result` and the next round).
|
||
assert js.index('ev.type === "tool_result"') < js.index('ev.type === "verbatim_block"')
|
||
|
||
|
||
def test_the_answer_renderers_opt_in_and_nothing_else_does() -> None:
|
||
"""The per-delta write and the ``done``-time re-render both pass
|
||
``{ verbatim: true }`` (the third site is the restore renderer, pinned in
|
||
``test_the_restore_path_rehydrates_from_the_record``); the user bubble of
|
||
the same turn does NOT — a question is not a locator."""
|
||
js = _text(APP_JS)
|
||
branch = _done_branch(js)
|
||
assert "renderMarkdown(acc, { verbatim: true })" in js
|
||
assert "renderMarkdown(finalText, { verbatim: true })" in branch
|
||
assert 'addMessage(\n "user",\n renderMarkdown(text),' in js, (
|
||
"the sent question stays one-argument"
|
||
)
|
||
|
||
|
||
def test_the_save_point_persists_the_blocks_additively() -> None:
|
||
"""``answer_text: ev.answer_text || undefined`` on the ``done`` save point
|
||
— ``undefined`` drops the key from the JSON (the ``answer_image``
|
||
precedent), so a pre-phase record stays byte-identical and a
|
||
``answer_text=False`` turn stores nothing new."""
|
||
branch = _done_branch(_text(APP_JS))
|
||
assert "answer_text: ev.answer_text || undefined," in branch
|
||
turn = _fn(_text(APP_JS), "rememberBrainTurn")
|
||
assert "...meta" in turn, (
|
||
"the meta spread carries the key — rememberBrainTurn itself is untouched"
|
||
)
|
||
|
||
|
||
def test_the_restore_path_rehydrates_from_the_record() -> None:
|
||
"""Reload parity: the restore renderer opts in, then hydrates from
|
||
``m.answer_text`` and appends the unlocated blocks in the same band, then
|
||
settles the code blocks — the SAME builders as the live turn. A record
|
||
with no ``answer_text`` renders its fences as the unresolved state (the
|
||
record is what it is: never resurrect model bytes)."""
|
||
js = _text(APP_JS)
|
||
body = _fn(js, "renderStoredMessage")
|
||
assert 'addMessage("brain", renderMarkdown(m.text, { verbatim: true }), true)' in body
|
||
assert (
|
||
'hydrateVerbatim(wrap.querySelector(".bubble"), m.answer_text || [], VERBATIM_LINK_OPTS)'
|
||
in body
|
||
)
|
||
assert "appendUnlocatedVerbatim(wrap, m.answer_text || [], VERBATIM_LINK_OPTS)" in body
|
||
assert "enhanceAnswerCodeBlocks(wrap)" in body
|
||
order = [
|
||
body.index("appendAnswerImage(wrap, m.answer_image)"),
|
||
body.index("hydrateVerbatim("),
|
||
body.index("appendUnlocatedVerbatim("),
|
||
body.index("enhanceAnswerCodeBlocks("),
|
||
body.index("appendSources(wrap, m.sources)"),
|
||
]
|
||
assert order == sorted(order)
|
||
|
||
|
||
def test_every_terminal_path_settles_the_placeholders() -> None:
|
||
"""Nothing sits at \u201cRetrieving the exact text\u2026\u201d after a terminal
|
||
frame: the ``error`` frame handler, the 300 s guard, that guard's own
|
||
abort surface, the user-stop branch and the failed-turn funnel each call
|
||
``settleVerbatimPlaceholders(…, "unverified")`` — five sites, no more."""
|
||
js = _text(APP_JS)
|
||
assert js.count("settleVerbatimPlaceholders(") == 5, (
|
||
"the five terminal sites: error frame, timeout, aborted catch, stop "
|
||
"catch, failed-turn funnel"
|
||
)
|
||
assert js.count('"unverified"') >= 5, "every settle names the honest kind"
|
||
funnel = _fn(js, "finalizeFailedTurn")
|
||
assert 'settleVerbatimPlaceholders(wrap.querySelector(".bubble"), "unverified");' in funnel
|
||
assert funnel.index("closeAllRuns(wrap)") < funnel.index("settleVerbatimPlaceholders(")
|
||
stop = js[js.index("stoppedByUser || err?.name ===") :]
|
||
assert "settleVerbatimPlaceholders(" in stop[:800], "the Stop action settles"
|
||
frame = js[js.index('ev.type === "error"') :]
|
||
assert "settleVerbatimPlaceholders(" in frame[:700], "the error frame settles"
|
||
|
||
|
||
def test_the_chat_panel_links_back_to_the_chat_page() -> None:
|
||
"""The chat page's panels navigate EXACTLY like a source chip: the same
|
||
``documentUrl(…, "/")`` href (back → the chat, phase 153) and the same
|
||
left-click → same-page modal (phase 26), handed to the client as ONE
|
||
options object."""
|
||
js = _text(APP_JS)
|
||
opts = (
|
||
_fn(js, "VERBATIM_LINK_OPTS")
|
||
if "function VERBATIM_LINK_OPTS" in js
|
||
else js[
|
||
js.index("const VERBATIM_LINK_OPTS") : js.index(
|
||
"};", js.index("const VERBATIM_LINK_OPTS")
|
||
)
|
||
]
|
||
)
|
||
assert "documentHref:" in opts and 'documentUrl(source, path, "/")' in opts
|
||
assert "onOpen:" in opts and 'openDocumentModal(source, path, opener, "/")' in opts
|
||
# every app.js call site passes it
|
||
assert _text(APP_JS).count("VERBATIM_LINK_OPTS") >= 5
|
||
|
||
|
||
def test_the_share_path_delegates_to_the_one_clipboard_helper() -> None:
|
||
"""Task 07 consolidates the clipboard path: ``app.js``'s owner-locked
|
||
share copy delegates to ``copyTextWithFallback`` with the same host and
|
||
the same title, and the page no longer carries its own selection
|
||
helper."""
|
||
js = _text(APP_JS)
|
||
share = _fn(js, "copyShareLinkWithFallback")
|
||
assert "copyTextWithFallback(" in share
|
||
assert "composer," in share
|
||
assert '"Share link — click, then copy (Ctrl/Cmd+C)"' in share
|
||
assert "function selectAllInField" not in js, (
|
||
"the selection helper moved with the clipboard path — one implementation"
|
||
)
|
||
verbatim = _text(VERBATIM_JS)
|
||
assert "function selectAllInFallbackField" in verbatim
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# shared.js parity
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_shared_page_reaches_the_same_globals_with_a_plain_link() -> None:
|
||
"""Parity is a hard requirement: the shared renderer calls the SAME three
|
||
globals in the SAME band and order as the chat page's restore, and passes
|
||
a plain ``/document.html?source=&path=`` href with NO modal (that page has
|
||
none, and the documents API is admin-gated)."""
|
||
js = _text(SHARED_JS)
|
||
body = _fn(js, "renderSharedMessage")
|
||
assert "hydrateVerbatim(" in body
|
||
assert "appendUnlocatedVerbatim(wrap, m.answer_text || [], SHARED_VERBATIM_OPTS)" in body
|
||
assert "enhanceAnswerCodeBlocks(wrap)" in body
|
||
order = [
|
||
body.index("addAnswerImage(wrap, m.answer_image)"),
|
||
body.index("hydrateVerbatim("),
|
||
body.index("appendUnlocatedVerbatim("),
|
||
body.index("addSources(wrap, m.sources)"),
|
||
]
|
||
assert order == sorted(order)
|
||
opts = js[
|
||
js.index("const SHARED_VERBATIM_OPTS") : js.index(
|
||
"};", js.index("const SHARED_VERBATIM_OPTS")
|
||
)
|
||
]
|
||
assert "/document.html?source=" in opts and "encodeURIComponent(" in opts
|
||
assert "onOpen" not in opts, "no modal on the shared page"
|
||
assert "openDocumentModal" not in js
|
||
assert "document-modal" not in js
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# styles.css: the states the JS adds stay inside the phase-182 block
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _css_block() -> str:
|
||
css = _text(STYLES_CSS)
|
||
start = css.index(BLOCK_BEGIN)
|
||
end = css.index(BLOCK_END, start)
|
||
return css[start : end + len(BLOCK_END)]
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"selector", [".verbatim-body", ".verbatim-text", ".verbatim-open", ".verbatim-more"]
|
||
)
|
||
def test_the_task_07_dom_selectors_are_declared_in_the_phase_182_block(
|
||
selector: str,
|
||
) -> None:
|
||
"""The DOM the panel builder really produces is styled INSIDE the labelled
|
||
region (so task 06's palette-only and contiguity pins keep holding): the
|
||
body is the flex row the gutter's ``flex: 0 0 auto`` was written for, and
|
||
the viewer link + the truncation line are themed, not unstyled."""
|
||
block = _css_block()
|
||
rules = re.findall(r"(?m)^" + re.escape(selector) + r" \{([^}]*)\}", block)
|
||
assert rules, f"{selector} must be declared inside the phase-182 block"
|
||
# task 06's `_body` idiom: task 07 extends a rule with its own declaration
|
||
# line (a second block for the same selector), so every body counts.
|
||
body = " ".join(rules)
|
||
assert not re.search(r"#[0-9a-fA-F]{3,8}\b", body), "no hex colour"
|
||
for literal in ("rgb(", "rgba(", "hsl(", "hsla("):
|
||
assert literal not in body, f"{selector} must stay palette-only ({literal})"
|
||
assert not re.search(r":\s*(?:white|black|#fff|#000)\s*[;}]", body), (
|
||
f"{selector} must not name a colour"
|
||
)
|
||
if selector == ".verbatim-body":
|
||
assert "display: flex" in body, "the gutter + text row the sticky gutter needs"
|
||
if selector == ".verbatim-body":
|
||
assert "display: flex" in body, "the gutter + text row the sticky gutter needs"
|
||
if selector == ".verbatim-open":
|
||
assert "text-decoration: underline" in body, "a link is not colour-only"
|
||
assert "var(--ink-soft)" in body, "the link is themed, not unstyled"
|
||
if selector == ".verbatim-more":
|
||
assert "var(--ink-soft)" in body and "var(--line)" in body
|
||
|
||
|
||
def test_the_viewer_link_reaches_the_mobile_touch_floor() -> None:
|
||
"""PLAN §7's 44px tap target applies to the panel's link too — it lives in
|
||
the ``max-width: 640px`` block beside the two Copy controls."""
|
||
css = _text(STYLES_CSS)
|
||
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
|
||
assert mobile, "the 640px block must exist"
|
||
hit = re.search(r"\.verbatim-open \{([^}]*)\}", mobile.group(1))
|
||
assert hit and "min-height: 44px" in hit.group(1)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# node: the panel's pure logic, run against the real file
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
_HARNESS = """
|
||
const fs = require("fs");
|
||
const src = fs.readFileSync(process.argv[2], "utf8");
|
||
const api = new Function(
|
||
src +
|
||
"\\nreturn { verbatimCaptionText, verbatimGutter, verbatimPanelLabel," +
|
||
" verbatimIdentity, verbatimSplitLocator, verbatimDocumentHref," +
|
||
" isVerbatimBlock, verbatimBlocks, matchVerbatimBlock, verbatimUnresolvedCopy," +
|
||
" verbatimBadgeTitle };"
|
||
)();
|
||
const cases = JSON.parse(fs.readFileSync(process.argv[3], "utf8"));
|
||
const out = {};
|
||
for (const [name, body] of Object.entries(cases)) {
|
||
try {
|
||
// `with` so the case bodies read the panel API as plain names (the file
|
||
// itself is sloppy-mode classic-script code, and the harness is its
|
||
// own scope — no strict-mode conflict).
|
||
const fn = new Function("api", "with (api) { return (" + body + "); }");
|
||
out[name] = { ok: true, value: fn(api) };
|
||
} catch (err) {
|
||
out[name] = { ok: false, error: String(err && err.message ? err.message : err) };
|
||
}
|
||
}
|
||
process.stdout.write(JSON.stringify(out));
|
||
"""
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def tmp_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||
return tmp_path_factory.mktemp("verbatim")
|
||
|
||
|
||
def _node(cases: dict[str, str], tmp_dir: Path) -> dict[str, object]:
|
||
"""Run *cases* (JS bodies receiving the panel API) under node against the
|
||
real ``verbatim.js`` — the pure half needs no DOM."""
|
||
script = tmp_dir / "harness.cjs"
|
||
script.write_text(_HARNESS, encoding="utf-8")
|
||
case_file = tmp_dir / "cases.json"
|
||
case_file.write_text(
|
||
# the case bodies are plain JS EXPRESSIONS evaluated with the panel
|
||
# API in scope (the harness wraps them, see _HARNESS)
|
||
json.dumps(dict(cases)),
|
||
encoding="utf-8",
|
||
)
|
||
proc = subprocess.run(
|
||
["node", str(script), str(VERBATIM_JS), str(case_file)],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=60,
|
||
check=False,
|
||
)
|
||
assert proc.returncode == 0, proc.stderr
|
||
return json.loads(proc.stdout)
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_caption_prints_the_servers_extent(tmp_dir: Path) -> None:
|
||
"""The caption is the SERVER's resolved extent (LOCKED A2 made visible):
|
||
a slice reads ``path · lines 12\u201340 of 210``, a whole document reads
|
||
``path · all 210 lines``, a CAPPED block is never labelled "all" even when
|
||
it starts at line 1, and a garbage count degrades to the bare path rather
|
||
than printing a lie."""
|
||
out = _node(
|
||
{
|
||
"slice": """verbatimCaptionText({ source: "homelab", path: "a.md",
|
||
start_line: 12, end_line: 40, total_lines: 210 })""",
|
||
"whole": """verbatimCaptionText({ source: "homelab", path: "a.md",
|
||
start_line: 1, end_line: 210, total_lines: 210 })""",
|
||
"capped": """verbatimCaptionText({ source: "homelab", path: "a.md",
|
||
start_line: 1, end_line: 30, total_lines: 210, truncated: true })""",
|
||
"no_total": """verbatimCaptionText({ source: "homelab", path: "a.md",
|
||
start_line: 3, end_line: 4 })""",
|
||
"garbage": """verbatimCaptionText({ source: "homelab", path: "a.md",
|
||
start_line: "x", end_line: null })""",
|
||
"label": """verbatimPanelLabel({ source: "homelab", path: "a.md",
|
||
start_line: 12, end_line: 40 })""",
|
||
"label_whole": """verbatimPanelLabel({ source: "homelab", path: "a.md",
|
||
start_line: 1, end_line: 1 })""",
|
||
"href": """verbatimDocumentHref("src a", "dir/x y.md")""",
|
||
},
|
||
tmp_dir,
|
||
)
|
||
assert out["slice"] == {"ok": True, "value": "homelab/a.md \u00b7 lines 12\u201340 of 210"}
|
||
assert out["whole"] == {"ok": True, "value": "homelab/a.md \u00b7 all 210 lines"}
|
||
assert out["capped"] == {"ok": True, "value": "homelab/a.md \u00b7 lines 1\u201330 of 210"}
|
||
assert out["no_total"] == {"ok": True, "value": "homelab/a.md \u00b7 lines 3\u20134"}
|
||
assert out["garbage"] == {"ok": True, "value": "homelab/a.md"}
|
||
assert out["label"] == {
|
||
"ok": True,
|
||
"value": "Verbatim text from homelab/a.md, lines 12\u201340",
|
||
}
|
||
assert out["label_whole"] == {
|
||
"ok": True,
|
||
"value": "Verbatim text from homelab/a.md, line 1",
|
||
}, "one line reads `line N`, never `lines 1–1`"
|
||
# SEC: the href is built from ENCODED halves (the `documentUrl` parity —
|
||
# the whole path is one query value, slashes included, and FastAPI decodes
|
||
# it back), and it is always a same-origin relative URL.
|
||
assert out["href"] == {
|
||
"ok": True,
|
||
"value": "/document.html?source=src%20a&path=dir%2Fx%20y.md",
|
||
}
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_gutter_numbers_the_servers_lines_and_not_the_marker(tmp_dir: Path) -> None:
|
||
"""One gutter row per line of the server text (so the rows align with the
|
||
``<pre>``), numbered from the server's ``start_line``. The truncation
|
||
marker the cap appended is a real line of the panel but NOT a line of the
|
||
file, so it gets a blank cell (an empty cell keeps the rows aligned \u2014 the
|
||
gutter never claims a coordinate the file does not have)."""
|
||
out = _node(
|
||
{
|
||
"range": """verbatimGutter({ text: "one\\ntwo\\nthree", start_line: 12,
|
||
end_line: 14 }).map(r => r.number).join("|")""",
|
||
"truncated": """verbatimGutter({ text: "one\\ntwo\\n\\u2026truncated\\u2026",
|
||
start_line: 1, end_line: 2, truncated: true })
|
||
.map(r => JSON.stringify(r.number)).join("|")""",
|
||
"unnumbered": """verbatimGutter({ text: "a\\nb" }).map(r => r.number).join("|")""",
|
||
},
|
||
tmp_dir,
|
||
)
|
||
assert out["range"] == {"ok": True, "value": "12|13|14"}
|
||
assert out["truncated"] == {"ok": True, "value": '"1"|"2"|" "'}
|
||
assert out["unnumbered"] == {"ok": True, "value": " | "}
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_match_rule_exact_then_unique_path_then_none(tmp_dir: Path) -> None:
|
||
"""The ONE hydration rule: the canonical locator first; else exactly ONE
|
||
block for the same ``source/path`` (the model's range was wrong \u2014 the panel
|
||
shows the server's extent); else null, INCLUDING the ambiguous case (two
|
||
blocks, same file, different ranges \u2014 never guess)."""
|
||
out = _node(
|
||
{
|
||
"exact": """(() => { const b = [
|
||
{ source: "h", path: "a.md", locator: "h/a.md#12-40" },
|
||
{ source: "h", path: "a.md", locator: "h/a.md#1-5" }];
|
||
return matchVerbatimBlock(b, "h/a.md#1-5").locator; })()""",
|
||
"path_only": """(() => { const b = [{ source: "h", path: "a.md",
|
||
locator: "h/a.md#1-9" }];
|
||
return matchVerbatimBlock(b, "h/a.md#99-120").locator; })()""",
|
||
"whole_doc_path_only": """(() => { const b = [{ source: "h", path: "a.md",
|
||
locator: "h/a.md" }];
|
||
return matchVerbatimBlock(b, "h/a.md").locator; })()""",
|
||
"ambiguous": """(() => { const b = [
|
||
{ source: "h", path: "a.md", locator: "h/a.md#1-9" },
|
||
{ source: "h", path: "a.md", locator: "h/a.md#20-29" }];
|
||
return matchVerbatimBlock(b, "h/a.md#7-8"); })()""",
|
||
"none": """matchVerbatimBlock([], "h/a.md#1-2")""",
|
||
"other_path": """matchVerbatimBlock([{ source: "h", path: "b.md",
|
||
locator: "h/b.md" }], "h/a.md")""",
|
||
"split": """JSON.stringify(verbatimSplitLocator("h/a.md#12-40"))""",
|
||
"split_no_range": """JSON.stringify(verbatimSplitLocator("h/a.md"))""",
|
||
},
|
||
tmp_dir,
|
||
)
|
||
assert out["exact"] == {"ok": True, "value": "h/a.md#1-5"}
|
||
assert out["path_only"] == {"ok": True, "value": "h/a.md#1-9"}
|
||
assert out["whole_doc_path_only"] == {"ok": True, "value": "h/a.md"}
|
||
assert out["ambiguous"] == {"ok": True, "value": None}
|
||
assert out["none"] == {"ok": True, "value": None}
|
||
assert out["other_path"] == {"ok": True, "value": None}
|
||
assert out["split"] == {
|
||
"ok": True,
|
||
"value": '{"identity":"h/a.md","hasRange":true}',
|
||
}
|
||
assert out["split_no_range"] == {
|
||
"ok": True,
|
||
"value": '{"identity":"h/a.md","hasRange":false}',
|
||
}
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_malformed_blocks_are_dropped_and_never_throw(tmp_dir: Path) -> None:
|
||
"""A malformed / missing ``answer_text`` is a NON-event: every odd shape
|
||
(null, a bare object, a block without text, a block without a path,
|
||
numbers-as-strings) is filtered out instead of throwing \u2014 hydration stays
|
||
honest for the placeholders instead of painting garbage."""
|
||
out = _node(
|
||
{
|
||
"undefined_blocks": """verbatimBlocks(undefined).length""",
|
||
"not_an_array": """verbatimBlocks({ length: 1, 0: { source: "h", path: "a.md",
|
||
text: "x" } }).length""",
|
||
"mixed": """verbatimBlocks([null, 7, "s", {}, { source: "", path: "a.md", text: "x" },
|
||
{ source: "h", path: "", text: "x" },
|
||
{ source: "h", path: "a.md" },
|
||
{ source: "h", path: "a.md", text: "" }]).length""",
|
||
"identity_with_empty_text": """isVerbatimBlock({ source: "h",
|
||
path: "a.md", text: "" })""",
|
||
"identity": """verbatimIdentity("homelab", "runbooks/a.md")""",
|
||
"unresolved_copy": """verbatimUnresolvedCopy()""",
|
||
"badge_title": """verbatimBadgeTitle()""",
|
||
},
|
||
tmp_dir,
|
||
)
|
||
assert out["undefined_blocks"] == {"ok": True, "value": 0}
|
||
assert out["not_an_array"] == {"ok": True, "value": 0}
|
||
# only the last entry survives: the others miss source/path/text.
|
||
assert out["mixed"] == {"ok": True, "value": 1}
|
||
assert out["identity_with_empty_text"] == {"ok": True, "value": True}
|
||
assert out["identity"] == {"ok": True, "value": "homelab/runbooks/a.md"}
|
||
assert out["unresolved_copy"] == {"ok": True, "value": UNRESOLVED_COPY}
|
||
assert out["badge_title"] == {"ok": True, "value": BADGE_TITLE}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# node: the MID-TURN pass (phase 192, task 02), run against the real file on
|
||
# a minimal DOM — the marker pins above prove the SHAPE, these prove the
|
||
# behaviour the E2E (task 04) will show in a browser: matched fences settle in
|
||
# place, everything else stays pending, a bad frame settles nothing.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
#: The minimal DOM + the fixtures (``pendingBubble`` / ``shownBlock`` /
|
||
#: ``snapshot``). ``querySelectorAll`` understands the ONE compound selector
|
||
#: the client walks (``.a.b[attr]``) and throws on anything else, so a
|
||
#: selector drift in the client fails loudly instead of matching nothing.
|
||
_DOM_LIB = """
|
||
class El {
|
||
constructor(tag) {
|
||
this.tag = tag;
|
||
this.children = [];
|
||
this.parent = null;
|
||
this.attrs = {};
|
||
this.dataset = {};
|
||
this.classes = new Set();
|
||
this._own = "";
|
||
}
|
||
get className() { return [...this.classes].join(" "); }
|
||
set className(v) { this.classes = new Set(String(v).split(/\\s+/).filter(Boolean)); }
|
||
get classList() {
|
||
return { contains: (c) => this.classes.has(c), add: (c) => this.classes.add(c) };
|
||
}
|
||
setAttribute(k, v) { this.attrs[k] = String(v); }
|
||
addEventListener(ev, fn) { (this.listeners = this.listeners || {})[ev] = fn; }
|
||
getAttribute(k) {
|
||
if (Object.prototype.hasOwnProperty.call(this.attrs, k)) return this.attrs[k];
|
||
if (k.startsWith("data-")) {
|
||
const camel = k.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
||
if (Object.prototype.hasOwnProperty.call(this.dataset, camel)) {
|
||
return String(this.dataset[camel]);
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
appendChild(c) { c.parent = this; this.children.push(c); return c; }
|
||
append(...cs) { for (const c of cs) this.appendChild(c); }
|
||
insertBefore(box, ref) {
|
||
const i = this.children.indexOf(ref);
|
||
this.children.splice(i < 0 ? this.children.length : i, 0, box);
|
||
box.parent = this;
|
||
return box;
|
||
}
|
||
replaceWith(node) {
|
||
if (!this.parent) throw new Error("replaceWith on a detached node");
|
||
const i = this.parent.children.indexOf(this);
|
||
this.parent.children[i] = node;
|
||
node.parent = this.parent;
|
||
this.parent = null;
|
||
}
|
||
remove() {
|
||
if (!this.parent) return;
|
||
const i = this.parent.children.indexOf(this);
|
||
if (i >= 0) this.parent.children.splice(i, 1);
|
||
this.parent = null;
|
||
}
|
||
closest(sel) {
|
||
let cur = this;
|
||
while (cur) {
|
||
if (matches(cur, sel)) return cur;
|
||
cur = cur.parent;
|
||
}
|
||
return null;
|
||
}
|
||
querySelectorAll(sel) { return descendants(this).filter((el) => matches(el, sel)); }
|
||
querySelector(sel) { return descendants(this).find((el) => matches(el, sel)) || null; }
|
||
get textContent() {
|
||
if (!this.children.length) return this._own;
|
||
return this.children.map((c) => c.textContent).join("");
|
||
}
|
||
set textContent(v) { this.children = []; this._own = String(v); }
|
||
}
|
||
|
||
function descendants(el, out) {
|
||
const list = out || [];
|
||
for (const c of el.children) { list.push(c); descendants(c, list); }
|
||
return list;
|
||
}
|
||
|
||
function matches(el, sel) {
|
||
const parts = sel.match(/\\.[A-Za-z0-9_-]+|\\[[^[\\]]+\\]/g) || [];
|
||
if (!parts.length) throw new Error("unsupported selector: " + sel);
|
||
return parts.every((p) =>
|
||
p.startsWith(".")
|
||
? el.classes.has(p.slice(1))
|
||
: el.getAttribute(p.slice(1, -1)) !== null
|
||
);
|
||
}
|
||
|
||
globalThis.document = {
|
||
createElement: (t) => new El(t),
|
||
createTextNode: (t) => {
|
||
const node = new El("#text");
|
||
node._own = String(t);
|
||
return node;
|
||
},
|
||
querySelectorAll: () => [],
|
||
body: new El("body"),
|
||
};
|
||
|
||
function pendingBubble(locators) {
|
||
const bubble = new El("div");
|
||
bubble.classes.add("bubble");
|
||
for (const loc of locators) {
|
||
const node = new El("div");
|
||
node.className = "verbatim verbatim-pending";
|
||
node.dataset.borLocator = loc;
|
||
bubble.appendChild(node);
|
||
}
|
||
return bubble;
|
||
}
|
||
|
||
function shownBlock(over) {
|
||
return Object.assign({
|
||
source: "homelab",
|
||
path: "runbooks/a.md",
|
||
title: "Runbook A",
|
||
start_line: 12,
|
||
end_line: 40,
|
||
total_lines: 210,
|
||
text: "line twelve\\nline thirteen",
|
||
truncated: false,
|
||
locator: "homelab/runbooks/a.md#12-40",
|
||
}, over || {});
|
||
}
|
||
|
||
function pendingWrap(locators) {
|
||
const msg = new El("div");
|
||
msg.className = "msg brain";
|
||
const body = new El("div");
|
||
body.className = "msg-body";
|
||
const bubble = new El("div");
|
||
bubble.className = "bubble";
|
||
msg.appendChild(body);
|
||
body.appendChild(bubble);
|
||
for (const loc of locators) {
|
||
const node = new El("div");
|
||
node.className = "verbatim verbatim-pending";
|
||
node.dataset.borLocator = loc;
|
||
bubble.appendChild(node);
|
||
}
|
||
return msg;
|
||
}
|
||
|
||
function findClass(root, cls) {
|
||
return descendants(root).find((el) => el.classes.has(cls)) || null;
|
||
}
|
||
|
||
function inBubble(el) {
|
||
let cur = el.parent;
|
||
while (cur) {
|
||
if (cur.classes.has("bubble")) return true;
|
||
cur = cur.parent;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/* The RESOLVED panels in document order — a pending or unresolved node is
|
||
* NOT a placement (appendUnlocatedVerbatim's own convention), which is what
|
||
* makes this the "one locator, one panel" reading. */
|
||
function placed(msg) {
|
||
return descendants(msg)
|
||
.filter((el) => el.classes.has("verbatim"))
|
||
.filter((el) => !el.classes.has("verbatim-pending"))
|
||
.filter((el) => !el.classes.has("verbatim-unresolved"))
|
||
.map((el) => ({
|
||
locator: el.getAttribute("data-bor-locator"),
|
||
where: inBubble(el) ? "bubble" : "body",
|
||
}));
|
||
}
|
||
|
||
/* The fence the model writes in a LATER round (the mid-turn frame arrived
|
||
* before it): a fresh pending node inside the bubble. */
|
||
function addFence(msg, locator) {
|
||
const node = new El("div");
|
||
node.className = "verbatim verbatim-pending";
|
||
node.dataset.borLocator = locator;
|
||
msg.querySelector(".bubble").appendChild(node);
|
||
return node;
|
||
}
|
||
|
||
/* The same later-round fence inside a BARE bubble (no `.msg` wrap) — the arm
|
||
* that proves the ROOT bounds the twin scan when there is no message wrap. */
|
||
function bareFence(bubble, locator) {
|
||
const node = new El("div");
|
||
node.className = "verbatim verbatim-pending";
|
||
node.dataset.borLocator = locator;
|
||
bubble.appendChild(node);
|
||
return node;
|
||
}
|
||
|
||
function snapshot(root) {
|
||
return descendants(root)
|
||
.filter((el) => el.classes.has("verbatim"))
|
||
.map((el) => {
|
||
const caption = findClass(el, "verbatim-caption");
|
||
const pre = findClass(el, "verbatim-text");
|
||
const link = findClass(el, "verbatim-open");
|
||
return {
|
||
pending: el.classes.has("verbatim-pending"),
|
||
unresolved: el.classes.has("verbatim-unresolved"),
|
||
locator: el.getAttribute("data-bor-locator"),
|
||
caption: caption ? caption.textContent : null,
|
||
text: pre ? pre.textContent : null,
|
||
href: link ? link.href : null,
|
||
};
|
||
});
|
||
}
|
||
|
||
module.exports = {
|
||
El, pendingBubble, pendingWrap, shownBlock, snapshot, placed, addFence,
|
||
bareFence, findClass, descendants,
|
||
};
|
||
"""
|
||
|
||
_DOM_HARNESS = """
|
||
const fs = require("fs");
|
||
const src = fs.readFileSync(process.argv[2], "utf8");
|
||
const dom = require(process.argv[3]); // installs globalThis.document
|
||
const cases = JSON.parse(fs.readFileSync(process.argv[4], "utf8"));
|
||
const panel = new Function(
|
||
src +
|
||
"\\nreturn { hydrateVerbatimNow, hydrateVerbatim, appendUnlocatedVerbatim," +
|
||
" buildVerbatimPanel, isVerbatimBlock };"
|
||
)();
|
||
const scope = Object.assign({}, panel, dom);
|
||
const out = {};
|
||
for (const [name, body] of Object.entries(cases)) {
|
||
try {
|
||
const fn = new Function("api", "with (api) { return (" + body + "); }");
|
||
out[name] = { ok: true, value: fn(scope) };
|
||
} catch (err) {
|
||
out[name] = { ok: false, error: String(err && err.message ? err.message : err) };
|
||
}
|
||
}
|
||
process.stdout.write(JSON.stringify(out));
|
||
"""
|
||
|
||
#: The block a mid-turn ``verbatim_block`` frame carries in these cases.
|
||
SHOWN_BLOCK_LOCATOR = "homelab/runbooks/a.md#12-40"
|
||
#: The OTHER fence on screen mid-answer — the one this frame must NOT touch.
|
||
OTHER_PENDING_LOCATOR = "homelab/runbooks/b.md#1-5"
|
||
#: The caption the panel must carry in every arm: the SERVER's extent (the
|
||
#: phase-182 contract), never the range the model typed into its fence.
|
||
CAPTION_SERVER_EXTENT = "homelab/runbooks/a.md \u00b7 lines 12\u201340 of 210"
|
||
|
||
|
||
def _node_dom(cases: dict[str, str], tmp_dir: Path) -> dict[str, Any]:
|
||
"""Run *cases* (JS expressions with the panel API + the DOM fixtures in
|
||
scope) under node against the real ``verbatim.js`` on the minimal DOM.
|
||
|
||
``Any`` on purpose: the DOM cases return nested objects/arrays, and the
|
||
assertions below walk them (``value["nodes"][0]["caption"]``)."""
|
||
lib = tmp_dir / "bor_dom.cjs"
|
||
lib.write_text(_DOM_LIB, encoding="utf-8")
|
||
script = tmp_dir / "bor_dom_harness.cjs"
|
||
script.write_text(_DOM_HARNESS, encoding="utf-8")
|
||
case_file = tmp_dir / "dom_cases.json"
|
||
case_file.write_text(json.dumps(dict(cases)), encoding="utf-8")
|
||
proc = subprocess.run(
|
||
["node", str(script), str(VERBATIM_JS), str(lib), str(case_file)],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=60,
|
||
check=False,
|
||
)
|
||
assert proc.returncode == 0, proc.stderr
|
||
return json.loads(proc.stdout)
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_midturn_pass_settles_the_matched_fence_and_leaves_the_rest(tmp_dir: Path) -> None:
|
||
"""The behaviour of ``hydrateVerbatimNow`` against the real file: the fence
|
||
whose canonical locator the frame carries settles IN PLACE with the
|
||
server's caption and bytes; the next fence in the same answer is NOT
|
||
touched (no panel, no unresolved sentence — it stays a promise the ``done``
|
||
frame still has to keep)."""
|
||
out = _node_dom(
|
||
{
|
||
"exact": f"""(() => {{
|
||
const bubble = pendingBubble(["{SHOWN_BLOCK_LOCATOR}", "{OTHER_PENDING_LOCATOR}"]);
|
||
const n = hydrateVerbatimNow(bubble, shownBlock(), {{}});
|
||
return {{ n, nodes: snapshot(bubble) }};
|
||
}})()""",
|
||
"wrong_range_uses_the_servers_extent": """(() => {
|
||
const bubble = pendingBubble(["homelab/runbooks/a.md#99-120"]);
|
||
const n = hydrateVerbatimNow(bubble, shownBlock(), {});
|
||
return { n, nodes: snapshot(bubble) };
|
||
})()""",
|
||
"two_fences_one_locator": f"""(() => {{
|
||
const bubble = pendingBubble(["{SHOWN_BLOCK_LOCATOR}", "{SHOWN_BLOCK_LOCATOR}"]);
|
||
const n = hydrateVerbatimNow(bubble, shownBlock(), {{}});
|
||
return {{ n, pending: snapshot(bubble).filter((x) => x.pending).length }};
|
||
}})()""",
|
||
"opts_thread_through": f"""(() => {{
|
||
const bubble = pendingBubble(["{SHOWN_BLOCK_LOCATOR}"]);
|
||
const n = hydrateVerbatimNow(bubble, shownBlock(), {{
|
||
documentHref: (s, p) => "HOOK/" + s + "/" + p,
|
||
}});
|
||
return {{ n, nodes: snapshot(bubble) }};
|
||
}})()""",
|
||
},
|
||
tmp_dir,
|
||
)
|
||
exact = out["exact"]
|
||
assert exact["ok"] is True, exact
|
||
assert exact["value"]["n"] == 1, "exactly the matching placeholder settles"
|
||
settled, untouched = exact["value"]["nodes"]
|
||
assert settled == {
|
||
"pending": False,
|
||
"unresolved": False,
|
||
"locator": SHOWN_BLOCK_LOCATOR,
|
||
"caption": CAPTION_SERVER_EXTENT,
|
||
"text": "line twelve\nline thirteen",
|
||
"href": "/document.html?source=homelab&path=runbooks%2Fa.md",
|
||
}, "the panel is the done path's panel, built from the SERVER's bytes"
|
||
assert untouched == {
|
||
"pending": True,
|
||
"unresolved": False,
|
||
"locator": OTHER_PENDING_LOCATOR,
|
||
"caption": None,
|
||
"text": None,
|
||
"href": None,
|
||
}, "a fence this block does not name STAYS pending"
|
||
# The model's range was wrong: the identity fallback still applies, and
|
||
# the caption / data attribute carry the SERVER's extent (mid-turn and
|
||
# done can never disagree — the phase-182 contract).
|
||
fallback = out["wrong_range_uses_the_servers_extent"]
|
||
assert fallback["ok"] is True, fallback
|
||
assert fallback["value"]["n"] == 1
|
||
assert fallback["value"]["nodes"][0]["locator"] == SHOWN_BLOCK_LOCATOR
|
||
assert fallback["value"]["nodes"][0]["caption"] == CAPTION_SERVER_EXTENT
|
||
dupes = out["two_fences_one_locator"]
|
||
assert dupes["ok"] is True, dupes
|
||
assert dupes["value"]["n"] == 2 and dupes["value"]["pending"] == 0
|
||
hooked = out["opts_thread_through"]
|
||
assert hooked["ok"] is True, hooked
|
||
assert hooked["value"]["n"] == 1
|
||
assert hooked["value"]["nodes"][0]["href"] == "HOOK/homelab/runbooks/a.md", (
|
||
"opts (the chat's modal hook) reach the mid-turn panel"
|
||
)
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_midturn_pass_is_total_on_garbage_and_no_match(tmp_dir: Path) -> None:
|
||
"""Nothing a mid-turn frame can carry settles a fence the block does not
|
||
name \u2014 and nothing throws: no match, a malformed frame (the ``isVerbatimBlock``
|
||
guard), a null root, a root that is not an element, no root at all. The
|
||
pending node stays ``.verbatim-pending`` in every arm (NEVER the honest
|
||
unresolved sentence \u2014 that is the terminal path's verdict, not this one)."""
|
||
out = _node_dom(
|
||
{
|
||
"other_file": f"""(() => {{
|
||
const bubble = pendingBubble(["{OTHER_PENDING_LOCATOR}"]);
|
||
const n = hydrateVerbatimNow(bubble, shownBlock(), {{}});
|
||
return {{ n, nodes: snapshot(bubble) }};
|
||
}})()""",
|
||
"malformed_no_source": f"""(() => {{
|
||
const bubble = pendingBubble(["{SHOWN_BLOCK_LOCATOR}"]);
|
||
const n = hydrateVerbatimNow(bubble, {{ path: "runbooks/a.md", text: "x" }}, {{}});
|
||
return {{ n, nodes: snapshot(bubble) }};
|
||
}})()""",
|
||
"malformed_not_an_object": f"""(() => {{
|
||
const bubble = pendingBubble(["{SHOWN_BLOCK_LOCATOR}"]);
|
||
const n = hydrateVerbatimNow(bubble, "verbatim", {{}});
|
||
return {{ n, nodes: snapshot(bubble) }};
|
||
}})()""",
|
||
"null_root": "hydrateVerbatimNow(null, shownBlock(), {})",
|
||
"plain_object_root": "hydrateVerbatimNow({}, shownBlock(), {})",
|
||
"undefined_root": "hydrateVerbatimNow(undefined, shownBlock(), undefined)",
|
||
},
|
||
tmp_dir,
|
||
)
|
||
for name in ("other_file", "malformed_no_source", "malformed_not_an_object"):
|
||
case = out[name]
|
||
assert case["ok"] is True, (name, case)
|
||
assert case["value"]["n"] == 0, name
|
||
node = case["value"]["nodes"][0]
|
||
assert node["pending"] is True and node["unresolved"] is False, (
|
||
f"{name}: the fence stays a promise, never a burned placeholder"
|
||
)
|
||
assert node["text"] is None, f"{name}: no panel, so no bytes on screen"
|
||
for name in ("null_root", "plain_object_root", "undefined_root"):
|
||
assert out[name] == {"ok": True, "value": 0}, name
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_midturn_panel_survives_the_done_pass(tmp_dir: Path) -> None:
|
||
"""The parity that keeps the phase additive: the mid-turn pass runs, then
|
||
the ``done`` frame arrives with the SAME block. ``hydrateVerbatim`` only
|
||
touches ``.verbatim-pending``, so the panel the mid-turn pass built is the
|
||
SAME node afterwards (its ``Copied`` state and scroll position survive),
|
||
and the unrelated fence settles to the honest sentence."""
|
||
out = _node_dom(
|
||
{
|
||
"then_done": f"""(() => {{
|
||
const bubble = pendingBubble(["{SHOWN_BLOCK_LOCATOR}", "{OTHER_PENDING_LOCATOR}"]);
|
||
const n1 = hydrateVerbatimNow(bubble, shownBlock(), {{}});
|
||
const midturnPanel = findClass(bubble, "verbatim");
|
||
const n2 = hydrateVerbatim(bubble, [shownBlock()], {{}});
|
||
const nodes = snapshot(bubble);
|
||
return {{
|
||
n1,
|
||
n2,
|
||
sameNode: findClass(bubble, "verbatim") === midturnPanel,
|
||
nodes,
|
||
}};
|
||
}})()""",
|
||
},
|
||
tmp_dir,
|
||
)
|
||
case = out["then_done"]
|
||
assert case["ok"] is True, case
|
||
value = case["value"]
|
||
assert value["n1"] == 1 and value["n2"] == 1
|
||
assert value["sameNode"] is True, (
|
||
"the done pass re-runs over the mid-turn panel without rebuilding it"
|
||
)
|
||
settled, unresolved = value["nodes"]
|
||
assert settled["pending"] is False and settled["locator"] == SHOWN_BLOCK_LOCATOR
|
||
assert unresolved["pending"] is False and unresolved["unresolved"] is True, (
|
||
"the fence no frame ever resolved lands on the honest state at done"
|
||
)
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_one_locator_one_panel_across_the_midturn_and_done_passes(tmp_dir: Path) -> None:
|
||
"""The duplicate the mid-turn landing would otherwise leave behind (the
|
||
phase's A3): a ``show_text`` block arrives BEFORE the answer fences it, so
|
||
the mid-turn pass appends it at the answer position; the fence lands in a
|
||
later round and hydrates at ``done``. Exactly ONE panel remains and it sits
|
||
IN THE BUBBLE, where the model put the fence.
|
||
|
||
The two arms that must NOT collapse: two different ranges of one file are
|
||
two honest panels (the scan is per canonical locator), and a block the
|
||
answer never fences keeps its single appended panel."""
|
||
out = _node_dom(
|
||
{
|
||
"fence_arrives_later": """(() => {
|
||
const msg = pendingWrap([]);
|
||
const bubble = msg.querySelector(".bubble");
|
||
const mid = hydrateVerbatimNow(bubble, shownBlock(), {});
|
||
const appended = appendUnlocatedVerbatim(msg, [shownBlock()], {});
|
||
addFence(msg, "homelab/runbooks/a.md#99-120");
|
||
const settled = hydrateVerbatim(bubble, [shownBlock()], {});
|
||
return { mid, appended, settled, panels: placed(msg) };
|
||
})()""",
|
||
"two_ranges_one_file": """(() => {
|
||
const b1 = shownBlock();
|
||
const b2 = shownBlock({ start_line: 10, end_line: 14,
|
||
locator: "homelab/runbooks/a.md#10-14" });
|
||
const msg = pendingWrap([]);
|
||
const bubble = msg.querySelector(".bubble");
|
||
appendUnlocatedVerbatim(msg, [b1], {});
|
||
addFence(msg, "homelab/runbooks/a.md#10-14");
|
||
addFence(msg, "homelab/runbooks/a.md#12-40");
|
||
hydrateVerbatim(bubble, [b1, b2], {});
|
||
return { panels: placed(msg) };
|
||
})()""",
|
||
"never_fenced_keeps_its_panel": """(() => {
|
||
const msg = pendingWrap([]);
|
||
const bubble = msg.querySelector(".bubble");
|
||
const appended = appendUnlocatedVerbatim(msg, [shownBlock()], {});
|
||
const settled = hydrateVerbatim(bubble, [shownBlock()], {});
|
||
const again = appendUnlocatedVerbatim(msg, [shownBlock()], {});
|
||
return { appended, settled, again, panels: placed(msg) };
|
||
})()""",
|
||
"repeated_fence_shows_the_bytes_once": """(() => {
|
||
const msg = pendingWrap(["homelab/runbooks/a.md#12-40",
|
||
"homelab/runbooks/a.md#12-40"]);
|
||
const bubble = msg.querySelector(".bubble");
|
||
const settled = hydrateVerbatim(bubble, [shownBlock()], {});
|
||
return { settled, panels: placed(msg) };
|
||
})()""",
|
||
"unresolved_twin_is_not_a_placement": """(() => {
|
||
const msg = pendingWrap(["homelab/runbooks/a.md#1-2"]);
|
||
const bubble = msg.querySelector(".bubble");
|
||
hydrateVerbatim(bubble, [], {}); // the honest state, same locator
|
||
const appended = appendUnlocatedVerbatim(msg, [shownBlock()], {});
|
||
return { appended, panels: placed(msg) };
|
||
})()""",
|
||
},
|
||
tmp_dir,
|
||
)
|
||
fenced = out["fence_arrives_later"]
|
||
assert fenced["ok"] is True, fenced
|
||
assert fenced["value"] == {
|
||
"mid": 0,
|
||
"appended": 1,
|
||
"settled": 1,
|
||
"panels": [{"locator": SHOWN_BLOCK_LOCATOR, "where": "bubble"}],
|
||
}, "the fence wins: one panel, in the answer, the mid-turn twin gone"
|
||
|
||
ranges = out["two_ranges_one_file"]
|
||
assert ranges["ok"] is True, ranges
|
||
assert ranges["value"]["panels"] == [
|
||
{"locator": "homelab/runbooks/a.md#10-14", "where": "bubble"},
|
||
{"locator": SHOWN_BLOCK_LOCATOR, "where": "bubble"},
|
||
], "two ranges of one file are TWO panels — the scan is per locator"
|
||
|
||
unfenced = out["never_fenced_keeps_its_panel"]
|
||
assert unfenced["ok"] is True, unfenced
|
||
assert unfenced["value"] == {
|
||
"appended": 1,
|
||
"settled": 0,
|
||
"again": 0,
|
||
"panels": [{"locator": SHOWN_BLOCK_LOCATOR, "where": "body"}],
|
||
}, "a block no fence names keeps exactly its one answer-position panel"
|
||
|
||
repeated = out["repeated_fence_shows_the_bytes_once"]
|
||
assert repeated["ok"] is True, repeated
|
||
assert repeated["value"]["settled"] == 2
|
||
assert repeated["value"]["panels"] == [
|
||
{"locator": SHOWN_BLOCK_LOCATOR, "where": "bubble"}
|
||
], "the same fence twice shows those bytes once"
|
||
|
||
unresolved = out["unresolved_twin_is_not_a_placement"]
|
||
assert unresolved["ok"] is True, unresolved
|
||
assert unresolved["value"]["appended"] == 1, (
|
||
"an unresolved node is not a placement, so the block still lands"
|
||
)
|
||
assert unresolved["value"]["panels"] == [
|
||
{"locator": SHOWN_BLOCK_LOCATOR, "where": "body"}
|
||
]
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_the_twin_scan_bounds_to_the_message_it_hydrates(tmp_dir: Path) -> None:
|
||
"""The dedupe's BOUND, run against the real file (task 03): the scan reads
|
||
the message the hydration belongs to.
|
||
|
||
* a bare bubble with no ``.msg`` ancestor still dedupes — the ``|| root``
|
||
fallback means "no wrap" narrows the scan to the root, it never turns
|
||
the dedupe off (two panels for one locator is the bug either way);
|
||
* a second range of the same file keeps its own panel under that fallback
|
||
too (per locator, never per identity — the fallback is not a wider scan);
|
||
* a twin that belongs to ANOTHER message survives: hydrating one answer
|
||
never reaches into the next one's panels."""
|
||
out = _node_dom(
|
||
{
|
||
"bare_bubble_dedupes_too": """(() => {
|
||
const bubble = pendingBubble([]);
|
||
const appended = appendUnlocatedVerbatim(bubble, [shownBlock()], {});
|
||
bareFence(bubble, "homelab/runbooks/a.md#12-40");
|
||
const settled = hydrateVerbatim(bubble, [shownBlock()], {});
|
||
return { appended, settled, panels: placed(bubble) };
|
||
})()""",
|
||
"bare_bubble_two_ranges_stay_two": """(() => {
|
||
const other = shownBlock({ start_line: 10, end_line: 14,
|
||
locator: "homelab/runbooks/a.md#10-14" });
|
||
const bubble = pendingBubble([]);
|
||
appendUnlocatedVerbatim(bubble, [shownBlock()], {});
|
||
bareFence(bubble, "homelab/runbooks/a.md#10-14");
|
||
const settled = hydrateVerbatim(bubble, [shownBlock(), other], {});
|
||
return { settled, panels: placed(bubble) };
|
||
})()""",
|
||
"another_message_is_out_of_bounds": """(() => {
|
||
const here = pendingWrap(["homelab/runbooks/a.md#12-40"]);
|
||
const neighbour = pendingWrap([]);
|
||
appendUnlocatedVerbatim(neighbour, [shownBlock()], {});
|
||
const settled = hydrateVerbatim(
|
||
here.querySelector(".bubble"), [shownBlock()], {});
|
||
return { settled, here: placed(here), neighbour: placed(neighbour) };
|
||
})()""",
|
||
},
|
||
tmp_dir,
|
||
)
|
||
bare = out["bare_bubble_dedupes_too"]
|
||
assert bare["ok"] is True, bare
|
||
assert bare["value"] == {
|
||
"appended": 1,
|
||
"settled": 1,
|
||
"panels": [{"locator": SHOWN_BLOCK_LOCATOR, "where": "bubble"}],
|
||
}, "no .msg wrap still means ONE panel for one locator"
|
||
|
||
ranges = out["bare_bubble_two_ranges_stay_two"]
|
||
assert ranges["ok"] is True, ranges
|
||
assert ranges["value"]["settled"] == 1
|
||
assert ranges["value"]["panels"] == [
|
||
{"locator": SHOWN_BLOCK_LOCATOR, "where": "bubble"},
|
||
{"locator": "homelab/runbooks/a.md#10-14", "where": "bubble"},
|
||
], "the fallback bounds the scan — it does not widen it past the locator"
|
||
|
||
neighbour = out["another_message_is_out_of_bounds"]
|
||
assert neighbour["ok"] is True, neighbour
|
||
assert neighbour["value"]["settled"] == 1
|
||
assert neighbour["value"]["here"] == [
|
||
{"locator": SHOWN_BLOCK_LOCATOR, "where": "bubble"}
|
||
], "this answer hydrates to one panel"
|
||
assert neighbour["value"]["neighbour"] == [
|
||
{"locator": SHOWN_BLOCK_LOCATOR, "where": "body"}
|
||
], "the next answer's panel is not this scan's to remove"
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_a_stored_record_restores_one_panel_per_locator(tmp_dir: Path) -> None:
|
||
"""The restore/shared parity of the dedupe (task 03): both call sites run
|
||
the SAME two passes in the SAME order — hydrate the record's fences, then
|
||
append the blocks the answer never located. So a stored answer that repeats
|
||
one locator twice and additionally carries an unlocated block comes back as
|
||
exactly TWO panels: the locator once, IN the text, and the unlocated block
|
||
once, in the answer band. One reload, one render — the duplicate the
|
||
mid-turn frame could leave behind never reaches the record's reader."""
|
||
out = _node_dom(
|
||
{
|
||
"restore": f"""(() => {{
|
||
const msg = pendingWrap(["{SHOWN_BLOCK_LOCATOR}", "{SHOWN_BLOCK_LOCATOR}"]);
|
||
const bubble = msg.querySelector(".bubble");
|
||
const record = [
|
||
shownBlock(),
|
||
shownBlock({{
|
||
source: "homelab", path: "runbooks/c.md", title: "Runbook C",
|
||
start_line: 1, end_line: 3, total_lines: 9,
|
||
text: "c one\\nc two", locator: "homelab/runbooks/c.md#1-3",
|
||
}}),
|
||
];
|
||
const settled = hydrateVerbatim(bubble, record, {{}});
|
||
const appended = appendUnlocatedVerbatim(msg, record, {{}});
|
||
return {{ settled, appended, panels: placed(msg) }};
|
||
}})()""",
|
||
},
|
||
tmp_dir,
|
||
)
|
||
case = out["restore"]
|
||
assert case["ok"] is True, case
|
||
assert case["value"]["settled"] == 2, "every fence settles — nothing is left pending"
|
||
assert case["value"]["appended"] == 1, (
|
||
"only the block the answer never located lands in the answer band"
|
||
)
|
||
assert case["value"]["panels"] == [
|
||
{"locator": SHOWN_BLOCK_LOCATOR, "where": "bubble"},
|
||
{"locator": "homelab/runbooks/c.md#1-3", "where": "body"},
|
||
], "one locator, one panel — the same answer on every surface"
|