diff --git a/package.json b/package.json index 630f2a06f..b0cb16721 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "0.2.73", "@octokit/rest": "^21.0.0", + "@xterm/xterm": "^6.0.0", "dotenv": "^16.4.0", "posthog-node": "^5.28.1", "sanitize-html": "^2.17.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ba11a0c4..078a6926f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@octokit/rest': specifier: ^21.0.0 version: 21.1.1 + '@xterm/xterm': + specifier: ^6.0.0 + version: 6.0.0 dotenv: specifier: ^16.4.0 version: 16.6.1 @@ -239,56 +242,66 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-win32-arm64@0.34.5': resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} @@ -369,6 +382,9 @@ packages: '@types/sanitize-html@2.16.1': resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==} + '@xterm/xterm@6.0.0': + resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} + before-after-hook@3.0.2: resolution: {integrity: sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==} @@ -768,6 +784,8 @@ snapshots: dependencies: htmlparser2: 10.1.0 + '@xterm/xterm@6.0.0': {} + before-after-hook@3.0.2: {} cross-spawn@7.0.6: diff --git a/services/wizard-ci/report-runtime.js b/services/wizard-ci/report-runtime.js new file mode 100644 index 000000000..03e23d85c --- /dev/null +++ b/services/wizard-ci/report-runtime.js @@ -0,0 +1,45 @@ +// Browser runtime for the snapshot report: render each captured frame's ANSI +// into a real xterm.js terminal so Playwright can screenshot the colored TUI. +// Frames arrive as window.__FRAMES__; sets window.__ready once all have painted. +(() => { + const frames = window.__FRAMES__ || []; + const root = document.getElementById("rows"); + let pending = frames.length; + const done = () => { + if (--pending === 0) requestAnimationFrame(() => (window.__ready = true)); + }; + if (!frames.length) window.__ready = true; + + for (const f of frames) { + const section = document.createElement("section"); + section.className = "row"; + section.setAttribute("data-frame", f.file); + section.innerHTML = + '

' + f.file + ' (' + f.elapsed + ')

'; + const host = document.createElement("div"); + host.className = "term"; + section.appendChild(host); + root.appendChild(section); + + // Drop the trailing newline so writing the last row doesn't scroll the top + // row (the header bar) off into discarded scrollback. + const ansi = f.ansi.replace(/\n$/, ""); + // Size the terminal to the frame: rows = line count, cols = widest line + // (measured with SGR escapes stripped, since they occupy no columns). + const lines = ansi.split("\n"); + const cols = Math.max(1, ...lines.map((l) => l.replace(/\x1b\[[0-9;]*m/g, "").length)); + const term = new Terminal({ + cols, + rows: Math.max(1, lines.length), + fontSize: 14, + fontFamily: 'ui-monospace,SFMono-Regular,Menlo,"DejaVu Sans Mono",monospace', + theme: { background: "#010409", foreground: "#c9d1d9" }, + convertEol: true, + scrollback: 0, + disableStdin: true, + cursorInactiveStyle: "none", + }); + term.open(host); + term.write(ansi, done); + } +})(); diff --git a/services/wizard-ci/screenshot.ts b/services/wizard-ci/screenshot.ts index 55d3ca052..17d58828a 100644 --- a/services/wizard-ci/screenshot.ts +++ b/services/wizard-ci/screenshot.ts @@ -32,6 +32,11 @@ async function main(): Promise { }); await page.goto(`file://${report}`); await page.waitForLoadState("networkidle"); + // The report renders each frame into an xterm terminal; wait until they've + // all painted before screenshotting. + await page.waitForFunction(() => (window as { __ready?: boolean }).__ready === true, { + timeout: 30_000, + }); const rows = page.locator("section.row[data-frame]"); const count = await rows.count(); diff --git a/services/wizard-ci/snapshots.ts b/services/wizard-ci/snapshots.ts index fd422a175..fc8190f8f 100644 --- a/services/wizard-ci/snapshots.ts +++ b/services/wizard-ci/snapshots.ts @@ -21,6 +21,7 @@ */ import "dotenv/config"; import { join, basename } from "path"; +import { createRequire } from "module"; import { existsSync, mkdirSync, @@ -40,6 +41,14 @@ import { findApps } from "./utils.js"; import { commandToProgram, findCommandByAppPath } from "../wizard-commands.js"; import { selectApp } from "../wizard-run/picker.js"; +// xterm.js is the terminal emulator; the browser build renders the captured +// ANSI to a real colored terminal that Playwright then screenshots. Inline its +// script + CSS so the report is self-contained. +const require = createRequire(import.meta.url); +const XTERM_JS = readFileSync(require.resolve("@xterm/xterm/lib/xterm.js"), "utf8"); +const XTERM_CSS = readFileSync(require.resolve("@xterm/xterm/css/xterm.css"), "utf8"); +const RUNTIME_JS = readFileSync(require.resolve("./report-runtime.js"), "utf8"); + /** A CI-e2e test definition: which flow runs against which app. */ interface TestDef { name: string; @@ -67,36 +76,33 @@ function readFrames(dir: string): Frame[] { .map((file) => ({ file, text: readFileSync(join(dir, file), "utf8") })); } -function escapeHtml(s: string): string { - return s.replace(/&/g, "&").replace(//g, ">"); -} - function reportHtml( name: string, frames: Frame[], timings: Record, ): string { - const rows = frames - .map( - (f) => ` -
-

${f.file} (${fmtElapsed(timings[f.file] ?? 0)})

-
${escapeHtml(f.text)}
-
`, - ) - .join(""); + const data = JSON.stringify( + frames.map((f) => ({ + file: f.file, + elapsed: fmtElapsed(timings[f.file] ?? 0), + ansi: f.text, + })), + ); return `wizard-ci snapshots — ${name} -

wizard-ci TUI snapshots — ${name}

${frames.length} key-moment frames from the current run
-${rows} +
+ + + `; }