mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
4007fab855
Round-3 review, unprimed. Two of its majors were the new scenarios asserting things they did not prove — the false-detector class this campaign keeps returning to. E8 never checked that the held /history was still OUTSTANDING when the redial completed. The disconnect/send/wait_turn/redial sequence is unbounded (wait_turn alone allows 45s), so on a slow box the payload resolves while evtSource is still null, the PRESENCE term declines it, and the run stamps dupes1-healed1 without ever evaluating the generation term. It now fails loudly with the counter values instead. E7 gained the same positive proof its siblings already carried: sse_opens == 0 only means "nothing connected in 8s", which is not the same as "the held load settled and its .finally chose not to reconnect". E5's stated control was simply wrong, in three places. A hide nulls evtSource, and connectSSE early-returns while hidden, so the scenario cannot produce the non-null-but-not-OPEN source that readyState === OPEN exists for — it exercises the presence term only. The earlier control removed both terms at once, which is what disguised it. The readyState half is covered by reasoning plus coord parity, and its correctness twin IS covered through the render-time gate by E6/E8; that scope is now written down rather than overclaimed. Coord's G5 has the same shape. The retry floor becomes a shared export beside its jitter: four sites must move together (both clients' arms, both non-occurrence windows) and it was the only one of them with no single source of truth. Interactive's use of the expression had no pin at all — reverting it to a bare 2000 would have broken cross-client parity with the suite green. Coord's re-anchor still raised ValueError rather than failing on a named assertion, and its first replacement used a fixed window that truncated mid-expression.
1306 lines
68 KiB
Python
1306 lines
68 KiB
Python
"""Static smoke guards for the shared interactive pane module.
|
|
|
|
``turnstone/shared_static/interactive.js`` is the per-workstream conversational
|
|
``Pane`` lifted out of ``ui/static/app.js`` (L-shell step 5a) so BOTH the
|
|
standalone ``turnstone-server`` UI and the console L-shell can mount it. The
|
|
load-bearing invariants of that extraction are pinned here — like the rest of
|
|
the WebUI, the module has no JS test framework, so these are Python-side
|
|
string-presence assertions that catch the silent one-line regression.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_ROOT = Path(__file__).resolve().parent.parent
|
|
_INTERACTIVE = _ROOT / "turnstone/shared_static/interactive.js"
|
|
_COMPOSER = _ROOT / "turnstone/shared_static/composer.js"
|
|
_AUTH = _ROOT / "turnstone/shared_static/auth.js"
|
|
_APP = _ROOT / "turnstone/ui/static/app.js"
|
|
_UI_INDEX = _ROOT / "turnstone/ui/static/index.html"
|
|
|
|
|
|
def _strip_comments(js: str) -> str:
|
|
js = re.sub(r"/\*.*?\*/", "", js, flags=re.S)
|
|
js = re.sub(r"//[^\n]*", "", js)
|
|
return js
|
|
|
|
|
|
def test_interactive_is_esm_imported_by_the_shell() -> None:
|
|
"""Real ES module: it ``export``s the factory the shell imports in BOTH
|
|
deployments. Step 6 retired the window bridge (no window.InteractivePane)
|
|
and the standalone HTML no longer script-tags interactive.js — shell.js
|
|
pulls it via ``import``."""
|
|
body = _INTERACTIVE.read_text(encoding="utf-8")
|
|
assert "export { Pane as InteractivePane, createInteractivePane };" in body
|
|
assert "window.InteractivePane = Pane" not in body, (
|
|
"the window bridge is retired — the shell imports the factory (ESM)."
|
|
)
|
|
html = _UI_INDEX.read_text(encoding="utf-8")
|
|
assert "/shared/interactive.js" not in html, (
|
|
"the standalone HTML must NOT script-tag interactive.js — shell.js imports it."
|
|
)
|
|
|
|
|
|
def test_pane_constructor_takes_transport_and_host_seam() -> None:
|
|
"""The constructor takes the ``(wsId, opts)`` seam: a transport ``base``
|
|
(the node-proxy prefix) and a ``host`` adapter for the few things only the
|
|
surrounding shell knows. The old ``embedded`` flag is gone — every pane is
|
|
L-shell-hosted since the step-6 fork collapse."""
|
|
body = _INTERACTIVE.read_text(encoding="utf-8")
|
|
assert "constructor(wsId, opts) {" in body
|
|
for field in (
|
|
"this._base = opts.base",
|
|
"this._host = opts.host || INTERACTIVE_DEFAULT_HOST",
|
|
):
|
|
assert field in body, f"missing constructor seam: {field!r}"
|
|
assert "opts.embedded" not in body, (
|
|
"the embedded flag is retired — every pane is L-shell-hosted."
|
|
)
|
|
|
|
|
|
def test_transport_urls_are_base_prefixed() -> None:
|
|
"""Every per-ws request is prefixed with ``this._base`` so a console pane
|
|
proxies through ``/node/{id}`` (the LOCALITY invariant: an interactive
|
|
session lives on a cluster node). A bare ``/v1/api/workstreams/`` URL would
|
|
hit the console instead of the node and silently 404 / cross-talk."""
|
|
body = _strip_comments(_INTERACTIVE.read_text(encoding="utf-8"))
|
|
# Collapse whitespace so a prettier line-wrap (``this._base +`` on the line
|
|
# ABOVE the URL string) doesn't read as a bare URL: every workstream URL
|
|
# must be preceded by ``this._base +``.
|
|
collapsed = re.sub(r"\s+", " ", body)
|
|
bad = re.findall(r'(?<!this\._base \+ )"/v1/api/workstreams/"', collapsed)
|
|
assert not bad, (
|
|
"found a bare '/v1/api/workstreams/' URL not prefixed by this._base — "
|
|
"a console pane would route it to the console, not the owning node."
|
|
)
|
|
# The EventSource + history + send all go through the base.
|
|
assert 'this._base + "/v1/api/workstreams/"' in collapsed
|
|
assert "new EventSource(evtUrl" in body
|
|
|
|
|
|
def test_split_pane_chrome_is_retired() -> None:
|
|
"""The standalone split-pane chrome is GONE, not gated: no pane header
|
|
(name / persona / state live in the tab + rail; the conversation owns the
|
|
full pane height), no focus tracking, no split/close buttons. The dead
|
|
``!this._embedded`` branches referenced shell globals (setFocusedPane,
|
|
splitPane, splitRoot…) that no longer exist anywhere — reaching them was a
|
|
guaranteed ReferenceError, so their removal is a bugfix too."""
|
|
body = _INTERACTIVE.read_text(encoding="utf-8")
|
|
assert "_embedded" not in body, "the embedded gate is retired (always-on)"
|
|
assert 'className = "pane pane--embedded"' in body, (
|
|
"the pane root must carry pane--embedded unconditionally — "
|
|
"interactive.css scopes the slim-chrome layout to it"
|
|
)
|
|
for gone in (
|
|
"setFocusedPane",
|
|
"showPaneContextMenu",
|
|
"splitPane(",
|
|
"splitRoot",
|
|
"this.headerEl",
|
|
'"pane-header"',
|
|
'"pane-action-btn"',
|
|
"updateWsName",
|
|
):
|
|
assert gone not in body, f"retired split-pane symbol {gone!r} resurfaced"
|
|
# The persona tag stays gone (the rail's INT/COORD vocabulary shows it).
|
|
assert '"pane-persona-tag"' not in body
|
|
assert '"INTERACTIVE"' not in body
|
|
|
|
|
|
def test_factory_returns_lifecycle_over_node_proxy() -> None:
|
|
"""``createInteractivePane`` is the console factory (mirrors
|
|
``createCoordinatorPane``): it derives the node-proxy base from ``nodeId``
|
|
and returns the lifecycle controller the shell drives."""
|
|
body = _INTERACTIVE.read_text(encoding="utf-8")
|
|
assert "function createInteractivePane(root, wsId, opts) {" in body
|
|
assert '"/node/" + encodeURIComponent(opts.nodeId)' in body
|
|
for hook in ("connect()", "deactivate()", "onLogin()", "destroy()"):
|
|
assert hook in body, f"factory controller missing lifecycle hook {hook!r}"
|
|
# Teardown must close the stream so a backgrounded pane can't leak an
|
|
# upstream node connection.
|
|
assert "pane.disconnectSSE();" in body
|
|
|
|
|
|
def test_host_seam_routes_shell_couplings() -> None:
|
|
"""Every coupling to the surrounding shell goes through ``this._host`` — so
|
|
the same Pane works standalone (real adapter) and console-embedded (no-op /
|
|
Tier-1 adapter). No direct ``focusedPaneId`` / ``workstreams`` / consent
|
|
badge reference survives in the module."""
|
|
body = _INTERACTIVE.read_text(encoding="utf-8")
|
|
for call in (
|
|
"this._host.isFocused(this)",
|
|
"this._host.onStreamError(this)",
|
|
"this._host.warningTarget(this)",
|
|
"this._host.onConsentDetected(",
|
|
):
|
|
assert call in body, f"missing host seam call {call!r}"
|
|
assert "getWsName" not in body, (
|
|
"getWsName left the host seam with the pane header — the tab + rail "
|
|
"own the workstream name now."
|
|
)
|
|
code = _strip_comments(body)
|
|
# The classic split-pane shell globals must not leak into the module as
|
|
# bare code references (URL path strings excepted, handled above).
|
|
assert not re.search(r"(?<![\w$./\"])focusedPaneId(?![\w$])", code), (
|
|
"focusedPaneId leaked into the shared module — route it through "
|
|
"host.isFocused so the console (which has no such global) still works."
|
|
)
|
|
assert "_pendingConsentServers" not in code, (
|
|
"the consent-badge state must stay in the standalone shell; the pane "
|
|
"only notifies via host.onConsentDetected."
|
|
)
|
|
|
|
|
|
def test_standalone_opens_sessions_via_the_shell_pane_manager() -> None:
|
|
"""Step 6 retired the standalone's local split-pane construction: app.js no
|
|
longer builds panes via window.InteractivePane / STANDALONE_HOST. Sessions
|
|
open through the shared shell's PaneManager — openSessionPane delegates to
|
|
openPane('interactive', wsId)."""
|
|
app = _APP.read_text(encoding="utf-8")
|
|
assert "STANDALONE_HOST" not in app, "the standalone host adapter is retired."
|
|
assert "new window.InteractivePane(" not in app, (
|
|
"the standalone no longer constructs panes locally."
|
|
)
|
|
start = app.index("function openSessionPane(wsId)")
|
|
fn = app[start : start + 300]
|
|
assert 'openPane("interactive", wsId)' in fn, (
|
|
"openSessionPane must open the session as a pane via the shell PaneManager."
|
|
)
|
|
|
|
|
|
def test_approval_keyboard_shortcuts_wired() -> None:
|
|
"""The converged card advertises y/n/a (+Enter/Esc) kbd hints, so the pane
|
|
must route those keys to resolveApproval when a pending approval is up —
|
|
pane-owned on this.el (the fork collapse retired the old app.js global
|
|
handler + getFocusedPane). Guards against the chips over-promising."""
|
|
body = _INTERACTIVE.read_text(encoding="utf-8")
|
|
assert "if (!this.pendingApproval || !this.approvalBlockEl) return;" in body, (
|
|
"approval keydown must early-return unless a pending approval is up"
|
|
)
|
|
assert "e.key.toLowerCase()" in body, "the y/n/a shortcut branch"
|
|
assert ".conv-feedback" in body, (
|
|
"the feedback field uses the converged .conv-feedback, not the retired "
|
|
".ts-approval-feedback"
|
|
)
|
|
|
|
|
|
def test_media_playback_lifted_and_pane_owned() -> None:
|
|
"""The media Play affordance is rendered by the pane (buildPlayButton /
|
|
buildMediaEmbed), so its activation must live in the pane too — the old
|
|
standalone wired a DOCUMENT-level click/keydown listener in app.js, which
|
|
the console host never loaded (so the button was dead in console-hosted
|
|
panes). The fix mirrors the approval-keydown pattern: a pane-owned listener
|
|
on this.el, root-scoped via closest(".media-play-btn"). Pin both the
|
|
lifted helpers and the pane wiring so the document-level regression can't
|
|
silently come back."""
|
|
body = _INTERACTIVE.read_text(encoding="utf-8")
|
|
# The lifted activation machinery now lives in the shared module.
|
|
for fn in (
|
|
"function _loadHls(",
|
|
"function _isHlsUrl(",
|
|
"function _activatePlayer(",
|
|
"function activateMediaPlayButton(",
|
|
):
|
|
assert fn in body, f"media player helper must be lifted into the pane: {fn}"
|
|
# The HLS vendor is fetched by absolute /shared/ URL (resolves in BOTH the
|
|
# standalone server and the console, where /shared is mounted at the root).
|
|
assert 'script.src = "/shared/hls-1.6.16/hls.min.js";' in body
|
|
# Pane-owned + root-scoped — NOT a document-level delegated listener.
|
|
assert 'this.el.addEventListener("click"' in body, (
|
|
"media play must be wired on this.el (pane-owned), not document"
|
|
)
|
|
assert 'e.target.closest(".media-play-btn")' in body, (
|
|
"the play handler must be root-scoped via closest, not a document-wide id"
|
|
)
|
|
assert "activateMediaPlayButton(btn)" in body
|
|
collapsed = _strip_comments(body)
|
|
assert 'document.addEventListener("click"' not in collapsed, (
|
|
"the pane must not register a document-level click delegate — that is "
|
|
"the standalone regression that left console panes dead"
|
|
)
|
|
|
|
|
|
def test_controller_terminal_dead_state() -> None:
|
|
"""Lifecycle round 2: the console controller must STOP reconnect-polling a
|
|
session that is gone (closed / evicted / node restarted) — three consecutive
|
|
CLOSED recovery beats → give up: stream closed, status bar terminal,
|
|
``opts.onDead()`` fired once. A successful stream open resets the counter
|
|
(the new host.onStreamOpen seam). ``isDead()`` / ``markDead()`` / ``base``
|
|
are the shell's revive surface; a dead controller also ignores the login
|
|
re-arm (recovery may need a DIFFERENT node — the shell's revive owns it)."""
|
|
body = _INTERACTIVE.read_text(encoding="utf-8")
|
|
# The give-up ladder.
|
|
assert "let dead = false;" in body and "let failCount = 0;" in body
|
|
assert "const giveUp = function () {" in body
|
|
assert "failCount += 1;" in body and "if (failCount >= 3) giveUp();" in body
|
|
assert 'pane._sbTokens.textContent = "Disconnected"' in body, (
|
|
"the terminal state must be worded distinctly from the transient Reconnecting…"
|
|
)
|
|
assert "opts.onDead" in body, "the shell must hear about the give-up"
|
|
# The reset seam: Pane.connectSSE onopen → host.onStreamOpen → failCount = 0.
|
|
assert "this._host.onStreamOpen(this)" in body
|
|
assert "onStreamOpen() {}" in body, "the default host must carry the no-op"
|
|
# The shell-facing surface.
|
|
assert "isDead()" in body and "markDead: giveUp," in body
|
|
assert "base: base," in body, "the controller must expose its transport base"
|
|
# Dead controllers don't reconnect on re-auth.
|
|
assert "if (connected && !dead) pane._loadHistoryThenConnect(wsId);" in body
|
|
|
|
|
|
def test_stream_pipeline_is_wedge_proof() -> None:
|
|
"""Long-session hardening (perf audit P0): the SSE pipeline must not be
|
|
able to permanently wedge the pane. ``onmessage`` guards BOTH the
|
|
``JSON.parse`` and the ``handleEvent`` dispatch (an exception escaping it
|
|
doesn't close the EventSource, so an unguarded throw left the streaming
|
|
refs poisoned for the rest of the session), and ``stream_end`` resets the
|
|
segment refs BEFORE the finalize render, with a plain-text fallback —
|
|
with the old order a finalize throw skipped the clears and every later
|
|
delta painted into the dead segment."""
|
|
body = _INTERACTIVE.read_text(encoding="utf-8")
|
|
assert "dropping malformed SSE frame" in body
|
|
assert "handleEvent failed for" in body
|
|
case = body.index('case "stream_end"')
|
|
seg = body[case : body.index("break;", case)]
|
|
clears = seg.index("this.currentAssistantBodyEl = null;")
|
|
finalize = seg.index("streamingRenderFinalize(")
|
|
assert clears < finalize, (
|
|
"stream_end must clear segment refs BEFORE finalize — the old "
|
|
"finalize-first order wedged all later assistant output on a throw."
|
|
)
|
|
assert "doneBodyEl.textContent = doneBuffer;" in seg
|
|
|
|
|
|
def test_rebuild_quiesces_live_events_and_releases_agent_tracking() -> None:
|
|
"""clear_ui re-render race (perf audit P0): live SSE events painted
|
|
between the history snapshot and ``replaceChildren()`` were wiped with
|
|
no redelivery, and streaming refs kept pointing at detached nodes.
|
|
Pinned: the quiesce queue sits on the handleEvent hot path, the
|
|
live-stream re-render trigger (clear_ui) arms it, ``replayHistory``
|
|
resets the streaming refs and clears the agent-card/orphan maps (the
|
|
detached-DOM retention leak), and the mid-stream guard covers the
|
|
reasoning bubble. (replay_truncated no longer arms the quiesce — it
|
|
tears the stream down and runs the full fresh-connect flow instead;
|
|
see test_truncated_resync_is_full_fresh_connect.)"""
|
|
body = _INTERACTIVE.read_text(encoding="utf-8")
|
|
assert "this._replayQueue.events.push(evt);" in body
|
|
assert body.count("this._beginReplayQuiesce(") >= 1, (
|
|
"clear_ui must arm the quiesce (it re-renders on a LIVE stream)"
|
|
)
|
|
assert "!this.currentAssistantEl && !this.currentReasoningEl" in body
|
|
replay = body.index("replayHistory(messages) {")
|
|
seg = body[replay : replay + 2600]
|
|
for line in (
|
|
"this._resetStreamingRefs();",
|
|
"this._clearAgentTracking();",
|
|
):
|
|
assert line in seg, f"replayHistory must reset: {line!r}"
|
|
assert "this._agentCards.clear();" in body
|
|
# Review-hardened lifecycle: the card entry SURVIVES the terminal
|
|
# tool_result (a late child event finding no Map entry would rebuild a
|
|
# duplicate empty card beside the finished one), and transport-only
|
|
# reconnects preserve the maps + any armed quiesce queue — clearing them
|
|
# in disconnectSSE duplicated cards and dropped buffered orphan steps on
|
|
# every transient stream blip. Full-reload cleanup lives in
|
|
# _loadHistoryThenConnect; terminal cleanup in the factory's destroy().
|
|
assert "this._agentCards.delete(callId);" not in body
|
|
disc = body.index("disconnectSSE() {")
|
|
disc_seg = body[disc : body.index("_loadHistoryThenConnect(wsId) {", disc)]
|
|
assert "this._clearAgentTracking();" not in disc_seg
|
|
assert "this._replayQueue = null;" not in disc_seg
|
|
load = body.index("_loadHistoryThenConnect(wsId) {")
|
|
load_seg = body[load : body.index("async _refetchHistory(", load)]
|
|
assert "this._clearAgentTracking();" in load_seg
|
|
assert "this._replayQueue = null;" in load_seg
|
|
# A mid-stream replay_truncated DEFERS the re-sync (flag consumed on the
|
|
# idle edge) instead of dropping it — skipping left the lost-event gap
|
|
# unrepaired for the rest of the session.
|
|
assert "this._pendingTruncatedResync = true;" in body
|
|
# The refetch FAILURE branch is a DOM/ref no-op (#890): the full
|
|
# guard-before-wipe contract — failure branch, clear_ui, the
|
|
# resumability-gated reload reset, and the affordance gates — is
|
|
# pinned in test_interactive_refetch_failure_preserves_the_pane.
|
|
|
|
|
|
def test_interactive_refetch_failure_preserves_the_pane() -> None:
|
|
"""A FAILED /history fetch must leave the transcript, streaming refs,
|
|
and repair-intent state untouched on EVERY re-render route (#890 —
|
|
the interactive mirror of coord's #882 G3 guard-before-wipe, pinned
|
|
there by test_coordinator_refetch_failure_preserves_the_pane). The
|
|
wipe + resets live in replayHistory, reached only on success:
|
|
|
|
- the clear_ui case must NOT pre-wipe (pre-#890 a failed fetch
|
|
during a rewind blanked the highest-traffic pane on a live
|
|
stream);
|
|
- the _refetchHistory failure branch must be empty except for the
|
|
quiesce release (no showEmptyState — the stray hint below stale
|
|
content was the resync-route wart; no ref reset);
|
|
- the failed-first-paint placeholder survives ONLY via the factory
|
|
connect() pre-seed, which must stay ahead of the load call;
|
|
- _loadHistoryThenConnect must reset streaming refs UNLESS a
|
|
truncation resync is armed (the armed route's cursor replay
|
|
resumes the mid-jitter bubble; every non-resumable flavor —
|
|
ws switch, first paint, idle edge, unarmed re-auth reload —
|
|
resets, or a stale ref would concatenate the next turn into the
|
|
old bubble);
|
|
- the row-level mutating affordances (rewind / edit /
|
|
edit-and-resend) must gate on the _historyStale latch alongside
|
|
busy — the latch spans clear_ui arrival through the next
|
|
SUCCESSFUL render, covering the fetch window AND the failed-fetch
|
|
aftermath (a quiesce-based gate reopened on the failure exit and
|
|
let a second rewind over-rewind off the stale DOM);
|
|
- the latch heals TRANSPORT-FREE: one turn-free bounded retry from
|
|
the clear_ui .then, and a quiesced same-token refetch at organic
|
|
idle edges as the double-failure backstop. Neither may touch
|
|
the stream — a reload's fresh reconnect draws the server's
|
|
synthetic state_change:idle back into the backstop's own trigger
|
|
(the round-5 zero-backoff reconnect storm).
|
|
"""
|
|
body = _INTERACTIVE.read_text(encoding="utf-8")
|
|
|
|
# clear_ui: no pre-wipe before the fetch, and the quiesce must be
|
|
# armed BEFORE the fetch (queued live events land in the rebuilt —
|
|
# or, on failure, the stale-but-real — pane, never the void).
|
|
cl = body.index('case "clear_ui":')
|
|
cl_seg = body[cl : body.index("break;", cl)]
|
|
assert "this._refetchHistory(this.wsId, token)" in cl_seg
|
|
assert "this.messagesEl.replaceChildren();" not in cl_seg, (
|
|
"clear_ui must not pre-wipe the transcript (#890)"
|
|
)
|
|
assert "this._resetStreamingRefs();" not in cl_seg, (
|
|
"clear_ui must not pre-reset streaming refs (#890)"
|
|
)
|
|
assert cl_seg.index("this._beginReplayQuiesce(token);") < cl_seg.index(
|
|
"this._refetchHistory("
|
|
), "clear_ui must arm the quiesce BEFORE the fetch"
|
|
|
|
# Render-time cursor-safety gate (#900): a SEEDLESS render commits
|
|
# /history's as-of-now truth without advancing _lastEventId, so
|
|
# committing it while the transport is down strands the cursor below
|
|
# the rows just painted and the next connect's replay paints them
|
|
# again. The gate must be seedless-SCOPED — _loadHistoryThenConnect
|
|
# disconnects first, so its evtSource is null for its whole fetch and
|
|
# gating it would break every first paint, ws switch and resync.
|
|
ref = body.index("async _refetchHistory(wsId, token, seedCursor = false) {")
|
|
ref_seg = body[ref : body.index("\n _beginReplayQuiesce(token) {", ref)]
|
|
gate = ref_seg.index("const cursorSafe =")
|
|
gate_seg = ref_seg[gate : ref_seg.index(";", gate)]
|
|
assert "seedCursor ||" in gate_seg, (
|
|
"the render-time gate must exempt seeded (disconnect-first) loads (#900)"
|
|
)
|
|
assert "this.evtSource.readyState === EventSource.OPEN" in gate_seg
|
|
# Connection-generation term (#900 r2): readyState alone cannot see a
|
|
# transport that DROPPED and finished RE-ESTABLISHING inside the await —
|
|
# it reads OPEN either way, while the redial re-presented the frozen
|
|
# cursor and the quiesce buffered the replay the render would duplicate.
|
|
# Object identity is not a substitute: a NATIVE reconnect reuses the same
|
|
# EventSource, so only a counter can see it.
|
|
assert "this._connectEpoch === epoch" in gate_seg, (
|
|
"the seedless render must require an unchanged stream generation (#900)"
|
|
)
|
|
assert "if (data && cursorSafe) {" in ref_seg, (
|
|
"the seedless render must be gated on a live cursor (#900)"
|
|
)
|
|
# Captured at DISPATCH, not read live at render time — a live read would
|
|
# compare the epoch against itself and the term would be vacuous.
|
|
assert "const epoch = this._connectEpoch;" in ref_seg, (
|
|
"the gate must compare against the generation captured at dispatch (#900)"
|
|
)
|
|
assert ref_seg.index("const epoch = this._connectEpoch;") < ref_seg.index("await authFetch("), (
|
|
"the generation must be captured BEFORE the fetch await (#900)"
|
|
)
|
|
|
|
# The bump belongs to onopen and NOWHERE else. A native auto-reconnect
|
|
# calls neither connectSSE nor disconnectSSE, so those two are blind to
|
|
# the very case the term exists for; connectSSE would additionally
|
|
# FALSE-bump on its document.hidden early return, which establishes no
|
|
# stream. Absence from both is therefore load-bearing, not incidental.
|
|
assert "this._connectEpoch = 0;" in body, (
|
|
"the generation must be initialised — undefined makes every compare "
|
|
"NaN-false and silently declines every seedless render (#900)"
|
|
)
|
|
onopen = body.index("this.evtSource.onopen = () => {")
|
|
onopen_seg = body[onopen : body.index("\n };", onopen)]
|
|
assert "this._connectEpoch += 1;" in onopen_seg, (
|
|
"the stream generation must be bumped in onopen — the only site that "
|
|
"fires for a NATIVE auto-reconnect (#900)"
|
|
)
|
|
conn = body.index("connectSSE(wsId) {")
|
|
assert "_connectEpoch" not in _strip_comments(body[conn:onopen]), (
|
|
"connectSSE must NOT bump the generation — it would false-bump on the "
|
|
"document.hidden early return, which establishes no stream (#900)"
|
|
)
|
|
dis = re.search(r"\n disconnectSSE\(\) \{(.*?)\n \}\n", body, re.S)
|
|
assert dis is not None, "disconnectSSE not found"
|
|
# Comment-stripped: the method's inventory comment NAMES both fields as
|
|
# deliberately-not-cleared, which is the ruling — assert on code only.
|
|
dis_code = _strip_comments(dis.group(1))
|
|
assert "_connectEpoch" not in dis_code, (
|
|
"disconnectSSE must NOT bump the generation — a teardown is decided "
|
|
"by the presence term, and a re-establish by the next onopen (#900)"
|
|
)
|
|
# THE load-bearing negative: disconnectSSE deliberately does NOT cancel
|
|
# the clear_ui retry (transport-only redials keep the pending heal
|
|
# intent). That is exactly why the retry can fire against a dead
|
|
# transport, which is what its OPEN fire-guard term exists to handle —
|
|
# so a "symmetry" cleanup adding the cancel here would silently make
|
|
# that guard unreachable and E5's detector vacuous, with nothing else
|
|
# failing. Coord pins the same invariant (test_coordinator_page.py).
|
|
assert "_staleRetryTimer" not in dis_code, (
|
|
"disconnectSSE must NOT cancel the clear_ui failure retry — "
|
|
"transport-only reconnects keep the pending heal intent (#890/#900)"
|
|
)
|
|
|
|
# Failure branch: quiesce release only.
|
|
fail = body.index("Failed fetch = DOM + ref + repair-intent no-op")
|
|
fail_seg = body[fail : fail + 1100]
|
|
assert "this._endReplayQuiesce(token);" in fail_seg
|
|
assert "this.showEmptyState();" not in fail_seg, (
|
|
"a failed fetch must not append an empty-state hint (#890)"
|
|
)
|
|
assert "this._resetStreamingRefs();" not in fail_seg
|
|
# The failure branch must RESOLVE, never throw/reject: the clear_ui
|
|
# .then dispatches the queued edit-and-resend after a failed fetch
|
|
# too (the rewind already committed server-side) — a throw here
|
|
# would route to .catch and strand the resend.
|
|
assert "throw " not in fail_seg
|
|
assert "Promise.reject" not in fail_seg
|
|
|
|
# Success path still owns the wipe + resets.
|
|
replay = body.index("replayHistory(messages) {")
|
|
replay_seg = body[replay : replay + 2600]
|
|
assert "this.messagesEl.replaceChildren();" in replay_seg
|
|
assert "this._resetStreamingRefs();" in replay_seg
|
|
|
|
# Factory pre-seed ahead of the load — the failed-first-paint
|
|
# placeholder's only remaining producer.
|
|
conn = body.index("Load-bearing pre-seed (#890)")
|
|
conn_seg = body[conn : conn + 600]
|
|
seed = conn_seg.index("pane.showEmptyState();")
|
|
load = conn_seg.index("pane._loadHistoryThenConnect(wsId);")
|
|
assert seed < load, "connect() must seed the empty-state BEFORE the fetch"
|
|
|
|
# Resumability-gated ref reset in _loadHistoryThenConnect: refs
|
|
# survive a reload only when an armed truncation cursor lets the
|
|
# reconnect resume into them; every other flavor (ws switch,
|
|
# unarmed re-auth reload) resets.
|
|
lh = body.index("_loadHistoryThenConnect(wsId) {")
|
|
lh_seg = body[lh : body.index("async _refetchHistory(", lh)]
|
|
assert "if (this._truncatedFromCursor == null) this._resetStreamingRefs();" in lh_seg, (
|
|
"reload must reset streaming refs unless a truncation resync is armed (#890)"
|
|
)
|
|
|
|
# The mutating row affordances gate on the staleness latch alongside
|
|
# busy — scoped per method so a gate migrating off one affordance
|
|
# cannot hide inside a file-wide occurrence count.
|
|
for sig in (
|
|
"_rewindToMessage(msgEl) {",
|
|
"_startEdit(msgEl, originalText) {",
|
|
"_editAndResend(msgEl, newText) {",
|
|
):
|
|
m = body.index(sig)
|
|
assert "if (this.busy || this._historyStale) return;" in body[m : m + 400], (
|
|
f"missing busy || _historyStale gate in {sig!r} (#890)"
|
|
)
|
|
|
|
# Latch lifecycle: set at clear_ui arrival BEFORE the quiesce is
|
|
# armed (so no event can interleave between restructure-signal and
|
|
# gate-close); cleared ONLY in replayHistory's render, which also
|
|
# cancels the pending failure retry; the retry is scheduled from
|
|
# the clear_ui .then (bounded by construction — a retry's own
|
|
# failure cannot re-schedule) and fire-time-gated turn-free; the
|
|
# idle edge backstops a double failure via else-if BEHIND the
|
|
# truncated branch (whose own render heals the latch too).
|
|
assert cl_seg.index("this._historyStale = true;") < cl_seg.index(
|
|
"this._beginReplayQuiesce(token);"
|
|
), "the staleness latch must be set before the quiesce is armed"
|
|
assert "this._historyStale = false;" in replay_seg, (
|
|
"replayHistory must clear the staleness latch (its only clear site)"
|
|
)
|
|
assert "clearTimeout(this._staleRetryTimer);" in replay_seg
|
|
assert "this._staleRetryTimer = setTimeout(" in cl_seg
|
|
assert "!this.currentAssistantEl &&" in cl_seg, (
|
|
"the clear_ui failure retry must be turn-free-gated"
|
|
)
|
|
assert "} else if (this._historyStale && !this._replayQueue) {" in body, (
|
|
"the idle edge must backstop the latch behind the truncated branch, "
|
|
"skipping edges with a quiesced fetch already in flight"
|
|
)
|
|
# The backstop must be TRANSPORT-FREE: a quiesced same-token refetch,
|
|
# never _loadHistoryThenConnect — the reload's fresh reconnect draws
|
|
# the server's synthetic state_change:idle back into this branch's
|
|
# own trigger (the round-5 storm).
|
|
backstop = body.index("} else if (this._historyStale && !this._replayQueue) {")
|
|
backstop_seg = body[backstop : backstop + 2200]
|
|
assert "this._refetchHistory(this.wsId, staleToken);" in backstop_seg, (
|
|
"the staleness backstop must heal via a quiesced REST refetch"
|
|
)
|
|
assert "this._loadHistoryThenConnect(" not in backstop_seg, (
|
|
"the staleness backstop must never touch the transport (#890 r5)"
|
|
)
|
|
# The retry yields to an in-flight quiesce (no same-token stomp).
|
|
retry = cl_seg.index("this._staleRetryTimer = setTimeout(")
|
|
assert "!this._replayQueue &&" in cl_seg[retry : retry + 700], (
|
|
"the clear_ui retry must yield to an in-flight quiesced fetch"
|
|
)
|
|
# ...and must not fire against a DOWN transport (#900): disconnectSSE
|
|
# deliberately keeps the timer armed, so a hidden tab / degraded
|
|
# cooldown / native redial can hold the fire. A seedless refetch then
|
|
# paints rows the frozen _lastEventId still sits below, and the next
|
|
# connect replays that slice on top. OPEN, not merely present — a
|
|
# CONNECTING source has a frozen cursor with a replay pending.
|
|
assert "this.evtSource.readyState === EventSource.OPEN" in cl_seg[retry:], (
|
|
"the clear_ui retry must require a live stream at fire time (#900)"
|
|
)
|
|
# The delay is floor + spread, both from the SHARED module: one clear_ui
|
|
# reaches every listener on the ws, so an un-spread retry re-fetches in
|
|
# lockstep across tabs, and the floor is what the e2e non-occurrence
|
|
# windows size themselves on. Coord carries the identical expression —
|
|
# this pin is what keeps the two from drifting.
|
|
assert "STALE_RETRY_BASE_MS + Math.random() * STALE_RETRY_JITTER_MS" in cl_seg[retry:], (
|
|
"the clear_ui retry must keep the shared floor + jitter (#900)"
|
|
)
|
|
|
|
# Terminal teardown must invalidate in-flight loads AND cancel the
|
|
# failure retry. The token bump (#900) is the chokepoint: without it
|
|
# destroy() left _loadHistoryThenConnect's .finally free to reopen an
|
|
# EventSource on the detached pane (re-registering the document-level
|
|
# visibilitychange listener destroy just removed), a settling
|
|
# _refetchHistory free to replayHistory into detached DOM, and the
|
|
# clear_ui .then free to RE-ARM the timer destroy had cancelled. The
|
|
# clearTimeout stays because the timer may already be armed and a
|
|
# timer into a destroyed pane must be dead, not merely inert.
|
|
# (disconnectSSE deliberately does NOT cancel it — transport-only
|
|
# reconnects keep the pending heal intent.)
|
|
dest = body.index("destroy() {")
|
|
dest_seg = body[dest : body.index("\n },", dest)]
|
|
assert "clearTimeout(pane._staleRetryTimer);" in dest_seg, (
|
|
"destroy() must cancel the clear_ui failure retry (#890)"
|
|
)
|
|
assert "pane._historyLoadToken = (pane._historyLoadToken || 0) + 1;" in dest_seg, (
|
|
"destroy() must bump the load token to invalidate in-flight loads (#900)"
|
|
)
|
|
# Same rule on the other terminal path: giveUp() already bumped the
|
|
# token, so the timer is inert there — but inert is not dead.
|
|
give = body.index("const giveUp = function () {")
|
|
give_seg = body[give : body.index("\n };", give)]
|
|
assert "pane._historyLoadToken = (pane._historyLoadToken || 0) + 1;" in give_seg
|
|
assert "clearTimeout(pane._staleRetryTimer);" in give_seg, (
|
|
"giveUp() must cancel the clear_ui failure retry too (#900 symmetry)"
|
|
)
|
|
|
|
|
|
def test_per_token_hot_path_avoids_container_scans() -> None:
|
|
"""P1 (perf audit): per-token work must stay O(1) in transcript length.
|
|
The thinking indicator is an instance ref (the class-selector miss walked
|
|
the whole transcript on EVERY content/reasoning delta); near-bottom state
|
|
comes from the passive scroll listener instead of a forced-layout
|
|
geometry read per event; the scroll pin is rAF-coalesced; per-tool
|
|
row/stream lookups resolve through the self-healing caches."""
|
|
body = _INTERACTIVE.read_text(encoding="utf-8")
|
|
stripped = _strip_comments(body)
|
|
assert 'querySelector(".thinking-indicator")' not in stripped, (
|
|
"thinking indicator must use the instance ref, not a container scan"
|
|
)
|
|
assert "this._thinkingEl" in body
|
|
near = body.index("isNearBottom() {")
|
|
assert "return this._nearBottom;" in body[near : near + 700]
|
|
assert "passive: true" in body
|
|
# The rAF pin re-checks the flag AT FIRE TIME (a user scroll landing in
|
|
# the schedule→rAF window must win over a stale pin), with force
|
|
# requests latched across the coalescing window; resizes re-derive the
|
|
# flag via ResizeObserver since they move the bottom without a scroll.
|
|
assert "this._scrollPinForce = false;" in body
|
|
assert "ResizeObserver" in body
|
|
for helper in ("_toolRow(callId) {", "_streamEl(callId) {"):
|
|
assert helper in body, f"missing lookup-cache helper: {helper!r}"
|
|
|
|
|
|
# -- Shared-workstream cross-user send gate -----------------------------------
|
|
#
|
|
# The UX complement to the server-side CrossUserInterjectionError (a 409): while
|
|
# another participant's turn is in flight, this viewer's send button is disabled
|
|
# so they can't interject under the initiator's credentials / be misattributed.
|
|
# The wiring spans three modules; these string-presence guards catch the silent
|
|
# one-line regression the way the rest of this file does (no JS test framework).
|
|
|
|
|
|
def test_composer_exposes_hard_send_block() -> None:
|
|
"""The composer has an independent hard-block axis, reconciled with busy,
|
|
so a caller can disable send even in queueWhileBusy (queue) mode."""
|
|
body = _COMPOSER.read_text(encoding="utf-8")
|
|
assert "Composer.prototype.setSendBlocked = function" in body
|
|
assert "Composer.prototype._reconcileDisabled = function" in body
|
|
assert "this._sendBlocked = false;" in body
|
|
# setBusy must route the disabled write through the reconciler (not clobber
|
|
# the block with a direct sendBtn.disabled assignment).
|
|
stripped = _strip_comments(body)
|
|
setbusy = stripped.index("Composer.prototype.setBusy = function")
|
|
setbusy_end = stripped.index("Composer.prototype._reconcileDisabled")
|
|
assert "this._reconcileDisabled();" in stripped[setbusy:setbusy_end]
|
|
assert "this.sendBtn.disabled =" not in stripped[setbusy:setbusy_end], (
|
|
"setBusy must not write sendBtn.disabled directly — reconcile owns it"
|
|
)
|
|
|
|
|
|
def test_auth_retains_user_id_for_gate() -> None:
|
|
"""whoami's opaque user_id is retained (separately from the display
|
|
username) so the pane can compare it against the acting-user id."""
|
|
body = _AUTH.read_text(encoding="utf-8")
|
|
assert 'sessionStorage.setItem("ts.user_id", data.user_id);' in body
|
|
assert 'sessionStorage.removeItem("ts.user_id");' in body
|
|
|
|
|
|
def test_pane_gates_send_on_cross_user_busy() -> None:
|
|
"""The pane tracks the acting user from state_change, compares it against
|
|
the viewer's own id, and blocks send while another participant is busy."""
|
|
body = _INTERACTIVE.read_text(encoding="utf-8")
|
|
assert "_reconcileSendBlock() {" in body
|
|
# tracks the acting user from the state_change event...
|
|
assert "this._actingUserId = evt.acting_user_id;" in body
|
|
assert "this._actingUserId = null;" in body # cleared when the turn settles
|
|
# ...compares against the viewer's own id from /whoami...
|
|
assert 'sessionStorage.getItem("ts.user_id")' in body
|
|
assert "this._actingUserId !== me" in body
|
|
# ...and drives the composer's hard block, re-run on every busy edge.
|
|
assert "this.composer.setSendBlocked(" in body
|
|
stripped = _strip_comments(body)
|
|
setbusy = stripped.index("setBusy(b, source) {")
|
|
assert "this._reconcileSendBlock();" in stripped[setbusy : setbusy + 800]
|
|
|
|
|
|
def test_pane_handles_cross_user_409() -> None:
|
|
"""The reactive fallback: a 409 (button not yet disabled) surfaces a clean
|
|
message, not the generic 'Connection error' catch. The pane converts
|
|
the 409 body at the fetch stage; the status ARM itself lives in the
|
|
shared settle helper (composer_queue.settleSendResponse) with the rest
|
|
of the response matrix."""
|
|
body = _INTERACTIVE.read_text(encoding="utf-8")
|
|
assert "r.status === 409" in body
|
|
assert 'status: "cross_user_interjection"' in body
|
|
helper = (_ROOT / "turnstone/shared_static/composer_queue.js").read_text(encoding="utf-8")
|
|
assert 'status === "cross_user_interjection"' in helper
|
|
|
|
|
|
def test_sync_approval_state_prunes_orphan_cycles() -> None:
|
|
"""``_syncApprovalState`` prunes cycles whose block elements are no longer
|
|
in the living DOM (``.isConnected === false``). This covers the rare case
|
|
where an ``approve_request`` event is processed between a DOM wipe
|
|
(``clear_ui`` / ``replay_truncated`` / ``replaceChildren``) and the
|
|
refetch-restore — the cycle card lives in a detached subtree, the matching
|
|
``approval_resolved`` never arrives, and the send button stays disabled
|
|
forever without this guard. The pin guards against a future refactor that
|
|
drops the orphan prune but doesn't otherwise break ``_syncApprovalState``."""
|
|
body = _INTERACTIVE.read_text(encoding="utf-8")
|
|
fn_start = body.index("_syncApprovalState() {")
|
|
assert "entry.blockEls && !entry.blockEls.some((el) => el.isConnected)" in body, (
|
|
"orphan pruning must check .isConnected on block elements"
|
|
)
|
|
tail = body[fn_start : body.index("_oldestCycleId()", fn_start)]
|
|
assert "this.approvalCycles.delete(cid);" in tail, (
|
|
"orphan pruning must delete the cycle from the Map"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SSE overflow recovery + close-on-hide (fast-stream corruption fixes)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_stream_overflow_case_counts_and_rate_limits() -> None:
|
|
"""The server closes an overflowed stream after an id-less
|
|
``stream_overflow`` frame; the pane must count it (field
|
|
instrumentation for the drop-vs-render-wedge diagnosis) and route it
|
|
through the reconnect limiter so a persistently slow consumer trips
|
|
the degraded catch-up instead of churning reconnect/replay cycles."""
|
|
body = _INTERACTIVE.read_text(encoding="utf-8")
|
|
assert 'case "stream_overflow":' in body
|
|
assert "this._noteStreamOverflow();" in body
|
|
# The health object carries all four field-forensics counters; the
|
|
# truncated-resync counter distinguishes the replay-window class from
|
|
# the dropped-events class (overflows).
|
|
health = re.search(r"_streamHealth = \{(.*?)\};", body, re.S)
|
|
assert health is not None, "_streamHealth initializer not found"
|
|
for field in ("overflows: 0", "renderThrows: 0", "malformedFrames: 0", "truncatedGaps: 0"):
|
|
assert field in health.group(1), f"_streamHealth must init {field!r}"
|
|
# Both wedge-class catch sites increment the render-throw counter,
|
|
# and the malformed-frame drop counts too — the C-OVERDETERMINED
|
|
# instrumentation that tells drops apart from wedges in the field.
|
|
assert body.count("this._streamHealth.renderThrows += 1;") == 2
|
|
assert "this._streamHealth.malformedFrames += 1;" in body
|
|
assert "this._streamHealth.overflows += 1;" in body
|
|
|
|
|
|
def test_truncated_resync_is_full_fresh_connect_with_churn_limit() -> None:
|
|
"""replay_truncated = the stream admitted losing events past recovery.
|
|
The pane must treat the connection as DEAD: run the full fresh-connect
|
|
flow (``_loadHistoryThenConnect`` — disconnect first, REST /history,
|
|
adopt the resume cursor, reconnect) rather than an in-place refetch.
|
|
The in-place shape discarded the /history cursor while /history TRIMS
|
|
the trailing in-flight turn whenever it returns one — a mid-run
|
|
truncation wiped the executing turn (task cards included) with no
|
|
redelivery, and sub-agent children then escaped to top-level rows
|
|
after the orphan grace (the 2026-07 field reports).
|
|
|
|
Pinned: (1) the immediate branch routes through
|
|
``_noteTruncatedResync()`` and SKIPS the resync when the limiter just
|
|
tripped — the cooldown disconnected the stream, and the flow's
|
|
``.finally`` reconnect would defeat it; on no-trip it SCHEDULES the
|
|
fresh connect behind the herd-spreading jitter rather than starting
|
|
it inline (a node restart makes every stale-cursor tab resync inside
|
|
the EventSource retry window, and the per-tab limiter cannot see a
|
|
cross-tab herd); (2) the mid-stream guard still DEFERS (detachable
|
|
bubble); (3) the idle-edge consumption runs the same fresh-connect
|
|
flow, unjittered (staggered by turn-settle timing); (4) the limiter
|
|
feeds the SAME churn window as overflow closes and enters degraded
|
|
catch-up on trip; (5) neither truncated branch arms the quiesce or
|
|
calls the in-place ``_refetchHistory`` — the old shape must not come
|
|
back; (6) every ``truncatedGaps`` bump routes through the one
|
|
increment+log step (``_recordTruncatedGap``), whose class-of-event
|
|
wording never asserts a resync a trip may skip; (7) the scheduler
|
|
nulls its handle before loading and ``disconnectSSE`` cancels a
|
|
pending one, so a torn-down stream's resync can't fire against the
|
|
pane's next workstream; (8) the gap-repair guarantee is
|
|
interleaving-proof via the connect chokepoint; (9) a same-ws load
|
|
supersession must not strand a queued edit-and-resend."""
|
|
body = _INTERACTIVE.read_text(encoding="utf-8")
|
|
trunc = re.search(r'case "replay_truncated":(.*?)break;', body, re.S)
|
|
assert trunc is not None, "replay_truncated case not found"
|
|
t = trunc.group(1)
|
|
# (1) immediate branch: limiter check gates the JITTERED fresh connect.
|
|
assert "if (!this._noteTruncatedResync())" in t
|
|
assert "this._scheduleTruncatedResync();" in t
|
|
# (2) mid-stream defer unchanged.
|
|
assert "!this.currentAssistantEl && !this.currentReasoningEl" in t
|
|
assert "this._pendingTruncatedResync = true;" in t
|
|
# (5) the old in-place shape must not come back.
|
|
assert "_beginReplayQuiesce" not in t
|
|
assert "_refetchHistory" not in t
|
|
# (3) idle-edge consumption: consume the latch, record, fresh-connect.
|
|
idle = re.search(
|
|
r"if \(this\._pendingTruncatedResync\) \{(.*?)\n \}",
|
|
body,
|
|
re.S,
|
|
)
|
|
assert idle is not None, "idle-edge truncated consumption not found"
|
|
i = idle.group(1)
|
|
assert "this._pendingTruncatedResync = false;" in i
|
|
assert "this._recordTruncatedGap();" in i
|
|
assert "this._loadHistoryThenConnect(this.wsId);" in i
|
|
assert "_refetchHistory" not in i
|
|
# (4) the limiter method: records the gap, then delegates churn
|
|
# accounting to the ONE shared trip step (also used by
|
|
# _noteStreamOverflow) so the trip parameters cannot silently diverge
|
|
# between the overflow and truncated classes.
|
|
note = re.search(r"_noteTruncatedResync\(\)\s*\{(.*?)\n \}", body, re.S)
|
|
assert note is not None, "_noteTruncatedResync method not found"
|
|
n = note.group(1)
|
|
assert "this._recordTruncatedGap();" in n
|
|
assert "return this._recordChurnAndMaybeTrip();" in n
|
|
churn = re.search(r"_recordChurnAndMaybeTrip\(\)\s*\{(.*?)\n \}", body, re.S)
|
|
assert churn is not None, "_recordChurnAndMaybeTrip method not found"
|
|
c = churn.group(1)
|
|
assert "this._overflowTimes.push(now);" in c
|
|
assert "overflowWindowTripped(" in c
|
|
assert "this._enterDegradedCatchup();" in c
|
|
assert "return true;" in c
|
|
assert "return false;" in c
|
|
over = re.search(r"_noteStreamOverflow\(\)\s*\{(.*?)\n \}", body, re.S)
|
|
assert over is not None, "_noteStreamOverflow method not found"
|
|
assert "this._recordChurnAndMaybeTrip();" in over.group(1), (
|
|
"overflow closes must feed the same shared churn step"
|
|
)
|
|
# (6) the one increment+log step: every bump carries the running count,
|
|
# class-of-event wording (no action a trip may skip); the counter is
|
|
# named for what it counts — gap detections, not resyncs performed.
|
|
rec = re.search(r"_recordTruncatedGap\(\)\s*\{(.*?)\n \}", body, re.S)
|
|
assert rec is not None, "_recordTruncatedGap method not found"
|
|
r = rec.group(1)
|
|
assert "this._streamHealth.truncatedGaps += 1;" in r
|
|
assert "console.warn(" in r
|
|
assert "resyncing history from REST" not in r, (
|
|
"the shared log line must not assert a resync the trip branch skips"
|
|
)
|
|
assert body.count("this._streamHealth.truncatedGaps += 1;") == 1, (
|
|
"all truncatedGaps bumps must route through _recordTruncatedGap "
|
|
"so the running-count console invariant holds"
|
|
)
|
|
# (7) jittered scheduler: dedup guard, null-before-load, cancel on
|
|
# disconnect.
|
|
sched = re.search(r"_scheduleTruncatedResync\(\)\s*\{(.*?)\n \}", body, re.S)
|
|
assert sched is not None, "_scheduleTruncatedResync method not found"
|
|
s = sched.group(1)
|
|
assert "if (this._resyncTimer != null) return;" in s
|
|
assert "Math.random() * TRUNCATED_RESYNC_JITTER_MS" in s
|
|
null_then_load = s.index("this._resyncTimer = null;")
|
|
load = s.index("this._loadHistoryThenConnect(this.wsId);")
|
|
assert null_then_load < load, (
|
|
"the firing path must null the handle BEFORE loading, or "
|
|
"disconnectSSE (called inside the load) would cancel the work it "
|
|
"is part of"
|
|
)
|
|
dis = re.search(r"disconnectSSE\(\)\s*\{(.*?)\n \}", body, re.S)
|
|
assert dis is not None, "disconnectSSE not found"
|
|
assert "clearTimeout(this._resyncTimer)" in dis.group(1)
|
|
# (8) interleaving-proof gap repair: the truncation-time cursor is a
|
|
# single record — captured keep-oldest at the envelope, cleared by
|
|
# any full committed-history render (replayHistory) or a ws switch —
|
|
# and the CONNECT CHOKEPOINT consumes it: while set, every manual
|
|
# (re)connect presents it instead of the since-advanced live cursor,
|
|
# so the server re-answers replay_truncated and the resync re-arms
|
|
# no matter which teardown (hide/show, degraded cooldown, recover
|
|
# beat, failed /history fetch) cancelled the pending jittered timer.
|
|
# A cancellable timer alone silently lost the repair when a
|
|
# hide/show cycle landed inside the jitter window.
|
|
assert re.search(
|
|
r"if \(this\._truncatedFromCursor == null\) \{\s*"
|
|
r"this\._truncatedFromCursor = this\._lastEventId;",
|
|
t,
|
|
), "the truncated case must record the truncation-time cursor keep-oldest"
|
|
load = body.index("_loadHistoryThenConnect(wsId) {")
|
|
load_seg = body[load : body.index("async _refetchHistory(", load)]
|
|
assert "if (this.wsId !== wsId) this._truncatedFromCursor = null;" in load_seg, (
|
|
"a ws switch must drop the old ws's truncation record"
|
|
)
|
|
replay_fn = body.index("replayHistory(messages) {")
|
|
replay_head = body[replay_fn : replay_fn + 1200]
|
|
assert "this._truncatedFromCursor = null;" in replay_head, (
|
|
"a successful full-history render must clear the truncation record"
|
|
)
|
|
# (8b) ...and supersede ALL pending repair intent in the same breath —
|
|
# the deferred mid-turn latch and any pending jittered timer. Without
|
|
# these, a clear_ui heal left them armed and the next idle edge fired a
|
|
# phantom _loadHistoryThenConnect against the repaired gap (false
|
|
# truncatedGaps bump; on its failed-fetch leg, a cursorless reconnect
|
|
# with nothing armed). Every _loadHistoryThenConnect flavor
|
|
# clears both BEFORE its fetch, so these are no-ops on the load paths —
|
|
# the clear_ui heal is the path they exist for. Mirrors the
|
|
# coordinator's refetchHistory supersession.
|
|
assert "this._pendingTruncatedResync = false;" in replay_head, (
|
|
"replayHistory must clear the deferred-resync latch — a latch "
|
|
"surviving a heal fires a phantom resync at the next idle edge"
|
|
)
|
|
assert "clearTimeout(this._resyncTimer)" in replay_head, (
|
|
"replayHistory must cancel a pending jittered resync — the render "
|
|
"just repaired the gap it was scheduled for"
|
|
)
|
|
conn = body.index("connectSSE(wsId) {")
|
|
conn_seg = body[conn : body.index("this.evtSource = new EventSource", conn)]
|
|
assert "this._truncatedFromCursor != null" in conn_seg, (
|
|
"connectSSE must consult the truncation record"
|
|
)
|
|
assert "encodeURIComponent(connectCursor)" in conn_seg
|
|
# (9) same-ws supersession must not strand the queued edit-and-resend
|
|
# (the jittered resync bumps _historyLoadToken through
|
|
# _loadHistoryThenConnect while a clear_ui edit flow is in flight);
|
|
# only a ws SWITCH discards the resend, and then it recovers the
|
|
# composer instead of leaving the latch armed for the next ws.
|
|
clear = re.search(r'case "clear_ui": \{(.*?)break;', body, re.S)
|
|
assert clear is not None, "clear_ui case not found"
|
|
cl = clear.group(1)
|
|
assert "const editWs = this.wsId;" in cl
|
|
assert "if (token !== this._historyLoadToken && this.wsId !== editWs)" in cl, (
|
|
"only a cross-ws supersession may discard the edit-and-resend"
|
|
)
|
|
assert "this._pendingEditSend = null;" in cl
|
|
assert "this.setBusy(false);" in cl
|
|
# Supersession is centralized at the render (replayHistory) — clear_ui
|
|
# must not grow a path-local resync cancel of its own.
|
|
assert "clearTimeout(this._resyncTimer)" not in cl
|
|
|
|
|
|
def test_degraded_catchup_stops_live_stream_and_retries() -> None:
|
|
"""Degraded catch-up contract: close the stream FIRST (which also
|
|
clears any earlier degraded timer — disconnectSSE owns that), show a
|
|
plain-language status, then arm the retry timer with a doubling
|
|
cooldown. The retry must defer to the show edge when the tab is
|
|
hidden (reopening into a throttled tab would overflow again)."""
|
|
body = _INTERACTIVE.read_text(encoding="utf-8")
|
|
m = re.search(r"_enterDegradedCatchup\(\)\s*\{(.*?)\n \}", body, re.S)
|
|
assert m is not None, "_enterDegradedCatchup method not found"
|
|
method = m.group(1)
|
|
# Order matters: disconnect before arming the timer, or the fresh
|
|
# timer would be cancelled by its own disconnect.
|
|
assert method.index("this.disconnectSSE()") < method.index("this._degradedTimer = setTimeout")
|
|
assert "Connection is slow" in method, "degraded state must use plain language"
|
|
assert "DEGRADED_COOLDOWN_MAX_MS" in method
|
|
assert "document.hidden" in method
|
|
# disconnectSSE owns the timer teardown (ws-switch / giveUp / destroy
|
|
# all supersede a pending degraded retry through it).
|
|
dis = re.search(r"disconnectSSE\(\)\s*\{(.*?)\n \}", body, re.S)
|
|
assert dis is not None
|
|
assert "clearTimeout(this._degradedTimer)" in dis.group(1)
|
|
|
|
|
|
def test_visibilitychange_closes_on_hide_reconnects_on_show() -> None:
|
|
"""Close-on-hide / replay-on-show: a hidden tab's throttled drain is
|
|
the likeliest slow consumer behind server-side overflow (the old
|
|
"PR-G closes those connections on hide" comment described a handler
|
|
that never existed). The pane installs one visibilitychange
|
|
listener, marks ITS OWN hide-closes via ``_hiddenDisconnect`` so a
|
|
show edge never resurrects a deliberately-closed stream, and the
|
|
factory's destroy removes the listener (it strongly references the
|
|
pane)."""
|
|
body = _INTERACTIVE.read_text(encoding="utf-8")
|
|
assert 'document.addEventListener("visibilitychange", this._visHandler);' in body
|
|
assert 'document.removeEventListener("visibilitychange", this._visHandler);' in body
|
|
vis = re.search(r"_onVisibilityChange\(\)\s*\{(.*?)\n \}", body, re.S)
|
|
assert vis is not None, "_onVisibilityChange method not found"
|
|
method = vis.group(1)
|
|
assert "this.disconnectSSE();" in method
|
|
assert "this._hiddenDisconnect = true;" in method
|
|
assert "this.connectSSE(this.wsId);" in method
|
|
# Reconnect only consumes OUR hide-close marker.
|
|
assert "else if (this._hiddenDisconnect)" in method
|
|
# Teardown: the factory controller removes the listener on destroy.
|
|
assert "pane._removeVisibilityHandler();" in body
|
|
# The streaming buffers survive a hide-close: disconnectSSE stays
|
|
# transport-only (no contentBuffer wipe) so the visible tail is
|
|
# intact when the tab returns.
|
|
dis = re.search(r"disconnectSSE\(\)\s*\{(.*?)\n \}", body, re.S)
|
|
assert dis is not None
|
|
assert "contentBuffer" not in dis.group(1)
|
|
|
|
|
|
def test_no_global_sse_gap_detector() -> None:
|
|
"""Live event ids are NOT strictly monotonic across concurrent
|
|
tool+content emit (the fan-out runs outside the listeners lock), so
|
|
a naive ``id !== lastEventId + 1`` gap check would false-positive.
|
|
Recovery is server-signalled (``stream_overflow``) + reconnect
|
|
replay instead. This tripwire pins the absence of the naive
|
|
arithmetic — if gap detection is ever added, it must be scoped to
|
|
the content stream only (content-vs-content never reorders)."""
|
|
code = _strip_comments(_INTERACTIVE.read_text(encoding="utf-8"))
|
|
assert not re.search(r"_lastEventId\s*[+\-]\s*1", code), (
|
|
"found lastEventId +/- 1 arithmetic — a global gap detector "
|
|
"false-positives on legal concurrent tool/content id inversion"
|
|
)
|
|
|
|
|
|
def test_overflow_helpers_extracted_to_shared_module() -> None:
|
|
"""The storm-guard constants + the two pure helpers were extracted to the
|
|
shared ``sse_overflow.js`` module (its own runtime probes live in
|
|
``test_sse_overflow_js.py``) so the interactive and coordinator panes can't
|
|
drift. Pin that the pane IMPORTS them rather than re-declaring a local
|
|
copy: a stray local ``function overflowWindowTripped`` / ``const
|
|
OVERFLOW_TRIP_COUNT`` would silently fork the trip math again."""
|
|
body = _INTERACTIVE.read_text(encoding="utf-8")
|
|
m = re.search(
|
|
r"import \{([^}]*)\} from \"\./sse_overflow\.js\";",
|
|
body,
|
|
re.S,
|
|
)
|
|
assert m is not None, "interactive pane must import the shared overflow helpers"
|
|
imported = m.group(1)
|
|
for name in (
|
|
"OVERFLOW_TRIP_COUNT",
|
|
"OVERFLOW_TRIP_WINDOW_MS",
|
|
"DEGRADED_COOLDOWN_BASE_MS",
|
|
"DEGRADED_COOLDOWN_MAX_MS",
|
|
"DEGRADED_COOLDOWN_RESET_MS",
|
|
"overflowWindowTripped",
|
|
"degradedCooldownStep",
|
|
):
|
|
assert name in imported, f"{name} must be imported from sse_overflow.js"
|
|
# No local fork of the extracted definitions.
|
|
assert not re.search(r"^function overflowWindowTripped\(", body, re.M), (
|
|
"overflowWindowTripped must be imported, not re-declared locally"
|
|
)
|
|
assert not re.search(r"^function degradedCooldownStep\(", body, re.M), (
|
|
"degradedCooldownStep must be imported, not re-declared locally"
|
|
)
|
|
assert not re.search(r"^const OVERFLOW_TRIP_COUNT\s*=", body, re.M), (
|
|
"the trip constants must be imported, not re-declared locally"
|
|
)
|
|
|
|
|
|
def test_note_stream_overflow_does_not_reset_cooldown() -> None:
|
|
"""The exact finding [0] bug shape must not regress: _noteStreamOverflow
|
|
only counts + trips; it must NOT touch _degradedCooldownMs (the reset
|
|
that defeated the ladder lived here). The ladder decision lives solely
|
|
in _enterDegradedCatchup, keyed off _lastDegradedAt via
|
|
degradedCooldownStep."""
|
|
body = _INTERACTIVE.read_text(encoding="utf-8")
|
|
note = re.search(r"_noteStreamOverflow\(\)\s*\{(.*?)\n \}", body, re.S)
|
|
assert note is not None, "_noteStreamOverflow not found"
|
|
assert "_degradedCooldownMs" not in note.group(1), (
|
|
"_noteStreamOverflow must not write _degradedCooldownMs — that reset "
|
|
"was the bug that stopped the ladder escalating"
|
|
)
|
|
enter = re.search(r"_enterDegradedCatchup\(\)\s*\{(.*?)\n \}", body, re.S)
|
|
assert enter is not None
|
|
assert "degradedCooldownStep(" in enter.group(1)
|
|
assert "this._lastDegradedAt = now" in enter.group(1)
|
|
|
|
|
|
def test_recover_beat_defers_reconnect_when_tab_hidden() -> None:
|
|
"""Review round-2 finding [1]: the factory's transient-error recovery
|
|
beat (recoverTimer) must NOT reopen an EventSource into a hidden tab —
|
|
that re-creates the throttled slow-consumer overflow that close-on-hide
|
|
exists to prevent. It guards on document.hidden and defers to the
|
|
visibilitychange show edge (marking _hiddenDisconnect)."""
|
|
body = _INTERACTIVE.read_text(encoding="utf-8")
|
|
beat = re.search(r"recoverTimer = setTimeout\(\(\) => \{(.*?)\n \}, 5000\);", body, re.S)
|
|
assert beat is not None, "recoverTimer setTimeout body not found"
|
|
b = beat.group(1)
|
|
assert "document.hidden" in b, "recovery beat must guard on document.hidden"
|
|
assert "pane._hiddenDisconnect = true" in b, (
|
|
"recovery beat must defer to the show edge when hidden"
|
|
)
|
|
# The hidden guard must precede the reconnect (connectSSE) so it can't fall
|
|
# through to reopening the stream.
|
|
assert b.index("document.hidden") < b.index("pane.connectSSE(pane.wsId)")
|
|
|
|
|
|
def test_giveup_removes_visibility_handler() -> None:
|
|
"""Review round-2 finding [3]: giveUp() (markDead) must detach the
|
|
visibility handler and clear _hiddenDisconnect, or a tab hidden before
|
|
the give-up resurrects the dead controller's stream on return (the show
|
|
edge would connectSSE the closed ws and 404-reconnect it forever)."""
|
|
body = _INTERACTIVE.read_text(encoding="utf-8")
|
|
give = re.search(r"const giveUp = function \(\) \{(.*?)\n \};", body, re.S)
|
|
assert give is not None, "giveUp function body not found"
|
|
g = give.group(1)
|
|
assert "pane._removeVisibilityHandler();" in g, (
|
|
"giveUp must remove the visibility handler so a show edge can't resurrect a dead controller"
|
|
)
|
|
# _removeVisibilityHandler also clears _hiddenDisconnect (pinned in its body).
|
|
rvh = re.search(r"_removeVisibilityHandler\(\)\s*\{(.*?)\n \}", body, re.S)
|
|
assert rvh is not None
|
|
assert "this._hiddenDisconnect = false" in rvh.group(1)
|
|
|
|
|
|
def test_connectsse_defers_open_when_tab_hidden() -> None:
|
|
"""PR #805 review (Copilot + R3): connectSSE is the single connect
|
|
chokepoint and must not open an EventSource into a hidden tab. The
|
|
fresh-connect path (_loadHistoryThenConnect) has no timer guard, so a
|
|
first load in a background tab would otherwise open a throttled stream —
|
|
the slow-consumer overflow this PR exists to prevent. The guard sits
|
|
AFTER the visibilitychange-handler install (so the show edge can
|
|
reconnect) and AFTER the wsId assignment (so it targets the right ws),
|
|
and BEFORE `new EventSource` (so nothing opens)."""
|
|
body = _INTERACTIVE.read_text(encoding="utf-8")
|
|
start = body.index("connectSSE(wsId) {")
|
|
open_at = body.index("new EventSource(evtUrl)", start)
|
|
head = body[start:open_at] # connectSSE up to the EventSource open
|
|
assert "if (document.hidden) {" in head, (
|
|
"connectSSE must guard on document.hidden BEFORE opening the stream"
|
|
)
|
|
assert "this._hiddenDisconnect = true;" in head, (
|
|
"the deferred connect must mark _hiddenDisconnect so the show edge reconnects"
|
|
)
|
|
assert head.index("this.wsId = wsId;") < head.index("if (document.hidden) {")
|
|
assert head.index('addEventListener("visibilitychange"') < head.index("if (document.hidden) {")
|
|
|
|
|
|
def test_send_post_abort_machinery_is_gone() -> None:
|
|
"""The parked-POST era's client abort machinery must stay deleted in
|
|
BOTH panes: sends during a command window are answered "queued"
|
|
immediately (server-side defer-and-drain), so there is no long-lived
|
|
POST for a compaction-aware bound (``sendAbortMs``) to protect, and
|
|
dismissal is bind() → server-confirmed DELETE — never a POST abort
|
|
(``_sendAbort``), which fired on the interjection path too and
|
|
dispatched "dismissed" messages anyway. Reintroducing either hook
|
|
means re-parking the POST; that design deterministically dropped
|
|
messages from every timeout-bounded caller (coordinator client and
|
|
console proxy at 30s, SDKs, stock proxies)."""
|
|
interactive = _INTERACTIVE.read_text(encoding="utf-8")
|
|
coordinator = (_ROOT / "turnstone/console/static/coordinator/coordinator.js").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
conversation = (_ROOT / "turnstone/shared_static/conversation.js").read_text(encoding="utf-8")
|
|
composer_queue = (_ROOT / "turnstone/shared_static/composer_queue.js").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
for name, src in (
|
|
("interactive.js", interactive),
|
|
("coordinator.js", coordinator),
|
|
("conversation.js", conversation),
|
|
("composer_queue.js", composer_queue),
|
|
):
|
|
assert "sendAbortMs" not in src, f"{name}: the compaction-aware abort bound is dead"
|
|
assert "_sendAbort" not in src, f"{name}: dismiss must be bind() → DELETE, not a POST abort"
|
|
# The flat wedged-node bound stands in both panes: every /send answers
|
|
# within RTT now (dispatched / queued / deferred-with-msg_id).
|
|
assert "sendCtrl.abort(), 15000" in interactive
|
|
assert "sendCtrl.abort(), 15000" in coordinator
|
|
# The deferred-attachment count rides bind()'s documented options seam
|
|
# (controller dataset) — the per-pane element expando is dead.
|
|
for name, src in (
|
|
("interactive.js", interactive),
|
|
("coordinator.js", coordinator),
|
|
("composer_queue.js", composer_queue),
|
|
):
|
|
assert "_deferredAttachments" not in src, (
|
|
f"{name}: deferred state must ride bind(el, msgId, opts), not an expando"
|
|
)
|
|
|
|
|
|
def test_deferred_send_settle_protocol_pins() -> None:
|
|
"""The deferred-chip settle protocol (round 7, C4): a deferred send's
|
|
queued chip keeps its retraction affordance exactly until the message
|
|
truly leaves the parked list. Pins the controller's contract and both
|
|
panes' wiring — losing any of these silently re-promotes parked
|
|
messages to "sent" while the server still honors DELETE (loss
|
|
disguised as delivery on a node restart)."""
|
|
interactive = _INTERACTIVE.read_text(encoding="utf-8")
|
|
coordinator = (_ROOT / "turnstone/console/static/coordinator/coordinator.js").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
composer_queue = (_ROOT / "turnstone/shared_static/composer_queue.js").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
# Controller: bind() stores the options on its own dataset state...
|
|
assert "function bind(el, msgId, opts)" in composer_queue
|
|
assert 'el.dataset.deferred = "1"' in composer_queue
|
|
assert "el.dataset.attachedCount = String(opts.attachedCount)" in composer_queue
|
|
# ...the idle sweep skips deferred AND unbound chips (the "idle ⇒
|
|
# drained" invariant is untrue for both)...
|
|
assert "if (el.dataset.deferred) return;" in composer_queue
|
|
assert "if (!el.dataset.msgId) return;" in composer_queue
|
|
# ...and settleDeferred branches on the fold-in arm: clear the flag
|
|
# only (DELETE still genuinely removes a folded message until the seam
|
|
# drains), promote only on the fresh-spawn arm.
|
|
assert "function settleDeferred(msgId, folded)" in composer_queue
|
|
assert "delete target.dataset.deferred;" in composer_queue
|
|
assert "settleDeferred: settleDeferred" in composer_queue
|
|
# A barrier-deferred entry can dispatch within milliseconds of its ack,
|
|
# so the SSE settle can beat the POST response's bind(): the controller
|
|
# parks chip-absent settles and bind() reconciles them — without this a
|
|
# raced chip stays flagged deferred and the idle sweep skips it forever.
|
|
# Expiry is TTL-based: a size cap evicted exactly this tab's raced
|
|
# settle when a window closed with a burst of deferred sends (ours
|
|
# parks FIRST, the foreign settles behind it overflow the cap).
|
|
assert "_preBindSettles" in composer_queue
|
|
assert "_preBindSettles.has(msgId)" in composer_queue
|
|
assert "PRE_BIND_SETTLE_TTL_MS" in composer_queue
|
|
assert "_preBindSettles.size" not in composer_queue, "size-cap eviction must stay dead"
|
|
# The full send-response settle matrix lives ONCE, in the shared
|
|
# helper — retro-convert (a parked, still-retractable message must not
|
|
# render as a sent bubble), the deferred busy-undo, and the queue_full
|
|
# idle-pane cleanup (bubble removed + busy restored: the refusal can
|
|
# now fire with no worker and no drain alive, so no state event would
|
|
# ever unstick the composer).
|
|
assert "export function settleSendResponse(queue, data, ctx)" in composer_queue
|
|
assert "!queuedEl && data.deferred" in composer_queue
|
|
assert "deferred: !!data.deferred" in composer_queue
|
|
assert "attachedCount: (data.attached_ids || []).length" in composer_queue
|
|
assert "ctx.busyIsOptimistic()" in composer_queue
|
|
assert composer_queue.count("ctx.optimisticEl.remove()") >= 2, (
|
|
"both the retro-convert and queue_full arms must clear the optimistic bubble"
|
|
)
|
|
# The missed-edge settle: a non-deferred chip binding onto an
|
|
# already-idle pane missed its only sweep — the post-bind promote
|
|
# (keyed on POST-bind chip state, honoring the aria-busy
|
|
# dismiss-in-flight discipline) is what settles it.
|
|
assert "!ctx.paneIsBusy()" in composer_queue
|
|
assert 'queuedEl.hasAttribute("aria-busy")' in composer_queue
|
|
# Both panes route their parsed /send response through the helper and
|
|
# consume the pane-tier settle event; the busy stamp is centralized in
|
|
# each pane's setBusy (source defaults to "server" — only the send
|
|
# flow's optimistic flip may ever be undone).
|
|
for name, src in (("interactive.js", interactive), ("coordinator.js", coordinator)):
|
|
assert "settleSendResponse(" in src, f"{name}: settle matrix must be the shared helper"
|
|
assert "busyIsOptimistic" in src, name
|
|
assert "paneIsBusy" in src, f"{name}: the missed-edge settle needs the live flag"
|
|
assert 'setBusy(true, "optimistic")' in src, f"{name}: optimistic flip must stamp"
|
|
assert "parsePriority(" in src, f"{name}: shared !!! parse"
|
|
assert 'case "message_dispatched"' in src, f"{name}: settle event not consumed"
|
|
assert "settleDeferred(" in src, name
|
|
# /command's degraded outcomes are ALL surfaced: busy, running (the
|
|
# backstop answer), error (503 — the worker never spawned), the
|
|
# status-less non-2xx arm (404 / proxy 502), and the transport catch —
|
|
# silence at any of them reads as success.
|
|
assert 'body.status === "running"' in interactive
|
|
assert 'body.status === "error"' in interactive
|
|
assert "Command failed (HTTP " in interactive
|
|
assert '"Command failed: " + err.message' in interactive
|
|
|
|
|
|
def test_settle_send_response_missed_edge_behavior(tmp_path) -> None:
|
|
"""Execute the shared settle helper under node and pin the
|
|
missed-edge matrix behaviorally (not just textually): a non-deferred
|
|
chip binding onto an idle pane promotes; a busy pane, a deferred
|
|
chip, and a dismiss-in-flight chip do not."""
|
|
import shutil
|
|
import subprocess
|
|
|
|
if shutil.which("node") is None:
|
|
pytest.skip("node binary not available on PATH")
|
|
helper = _ROOT / "turnstone/shared_static/composer_queue.js"
|
|
script = tmp_path / "settle_harness.mjs"
|
|
script.write_text(
|
|
f'const {{ settleSendResponse }} = await import("file://{helper}");\n'
|
|
+ """
|
|
function makeEl(over) {
|
|
const el = {
|
|
isConnected: true,
|
|
classList: { contains: (c) => c === "msg-queued" },
|
|
dataset: {},
|
|
hasAttribute: () => false,
|
|
};
|
|
return Object.assign(el, over || {});
|
|
}
|
|
function run(queuedEl, paneBusy, data) {
|
|
const calls = [];
|
|
const queue = {
|
|
bind: (el, id, opts) => {
|
|
calls.push("bind");
|
|
// Mirror the real bind: stamp the deferred flag from opts.
|
|
if (opts && opts.deferred) el.dataset.deferred = "1";
|
|
},
|
|
promote: () => calls.push("promote"),
|
|
remove: () => calls.push("remove"),
|
|
addQueuedMessage: () => makeEl(),
|
|
};
|
|
settleSendResponse(queue, data, {
|
|
queuedEl,
|
|
optimisticEl: null,
|
|
isBusy: true,
|
|
displayText: "t",
|
|
priority: "notice",
|
|
setBusy: () => {},
|
|
busyIsOptimistic: () => false,
|
|
paneIsBusy: () => paneBusy,
|
|
renderError: () => {},
|
|
consumeAttachments: () => {},
|
|
});
|
|
return calls;
|
|
}
|
|
const queued = { status: "queued", msg_id: "m1" };
|
|
let c = run(makeEl(), false, queued);
|
|
if (!(c.includes("bind") && c.includes("promote")))
|
|
throw new Error("missed-edge chip must promote: " + c);
|
|
c = run(makeEl(), true, queued);
|
|
if (c.includes("promote")) throw new Error("busy pane must not promote: " + c);
|
|
c = run(makeEl(), false, { status: "queued", msg_id: "m1", deferred: true });
|
|
if (c.includes("promote"))
|
|
throw new Error("deferred chip is message_dispatched's: " + c);
|
|
c = run(makeEl({ hasAttribute: (a) => a === "aria-busy" }), false, queued);
|
|
if (c.includes("promote"))
|
|
throw new Error("dismiss-in-flight chip must be left to its DELETE verdict: " + c);
|
|
// Null / non-object 2xx body (a misbehaving proxy answering `200 null`): the
|
|
// helper normalizes it to {} so neither call site guards — it must fall through
|
|
// to the unknown/"ok" arm and SETTLE the optimistic chip (promote), never throw
|
|
// and strand a delivered message as a connection error. (The no-op
|
|
// consumeAttachments stub cannot prevent this: data.attached_ids is evaluated to
|
|
// build the :639 call args, so against unfixed code this line throws and crashes
|
|
// the harness.)
|
|
c = run(makeEl(), false, null);
|
|
if (!c.includes("promote"))
|
|
throw new Error("null body must settle via unknown-ok, not throw: " + c);
|
|
console.log("settle matrix OK");
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
proc = subprocess.run(
|
|
["node", str(script)],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=15,
|
|
)
|
|
assert proc.returncode == 0, f"settle harness failed:\n{proc.stderr}\n{proc.stdout}"
|