Files
turnstone/tests/test_app_js.py
T
Patrick Buckley 2320c6d13c refactor(ui): migrate the shared_static substrate to ES modules
utils/toast/kb/cards/auth/renderer/composer/composer_attachments/
composer_queue/status_bar convert from classic scripts (implicit globals,
IIFE wrappers) to ES modules with explicit exports. Parse-time
cross-dependencies become real imports (auth/kb/cards -> utils,
auth/cards -> toast, cards -> auth, renderer -> utils), which deletes the
implicit script-order contract those files relied on. utils stays
import-free (bottom of the graph); its two upward calls (setMarkdown ->
renderer, export -> toast/auth) late-bind through window at call time to
avoid import cycles.

Each module installs a transitional window bridge for the still-classic
bundles (console app/admin/governance, ui app, inline onclick=), which
only touch the globals at boot/event time — verified by a column-0 /
IIFE-body audit of all four consumers, and including the audit-missed
initLogin() that both app.js boot paths call. theme.js stays classic:
deferring it would flash the wrong theme before first paint. Vendored
katex/hljs/mermaid stay classic and lazily typeof-guarded.

interactive.js and shell.js drop their bare-global reads for real imports
(authFetch, showToast, Composer, StatusBar, queue/attachment controllers,
streaming renderer, setMarkdown). The three HTML entries load the
substrate as module tags (same positions, same version_html stamping);
classic admin/governance/app still parse first, modules evaluate before
shell.js calls TS_APP.boot().

Tests: auth/kb/utils move from test_app_js's classic node-check sweep to
test_shell_js's module-semantics sweep, which now covers all 15 shared
modules (sink scan excludes renderer.js, the sanctioned HTML producer;
the no-var ratchet covers the var-free subset). The const-reassign guard
re-includes the converted files plus the shell modules.
2026-06-09 13:37:35 -07:00

1522 lines
69 KiB
Python

"""Static smoke guards for ``turnstone/ui/static/app.js``.
The interactive WebUI's app.js has no JS test framework on the
project side. This file holds Python-side string-presence assertions
that catch regressions on critical paths — the kind of one-line
deletion or rename that breaks the UI silently and only surfaces in
manual testing.
"""
from __future__ import annotations
import re
import subprocess
from pathlib import Path
import pytest
_APP_JS = Path(__file__).resolve().parent.parent / "turnstone/ui/static/app.js"
_INTERACTIVE_JS = Path(__file__).resolve().parent.parent / "turnstone/shared_static/interactive.js"
def _pane_method_offset(body: str, name: str) -> int:
"""Return the start offset of class method ``name`` in ``body``.
Indent-agnostic — matches the method header at any leading-whitespace
depth (2 spaces for the current class, 4 if the class is ever
wrapped in an IIFE or module, etc.) so slice tests survive deferred
modernization without silent ``ValueError`` failures. Asserts on
miss so a refactor that renames the method fails loudly at the
pinning slice instead of further downstream.
"""
pattern = re.compile(r"^\s{2,}" + re.escape(name) + r"\(", re.MULTILINE)
m = pattern.search(body)
assert m is not None, f"class method {name!r} not found in interactive.js"
return m.start()
def test_switch_tab_opens_an_interactive_pane() -> None:
"""In the L-shell ``switchTab`` is a thin shim onto the PaneManager: it
opens/focuses the session as an interactive pane. The split-pane
``createPane`` bootstrap is retired."""
body = _APP_JS.read_text(encoding="utf-8")
start = body.index("function switchTab(wsId) {")
fn = body[start : start + 400]
assert "openSessionPane(wsId)" in fn, (
"switchTab must delegate to openSessionPane (PaneManager.openPane "
"'interactive'), not the retired createPane bootstrap."
)
assert "createPane" not in body, "the split-pane createPane bootstrap is retired."
def test_tool_error_does_not_overwrite_approval_badge() -> None:
"""When an approved tool subsequently errors, the existing
``✓ approved`` (or ``✓ auto-approved``) pill must remain visible —
the error indicator is appended as a sibling pill, not by mutating
the approval pill in place. Pre-fix, both ``appendToolOutput``
(live) and ``replayHistory`` (history reconstruction) located the
existing approval badge via ``querySelector(".ts-approval-badge")``
and overwrote its className + textContent with the ``--error``
state, so the user lost the record that they had approved the
call. This test pins the new append-sibling behaviour."""
body = _INTERACTIVE_JS.read_text(encoding="utf-8")
# Affirmatively check that an idempotency guard exists somewhere:
# a ``querySelector(".ts-approval-badge--error")`` lookup is the
# structural marker of the fix. Pre-fix the modifier never appeared
# in app.js at all. Loose on quote style and surrounding form (the
# guard might be a negated ``if (!q) {build...}`` block at a call
# site, or a positive ``if (q) return;`` early-exit inside an
# extracted helper) so a later refactor doesn't trip CI on
# cosmetics.
# 5e.2c: the resolved/error pills converged onto the shared .conv-status
# vocabulary; the error variant is .conv-status--error.
error_guard_re = re.compile(
r"""querySelector\(\s*['"]\.conv-status--error['"]\s*\)""",
)
assert error_guard_re.search(body), (
"The error-badge code path must guard creation with a "
"querySelector for .conv-status--error so duplicate fires "
"(live + history re-render) do not stack badges."
)
# Forbid the mutate-existing-badge sequence: a generic
# ``.ts-approval-badge`` lookup followed within a handful of lines
# by mutating that same handle into the ``--error`` state. Two
# unrelated call sites (history rendering + live tool-output
# insertion) legitimately query ``.ts-approval-badge`` to position
# output above it, so the bare query alone is not the anti-pattern;
# the close pairing with an ``--error`` class mutation is. Accept
# either quote style and catch both ``className = "..."`` and
# ``classList.add("ts-approval-badge--error")`` forms.
overwrite_re = re.compile(
r"""(\w+)\s*=\s*\w+\.querySelector\(\s*(["'])\.conv-status\2\s*\)\s*;"""
r""".{0,200}?"""
r"""(?:"""
r"""\1\.className\s*=\s*(["'])[^"']*\bconv-status--error\b[^"']*\3"""
r"""|"""
r"""\1\.classList\.add\([^)]*(["'])conv-status--error\4[^)]*\)"""
r""")""",
re.DOTALL,
)
assert not overwrite_re.search(body), (
"Found the badge-overwrite anti-pattern: a queried "
".conv-status handle is mutated into the --error variant "
"(via className overwrite or classList.add). Append a sibling "
"badge instead so the approval verdict stays visible alongside "
"the error."
)
def test_replay_history_renders_content_before_tool_block() -> None:
"""In ``replayHistory``'s ``role === "assistant"`` branch, the
``msg.content`` render must precede the ``msg.tool_calls`` render.
Two reasons, both load-bearing:
1. **Structural** — the next loop iteration's ``role === "tool"``
message anchors to ``lastToolBlock``. The tool-block branch sets
that anchor; the content branch clears it. If content runs after
the tool block, the clear silently drops the upcoming tool
result. Pre-fix, every interactive tool result was missing from
saved-workstream replays whenever the assistant turn carried
both narration and tool calls (very common output shape).
2. **Visual** — the live SSE path renders content first
(``stream_text`` streams before ``tool_info`` /
``approve_request``), so replay should match.
The test pins the order via the offsets of the ``msg.content`` and
``msg.tool_calls`` branch headers inside the function body."""
body = _INTERACTIVE_JS.read_text(encoding="utf-8")
start = _pane_method_offset(body, "replayHistory")
end = _pane_method_offset(body, "_attachRetryToLastAssistant")
fn = body[start:end]
# Locate the assistant branch and bound the search to its body —
# the function also handles user / tool roles which would otherwise
# confuse the offset comparison.
asst_start = fn.index('msg.role === "assistant"')
asst_end = fn.index('msg.role === "tool"', asst_start)
asst = fn[asst_start:asst_end]
# ``if (msg.content && msg.content.trim())`` guards against a
# whitespace-only content row (Qwen-style "\n\n" left over after a
# reasoning-parser model strips ``<think>…</think>`` and emits
# nothing else before the tool call). Pre-trim guard, those rows
# rendered as a visible-but-empty ``.msg.assistant`` card on
# replay. Match the substring up to ``msg.content`` so the test
# tolerates either guard shape without locking the trim() in.
content_idx = asst.index("if (msg.content")
tool_calls_idx = asst.index("if (msg.tool_calls && msg.tool_calls.length)")
assert content_idx < tool_calls_idx, (
"replayHistory must render msg.content BEFORE msg.tool_calls "
"inside the assistant branch — otherwise the lastToolBlock "
"anchor is clobbered before the next iteration's tool result "
"can attach to it (and the visual order also drifts from the "
"live SSE flow)."
)
def test_replay_history_renders_persisted_verdict_badge() -> None:
"""Saved-workstream replays must paint the persisted intent verdict
next to each tool div, using the same ``renderVerdictBadge`` helper
the live ``showInlineToolBlock`` path uses. Pre-fix the audit trail
was complete in storage (``intent_verdicts`` table) but never
surfaced on replay — operators reviewing a saved workstream
couldn't see what the heuristic / LLM judge thought of any tool
call. This test pins the call site so a refactor that drops the
decoration regresses the audit surface."""
body = _INTERACTIVE_JS.read_text(encoding="utf-8")
start = _pane_method_offset(body, "replayHistory")
end = _pane_method_offset(body, "_attachRetryToLastAssistant")
fn = body[start:end]
# Match a `renderVerdictBadge(<something>.verdict, ...)` call inside
# the replay loop. Loose on whitespace + identifier so a future
# rename of the iteration variable doesn't trip CI.
badge_call_re = re.compile(
r"buildConvVerdict\(\s*\w+\.verdict\b",
)
assert badge_call_re.search(fn), (
"replayHistory must call buildConvVerdict(tc.verdict, ...) "
"when a persisted verdict is attached to a tool_call entry — "
"otherwise the audit-trail data persisted to intent_verdicts "
"doesn't surface on saved-workstream replays."
)
def test_refetch_history_seeds_resume_cursor_only_on_initial_connect() -> None:
"""``_refetchHistory`` must seed ``_lastEventId`` from a non-null
``data.cursor`` so the initial ``connectSSE`` opens with
``?last_event_id=`` and takes the ``replay_ok`` fast-forward —
rebuilding the executing in-flight turn that ``/history`` omitted
(the fresh-connect-during-parallel-batch fix).
Two load-bearing guards are pinned here:
1. The seed is gated on ``seedCursor`` so ONLY the initial-connect
caller (``_loadHistoryThenConnect``, which reconnects) seeds it;
the clear_ui / replay_truncated re-render callers (no reconnect)
must NOT rewind ``_lastEventId`` off the live stream position.
2. ``connectSSE`` gates the ``?last_event_id=`` param on
``!= null`` (not truthiness) so a valid cursor of 0 — a brand-new
ws's first-turn boundary — isn't silently dropped to the fresh
snapshot path."""
body = _INTERACTIVE_JS.read_text(encoding="utf-8")
# ``_refetchHistory`` is an ``async`` method, which the shared
# ``_pane_method_offset`` header regex doesn't match — anchor on the
# definition directly and bound at the next method.
start = body.index("async _refetchHistory(")
end = body.index("handleEvent(", start)
fn = body[start:end]
# (1a) seed is gated on BOTH seedCursor AND a non-null cursor.
seed_re = re.compile(
r"if\s*\(\s*seedCursor\s*&&\s*data\.cursor\s*!=\s*null\s*\)\s*"
r"this\._lastEventId\s*=\s*data\.cursor"
)
assert seed_re.search(fn), (
"_refetchHistory must seed this._lastEventId only when "
"seedCursor AND data.cursor != null — so re-render callers don't "
"rewind the live stream and a 0 cursor still fast-forwards."
)
assert "seedCursor = false" in fn, (
"seedCursor must default false so the clear_ui / replay_truncated "
"re-render callers (which pass only 2 args) never seed the cursor."
)
# (1b) the initial-connect path opts in with seedCursor=true.
assert "_refetchHistory(wsId, token, true)" in body, (
"_loadHistoryThenConnect must call _refetchHistory(..., true) so "
"the reconnecting initial-connect path is the only seeder."
)
# (2) connectSSE gates the last_event_id param on != null, not truthiness.
assert re.search(
r"if\s*\(\s*this\._lastEventId\s*!=\s*null\s*\)\s*\{\s*"
r"evtUrl\s*\+=\s*\"\?last_event_id=\"",
body,
), (
"connectSSE must gate the ?last_event_id= param on "
"this._lastEventId != null (not truthiness) — else a cursor of 0 "
"(brand-new ws first turn) is dropped to the fresh snapshot path."
)
def test_shared_utils_no_longer_defines_replay_advisories_after_tool() -> None:
"""Operator context (interjections / guard findings / nudges) no longer
rides the tool envelope — it is first-class ``{"role": "system"}`` rows
— so the ``replayAdvisoriesAfterTool`` advisory-walk helper is gone.
Guard its removal so a stale re-introduction is caught.
"""
utils_js = Path(__file__).resolve().parent.parent / "turnstone/shared_static/utils.js"
body = utils_js.read_text(encoding="utf-8")
assert "replayAdvisoriesAfterTool" not in body, (
"replayAdvisoriesAfterTool should be deleted — operator context now "
"rides first-class system rows, not the tool envelope."
)
def test_replay_renders_system_turn_via_add_system_context() -> None:
"""First-class operator-context ``system`` turns (output-guard findings,
user interjections, metacognitive nudges) replay through the ``system``
branch of ``replayHistory``, rendering an operator bubble via
``addSystemContext``. Pins the call site so a refactor that drops the
branch regresses the operator-context replay shape silently."""
body = _INTERACTIVE_JS.read_text(encoding="utf-8")
start = _pane_method_offset(body, "replayHistory")
end = _pane_method_offset(body, "_attachRetryToLastAssistant")
fn = body[start:end]
assert 'msg.role === "system"' in fn, (
"replayHistory must have a system-role branch for first-class operator-context turns."
)
# Whitespace-tolerant: the call carries a 3rd ``meta`` arg now, so the
# formatter wraps it across lines — match the call + first arg, not a
# brittle contiguous substring.
assert re.search(r"addSystemContext\(\s*msg\.content", fn), (
"the system-role branch must route the turn through addSystemContext "
"so it renders as an operator bubble."
)
def test_system_turn_dedups_against_history_by_event_id() -> None:
"""The live ``system_turn`` handler skips an event already painted from
``/history`` (matched by ``_event_id``), so an SSE replay that redelivers
it past the resume cursor doesn't double-render the operator bubble —
belt-and-braces for the row-vs-event id-alignment fix."""
body = _INTERACTIVE_JS.read_text(encoding="utf-8")
assert re.search(r"_renderedSystemEventIds\s*\.\s*has\(", body), (
"the system_turn handler must skip an event whose id was already rendered from /history."
)
assert re.search(r"_renderedSystemEventIds\s*\.\s*add\(", body), (
"replayHistory (and the live handler) must record system-turn ids for the dedup set."
)
def test_retry_walk_skips_operator_context_cards() -> None:
"""Interactive twin of the coord retry-skip guard.
``_attachRetryToLastAssistant`` walks back past ``.operator-context`` rows
before testing for ``.ts-approval`` — so a watch-result / guard-finding
card (or a plain system bubble) trailing a tool-only turn doesn't make retry
attach to a stale earlier assistant turn. Pin the walk predicate (scoped to
the method) AND the shared marker on every operator row that can trail a
tool batch, so adding a card kind without the marker fails loudly here."""
body = _INTERACTIVE_JS.read_text(encoding="utf-8")
start = _pane_method_offset(body, "_attachRetryToLastAssistant")
end = _pane_method_offset(body, "announceToolBlock")
fn = body[start:end]
assert 'classList.contains("operator-context")' in fn, (
"_attachRetryToLastAssistant must walk back past .operator-context "
"rows so the tool-only retry skip fires even when a card trails."
)
# Every operator row that can trail a tool batch carries the shared marker.
# The watch-result card moved to the shared conversation.js (step 5e.1); the
# plain system-context + guard-finding cards stay in the pane.
shared = (_INTERACTIVE_JS.parent / "conversation.js").read_text(encoding="utf-8")
assert '"msg watch-result operator-context"' in shared, (
"buildWatchResultCard must carry the operator-context marker."
)
for cls in (
'"msg system-context operator-context"',
'"msg guard-finding operator-context"',
):
assert cls in body, (
f"operator row className {cls} must carry the operator-context "
"marker or the retry walk won't skip it."
)
def test_operator_nudge_labels_use_shared_helper() -> None:
"""Operator-context nudge bubbles collapse the metacognition nudge types
(start / resume / correction / denial / completion / repeat) to one
'metacognition' category via the shared ``utils.js`` ``operatorSourceLabel``
helper rather than leaking the raw ``_source`` (the 'operator · start'
regression). Both panes call the one helper so they can't drift."""
root = Path(__file__).resolve().parent.parent
utils = (root / "turnstone/shared_static/utils.js").read_text(encoding="utf-8")
assert "function operatorSourceLabel(" in utils
for t in ("start", "resume", "correction", "denial", "completion", "repeat"):
assert f'{t}: "metacognition"' in utils, f"nudge type {t!r} must label as metacognition"
assert 'tool_error: "tool error"' in utils
assert 'skill_hint: "skill hint"' in utils
app = (root / "turnstone/shared_static/interactive.js").read_text(encoding="utf-8")
coord = (root / "turnstone/console/static/coordinator/coordinator.js").read_text(
encoding="utf-8"
)
assert "operatorSourceLabel(source)" in app, "interactive pane must use the shared label helper"
assert "operatorSourceLabel(source)" in coord, "coord pane must use the shared label helper"
# ---------------------------------------------------------------------------
# Phase 8 — Chunk D: MCP error embed + settings panel UX
# ---------------------------------------------------------------------------
_INDEX_HTML = Path(__file__).resolve().parent.parent / "turnstone/ui/static/index.html"
_STYLE_CSS = Path(__file__).resolve().parent.parent / "turnstone/ui/static/style.css"
# Pins the absence of unsafe DOM-write and dynamic-code sinks. Spell
# the property/identifier names out of literal string concatenation so
# the tooling that flags occurrences in code strings doesn't
# false-positive on the test source.
#
# The pattern catches each of:
# * plain HTML-assignment — inner/outer-HTML to a value
# * concat HTML-assignment — inner/outer-HTML += value (the
# ``\+?`` makes the ``+`` optional so a regression switching the
# sink to concat-assignment doesn't bypass the lint)
# * insertAdjacent HTML — ``insertAdjacentHTML(...)`` (the
# ``HTML\(`` suffix excludes ``insertAdjacentElement``, which
# takes a DOM node and is not an XSS sink)
# * legacy doc-write — ``document`` + ``.write(...)``
# * string-to-code helpers — the JS ``ev`` + ``al`` builtin, the
# dynamic-Function constructor (``new`` + ``Function(...)``), and
# ``setTimeout``/``setInterval`` whose first arg is a string
# literal (function-first-arg forms remain unflagged)
#
# The trailing ``(?!=)`` negative-lookahead on the HTML assignments
# excludes ``===`` / ``==`` reads — only the write sinks are flagged.
#
# The scan in ``test_no_unsafe_code_sinks_in_static_assets`` runs the
# regex over the *entire file body* (not line-by-line) so that ``\s*``
# can span newlines and catch multi-line sinks like
# ``el.innerHTML\n = X``.
_UNSAFE_CODE_SINK_RE = re.compile(
r"\.(?:inner|outer)"
+ r"HTML\s*\+?=(?!=)"
+ r"|\.insertAdjacent"
+ r"HTML\s*\("
+ r"|"
+ r"document"
+ r"\."
+ r"write"
+ r"\("
+ r"|\b"
+ r"eval\s*\("
+ r"|\bnew\s+"
+ r"Function\s*\("
+ r"|\bset(?:Timeout|Interval)\s*\(\s*['\"`]"
)
def test_phase8_mcp_error_helpers_defined() -> None:
"""``tryParseMcpError`` (envelope detector) + ``buildMcpErrorEmbed``
(interactive consent / forbidden / operator card) moved into the shared
interactive module with the Pane. The consent-badge state
(``_pendingConsentServers`` / ``_onConsentDetected``) stays in the
standalone shell — it drives the settings-gear badge — and the pane reaches
it through the ``host.onConsentDetected`` seam (a no-op in the console,
which has no gear badge). Pin both halves and the seam."""
inter = _INTERACTIVE_JS.read_text(encoding="utf-8")
assert "function tryParseMcpError" in inter
assert "function buildMcpErrorEmbed" in inter
# The actionable branch surfaces consent via the THREADED callback, not a
# direct shell call — that decoupling is what lets the console no-op it.
assert "if (onConsent) onConsent(err.server)" in inter
assert "onConsentDetected(s)" in inter, (
"the pane must notify consent through host.onConsentDetected"
)
app = _APP_JS.read_text(encoding="utf-8")
assert "_pendingConsentServers" in app
assert "function _onConsentDetected" in app
assert "onConsentDetected(server)" in app, (
"STANDALONE_HOST must wire host.onConsentDetected -> _onConsentDetected"
)
def test_phase8_settings_panel_handlers_defined() -> None:
"""The settings modal exposes four entry points that the inline
``onclick`` attributes in index.html depend on. Renaming or
deleting any of them breaks the modal silently (the buttons are
still rendered but click-to-action is dead). Catch that here."""
body = _APP_JS.read_text(encoding="utf-8")
for name in [
"function openSettingsPanel",
"function closeSettingsPanel",
"function confirmRevokeMcp",
"function cancelRevokeMcp",
]:
assert name in body, f"Missing required handler: {name}"
# The connections list is fetched against the Phase-7 endpoint —
# pin the URL so a server-side rename forces an explicit UI bump.
assert "/v1/api/mcp/oauth/connections" in body, (
"Settings panel must fetch /v1/api/mcp/oauth/connections — "
"a server-side rename needs an explicit UI update."
)
def test_phase8_appendtooloutput_dispatches_mcp_error_before_renderer() -> None:
"""``appendToolOutput`` must call ``tryParseMcpError`` inside its
``isError`` branch BEFORE falling through to the plain
``renderToolOutput`` path. The ordering is what makes the
interactive consent card replace the JSON dump; reverse the calls
and the user sees the raw error envelope as text again."""
body = _INTERACTIVE_JS.read_text(encoding="utf-8")
start = _pane_method_offset(body, "appendToolOutput")
end = _pane_method_offset(body, "sendMessage")
fn = body[start:end]
parse_idx = fn.find("tryParseMcpError(")
render_idx = fn.find("renderToolOutput(")
assert parse_idx >= 0, (
"appendToolOutput must call tryParseMcpError on the error path "
"before renderToolOutput, otherwise the consent card never "
"replaces the plain JSON output."
)
assert render_idx >= 0, "renderToolOutput call must remain present"
assert parse_idx < render_idx, (
"tryParseMcpError must run BEFORE renderToolOutput so the "
"interactive card path takes precedence over plain rendering."
)
_UTILS_JS = Path(__file__).resolve().parent.parent / "turnstone/shared_static/utils.js"
_AUTH_JS = Path(__file__).resolve().parent.parent / "turnstone/shared_static/auth.js"
_KB_JS = Path(__file__).resolve().parent.parent / "turnstone/shared_static/kb.js"
_COORD_JS = (
Path(__file__).resolve().parent.parent / "turnstone/console/static/coordinator/coordinator.js"
)
_CONSOLE_ADMIN_JS = Path(__file__).resolve().parent.parent / "turnstone/console/static/admin.js"
_CONSOLE_GOVERNANCE_JS = (
Path(__file__).resolve().parent.parent / "turnstone/console/static/governance.js"
)
_CONSOLE_INTERACTIVE_JS = Path(__file__).resolve().parent.parent / "turnstone/console/static/app.js"
_UNSAFE_CODE_SINK_LINT_TARGETS = [
("turnstone/ui/static/app.js", _INTERACTIVE_JS),
("turnstone/shared_static/utils.js", _UTILS_JS),
("turnstone/shared_static/auth.js", _AUTH_JS),
("turnstone/shared_static/kb.js", _KB_JS),
("turnstone/console/static/coordinator/coordinator.js", _COORD_JS),
("turnstone/console/static/admin.js", _CONSOLE_ADMIN_JS),
("turnstone/console/static/governance.js", _CONSOLE_GOVERNANCE_JS),
("turnstone/console/static/app.js", _CONSOLE_INTERACTIVE_JS),
]
@pytest.mark.parametrize(
"label,path",
_UNSAFE_CODE_SINK_LINT_TARGETS,
ids=[label for label, _ in _UNSAFE_CODE_SINK_LINT_TARGETS],
)
def test_no_unsafe_code_sinks_in_static_assets(label: str, path: Path) -> None:
"""Whole-file pin: no direct DOM-write *or* dynamic-code sinks
in any of the static JS bundles that render LLM output, tool
results, operator-supplied data, or user input. Covers
inner/outer-HTML assignment (plain and concat), insertAdjacentHTML,
legacy doc-write, string-eval, dynamic-Function constructor, and
string-first-arg timer scheduling.
Two distinct cleanup postures across the targets:
1. **Strict DOM-construction** (``ui/static/app.js``,
``shared_static/utils.js``, ``shared_static/auth.js``,
``shared_static/kb.js``, ``coordinator.js`` chat entry,
``console/static/app.js``): renderer output routes through
``setMarkdown`` (or ``setSafeHtml`` for pre-baked HTML strings);
every other site uses ``createElement`` + ``textContent`` +
``append`` / ``replaceChildren``. Missing escapes are
structurally impossible — no HTML string is ever interpolated.
2. **Sink-free string-concat** (``console/static/admin.js``,
``console/static/governance.js``): operator-facing admin /
governance pages still build HTML via ``escapeHtml`` + string
concat, but the unsafe sink is off the call site (everything
routes through ``setSafeHtml``). XSS defence still depends on
every interpolated value going through escapeHtml; the lint
catches the sink but cannot catch a missing escape.
All admin-side bundles are now covered.
The regex covers inner/outer-HTML assignment (plain and
concat-assignment), ``insertAdjacentHTML``, legacy doc-write, and
the dynamic-code constructors (string-eval, dynamic-Function,
string-first-arg timer scheduling). ``insertAdjacentElement`` is
intentionally not flagged — it takes a DOM node, not a string.
Parametrized so each target is its own pytest case — a failure on
one file is attributed precisely without masking offenders in the
others.
Scans the whole file body (not line-by-line) so the regex's
``\\s*`` can span newlines and catch multi-line sinks like
``el.innerHTML\\n = X``. Match positions map back to line
numbers for the failure message."""
body = path.read_text(encoding="utf-8")
lines = body.splitlines()
offenders: list[tuple[int, str]] = []
for m in _UNSAFE_CODE_SINK_RE.finditer(body):
line_no = body.count("\n", 0, m.start()) + 1
offenders.append((line_no, lines[line_no - 1].rstrip()))
assert not offenders, (
f"Found {len(offenders)} unsafe code/DOM sink(s) in "
f"{label}:\n"
+ "\n".join(f" line {n}: {line}" for n, line in offenders[:10])
+ "\nUse DOM construction (createElement + textContent + "
"append/replaceChildren) or route renderer output through "
"setMarkdown() / setSafeHtml() in shared/utils.js."
)
def test_shared_utils_defines_set_markdown_helper() -> None:
"""The ``setMarkdown`` helper in ``shared/utils.js`` is the single
audited entry point for rendering markdown content into a DOM
element from ``app.js``. It parses ``renderMarkdown``'s output via
``DOMParser`` (avoiding the unsafe sink entirely) and runs
``postRenderMarkdown`` on the result. A refactor that drops or
renames it would break the two interactive call sites silently at
runtime."""
body = _UTILS_JS.read_text(encoding="utf-8")
assert "function setMarkdown(el, content)" in body, (
"shared/utils.js must define setMarkdown(el, content) — "
"app.js routes both renderer-output sites through this helper."
)
# The DOMParser path is what avoids the unsafe sink. The absence
# of the unsafe assignment inside the helper is pinned by the
# broader ``test_no_unsafe_code_sinks_in_static_assets`` scan
# above; pin DOMParser presence here too so a refactor that swaps
# to e.g. ``Range.createContextualFragment`` forces an explicit
# reviewer decision.
assert "DOMParser()" in body, (
"setMarkdown must parse via DOMParser, not the unsafe DOM-write "
"sink — that is what keeps the audit surface at one location."
)
def test_phase8_no_unsafe_dom_write_in_settings_panel() -> None:
"""Defensive XSS guard: the settings panel renders user-controlled
server names, scope strings, and timestamp values into the DOM.
The whole section MUST go through ``textContent``-style APIs; an
unsafe-DOM-write assignment would be a regression vector. Bound
the check to the section 15 body to avoid false positives
elsewhere."""
body = _APP_JS.read_text(encoding="utf-8")
start = body.index("// 15. MCP server connections settings panel")
# Bound to the full settings section (terminates at the next
# top-level keydown handler block).
end = body.index('document.addEventListener("keydown"', start)
section = body[start:end]
assert not _UNSAFE_CODE_SINK_RE.search(section), (
"Section 15 must not assign to the unsafe DOM-write property — "
"server names and scope values flow through here and would be "
"XSS-injectable. Use textContent / DOM APIs instead."
)
def test_gear_retired_mcp_in_manage_pane() -> None:
"""Step 6: the floating settings gear is retired — no #settings-btn, no
toggle/open/close gear handlers. MCP server connections moved into the
Admin pane's Connections panel (#view-admin), reached via the rail's
Manage > Connections row (the TS_ADMIN seam)."""
index = _INDEX_HTML.read_text(encoding="utf-8")
app = _APP_JS.read_text(encoding="utf-8")
assert 'id="settings-btn"' not in index, "the floating settings gear is retired."
assert "toggleSettingsMenu" not in app, "the gear dropdown handlers are retired."
assert 'id="view-admin"' in index and 'id="settings-mcp-table"' in index, (
"MCP connections render into the Admin pane's #view-admin panel."
)
assert "window.TS_ADMIN.openTab = function" in app and '"connections"' in app, (
"the Manage > Connections row opens the MCP panel via the TS_ADMIN seam."
)
def test_dashboard_is_the_main_pane_body() -> None:
"""In the L-shell the dashboard is the Dashboard pane's body (#main) — the
shell adopts #main — not a floating overlay. It holds the launcher + the
workstreams table and is not a modal."""
body = _INDEX_HTML.read_text(encoding="utf-8")
assert 'id="main"' in body, "the dashboard content lives in #main (the Dashboard pane body)."
start = body.index('id="main"')
chunk = body[start : start + 4000]
assert 'id="dashboard-input"' in chunk and 'id="dash-ws-table"' in chunk, (
"#main must hold the new-session launcher + the workstreams table."
)
assert 'class="dashboard-overlay"' not in body, "the fixed dashboard overlay is retired."
def test_mcp_connections_panel_and_revoke_modal_in_index_html() -> None:
"""MCP connections moved from the floating #settings-overlay into the Admin
pane's Connections panel (#view-admin), reusing the same #settings-mcp-*
table ids so the render code is unchanged. The revoke modal stays."""
body = _INDEX_HTML.read_text(encoding="utf-8")
assert 'id="settings-overlay"' not in body, "the floating MCP settings overlay is retired."
assert 'id="view-admin"' in body, "the Admin pane host (#view-admin) must exist."
va = body.index('id="view-admin"')
panel = body[va : va + 1500]
assert 'id="settings-mcp-table"' in panel and 'id="settings-mcp-tbody"' in panel, (
"the MCP table (reused ids) must live inside #view-admin."
)
idx = body.index('id="revoke-mcp-overlay"')
chunk = body[idx : idx + 600]
assert 'role="dialog"' in chunk and 'aria-modal="true"' in chunk, (
"revoke-mcp-overlay stays a modal dialog."
)
def test_phase8_xss_safe_render_in_build_mcp_error_embed() -> None:
"""Adversarial input — the renderer for an MCP error envelope
must use ``textContent`` (not the unsafe DOM-write API) for every
field that flows from the server: ``err.detail``, ``err.server``,
scopes list. The card builder uses createElement + textContent
throughout so a script-tag server name renders harmlessly. Pin
the absence of the unsafe-write inside ``buildMcpErrorEmbed``."""
body = _INTERACTIVE_JS.read_text(encoding="utf-8")
start = body.index("function buildMcpErrorEmbed(")
# Bound to the function body — find its closing brace at column 0.
rest = body[start:]
# Closing function brace at line start (matches existing functions)
end_match = re.search(r"\n}\n", rest)
assert end_match is not None
fn = rest[: end_match.end()]
assert not _UNSAFE_CODE_SINK_RE.search(fn), (
"buildMcpErrorEmbed must not use the unsafe-DOM-write API — "
"server names and detail strings flow through here. An "
"adversarial server name must render harmlessly via "
"textContent."
)
def test_phase8_css_classes_present_in_stylesheet() -> None:
"""The MCP error-embed + connections classes app.js/interactive.js reference
must keep their CSS rules (else the consent / connections UX silently loses
its visual treatment). The settings OVERLAY is retired in step 6 — MCP
connections render in the Admin pane's Connections panel (#view-admin), not a
floating dialog — so #settings-overlay / #settings-box are no longer pinned."""
css = _STYLE_CSS.read_text(encoding="utf-8")
for selector in [
".mcp-error-card",
".mcp-error-icon",
".mcp-error-action-btn",
".mcp-scope-pill",
".settings-revoke-btn",
".settings-consent-badge",
"#revoke-mcp-overlay",
]:
assert selector in css, f"Missing CSS rule for {selector}"
def test_phase8_consent_url_prefix_check_in_click_handler() -> None:
"""Defence-in-depth: the consent button's click handler must reject
any ``consent_url`` that doesn't start with the dispatcher's known
prefix (``/v1/api/mcp/oauth/start``). ``_build_consent_url`` always
emits a path-relative URL with that exact prefix; a non-prefix
value implies the producer drifted (or was compromised) and a
``window.open("javascript:...")`` would be catastrophic.
The renderer is the last line of defence before ``window.open`` and
must not rely on the producer-side guarantee alone. Pin the prefix
string and the ``startsWith`` form so a future refactor can't
silently weaken the guard.
"""
body = _INTERACTIVE_JS.read_text(encoding="utf-8")
# Bound the search to the click handler region (between the
# ``buildMcpErrorEmbed`` function and the next top-level helper) to
# avoid false positives from unrelated string occurrences.
start = body.index("function buildMcpErrorEmbed(")
end = body.index("\n}\n", start) + 1
fn = body[start:end]
assert 'consentUrl.startsWith("/v1/api/mcp/oauth/start")' in fn, (
"Click handler must guard window.open with "
'consentUrl.startsWith("/v1/api/mcp/oauth/start"). Without it '
"a future producer drift to a non-path-relative URL (or a "
'"javascript:" injection) would be passed straight to '
"window.open."
)
# ---------------------------------------------------------------------------
# Post-var-sweep invariants — added by chore/interactive-var-sweep
# ---------------------------------------------------------------------------
#
# After the whole-file var → const/let sweep across these 7 bundles, three
# guards keep the post-sweep state honest:
# 1. ``node --check`` per bundle catches parse-level regressions on any
# future edit (mis-balanced braces, stray tokens) before they reach
# the browser.
# 2. A var-free static assertion pins the keyword sweep — any future
# ``var`` declaration in these bundles fails CI loudly.
# 3. A static const-reassign guard catches the specific bug class that
# shipped through the original sweep (``const X = …; … X = …``
# throws ``TypeError`` only at call-time, which ``node --check``
# does not surface). This is the same paren/string/regex-aware
# reassignment check the sweep walker uses.
#
# A fourth guard runs ``_redactApiKeys`` via ``node -e`` as a runtime
# smoke; the function is pure (no DOM dependency) so it transplants
# cleanly into a standalone node invocation.
def _slice_balanced_body(body: str, anchor: int) -> str | None:
"""Slice ``body`` from ``anchor`` (which must point at or just before
the opening ``{`` of a block) up to and including the matching ``}``.
Tracks brace depth + string state so the slice is robust to comment
growth and arbitrary body reorganisation. Returns ``None`` if the
matching brace isn't found within a reasonable window.
Used to slice JS handler / function bodies for static assertions
without committing to a fixed character window."""
n = len(body)
i = body.find("{", anchor)
if i == -1 or i - anchor > 200:
return None
depth = 0
in_str: str | None = None
start = i
while i < n and i - start < 8000:
ch = body[i]
if in_str:
if ch == "\\" and i + 1 < n:
i += 2
continue
if ch == in_str:
in_str = None
i += 1
continue
if ch in ('"', "'", "`"):
in_str = ch
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return body[start : i + 1]
i += 1
return None
def _slice_listener_body(body: str, event_name: str) -> str | None:
"""Return the handler-function body registered via
``addEventListener("<event_name>", function ...)``, sliced by
matching braces (robust to comment / formatting growth)."""
anchor = body.find(f'addEventListener("{event_name}"')
if anchor == -1:
return None
return _slice_balanced_body(body, anchor)
def _slice_function_body(body: str, fn_name: str) -> str | None:
"""Return the body of ``function <fn_name>(...) { ... }`` sliced by
matching braces."""
m = re.search(r"function\s+" + re.escape(fn_name) + r"\s*\(", body)
if m is None:
return None
return _slice_balanced_body(body, m.start())
_REPO_ROOT = Path(__file__).resolve().parent.parent
# CLASSIC bundles that completed the var → const/let sweep. Add a new JS
# file here only after it has itself been swept — the var-free +
# const-reassign guards below will otherwise fail loudly on any pre-sweep
# `var` it contains. coordinator.js is intentionally excluded (already
# modern; 3 surviving `var` are by design per the sweep briefing). The
# shared_static files that used to sit here (auth/kb/utils) are ES modules
# now — test_shell_js.py sweeps them with module semantics.
_SWEPT_BUNDLES = [
_REPO_ROOT / "turnstone/ui/static/app.js",
_REPO_ROOT / "turnstone/console/static/admin.js",
_REPO_ROOT / "turnstone/console/static/governance.js",
_REPO_ROOT / "turnstone/console/static/app.js",
]
# The const-reassign analysis below is pure text — module vs script semantics
# is irrelevant — so the var-free ES modules ride the same guard (their parse
# + var + sink guards live in test_shell_js.py).
_CONST_GUARD_BUNDLES = _SWEPT_BUNDLES + [
_REPO_ROOT / "turnstone/shared_static/auth.js",
_REPO_ROOT / "turnstone/shared_static/kb.js",
_REPO_ROOT / "turnstone/shared_static/utils.js",
_REPO_ROOT / "turnstone/shared_static/toast.js",
_REPO_ROOT / "turnstone/shared_static/shell.js",
_REPO_ROOT / "turnstone/shared_static/pane.js",
_REPO_ROOT / "turnstone/shared_static/rail.js",
_REPO_ROOT / "turnstone/shared_static/interactive.js",
_REPO_ROOT / "turnstone/shared_static/conversation.js",
]
@pytest.mark.parametrize("bundle", _SWEPT_BUNDLES, ids=lambda p: p.name)
def test_swept_bundle_parses(bundle: Path) -> None:
"""``node --check`` each swept bundle. Catches syntax-level
regressions (a future edit that drops a brace, mis-balances a
string, etc.) before they reach the browser. Skipped silently if
``node`` is not on PATH so local dev without Node still passes."""
node = "node"
try:
proc = subprocess.run(
[node, "--check", str(bundle)],
capture_output=True,
text=True,
timeout=15,
)
except FileNotFoundError:
pytest.skip("node binary not available on PATH")
assert proc.returncode == 0, f"node --check failed for {bundle.name}:\n{proc.stderr}"
@pytest.mark.parametrize("bundle", _SWEPT_BUNDLES, ids=lambda p: p.name)
def test_swept_bundle_has_no_var_decl(bundle: Path) -> None:
"""Pin the var-free post-sweep state across all 7 bundles. A
future ``var`` declaration here fails CI loudly so the sweep
doesn't regress in patches."""
body = bundle.read_text(encoding="utf-8")
# Line-start ``var`` declarations.
line_start = re.findall(r"^\s*var\s+\w", body, re.MULTILINE)
# ``for (var i …)`` counters anywhere on a line.
for_init = re.findall(r"\bfor\s*\(\s*var\s+", body)
stray = line_start + for_init
assert not stray, (
f"{bundle.name}: {len(stray)} stray ``var`` declarations found "
f"after the var-sweep — the post-sweep invariant is broken. "
f"Convert to ``const``/``let``."
)
def _strip_strings_and_line_comments(line: str) -> str:
"""Return ``line`` with string-literal contents and ``// …`` tails
removed, so simple regex-based scanning can't be tricked by an
identifier embedded in a CSS class name or HTML attribute.
Mirrors the sweep walker's helper of the same purpose."""
out: list[str] = []
i = 0
n = len(line)
in_str: str | None = None
while i < n:
ch = line[i]
if in_str:
if ch == "\\" and i + 1 < n:
i += 2
continue
if ch == in_str:
in_str = None
i += 1
continue
if ch in ('"', "'", "`"):
in_str = ch
i += 1
continue
if ch == "/" and i + 1 < n and line[i + 1] == "/":
break
out.append(ch)
i += 1
return "".join(out)
_REGEX_OK_KEYWORDS = frozenset(
{
"return",
"throw",
"typeof",
"instanceof",
"in",
"of",
"new",
"delete",
"void",
"do",
"yield",
"await",
"case",
"else",
}
)
def _is_regex_context_at(text: str, slash_pos: int) -> bool:
"""``text[slash_pos]`` is ``/``. Return ``True`` if it starts a regex
literal vs the division operator, by inspecting the previous significant
char (skipping whitespace and ``/* */`` block comments going backward)."""
i = slash_pos - 1
while i >= 0:
ch = text[i]
if ch.isspace():
i -= 1
continue
if ch == "/" and i >= 1 and text[i - 1] == "*":
open_i = text.rfind("/*", 0, i - 1)
if open_i == -1:
return True
i = open_i - 1
continue
if ch.isalnum() or ch in "_$":
k = i
while k >= 0 and (text[k].isalnum() or text[k] in "_$"):
k -= 1
ident = text[k + 1 : i + 1]
return ident in _REGEX_OK_KEYWORDS
return ch not in ")]"
return True
def _consume_regex_at(text: str, start: int) -> tuple[int, bool]:
"""Consume regex literal starting at ``text[start] == '/'``. Returns
``(end_pos, ok)``. Handles backslash escapes and ``[...]`` char classes
(a ``/`` inside a class doesn't end the regex)."""
n = len(text)
i = start + 1
in_class = False
while i < n:
ch = text[i]
if ch == "\n":
return start, False
if ch == "\\" and i + 1 < n:
i += 2
continue
if ch == "[":
in_class = True
elif ch == "]":
in_class = False
elif ch == "/" and not in_class:
i += 1
while i < n and text[i] in "gimsuyd":
i += 1
return i, True
i += 1
return start, False
def _build_brace_map(
text: str,
) -> tuple[dict[int, int], list[int]]:
"""Walk ``text`` once. Returns ``(open_to_close, line_starts)`` where
``open_to_close[open_off] = close_off`` for matched braces, and
``line_starts[i]`` is the char offset where line index ``i`` (0-based)
begins. Robust to JS regex literals, strings, ``//`` and ``/* */``
comments."""
n = len(text)
line_starts = [0]
for i, ch in enumerate(text):
if ch == "\n":
line_starts.append(i + 1)
stack: list[int] = []
open_to_close: dict[int, int] = {}
in_str: str | None = None
in_comment: str | None = None
i = 0
while i < n:
ch = text[i]
if in_comment == "//":
if ch == "\n":
in_comment = None
i += 1
continue
if in_comment == "/*":
if ch == "*" and i + 1 < n and text[i + 1] == "/":
in_comment = None
i += 2
continue
i += 1
continue
if in_str:
if ch == "\\" and i + 1 < n:
i += 2
continue
if ch == in_str:
in_str = None
i += 1
continue
if ch in ('"', "'", "`"):
in_str = ch
i += 1
continue
if ch == "/" and i + 1 < n:
if text[i + 1] == "/":
in_comment = "//"
i += 2
continue
if text[i + 1] == "*":
in_comment = "/*"
i += 2
continue
if _is_regex_context_at(text, i):
end, ok = _consume_regex_at(text, i)
if ok:
i = end
continue
if ch == "{":
stack.append(i)
elif ch == "}" and stack:
open_to_close[stack.pop()] = i
i += 1
return open_to_close, line_starts
def _offset_to_line(line_starts: list[int], off: int) -> int:
lo, hi = 0, len(line_starts)
while lo + 1 < hi:
mid = (lo + hi) // 2
if line_starts[mid] <= off:
lo = mid
else:
hi = mid
return lo
def _enclosing_block(
decl_offset: int,
open_to_close: dict[int, int],
line_starts: list[int],
total_lines: int,
) -> tuple[int, int]:
"""Innermost block containing ``decl_offset``. ``(start_line, end_line)``
inclusive. Returns ``(0, total_lines - 1)`` when at top-level."""
candidates = [(op, cl) for op, cl in open_to_close.items() if op < decl_offset < cl]
if not candidates:
return 0, total_lines - 1
op, cl = max(candidates, key=lambda x: x[0])
return (
_offset_to_line(line_starts, op),
_offset_to_line(line_starts, cl),
)
@pytest.mark.parametrize("bundle", _CONST_GUARD_BUNDLES, ids=lambda p: p.name)
def test_swept_bundle_has_no_const_reassign(bundle: Path) -> None:
"""For each ``const X = …`` declaration, fail if X is reassigned
*within the same block scope* (``X = …``, ``X +=``, ``X++``, ``++X``,
etc., with lookbehind to skip ``obj.X = …`` property writes). Block
scope is found by brace-tracking with regex/string/comment awareness,
so a same-named ``let X`` in an unrelated function doesn't
false-positive against a ``const X`` in this one. Caught the
original ``_redactApiKeys`` shipped bug (postfix ``redacted = …``)
and a sibling ``++_paneCounter`` prefix-increment that the first
iteration of this guard missed — both were ``TypeError`` at
call-time, invisible to ``node --check`` and to whole-file
keyword scans."""
body = bundle.read_text(encoding="utf-8")
lines = body.splitlines()
open_to_close, line_starts = _build_brace_map(body)
const_decl = re.compile(r"^(\s*)const\s+(\w+)\b")
bugs: list[tuple[int, str, int, str, str]] = []
for idx, line in enumerate(lines):
m = const_decl.match(line)
if not m:
continue
name = m.group(2)
decl_offset = line_starts[idx] + len(m.group(1))
start_line, end_line = _enclosing_block(decl_offset, open_to_close, line_starts, len(lines))
# Reassignment forms: postfix `X++`/`X--`, prefix `++X`/`--X`,
# compound `X +=`/`X -=`/.../`X ??=`, plain `X =` (not ==/===).
# Negative lookbehind skips property writes (`obj.X = …`).
pat = re.compile(
r"(?:"
r"(?<![A-Za-z0-9_$])(?:\+\+|--)" # prefix `++X` / `--X`
+ re.escape(name)
+ r"(?![A-Za-z0-9_$])"
+ r"|"
r"(?<![A-Za-z0-9_$.])"
+ re.escape(name)
+ r"\s*(?:\+\+|--|" # postfix `X++` / `X--`
+ r"(?:\+|-|\*\*?|/|%|&&?|\|\|?|\^|<<|>>>?|\?\?)=|" # compound
+ r"=(?!=))" # plain `X =`
+ r")"
)
decl_other = re.compile(
r"(?:^\s*(?:let|const|var)\s+|\bfor\s*\(\s*(?:let|const|var)\s+)"
+ re.escape(name)
+ r"\b"
)
param = re.compile(r"\((?:[^()]*?,\s*)?" + re.escape(name) + r"\s*[,)]")
for j in range(start_line, end_line + 1):
if j == idx:
continue
stripped = _strip_strings_and_line_comments(lines[j])
if not pat.search(stripped):
continue
if decl_other.search(stripped):
continue
if param.search(stripped):
cleaned = param.sub("(", stripped)
if not pat.search(cleaned):
continue
bugs.append((idx + 1, name, j + 1, lines[idx].strip(), lines[j].strip()))
break
if bugs:
detail = "\n".join(
f" {bundle.name}:{decl_ln} const {name} reassigned at "
f"{bundle.name}:{reass_ln}\n decl: {decl_text}\n reass: {reass_text}"
for decl_ln, name, reass_ln, decl_text, reass_text in bugs[:3]
)
suffix = f"\n ... and {len(bugs) - 3} more" if len(bugs) > 3 else ""
raise AssertionError(
f"const declaration(s) reassigned within block scope. "
f"Change to `let` or eliminate the reassignment:\n{detail}{suffix}"
)
def test_redact_api_keys_runtime_smoke() -> None:
"""Runtime smoke for ``_redactApiKeys``. The function is pure — no
DOM dependency — so it transplants cleanly into a standalone
``node -e`` invocation. This is the bit that would have caught
the original ``const redacted`` bug (which ``node --check`` and a
pure-static keyword scan both miss; the ``TypeError`` only fires
at call-time)."""
body = _INTERACTIVE_JS.read_text(encoding="utf-8")
m = re.search(
r"function _redactApiKeys\(text\) \{.*?\n\}\n",
body,
re.DOTALL,
)
assert m is not None, "_redactApiKeys not found in app.js"
fn = m.group(0)
script = (
fn
+ "\nconst q = _redactApiKeys('https://x?api_key=abc&u=foo');\n"
+ 'if (q !== "https://x?api_key=***&u=foo") '
+ "throw new Error('query-string redact failed: ' + q);\n"
+ 'const j = _redactApiKeys(\'{"api_key":"abc"}\');\n'
+ 'if (j !== \'{"api_key":"***"}\') '
+ "throw new Error('json-style redact failed: ' + j);\n"
)
try:
proc = subprocess.run(
["node", "-e", script],
capture_output=True,
text=True,
timeout=15,
)
except FileNotFoundError:
pytest.skip("node binary not available on PATH")
assert proc.returncode == 0, (
f"_redactApiKeys runtime smoke failed. stdout={proc.stdout!r} stderr={proc.stderr!r}"
)
def test_beforeunload_closes_global_sse() -> None:
"""The ``beforeunload`` handler closes ``globalEvtSource`` before navigation.
In the L-shell the per-pane streams are owned by PaneManager/interactive.js,
so this handler only owns the global Tier-1 stream."""
body = _APP_JS.read_text(encoding="utf-8")
handler = _slice_listener_body(body, "beforeunload")
assert handler is not None, "beforeunload handler missing."
assert "globalEvtSource" in handler and ".close()" in handler, (
"beforeunload must close the global Tier-1 stream."
)
def test_dead_sse_defensive_reconnect_registered() -> None:
"""visibilitychange + focus listeners re-open the global Tier-1 stream if it
was closed (e.g. a cancelled navigation). In the L-shell per-pane streams
are PaneManager's, so the helper only revives the global SSE."""
body = _APP_JS.read_text(encoding="utf-8")
assert 'addEventListener("visibilitychange"' in body
assert 'addEventListener("focus"' in body
helper_body = _slice_function_body(body, "_reconnectDeadSSEs")
assert helper_body is not None, "_reconnectDeadSSEs helper missing."
assert "EventSource" in helper_body and "connectGlobalSSE()" in helper_body, (
"_reconnectDeadSSEs must revive the global SSE when closed."
)
# ---------------------------------------------------------------------------
# PR-D reconnect-with-replay: onerror must preserve native EventSource
# auto-reconnect for transient errors
# ---------------------------------------------------------------------------
#
# PR-D adds a server-side per-ws ring buffer + ``Last-Event-ID`` replay so
# a brief disconnect transparently replays the missed events. That whole
# foundation is defeated if the browser's ``onerror`` handler explicitly
# closes the EventSource on a transient network error — closing forces a
# CONNECTING -> CLOSED state transition that prevents native auto-reconnect
# from firing. The post-PR-D contract is: never call ``.close()`` on a
# transient error; let native reconnect run with the ``Last-Event-ID``
# header. Explicit closes survive only on terminal branches (401 expired
# session, workstream-reassignment to a different ws). These guards pin
# the contract so a future refactor can't silently regress it.
def _strip_js_comments(src: str) -> str:
"""Strip ``//`` and ``/* */`` comments while preserving string
literal contents (``"..."``, ``'...'``, `` `...` ``) and keeping
byte length identical (comments replaced with spaces).
Limitation — does NOT detect regex literals (``/pattern/flags``).
A ``//`` inside a regex like ``/abc//`` would be misread as the
start of a line comment. Safe today because the regions we scan
(SSE-handler ``onerror`` bodies, ``connectSSE`` /
``connectGlobalSSE`` function bodies) don't contain regex
literals; if a future caller wants to scan a region with regex
literals, extend the tracker first.
Motivation: ``_slice_balanced_body`` doesn't skip comments, so an
apostrophe inside a comment (``can't``, ``don't``) opens a fake
string state that swallows braces until the next ``'``. The new
onerror handlers carry these comments routinely; stripping
comments before brace-walking removes the hazard without
re-architecting the existing slice helper.
"""
out: list[str] = []
n = len(src)
i = 0
in_str: str | None = None
while i < n:
ch = src[i]
if in_str:
out.append(ch)
if ch == "\\" and i + 1 < n:
out.append(src[i + 1])
i += 2
continue
if ch == in_str:
in_str = None
i += 1
continue
# Line comment: replace with spaces up to newline (preserve
# length so downstream offset math still works).
if ch == "/" and i + 1 < n and src[i + 1] == "/":
j = src.find("\n", i)
if j == -1:
j = n
out.append(" " * (j - i))
i = j
continue
# Block comment: replace with spaces up to closing */.
if ch == "/" and i + 1 < n and src[i + 1] == "*":
j = src.find("*/", i + 2)
if j == -1:
out.append(" " * (n - i))
i = n
continue
out.append(" " * (j + 2 - i))
i = j + 2
continue
if ch in ('"', "'", "`"):
in_str = ch
out.append(ch)
i += 1
return "".join(out)
def _onerror_block(body: str, anchor_substring: str) -> str | None:
"""Slice an ``X.onerror = ...`` handler body by matching braces.
``anchor_substring`` is something that uniquely identifies the
enclosing function so we don't accidentally pick the wrong
``.onerror = function ...`` (the file has several). Returns the
body between the matching braces, or ``None`` if not found.
Strips comments first so apostrophes in comment prose can't
desync the brace walker.
"""
stripped = _strip_js_comments(body)
anchor = stripped.find(anchor_substring)
if anchor == -1:
return None
onerror = stripped.find(".onerror", anchor)
if onerror == -1:
return None
return _slice_balanced_body(stripped, onerror)
def _onerror_preserves_native_reconnect(body: str, source_var: str) -> tuple[bool, str]:
"""Return (passed, reason).
``source_var`` is the EventSource handle (e.g. ``this.evtSource``,
``globalEvtSource``, ``evtSource``). An onerror handler passes if:
1. Either it never calls ``source_var.close()`` directly OR every
such close is inside a 401-detection branch / login-overlay
early-return / wsId-reassignment branch (allowed terminal
exits).
2. OR the handler explicitly references ``last_event_id`` —
escape hatch for a future redesign that abandons native
reconnect entirely but takes explicit responsibility for the
replay header.
"""
# If the body threads last_event_id, the implementer has taken
# explicit responsibility for the replay header — escape hatch.
if "last_event_id" in body or "lastEventId" in body:
# Caller still has to ensure the body doesn't ALSO have a
# naked close() outside a terminal branch; rely on the regex
# search below as well.
pass
# Walk lines, track depth of common terminal branches. Simple
# heuristic: any ``source_var.close()`` line that isn't preceded by
# ``status === 401`` or ``loginOverlay`` or ``disconnectSSE()`` in
# the surrounding line window is a defect.
pattern = re.compile(
re.escape(source_var) + r"\.close\(\s*\)",
)
matches = list(pattern.finditer(body))
if not matches:
return True, "no close() calls — native reconnect preserved"
for m in matches:
start = m.start()
# Look back ~400 chars for a terminal-branch marker on the
# same conditional path. ``r.status === 401`` is the canonical
# 401-detection guard; ``loginOverlay`` is the login-modal
# early-return; ``disconnectSSE()`` immediately followed by
# setting a new wsId is the reassignment path.
window = body[max(0, start - 400) : start]
is_401_branch = "status === 401" in window or "r.status === 401" in window
is_login_branch = "loginOverlay" in window
is_reassign_branch = "disconnectSSE()" in window
if not (is_401_branch or is_login_branch or is_reassign_branch):
snippet = body[max(0, start - 80) : min(len(body), start + 80)]
return False, (
f"naked {source_var}.close() at offset {start} — would "
f"defeat native auto-reconnect for transient errors. "
f"Context: ...{snippet}..."
)
return True, "all close() calls are in terminal branches (401 / login / reassign)"
def test_pane_connectsse_onerror_preserves_native_reconnect() -> None:
"""``Pane.connectSSE``'s onerror must not close evtSource on
transient errors — PR-D's reconnect-with-replay depends on native
EventSource auto-reconnect firing with the ``Last-Event-ID`` header."""
body = _strip_js_comments(_INTERACTIVE_JS.read_text(encoding="utf-8"))
# Slice the Pane.connectSSE method body, then the onerror handler
# inside it. Reuse the indent-agnostic class-method finder.
method_start = _pane_method_offset(body, "connectSSE")
method = _slice_balanced_body(body, method_start)
assert method is not None, "Pane.connectSSE method body not found"
# ``_onerror_block`` re-strips internally; passing the already-
# stripped method body is idempotent (no comments left to strip).
onerror = _onerror_block(method, "this.evtSource.onerror")
assert onerror is not None, "Pane.connectSSE.onerror not found"
passed, reason = _onerror_preserves_native_reconnect(onerror, "this.evtSource")
assert passed, f"Pane.connectSSE.onerror regressed: {reason}"
def test_connectglobalsse_onerror_preserves_native_reconnect() -> None:
"""``connectGlobalSSE`` is the global-SSE counterpart of
Pane.connectSSE — same close-defeats-reconnect contract."""
body = _strip_js_comments(_APP_JS.read_text(encoding="utf-8"))
fn = _slice_function_body(body, "connectGlobalSSE")
assert fn is not None, "connectGlobalSSE not found"
onerror = _onerror_block(fn, "globalEvtSource.onerror")
assert onerror is not None, "globalEvtSource.onerror not found"
passed, reason = _onerror_preserves_native_reconnect(onerror, "globalEvtSource")
assert passed, f"connectGlobalSSE.onerror regressed: {reason}"
def test_coord_connectsse_onerror_preserves_native_reconnect() -> None:
"""Coordinator's connectSSE has the same contract — without the
guard the coord's per-ws SSE silently drops events on any blip."""
coord_js = _REPO_ROOT / "turnstone/console/static/coordinator/coordinator.js"
body = _strip_js_comments(coord_js.read_text(encoding="utf-8"))
onerror = _onerror_block(body, "evtSource.onerror")
assert onerror is not None, "coordinator.js evtSource.onerror not found"
passed, reason = _onerror_preserves_native_reconnect(onerror, "evtSource")
assert passed, f"coordinator.js connectSSE.onerror regressed: {reason}"
def test_interactive_history_is_rest_first_not_sse() -> None:
"""PR A converged interactive onto coord's REST-first history
model: first paint and post-rewind re-render fetch ``GET /history``
over REST (``_loadHistoryThenConnect`` / ``_refetchHistory``), and
the server no longer replays the conversation inline over SSE — so
the client must no longer consume a ``history`` SSE event. Guards
against a regression that re-couples first paint to the removed
inline-history replay."""
body = _INTERACTIVE_JS.read_text(encoding="utf-8")
assert "_loadHistoryThenConnect" in body, (
"REST-first first-paint helper missing — interactive must fetch "
"history via GET /history before connecting SSE (coord's model)."
)
assert "_refetchHistory" in body
# Race/identity guard (PR #595 review follow-up): a load-generation token
# must discard a superseded refetch so a slow ws-A load cannot render over
# ws-B after a tab switch / child open.
assert "_historyLoadToken" in body, (
"missing the load-generation guard — a stale history refetch can "
"render the wrong workstream's history"
)
# Pre-PR-A interactive had no REST /history fetch; the quoted URL
# segment only appears in the new fetch concatenation.
assert '"/history"' in body
# The inline SSE ``history`` event is no longer emitted server-side,
# so the client must not handle it (history is REST-only now).
assert 'case "history":' not in body
# The server now projects the canonical wire shape at /history
# (make_history_handler → project_history_messages), so interactive
# feeds the payload straight to replayHistory — the client-side
# normaliser (history_normalize.js) was retired.
assert "normalizeHistoryMessages" not in body, (
"the client-side history normaliser was retired — interactive must "
"consume the server-projected /history shape directly"
)
assert "this.replayHistory(data.messages" in body, (
"interactive must feed the projected REST /history payload straight to replayHistory"
)
idx = (Path(__file__).resolve().parent.parent / "turnstone/ui/static/index.html").read_text(
encoding="utf-8"
)
assert "/shared/history_normalize.js" not in idx, (
"the retired history_normalize.js script tag must be removed from index.html"
)
def test_early_paint_tool_pending_wiring() -> None:
"""The interactive UI must paint a committed tool call on ``tool_pending``
— before the judge verdict / approval gate resolve — and then UPGRADE
that same block in place on the authoritative ``tool_info`` /
``approve_request`` rather than appending a duplicate. Pre-fix (PR #621)
the card waited on the verdict; this guards the early-paint wiring against
a rename/deletion that would silently revert to post-verdict rendering."""
body = _INTERACTIVE_JS.read_text(encoding="utf-8")
# Dispatch routes the early event to the announce painter.
assert 'case "tool_pending":' in body
assert "announceToolBlock(evt.items)" in body
# The announce painter + the idempotent take-or-build helper exist.
assert "announceToolBlock(items) {" in body
assert "_takeAnnouncedBlock(items) {" in body
# showInlineToolBlock reuses the announced shell rather than always
# creating + appending a fresh block (the duplicate-card bug).
assert "this._takeAnnouncedBlock(items)" in body
assert "if (!announced) this.messagesEl.appendChild(block);" in body
def test_risk_level_normalized_before_dom_interpolation() -> None:
"""Server-supplied ``risk_level`` lands in className / data-risk strings the
verdict + warning CSS depend on, so every interpolation must funnel through
``normalizeRiskLevel`` (issue #562). Post-5e.2c the pane DELEGATES the card
DOM to the shared builders (conversation.js), which OWN the normalization —
so the pane must (a) carry no raw ``risk_level || "medium"`` fallback and
(b) build verdict/warning DOM only via the shared builders, never inline."""
body = _INTERACTIVE_JS.read_text(encoding="utf-8")
# No raw fallback antipattern anywhere in the pane.
assert 'risk_level || "medium"' not in body
assert 'risk_level) || "medium"' not in body
# Verdict + warning DOM is built by the shared builders (which normalize),
# not by an inline className / data-risk interpolation in the pane.
assert "buildConvVerdict(" in body
assert "buildConvWarning(" in body
# The chokepoint + its enum live in the shared module, and the builders there
# route the server risk through it.
shared = (_INTERACTIVE_JS.parent / "conversation.js").read_text(encoding="utf-8")
assert "export function normalizeRiskLevel(" in shared
assert "normalizeRiskLevel(verdict.risk_level)" in shared # buildConvVerdict
assert "normalizeRiskLevel(a.risk_level)" in shared # buildConvWarning
for level in ("low", "medium", "high", "critical"):
assert f'"{level}"' in shared
def test_announced_rail_outspecifies_inline_cyan_hold() -> None:
"""The early-paint announced card's left rail MUST out-specify
``.msg.ts-approval--inline`` (specificity 0,2,0), which deliberately
sets ``border-left-color: var(--cyan)`` to hold resolved inline cards
cyan. A bare ``.ts-approval--announced`` (0,1,0) loses that cascade and
silently renders the rail cyan — indistinguishable from a normal tool
card, defeating the whole "spot the committed call and Stop it" signal.
This is a render-only failure no JS string-guard catches, so pin the
high-specificity selector + the visible ``--accent`` (not the
near-invisible 15%-alpha ``--accent-dim``)."""
css = (_APP_JS.resolve().parent / "style.css").read_text(encoding="utf-8")
assert ".msg.ts-approval--inline.ts-approval--announced {" in css, (
"announced rail selector must qualify with .msg.ts-approval--inline to "
"beat the inline cyan-hold rule (0,2,0) — else it renders dashed cyan"
)
# The rule body uses the full --accent amber + dashed style.
block_start = css.index(".msg.ts-approval--inline.ts-approval--announced {")
block = css[block_start : block_start + 160]
assert "border-left-color: var(--accent)" in block
assert "border-left-style: dashed" in block
assert "--accent-dim" not in block # 15%-alpha is invisible at 3px
def test_early_paint_screen_reader_announce() -> None:
"""Screen-reader parity for the early paint: a committed tool call must be
announced politely (messagesEl is flipped to aria-live="off" mid-stream, so
the appended shell alone is inaudible), and the announced shell must carry
aria-busy until the gate resolves. All silent failures — no JS error, just
a blind operator who never hears the call land — so pin the wiring."""
body = _INTERACTIVE_JS.read_text(encoding="utf-8")
# Dedicated polite SR region (separate from the voice one) + summary builder.
assert "function toolAnnounce(" in body
assert "function _toolAnnounceText(" in body
assert 'setAttribute("aria-live", "polite")' in body
# Early paint announces + marks the shell busy; the upgrade clears it.
assert "toolAnnounce(_toolAnnounceText(list))" in body
assert 'block.setAttribute("aria-busy", "true")' in body
assert 'block.removeAttribute("aria-busy")' in body