返回 DeepSeek-Reasonix
tool-card-running-elapsed.test.tsx
根目录 / desktop / frontend / src / __tests__ / tool-card-running-elapsed.test.tsx
1 // Run: tsx src/__tests__/tool-card-running-elapsed.test.tsx
2
3 import { JSDOM } from "jsdom";
4 import React from "react";
5 import { act } from "react";
6 import { createRoot } from "react-dom/client";
7 import { ToolCard } from "../components/ToolCard";
8 import { localizedNoticeText } from "../lib/controllerNotices";
9 import { LocaleProvider } from "../lib/i18n";
10 import { zh } from "../locales/zh";
11 import { zhTW } from "../locales/zh-TW";
12 import { initialState, reducer, type Item } from "../lib/useController";
13
14 type ToolItem = Extract<Item, { kind: "tool" }>;
15
16 let passed = 0;
17 let failed = 0;
18
19 function ok(value: unknown, label: string) {
20 if (value) {
21 process.stdout.write(` PASS ${label}\n`);
22 passed += 1;
23 } else {
24 process.stdout.write(` FAIL ${label}\n`);
25 failed += 1;
26 }
27 }
28
29 function eq(actual: unknown, expected: unknown, label: string) {
30 if (actual === expected) ok(true, label);
31 else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
32 }
33
34 function flushTimers(): Promise<void> {
35 return new Promise((resolve) => setTimeout(resolve, 0));
36 }
37
38 const originalNow = Date.now;
39 let fakeNow = 100_000;
40 Date.now = () => fakeNow;
41
42 // ToolCard ticks through window.setInterval; capturing the callbacks lets the
43 // test advance the clock without waiting real seconds.
44 const intervals = new Map<number, () => void>();
45 let nextIntervalId = 1;
46
47 function installDom() {
48 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
49 pretendToBeVisual: true,
50 url: "http://localhost/",
51 });
52 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
53 globalThis.window = dom.window as unknown as Window & typeof globalThis;
54 globalThis.document = dom.window.document;
55 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
56 globalThis.Node = dom.window.Node;
57 globalThis.Element = dom.window.Element;
58 globalThis.HTMLElement = dom.window.HTMLElement;
59 globalThis.Event = dom.window.Event;
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 dom.window.setInterval = ((handler: TimerHandler) => {
74 const id = nextIntervalId++;
75 if (typeof handler === "function") intervals.set(id, handler as () => void);
76 return id;
77 }) as typeof dom.window.setInterval;
78 dom.window.clearInterval = ((id?: number) => {
79 if (id !== undefined) intervals.delete(id);
80 }) as typeof dom.window.clearInterval;
81 return dom;
82 }
83
84 async function renderCard(item: ToolItem) {
85 const dom = installDom();
86 const rootEl = document.getElementById("root");
87 if (!rootEl) throw new Error("missing root");
88 const root = createRoot(rootEl);
89 await act(async () => {
90 root.render(React.createElement(LocaleProvider, null, React.createElement(ToolCard, { item })));
91 await flushTimers();
92 });
93 return {
94 async cleanup() {
95 await act(async () => {
96 root.unmount();
97 });
98 dom.window.close();
99 },
100 };
101 }
102
103 async function advance(ms: number) {
104 fakeNow += ms;
105 await act(async () => {
106 for (const fire of [...intervals.values()]) fire();
107 await flushTimers();
108 });
109 }
110
111 function durationText(): string | null {
112 return document.querySelector(".tool__duration")?.textContent ?? null;
113 }
114
115 console.log("\ntool card running elapsed");
116
117 let s = reducer(initialState, { type: "event", e: { kind: "turn_started" } });
118 s = reducer(s, {
119 type: "event",
120 e: { kind: "tool_dispatch", tool: { id: "run-bash", name: "bash", args: `{"command":"sleep 600"}`, readOnly: false } },
121 });
122 const running = s.items.find((it): it is ToolItem => it.kind === "tool" && it.id === "run-bash");
123 eq(running?.status, "running", "dispatch creates a running card");
124 eq(running?.startedAt, 100_000, "dispatch stamps startedAt from the frontend clock");
125
126 {
127 fakeNow += 5_000;
128 s = reducer(s, {
129 type: "event",
130 e: { kind: "tool_dispatch", tool: { id: "run-partial", name: "write_file", partial: true, argChars: 12, readOnly: false } },
131 });
132 fakeNow += 5_000;
133 s = reducer(s, {
134 type: "event",
135 e: { kind: "tool_dispatch", tool: { id: "run-partial", name: "write_file", args: `{"path":"a.txt","content":"x"}`, readOnly: false } },
136 });
137 const merged = s.items.find((it): it is ToolItem => it.kind === "tool" && it.id === "run-partial");
138 eq(merged?.startedAt, 105_000, "the full dispatch keeps the partial dispatch's startedAt");
139 fakeNow = 100_000;
140 }
141
142 {
143 const ui = await renderCard(running!);
144 eq(durationText(), "0s", "running card shows a live elapsed label at dispatch");
145 eq(intervals.size, 1, "running card registers exactly one ticker");
146 await advance(83_000);
147 eq(durationText(), "1m23s", "live elapsed label advances with the clock");
148 await advance(60_000);
149 eq(durationText(), "2m23s", "live elapsed label keeps advancing");
150 await ui.cleanup();
151 eq(intervals.size, 0, "unmount clears the ticker");
152 fakeNow = 100_000;
153 }
154
155 {
156 const subagent: ToolItem = {
157 kind: "tool",
158 id: "task-1",
159 name: "task",
160 args: "{}",
161 readOnly: false,
162 status: "running",
163 startedAt: fakeNow,
164 subagentProgress: { phase: "running", reasoning: "", text: "", notice: "", lastActivityAt: fakeNow, truncated: false, startedAt: fakeNow },
165 };
166 const ui = await renderCard(subagent);
167 eq(intervals.size, 1, "sub-agent card registers exactly one ticker (no double tick)");
168 eq(durationText(), null, "sub-agent card leaves elapsed to its progress chip");
169 await advance(5_000);
170 const chip = document.querySelector(".tool__subagent-chip")?.textContent ?? "";
171 ok(chip.includes("5s"), `sub-agent chip still ticks (got ${JSON.stringify(chip)})`);
172 await ui.cleanup();
173 fakeNow = 100_000;
174 }
175
176 {
177 s = reducer(s, {
178 type: "event",
179 e: { kind: "tool_result", tool: { id: "run-bash", name: "bash", readOnly: false, output: "ok", durationMs: 83421 } },
180 });
181 const done = s.items.find((it): it is ToolItem => it.kind === "tool" && it.id === "run-bash");
182 eq(done?.status, "done", "tool_result settles the card");
183 const ui = await renderCard(done!);
184 eq(durationText(), "83421 ms", "completed card shows the final duration");
185 eq(intervals.size, 0, "completed card registers no ticker");
186 await advance(10_000);
187 eq(durationText(), "83421 ms", "completed card's duration does not drift with the clock");
188 await ui.cleanup();
189 fakeNow = 100_000;
190 }
191
192 {
193 const hydrated: ToolItem = { kind: "tool", id: "hydrated", name: "bash", args: `{"command":"ls"}`, readOnly: false, status: "running" };
194 const ui = await renderCard(hydrated);
195 eq(durationText(), null, "running card without startedAt hides the elapsed label");
196 eq(intervals.size, 0, "running card without startedAt does not tick");
197 await ui.cleanup();
198 }
199
200 {
201 const before = s;
202 s = reducer(s, {
203 type: "event",
204 e: { kind: "notice", level: "warn", code: "turn_stalled", text: "No events for 10m0s; the turn may be stuck." },
205 });
206 const notice = s.items[s.items.length - 1];
207 eq(notice?.kind, "notice", "turn_stalled appends a transcript notice");
208 ok(notice?.kind === "notice" && notice.level === "warn", "turn_stalled notice keeps its warn level");
209 ok(
210 notice?.kind === "notice" && notice.text === "No progress for a while. The turn is still running; if it looks stuck, press Stop.",
211 "turn_stalled notice text is localized by code",
212 );
213 eq(s.running, before.running, "turn_stalled does not change the running flag");
214 eq(s.turnActive, before.turnActive, "turn_stalled does not end the turn");
215 eq(s.streamInterruptNoticeShown, before.streamInterruptNoticeShown, "turn_stalled does not touch the stream-interrupt flag");
216 }
217
218 eq(
219 localizedNoticeText("No events for 10m0s; the turn may be stuck.", "turn_stalled"),
220 "No progress for a while. The turn is still running; if it looks stuck, press Stop.",
221 "localizedNoticeText maps turn_stalled to the English copy",
222 );
223 eq(zh["notice.turnStalled"], "已经有一段时间没有任何进展。回合仍在运行;如果看起来卡住了,请点击停止。", "zh copy for turn_stalled");
224 eq(zhTW["notice.turnStalled"], "已經有一段時間沒有任何進展。回合仍在執行;如果看起來卡住了,請點擊停止。", "zh-TW copy for turn_stalled");
225
226 Date.now = originalNow;
227 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
228 if (failed > 0) process.exit(1);
229
229 lines Plain Text