diff --git a/services/github/api-commits.ts b/services/github/api-commits.ts index 6054a98fa..03d47ed69 100644 --- a/services/github/api-commits.ts +++ b/services/github/api-commits.ts @@ -192,12 +192,16 @@ export async function createSignedCommit(opts: ApiCommitOptions): Promise(CREATE_COMMIT_MUTATION, { diff --git a/services/wizard-ci/e2e.ts b/services/wizard-ci/e2e.ts index fe06efbf8..a5fdd3879 100644 --- a/services/wizard-ci/e2e.ts +++ b/services/wizard-ci/e2e.ts @@ -184,7 +184,7 @@ export function runE2e(opts: E2eOptions): number { ["full interactive flow reached keep-skills", !!result?.screenPath?.includes("keep-skills")], ["skillsComplete", result?.skillsComplete === true], ] - : [["reached the outro", !!result?.screenPath?.includes("outro")]]; + : [["reached the outro", !!result?.screenPath?.some((s) => s.endsWith("outro"))]]; const checks: Array<[string, boolean]> = result ? [ ["agent run completed", result.runPhase === "completed"], diff --git a/services/wizard-ci/snapshots-review.ts b/services/wizard-ci/snapshots-review.ts index b0596da93..e39d4a00c 100644 --- a/services/wizard-ci/snapshots-review.ts +++ b/services/wizard-ci/snapshots-review.ts @@ -100,7 +100,8 @@ async function main(): Promise { const reportDir = reportDirFor(app); // 1. a real run → report.html (the current real-TUI frames). - run("npx", ["tsx", "services/wizard-ci/snapshots.ts", app]); + const renderArgs = args.includes("--render-only") ? ["--render-only"] : []; + run("npx", ["tsx", "services/wizard-ci/snapshots.ts", app, ...renderArgs]); const report = join(reportDir, "report.html"); if (!existsSync(report)) { console.error(`✖ no report at ${report}`); @@ -164,7 +165,7 @@ async function main(): Promise { const baseSha = git("rev-parse HEAD", repoRoot); console.log(`Opening snapshots-review PR on branch ${branch}…`); try { - const r = (await commitAndCreatePR({ + const r = await commitAndCreatePR({ repoOwner, repoName, repoRoot, @@ -177,8 +178,12 @@ async function main(): Promise { body, draft: false, token, - })) as { prUrl?: string }; - console.log(`✓ review PR: ${r.prUrl ?? "(created)"}`); + }); + if (!r.success) { + console.error(`✖ ${r.error ?? "review PR not created"}`); + return 1; + } + console.log(`✓ review PR: ${r.prUrl}`); // When triggered by a /wizard-ci PR comment, report back on that PR with a // link to the full review. diff --git a/services/wizard-ci/snapshots.ts b/services/wizard-ci/snapshots.ts index fd422a175..f1427513c 100644 --- a/services/wizard-ci/snapshots.ts +++ b/services/wizard-ci/snapshots.ts @@ -71,6 +71,67 @@ function escapeHtml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">"); } +function paletteCss(n: number): string { + const BASIC = [ + "#000000", "#cd3131", "#0dbc79", "#e5e510", "#2472c8", "#bc3fbc", + "#11a8cd", "#e5e5e5", "#666666", "#f14c4c", "#23d18b", "#f5f543", + "#3b8eea", "#d670d6", "#29b8db", "#ffffff", + ]; + if (n < 16) return BASIC[n]; + if (n < 232) { + const i = n - 16; + const step = (v: number) => (v === 0 ? 0 : 55 + v * 40); + return `rgb(${step(Math.floor(i / 36))},${step(Math.floor(i / 6) % 6)},${step(i % 6)})`; + } + const g = 8 + (n - 232) * 10; + return `rgb(${g},${g},${g})`; +} + +function ansiToHtml(s: string): string { + let out = ""; + let open = false; + let last = 0; + const SGR = /\x1b\[([0-9;]*)m/g; + const close = () => { + if (open) out += ""; + open = false; + }; + for (let m = SGR.exec(s); m; m = SGR.exec(s)) { + out += escapeHtml(s.slice(last, m.index)); + last = m.index + m[0].length; + close(); + const codes = m[1].split(";").filter(Boolean).map(Number); + if (!codes.length) continue; + const style: string[] = []; + for (let i = 0; i < codes.length; i++) { + const c = codes[i]; + if (c === 1) style.push("font-weight:bold"); + else if (c === 2) style.push("opacity:.6"); + else if (c === 3) style.push("font-style:italic"); + else if (c === 4) style.push("text-decoration:underline"); + else if (c === 8) style.push("visibility:hidden"); + else if (c === 9) style.push("text-decoration:line-through"); + else if (c === 38 || c === 48) { + const prop = c === 38 ? "color" : "background"; + if (codes[i + 1] === 5) { + style.push(`${prop}:${paletteCss(codes[i + 2])}`); + i += 2; + } else if (codes[i + 1] === 2) { + style.push(`${prop}:rgb(${codes[i + 2]},${codes[i + 3]},${codes[i + 4]})`); + i += 4; + } + } + } + if (style.length) { + out += ``; + open = true; + } + } + out += escapeHtml(s.slice(last)); + close(); + return out; +} + function reportHtml( name: string, frames: Frame[], @@ -81,7 +142,7 @@ function reportHtml( (f) => `

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

-
${escapeHtml(f.text)}
+
${ansiToHtml(f.text)}
`, ) .join(""); @@ -113,6 +174,8 @@ async function main(): Promise { const programIdx = args.indexOf("--program"); const explicitProgram = programIdx !== -1 ? args[programIdx + 1] : undefined; + const renderOnly = args.includes("--render-only"); + // Resolve which app(s) to snapshot: // - explicit positional → that app // - no app, interactive shell (local) → app picker, like the run screens @@ -147,7 +210,7 @@ async function main(): Promise { // A non-zero run (e.g. self-driving aborting at GitHub-connect) is still // worth a report — render whatever frames were captured, and reflect the // failure in the exit code rather than bailing before the report. - const code = runE2e({ app: def.app, projectId, program }); + const code = renderOnly ? 0 : runE2e({ app: def.app, projectId, program }); if (code !== 0) { console.error(`✖ e2e run failed for ${def.name} (exit ${code}) — rendering captured frames anyway`); failed = true;