返回 DeepSeek-Reasonix
TaskMonitorPanel.test.tsx
根目录 / desktop / frontend / src / components / TaskMonitorPanel.test.tsx
1 // Run: tsx src/components/TaskMonitorPanel.test.tsx
2
3 import { JSDOM } from "jsdom";
4 import { act } from "react";
5 import { createRoot, type Root } from "react-dom/client";
6 import { LocaleProvider } from "../lib/i18n";
7 import { installDesktopHostStub } from "../__tests__/desktopHostStub";
8
9 type Task = Record<string, unknown>;
10 type Event = Record<string, unknown>;
11
12 let passed = 0;
13 let failed = 0;
14
15 function ok(value: boolean, label: string) {
16 if (value) {
17 process.stdout.write(` PASS ${label}\n`);
18 passed += 1;
19 } else {
20 process.stdout.write(` FAIL ${label}\n`);
21 failed += 1;
22 }
23 }
24
25 function snap(overrides: Task = {}): Task {
26 return {
27 schema_version: 1,
28 task_id: "task-0001",
29 session_id: "sess-1",
30 state: "running",
31 runtime_state: "alive",
32 version: 1,
33 created_at: "2025-01-01T00:00:00Z",
34 updated_at: "2025-01-01T01:00:00Z",
35 ...overrides,
36 };
37 }
38
39 function taskEvent(overrides: Event = {}): Event {
40 return {
41 sequence: 1,
42 timestamp: "2025-01-01T00:00:01Z",
43 event_type: "state_change",
44 task_id: "task-0001",
45 session_id: "sess-1",
46 state: "running",
47 runtime_state: "alive",
48 ...overrides,
49 };
50 }
51
52 const dom = new JSDOM("<!doctype html><html><body></body></html>", {
53 pretendToBeVisual: true,
54 url: "http://localhost/",
55 });
56 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
57 globalThis.window = dom.window as unknown as Window & typeof globalThis;
58 globalThis.document = dom.window.document;
59 // Pin the locale source to the JSDOM navigator (en-US): Node's own global
60 // navigator follows the machine's system language, which flips detectLocale
61 // to Chinese on zh hosts and breaks the English assertions below.
62 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
63 globalThis.Node = dom.window.Node;
64 globalThis.Element = dom.window.Element;
65 globalThis.HTMLElement = dom.window.HTMLElement;
66 globalThis.SVGElement = dom.window.SVGElement;
67 globalThis.Event = dom.window.Event;
68 globalThis.MouseEvent = dom.window.MouseEvent;
69 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
70 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
71
72 let listTasksImpl: () => Promise<Task[]> = async () => [];
73 let listEventsImpl: () => Promise<Event[]> = async () => [];
74 const listTaskTabIDs: string[] = [];
75 const listEventCalls: unknown[][] = [];
76 const stopCalls: unknown[][] = [];
77 const requeueCalls: unknown[][] = [];
78 const mockApp = {
79 ListTasks: () => listTasksImpl(),
80 GetTask: async () => null,
81 ListTaskEvents: () => listEventsImpl(),
82 StopTask: async () => ({ schema_version: 1, command: "stop", task_id: "", accepted: true, idempotent: false }),
83 CancelTask: async () => ({ schema_version: 1, command: "cancel", task_id: "", accepted: true, idempotent: false }),
84 RequeueTask: async (...args: unknown[]) => {
85 requeueCalls.push(args);
86 return {
87 schema_version: 1,
88 command: "requeue",
89 task_id: String(args[0] ?? ""),
90 state: "queued",
91 runtime_state: "exited",
92 version: 2,
93 accepted: true,
94 idempotent: false,
95 };
96 },
97 OpenTaskSession: async () => ({ schema_version: 1, command: "open_session", task_id: "", session_id: "sess-1", accepted: true, idempotent: false }),
98 ListTasksForTab: async (tabID: string) => {
99 listTaskTabIDs.push(tabID);
100 return listTasksImpl();
101 },
102 ListTaskEventsForTab: async (...args: unknown[]) => {
103 listEventCalls.push(args);
104 return listEventsImpl();
105 },
106 StopTaskForTab: async (...args: unknown[]) => {
107 stopCalls.push(args);
108 return { schema_version: 1, command: "stop", task_id: "", accepted: true, idempotent: false };
109 },
110 CancelTaskForTab: async () => ({ schema_version: 1, command: "cancel", task_id: "", accepted: true, idempotent: false }),
111 RequeueTaskForTab: async (...args: unknown[]) => {
112 requeueCalls.push(args);
113 return {
114 schema_version: 1,
115 command: "requeue",
116 task_id: String(args[1] ?? ""),
117 state: "queued",
118 runtime_state: "exited",
119 version: 2,
120 accepted: true,
121 idempotent: false,
122 };
123 },
124 OpenTaskSessionForTab: async () => ({ schema_version: 1, command: "open_session", task_id: "", session_id: "sess-1", accepted: true, idempotent: false }),
125 };
126 installDesktopHostStub(mockApp);
127
128 const { TaskMonitorPanel } = await import("./TaskMonitorPanel");
129
130 let activeRoot: Root | null = null;
131 let activeHost: HTMLElement | null = null;
132
133 async function flush() {
134 await new Promise((resolve) => setTimeout(resolve, 25));
135 }
136
137 async function renderPanel(
138 onClose?: () => void,
139 onOpenSession?: (tabID: string, taskID: string) => Promise<boolean> | boolean,
140 tabID = "tab-a",
141 ) {
142 activeHost = document.createElement("div");
143 document.body.appendChild(activeHost);
144 activeRoot = createRoot(activeHost);
145 await act(async () => {
146 activeRoot?.render(
147 <LocaleProvider>
148 <TaskMonitorPanel tabID={tabID} onClose={onClose} onOpenSession={onOpenSession} />
149 </LocaleProvider>,
150 );
151 await flush();
152 });
153 }
154
155 async function cleanup() {
156 if (activeRoot) {
157 await act(async () => activeRoot?.unmount());
158 }
159 activeHost?.remove();
160 activeRoot = null;
161 activeHost = null;
162 listTasksImpl = async () => [];
163 listEventsImpl = async () => [];
164 listTaskTabIDs.length = 0;
165 listEventCalls.length = 0;
166 stopCalls.length = 0;
167 requeueCalls.length = 0;
168 }
169
170 function buttonByLabel(label: string): HTMLButtonElement {
171 const button = Array.from(document.querySelectorAll<HTMLButtonElement>("button"))
172 .find((candidate) => candidate.getAttribute("aria-label") === label);
173 if (!button) throw new Error(`missing button: ${label}`);
174 return button;
175 }
176
177 function buttonByText(label: string): HTMLButtonElement {
178 const button = Array.from(document.querySelectorAll<HTMLButtonElement>("button"))
179 .find((candidate) => candidate.textContent?.trim() === label);
180 if (!button) throw new Error(`missing button text: ${label}`);
181 return button;
182 }
183
184 async function click(button: HTMLButtonElement) {
185 await act(async () => {
186 button.click();
187 await flush();
188 });
189 }
190
191 async function keyDown(element: HTMLElement, key: string) {
192 await act(async () => {
193 element.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key, bubbles: true }));
194 await flush();
195 });
196 }
197
198 async function openPanel() {
199 await click(buttonByLabel("Expand tasks"));
200 }
201
202 async function check(label: string, run: () => Promise<boolean>) {
203 try {
204 ok(await run(), label);
205 } catch (error) {
206 process.stderr.write(` ERROR ${label}: ${String(error)}\n`);
207 ok(false, label);
208 } finally {
209 await cleanup();
210 }
211 }
212
213 console.log("\nTask Monitor panel");
214
215 await check("renders the panel header", async () => {
216 await renderPanel();
217 return document.body.textContent?.includes("Tasks") === true;
218 });
219
220 await check("shows the empty state", async () => {
221 await renderPanel();
222 await openPanel();
223 return document.body.textContent?.includes("No background tasks") === true;
224 });
225
226 await check("shows task-fetch errors", async () => {
227 listTasksImpl = async () => { throw new Error("Network error"); };
228 await renderPanel();
229 await openPanel();
230 return document.body.textContent?.includes("Network error") === true;
231 });
232
233 await check("binds task reads to the source tab", async () => {
234 listTasksImpl = async () => [snap({ session_id: "sess-current" })];
235 await renderPanel(undefined, undefined, "tab-source");
236 return listTaskTabIDs.length === 1 && listTaskTabIDs[0] === "tab-source";
237 });
238
239 await check("renders lifecycle badges", async () => {
240 listTasksImpl = async () => [snap({ task_id: "a1" }), snap({ task_id: "b2", state: "failed" })];
241 await renderPanel();
242 await openPanel();
243 const text = document.body.textContent ?? "";
244 return text.includes("Running") && text.includes("Failed");
245 });
246
247 await check("separates lifecycle state from runtime liveness", async () => {
248 listTasksImpl = async () => [
249 snap({ task_id: "failed-1", state: "failed", runtime_state: "exited" }),
250 snap({ task_id: "legacy-1", runtime_state: undefined }),
251 ];
252 await renderPanel();
253 await openPanel();
254 const text = document.body.textContent ?? "";
255 return text.includes("Exited") && text.includes("Runtime unknown");
256 });
257
258 await check("offers one working stop action for active tasks", async () => {
259 listTasksImpl = async () => [snap()];
260 await renderPanel();
261 await openPanel();
262 await click(buttonByLabel("Task task-000 — Running"));
263 const actionLabels = Array.from(document.querySelectorAll<HTMLButtonElement>(".taskmonitor__actions button"))
264 .map((button) => button.textContent?.trim());
265 const hasMergedActions = actionLabels.filter((label) => label === "Stop").length === 1
266 && !actionLabels.includes("Cancel")
267 && actionLabels.includes("Open session");
268 await click(buttonByText("Stop"));
269 const confirmStop = buttonByText("Stop");
270 const detailValues = Array.from(document.querySelectorAll<HTMLElement>(".taskmonitor__detail dd"))
271 .map((value) => value.textContent?.trim());
272 const hasReplacementConfirmation = document.querySelector(".taskmonitor__actions") === null
273 && document.activeElement === confirmStop
274 && detailValues.includes("Running")
275 && !detailValues.includes("running");
276 await click(confirmStop);
277 return hasMergedActions
278 && hasReplacementConfirmation
279 && JSON.stringify(stopCalls[0]) === JSON.stringify([
280 "tab-a",
281 "task-0001",
282 1,
283 "desktop request",
284 "desktop-stop-task-0001-1",
285 ]);
286 });
287
288 await check("dismisses stop confirmation with Escape and restores focus", async () => {
289 listTasksImpl = async () => [snap()];
290 await renderPanel();
291 await openPanel();
292 await click(buttonByLabel("Task task-000 — Running"));
293 await click(buttonByText("Stop"));
294 await keyDown(buttonByText("Stop"), "Escape");
295 const restoredStop = buttonByText("Stop");
296 return document.querySelector(".taskmonitor__confirm") === null
297 && document.activeElement === restoredStop
298 && stopCalls.length === 0;
299 });
300
301 await check("dismisses stop confirmation when polling observes a terminal task", async () => {
302 listTasksImpl = async () => [snap()];
303 await renderPanel();
304 await openPanel();
305 await click(buttonByLabel("Task task-000 — Running"));
306 await click(buttonByText("Stop"));
307 listTasksImpl = async () => [snap({ state: "succeeded", runtime_state: "exited", version: 2 })];
308 await click(buttonByLabel("Refresh"));
309 return document.querySelector(".taskmonitor__confirm") === null
310 && document.body.textContent?.includes("Succeeded") === true
311 && stopCalls.length === 0;
312 });
313
314 await check("requeues failed exited tasks", async () => {
315 listTasksImpl = async () => [snap({ task_id: "failed-1", state: "failed", runtime_state: "exited", version: 7 })];
316 await renderPanel();
317 await openPanel();
318 await click(buttonByLabel("Task failed-1 — Failed"));
319 await click(buttonByText("Requeue"));
320 return JSON.stringify(requeueCalls[0]) === JSON.stringify(["tab-a", "failed-1", 7, "desktop-requeue-failed-1-7"]);
321 });
322
323 await check("expands and collapses task details", async () => {
324 listTasksImpl = async () => [snap({ state: "succeeded" })];
325 await renderPanel();
326 await openPanel();
327 const row = buttonByLabel("Task task-000 — Succeeded");
328 await click(row);
329 const expanded = document.body.textContent?.includes("Task ID") === true;
330 await click(row);
331 return expanded && document.body.textContent?.includes("Task ID") !== true;
332 });
333
334 await check("loads recent task events", async () => {
335 listTasksImpl = async () => [snap({ state: "failed" })];
336 listEventsImpl = async () => [taskEvent({ event_type: "error", error_code: "CRASH" })];
337 await renderPanel();
338 await openPanel();
339 await click(buttonByLabel("Task task-000 — Failed"));
340 return document.body.textContent?.includes("CRASH") === true
341 && JSON.stringify(listEventCalls[0]) === JSON.stringify(["tab-a", "task-0001", 0]);
342 });
343
344 await check("shows task-event errors", async () => {
345 listTasksImpl = async () => [snap()];
346 listEventsImpl = async () => { throw new Error("Event failure"); };
347 await renderPanel();
348 await openPanel();
349 await click(buttonByLabel("Task task-000 — Running"));
350 return document.body.textContent?.includes("Event failure") === true;
351 });
352
353 await check("calls the close callback", async () => {
354 let closeCalls = 0;
355 await renderPanel(() => { closeCalls += 1; });
356 await click(buttonByLabel("Close session summary"));
357 return closeCalls === 1;
358 });
359
360 await check("opens the task session through the navigation callback", async () => {
361 listTasksImpl = async () => [snap()];
362 let openedTarget: string[] = [];
363 await renderPanel(undefined, async (tabID, taskID) => {
364 openedTarget = [tabID, taskID];
365 return true;
366 });
367 await openPanel();
368 await click(buttonByLabel("Task task-000 — Running"));
369 await click(buttonByText("Open session"));
370 return JSON.stringify(openedTarget) === JSON.stringify(["tab-a", "task-0001"]);
371 });
372
373 await check("does not close the panel for a stale open completion", async () => {
374 listTasksImpl = async () => [snap()];
375 let closeCalls = 0;
376 await renderPanel(() => { closeCalls += 1; }, async () => false);
377 await openPanel();
378 await click(buttonByLabel("Task task-000 — Running"));
379 await click(buttonByText("Open session"));
380 return closeCalls === 0;
381 });
382
383 await check("refreshes tasks on request", async () => {
384 let calls = 0;
385 listTasksImpl = async () => (++calls === 1 ? [] : [snap({ task_id: "ok" })]);
386 await renderPanel();
387 await openPanel();
388 await click(buttonByLabel("Refresh"));
389 return document.body.textContent?.includes("ok") === true;
390 });
391
392 await check("shows the task count", async () => {
393 listTasksImpl = async () => [snap({ task_id: "a" }), snap({ task_id: "b" })];
394 await renderPanel();
395 return document.querySelector(".taskmonitor__count")?.textContent === "2";
396 });
397
398 await check("marks only terminal tasks", async () => {
399 listTasksImpl = async () => [snap({ task_id: "t1", state: "succeeded" }), snap({ task_id: "t2" })];
400 await renderPanel();
401 await openPanel();
402 return document.querySelectorAll(".taskmonitor__terminal").length === 1;
403 });
404
405 await check("freezes terminal task elapsed time", async () => {
406 const realNow = Date.now;
407 try {
408 Date.now = () => Date.parse("2025-01-01T02:00:00Z");
409 listTasksImpl = async () => [snap({ state: "cancelled", runtime_state: "exited" })];
410 await renderPanel();
411 await openPanel();
412 const atFinish = document.querySelector(".taskmonitor__time")?.textContent;
413 Date.now = () => Date.parse("2025-01-01T03:00:00Z");
414 await click(buttonByLabel("Refresh"));
415 const afterRefresh = document.querySelector(".taskmonitor__time")?.textContent;
416 return atFinish === "1h" && afterRefresh === "1h";
417 } finally {
418 Date.now = realNow;
419 }
420 });
421
422 await check("uses the expired runtime lease for stale task elapsed time", async () => {
423 const realNow = Date.now;
424 try {
425 Date.now = () => Date.parse("2025-01-01T02:00:00Z");
426 listTasksImpl = async () => [snap({
427 state: "stale",
428 runtime_state: "exited",
429 updated_at: "2025-01-01T00:00:00Z",
430 runtime_lease_until: "2025-01-01T00:30:00Z",
431 })];
432 await renderPanel();
433 await openPanel();
434 const atDetection = document.querySelector(".taskmonitor__time")?.textContent;
435 Date.now = () => Date.parse("2025-01-01T03:00:00Z");
436 await click(buttonByLabel("Refresh"));
437 const afterRefresh = document.querySelector(".taskmonitor__time")?.textContent;
438 return atDetection === "30m" && afterRefresh === "30m";
439 } finally {
440 Date.now = realNow;
441 }
442 });
443
444 await check("does not present requeue age as elapsed runtime", async () => {
445 listTasksImpl = async () => [snap({ state: "queued", runtime_state: "exited" })];
446 await renderPanel();
447 await openPanel();
448 return document.querySelector(".taskmonitor__time")?.textContent === "—";
449 });
450
451 dom.window.close();
452 console.log(`\n${passed} passed, ${failed} failed`);
453 if (failed > 0) process.exit(1);
454
454 lines Plain Text