diff --git a/docs/media/gate-demo-narrow-transcript.txt b/docs/media/gate-demo-narrow-transcript.txt index a5bf6d4..d678c26 100644 --- a/docs/media/gate-demo-narrow-transcript.txt +++ b/docs/media/gate-demo-narrow-transcript.txt @@ -23,13 +23,13 @@ proofloop gate: running check "npm test" -> npm test > tiny-app@1.0.0 test > node test.js -proofloop gate: check "npm test" FAILED (2640ms, exit 1) +proofloop gate: check "npm test" FAILED (876ms, exit 1) proofloop gate: FAILED state: ~/tiny-app\.proofloop\gate-state.json - ts: 2026-10-16T15:09:56.875Z + ts: 2026-10-18T03:45:01.655Z source: npm-test-fallback checks: - - [FAIL] npm test (2640ms) + - [FAIL] npm test (876ms) node:assert:150 throw new AssertionError(obj); @@ -70,11 +70,11 @@ proofloop gate: running check "npm test" -> npm test > node test.js test ok: add(2,3)=5 -proofloop gate: check "npm test" PASSED (2760ms, exit 0) +proofloop gate: check "npm test" PASSED (926ms, exit 0) proofloop gate: PASSED state: ~/tiny-app\.proofloop\gate-state.json - ts: 2026-10-16T15:10:00.090Z + ts: 2026-10-18T03:45:02.726Z source: npm-test-fallback checks: - - [pass] npm test (2760ms) + - [pass] npm test (926ms) diff --git a/docs/media/gate-demo-narrow.gif b/docs/media/gate-demo-narrow.gif index 7562531..afeadb1 100644 Binary files a/docs/media/gate-demo-narrow.gif and b/docs/media/gate-demo-narrow.gif differ diff --git a/scripts/narrow-wrap.mjs b/scripts/narrow-wrap.mjs new file mode 100644 index 0000000..549b882 --- /dev/null +++ b/scripts/narrow-wrap.mjs @@ -0,0 +1,60 @@ +/** + * The 34-column wrap behind `node scripts/record-gate-demo.mjs --narrow`, + * kept apart from the recorder so tests can feed it lines without running + * Playwright or ffmpeg. + */ +export const COLS = 34; +export const IND = " "; + +// Greedy word wrap. A soft break drops the spaces at the break (kept in `join`), +// a token longer than a row is split (join ""), so rows can always be re-joined exactly. +export function wrapLine(s) { + if (s.length <= COLS) return [{ text: s, join: "" }]; + const rows = []; + let cur = ""; + let pend = ""; + let join = ""; + const flush = (next) => { + rows.push({ text: cur, join }); + cur = IND; + join = next; + }; + for (const tok of s.match(/ +|[^ ]+/g)) { + if (tok[0] === " ") { + pend += tok; + continue; + } + if (cur.length + pend.length + tok.length <= COLS) { + cur += pend + tok; + pend = ""; + continue; + } + if (cur.trim()) flush(pend); + else cur += pend; + pend = ""; + let w = tok; + while (cur.length + w.length > COLS) { + const room = COLS - cur.length; + cur += w.slice(0, room); + w = w.slice(room); + flush(""); + } + cur += w; + } + rows.push({ text: cur + pend, join }); + return rows; +} + +/** + * Every wrapped row, whitespace-only and leading-indent rows included, must fit + * the column budget; a wider row would be clipped by the canvas. wrapLine does + * not guarantee this on its own: a 40-space line, trailing spaces, or an indent + * wider than a row each come back over-long, so this check is the guard. + */ +export function assertRowsFit(rows, src) { + for (const row of rows) { + if (!(row.length <= COLS)) { + throw new Error(`wrapped row is ${row.length} columns, over ${COLS}: ${JSON.stringify(row)} (from ${JSON.stringify(src)})`); + } + } +} diff --git a/scripts/record-gate-demo.mjs b/scripts/record-gate-demo.mjs index 21dd9ff..d544715 100644 --- a/scripts/record-gate-demo.mjs +++ b/scripts/record-gate-demo.mjs @@ -40,7 +40,7 @@ const { chromium } = await import("playwright").catch(() => { /** * Phone variant: node scripts/record-gate-demo.mjs --narrow - * Same real runs, replayed on a 648x900 canvas at 32px so the text is still + * Same real runs, replayed at 32px on a 648 px wide canvas, as tall as its fullest frame, so the text is still * readable when GitHub scales the image to a ~324 css px phone column (cap * height about 10 css px; the 960px clip lands near 3). Lines are wrapped at * 34 columns on word boundaries (a token longer than a row is split), never @@ -50,6 +50,7 @@ const { chromium } = await import("playwright").catch(() => { * the fresh run it replays; the wide clip and its transcript are not touched). * NARROW_WRAP_JSON= also dumps the wrapped rows for an external check. */ +import { COLS, IND, wrapLine, assertRowsFit } from "./narrow-wrap.mjs"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const NARROW = process.argv.includes("--narrow"); const OUT_GIF = path.join(ROOT, NARROW ? "docs/media/gate-demo-narrow.gif" : "docs/media/gate-demo.gif"); @@ -112,47 +113,7 @@ push(gatePass); await fs.mkdir(path.dirname(OUT_TXT), { recursive: true }); await fs.writeFile(OUT_TXT, lines.map((l) => l.s).join("\n") + "\n"); -// --- 3b. Phone variant (--narrow): wrap, replay on a 648x900 canvas, build the GIF, stop. -const COLS = 34; -const IND = " "; -// Greedy word wrap. A soft break drops the spaces at the break (kept in `join`), -// a token longer than a row is split (join ""), so rows can always be re-joined exactly. -function wrapLine(s) { - if (s.length <= COLS) return [{ text: s, join: "" }]; - const rows = []; - let cur = ""; - let pend = ""; - let join = ""; - const flush = (next) => { - rows.push({ text: cur, join }); - cur = IND; - join = next; - }; - for (const tok of s.match(/ +|[^ ]+/g)) { - if (tok[0] === " ") { - pend += tok; - continue; - } - if (cur.length + pend.length + tok.length <= COLS) { - cur += pend + tok; - pend = ""; - continue; - } - if (cur.trim()) flush(pend); - else cur += pend; - pend = ""; - let w = tok; - while (cur.length + w.length > COLS) { - const room = COLS - cur.length; - cur += w.slice(0, room); - w = w.slice(room); - flush(""); - } - cur += w; - } - rows.push({ text: cur + pend, join }); - return rows; -} +// --- 3b. Phone variant (--narrow): wrap, replay on a canvas fitted to its tallest frame, build the GIF, stop. if (NARROW) { const esc = (x) => x.replace(/&/g, "&").replace(//g, ">"); const rows = []; @@ -164,6 +125,7 @@ if (NARROW) { const n = (x) => x.split(word).length - 1; if (n(l.s) !== w.reduce((a, r) => a + n(r.text), 0)) throw new Error(`${word} split across rows: ${JSON.stringify(l.s)}`); } + assertRowsFit(w.map((r) => r.text), l.s); // every row.length <= COLS, whitespace-only and indented rows included for (const r of w) rows.push({ ...r, src, t: l.t, verdict: /^proofloop gate: (FAILED|PASSED)$/.test(l.s) }); } if (process.env.NARROW_WRAP_JSON) { @@ -172,8 +134,22 @@ if (NARROW) { JSON.stringify({ cols: COLS, indent: IND.length, lines: lines.map((l) => l.s), rows }, null, 1), ); } - const W = 648, H = 900, FONT = 32, PITCH = 45, PAD = 24; - const visible = Math.floor((H - 2 * PAD) / PITCH); + // 3 rows per frame, but a frame always ends on a verdict row; verdict frames and the last frame are held. + const PER_FRAME = 3, FRAME_S = 0.36, HOLD_S = 2.4; + const marks = []; + let since = 0; + rows.forEach((r, i) => { + if (++since === PER_FRAME || r.verdict || i === rows.length - 1) { + marks.push(i + 1); + since = 0; + } + }); + // The terminal scrolls: at most VISIBLE rows show at once. The canvas is exactly as tall as the + // fullest frame (plus padding), so a full window leaves no blank band; frames still filling up are shorter than the canvas. + const W = 648, FONT = 32, PITCH = 34, PAD = 12, VISIBLE = 12; + const visible = Math.min(VISIBLE, rows.length); + const tallest = Math.max(...marks.map((n) => Math.min(n, visible))); + const H = 2 * PAD + tallest * PITCH; const rowHtml = (r) => { const cls = r.t + (/FAILED/.test(r.text) ? " fail" : "") + (/PASSED/.test(r.text) ? " pass" : ""); return `
${esc(r.text) || " "}
`; @@ -196,16 +172,6 @@ if (NARROW) { el.style.display = i < n && i >= n - ${visible} ? "block" : "none"; }); `; - // 3 rows per frame, but a frame always ends on a verdict row; verdict frames and the last frame are held. - const PER_FRAME = 3, FRAME_S = 0.36, HOLD_S = 2.4; - const marks = []; - let since = 0; - rows.forEach((r, i) => { - if (++since === PER_FRAME || r.verdict || i === rows.length - 1) { - marks.push(i + 1); - since = 0; - } - }); const frameDir = await fs.mkdtemp(path.join(os.tmpdir(), "gate-frames-")); const browser = await chromium.launch(); const pg = await browser.newPage({ viewport: { width: W, height: H } }); @@ -221,6 +187,7 @@ if (NARROW) { const list = ["ffconcat version 1.0"]; for (const [k, n] of marks.entries()) { await pg.evaluate((m) => window.reveal(m), n); + if (await pg.evaluate(() => term.scrollHeight > term.clientHeight)) throw new Error(`frame ${k}: rows are taller than the canvas`); const f = `f${String(k).padStart(4, "0")}.png`; await pg.screenshot({ path: path.join(frameDir, f) }); const hold = rows[n - 1].verdict || k === marks.length - 1; diff --git a/tests/narrowWrap.test.ts b/tests/narrowWrap.test.ts new file mode 100644 index 0000000..6f59fc5 --- /dev/null +++ b/tests/narrowWrap.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +// @ts-expect-error plain .mjs shared with scripts/record-gate-demo.mjs +import { COLS, wrapLine, assertRowsFit } from "../scripts/narrow-wrap.mjs"; + +/** + * The scenario: a reader on a phone opens the README, where the narrow gate-demo + * GIF is shown at about half its pixel width. A row wider than 34 columns would be + * clipped by the canvas, so the last characters of a real command or verdict would + * silently go missing from the clip. The recorder wraps every transcript line at + * 34 columns and must refuse to render if any row, including a whitespace-only or + * deeply indented one, is wider than that. + */ +const rowsOf = (line: string): string[] => wrapLine(line).map((r: { text: string }) => r.text); + +describe("narrow gate-demo wrap: every row fits 34 columns", () => { + it("accepts transcript-shaped lines: prose, a path, a long unbroken token, a short indent", () => { + const lines = [ + "$ node dist/cli.js gate # the gate decides whether \u201cdone\u201d is true", + " state: ~/tiny-app\\.proofloop\\gate-state.json", + " at Object. (~/tiny-app\\test.js:3:8)", + " " + "y".repeat(60), + "", + " ", + ]; + for (const line of lines) expect(() => assertRowsFit(rowsOf(line), line)).not.toThrow(); + }); + + // Negative controls: wrapLine really does return these over-long (so the assertion is + // load-bearing, not decorative), and assertRowsFit must throw on every one of them. + it.each([ + ["a whitespace-only line", " ".repeat(40)], + ["trailing spaces after text", "x".repeat(10) + " ".repeat(40)], + ["a leading indent wider than a row", " ".repeat(36) + "x".repeat(40)], + ])("throws on %s", (_name, line) => { + const rows = rowsOf(line); + expect(Math.max(...rows.map((r) => r.length))).toBeGreaterThan(COLS); + expect(() => assertRowsFit(rows, line)).toThrow(/over 34/); + }); + + it("throws on a hand-made over-long row", () => { + expect(() => assertRowsFit(["x".repeat(COLS + 1)], "synthetic")).toThrow(/35 columns, over 34/); + }); +});