返回 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
8 type Task = Record<string, unknown>;
9 type Event = Record<string, unknown>;
10
11 let passed = 0;
12 let failed = 0;
13
14 function ok(value: boolean, label: string) {
15 if (value) {
16 process.stdout.write(` PASS ${label}\n`);
17 passed += 1;
18 } else {
19 process.stdout.write(` FAIL ${label}\n`);
20 failed += 1;
21 }
22 }
23
24 function snap(overrides: Task = {}): Task {
25 return {
26 schema_version: 1,
27 task_id: "task-0001",
28 session_id: "sess-1",
29 state: "running",
30 runtime_state: "alive",
31 version: 1,
32 created_at: "2025-01-01T00:00:00Z",
33 updated_at: "2025-01-01T01:00:00Z",
34 ...overrides,
35 };
36 }
37
38 function taskEvent(overrides: Event = {}): Event {
39 return {
40 sequence: 1,
41 timestamp: "2025-01-01T00:00:01Z",
42 event_type: "state_change",
43 task_id: "task-0001",
44 session_id: "sess-1",
45 state: "running",
46 runtime_state: "alive",
47 ...overrides,
48 };
49 }
50
51 const dom = new JSDOM("<!doctype html><html><body></body></html>", {
52 pretendToBeVisual: true,
53 url: "http://localhost/",
54 });
55 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
56 globalThis.window = dom.window as unknown as Window & typeof globalThis;
57 globalThis.document = dom.window.document;
58 globalThis.Node = dom.window.Node;
59 globalThis.Element = dom.window.Element;
60 globalThis.HTMLElement = dom.window.HTMLElement;
61 globalThis.SVGElement = dom.window.SVGElement;
62 globalThis.Event = dom.window.Event;
63 globalThis.MouseEvent = dom.window.MouseEvent;
64 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
65 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
66
67 let listTasksImpl: () => Promise<Task[]> = async () => [];
68 let listEventsImpl: () => Promise<Event[]> = async () => [];
69 const listTaskTabIDs: string[] = [];
70 const listEventCalls: unknown[][] = [];
71 const requeueCalls: unknown[][] = [];
72 const mockApp = {
73 ListTasks: () => listTasksImpl(),
74 GetTask: async () => null,
75 ListTaskEvents: () => listEventsImpl(),
76 StopTask: async () => ({ schema_version: 1, command: "stop", task_id: "", accepted: true, idempotent: false }),
77 CancelTask: async () => ({ schema_version: 1, command: "cancel", task_id: "", accepted: true, idempotent: false }),
78 RequeueTask: async (...args: unknown[]) => {
79 requeueCalls.push(args);
80 return {
81 schema_version: 1,
82 command: "requeue",
83 task_id: String(args[0] ?? ""),
84 state: "queued",
85 runtime_state: "exited",
86 version: 2,
87 accepted: true,
88 idempotent: false,
89 };
90 },
91 OpenTaskSession: async () => ({ schema_version: 1, command: "open_session", task_id: "", session_id: "sess-1", accepted: true, idempotent: false }),
92 ListTasksForTab: async (tabID: string) => {
93 listTaskTabIDs.push(tabID);
94 return listTasksImpl();
95 },
96 ListTaskEventsForTab: async (...args: unknown[]) => {
97 listEventCalls.push(args);
98 return listEventsImpl();
99 },
100 StopTaskForTab: async () => ({ schema_version: 1, command: "stop", task_id: "", accepted: true, idempotent: false }),
101 CancelTaskForTab: async () => ({ schema_version: 1, command: "cancel", task_id: "", accepted: true, idempotent: false }),
102 RequeueTaskForTab: async (...args: unknown[]) => {
103 requeueCalls.push(args);
104 return {
105 schema_version: 1,
106 command: "requeue",
107 task_id: String(args[1] ?? ""),
108 state: "queued",
109 runtime_state: "exited",
110 version: 2,
111 accepted: true,
112 idempotent: false,
113 };
114 },
115 OpenTaskSessionForTab: async () => ({ schema_version: 1, command: "open_session", task_id: "", session_id: "sess-1", accepted: true, idempotent: false }),
116 };
117 (window as unknown as { go: { main: { App: typeof mockApp } } }).go = { main: { App: mockApp } };
118
119 const { TaskMonitorPanel } = await import("./TaskMonitorPanel");
120
121 let activeRoot: Root | null = null;
122 let activeHost: HTMLElement | null = null;
123
124 async function flush() {
125 await new Promise((resolve) => setTimeout(resolve, 25));
126 }
127
128 async function renderPanel(
129 onClose?: () => void,
130 onOpenSession?: (tabID: string, taskID: string) => Promise<boolean> | boolean,
131 tabID = "tab-a",
132 ) {
133 activeHost = document.createElement("div");
134 document.body.appendChild(activeHost);
135 activeRoot = createRoot(activeHost);
136 await act(async () => {
137 activeRoot?.render(
138 <LocaleProvider>
139 <TaskMonitorPanel tabID={tabID} onClose={onClose} onOpenSession={onOpenSession} />
140 </LocaleProvider>,
141 );
142 await flush();
143 });
144 }
145
146 async function cleanup() {
147 if (activeRoot) {
148 await act(async () => activeRoot?.unmount());
149 }
150 activeHost?.remove();
151 activeRoot = null;
152 activeHost = null;
153 listTasksImpl = async () => [];
154 listEventsImpl = async () => [];
155 listTaskTabIDs.length = 0;
156 listEventCalls.length = 0;
157 requeueCalls.length = 0;
158 }
159
160 function buttonByLabel(label: string): HTMLButtonElement {
161 const button = Array.from(document.querySelectorAll<HTMLButtonElement>("button"))
162 .find((candidate) => candidate.getAttribute("aria-label") === label);
163 if (!button) throw new Error(`missing button: ${label}`);
164 return button;
165 }
166
167 function buttonByText(label: string): HTMLButtonElement {
168 const button = Array.from(document.querySelectorAll<HTMLButtonElement>("button"))
169 .find((candidate) => candidate.textContent?.trim() === label);
170 if (!button) throw new Error(`missing button text: ${label}`);
171 return button;
172 }
173
174 async function click(button: HTMLButtonElement) {
175 await act(async () => {
176 button.click();
177 await flush();
178 });
179 }
180
181 async function openPanel() {
182 await click(buttonByLabel("Expand tasks"));
183 }
184
185 async function check(label: string, run: () => Promise<boolean>) {
186 try {
187 ok(await run(), label);
188 } catch (error) {
189 process.stderr.write(` ERROR ${label}: ${String(error)}\n`);
190 ok(false, label);
191 } finally {
192 await cleanup();
193 }
194 }
195
196 console.log("\nTask Monitor panel");
197
198 await check("renders the panel header", async () => {
199 await renderPanel();
200 return document.body.textContent?.includes("Tasks") === true;
201 });
202
203 await check("shows the empty state", async () => {
204 await renderPanel();
205 await openPanel();
206 return document.body.textContent?.includes("No background tasks") === true;
207 });
208
209 await check("shows task-fetch errors", async () => {
210 listTasksImpl = async () => { throw new Error("Network error"); };
211 await renderPanel();
212 await openPanel();
213 return document.body.textContent?.includes("Network error") === true;
214 });
215
216 await check("binds task reads to the source tab", async () => {
217 listTasksImpl = async () => [snap({ session_id: "sess-current" })];
218 await renderPanel(undefined, undefined, "tab-source");
219 return listTaskTabIDs.length === 1 && listTaskTabIDs[0] === "tab-source";
220 });
221
222 await check("renders lifecycle badges", async () => {
223 listTasksImpl = async () => [snap({ task_id: "a1" }), snap({ task_id: "b2", state: "failed" })];
224 await renderPanel();
225 await openPanel();
226 const text = document.body.textContent ?? "";
227 return text.includes("Running") && text.includes("Failed");
228 });
229
230 await check("separates lifecycle state from runtime liveness", async () => {
231 listTasksImpl = async () => [
232 snap({ task_id: "failed-1", state: "failed", runtime_state: "exited" }),
233 snap({ task_id: "legacy-1", runtime_state: undefined }),
234 ];
235 await renderPanel();
236 await openPanel();
237 const text = document.body.textContent ?? "";
238 return text.includes("Exited") && text.includes("Runtime unknown");
239 });
240
241 await check("requeues failed exited tasks", async () => {
242 listTasksImpl = async () => [snap({ task_id: "failed-1", state: "failed", runtime_state: "exited", version: 7 })];
243 await renderPanel();
244 await openPanel();
245 await click(buttonByLabel("Task failed-1 — Failed"));
246 await click(buttonByText("Requeue"));
247 return JSON.stringify(requeueCalls[0]) === JSON.stringify(["tab-a", "failed-1", 7, "desktop-requeue-failed-1-7"]);
248 });
249
250 await check("expands and collapses task details", async () => {
251 listTasksImpl = async () => [snap({ state: "succeeded" })];
252 await renderPanel();
253 await openPanel();
254 const row = buttonByLabel("Task task-000 — Succeeded");
255 await click(row);
256 const expanded = document.body.textContent?.includes("Task ID") === true;
257 await click(row);
258 return expanded && document.body.textContent?.includes("Task ID") !== true;
259 });
260
261 await check("loads recent task events", async () => {
262 listTasksImpl = async () => [snap({ state: "failed" })];
263 listEventsImpl = async () => [taskEvent({ event_type: "error", error_code: "CRASH" })];
264 await renderPanel();
265 await openPanel();
266 await click(buttonByLabel("Task task-000 — Failed"));
267 return document.body.textContent?.includes("CRASH") === true
268 && JSON.stringify(listEventCalls[0]) === JSON.stringify(["tab-a", "task-0001", 0]);
269 });
270
271 await check("shows task-event errors", async () => {
272 listTasksImpl = async () => [snap()];
273 listEventsImpl = async () => { throw new Error("Event failure"); };
274 await renderPanel();
275 await openPanel();
276 await click(buttonByLabel("Task task-000 — Running"));
277 return document.body.textContent?.includes("Event failure") === true;
278 });
279
280 await check("calls the close callback", async () => {
281 let closeCalls = 0;
282 await renderPanel(() => { closeCalls += 1; });
283 await click(buttonByLabel("Close session summary"));
284 return closeCalls === 1;
285 });
286
287 await check("opens the task session through the navigation callback", async () => {
288 listTasksImpl = async () => [snap()];
289 let openedTarget: string[] = [];
290 await renderPanel(undefined, async (tabID, taskID) => {
291 openedTarget = [tabID, taskID];
292 return true;
293 });
294 await openPanel();
295 await click(buttonByLabel("Task task-000 — Running"));
296 await click(buttonByText("Open session"));
297 return JSON.stringify(openedTarget) === JSON.stringify(["tab-a", "task-0001"]);
298 });
299
300 await check("does not close the panel for a stale open completion", async () => {
301 listTasksImpl = async () => [snap()];
302 let closeCalls = 0;
303 await renderPanel(() => { closeCalls += 1; }, async () => false);
304 await openPanel();
305 await click(buttonByLabel("Task task-000 — Running"));
306 await click(buttonByText("Open session"));
307 return closeCalls === 0;
308 });
309
310 await check("refreshes tasks on request", async () => {
311 let calls = 0;
312 listTasksImpl = async () => (++calls === 1 ? [] : [snap({ task_id: "ok" })]);
313 await renderPanel();
314 await openPanel();
315 await click(buttonByLabel("Refresh"));
316 return document.body.textContent?.includes("ok") === true;
317 });
318
319 await check("shows the task count", async () => {
320 listTasksImpl = async () => [snap({ task_id: "a" }), snap({ task_id: "b" })];
321 await renderPanel();
322 return document.querySelector(".taskmonitor__count")?.textContent === "2";
323 });
324
325 await check("marks only terminal tasks", async () => {
326 listTasksImpl = async () => [snap({ task_id: "t1", state: "succeeded" }), snap({ task_id: "t2" })];
327 await renderPanel();
328 await openPanel();
329 return document.querySelectorAll(".taskmonitor__terminal").length === 1;
330 });
331
332 dom.window.close();
333 console.log(`\n${passed} passed, ${failed} failed`);
334 if (failed > 0) process.exit(1);
335
335 lines Plain Text