| 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 |