返回 DeepSeek-Reasonix
runtime-attention-notifications.test.tsx
根目录 / desktop / frontend / src / __tests__ / runtime-attention-notifications.test.tsx
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
98 lines Plain Text