| 1 | // Real-browser click verification for answer file references and the SVG code |
| 2 | // block. Proves the DOM behavior the tsx suites can only simulate: a verified |
| 3 | // path becomes a clickable reference that commits a preview command into the |
| 4 | // running navigation owner, an unverified path stays plain text, and the SVG |
| 5 | // block actually renders a picture from the sanitized source with a working |
| 6 | // preview/source toggle. |
| 7 | import assert from "node:assert/strict"; |
| 8 | import { createServer } from "vite"; |
| 9 | import path from "node:path"; |
| 10 | import { fileURLToPath } from "node:url"; |
| 11 | |
| 12 | const frontendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); |
| 13 | process.env.PLAYWRIGHT_BROWSERS_PATH = !process.env.PLAYWRIGHT_BROWSERS_PATH || process.env.PLAYWRIGHT_BROWSERS_PATH === ".pw-browsers" |
| 14 | ? path.join(frontendDir, ".pw-browsers") |
| 15 | : process.env.PLAYWRIGHT_BROWSERS_PATH; |
| 16 | const { chromium } = await import("playwright"); |
| 17 | |
| 18 | const server = await createServer({ |
| 19 | root: frontendDir, |
| 20 | server: { host: "127.0.0.1", port: 0, hmr: false }, |
| 21 | logLevel: "error", |
| 22 | plugins: [{ |
| 23 | name: "chat-file-reference-fixture", |
| 24 | configureServer(dev) { |
| 25 | dev.middlewares.use(async (req, res, next) => { |
| 26 | if (req.url !== "/chat-file-reference-fixture") return next(); |
| 27 | res.setHeader("Content-Type", "text/html"); |
| 28 | res.end(await dev.transformIndexHtml(req.url, '<html><head><link rel="icon" href="data:,"></head><body><div id="root"></div><script type="module" src="/bench/chat-file-reference-fixture.tsx"></script></body></html>')); |
| 29 | }); |
| 30 | }, |
| 31 | }], |
| 32 | }); |
| 33 | await server.listen(); |
| 34 | |
| 35 | let browser; |
| 36 | try { |
| 37 | const address = server.httpServer.address(); |
| 38 | browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_EXECUTABLE }); |
| 39 | const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); |
| 40 | const errors = []; |
| 41 | page.on("pageerror", error => { errors.push(error.message); console.error(error.message); }); |
| 42 | page.on("console", message => { if (message.type() === "error") { errors.push(message.text()); console.error(message.text()); } }); |
| 43 | await page.goto(`http://127.0.0.1:${address.port}/chat-file-reference-fixture`); |
| 44 | |
| 45 | // The verified reference is clickable and names the host's display path. |
| 46 | const reference = page.locator("button.md-code--presented-file"); |
| 47 | await reference.waitFor({ timeout: 10_000 }); |
| 48 | assert.equal(await reference.getAttribute("title"), "out/diagram.svg", "reference names the verified display path"); |
| 49 | assert.match(await page.locator(".md").first().innerText(), /\/repo\/out\/missing\.svg/, "the answer text is preserved"); |
| 50 | assert.equal(await page.locator("button.md-code--presented-file").count(), 1, "an unverified path stays text"); |
| 51 | console.log("PASS verified reference is the only clickable path"); |
| 52 | |
| 53 | await reference.click(); |
| 54 | await page.waitForFunction(() => document.getElementById("request")?.textContent !== "", null, { timeout: 10_000 }); |
| 55 | const request = JSON.parse(await page.locator("#request").textContent()); |
| 56 | assert.equal(request.source, "reference", "the click enters the navigation lifecycle as a reference"); |
| 57 | assert.equal(request.action, "preview", "the default click previews"); |
| 58 | assert.equal(request.path, "out/diagram.svg", "the preview targets the verified display path"); |
| 59 | console.log("PASS click commits a reference preview command"); |
| 60 | |
| 61 | // The SVG fence renders a picture built from the sanitized bytes. |
| 62 | const image = page.locator(".md-svg__preview img"); |
| 63 | await image.waitFor({ timeout: 10_000 }); |
| 64 | const source = await image.getAttribute("src"); |
| 65 | assert.match(source, /^(blob:|data:image\/svg\+xml)/, "the preview loads sanitized bytes as an image source"); |
| 66 | const box = await image.boundingBox(); |
| 67 | assert(box && box.width > 0 && box.height > 0, "the picture is laid out with real geometry"); |
| 68 | // A malformed document still yields a sized <img> element, so prove the |
| 69 | // browser decoded the picture instead of showing a broken-image placeholder. |
| 70 | const decoded = await page.evaluate(async () => { |
| 71 | const img = document.querySelector(".md-svg__preview img"); |
| 72 | if (!(img instanceof HTMLImageElement)) return null; |
| 73 | try { await img.decode(); } catch { return "decode-failed"; } |
| 74 | return img.naturalWidth > 0 && img.naturalHeight > 0 ? "decoded" : "empty"; |
| 75 | }); |
| 76 | assert.equal(decoded, "decoded", "the browser decoded the sanitized SVG as an image"); |
| 77 | assert(box.height <= 32 * 16 + 1, "the preview stays inside its height ceiling"); |
| 78 | assert.equal(await page.locator(".md-svg__note").count(), 0, "a previewable SVG shows no fallback note"); |
| 79 | console.log(`PASS svg fence renders a ${Math.round(box.width)}x${Math.round(box.height)} picture`); |
| 80 | |
| 81 | // Preview / source toggle, and copy keeps the original text. |
| 82 | await page.getByRole("button", { name: /source|源码|原始碼/i }).click(); |
| 83 | await page.locator(".md-svg .code-block").waitFor({ timeout: 10_000 }); |
| 84 | const code = await page.locator(".md-svg .code-block").innerText(); |
| 85 | assert.match(code, /linearGradient/, "the source view shows the original SVG"); |
| 86 | assert.equal(await page.locator(".md-svg__preview img").count(), 0, "source mode replaces the picture"); |
| 87 | await page.getByRole("button", { name: /preview|预览|預覽/i }).click(); |
| 88 | await page.locator(".md-svg__preview img").waitFor({ timeout: 10_000 }); |
| 89 | console.log("PASS preview and source toggle both ways"); |
| 90 | |
| 91 | assert.deepEqual(errors, [], "no page errors"); |
| 92 | console.log("PASS no page errors"); |
| 93 | } finally { |
| 94 | await browser?.close(); |
| 95 | await server.close(); |
| 96 | } |
| 97 |