| 1 | #!/usr/bin/env node |
| 2 | // Headless test for the webview's tool-call rendering (media/main.js). |
| 3 | // |
| 4 | // Loads the real webview script against a minimal fake DOM and replays a |
| 5 | // tool_call -> tool_call_update sequence (the model-download flow), asserting |
| 6 | // that updates sharing a toolCallId mutate ONE row in place (with a progress |
| 7 | // bar) instead of stacking new bubbles. |
| 8 | // |
| 9 | // Run: node test/webview.mjs |
| 10 | |
| 11 | import { readFileSync } from "node:fs"; |
| 12 | import { fileURLToPath } from "node:url"; |
| 13 | import { dirname, join } from "node:path"; |
| 14 | |
| 15 | const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 16 | const src = readFileSync(join(__dirname, "..", "media", "main.js"), "utf8"); |
| 17 | |
| 18 | function makeEl() { |
| 19 | return { |
| 20 | className: "", |
| 21 | hidden: false, |
| 22 | style: {}, |
| 23 | textContent: "", |
| 24 | children: [], |
| 25 | value: "", |
| 26 | disabled: false, |
| 27 | scrollTop: 0, |
| 28 | scrollHeight: 0, |
| 29 | appendChild(c) { |
| 30 | this.children.push(c); |
| 31 | return c; |
| 32 | }, |
| 33 | addEventListener() {}, |
| 34 | _innerHTML: "", |
| 35 | set innerHTML(v) { |
| 36 | this._innerHTML = v; |
| 37 | if (!v) { |
| 38 | this.children = []; |
| 39 | } |
| 40 | }, |
| 41 | get innerHTML() { |
| 42 | return this._innerHTML; |
| 43 | } |
| 44 | }; |
| 45 | } |
| 46 | |
| 47 | const messagesEl = makeEl(); |
| 48 | const byId = { |
| 49 | messages: messagesEl, |
| 50 | status: makeEl(), |
| 51 | composer: makeEl(), |
| 52 | input: makeEl(), |
| 53 | send: makeEl() |
| 54 | }; |
| 55 | |
| 56 | let messageHandler = null; |
| 57 | globalThis.acquireVsCodeApi = () => ({ postMessage() {}, getState() {}, setState() {} }); |
| 58 | globalThis.document = { |
| 59 | getElementById: (id) => byId[id], |
| 60 | createElement: () => makeEl() |
| 61 | }; |
| 62 | globalThis.window = { |
| 63 | addEventListener: (type, fn) => { |
| 64 | if (type === "message") { |
| 65 | messageHandler = fn; |
| 66 | } |
| 67 | } |
| 68 | }; |
| 69 | |
| 70 | // Run the real webview IIFE in this faked global context. |
| 71 | (0, eval)(src); |
| 72 | |
| 73 | function send(msg) { |
| 74 | messageHandler({ data: msg }); |
| 75 | } |
| 76 | function toolRows() { |
| 77 | return messagesEl.children.filter((c) => c.className.includes("message-tool")); |
| 78 | } |
| 79 | function assert(cond, msg) { |
| 80 | if (!cond) { |
| 81 | console.error(`FAIL: ${msg}`); |
| 82 | process.exit(1); |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | assert(typeof messageHandler === "function", "webview registered a message handler"); |
| 87 | |
| 88 | // Same toolCallId across four updates -> exactly one row. |
| 89 | send({ type: "tool", toolCallId: "tc1", title: "Downloading Qwen 2.5 3B", status: "in_progress" }); |
| 90 | send({ type: "tool", toolCallId: "tc1", title: "Downloading Qwen 2.5 3B (~1.80 GB) (0%)", status: "in_progress" }); |
| 91 | send({ type: "tool", toolCallId: "tc1", title: "Downloading Qwen 2.5 3B (~1.80 GB) (50%)", status: "in_progress" }); |
| 92 | send({ type: "tool", toolCallId: "tc1", status: "failed" }); // no title -> keep prior label |
| 93 | |
| 94 | assert(toolRows().length === 1, `4 updates should yield 1 row, got ${toolRows().length}`); |
| 95 | console.log(`OK in-place: 4 updates -> ${toolRows().length} row`); |
| 96 | |
| 97 | const row = toolRows()[0]; |
| 98 | assert(row.className.includes("tool-failed"), `final status class was "${row.className}"`); |
| 99 | console.log("OK final status -> tool-failed"); |
| 100 | |
| 101 | const bubble = row.children[0]; |
| 102 | const labelEl = bubble.children[0]; |
| 103 | const bar = bubble.children[1].children[0]; |
| 104 | assert( |
| 105 | labelEl.textContent === "Downloading Qwen 2.5 3B (~1.80 GB) — failed", |
| 106 | `label was "${labelEl.textContent}"` |
| 107 | ); |
| 108 | console.log(`OK label updated in place: "${labelEl.textContent}"`); |
| 109 | assert(bar.style.width === "50%", `progress width was "${bar.style.width}"`); |
| 110 | console.log(`OK progress bar at ${bar.style.width}`); |
| 111 | |
| 112 | // A different toolCallId makes a new row. |
| 113 | send({ type: "tool", toolCallId: "tc2", title: "Reading file", status: "completed" }); |
| 114 | assert(toolRows().length === 2, `distinct id should add a row, got ${toolRows().length}`); |
| 115 | console.log("OK distinct toolCallId -> new row"); |
| 116 | |
| 117 | // clear() resets tracking so ids can be reused cleanly. |
| 118 | send({ type: "clear" }); |
| 119 | send({ type: "tool", toolCallId: "tc1", title: "Again", status: "in_progress" }); |
| 120 | assert(toolRows().length === 1, `after clear expected 1 row, got ${toolRows().length}`); |
| 121 | console.log("OK clear resets tool tracking"); |
| 122 | |
| 123 | console.log("\nALL WEBVIEW CHECKS PASSED"); |