返回 DeepSeek-Reasonix
use-controller-history-live-race.test.tsx
根目录 / desktop / frontend / src / __tests__ / use-controller-history-live-race.test.tsx
1 // Run: tsx src/__tests__/use-controller-history-live-race.test.tsx
2
3 import { JSDOM } from "jsdom";
4 import React, { act } from "react";
5 import { createRoot } from "react-dom/client";
6 import type { AppBindings } from "../lib/bridge";
7 import { initialState, useController } from "../lib/useController";
8 import { getTranscriptStore } from "../lib/transcriptStore";
9 import { historySliceFromMessages } from "./mockHistorySlice";
10 import type { HistorySlice, Meta, TabMeta, WireEvent } from "../lib/types";
11 import { installDesktopHostStub } from "./desktopHostStub";
12
13 let passed = 0;
14 let failed = 0;
15
16 function ok(value: boolean, label: string) {
17 if (value) {
18 process.stdout.write(` PASS ${label}\n`);
19 passed += 1;
20 } else {
21 process.stdout.write(` FAIL ${label}\n`);
22 failed += 1;
23 }
24 }
25
26 function deferred<T>() {
27 let resolve!: (value: T) => void;
28 const promise = new Promise<T>((done) => { resolve = done; });
29 return { promise, resolve };
30 }
31
32 function flushPromises(): Promise<void> {
33 return new Promise((resolve) => setTimeout(resolve, 0));
34 }
35
36 async function waitFor(label: string, predicate: () => boolean) {
37 for (let attempt = 0; attempt < 30; attempt += 1) {
38 await act(async () => { await flushPromises(); });
39 if (predicate()) return;
40 }
41 throw new Error(`timed out waiting for ${label}`);
42 }
43
44 console.log("\nuse controller history/live race");
45
46 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
47 pretendToBeVisual: true,
48 url: "http://localhost/",
49 });
50 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
51 globalThis.window = dom.window as unknown as Window & typeof globalThis;
52 globalThis.document = dom.window.document;
53 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
54 globalThis.Node = dom.window.Node;
55 globalThis.HTMLElement = dom.window.HTMLElement;
56 globalThis.Event = dom.window.Event;
57 globalThis.CustomEvent = dom.window.CustomEvent;
58 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
59 globalThis.MouseEvent = dom.window.MouseEvent;
60 globalThis.localStorage = dom.window.localStorage;
61 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
62 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
63
64 const tab: TabMeta = {
65 id: "tab-live",
66 scope: "project",
67 workspaceRoot: "/repo",
68 workspaceName: "repo",
69 workspacePath: "/repo",
70 topicId: "topic-live",
71 topicTitle: "General",
72 sessionPath: "/repo/sessions/live.jsonl",
73 sessionRevision: 1,
74 sessionDigest: "digest-v1",
75 label: "model",
76 ready: true,
77 running: false,
78 mode: "normal",
79 toolApprovalMode: "ask",
80 tokenMode: "full",
81 active: true,
82 cwd: "/repo",
83 };
84 const meta: Meta = {
85 label: "model",
86 ready: true,
87 eventChannel: "agent:event",
88 sessionPath: tab.sessionPath,
89 sessionRevision: tab.sessionRevision,
90 sessionDigest: tab.sessionDigest,
91 cwd: "/repo",
92 workspaceRoot: "/repo",
93 workspaceName: "repo",
94 workspacePath: "/repo",
95 autoApproveTools: false,
96 bypass: false,
97 collaborationMode: "normal",
98 toolApprovalMode: "ask",
99 tokenMode: "full",
100 goal: "",
101 goalStatus: "stopped",
102 };
103 const historyGate = deferred<HistorySlice>();
104 let historyStarted = false;
105
106 const desktopStub = installDesktopHostStub(({
107 main: {
108 App: {
109 ListTabs: async () => [tab],
110 MetaForTab: async () => meta,
111 ContextUsageForTab: async () => ({ used: 0, window: 100, sessionTokens: 0 }),
112 EffortForTab: async () => ({ supported: true, current: "auto", default: "auto", levels: ["auto"] }),
113 BalanceForTab: async () => ({ available: false, display: "" }),
114 JobsForTab: async () => [],
115 CheckpointsForTab: async () => [],
116 ForkTargetsForTab: async () => ({ targets: [], verifiable: false }),
117 HistorySliceForTab: async () => {
118 historyStarted = true;
119 return historyGate.promise;
120 },
121 HistoryCheckpointTurnsForTab: async () => [],
122 ReplayPendingPrompts: async () => {},
123 } as Partial<AppBindings> as AppBindings,
124 },
125 }).main.App);
126
127 type Controller = ReturnType<typeof useController>;
128 let controller: Controller | undefined;
129 let renders = 0;
130 function Probe() {
131 renders++;
132 controller = useController();
133 return null;
134 }
135
136 const rootElement = document.getElementById("root");
137 if (!rootElement) throw new Error("missing root");
138 const root = createRoot(rootElement);
139 await act(async () => {
140 root.render(<Probe />);
141 await flushPromises();
142 });
143 await waitFor("history request", () => historyStarted && (desktopStub.events.get("agent:event")?.size ?? 0) > 0);
144
145 await act(async () => {
146 desktopStub.emit("agent:event", { kind: "turn_started", tabId: tab.id });
147 desktopStub.emit("agent:event", { kind: "text", tabId: tab.id, messageId: "active", text: "active prefix" });
148 await flushPromises();
149 });
150 ok(controller?.state.transcriptConnection === "syncing", "frames wait for atomic baseline installation");
151
152 historyGate.resolve(historySliceFromMessages(
153 tab.id,
154 [{ role: "user", content: "stale durable history" }],
155 { cursor: "", turns: 12 },
156 { revision: 1, digest: "digest-v1" },
157 ));
158 await waitFor("hydration completion", () => controller?.state.hydrating === false);
159
160 ok(controller?.state.running ?? false, "late history keeps the live turn running");
161 ok(controller?.state.items.some((item) => item.kind === "assistant" && item.streaming) ?? false, "late history keeps the live assistant stream");
162 // The page read before the turn started is this session's history, not a
163 // competing version of it: it goes in front of the live turn instead of
164 // replacing it, so the turn never streams over a blank transcript.
165 ok(controller?.state.items[0]?.kind === "user", "late history lands in front of the live turn");
166 ok(controller?.state.items.at(-1)?.kind === "assistant", "late history leaves the live turn at the tail");
167
168 await act(async () => { await flushPromises(); await flushPromises(); });
169 const backgroundMeta = { ...meta, sessionPath: "/background", label: "before" };
170 getTranscriptStore().setState("background", { ...initialState, meta: backgroundMeta, running: true });
171 let backgroundNotifications = 0;
172 const releaseBackground = getTranscriptStore().subscribeState("background", () => backgroundNotifications++);
173 const foregroundRenders = renders;
174 await act(async () => {
175 desktopStub.emit("tab:meta", { tabId: "background", meta: { ...backgroundMeta, label: "updated" } });
176 await flushPromises();
177 });
178 ok(backgroundNotifications > 0, "background state still reaches its own subscribers");
179 ok(getTranscriptStore().states.get("background")?.meta?.label === "updated", "background metadata is published");
180 ok(getTranscriptStore().states.get("background")?.running === true, "background task keeps running");
181 ok(renders === foregroundRenders, "background structural events do not rerender the foreground controller");
182 releaseBackground();
183 await act(async () => { root.unmount(); });
184 dom.window.close();
185
186 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
187 if (failed > 0) process.exit(1);
188
188 lines Plain Text