返回 DeepSeek-Reasonix
workspace-turn-verification.test.tsx
根目录 / desktop / frontend / src / __tests__ / workspace-turn-verification.test.tsx
1 // Run: tsx src/__tests__/workspace-turn-verification.test.tsx
2
3 import { JSDOM } from "jsdom";
4 import { registerHooks } from "node:module";
5 import React, { act } from "react";
6 import { createRoot, type Root } from "react-dom/client";
7 import { WORKSPACE_TURN_VERIFICATION_ID, WorkspacePanel } from "../components/WorkspacePanel";
8 import { WorkspaceTurnResult } from "../components/WorkspaceTurnResult";
9 import { TurnCheckDetails } from "../components/TurnCheckDetails";
10 import { LocaleProvider } from "../lib/i18n";
11 import type { AppBindings } from "../lib/bridge";
12 import type { WireCompletionSummary } from "../lib/types";
13 import { installDesktopHostStub } from "./desktopHostStub";
14
15 registerHooks({
16 resolve(specifier, context, nextResolve) {
17 if (specifier.endsWith(".css")) {
18 return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
19 }
20 return nextResolve(specifier, context);
21 },
22 });
23
24 let passed = 0;
25 let failed = 0;
26
27 function ok(value: unknown, label: string) {
28 if (value) {
29 process.stdout.write(` PASS ${label}\n`);
30 passed += 1;
31 } else {
32 process.stdout.write(` FAIL ${label}\n`);
33 failed += 1;
34 }
35 }
36
37 const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0));
38
39 async function waitFor(label: string, predicate: () => boolean) {
40 for (let attempt = 0; attempt < 30; attempt += 1) {
41 await act(async () => {
42 await flushPromises();
43 });
44 if (predicate()) return;
45 }
46 throw new Error(`timed out waiting for ${label}`);
47 }
48
49 class TestResizeObserver {
50 observe() {}
51 unobserve() {}
52 disconnect() {}
53 }
54
55 function installDom() {
56 const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
57 pretendToBeVisual: true,
58 url: "http://localhost/",
59 });
60 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
61 globalThis.window = dom.window as unknown as Window & typeof globalThis;
62 globalThis.document = dom.window.document;
63 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
64 globalThis.Node = dom.window.Node;
65 globalThis.Element = dom.window.Element;
66 globalThis.HTMLElement = dom.window.HTMLElement;
67 globalThis.Event = dom.window.Event;
68 globalThis.CustomEvent = dom.window.CustomEvent;
69 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
70 globalThis.MouseEvent = dom.window.MouseEvent;
71 globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
72 globalThis.MutationObserver = dom.window.MutationObserver;
73 globalThis.ResizeObserver = TestResizeObserver;
74 dom.window.ResizeObserver = TestResizeObserver;
75 globalThis.localStorage = dom.window.localStorage;
76 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
77 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
78 (dom.window.HTMLElement.prototype as unknown as { attachEvent: () => void }).attachEvent = () => {};
79 (dom.window.HTMLElement.prototype as unknown as { detachEvent: () => void }).detachEvent = () => {};
80 Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => {} });
81 Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", { configurable: true, get: () => 320 });
82 Object.defineProperty(dom.window.HTMLElement.prototype, "offsetHeight", { configurable: true, get: () => 300 });
83 Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", {
84 configurable: true,
85 value: () => ({ x: 0, y: 0, top: 0, left: 0, right: 320, bottom: 300, width: 320, height: 300, toJSON: () => ({}) }) as DOMRect,
86 });
87 return dom;
88 }
89
90 type WorkspaceProps = Parameters<typeof WorkspacePanel>[0];
91
92 async function createHarness(props: Partial<WorkspaceProps>) {
93 const dom = installDom();
94 installDesktopHostStub(({
95 main: {
96 App: {
97 ListDirForTab: async () => [],
98 SearchFileRefsForTab: async () => [],
99 WorkspaceGitHistory: async () => [],
100 WorkspaceChanges: async () => ({ files: [], gitAvailable: true }),
101 WorkspaceChangeDetail: async () => ({}),
102 WorkspaceTurnChanges: async () => ({ turn: 0, coverage: "unknown", files: [], added: 0, removed: 0, reasons: [] }),
103 WorkspaceTurnChangeDetail: async () => null,
104 TurnCheckLog: async () => null,
105 ReadFileForTab: async (_tabID, path) => ({ path, body: "", size: 0, truncated: false, binary: false }),
106 } as Partial<AppBindings> as AppBindings,
107 },
108 }).main.App);
109 const root = createRoot(document.getElementById("root")!);
110 let currentProps: WorkspaceProps = {
111 open: true,
112 tabId: "tab-a",
113 cwd: "/repo",
114 maximized: false,
115 onClose: () => {},
116 onToggleMaximized: () => {},
117 ...props,
118 };
119 const rerender = async (next: Partial<WorkspaceProps> = {}) => {
120 currentProps = { ...currentProps, ...next };
121 await act(async () => {
122 root.render(<LocaleProvider><WorkspacePanel {...currentProps} /></LocaleProvider>);
123 await flushPromises();
124 });
125 };
126 await rerender();
127 return { dom, root, rerender };
128 }
129
130 async function closeHarness(dom: JSDOM, root: Root) {
131 await act(async () => root.unmount());
132 dom.window.close();
133 }
134
135 function summary(mutations: number, checksFailed = 1): WireCompletionSummary {
136 return {
137 preset: "balanced",
138 verdict: "partial",
139 mutations,
140 checks_passed: 2,
141 checks_failed: checksFailed,
142 checks_suppressed: 1,
143 review: "passed",
144 gap_kinds: ["stale_check", "future_internal_value"],
145 constraint_degraded: true,
146 };
147 }
148
149 console.log("\nworkspace turn verification");
150
151 {
152 const current = summary(3);
153 const { dom, root } = await createHarness({ initialViewMode: "changed", completionSummary: current });
154 ok(document.querySelector(".workspace-turn-result") === null, "workspace overview does not imply the current turn covers all changes");
155 await closeHarness(dom, root);
156 }
157
158 {
159 const current = summary(2);
160 const historical = { ...summary(7), receipt: { verdict: "partial", verifications: [{ command: "go test ./...", passed: false, exitCode: 1, toolCallId: "check-old", stale: true }] } };
161 const request = { id: 1, summary: historical, tabId: "tab-a", turnStartAt: 100, currentSummary: current, sessionPath: "/old.json", view: "checks" as const };
162 const { dom, root, rerender } = await createHarness({ initialViewMode: "changed", completionSummary: current, sessionPath: "/old.json", verificationRevealRequest: request, turnStartAt: 100 });
163 await waitFor("historical check", () => document.body.textContent?.includes("go test ./...") === true);
164 const text = document.body.textContent ?? "";
165 ok(text.includes("Exit code: 1"), "actual exit code appears with the historical command");
166 ok(!text.includes("7 files") && !text.includes("7 changes"), "mutation receipts are not presented as a diff inventory");
167 ok(text.includes("stale") || text.includes("Stale"), "later changes mark checks stale");
168 ok(!Array.from(document.querySelectorAll("button")).some(b => /Run|Retry|Continue verification/.test(b.textContent ?? "")), "result panel has only view actions");
169 await rerender({ sessionPath: "/new.json" });
170 ok(document.querySelector(".workspace-turn-result") === null, "session switch immediately fences a historical request");
171 await closeHarness(dom, root);
172 }
173
174 {
175 const current = summary(0);
176 const historical = { ...summary(9), turnId: "turn-old", receipt: { verdict: "complete", diff: { id: "0:42", turn: 0, coverage: "complete" as const, files: [{ path: "src/old.ts", kind: "modify", added: 2, removed: 1 }], added: 2, removed: 1, reasons: [] }, verifications: [] } };
177 const request = { id: 2, summary: historical, tabId: "tab-a", turnStartAt: 300, currentSummary: current, sessionPath: "/history.json", view: "changes" as const };
178 const { dom, root, rerender } = await createHarness({ initialViewMode: "changed", completionSummary: current, verificationRevealRequest: request, sessionPath: "/history.json", turnStartAt: 300 });
179 await waitFor("frozen result", () => document.body.textContent?.includes("src/old.ts") === true);
180 ok(document.body.textContent?.includes("+2"), "historical counts come from the frozen receipt");
181 await rerender({ completionSummary: summary(42) });
182 ok(document.body.textContent?.includes("src/old.ts"), "a current summary refresh preserves the selected historical result");
183 await rerender({ turnStartAt: 301 });
184 ok(document.querySelector(".workspace-turn-result") === null, "a new turn fences the old reveal");
185 await closeHarness(dom, root);
186 }
187
188 {
189 const dom = installDom();
190 const root = createRoot(document.getElementById("root")!);
191 const pending = new Map<string, (value: unknown) => void>();
192 const deferred = (key: string) => new Promise(resolve => pending.set(key, resolve));
193 installDesktopHostStub({
194 WorkspaceTurnChanges: (_tab, session) => deferred(`files:${session}`),
195 WorkspaceTurnChangeDetail: (_tab, session) => deferred(`detail:${session}`),
196 TurnCheckLog: (_tab, session) => deferred(`log:${session}`),
197 } as Partial<AppBindings> as AppBindings);
198 const diff = { id: "frozen", turn: 0, coverage: "complete" as const, files: [{ path: "f.ts", kind: "modify", added: 1, removed: 1 }], added: 1, removed: 1, reasons: [] };
199 const result = { ...summary(1), receipt: { verdict: "partial", diff, verifications: [{ command: "test", passed: false, toolCallId: "check", toolResultId: "entry" }] } };
200 const paint = (session: string) => act(async () => root.render(<LocaleProvider><WorkspaceTurnResult summary={result} tabId="a" sessionPath={session} initialView="changes" onAllChanges={() => {}} /></LocaleProvider>));
201 await paint("old");
202 await paint("new");
203 await act(async () => pending.get("files:old")!(diff));
204 ok(document.querySelector<HTMLButtonElement>(".turn-file-list__entry")?.disabled, "old session response cannot enable the new session file list");
205 await act(async () => pending.get("files:new")!(diff));
206 await act(async () => document.querySelector<HTMLButtonElement>(".turn-file-list__entry")!.click());
207 await paint("replacement");
208 await act(async () => pending.get("detail:new")!({ ...diff.files[0], patch: "stale private patch" }));
209 ok(!document.body.textContent?.includes("stale private patch"), "late diff response is discarded after session replacement");
210 const logs = (session: string) => act(async () => root.render(<LocaleProvider><TurnCheckDetails summary={result} tabId="a" sessionPath={session} /></LocaleProvider>));
211 await logs("old");
212 await act(async () => { const details = document.querySelector<HTMLDetailsElement>("details")!; details.open = true; details.dispatchEvent(new Event("toggle")); });
213 await waitFor("old log request", () => pending.has("log:old"));
214 await logs("new");
215 await waitFor("new log request", () => pending.has("log:new"));
216 await act(async () => pending.get("log:old")!({ output: "stale private log" }));
217 ok(!document.body.textContent?.includes("stale private log"), "late log response is discarded after session replacement");
218 await act(async () => pending.get("log:new")!(null));
219 ok(document.body.textContent?.includes("Logs have not arrived, were cleared, or cannot be linked."), "cleared logs remain explicitly unavailable");
220 await closeHarness(dom, root);
221 }
222
223 console.log(`\n${passed} passed, ${failed} failed`);
224 if (failed > 0) process.exit(1);
225
225 lines Plain Text