From f922f4ca3696a6c656fe25d1f03747ed685fef5f Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Fri, 10 Jul 2026 19:05:57 -0400 Subject: [PATCH 1/2] fix(wizard-ci): restore ansi-html so snapshot frames render in colour Bring back the ansi-html.ts converter (deleted in 96685c4f8) and render .ans frames through it, so the report PNGs show the TUI's colours instead of raw escape codes. Co-Authored-By: Claude Opus 4.8 --- services/wizard-ci/ansi-html.ts | 109 ++++++++++++++++++++++++++++++++ services/wizard-ci/snapshots.ts | 7 +- 2 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 services/wizard-ci/ansi-html.ts diff --git a/services/wizard-ci/ansi-html.ts b/services/wizard-ci/ansi-html.ts new file mode 100644 index 000000000..4307b1b6a --- /dev/null +++ b/services/wizard-ci/ansi-html.ts @@ -0,0 +1,109 @@ +/** + * Minimal ANSI (SGR) → HTML converter — no dependency, runs offline in CI. + * Handles the subset Ink emits: reset, bold/dim/italic/underline (+ resets), + * 16-color fg/bg, bright fg/bg, 256-color (38;5;n / 48;5;n), and truecolor + * (38;2;r;g;b / 48;2;r;g;b). Unknown codes are ignored. Output is HTML-escaped. + */ + +const BASE16 = [ + "#000000", "#cd3131", "#0dbc79", "#e5e510", "#2472c8", "#bc3fbc", "#11a8cd", "#e5e5e5", + "#666666", "#f14c4c", "#23d18b", "#f5f543", "#3b8eea", "#d670d6", "#29b8db", "#ffffff", +]; + +/** xterm 256-color index → #rrggbb. */ +function xterm256(n: number): string { + if (n < 16) return BASE16[n]; + if (n < 232) { + const i = n - 16; + const r = Math.floor(i / 36); + const g = Math.floor((i % 36) / 6); + const b = i % 6; + const c = (v: number) => (v === 0 ? 0 : 55 + v * 40); + return `#${[c(r), c(g), c(b)].map((v) => v.toString(16).padStart(2, "0")).join("")}`; + } + const v = (n - 232) * 10 + 8; + return `#${[v, v, v].map((x) => x.toString(16).padStart(2, "0")).join("")}`; +} + +interface Style { + fg?: string; + bg?: string; + bold?: boolean; + dim?: boolean; + italic?: boolean; + underline?: boolean; +} + +function styleToCss(s: Style): string { + const parts: string[] = []; + if (s.fg) parts.push(`color:${s.fg}`); + if (s.bg) parts.push(`background:${s.bg}`); + if (s.bold) parts.push("font-weight:bold"); + if (s.dim) parts.push("opacity:.6"); + if (s.italic) parts.push("font-style:italic"); + if (s.underline) parts.push("text-decoration:underline"); + return parts.join(";"); +} + +function applyCodes(style: Style, codes: number[]): Style { + const s = { ...style }; + for (let i = 0; i < codes.length; i++) { + const c = codes[i]; + if (c === 0) { + for (const k of Object.keys(s)) delete (s as Record)[k]; + } else if (c === 1) s.bold = true; + else if (c === 2) s.dim = true; + else if (c === 3) s.italic = true; + else if (c === 4) s.underline = true; + else if (c === 22) (s.bold = false), (s.dim = false); + else if (c === 23) s.italic = false; + else if (c === 24) s.underline = false; + else if (c === 39) delete s.fg; + else if (c === 49) delete s.bg; + else if (c >= 30 && c <= 37) s.fg = BASE16[c - 30]; + else if (c >= 90 && c <= 97) s.fg = BASE16[c - 90 + 8]; + else if (c >= 40 && c <= 47) s.bg = BASE16[c - 40]; + else if (c >= 100 && c <= 107) s.bg = BASE16[c - 100 + 8]; + else if (c === 38 || c === 48) { + const target = c === 38 ? "fg" : "bg"; + if (codes[i + 1] === 5) { + s[target] = xterm256(codes[i + 2]); + i += 2; + } else if (codes[i + 1] === 2) { + const [r, g, b] = [codes[i + 2], codes[i + 3], codes[i + 4]]; + s[target] = `#${[r, g, b].map((v) => (v || 0).toString(16).padStart(2, "0")).join("")}`; + i += 4; + } + } + } + return s; +} + +const escapeHtml = (s: string) => + s.replace(/&/g, "&").replace(//g, ">"); + +export function ansiToHtml(input: string): string { + let style: Style = {}; + let out = ""; + let buf = ""; + const flush = () => { + if (!buf) return; + const css = styleToCss(style); + out += css ? `${escapeHtml(buf)}` : escapeHtml(buf); + buf = ""; + }; + // eslint-disable-next-line no-control-regex + const re = /\x1b\[([0-9;]*)m/g; + let last = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(input))) { + buf += input.slice(last, m.index); + flush(); + const codes = m[1] === "" ? [0] : m[1].split(";").map(Number); + style = applyCodes(style, codes); + last = re.lastIndex; + } + buf += input.slice(last); + flush(); + return out; +} diff --git a/services/wizard-ci/snapshots.ts b/services/wizard-ci/snapshots.ts index fd422a175..70a9b266e 100644 --- a/services/wizard-ci/snapshots.ts +++ b/services/wizard-ci/snapshots.ts @@ -36,6 +36,7 @@ import { fmtElapsed, APPS_DIR, } from "./e2e.js"; +import { ansiToHtml } from "./ansi-html.js"; import { findApps } from "./utils.js"; import { commandToProgram, findCommandByAppPath } from "../wizard-commands.js"; import { selectApp } from "../wizard-run/picker.js"; @@ -67,10 +68,6 @@ 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[], @@ -81,7 +78,7 @@ function reportHtml( (f) => `

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

-
${escapeHtml(f.text)}
+
${ansiToHtml(f.text)}
`, ) .join(""); From 91da71e6f940fce66ffbcae7a49808d8f630588b Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Fri, 10 Jul 2026 19:22:47 -0400 Subject: [PATCH 2/2] fix(wizard-ci): render snapshot frames with xterm.js instead of a converter Drop the bespoke ansi-html converter and render each captured .ans frame in a real xterm.js terminal in the report page, so Playwright screenshots the actual coloured terminal. xterm does the colour; no SGR logic of our own. Co-Authored-By: Claude Opus 4.8 --- package.json | 1 + pnpm-lock.yaml | 18 +++++ services/wizard-ci/ansi-html.ts | 109 --------------------------- services/wizard-ci/report-runtime.js | 45 +++++++++++ services/wizard-ci/screenshot.ts | 5 ++ services/wizard-ci/snapshots.ts | 37 +++++---- 6 files changed, 92 insertions(+), 123 deletions(-) delete mode 100644 services/wizard-ci/ansi-html.ts create mode 100644 services/wizard-ci/report-runtime.js 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/ansi-html.ts b/services/wizard-ci/ansi-html.ts deleted file mode 100644 index 4307b1b6a..000000000 --- a/services/wizard-ci/ansi-html.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Minimal ANSI (SGR) → HTML converter — no dependency, runs offline in CI. - * Handles the subset Ink emits: reset, bold/dim/italic/underline (+ resets), - * 16-color fg/bg, bright fg/bg, 256-color (38;5;n / 48;5;n), and truecolor - * (38;2;r;g;b / 48;2;r;g;b). Unknown codes are ignored. Output is HTML-escaped. - */ - -const BASE16 = [ - "#000000", "#cd3131", "#0dbc79", "#e5e510", "#2472c8", "#bc3fbc", "#11a8cd", "#e5e5e5", - "#666666", "#f14c4c", "#23d18b", "#f5f543", "#3b8eea", "#d670d6", "#29b8db", "#ffffff", -]; - -/** xterm 256-color index → #rrggbb. */ -function xterm256(n: number): string { - if (n < 16) return BASE16[n]; - if (n < 232) { - const i = n - 16; - const r = Math.floor(i / 36); - const g = Math.floor((i % 36) / 6); - const b = i % 6; - const c = (v: number) => (v === 0 ? 0 : 55 + v * 40); - return `#${[c(r), c(g), c(b)].map((v) => v.toString(16).padStart(2, "0")).join("")}`; - } - const v = (n - 232) * 10 + 8; - return `#${[v, v, v].map((x) => x.toString(16).padStart(2, "0")).join("")}`; -} - -interface Style { - fg?: string; - bg?: string; - bold?: boolean; - dim?: boolean; - italic?: boolean; - underline?: boolean; -} - -function styleToCss(s: Style): string { - const parts: string[] = []; - if (s.fg) parts.push(`color:${s.fg}`); - if (s.bg) parts.push(`background:${s.bg}`); - if (s.bold) parts.push("font-weight:bold"); - if (s.dim) parts.push("opacity:.6"); - if (s.italic) parts.push("font-style:italic"); - if (s.underline) parts.push("text-decoration:underline"); - return parts.join(";"); -} - -function applyCodes(style: Style, codes: number[]): Style { - const s = { ...style }; - for (let i = 0; i < codes.length; i++) { - const c = codes[i]; - if (c === 0) { - for (const k of Object.keys(s)) delete (s as Record)[k]; - } else if (c === 1) s.bold = true; - else if (c === 2) s.dim = true; - else if (c === 3) s.italic = true; - else if (c === 4) s.underline = true; - else if (c === 22) (s.bold = false), (s.dim = false); - else if (c === 23) s.italic = false; - else if (c === 24) s.underline = false; - else if (c === 39) delete s.fg; - else if (c === 49) delete s.bg; - else if (c >= 30 && c <= 37) s.fg = BASE16[c - 30]; - else if (c >= 90 && c <= 97) s.fg = BASE16[c - 90 + 8]; - else if (c >= 40 && c <= 47) s.bg = BASE16[c - 40]; - else if (c >= 100 && c <= 107) s.bg = BASE16[c - 100 + 8]; - else if (c === 38 || c === 48) { - const target = c === 38 ? "fg" : "bg"; - if (codes[i + 1] === 5) { - s[target] = xterm256(codes[i + 2]); - i += 2; - } else if (codes[i + 1] === 2) { - const [r, g, b] = [codes[i + 2], codes[i + 3], codes[i + 4]]; - s[target] = `#${[r, g, b].map((v) => (v || 0).toString(16).padStart(2, "0")).join("")}`; - i += 4; - } - } - } - return s; -} - -const escapeHtml = (s: string) => - s.replace(/&/g, "&").replace(//g, ">"); - -export function ansiToHtml(input: string): string { - let style: Style = {}; - let out = ""; - let buf = ""; - const flush = () => { - if (!buf) return; - const css = styleToCss(style); - out += css ? `${escapeHtml(buf)}` : escapeHtml(buf); - buf = ""; - }; - // eslint-disable-next-line no-control-regex - const re = /\x1b\[([0-9;]*)m/g; - let last = 0; - let m: RegExpExecArray | null; - while ((m = re.exec(input))) { - buf += input.slice(last, m.index); - flush(); - const codes = m[1] === "" ? [0] : m[1].split(";").map(Number); - style = applyCodes(style, codes); - last = re.lastIndex; - } - buf += input.slice(last); - flush(); - return out; -} 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 70a9b266e..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, @@ -36,11 +37,18 @@ import { fmtElapsed, APPS_DIR, } from "./e2e.js"; -import { ansiToHtml } from "./ansi-html.js"; 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; @@ -73,27 +81,28 @@ function reportHtml( frames: Frame[], timings: Record, ): string { - const rows = frames - .map( - (f) => ` -
-

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

-
${ansiToHtml(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} +
+ + + `; }