| 1 | import assert from "node:assert/strict"; |
| 2 | import React, { act, useLayoutEffect, useRef } from "react"; |
| 3 | import { createRoot } from "react-dom/client"; |
| 4 | import { JSDOM } from "jsdom"; |
| 5 | import { useRuntimeEventHandlers } from "../app-runtime/useRuntimeEventHandlers"; |
| 6 | import { runtimeStateStore, type RuntimeProjection, type RuntimeSession } from "../lib/runtimeStateStore"; |
| 7 | import { ToastProvider } from "../lib/toast"; |
| 8 | import { LocaleProvider } from "../lib/i18n"; |
| 9 | import { setAttentionPreference, setSuccessPreference } from "../lib/sound"; |
| 10 | import { installDesktopHostStub } from "./desktopHostStub"; |
| 11 | |
| 12 | const dom = new JSDOM("<div id='root'></div>", { url: "http://localhost" }); |
| 13 | Object.assign(globalThis, { window: dom.window, document: dom.window.document, |
| 14 | localStorage: dom.window.localStorage, IS_REACT_ACT_ENVIRONMENT: true }); |
| 15 | let chimes = 0; |
| 16 | // The sound suite verifies synthesis; this probe counts actual playback entries. |
| 17 | Object.assign(globalThis, { AudioContext: class { constructor() { chimes++; } } }); |
| 18 | setAttentionPreference("synth"); |
| 19 | setSuccessPreference("synth"); |
| 20 | installDesktopHostStub({ ListTabs: async () => [] }); |
| 21 | let handlers!: ReturnType<typeof useRuntimeEventHandlers>; |
| 22 | function Probe({ active = "B" }: { active?: string }) { |
| 23 | handlers = useRuntimeEventHandlers({ activeTabId: active, workspaceScopeKey: "fixture", |
| 24 | workspaceScopeActiveTabRef: useRef(active), userPlanModeByTabRef: useRef({}), |
| 25 | setTabMetas() {}, setTabOrderIds() {}, setComposerProfilesByTab() {}, setDockRefreshKey() {}, |
| 26 | setProjectRevision() {}, setWorkspaceControllerEpoch() {}, async setControllerCollaborationMode() {} }); |
| 27 | useLayoutEffect(() => { |
| 28 | // Layout precedes the notification module's passive loading effect. |
| 29 | handlers.handleRuntimeEvent({ kind: "turn_done", tabId: "B" }); |
| 30 | }, []); |
| 31 | return <textarea aria-label="draft" defaultValue="B draft" />; |
| 32 | } |
| 33 | const root = createRoot(document.getElementById("root")!); |
| 34 | const paint = (active = "B") => act(async () => root.render(<LocaleProvider><ToastProvider><Probe active={active} /></ToastProvider></LocaleProvider>)); |
| 35 | let revision = 0; |
| 36 | function session(kind = "ask", turnId = "turn-A", requestId = "1"): RuntimeSession { |
| 37 | return { tabId: "detached:A", scope: "project", workspaceRoot: "/fixture", topicId: "topic-A", |
| 38 | sessionId: "session-A", sessionPath: "", sessionGeneration: 1, open: false, remote: false, freshness: "synced", |
| 39 | state: { schemaVersion: 1, runtimeEpoch: "runtime-A", activityRevision: 1, revision: 1, phase: "executing", |
| 40 | running: true, turnId, turnStatus: "running", turnEventSeq: 1, pendingPrompt: true, |
| 41 | pendingInteractions: [{ requestId, kind, turnId, runtimeEpoch: "runtime-A", headId: "head-A" }], |
| 42 | cancelRequested: false, cancellable: true, backgroundJobs: 0, activity: "" } }; |
| 43 | } |
| 44 | async function publish(sessions: RuntimeSession[]) { |
| 45 | const snapshot: RuntimeProjection = { epoch: "host", revision: ++revision, sessions, |
| 46 | topics: [{ scope: "project", workspaceRoot: "/fixture", node: { key: "topic-A", topicId: "topic-A", label: "Conversation A", kind: "session", session: { hostId: "local", sessionId: "session-A" } } }] }; |
| 47 | await act(async () => runtimeStateStore.commit(snapshot)); |
| 48 | } |
| 49 | try { |
| 50 | await paint(); |
| 51 | await act(async () => { await import("../lib/runtimeNotifications"); }); |
| 52 | assert.equal(chimes, 1, "completion arriving before notification code loads is queued, not lost"); |
| 53 | chimes = 0; |
| 54 | await act(async () => handlers.handleRuntimeEvent({ kind: "turn_done", err: "fixture error" })); |
| 55 | assert.equal(chimes, 0, "failed completion remains silent"); |
| 56 | await publish([session()]); |
| 57 | assert.equal(chimes, 1, "detached A asks while B is being edited: sound must play before switching"); |
| 58 | assert.match(document.querySelector('[role="status"]')!.textContent!, /Conversation A.*answer/i); |
| 59 | assert.equal((document.querySelector("textarea") as HTMLTextAreaElement).value, "B draft"); |
| 60 | await publish([session()]); |
| 61 | await act(async () => handlers.handleRuntimeReady("A")); |
| 62 | await act(async () => handlers.handleRuntimeRebuilt("A", "new-view-epoch")); |
| 63 | await act(async () => handlers.handleRuntimeReady()); |
| 64 | await paint("A"); |
| 65 | await act(async () => handlers.handleRuntimeEvent({ kind: "ask_request", tabId: "A", turnId: "turn-A", ask: { id: "1", questions: [] } })); |
| 66 | assert.equal(chimes, 1, "reattach, ready and prompt replay must not ring again"); |
| 67 | await paint("B"); |
| 68 | await act(async () => handlers.handleRuntimeEvent({ kind: "approval_request", tabId: "A", turnId: "turn-approval", approval: { id: "1", tool: "bash", subject: "fixture" } })); |
| 69 | await publish([session("approval", "turn-approval")]); |
| 70 | assert.equal(chimes, 2, "wire first, snapshot second: approval rings once"); |
| 71 | await publish([session("ask", "turn-new")]); |
| 72 | assert.equal(chimes, 3, "a new turn may reuse a per-controller request id"); |
| 73 | await publish([{ ...session("ask", "turn-remote"), remote: true, tabId: "B", hostId: "remote" }]); |
| 74 | assert.equal(chimes, 4, "remote background session sharing the visible tab still notifies"); |
| 75 | await publish([{ ...session("ask", "turn-unknown"), freshness: "unknown" }]); |
| 76 | assert.equal(chimes, 4, "untrusted stale remote state cannot create a new notification"); |
| 77 | await publish([{ ...session("ask", "turn-unknown"), freshness: "synced" }]); |
| 78 | assert.equal(chimes, 5, "reconnection can recover a previously unseen pending question"); |
| 79 | setAttentionPreference("off"); |
| 80 | await publish([session("ask", "turn-muted")]); |
| 81 | assert.equal(chimes, 5, "muted audio stays muted"); |
| 82 | assert.equal(document.querySelectorAll(".toast").length, 6, "visual alerts remain enabled with audio off"); |
| 83 | setAttentionPreference("synth"); |
| 84 | await publish([{ ...session("ask", "turn-visible"), tabId: "B", open: true }]); |
| 85 | assert.equal(chimes, 6, "visible requests retain their normal prompt sound"); |
| 86 | assert.equal(document.querySelectorAll(".toast").length, 6, "visible prompt cards need no background toast"); |
| 87 | for (const kind of ["plan", "recovery"]) { |
| 88 | await publish([session(kind, `turn-${kind}`)]); |
| 89 | await act(async () => handlers.handleRuntimeEvent({ kind: "approval_request", tabId: "A", turnId: `turn-${kind}`, |
| 90 | approval: { id: "1", tool: "fixture", subject: "fixture", kind } })); |
| 91 | } |
| 92 | assert.equal(chimes, 8, "plan and recovery approvals share request/snapshot dedupe"); |
| 93 | await act(async () => root.unmount()); |
| 94 | await publish([session("ask", "turn-disposed")]); |
| 95 | assert.equal(chimes, 8, "unmount removes the global state subscription"); |
| 96 | console.log("runtime attention: background Ask, approval, replay, navigation, remote, reconnect, mute and disposal passed"); |
| 97 | } finally { dom.window.close(); } |
| 98 |