返回 DeepSeek-Reasonix
subagent-progress-card.test.tsx
根目录 / desktop / frontend / src / __tests__ / subagent-progress-card.test.tsx
1 // Run: tsx src/__tests__/subagent-progress-card.test.tsx
2 //
3 // Verifies the ToolCard rendering of the sub-agent progress chip (phase +
4 // elapsed + recent activity) and the expanded preview body (reasoning /
5 // response preview / notices), including the terminal phase visuals.
6
7 import { JSDOM } from "jsdom";
8 import { registerHooks } from "node:module";
9 import React from "react";
10 import { act } from "react";
11 import { createRoot } from "react-dom/client";
12 import { ToolCard } from "../components/ToolCard";
13 import { LocaleProvider } from "../lib/i18n";
14 import { setReasoningSummaryEnabled } from "../lib/reasoningSummaryPreference";
15 import { hydrateSessionExperience } from "../lib/sessionExperience";
16 import type { Item, SubagentProgress } from "../lib/useController";
17
18 registerHooks({
19 resolve(specifier, context, nextResolve) {
20 if (specifier.endsWith(".css")) {
21 return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
22 }
23 return nextResolve(specifier, context);
24 },
25 });
26
27 type ToolItem = Extract<Item, { kind: "tool" }>;
28
29 let passed = 0;
30 let failed = 0;
31
32 function ok(value: unknown, label: string) {
33 if (value) {
34 process.stdout.write(` PASS ${label}\n`);
35 passed += 1;
36 } else {
37 process.stdout.write(` FAIL ${label}\n`);
38 failed += 1;
39 }
40 }
41
42 function flushTimers(): Promise<void> {
43 return new Promise((resolve) => setTimeout(resolve, 0));
44 }
45
46 function installDom() {
47 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
48 pretendToBeVisual: true,
49 url: "http://localhost/",
50 });
51 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
52 globalThis.window = dom.window as unknown as Window & typeof globalThis;
53 globalThis.document = dom.window.document;
54 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
55 globalThis.Node = dom.window.Node;
56 globalThis.Element = dom.window.Element;
57 globalThis.HTMLElement = dom.window.HTMLElement;
58 globalThis.Event = dom.window.Event;
59 globalThis.CustomEvent = dom.window.CustomEvent;
60 globalThis.MouseEvent = dom.window.MouseEvent;
61 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
62 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
63 dom.window.matchMedia = () => ({
64 matches: true,
65 media: "(prefers-reduced-motion: reduce)",
66 onchange: null,
67 addListener: () => undefined,
68 removeListener: () => undefined,
69 addEventListener: () => undefined,
70 removeEventListener: () => undefined,
71 dispatchEvent: () => false,
72 });
73 return dom;
74 }
75
76 function makeItem(phase: SubagentProgress["phase"], over: Partial<SubagentProgress> = {}): ToolItem {
77 const now = Date.now();
78 return {
79 kind: "tool",
80 id: `task-${phase}`,
81 name: "task",
82 args: "{}",
83 readOnly: true,
84 status: phase === "completed" || phase === "failed" ? "done" : phase === "cancelled" ? "stopped" : "running",
85 subagentProgress: {
86 phase,
87 reasoning: "**thinking** step by step\n\n- inspect\n- verify",
88 text: "draft answer preview",
89 notice: "heads up",
90 lastActivityAt: now - 3_000,
91 startedAt: now - 12_000,
92 truncated: false,
93 ...over,
94 },
95 };
96 }
97
98 console.log("\nsubagent progress card");
99
100 {
101 const dom = installDom();
102 const rootEl = document.getElementById("root");
103 if (!rootEl) throw new Error("missing root");
104 const root = createRoot(rootEl);
105 hydrateSessionExperience("standard");
106 await act(async () => {
107 root.render(React.createElement(LocaleProvider, null, React.createElement(ToolCard, { item: makeItem("reasoning") })));
108 for (let i = 0; i < 50; i += 1) {
109 await flushTimers();
110 if (document.querySelector(".tool__subagent-preview .md")) break;
111 }
112 });
113 ok(!!document.querySelector(".tool__subagent-preview .md"), "standard mode expands reasoning when the card mounts mid-stream");
114
115 const responding = makeItem("responding");
116 responding.id = "task-reasoning";
117 await act(async () => {
118 root.render(React.createElement(LocaleProvider, null, React.createElement(ToolCard, { item: responding })));
119 await flushTimers();
120 });
121 ok(!!document.querySelector(".tool__subagent-preview"), "standard mode keeps the subagent card open after reasoning starts responding");
122 ok(!!document.querySelector(".tool__subagent-preview .md"), "standard mode keeps completed subagent reasoning expanded while the task runs");
123
124 const completed = makeItem("completed");
125 completed.id = "task-reasoning";
126 await act(async () => {
127 root.render(React.createElement(LocaleProvider, null, React.createElement(ToolCard, { item: completed })));
128 await flushTimers();
129 });
130 ok(!document.querySelector(".tool__subagent-preview"), "standard mode collapses the untouched subagent card after the task settles");
131 await act(async () => root.unmount());
132 dom.window.close();
133 hydrateSessionExperience("standard");
134 }
135
136 {
137 const dom = installDom();
138 const rootEl = document.getElementById("root");
139 if (!rootEl) throw new Error("missing root");
140 const root = createRoot(rootEl);
141 hydrateSessionExperience("deep");
142 const running = makeItem("reasoning");
143 await act(async () => {
144 root.render(React.createElement(LocaleProvider, null, React.createElement(ToolCard, { item: running })));
145 for (let i = 0; i < 50; i += 1) {
146 await flushTimers();
147 if (document.querySelector(".tool__subagent-preview .md")) break;
148 }
149 });
150 ok(!!document.querySelector(".tool__subagent-preview .md"), "deep mode opens live sub-agent reasoning");
151
152 const completed = makeItem("completed", { durationMs: 42_000 });
153 completed.id = running.id;
154 await act(async () => {
155 root.render(React.createElement(LocaleProvider, null, React.createElement(ToolCard, { item: completed })));
156 await flushTimers();
157 });
158 ok(!!document.querySelector(".tool__subagent-preview .md"), "deep mode keeps completed sub-agent reasoning visible");
159
160 await act(async () => {
161 document.querySelector<HTMLButtonElement>(".tool__head")?.click();
162 await flushTimers();
163 });
164 ok(!document.querySelector(".tool__subagent-preview"), "manual card collapse still wins in deep mode");
165
166 await act(async () => {
167 root.render(React.createElement(LocaleProvider, null, React.createElement(ToolCard, { key: "completed-history", item: completed })));
168 for (let i = 0; i < 50; i += 1) {
169 await flushTimers();
170 if (document.querySelector(".tool__subagent-preview .md")) break;
171 }
172 });
173 ok(!!document.querySelector(".tool__subagent-preview .md"), "deep mode opens completed sub-agent reasoning restored from history");
174
175 await act(async () => root.unmount());
176 dom.window.close();
177 hydrateSessionExperience("standard");
178 }
179
180 {
181 const dom = installDom();
182 const rootEl = document.getElementById("root");
183 if (!rootEl) throw new Error("missing root");
184 const root = createRoot(rootEl);
185
186 // Running card: chip shows phase, live elapsed and recent activity.
187 const running = makeItem("reasoning");
188 await act(async () => {
189 root.render(
190 React.createElement(LocaleProvider, null, React.createElement(ToolCard, { item: running })),
191 );
192 await flushTimers();
193 });
194 const chip = document.querySelector(".tool__subagent-chip");
195 ok(!!chip, "running card renders the progress chip");
196 ok(chip?.textContent?.includes("reasoning"), "chip shows the phase label");
197 ok(chip?.textContent?.includes("12s"), "chip shows the running elapsed");
198 ok(chip?.textContent?.includes("3s ago"), "chip shows recent activity");
199 ok(chip?.getAttribute("data-phase") === "reasoning", "chip carries the phase attribute");
200
201 // Standard keeps active process work reachable without an extra card click.
202 const head = document.querySelector(".tool__head") as HTMLButtonElement | null;
203 ok(!!head, "card head renders");
204 ok(!!document.querySelector(".tool__subagent-preview"), "active standard card renders the preview block");
205 ok(document.querySelector(".tool__subagent-preview-label")?.textContent === "Reasoning", "reasoning section label");
206 ok(!document.querySelector(".tool__subagent-preview .reasoning-summary"), "active standard reasoning is not replaced by a summary");
207 ok(!!document.querySelector(".tool__subagent-preview .md"), "active standard reasoning renders full Markdown");
208 ok(document.body.textContent?.includes("draft answer preview"), "response preview text visible");
209 ok(document.body.textContent?.includes("heads up"), "notice preview text visible");
210 ok(document.body.textContent?.includes("thinking step by step"), "reasoning preview text visible after expanding");
211 ok(document.querySelector(".tool__subagent-preview-text strong")?.textContent === "thinking", "reasoning preview renders Markdown emphasis");
212 ok(document.querySelectorAll(".tool__subagent-preview-text li").length === 2, "reasoning preview renders Markdown lists");
213
214 // The section label toggles back to the summary and re-expands.
215 const reasoningLabel = document.querySelector(".tool__subagent-preview-label") as HTMLButtonElement | null;
216 await act(async () => {
217 reasoningLabel?.click();
218 await flushTimers();
219 });
220 ok(!document.querySelector(".tool__subagent-preview .md"), "clicking the reasoning label collapses back to the summary");
221 ok(document.querySelector(".tool__subagent-preview .reasoning-summary")?.textContent === "- verify", "collapsed reasoning section shows the summary again");
222 await act(async () => {
223 document.querySelector<HTMLButtonElement>(".tool__subagent-preview-label")?.click();
224 for (let i = 0; i < 50; i += 1) {
225 await flushTimers();
226 if (document.querySelector(".tool__subagent-preview .md strong")) break;
227 }
228 });
229 ok(!!document.querySelector(".tool__subagent-preview .md strong"), "clicking the reasoning label expands the full Markdown");
230
231 await act(async () => {
232 setReasoningSummaryEnabled(false);
233 root.render(
234 React.createElement(LocaleProvider, null, React.createElement(ToolCard, { key: "summary-off", item: running })),
235 );
236 await flushTimers();
237 });
238 ok(!document.querySelector(".tool__subagent-preview .reasoning-summary"), "legacy summary-off cannot collapse active Standard reasoning");
239 ok(!!document.querySelector(".tool__subagent-preview .md"), "legacy summary-off keeps active process Markdown reachable");
240 await act(async () => {
241 setReasoningSummaryEnabled(true);
242 root.render(
243 React.createElement(LocaleProvider, null, React.createElement(ToolCard, { key: "summary-on", item: running })),
244 );
245 await flushTimers();
246 });
247 ok(!!document.querySelector(".tool__subagent-preview .md"), "legacy summary-on leaves the canonical active preview intact");
248
249 await act(async () => {
250 root.unmount();
251 });
252 dom.window.close();
253 }
254
255 {
256 const dom = installDom();
257 const rootEl = document.getElementById("root");
258 if (!rootEl) throw new Error("missing root");
259 const root = createRoot(rootEl);
260
261 // Terminal chips: completed/failed/cancelled show the final duration, no
262 // recent-activity suffix, and the existing status visuals.
263 const completed = makeItem("completed", { durationMs: 42_000 });
264 await act(async () => {
265 root.render(
266 React.createElement(LocaleProvider, null, React.createElement(ToolCard, { item: completed })),
267 );
268 await flushTimers();
269 });
270 const chip = document.querySelector(".tool__subagent-chip");
271 ok(chip?.textContent?.includes("completed"), "completed chip label");
272 ok(chip?.textContent?.includes("42s"), "completed chip shows the terminal duration");
273 ok(!chip?.textContent?.includes("ago"), "terminal chip drops the recent-activity suffix");
274 ok(!!document.querySelector(".tool__status-icon--ok"), "completed card shows the done icon");
275
276 await act(async () => {
277 root.unmount();
278 });
279 dom.window.close();
280 }
281
282 {
283 const dom = installDom();
284 const rootEl = document.getElementById("root");
285 if (!rootEl) throw new Error("missing root");
286 const root = createRoot(rootEl);
287
288 const cancelled = makeItem("cancelled", { durationMs: 500 });
289 await act(async () => {
290 root.render(
291 React.createElement(LocaleProvider, null, React.createElement(ToolCard, { item: cancelled })),
292 );
293 await flushTimers();
294 });
295 ok(document.querySelector(".tool__subagent-chip")?.textContent?.includes("cancelled"), "cancelled chip label");
296 ok(!!document.querySelector(".tool__status-icon--stopped"), "cancelled card shows the stopped icon");
297 ok(document.querySelector(".tool__subagent-chip")?.classList.contains("tool__subagent-chip--cancelled"), "chip carries the cancelled modifier class");
298
299 await act(async () => {
300 root.unmount();
301 });
302 dom.window.close();
303 }
304
305 {
306 const dom = installDom();
307 const rootEl = document.getElementById("root");
308 if (!rootEl) throw new Error("missing root");
309 const root = createRoot(rootEl);
310 const live = makeItem("partial");
311 live.status = "error";
312 live.subagentOutcome = ["sa_live", "partial", "completion_uncertain", true];
313
314 await act(async () => {
315 root.render(React.createElement(LocaleProvider, null, React.createElement(ToolCard, { item: live })));
316 await flushTimers();
317 });
318 await act(async () => {
319 document.querySelector<HTMLButtonElement>(".tool__head")?.click();
320 for (let i = 0; i < 50; i += 1) {
321 await flushTimers();
322 if (document.querySelector(".tool__subagent-outcome")) break;
323 }
324 });
325 ok(document.querySelector(".tool__subagent-outcome")?.textContent?.includes("partially complete"), "live outcome tuple renders through the lazy card boundary");
326 ok(document.querySelector(".tool__subagent-outcome")?.textContent?.includes("sa_live"), "live outcome keeps the stable subagent reference");
327 ok(document.querySelector(".tool__subagent-outcome")?.textContent?.includes("completion_uncertain"), "live outcome exposes the bounded error code");
328
329 const history: ToolItem = {
330 kind: "tool",
331 id: "task-history-outcome",
332 name: "task",
333 args: "{}",
334 readOnly: true,
335 status: "error",
336 output: "Subagent reference (failed): sa_history\nSubagent outcome: status=failed retryable=false error_code=provider_error",
337 };
338 await act(async () => {
339 root.render(React.createElement(LocaleProvider, null, React.createElement(ToolCard, { key: history.id, item: history })));
340 await flushTimers();
341 });
342 await act(async () => {
343 document.querySelector<HTMLButtonElement>(".tool__head")?.click();
344 for (let i = 0; i < 50; i += 1) {
345 await flushTimers();
346 if (document.querySelector(".tool__subagent-outcome code")?.textContent === "sa_history") break;
347 }
348 });
349 ok(document.querySelector(".tool__subagent-outcome code")?.textContent === "sa_history", "history outcome is parsed only when the card is opened");
350 ok(document.querySelector(".tool__subagent-outcome")?.textContent?.includes("failed"), "history outcome uses the same localized status projection");
351
352 await act(async () => root.unmount());
353 dom.window.close();
354 }
355
356 console.log(`\nsubagent progress card: ${passed} passed, ${failed} failed`);
357 if (failed > 0) process.exit(1);
358
358 lines Plain Text