From a14e19f85dcc9aa135857309bb0d3fd7facb11bf Mon Sep 17 00:00:00 2001 From: Shironex Date: Sun, 27 Sep 2026 13:50:30 +0200 Subject: [PATCH 1/7] fix(tty): wait for the screen to settle before a tty shot --- src/tty/capture.ts | 58 ++++++++++++++++++++++++++++-- test/capture-tty.test.ts | 77 ++++++++++++++++++++++++++++++++++++++++ test/fixtures/tui.mjs | 18 +++++++--- 3 files changed, 146 insertions(+), 7 deletions(-) diff --git a/src/tty/capture.ts b/src/tty/capture.ts index 19d87d4..235ca31 100644 --- a/src/tty/capture.ts +++ b/src/tty/capture.ts @@ -10,7 +10,7 @@ import { log } from '../log.js'; import { outputPath } from '../paths.js'; import { killTreeSync } from '../process.js'; import { assertNodeRuntime, openTtySession, renderTtyScreen } from './index.js'; -import type { OpenTtySession, RenderTtyScreen, TtySession } from './types.js'; +import type { OpenTtySession, RenderTtyScreen, TtyScreen, TtySession } from './types.js'; /** The engine calls capture needs. A parameter so tests can drive the flow without a PTY. */ export interface TtyEngine { @@ -26,6 +26,21 @@ export interface TtyCaptureResult { /** Without `waitFor`, how long to wait for keys or `nav` to change the screen before taking it anyway. */ const CHANGE_WAIT_MS = 1_000; +/** + * How long the screen must stay unchanged before a shot is taken. One write of a frame can reach the kit in more + * than one read (a macOS pty hands it over 1024 bytes at a time), so the text a shot waits for can be on screen + * before the rest of its frame is. The reads of one write arrive well under a millisecond apart, so 100 ms bridges + * them even on a loaded machine, and it is about all a shot of a finished screen costs. + */ +const SETTLE_MS = 100; +/** How often to look at the screen while it settles: a quarter of `SETTLE_MS`, so a change is seen promptly. */ +const SETTLE_POLL_MS = 25; +/** + * The longest a shot waits for the screen to settle. A spinner or a clock never stops changing, and every shot of + * such an app costs this much, so it is short; a frozen mode in the app (see the terminal determinism guide) avoids it. + */ +const SETTLE_CAP_MS = 1_000; +const DETERMINISM_GUIDE = 'https://noctcore.github.io/showcase-kit/guides/terminal-determinism/'; /** How long a signal waits for open sessions to quit before exiting anyway. */ const SIGNAL_CLOSE_MS = 5_000; @@ -151,6 +166,34 @@ async function waitForChange(session: TtySession, before: string, timeoutMs: num while (Date.now() < deadline && session.screen().key === before) await session.sleep(25); } +const sleep = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, Math.max(0, ms))); + +/** The screen with everything the app printed so far parsed into it: a wait with no time left flushes, then checks. */ +async function flushedScreen(session: TtySession): Promise { + await session.waitForText('', { timeoutMs: 0 }).catch(() => {}); + return session.screen(); +} + +/** + * Wait until the screen has not changed for `SETTLE_MS`, so a shot never shows a frame the app is still drawing. + * It compares screens, not output, so an app that redraws the same frame on a timer settles at once. Gives up at + * `timeoutMs` and returns the screen as it is then, with `settled: false`. + */ +async function waitForSettle(session: TtySession, timeoutMs: number): Promise<{ screen: TtyScreen; settled: boolean }> { + const deadline = Date.now() + timeoutMs; + let screen = await flushedScreen(session); + let since = Date.now(); + for (;;) { + const now = Date.now(); + if (now - since >= SETTLE_MS) return { screen, settled: true }; + if (now >= deadline) return { screen, settled: false }; + await sleep(Math.min(SETTLE_POLL_MS, deadline - now)); + const next = await flushedScreen(session); + if (next.key !== screen.key) since = Date.now(); + screen = next; + } +} + export async function startSession( config: ResolvedTtyConfig, lang: string, @@ -201,6 +244,7 @@ async function shoot( lang: string, engine: TtyEngine, ): Promise { + const started = Date.now(); const before = session.screen().key; if (shot.keys !== undefined) await session.press(shot.keys); else if (shot.nav) await shot.nav(session); @@ -215,8 +259,18 @@ async function shoot( await waitForChange(session, before, CHANGE_WAIT_MS); } if (shot.delayMs > 0) await session.sleep(shot.delayMs); + // Every shot, including the first one after `ready` and one after a restart, waits for the app to finish drawing. + const budget = Math.min(SETTLE_CAP_MS, started + config.timeouts.shotMs - Date.now()); + const { screen, settled } = await waitForSettle(session, budget); + if (!settled) { + log.warn( + ` ${lang}/${shot.id}: the screen did not stay still for ${String(SETTLE_MS)}ms within ` + + `${String(Math.max(0, budget))}ms, so the shot may show it mid-change. Freeze spinners and clocks in the app ` + + `for captures: ${DETERMINISM_GUIDE}`, + ); + } - const png = await engine.renderTtyScreen(page, session.screen(), config.terminal, config.deviceScaleFactor); + const png = await engine.renderTtyScreen(page, screen, config.terminal, config.deviceScaleFactor); const path = outputPath(config, config.outputs.raw, lang, shot.id); await mkdir(dirname(path), { recursive: true }); await writeFile(path, png); diff --git a/test/capture-tty.test.ts b/test/capture-tty.test.ts index dcec494..888ab21 100644 --- a/test/capture-tty.test.ts +++ b/test/capture-tty.test.ts @@ -9,6 +9,7 @@ import type { ResolvedTtyConfig, TtyConfig } from '../src/config/types.js'; import { ShowcaseError } from '../src/errors.js'; import { log } from '../src/log.js'; import { captureTty, type TtyEngine } from '../src/tty/capture.js'; +import { openTtySession, renderTtyScreen } from '../src/tty/index.js'; import type { Keys, TtyScreen, TtySession, TtySessionOptions } from '../src/tty/types.js'; import { capture } from '../src/capture.js'; import { frame } from '../src/frame/index.js'; @@ -452,6 +453,82 @@ describe('capture, tty mode, real terminal', () => { expect(again.files.map(file => readFileSync(file.path).toString('base64'))).toEqual(bytes); }); + /** The real engine, keeping the text of every screen it renders. */ + function textEngine(): { engine: TtyEngine; texts: string[] } { + const texts: string[] = []; + const engine: TtyEngine = { + openTtySession, + renderTtyScreen: async (page, screen, look, deviceScaleFactor) => { + texts.push(screen.text); + return renderTtyScreen(page, screen, look, deviceScaleFactor); + }, + }; + return { engine, texts }; + } + + it('waits for a frame that arrives in two parts before any kind of shot', async () => { + const warn = vi.spyOn(log, 'warn'); + // The text each shot waits for is in the first part; the bottom rows follow 70 ms later, longer than the kit + // takes to see the first part and shorter than the 100 ms the screen must stay still. + const config = fixtureTty(tempDir(), { + target: { mode: 'tty', command: [process.execPath, TUI], cols: 80, rows: 24, inputDelayMs: 0, env: { TUI_SPLIT_MS: '70' } }, + shots: [ + { id: 'first' }, + { id: 'details', keys: '{Tab}', waitFor: 'name: api-gateway' }, + { id: 'back', keys: '{Tab}' }, + { id: 'fresh', restart: true }, + ], + }); + const { engine, texts } = textEngine(); + const { files, failures } = await captureTty(config, config.shots, config.langs, engine); + expect(failures).toEqual([]); + expect(files.map(file => file.id)).toEqual(['first', 'details', 'back', 'fresh']); + expect(texts.map(text => text.split('\n').at(-1))).toEqual(Array(4).fill(' ↑/↓ move tab switch q quit')); + expect(warn).not.toHaveBeenCalled(); + }); + + it('settles at once on an app that redraws the same frame on a timer', async () => { + const warn = vi.spyOn(log, 'warn'); + const config = fixtureTty(tempDir(), { + target: { mode: 'tty', command: [process.execPath, TUI], cols: 80, rows: 24, env: { TUI_REDRAW_MS: '20' } }, + shots: [{ id: 'services' }, { id: 'details', keys: '{Tab}', waitFor: 'name: api-gateway' }], + }); + const { files, failures } = await captureTty(config, config.shots, config.langs, textEngine().engine); + expect(failures).toEqual([]); + expect(files.map(file => file.id)).toEqual(['services', 'details']); + expect(warn).not.toHaveBeenCalled(); + }); + + it('takes the shot of an app that never stops drawing when the settle wait runs out, and says so', async () => { + const warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); + const counter = (shotMs?: number): ResolvedTtyConfig => + fixtureTty(tempDir(), { + target: { mode: 'tty', command: [process.execPath, join(FIXTURES, 'counter.mjs')], cols: 40, rows: 8 }, + ready: 'counter', + timeouts: shotMs === undefined ? undefined : { shotMs }, + shots: [{ id: 'busy' }], + }); + // By default the wait is capped at one second, far below the 15 s timeouts.shotMs. + const capped = counter(); + const first = await captureTty(capped, capped.shots, capped.langs, textEngine().engine); + expect(first.failures).toEqual([]); + expect(first.files.map(file => file.id)).toEqual(['busy']); + expect(warn).toHaveBeenCalledOnce(); + expect(warn.mock.calls[0]?.[0]).toMatch(/^ {2}en\/busy: the screen did not stay still for 100ms within 1000ms, /); + expect(warn.mock.calls[0]?.[0]).toContain('https://noctcore.github.io/showcase-kit/guides/terminal-determinism/'); + + // A shorter shotMs bounds it too. + warn.mockClear(); + const short = counter(300); + const second = await captureTty(short, short.shots, short.langs, textEngine().engine); + expect(second.failures).toEqual([]); + expect(second.files.map(file => file.id)).toEqual(['busy']); + expect(warn).toHaveBeenCalledOnce(); + const within = Number(/within (\d+)ms/.exec(String(warn.mock.calls[0]?.[0]))?.[1]); + expect(within).toBeGreaterThan(0); + expect(within).toBeLessThanOrEqual(300); + }); + it('scales with deviceScaleFactor, restarts on request, and leaves no process behind', async () => { const root = tempDir(); const pids: number[] = []; diff --git a/test/fixtures/tui.mjs b/test/fixtures/tui.mjs index bf92cca..570125a 100644 --- a/test/fixtures/tui.mjs +++ b/test/fixtures/tui.mjs @@ -5,12 +5,16 @@ // check the exact bytes a key sent. // // Env: TUI_GRANDCHILD=1 starts a sleeping grandchild and shows its PID; TUI_IGNORE_QUIT=1 ignores q and Ctrl+C; -// TUI_APP_CURSOR=1 turns on application cursor keys (arrows then arrive as ESC O A). +// TUI_APP_CURSOR=1 turns on application cursor keys (arrows then arrive as ESC O A); TUI_SPLIT_MS= writes each +// frame in two parts, the bottom three rows later, like one write a pty delivers in two reads; +// TUI_REDRAW_MS= redraws the same frame every , like an app that renders on a timer. import { spawn } from 'node:child_process'; const out = process.stdout; const items = ['api-gateway', 'billing-worker', 'postgres-main', 'redis-cache', 'web-frontend', 'cron-jobs']; const appCursor = process.env.TUI_APP_CURSOR === '1'; +const splitMs = Number(process.env.TUI_SPLIT_MS ?? 0); +const redrawMs = Number(process.env.TUI_REDRAW_MS ?? 0); let selected = 0; let screen = 'services'; let lastKey = ''; @@ -66,10 +70,13 @@ function draw() { s += move(4, 4) + `name: ${items[selected]}`; s += move(5, 4) + esc('38;5;208m') + 'orange 256' + esc('0m') + ' ' + esc('38;2;255;0;128m') + 'pink truecolor' + esc('0m'); } - s += move(rows - 2, 1) + `size ${cols}x${rows}` + (grandchild ? ` grandchild ${grandchild.pid}` : ''); - s += move(rows - 1, 1) + `last key: ${visible(lastKey)}`; - s += move(rows, 1) + esc('48;2;30;34;48m') + ' ↑/↓ move tab switch q quit '.padEnd(cols) + esc('0m'); - out.write(s); + let bottom = move(rows - 2, 1) + `size ${cols}x${rows}` + (grandchild ? ` grandchild ${grandchild.pid}` : ''); + bottom += move(rows - 1, 1) + `last key: ${visible(lastKey)}`; + bottom += move(rows, 1) + esc('48;2;30;34;48m') + ' ↑/↓ move tab switch q quit '.padEnd(cols) + esc('0m'); + if (splitMs > 0) { + out.write(s); + setTimeout(() => out.write(bottom), splitMs); + } else out.write(s + bottom); } function quit() { @@ -107,3 +114,4 @@ out.on('resize', draw); out.write(esc('?1049h') + (appCursor ? esc('?1h') : '')); draw(); +if (redrawMs > 0) setInterval(draw, redrawMs); From d7c4d2034b3e7153dd2043deabf87b036d9e8c37 Mon Sep 17 00:00:00 2001 From: Shironex Date: Sun, 27 Sep 2026 13:50:35 +0200 Subject: [PATCH 2/7] refactor(config): trim trailing separators in linear time --- src/capture.ts | 3 ++- src/config/resolve.ts | 5 +++-- src/text.ts | 16 +++++++++++++++ src/tty/pty.ts | 3 ++- test/config.test.ts | 11 ++++++++++ test/text.test.ts | 47 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 src/text.ts create mode 100644 test/text.test.ts diff --git a/src/capture.ts b/src/capture.ts index 55c7012..b663e5f 100644 --- a/src/capture.ts +++ b/src/capture.ts @@ -9,6 +9,7 @@ import { ShowcaseError } from './errors.js'; import { log } from './log.js'; import { navUrl, outputPath, select } from './paths.js'; import { answers, startCommand, waitForUrl, type StartedProcess } from './process.js'; +import { trimTrailing } from './text.js'; export interface CaptureOptions { /** Shot ids to capture. Default: all. */ @@ -106,7 +107,7 @@ async function startTarget(config: ResolvedWebConfig): Promise 0 && chars.includes(text.charAt(start - 1))) start--; + return start; +} + +/** `text` without the run of `chars` at its end, like `text.replace(/[chars]+$/, '')` in linear time. */ +export function trimTrailing(text: string, chars: string): string { + return text.slice(0, trailingRunStart(text, chars)); +} diff --git a/src/tty/pty.ts b/src/tty/pty.ts index df7fcbd..002da25 100644 --- a/src/tty/pty.ts +++ b/src/tty/pty.ts @@ -1,6 +1,7 @@ import { existsSync, statSync } from 'node:fs'; import { delimiter, extname, isAbsolute, join } from 'node:path'; import { ShowcaseError } from '../errors.js'; +import { trailingRunStart } from '../text.js'; /** The part of the node-pty API the engine uses. `@lydell/node-pty` and `node-pty` both provide it. */ export interface PtyProcess { @@ -149,7 +150,7 @@ function cmdQuote(arg: string): string { 'nor a line break. Run the program the shim starts directly, or use a command string and quote it yourself.', ); } - return /^[\w\-./\\:@+~]+$/.test(arg) ? arg : `"${arg.replace(/(\\+)$/, '$1$1')}"`; + return /^[\w\-./\\:@+~]+$/.test(arg) ? arg : `"${arg}${arg.slice(trailingRunStart(arg, '\\'))}"`; } /** diff --git a/test/config.test.ts b/test/config.test.ts index 72141a6..58a1a05 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -53,6 +53,17 @@ describe('resolveConfig', () => { }); }); + it('drops trailing separators from the portfolio dir and publicPath', () => { + const portfolio = (dir: string, publicPath: string) => + resolveConfig({ ...minimal, outputs: { portfolio: { dir, publicPath } } }, '/work/app').outputs.portfolio; + expect(portfolio('out/{slug}\\//', '/projects/{slug}//')).toMatchObject({ + dir: 'out/{slug}\\//', + publicPath: '/projects/{slug}', + gallery: 'out/{slug}/showcase.gallery.json', + }); + expect(portfolio('out', '/')).toMatchObject({ publicPath: '', gallery: 'out/showcase.gallery.json' }); + }); + it('reports every problem in a bad config at once', () => { const issues = issuesOf({ name: '', diff --git a/test/text.test.ts b/test/text.test.ts new file mode 100644 index 0000000..ccecf29 --- /dev/null +++ b/test/text.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { trailingRunStart, trimTrailing } from '../src/text.js'; + +describe('trimTrailing', () => { + it.each([ + ['normal', 'http://127.0.0.1:9222/', '/', 'http://127.0.0.1:9222'], + ['nothing to trim', '/projects/demo', '/', '/projects/demo'], + ['empty', '', '/', ''], + ['all separators', '////', '/', ''], + ['a run of mixed separators', 'out\\gallery/\\//', '\\/', 'out\\gallery'], + ['separators inside, not at the end', 'a//b', '/', 'a//b'], + ['newlines', 'row 1\n\nrow 3\n\n\n', '\n', 'row 1\n\nrow 3'], + ['spaces but not other blanks', 'cell \t ', ' ', 'cell \t'], + ])('%s', (_, text, chars, expected) => { + expect(trimTrailing(text, chars)).toBe(expected); + }); + + it('matches the regex it replaces on every short string of separators and letters', () => { + const regexes: Record = { '/': /\/+$/, '\\/': /[\\/]+$/, '\n': /\n+$/, ' ': / +$/ }; + const alphabet = ['/', '\\', '\n', ' ', 'a']; + // Every string of up to 5 characters, shortest first. + const texts = ['']; + for (let i = 0; (texts[i]?.length ?? 5) < 5; i++) for (const c of alphabet) texts.push(`${texts[i] ?? ''}${c}`); + for (const [chars, regex] of Object.entries(regexes)) { + for (const text of texts) expect(trimTrailing(text, chars), JSON.stringify(text)).toBe(text.replace(regex, '')); + } + }); + + it('stays linear on a long run of separators that does not end the string', () => { + const text = `${'/'.repeat(100_000)}x`; + const start = performance.now(); + expect(trimTrailing(text, '/')).toBe(text); + expect(trimTrailing(`${text}${'/'.repeat(100_000)}`, '/')).toBe(text); + expect(performance.now() - start).toBeLessThan(1_000); + }); +}); + +describe('trailingRunStart', () => { + it('doubles trailing backslashes the way the shim quoting needs', () => { + const double = (arg: string): string => arg + arg.slice(trailingRunStart(arg, '\\')); + expect(double('C:\\my dir\\')).toBe('C:\\my dir\\\\'); + expect(double('C:\\my dir\\\\')).toBe('C:\\my dir\\\\\\\\'); + expect(double('a\\b')).toBe('a\\b'); + expect(double('')).toBe(''); + for (const arg of ['x', '\\', 'a\\\\', '\\a\\', 'a b\\\\\\']) expect(double(arg)).toBe(arg.replace(/(\\+)$/, '$1$1')); + }); +}); From 491ff5569d9aa088ab6af07b7ab94ebf7c3d127d Mon Sep 17 00:00:00 2001 From: Shironex Date: Sun, 27 Sep 2026 13:50:35 +0200 Subject: [PATCH 3/7] refactor(tty): trim trailing blanks with the linear helper --- src/record.ts | 3 ++- src/tty/capture.ts | 3 ++- src/tty/render.ts | 3 ++- src/tty/session.ts | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/record.ts b/src/record.ts index 9ef25ea..a7f5441 100644 --- a/src/record.ts +++ b/src/record.ts @@ -10,6 +10,7 @@ import { ShowcaseError } from './errors.js'; import { composeInHole, renderFrameHole, type FrameHole } from './frame/render.js'; import { log } from './log.js'; import { fillTemplate } from './template.js'; +import { trimTrailing } from './text.js'; import type { TtyEngine } from './tty/capture.js'; import type { TtyScreen, TtySession } from './tty/types.js'; @@ -182,7 +183,7 @@ function matches(text: string, pattern: string | RegExp): boolean { } function screenNote(text: string): string { - const trimmed = text.replace(/\n+$/, ''); + const trimmed = trimTrailing(text, '\n'); return trimmed ? `Last screen:\n${trimmed.replace(/^/gm, ' | ')}` : 'The screen was empty.'; } diff --git a/src/tty/capture.ts b/src/tty/capture.ts index 235ca31..46c949e 100644 --- a/src/tty/capture.ts +++ b/src/tty/capture.ts @@ -9,6 +9,7 @@ import { ShowcaseError } from '../errors.js'; import { log } from '../log.js'; import { outputPath } from '../paths.js'; import { killTreeSync } from '../process.js'; +import { trimTrailing } from '../text.js'; import { assertNodeRuntime, openTtySession, renderTtyScreen } from './index.js'; import type { OpenTtySession, RenderTtyScreen, TtyScreen, TtySession } from './types.js'; @@ -119,7 +120,7 @@ export function describePattern(pattern: string | RegExp): string { } function lastScreen(session: TtySession): string { - const text = session.screenText().replace(/\n+$/, ''); + const text = trimTrailing(session.screenText(), '\n'); return text ? `Last screen:\n${text.replace(/^/gm, ' | ')}` : 'The screen was empty.'; } diff --git a/src/tty/render.ts b/src/tty/render.ts index 937747c..202dabc 100644 --- a/src/tty/render.ts +++ b/src/tty/render.ts @@ -1,5 +1,6 @@ import type { Page } from 'playwright'; import { ShowcaseError } from '../errors.js'; +import { trimTrailing } from '../text.js'; import { Attr, DEFAULT_COLOR, TRUECOLOR, type Grid, type GridCell, type GridColor } from './session.js'; import { BUNDLED_ADVANCE, checkTheme, FALLBACK_FAMILY, FONT_FAMILY, fontFaceCss, fontFaces } from './theme.js'; import type { RenderTtyScreen, ResolvedTerminalOptions, TerminalTheme, TtyScreen } from './types.js'; @@ -93,7 +94,7 @@ export function gridHtml(grid: Grid, look: ResolvedTerminalOptions, metrics: Cel if (!run) return; // Trailing blanks without a background or a line draw nothing: leave them out. if (run.text.length === run.width && !/background|text-decoration/.test(run.style)) { - const text = run.text.replace(/ +$/, ''); + const text = trimTrailing(run.text, ' '); run.width -= run.text.length - text.length; run.text = text; } diff --git a/src/tty/session.ts b/src/tty/session.ts index 68ffa15..3a955f5 100644 --- a/src/tty/session.ts +++ b/src/tty/session.ts @@ -3,6 +3,7 @@ import type { IBufferCell, IBufferLine, Terminal } from '@xterm/headless'; import { ShowcaseError } from '../errors.js'; import { log } from '../log.js'; import { killTreeSync } from '../process.js'; +import { trimTrailing } from '../text.js'; import { parseKeys } from './keys.js'; import { loadPty, spawnPty, type PtyProcess } from './pty.js'; import type { OpenTtySession, TtyScreen, TtySession } from './types.js'; @@ -63,7 +64,7 @@ function attrs(cell: IBufferCell): number { /** A row as plain text. xterm only trims cells never written to; spaces the app printed are trimmed too. */ function rowText(line: IBufferLine | undefined): string { - return (line?.translateToString(true) ?? '').replace(/ +$/, ''); + return trimTrailing(line?.translateToString(true) ?? '', ' '); } /** Copy the visible screen of `term` into an immutable `TtyScreen`. */ From 66d0ad141eaac7b7a09675af3b5850491915cc95 Mon Sep 17 00:00:00 2001 From: Shironex Date: Sun, 27 Sep 2026 13:50:35 +0200 Subject: [PATCH 4/7] docs(site): describe the settle wait before a tty shot --- site/src/content/docs/guides/terminal-apps.mdx | 10 ++++++++-- site/src/content/docs/guides/terminal-determinism.mdx | 8 +++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/site/src/content/docs/guides/terminal-apps.mdx b/site/src/content/docs/guides/terminal-apps.mdx index 30172c9..c53ef1e 100644 --- a/site/src/content/docs/guides/terminal-apps.mdx +++ b/site/src/content/docs/guides/terminal-apps.mdx @@ -215,8 +215,8 @@ last one left off. For each language the kit: 1. starts the app and waits for `ready` (without `ready`, for any text at all), up to `target.readyTimeoutMs`; 2. waits `inputDelayMs`, then runs `setup` once, with `{ tty, lang, mode: 'tty', config }`; -3. per shot: presses the `keys` (or runs `nav`), waits for `waitFor`, waits `delayMs`, and renders - the screen to the raw PNG; +3. per shot: presses the `keys` (or runs `nav`), waits for `waitFor`, waits `delayMs`, waits for + the screen to stop changing, and renders the screen to the raw PNG; 4. closes the app: sends `quitKey`, waits up to 1.5 s for it to quit, then kills its whole process tree by PID. @@ -224,6 +224,12 @@ Without `waitFor`, the kit gives the app up to one second to redraw after the ke soon as the screen changes); set `waitFor` for anything slower. `timeouts.shotMs` (default 15000) bounds each `waitFor`. +Before it renders, the kit waits until the screen has not changed for 100 ms, so a shot never shows +a frame the app is still drawing (a terminal can hand one write over in pieces). That adds about +100 ms to a shot. For an app whose screen never stops changing, the wait gives up after 1 s (sooner +if the shot's `timeouts.shotMs` runs out), takes the screen as it is and warns: freeze the app for +captures, see [Terminal determinism](/showcase-kit/guides/terminal-determinism/). + Without `ready`, the kit only waits for the app to draw anything, then the `inputDelayMs` grace, which may catch an app halfway through its first screen. Set `ready` to text the finished screen shows. diff --git a/site/src/content/docs/guides/terminal-determinism.mdx b/site/src/content/docs/guides/terminal-determinism.mdx index 9aea513..c6bbb5f 100644 --- a/site/src/content/docs/guides/terminal-determinism.mdx +++ b/site/src/content/docs/guides/terminal-determinism.mdx @@ -22,9 +22,11 @@ images must not churn. `FORCE_COLOR=3`), `TZ=UTC`, `LANG` and `LC_ALL` set to `en_US.UTF-8`, and no CI or terminal program hints (`CI`, `NO_COLOR`, `TERM_PROGRAM`, `WT_SESSION` and the like are removed). The full list is in [Terminal apps](/showcase-kit/guides/terminal-apps/). -3. **It settles on screen content, not on output silence.** It waits for the `ready` and `waitFor` - text, then a fixed grace. Waiting for the app to go quiet would never end for an app with a clock - or a spinner. +3. **It waits for a finished screen.** It waits for the `ready` and `waitFor` text, then, before + every shot, for the screen to stay unchanged for 100 ms, so a frame the terminal hands over in + pieces is never shot half drawn. It compares screens, not output, so an app that redraws the same + frame on a timer settles at once. A screen that never stops changing (a clock, a spinner) is shot + after at most 1 s, with a warning. 4. **A grace period after `ready`** (`target.inputDelayMs`, default 300 ms) before the first key, so keys are not lost while the app is still switching its terminal to raw mode. 5. **A fixed look.** A bundled font, a whole-pixel cell width, a fixed line height, a fixed theme and From a491a919558d3bcadf98cba12c3eae4aac408efa Mon Sep 17 00:00:00 2001 From: Shironex Date: Sun, 27 Sep 2026 13:50:35 +0200 Subject: [PATCH 5/7] ci(build-test): run the suite on macos-latest too --- .github/workflows/ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e052e1..7589c23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,8 +14,9 @@ jobs: strategy: fail-fast: false matrix: - # Linux runs the POSIX process group kill path, Windows the taskkill path. - os: [ubuntu-24.04, windows-latest] + # Linux runs the POSIX process group kill path, Windows the taskkill path. macOS ptys hand an app's output + # over in smaller reads than Linux, which is where a shot of a half drawn frame showed up (#3). + os: [ubuntu-24.04, windows-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 From c476aed6e654a8f68513bc17c1532c72e8c6230e Mon Sep 17 00:00:00 2001 From: Shironex Date: Sun, 27 Sep 2026 13:50:35 +0200 Subject: [PATCH 6/7] docs(contributing): list macos in ci and the new fixture tui modes --- CONTRIBUTING.md | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9a8afa4..345a137 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,9 +55,9 @@ bun run typecheck # tsc --noEmit over src/, test/ and the two config files bun run test # bun run build, then vitest run: every test file, one at a time ``` -CI (`.github/workflows/ci.yml`, on `ubuntu-24.04` and `windows-latest`) runs the same steps: -`bun install --frozen-lockfile`, `bunx playwright install --with-deps chromium`, `bun run typecheck` -and `bun run test`, which does the build. +CI (`.github/workflows/ci.yml`, on `ubuntu-24.04`, `windows-latest` and `macos-latest`) runs the +same steps: `bun install --frozen-lockfile`, `bunx playwright install --with-deps chromium`, +`bun run typecheck` and `bun run test`, which does the build. The docs site under `site/` is its own package with its own `bun.lock`, not a workspace (a root `workspaces` field would make changesets stop versioning the published package). It reads the built @@ -178,9 +178,9 @@ hold themselves to the same rule, on Linux, Windows and macOS: directory from `tempDir()`, under the OS temp directory. - **Fixtures have no clock and no randomness.** The fixture app and TUI draw fixed content; the TUI changes behaviour only through environment variables (`TUI_GRANDCHILD`, `TUI_IGNORE_QUIT`, - `TUI_APP_CURSOR`). A new fixture follows the same rule. The kit itself gives terminal apps - `TZ=UTC`, a fixed `TERM`, `COLORTERM`, `FORCE_COLOR`, `LANG` and `LC_ALL`, and strips `CI`, - `NO_COLOR` and other terminal hints (`ttyEnv` in `src/tty/pty.ts`). + `TUI_APP_CURSOR`, `TUI_SPLIT_MS`, `TUI_REDRAW_MS`). A new fixture follows the same rule. The kit + itself gives terminal apps `TZ=UTC`, a fixed `TERM`, `COLORTERM`, `FORCE_COLOR`, `LANG` and + `LC_ALL`, and strips `CI`, `NO_COLOR` and other terminal hints (`ttyEnv` in `src/tty/pty.ts`). - **No golden images.** Fonts rasterize differently on macOS than on Windows and Linux, so tests check image sizes computed from the layout (the comment next to each expected size shows the arithmetic), the colour of chosen pixels with a tolerance, file lists and screen text. They never @@ -201,8 +201,7 @@ hold themselves to the same rule, on Linux, Windows and macOS: ## Platform notes -CI runs on Linux and Windows. macOS is not in CI, so if your change touches the PTY, fonts, paths or -process handling and you have a Mac, run the suite there too. +CI runs on Linux, Windows and macOS. - **Paths.** Build paths with `node:path` (`join`, `resolve`), never with `/` in a string, and turn a path into an import URL with `pathToFileURL`. On Windows `\tools` is absolute but relative to the @@ -270,8 +269,8 @@ for dependencies and packaging. For example `fix(tty): pass shim arguments with like 8.3 short paths`. One logical change per commit. The pull request template asks for a changeset, the gates you actually ran, docs and JSDoc for a -new or changed config key, and deterministic tests. CI runs `typecheck` and `test` on Linux and -Windows on every pull request. +new or changed config key, and deterministic tests. CI runs `typecheck` and `test` on Linux, +Windows and macOS on every pull request. ## Reporting From cbe7e61740e3475d1d3d04b98fb7328b349980cb Mon Sep 17 00:00:00 2001 From: Shironex Date: Sun, 27 Sep 2026 13:50:35 +0200 Subject: [PATCH 7/7] chore(changeset): add a patch changeset for the tty settle wait --- .changeset/tty-shot-settle.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tty-shot-settle.md diff --git a/.changeset/tty-shot-settle.md b/.changeset/tty-shot-settle.md new file mode 100644 index 0000000..c810ac0 --- /dev/null +++ b/.changeset/tty-shot-settle.md @@ -0,0 +1,5 @@ +--- +"@noctcore/showcase-kit": patch +--- + +Terminal apps: a shot no longer catches a frame the app is still drawing. A terminal can hand one write over in pieces (a macOS pty passes 1024 bytes at a time), so the `waitFor` text could be on screen before the rest of its frame, and the shot came out cut off, with different bytes from run to run. Before every shot the kit now waits until the screen has not changed for 100 ms, which adds about 100 ms to each shot. An app that redraws the same frame on a timer settles at once. An app whose screen never stops changing (a clock, a spinner) is shot after at most 1 s, or sooner when the shot's `timeouts.shotMs` runs out, with a warning; give it a frozen mode for captures, as the terminal determinism guide describes. Trailing separators in a CDP url, `outputs.portfolio` and shim arguments are now trimmed in linear time, with the same results as before.