返回 DeepSeek-Reasonix
tool-card-command.test.tsx
根目录 / desktop / frontend / src / __tests__ / tool-card-command.test.tsx
1 import assert from "node:assert/strict";
2 import { JSDOM } from "jsdom";
3 import { act, createElement, useLayoutEffect } from "react";
4 import { createRoot } from "react-dom/client";
5 import { ToolCard } from "../components/ToolCard";
6 import { LocaleProvider } from "../lib/i18n";
7 import type { Item } from "../lib/useController";
8 import type { AppBindings } from "../lib/bridge";
9 import { installDesktopHostStub } from "./desktopHostStub";
10 import "../components/editors/HljsCode";
11
12 type Tool = Extract<Item, { kind: "tool" }>;
13 type Result = Awaited<ReturnType<AppBindings["ToolResultForTab"]>>;
14 const command = "printf '%s\\n' \"C:\\中文\\file.txt\"\r\n echo " + "long-command-".repeat(70) + "END_OF_COMMAND";
15 const card = (patch: Partial<Tool> = {}): Tool => ({
16 kind: "tool", id: "command", name: "bash", readOnly: false, status: "done",
17 args: JSON.stringify({ command }), output: "OUTPUT_STAYS_VISIBLE", ...patch,
18 });
19 function deferred() {
20 let resolve!: (value: Result) => void;
21 let reject!: (error: Error) => void;
22 const promise = new Promise<Result>((yes, no) => { resolve = yes; reject = no; });
23 return { promise, resolve, reject };
24 }
25 const settle = () => new Promise<void>(resolve => setImmediate(resolve));
26
27 async function mount(item: Tool, load: AppBindings["ToolResultForTab"] = async () => null) {
28 const dom = new JSDOM("<!doctype html><html><body><div id='root'></div></body></html>", {
29 url: "http://localhost/", pretendToBeVisual: true,
30 });
31 Object.assign(globalThis, {
32 window: dom.window, document: dom.window.document, Node: dom.window.Node,
33 Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, Event: dom.window.Event,
34 MouseEvent: dom.window.MouseEvent, IS_REACT_ACT_ENVIRONMENT: true,
35 requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window),
36 cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window),
37 });
38 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
39 const copied: string[] = [];
40 Object.defineProperty(dom.window.navigator, "clipboard", { configurable: true, value: {
41 writeText: async (text: string) => { copied.push(text); },
42 } });
43 const host = installDesktopHostStub({ ToolResultForTab: load });
44 const root = createRoot(document.getElementById("root")!);
45 const paints: string[] = [];
46 function PaintProbe({ current, tab }: { current: Tool; tab: string }) {
47 useLayoutEffect(() => { paints.push(document.querySelector(".tool__command")?.textContent ?? ""); });
48 return createElement(ToolCard, { item: current, tabId: tab });
49 }
50 async function update(current: Tool, tab = "tab-a") {
51 await act(async () => {
52 root.render(createElement(LocaleProvider, null, createElement(PaintProbe, { current, tab })));
53 await settle();
54 });
55 }
56 await update(item);
57 async function click(selector: string) {
58 const button = document.querySelector<HTMLButtonElement>(selector);
59 assert.ok(button, "missing button " + selector);
60 await act(async () => { button.click(); await settle(); });
61 }
62 return {
63 copied, paints, update, click,
64 expand: () => click(".tool__head"),
65 async cleanup() {
66 await act(async () => root.unmount());
67 host.uninstall();
68 dom.window.close();
69 },
70 };
71 }
72
73 // Full command is a body surface, not the ellipsized summary, and remains
74 // available independently of whether the process returned any stdout.
75 for (const output of ["OUTPUT_STAYS_VISIBLE", ""]) {
76 const ui = await mount(card({ output }));
77 try {
78 assert.equal(document.querySelector(".tool__command"), null, "collapsed command is not mounted");
79 await ui.expand();
80 assert.ok(document.querySelector(".tool__command code")?.textContent?.includes("END_OF_COMMAND"), "expanded command is complete");
81 assert.equal(document.querySelectorAll(".tool__command").length, 1);
82 if (output) assert.ok(document.querySelector(".tool__body")?.textContent?.includes(output));
83 assert.ok(!document.querySelector(".tool__body")?.textContent?.includes('"command":'), "no duplicate args JSON");
84 await ui.click(".tool__command .copybtn");
85 assert.deepEqual(ui.copied, [command], "copy preserves CRLF, quoting and backslashes");
86 await ui.expand();
87 assert.equal(document.querySelector(".tool__command"), null);
88 } finally { await ui.cleanup(); }
89 }
90
91 for (const [shell, language] of [["bash", "bash"], ["zsh", "bash"], ["powershell", "powershell"], ["pwsh", "powershell"], ["cmd", undefined], ["unknown-shell", undefined]] as const) {
92 const ui = await mount(card({ name: "exec_command", execution: { shell }, status: "error", error: "failed" }));
93 try {
94 await ui.expand();
95 assert.ok(document.querySelector(".tool__command code")?.textContent?.includes("END_OF_COMMAND"));
96 assert.equal(document.querySelector(".tool__command pre")?.getAttribute("data-lang") ?? undefined, language);
97 } finally { await ui.cleanup(); }
98 }
99
100 for (const args of ['{"path":"file.txt"}', "{invalid", '{"command":42}', "null"]) {
101 const ui = await mount(card({ args, output: "" }));
102 try {
103 await ui.expand();
104 assert.equal(document.querySelector(".tool__command"), null, "unknown args do not invent a command");
105 assert.ok(document.querySelector(".tool__body code"), "raw args remain inspectable");
106 } finally { await ui.cleanup(); }
107 }
108 {
109 const ui = await mount(card({ name: "read_file", args: '{"path":"file.txt"}' }));
110 try { await ui.expand(); assert.equal(document.querySelector(".tool__command"), null); }
111 finally { await ui.cleanup(); }
112 }
113
114 // Archived titles are summaries, never the source of the complete command.
115 {
116 const request = deferred();
117 let calls = 0;
118 const ui = await mount(card({ dataArchived: true, args: "", subject: "TRUNCATED_SUMMARY" }), () => { calls++; return request.promise; });
119 try {
120 assert.equal(calls, 0);
121 await ui.expand();
122 assert.equal(calls, 1);
123 assert.equal(document.querySelector(".tool__command"), null);
124 assert.ok(document.querySelector('.tool__data-status[role="status"]'));
125 await act(async () => { request.resolve({ args: JSON.stringify({ command }), output: "ARCHIVED_OUTPUT" }); await settle(); });
126 assert.ok(document.querySelector(".tool__command code")?.textContent?.includes("END_OF_COMMAND"));
127 assert.ok(document.querySelector(".tool__body")?.textContent?.includes("ARCHIVED_OUTPUT"));
128 } finally { await ui.cleanup(); }
129 }
130 for (const unavailable of [false, true]) {
131 let calls = 0;
132 const ui = await mount(card({ dataArchived: true, args: "" }), async () => {
133 if (++calls === 1) { if (unavailable) return null; throw new Error("fixture read failure"); }
134 return { args: JSON.stringify({ command }), output: "" };
135 });
136 try {
137 await ui.expand();
138 assert.ok(document.querySelector('.tool__data-status[role="alert"]'), "failed archive load is visible");
139 await ui.click(".tool__data-status button");
140 assert.equal(calls, 2);
141 assert.ok(document.querySelector(".tool__command code")?.textContent?.includes("END_OF_COMMAND"));
142 } finally { await ui.cleanup(); }
143 }
144
145 // Reject a late response after tab/item replacement, including the first paint
146 // before passive effect cleanup gets a chance to clear an old cached result.
147 {
148 const old = deferred(), next = deferred();
149 const archived = card({ dataArchived: true, args: "" });
150 const ui = await mount(archived, tab => tab === "tab-a" ? old.promise : next.promise);
151 try {
152 await ui.expand();
153 await ui.update(archived, "tab-b");
154 await act(async () => { old.resolve({ args: '{"command":"STALE_COMMAND"}', output: "" }); await settle(); });
155 assert.ok(!document.querySelector(".tool__command")?.textContent?.includes("STALE_COMMAND"));
156 await act(async () => { next.resolve({ args: '{"command":"CURRENT_COMMAND"}', output: "" }); await settle(); });
157 assert.ok(document.querySelector(".tool__command")?.textContent?.includes("CURRENT_COMMAND"));
158 await ui.update(card({ id: "replacement", args: '{"command":"NEW_COMMAND"}' }), "tab-b");
159 assert.ok(!ui.paints.at(-1)?.includes("CURRENT_COMMAND"), "new tool's first paint never uses old full data");
160 } finally { await ui.cleanup(); }
161 }
162 {
163 const pending = deferred();
164 const ui = await mount(card({ dataArchived: true, args: "" }), () => pending.promise);
165 await ui.expand();
166 await ui.cleanup();
167 await act(async () => { pending.resolve({ args: '{"command":"UNMOUNTED"}', output: "" }); await settle(); });
168 }
169 console.log("PASS shell command visibility, exact copy, archived retry and identity fencing");
170
170 lines Plain Text