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