返回 DeepSeek-Reasonix
tool-card-shell-execution.test.tsx
根目录 / desktop / frontend / src / __tests__ / tool-card-shell-execution.test.tsx
1 // Run: tsx src/__tests__/tool-card-shell-execution.test.tsx
2 //
3 // Desktop ToolCard shell-execution presentation for the three host paths:
4 // 1) live tool_result event (reducer → card)
5 // 2) history recovery (historyMessagesToItems)
6 // 3) archived expand (ToolResultForTab injects execution back onto fullData)
7
8 import { JSDOM } from "jsdom";
9 import React from "react";
10 import { act } from "react";
11 import { createRoot } from "react-dom/client";
12 import gsap from "gsap";
13 import { ToolCard } from "../components/ToolCard";
14 import { LocaleProvider } from "../lib/i18n";
15 import { historyMessagesToItems, initialState, reducer, type Item } from "../lib/useController";
16 import type { HistoryMessage, WireShellExecution } from "../lib/types";
17
18 type ToolItem = Extract<Item, { kind: "tool" }>;
19
20 // jsdom has no layout engine: stub GSAP the same way as subagent-progress-card.
21 type GsapToOptions = { onComplete?: () => void };
22 const gsapForTests = gsap as unknown as {
23 to: (target: unknown, vars: GsapToOptions) => unknown;
24 fromTo: (target: unknown, from: unknown, vars: GsapToOptions) => unknown;
25 set: (target: unknown, vars: unknown) => unknown;
26 killTweensOf: (target: unknown) => void;
27 };
28 gsapForTests.to = (_target: unknown, vars: GsapToOptions) => {
29 vars.onComplete?.();
30 return {};
31 };
32 gsapForTests.fromTo = (_target: unknown, _from: unknown, vars: GsapToOptions) => {
33 vars.onComplete?.();
34 return {};
35 };
36 gsapForTests.set = () => ({});
37 gsapForTests.killTweensOf = () => {};
38
39 let passed = 0;
40 let failed = 0;
41
42 function ok(value: unknown, label: string) {
43 if (value) {
44 process.stdout.write(` PASS ${label}\n`);
45 passed += 1;
46 } else {
47 process.stdout.write(` FAIL ${label}\n`);
48 failed += 1;
49 }
50 }
51
52 function eq(actual: unknown, expected: unknown, label: string) {
53 if (actual === expected) ok(true, label);
54 else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
55 }
56
57 function flushTimers(): Promise<void> {
58 return new Promise((resolve) => setTimeout(resolve, 0));
59 }
60
61 function installDom() {
62 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
63 pretendToBeVisual: true,
64 url: "http://localhost/",
65 });
66 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
67 globalThis.window = dom.window as unknown as Window & typeof globalThis;
68 globalThis.document = dom.window.document;
69 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
70 globalThis.Node = dom.window.Node;
71 globalThis.Element = dom.window.Element;
72 globalThis.HTMLElement = dom.window.HTMLElement;
73 globalThis.Event = dom.window.Event;
74 globalThis.MouseEvent = dom.window.MouseEvent;
75 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
76 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
77 dom.window.matchMedia = () => ({
78 matches: true,
79 media: "(prefers-reduced-motion: reduce)",
80 onchange: null,
81 addListener: () => undefined,
82 removeListener: () => undefined,
83 addEventListener: () => undefined,
84 removeEventListener: () => undefined,
85 dispatchEvent: () => false,
86 });
87 return dom;
88 }
89
90 async function renderCard(item: ToolItem, tabId?: string) {
91 const dom = installDom();
92 const rootEl = document.getElementById("root");
93 if (!rootEl) throw new Error("missing root");
94 const root = createRoot(rootEl);
95 await act(async () => {
96 root.render(
97 React.createElement(LocaleProvider, null,
98 React.createElement(ToolCard, { item, tabId }),
99 ),
100 );
101 await flushTimers();
102 });
103 return {
104 root,
105 dom,
106 async expand() {
107 const head = document.querySelector(".tool__head") as HTMLButtonElement | null;
108 if (!head) throw new Error("tool head missing");
109 await act(async () => {
110 head.click();
111 await flushTimers();
112 });
113 },
114 async cleanup() {
115 await act(async () => {
116 root.unmount();
117 });
118 dom.window.close();
119 },
120 };
121 }
122
123 const failedPSExecution: WireShellExecution = {
124 kind: "shell",
125 shell: "powershell",
126 shellVersion: "5.1",
127 platform: "windows",
128 supportsAndAnd: false,
129 state: "failed",
130 failurePhase: "execution",
131 exitCode: 1,
132 outputTail: "Select-String : 找不到路径“C:\\中文\\app.ps1”。\nAt line:1 char:1",
133 mutationRisk: "may_be_partial",
134 verification: "not_verification",
135 durationMs: 42,
136 };
137
138 const preflightExecution: WireShellExecution = {
139 kind: "shell",
140 shell: "bash",
141 state: "not_run",
142 failurePhase: "preflight",
143 mutationRisk: "not_started",
144 verification: "not_run",
145 durationMs: 0,
146 };
147
148 console.log("\ntool card shell execution");
149
150 // ── Path 1: live tool_result event ──
151 {
152 let s = reducer(initialState, { type: "event", e: { kind: "turn_started" } });
153 s = reducer(s, {
154 type: "event",
155 e: {
156 kind: "tool_dispatch",
157 tool: {
158 id: "live-ps",
159 name: "bash",
160 args: `{"command":"Get-Content .\\\\中文\\\\app.ps1"}`,
161 readOnly: false,
162 },
163 },
164 });
165 s = reducer(s, {
166 type: "event",
167 e: {
168 kind: "tool_result",
169 tool: {
170 id: "live-ps",
171 name: "bash",
172 readOnly: false,
173 output: "error: command exited: exit status 1\nSelect-String failed",
174 err: "command exited: exit status 1",
175 durationMs: 42,
176 execution: failedPSExecution,
177 },
178 },
179 });
180 const item = s.items.find((it): it is ToolItem => it.kind === "tool" && it.id === "live-ps");
181 ok(!!item, "live path: tool item created");
182 eq(item?.isShell, true, "live path: bash result marked isShell");
183 eq(item?.execution?.shell, "powershell", "live path: execution.shell preserved");
184 eq(item?.execution?.exitCode, 1, "live path: exitCode preserved");
185 eq(item?.execution?.failurePhase, "execution", "live path: failurePhase preserved");
186
187 const ui = await renderCard(item!);
188 const name = document.querySelector(".tool__name")?.textContent ?? "";
189 ok(name.includes("Windows PowerShell"), `live path: card shows Windows PowerShell (got ${JSON.stringify(name)})`);
190 const duration = document.querySelector(".tool__duration")?.textContent ?? "";
191 ok(duration.includes("exit 1") || duration.includes("execution"), `live path: summary shows exit/phase (got ${JSON.stringify(duration)})`);
192 const risk = document.body.textContent ?? "";
193 ok(risk.includes("partially modified") || risk.includes("部分"), "live path: partial mutation risk visible");
194 // Execution failure must not claim the command never ran.
195 ok(
196 !(risk.includes("did not run") || risk.includes("command did not run") || risk.includes("命令未执行") || risk.includes("未执行")),
197 "live path: execution failure must not show not-run label",
198 );
199
200 await ui.expand();
201 const details = document.querySelector("details.tool__error-details, .tool__error-details, details");
202 ok(!!details, "live path: stderr details element present after expand affordance");
203 if (details) {
204 await act(async () => {
205 details.setAttribute("open", "");
206 details.dispatchEvent(new Event("toggle"));
207 await flushTimers();
208 });
209 }
210 const afterExpand = document.body.textContent ?? "";
211 ok(
212 afterExpand.includes("中文") || afterExpand.includes("找不到路径"),
213 "live path: Chinese stderr from execution.outputTail is visible in the DOM",
214 );
215
216 await ui.cleanup();
217 }
218
219 // timed_out / cancelled must surface partial-write risk (backend may_be_partial).
220 {
221 for (const state of ["timed_out", "cancelled"] as const) {
222 const item: ToolItem = {
223 kind: "tool",
224 id: `risk-${state}`,
225 name: "bash",
226 args: `{"command":"long-run"}`,
227 readOnly: false,
228 status: "error",
229 error: state === "timed_out" ? "command timed out" : "context canceled",
230 isShell: true,
231 execution: {
232 kind: "shell",
233 shell: "bash",
234 state,
235 failurePhase: state === "timed_out" ? "timeout" : "cancellation",
236 mutationRisk: "may_be_partial",
237 verification: "not_verification",
238 durationMs: 100,
239 },
240 };
241 const ui = await renderCard(item);
242 const body = document.body.textContent ?? "";
243 ok(
244 body.includes("partially modified") || body.includes("部分"),
245 `${state}: shows partial mutation risk`,
246 );
247 ok(
248 !(body.includes("did not run") || body.includes("命令未执行")),
249 `${state}: does not claim command never ran`,
250 );
251 await ui.cleanup();
252 }
253 }
254
255 // ── Path 2: history recovery ──
256 {
257 const messages: HistoryMessage[] = [
258 {
259 role: "assistant",
260 content: "",
261 toolCalls: [{ id: "hist-bash", name: "bash", arguments: "{\"command\":\"go test ./...\"}" }],
262 },
263 {
264 role: "tool",
265 content: "blocked: mixed mutation and verification",
266 toolCallId: "hist-bash",
267 toolName: "bash",
268 toolResultError: "blocked: mixed mutation and verification command",
269 execution: preflightExecution,
270 },
271 ];
272 const items = historyMessagesToItems(messages, "h").items.filter((it): it is ToolItem => it.kind === "tool");
273 eq(items.length, 1, "history path: one tool item");
274 eq(items[0]?.isShell, true, "history path: bash isShell");
275 eq(items[0]?.execution?.failurePhase, "preflight", "history path: execution restored");
276 eq(items[0]?.execution?.mutationRisk, "not_started", "history path: not_started risk");
277
278 const ui = await renderCard(items[0]!);
279 const name = document.querySelector(".tool__name")?.textContent ?? "";
280 ok(name === "bash" || name.includes("bash"), `history path: shell name bash (got ${JSON.stringify(name)})`);
281 const body = document.body.textContent ?? "";
282 ok(
283 body.includes("did not run") || body.includes("not run") || body.includes("未执行") || body.includes("命令未执行"),
284 "history path: preflight shows command-not-run (not partial)",
285 );
286 ok(
287 !(body.includes("partially modified") || body.includes("部分修改文件")),
288 "history path: preflight must not claim partial file modification",
289 );
290 await ui.cleanup();
291 }
292
293 // ── Path 3a: archive compaction keeps execution from the live result ──
294 {
295 let s = reducer(initialState, { type: "event", e: { kind: "turn_started" } });
296 s = reducer(s, {
297 type: "event",
298 e: {
299 kind: "tool_dispatch",
300 tool: { id: "arch-live", name: "bash", args: `{"command":"exit 1"}`, readOnly: false },
301 },
302 });
303 s = reducer(s, {
304 type: "event",
305 e: {
306 kind: "tool_result",
307 tool: {
308 id: "arch-live",
309 name: "bash",
310 readOnly: false,
311 output: "error: exit 1\n" + "y".repeat(5000),
312 err: "command exited: exit status 1",
313 durationMs: 42,
314 execution: failedPSExecution,
315 },
316 },
317 });
318 const item = s.items.find((it): it is ToolItem => it.kind === "tool" && it.id === "arch-live");
319 ok(!!item?.dataArchived, "archive path: tool_result archives large output");
320 eq(item?.output, undefined, "archive path: output dropped after archive");
321 eq(item?.execution?.shell, "powershell", "archive path: execution survives compactArchivedToolItems");
322 eq(item?.execution?.exitCode, 1, "archive path: exitCode survives archive");
323
324 const ui = await renderCard(item!);
325 ok((document.querySelector(".tool__name")?.textContent ?? "").includes("Windows PowerShell"),
326 "archive path: card still shows Windows PowerShell without re-fetch");
327 ok((document.body.textContent ?? "").includes("partially modified") || (document.body.textContent ?? "").includes("部分"),
328 "archive path: partial risk still visible when execution retained");
329 await ui.cleanup();
330 }
331
332 // ── Path 3b: ToolResultForTab rehydrates execution when only fullData has it ──
333 {
334 const archived: ToolItem = {
335 kind: "tool",
336 id: "arch-ps",
337 name: "bash",
338 args: "",
339 readOnly: false,
340 status: "error",
341 error: "command exited: exit status 1",
342 dataArchived: true,
343 isShell: true,
344 execution: undefined,
345 durationMs: 42,
346 };
347
348 const dom = installDom();
349 // Inject a Wails-shaped binding so bridge.realApp() picks up our stub
350 // instead of the browser mock (which always returns null for ToolResultForTab).
351 (window as unknown as { go: { main: { App: Record<string, unknown> } } }).go = {
352 main: {
353 App: {
354 ToolResultForTab: async () => ({
355 args: `{"command":"Get-Content .\\\\中文\\\\app.ps1"}`,
356 output: "error: command exited: exit status 1\nSelect-String failed",
357 execution: failedPSExecution,
358 }),
359 },
360 },
361 };
362 const rootEl = document.getElementById("root");
363 if (!rootEl) throw new Error("missing root");
364 const root = createRoot(rootEl);
365
366 try {
367 await act(async () => {
368 root.render(
369 React.createElement(LocaleProvider, null,
370 React.createElement(ToolCard, { item: archived, tabId: "tab-1" }),
371 ),
372 );
373 await flushTimers();
374 });
375 const head = document.querySelector(".tool__head") as HTMLButtonElement | null;
376 if (!head) throw new Error("tool head missing");
377 await act(async () => {
378 head.click();
379 await flushTimers();
380 await flushTimers();
381 await flushTimers();
382 });
383
384 const name = document.querySelector(".tool__name")?.textContent ?? "";
385 ok(name.includes("Windows PowerShell"), `archive rehydrate: shell name after expand (got ${JSON.stringify(name)})`);
386 const duration = document.querySelector(".tool__duration")?.textContent ?? "";
387 ok(
388 duration.includes("exit 1") || duration.includes("execution"),
389 `archive rehydrate: exit/phase after expand (got ${JSON.stringify(duration)})`,
390 );
391 const body = document.body.textContent ?? "";
392 ok(body.includes("partially modified") || body.includes("部分"), "archive rehydrate: partial risk after expand");
393 } finally {
394 await act(async () => {
395 root.unmount();
396 });
397 delete (window as unknown as { go?: unknown }).go;
398 dom.window.close();
399 }
400 }
401
402 // ── Nil execution safety ──
403 {
404 const plain: ToolItem = {
405 kind: "tool",
406 id: "plain",
407 name: "bash",
408 args: `{"command":"echo hi"}`,
409 readOnly: false,
410 status: "done",
411 output: "hi\n",
412 isShell: true,
413 };
414 const ui = await renderCard(plain);
415 ok(document.querySelector(".tool__name")?.textContent === "bash", "nil execution falls back to bash label");
416 ok(!document.querySelector("[data-shell]") || document.querySelector("[data-shell]")?.getAttribute("data-shell") === "bash",
417 "nil execution still renders shell card without throwing");
418 await ui.cleanup();
419 }
420
421 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
422 if (failed > 0) process.exit(1);
423
423 lines Plain Text