From d0c8eefe1ea78c82daabb1c60b41097d556a456c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 10:23:20 +0000 Subject: [PATCH 01/20] chore: bump overrides to clear high-severity audit advisories aube audit --audit-level high (run by CI) reports 8 high advisories in transitive dev dependencies: brace-expansion (<5.0.9), js-yaml (<4.3.1), nanoid (<3.3.18), and postcss (<=8.5.17). Bump the existing overrides block to the fixed versions; each package resolves to a single version in the tree, so the bumps are mechanical. --- aube-lock.yaml | 49 ++++++++++++++++++++++++++----------------------- package.json | 5 ++++- 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/aube-lock.yaml b/aube-lock.yaml index e16de45..8be2bd9 100644 --- a/aube-lock.yaml +++ b/aube-lock.yaml @@ -5,8 +5,11 @@ settings: excludeLinksFromLockfile: false overrides: - brace-expansion: 5.0.6 + brace-expansion: 5.0.9 esbuild: 0.28.1 + js-yaml: 4.3.1 + nanoid: 3.3.18 + postcss: 8.5.18 vite: 8.0.16 ws: 8.21.0 @@ -101,7 +104,7 @@ time: balanced-match@4.0.4: 2026-02-22T11:38:25.951Z before-after-hook@2.2.3: 2022-10-04T00:19:26.570Z boolbase@1.0.0: 2014-02-15T14:44:50.620Z - brace-expansion@5.0.6: 2026-05-08T05:41:43.205Z + brace-expansion@5.0.9: 2026-07-30T10:00:32.762Z camelcase-keys@6.2.2: 2020-04-03T03:51:03.816Z camelcase@5.3.1: 2019-04-03T13:34:32.701Z chai@6.2.2: 2025-12-22T21:26:03.989Z @@ -181,7 +184,7 @@ time: is-obj@2.0.0: 2019-04-19T15:37:37.870Z is-plain-obj@1.1.0: 2015-11-05T09:31:58.189Z js-tokens@4.0.0: 2018-01-28T11:58:58.170Z - js-yaml@4.2.0: 2026-05-31T22:17:13.783Z + js-yaml@4.3.1: 2026-07-31T17:39:51.183Z jsep@1.4.0: 2024-11-05T14:49:55.640Z json-parse-even-better-errors@2.3.1: 2020-09-02T16:37:58.371Z json-stringify-safe@5.0.1: 2015-05-19T01:42:09.719Z @@ -212,7 +215,7 @@ time: minipass@7.1.3: 2026-02-19T00:34:33.886Z modify-values@1.0.1: 2018-03-23T07:35:47.388Z ms@2.1.3: 2020-12-08T13:54:35.223Z - nanoid@3.3.12: 2026-04-30T22:04:14.515Z + nanoid@3.3.18: 2026-08-07T16:41:05.696Z neo-async@2.6.2: 2020-07-09T18:23:53.065Z node-addon-api@7.1.1: 2024-07-12T10:15:07.595Z node-addon-api@8.7.0: 2026-03-26T01:10:33.995Z @@ -243,7 +246,7 @@ time: picomatch@4.0.4: 2026-03-23T20:39:47.960Z playwright-core@1.60.0: 2026-05-11T19:09:40.047Z playwright@1.60.0: 2026-05-11T19:09:33.114Z - postcss@8.5.15: 2026-05-19T09:51:29.843Z + postcss@8.5.18: 2026-07-12T20:38:40.936Z quick-lru@4.0.1: 2019-05-29T17:21:30.565Z react-reconciler@0.33.0: 2025-10-01T21:39:00.081Z react@19.2.7: 2026-06-01T18:00:48.323Z @@ -895,9 +898,9 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} camelcase-keys@6.2.2: resolution: {integrity: sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==} @@ -1220,8 +1223,8 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.2.0: - resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true jsep@1.4.0: @@ -1374,8 +1377,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -1495,8 +1498,8 @@ packages: engines: {node: '>=18'} hasBin: true - postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + postcss@8.5.18: + resolution: {integrity: sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==} engines: {node: ^10 || ^12 || >=14} quick-lru@4.0.1: @@ -1933,7 +1936,7 @@ snapshots: dependencies: '@octokit/rest': 20.1.2(@octokit/core@5.2.2) '@octokit/types': 13.10.0 - js-yaml: 4.2.0 + js-yaml: 4.3.1 minimatch: 10.2.5 '@iarna/toml@3.0.0': {} @@ -2174,7 +2177,7 @@ snapshots: boolbase@1.0.0: {} - brace-expansion@5.0.6: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -2468,7 +2471,7 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.2.0: + js-yaml@4.3.1: dependencies: argparse: 2.0.1 @@ -2554,7 +2557,7 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.6 + brace-expansion: 5.0.9 minimist-options@4.1.0: dependencies: @@ -2570,7 +2573,7 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.12: {} + nanoid@3.3.18: {} neo-async@2.6.2: {} @@ -2695,9 +2698,9 @@ snapshots: optionalDependencies: fsevents: 2.3.2 - postcss@8.5.15: + postcss@8.5.18: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -2749,7 +2752,7 @@ snapshots: figures: 3.2.0 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 - js-yaml: 4.2.0 + js-yaml: 4.3.1 jsonpath-plus: 10.4.0(jsep@1.4.0) node-html-parser: 6.1.13 parse-github-repo-url: 1.4.1 @@ -2954,7 +2957,7 @@ snapshots: esbuild: 0.28.1 lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.15 + postcss: 8.5.18 rolldown: 1.0.3 tinyglobby: 0.2.17(picomatch@4.0.4) tsx: 4.21.0 diff --git a/package.json b/package.json index b802f0a..8841fcf 100644 --- a/package.json +++ b/package.json @@ -100,8 +100,11 @@ } }, "overrides": { - "brace-expansion": "5.0.6", + "brace-expansion": "5.0.9", "esbuild": "0.28.1", + "js-yaml": "4.3.1", + "nanoid": "3.3.18", + "postcss": "8.5.18", "vite": "8.0.16", "ws": "8.21.0" } From b525854aaf493cf01e386226899ebcc65322be91 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 10:32:57 +0000 Subject: [PATCH 02/20] feat: add browserless deterministic SVG export to record export - src/export/svg.ts: pure grid-frame -> SVG renderer (style runs, XML escaping, fixed 8.4x18 cell metrics, SMIL discrete animation) - src/replay/gridFrames.ts: offline libghostty-vt replay capturing de-duplicated styled grid frames with recorded hold durations - record export --format svg (+ --animate, --timing rejected for svg) - schema/messages format enum, recording filename .svg extension, review-bundle .svg classification, USAGE.md docs --- docs/USAGE.md | 5 + src/cli/commands/record-export.ts | 104 +++++++++- src/cli/main.ts | 12 +- src/export/svg.ts | 332 ++++++++++++++++++++++++++++++ src/protocol/schemas.ts | 2 +- src/replay/gridFrames.ts | 289 ++++++++++++++++++++++++++ src/storage/artifactPaths.ts | 4 +- src/tools/review-bundle.ts | 2 +- 8 files changed, 741 insertions(+), 9 deletions(-) create mode 100644 src/export/svg.ts create mode 100644 src/replay/gridFrames.ts diff --git a/docs/USAGE.md b/docs/USAGE.md index 429fb14..cd54a0b 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -60,6 +60,7 @@ agent-tty --home snapshot --format text --json agent-tty --home screenshot --json agent-tty --home record export --format asciicast --json agent-tty --home record export --format webm --json +agent-tty --home record export --format svg --json ``` ## `run` @@ -198,10 +199,14 @@ agent-tty screenshot --profile reference-dark --json agent-tty screenshot --show-cursor --json agent-tty record export --format asciicast --out ./session.cast --json agent-tty record export --format webm --timing accelerated --out ./session.webm --json +agent-tty record export --format svg --json +agent-tty record export --format svg --animate --out ./session.svg --json ``` WebM export replays with recorded wall-clock timing by default. Pass `--timing accelerated` (idle gaps clamped to 400ms) or `--timing max-speed` for a time-compressed video. +SVG export renders styled grid frames from the event log through the native `libghostty-vt` backend with no browser and no ffmpeg, so it requires the optional `@coder/libghostty-vt-node` package (there is no `ghostty-web` fallback). The output is deterministic: exporting the same session twice produces byte-identical, diffable SVG. `--format svg` writes a still image of the final screen; add `--animate` for an animated SVG of de-duplicated frames replayed with recorded event-log timing (`--timing` is not supported with SVG). + Use `--renderer ghostty-web`, `AGENT_TTY_RENDERER=ghostty-web`, or Home `config.json` `{ "defaultRenderer": "ghostty-web" }` to force legacy all-browser rendering. Use `--renderer libghostty-vt` only when you intentionally want semantic and screenshot requests routed through the native backend; WebM requests still record `ghostty-web` as the actual video producer. `ghostty-web` provides reference visual truth for reviewable artifacts; it does not promise exact pixel parity with native terminals. diff --git a/src/cli/commands/record-export.ts b/src/cli/commands/record-export.ts index afa0d19..dd6a231 100644 --- a/src/cli/commands/record-export.ts +++ b/src/cli/commands/record-export.ts @@ -10,10 +10,12 @@ import type { CommandContext } from '../context.js'; import { emitSuccess } from '../output.js'; import { generateAsciicast } from '../../export/asciicast.js'; +import { renderGridFramesToSvg } from '../../export/svg.js'; import { generateWebmExport, type WebmExportResult, } from '../../export/webm.js'; +import { captureGridFrames } from '../../replay/gridFrames.js'; import { readEventLogRecords } from '../../storage/eventLogCodec.js'; import { hashProfile, resolveProfile } from '../../renderer/profiles.js'; import { CliError } from '../errors.js'; @@ -48,7 +50,7 @@ import { import { invariant } from '../../util/assert.js'; import { loadPackageMetadata } from '../../util/packageMetadata.js'; -const RecordExportFormatSchema = z.enum(['asciicast', 'webm']); +const RecordExportFormatSchema = z.enum(['asciicast', 'webm', 'svg']); type RecordExportFormat = z.infer; @@ -62,6 +64,7 @@ interface CommandOptions { out?: string; profile?: string; timing?: string; + animate?: boolean; } function resolveRecordExportFormat( @@ -71,7 +74,7 @@ function resolveRecordExportFormat( if (!formatResult.success) { throw makeCliError(ERROR_CODES.INVALID_INPUT, { - message: 'Record export format must be one of: asciicast, webm.', + message: 'Record export format must be one of: asciicast, webm, svg.', details: { format, }, @@ -105,7 +108,7 @@ function resolveReplayTimingMode( return timingResult.data; } -function resolveWebmProfileName( +function resolveRenderProfileName( commandProfile: string | undefined, contextProfileDefault: string | undefined, ): string | undefined { @@ -201,6 +204,29 @@ export async function runRecordExportCommand( options: CommandOptions, ): Promise { const format = resolveRecordExportFormat(options.format); + + if (options.animate === true && format !== 'svg') { + throw makeCliError(ERROR_CODES.INVALID_INPUT, { + message: '--animate is only supported with --format svg.', + details: { + format, + }, + }); + } + + // Animated SVG always replays with recorded event-log timing; there is no + // timing mode to choose. + if (format === 'svg' && options.timing !== undefined) { + throw makeCliError(ERROR_CODES.INVALID_INPUT, { + message: + '--timing is not supported with --format svg; animated SVG always uses recorded timing.', + details: { + format, + timing: options.timing, + }, + }); + } + const timingMode = resolveReplayTimingMode(options.timing); const home = options.context.home; let sessionDirectory: string; @@ -305,8 +331,78 @@ export async function runRecordExportCommand( bytes = contentsBuffer.byteLength; invariant(bytes > 0, 'asciicast export artifact must not be empty'); sha256 = createHash('sha256').update(contentsBuffer).digest('hex'); + } else if (format === 'svg') { + const svgProfileName = + resolveRenderProfileName( + options.profile, + options.context.profileDefault, + ) ?? 'reference-dark'; + const resolvedProfile = resolveProfile(svgProfileName); + const renderProfileHash = hashProfile(resolvedProfile); + const animate = options.animate === true; + + invariant(events.length > 0, 'svg export requires at least one event'); + const capture = await captureGridFrames({ + sessionId: options.sessionId, + manifest, + events, + profile: resolvedProfile, + mode: animate ? 'timeline' : 'final', + }); + const svgContents = renderGridFramesToSvg({ + profile: resolvedProfile, + frames: capture.frames, + animate, + }); + const contentsBuffer = Buffer.from(svgContents, 'utf8'); + + capturedAtSeq = capture.capturedAtSeq; + durationMs = animate ? capture.timelineDurationMs : 0; + artifactKind = 'recording'; + artifactMetadata = { + format, + outputPath: artifactOutputPath, + width: capture.cols, + height: capture.rows, + profileName: svgProfileName, + renderProfileHash, + rendererBackend: capture.rendererBackend, + animated: animate, + frameCount: capture.frames.length, + outputEventCount: capture.outputEventCount, + resizeEventCount: capture.resizeEventCount, + }; + resultMetadata = { + width: capture.cols, + height: capture.rows, + profileName: svgProfileName, + renderProfileHash, + rendererBackend: capture.rendererBackend, + animated: animate, + frameCount: capture.frames.length, + outputEventCount: capture.outputEventCount, + resizeEventCount: capture.resizeEventCount, + }; + + if (options.out === undefined) { + invariant( + capturedAtSeq === defaultCapturedAtSeq, + 'default svg artifact path seq must match exported seq', + ); + } + + await writeTextFileAtomic({ + path: artifactOutputPath, + pathLabel: 'record export path', + contents: svgContents, + writeErrorMessage: `Failed to write record export artifact at ${artifactOutputPath}.`, + }); + + bytes = contentsBuffer.byteLength; + invariant(bytes > 0, 'svg export artifact must not be empty'); + sha256 = createHash('sha256').update(contentsBuffer).digest('hex'); } else { - const webmProfileName = resolveWebmProfileName( + const webmProfileName = resolveRenderProfileName( options.profile, options.context.profileDefault, ); diff --git a/src/cli/main.ts b/src/cli/main.ts index 665dc82..af2bf18 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -803,13 +803,21 @@ async function main(): Promise { recordCommand .command('export ') .description('Export a recorded session artifact') - .requiredOption('--format ', "Export format: 'asciicast' or 'webm'") + .requiredOption( + '--format ', + "Export format: 'asciicast', 'webm', or 'svg'", + ) .option('--out ', 'Explicit output path') .option('--profile ', 'Render profile name') .option( '--timing ', 'Replay timing mode for WebM: recorded (default), accelerated, max-speed', ) + .option( + '--animate', + 'Animate the SVG export with recorded timing (only with --format svg)', + false, + ) .option('--json', 'Emit a JSON command envelope', false) .action( wrapAction( @@ -821,6 +829,7 @@ async function main(): Promise { out?: string; profile?: string; timing?: string; + animate: boolean; json: boolean; }, context: CommandContext, @@ -830,6 +839,7 @@ async function main(): Promise { json: options.json, sessionId, format: options.format, + animate: options.animate, ...(options.out !== undefined ? { out: options.out } : {}), ...(options.profile !== undefined ? { profile: options.profile } diff --git a/src/export/svg.ts b/src/export/svg.ts new file mode 100644 index 0000000..a8eb430 --- /dev/null +++ b/src/export/svg.ts @@ -0,0 +1,332 @@ +import type { SnapshotCell } from '../protocol/schemas.js'; +import type { RenderProfileConfig } from '../renderer/types.js'; +import { invariant } from '../util/assert.js'; + +/** + * Fixed reference cell metrics for SVG export. The builtin render profiles pin + * fontSize 14; the cell box is 8.4x18 px (a 0.6 advance-width ratio and a + * ~1.29 line height, matching common monospace metrics). Text runs are pinned + * to the grid with `textLength`, so viewers with different fonts still align + * glyphs to these columns. Constants (not measured fonts) keep the output + * deterministic. + */ +export const SVG_FONT_SIZE = 14; +export const SVG_CELL_WIDTH = 8.4; +export const SVG_CELL_HEIGHT = 18; +/** Text baseline offset from the top of a cell row. */ +export const SVG_BASELINE_OFFSET = 14; +/** Block cursor is drawn as a translucent overlay so the glyph stays legible. */ +export const SVG_CURSOR_FILL_OPACITY = 0.35; + +export interface SvgGridFrame { + cols: number; + rows: number; + cursorRow: number; + cursorCol: number; + /** + * Dense visible grid: `lines[row]` lists cells by column. Rows and trailing + * columns without content may be shorter than `rows`/`cols`. + */ + lines: ReadonlyArray>; + /** How long this frame stays visible on the animated timeline. */ + holdMs: number; +} + +export interface SvgRenderOptions { + profile: RenderProfileConfig; + frames: readonly SvgGridFrame[]; + animate: boolean; +} + +interface StyleRun { + startCol: number; + cellCount: number; + text: string; + fg: string | undefined; + bg: string | undefined; + bold: boolean; + italic: boolean; + underline: boolean; +} + +const XML_ESCAPES: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}; + +// XML 1.0 forbids raw C0 control characters (except tab/newline/CR, which +// never appear inside a terminal grid cell) and DEL; strip them defensively. +function stripXmlDisallowed(value: string): string { + let stripped = ''; + for (const character of value) { + const codePoint = character.codePointAt(0); + invariant(codePoint !== undefined, 'iterated character must exist'); + if (codePoint < 0x20 || codePoint === 0x7f) { + continue; + } + stripped += character; + } + return stripped; +} + +function escapeXml(value: string): string { + return stripXmlDisallowed( + value.replace(/[&<>"']/gu, (match) => { + const escaped = XML_ESCAPES[match]; + invariant(escaped !== undefined, 'xml escape table must cover match'); + return escaped; + }), + ); +} + +function formatSvgNumber(value: number): string { + invariant(Number.isFinite(value), 'svg number must be finite'); + const fixed = value.toFixed(2); + const trimmed = fixed.replace(/0+$/u, '').replace(/\.$/u, ''); + return trimmed === '-0' ? '0' : trimmed; +} + +function formatKeyTime(offsetMs: number, totalMs: number): string { + invariant(totalMs > 0, 'animation total duration must be positive'); + invariant( + offsetMs >= 0 && offsetMs <= totalMs, + 'animation key time offset must lie within the total duration', + ); + const fixed = (offsetMs / totalMs).toFixed(6); + const trimmed = fixed.replace(/0+$/u, '').replace(/\.$/u, ''); + return trimmed.length > 0 ? trimmed : '0'; +} + +function styleMatches(run: StyleRun, cell: SnapshotCell): boolean { + return ( + run.fg === cell.fg && + run.bg === cell.bg && + run.bold === (cell.bold ?? false) && + run.italic === (cell.italic ?? false) && + run.underline === (cell.underline ?? false) + ); +} + +/** + * Group one row's cells into consecutive same-style runs. Wide-glyph spacer + * cells (`char: ''`) extend the covered cell count without adding text, so a + * run's `textLength` still spans every column the glyphs occupy. + */ +export function groupRowIntoStyleRuns( + cells: readonly SnapshotCell[], +): StyleRun[] { + const runs: StyleRun[] = []; + let current: StyleRun | null = null; + + for (const [col, cell] of cells.entries()) { + if (current !== null && styleMatches(current, cell)) { + current.cellCount += 1; + current.text += cell.char; + continue; + } + current = { + startCol: col, + cellCount: 1, + text: cell.char, + fg: cell.fg, + bg: cell.bg, + bold: cell.bold ?? false, + italic: cell.italic ?? false, + underline: cell.underline ?? false, + }; + runs.push(current); + } + + return runs; +} + +function renderRunBackground(run: StyleRun, row: number): string | null { + if (run.bg === undefined) { + return null; + } + const x = formatSvgNumber(run.startCol * SVG_CELL_WIDTH); + const y = formatSvgNumber(row * SVG_CELL_HEIGHT); + const width = formatSvgNumber(run.cellCount * SVG_CELL_WIDTH); + const height = formatSvgNumber(SVG_CELL_HEIGHT); + return ``; +} + +function renderRunText(run: StyleRun, row: number): string | null { + const hasVisibleGlyphs = run.text.trim().length > 0; + const hasUnderlinedSpan = run.underline && run.text.length > 0; + if (!hasVisibleGlyphs && !hasUnderlinedSpan) { + return null; + } + + const attributes = [ + `x="${formatSvgNumber(run.startCol * SVG_CELL_WIDTH)}"`, + `y="${formatSvgNumber(row * SVG_CELL_HEIGHT + SVG_BASELINE_OFFSET)}"`, + `textLength="${formatSvgNumber(run.cellCount * SVG_CELL_WIDTH)}"`, + 'lengthAdjust="spacingAndGlyphs"', + 'xml:space="preserve"', + ]; + if (run.fg !== undefined) { + attributes.push(`fill="${escapeXml(run.fg)}"`); + } + if (run.bold) { + attributes.push('font-weight="bold"'); + } + if (run.italic) { + attributes.push('font-style="italic"'); + } + if (run.underline) { + attributes.push('text-decoration="underline"'); + } + + return `${escapeXml(run.text)}`; +} + +function renderCursor( + frame: SvgGridFrame, + profile: RenderProfileConfig, +): string { + invariant( + frame.cursorRow >= 0 && frame.cursorRow < frame.rows, + 'frame cursorRow must be within rows', + ); + invariant( + frame.cursorCol >= 0 && frame.cursorCol < frame.cols, + 'frame cursorCol must be within cols', + ); + const x = formatSvgNumber(frame.cursorCol * SVG_CELL_WIDTH); + const y = formatSvgNumber(frame.cursorRow * SVG_CELL_HEIGHT); + const width = formatSvgNumber(SVG_CELL_WIDTH); + const height = formatSvgNumber(SVG_CELL_HEIGHT); + const opacity = formatSvgNumber(SVG_CURSOR_FILL_OPACITY); + return ``; +} + +function renderFrameElements( + frame: SvgGridFrame, + profile: RenderProfileConfig, +): string[] { + invariant(frame.cols > 0, 'frame cols must be positive'); + invariant(frame.rows > 0, 'frame rows must be positive'); + invariant( + frame.lines.length <= frame.rows, + 'frame lines must fit within frame rows', + ); + + const backgrounds: string[] = []; + const texts: string[] = []; + for (const [row, cells] of frame.lines.entries()) { + invariant( + cells.length <= frame.cols, + 'frame row cells must fit within frame cols', + ); + for (const run of groupRowIntoStyleRuns(cells)) { + const background = renderRunBackground(run, row); + if (background !== null) { + backgrounds.push(background); + } + const text = renderRunText(run, row); + if (text !== null) { + texts.push(text); + } + } + } + + return [...backgrounds, ...texts, renderCursor(frame, profile)]; +} + +function renderVisibilityAnimation( + frameIndex: number, + frameCount: number, + startMs: number, + endMs: number, + totalMs: number, +): string { + invariant( + frameIndex >= 0 && frameIndex < frameCount, + 'frame index must be within the frame count', + ); + invariant(startMs < endMs, 'frame window must be non-empty'); + + let values: string; + let keyTimes: string; + if (frameIndex === 0) { + values = 'visible;hidden'; + keyTimes = `0;${formatKeyTime(endMs, totalMs)}`; + } else if (frameIndex === frameCount - 1) { + values = 'hidden;visible'; + keyTimes = `0;${formatKeyTime(startMs, totalMs)}`; + } else { + values = 'hidden;visible;hidden'; + keyTimes = `0;${formatKeyTime(startMs, totalMs)};${formatKeyTime(endMs, totalMs)}`; + } + + return ``; +} + +/** + * Render de-duplicated grid frames as deterministic SVG text. Still exports + * (`animate: false`) render only the final frame; animated exports emit one + * `` per frame toggled by SMIL discrete visibility animations whose key + * times derive purely from the frames' recorded `holdMs` values. The output + * contains no wall-clock timestamps, random identifiers, or other + * environment-dependent content, so repeated exports of the same session are + * byte-identical. + */ +export function renderGridFramesToSvg(options: SvgRenderOptions): string { + const { profile, frames, animate } = options; + invariant(frames.length > 0, 'svg rendering requires at least one frame'); + invariant( + profile.fontSize === SVG_FONT_SIZE, + 'svg cell metrics are pinned to the reference profile font size', + ); + for (const frame of frames) { + invariant( + Number.isInteger(frame.holdMs) && frame.holdMs >= 0, + 'frame holdMs must be a non-negative integer', + ); + } + + const canvasCols = Math.max(...frames.map((frame) => frame.cols)); + const canvasRows = Math.max(...frames.map((frame) => frame.rows)); + const width = formatSvgNumber(canvasCols * SVG_CELL_WIDTH); + const height = formatSvgNumber(canvasRows * SVG_CELL_HEIGHT); + + const lines: string[] = [ + ``, + ``, + ]; + + if (!animate || frames.length === 1) { + const finalFrame = frames.at(-1); + invariant(finalFrame !== undefined, 'final frame must exist'); + lines.push(...renderFrameElements(finalFrame, profile)); + } else { + const totalMs = frames.reduce((sum, frame) => sum + frame.holdMs, 0); + invariant(totalMs > 0, 'animated svg requires a positive total duration'); + + let offsetMs = 0; + for (const [frameIndex, frame] of frames.entries()) { + invariant(frame.holdMs > 0, 'animated svg frames must hold for >0 ms'); + const endMs = offsetMs + frame.holdMs; + lines.push( + '', + renderVisibilityAnimation( + frameIndex, + frames.length, + offsetMs, + endMs, + totalMs, + ), + ...renderFrameElements(frame, profile), + '', + ); + offsetMs = endMs; + } + } + + lines.push(''); + return `${lines.join('\n')}\n`; +} diff --git a/src/protocol/schemas.ts b/src/protocol/schemas.ts index 70dca88..26fea15 100644 --- a/src/protocol/schemas.ts +++ b/src/protocol/schemas.ts @@ -463,7 +463,7 @@ export const WaitForRenderResultSchema = z export const RecordExportResultSchema = z .object({ sessionId: NonEmptyStringSchema, - format: z.enum(['asciicast', 'webm']), + format: z.enum(['asciicast', 'webm', 'svg']), artifactPath: NonEmptyStringSchema, bytes: PositiveIntSchema, sha256: NonEmptyStringSchema, diff --git a/src/replay/gridFrames.ts b/src/replay/gridFrames.ts new file mode 100644 index 0000000..4c4a3c1 --- /dev/null +++ b/src/replay/gridFrames.ts @@ -0,0 +1,289 @@ +import { buildReplayInput } from '../host/replay.js'; +import type { + EventRecord, + SessionRecord, + SnapshotCell, +} from '../protocol/schemas.js'; +import { ERROR_CODES, makeCliError } from '../protocol/errors.js'; +import type { RendererBackend } from '../renderer/backend.js'; +import { createRendererBackend } from '../renderer/registry.js'; +import type { + RenderProfileConfig, + ReplayInput, + SemanticSnapshot, +} from '../renderer/types.js'; +import { invariant } from '../util/assert.js'; + +/** Matches the recorded-timing WebM export's final frame hold. */ +const DEFAULT_FINAL_FRAME_HOLD_MS = 1_000; + +export type GridFrameMode = 'final' | 'timeline'; + +export interface GridFrame { + capturedAtSeq: number; + cols: number; + rows: number; + cursorRow: number; + cursorCol: number; + /** Dense visible grid: `lines[row]` lists styled cells by column. */ + lines: SnapshotCell[][]; + holdMs: number; +} + +export interface CaptureGridFramesOptions { + sessionId: string; + manifest: SessionRecord; + events: readonly EventRecord[]; + profile: RenderProfileConfig; + /** `final` captures only the last frame; `timeline` captures every boundary. */ + mode: GridFrameMode; + finalFrameHoldMs?: number; +} + +export interface GridFrameCapture { + frames: GridFrame[]; + capturedAtSeq: number; + cols: number; + rows: number; + rendererBackend: string; + outputEventCount: number; + resizeEventCount: number; + /** Sum of frame holds; the recorded visual timeline plus the final hold. */ + timelineDurationMs: number; +} + +export interface GridFrameDeps { + backendFactory?: ( + sessionId: string, + profile: RenderProfileConfig, + ) => RendererBackend | Promise; +} + +interface FrameBoundary { + seq: number; + tsMs: number; +} + +function parseTimestamp(value: string, label: string): number { + const timestamp = Date.parse(value); + invariant( + Number.isFinite(timestamp), + `${label} must be a valid ISO timestamp`, + ); + return timestamp; +} + +/** + * Collect the seq boundaries where the visible grid may have changed: only + * `output` and `resize` events mutate the screen, and events sharing one + * timestamp are coalesced into the last seq of that instant so the frame count + * stays bounded by the number of distinct recorded instants. + */ +function collectFrameBoundaries(input: ReplayInput): { + boundaries: FrameBoundary[]; + outputEventCount: number; + resizeEventCount: number; +} { + const boundaries: FrameBoundary[] = []; + let outputEventCount = 0; + let resizeEventCount = 0; + + for (const event of input.events) { + if (event.seq > input.targetSeq) { + break; + } + if (event.type !== 'output' && event.type !== 'resize') { + continue; + } + if (event.type === 'output') { + outputEventCount += 1; + } else { + resizeEventCount += 1; + } + + const tsMs = parseTimestamp( + event.ts, + `event ${String(event.seq)} timestamp`, + ); + const lastBoundary = boundaries.at(-1); + if (lastBoundary !== undefined) { + invariant( + tsMs >= lastBoundary.tsMs, + 'event timestamps must be non-decreasing', + ); + if (tsMs === lastBoundary.tsMs) { + lastBoundary.seq = event.seq; + continue; + } + } + boundaries.push({ seq: event.seq, tsMs }); + } + + return { boundaries, outputEventCount, resizeEventCount }; +} + +function toGridFrame(snapshot: SemanticSnapshot, holdMs: number): GridFrame { + invariant( + snapshot.cells !== undefined, + 'grid frame snapshot must include styled cells', + ); + + const lines: SnapshotCell[][] = Array.from( + { length: snapshot.rows }, + () => [], + ); + for (const line of snapshot.cells) { + invariant( + line.lineNumber < snapshot.rows, + 'snapshot cell line must be within rows', + ); + lines[line.lineNumber] = line.cells; + } + + return { + capturedAtSeq: snapshot.capturedAtSeq, + cols: snapshot.cols, + rows: snapshot.rows, + cursorRow: snapshot.cursorRow, + cursorCol: snapshot.cursorCol, + lines, + holdMs, + }; +} + +/** Canonical frame content key (ignores seq and hold) used for de-duplication. */ +function frameContentKey(frame: GridFrame): string { + return JSON.stringify([ + frame.cols, + frame.rows, + frame.cursorRow, + frame.cursorCol, + frame.lines.map((cells) => + cells.map((cell) => [ + cell.char, + cell.fg ?? null, + cell.bg ?? null, + cell.bold ?? false, + cell.italic ?? false, + cell.underline ?? false, + ]), + ), + ]); +} + +async function createLibghosttyVtBackend( + sessionId: string, + profile: RenderProfileConfig, + deps?: GridFrameDeps, +): Promise { + const backendFactory = + deps?.backendFactory ?? + ((factorySessionId: string, factoryProfile: RenderProfileConfig) => + createRendererBackend('libghostty-vt', factorySessionId, factoryProfile)); + return await backendFactory(sessionId, profile); +} + +/** + * Replay a session's event log offline through the libghostty-vt backend and + * capture de-duplicated styled grid frames with recorded hold durations. This + * intentionally never falls back to the browser renderer: when the optional + * native package is unavailable the export fails with a clear error instead. + */ +export async function captureGridFrames( + options: CaptureGridFramesOptions, + deps?: GridFrameDeps, +): Promise { + invariant(options.sessionId.length > 0, 'sessionId is required'); + invariant(options.events.length > 0, 'grid capture requires >=1 event'); + const finalFrameHoldMs = + options.finalFrameHoldMs ?? DEFAULT_FINAL_FRAME_HOLD_MS; + invariant( + Number.isInteger(finalFrameHoldMs) && finalFrameHoldMs > 0, + 'finalFrameHoldMs must be a positive integer', + ); + + const replayInput = buildReplayInput( + options.sessionId, + options.manifest, + options.events, + ); + invariant(replayInput.targetSeq >= 0, 'grid capture requires >=1 event'); + + const { boundaries, outputEventCount, resizeEventCount } = + collectFrameBoundaries(replayInput); + // Sessions without visual events still have a (blank) grid; capture it at + // the final target seq. + const captureBoundaries: FrameBoundary[] = + options.mode === 'timeline' && boundaries.length > 0 + ? boundaries + : [ + { + seq: replayInput.targetSeq, + tsMs: boundaries.at(-1)?.tsMs ?? 0, + }, + ]; + + const backend = await createLibghosttyVtBackend( + options.sessionId, + options.profile, + deps, + ); + + try { + try { + await backend.boot(); + } catch (error) { + throw makeCliError(ERROR_CODES.EXPORT_ERROR, { + message: + 'SVG export requires the libghostty-vt renderer. Install the optional @coder/libghostty-vt-node package and retry.', + details: { rendererBackend: 'libghostty-vt' }, + cause: error, + }); + } + + const frames: GridFrame[] = []; + let previousContentKey: string | null = null; + for (const [index, boundary] of captureBoundaries.entries()) { + const nextBoundary = captureBoundaries[index + 1]; + const holdMs = + nextBoundary === undefined + ? finalFrameHoldMs + : nextBoundary.tsMs - boundary.tsMs; + invariant(holdMs > 0, 'frame hold duration must be positive'); + + await backend.replayTo({ ...replayInput, targetSeq: boundary.seq }); + const snapshot = await backend.snapshot({ includeCells: true }); + const frame = toGridFrame(snapshot, holdMs); + const contentKey = frameContentKey(frame); + if (contentKey === previousContentKey) { + const previousFrame = frames.at(-1); + invariant(previousFrame !== undefined, 'previous frame must exist'); + previousFrame.holdMs += holdMs; + continue; + } + frames.push(frame); + previousContentKey = contentKey; + } + + // Apply any trailing non-visual events so capturedAtSeq covers the log. + if (replayInput.targetSeq > (captureBoundaries.at(-1)?.seq ?? -1)) { + await backend.replayTo(replayInput); + } + + const finalFrame = frames.at(-1); + invariant(finalFrame !== undefined, 'grid capture must produce >=1 frame'); + + return { + frames, + capturedAtSeq: replayInput.targetSeq, + cols: finalFrame.cols, + rows: finalFrame.rows, + rendererBackend: backend.rendererBackend, + outputEventCount, + resizeEventCount, + timelineDurationMs: frames.reduce((sum, frame) => sum + frame.holdMs, 0), + }; + } finally { + await backend.dispose(); + } +} diff --git a/src/storage/artifactPaths.ts b/src/storage/artifactPaths.ts index ceeb80a..3a1112b 100644 --- a/src/storage/artifactPaths.ts +++ b/src/storage/artifactPaths.ts @@ -79,10 +79,10 @@ export function snapshotFilename( function recordingExtension(format: string): string { invariant( - format === 'asciicast' || format === 'webm', + format === 'asciicast' || format === 'webm' || format === 'svg', `unsupported recording format: ${format}`, ); - return format === 'asciicast' ? 'cast' : 'webm'; + return format === 'asciicast' ? 'cast' : format; } export function recordingFilename(seq: number, format: string): string { diff --git a/src/tools/review-bundle.ts b/src/tools/review-bundle.ts index 8c42be9..49b902f 100644 --- a/src/tools/review-bundle.ts +++ b/src/tools/review-bundle.ts @@ -354,7 +354,7 @@ export function classifyBundlePath(relativePath: string): ArtifactKind { if (SCRIPT_FILE_NAMES.has(fileName)) { return 'script'; } - if (lowerFileName.endsWith('.png')) { + if (lowerFileName.endsWith('.png') || lowerFileName.endsWith('.svg')) { return 'screenshot'; } if (lowerFileName.endsWith('.webm')) { From 3e37952d6eba465a4c771e1e0934360e11e1937a Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 10:38:42 +0000 Subject: [PATCH 03/20] test: cover svg record export across unit, integration, and schema suites - unit tests for renderGridFramesToSvg determinism, style runs, XML escaping, cursor block, SMIL hold key times - unit tests for captureGridFrames coalescing, de-duplication, recorded holds, final mode, and native-unavailable export error - record export command tests for svg envelope, --animate/--timing validation - native-gated integration tests incl. byte-identical repeated exports - protocol/golden/artifact-path/review-bundle enum extensions --- test/integration/record-export.test.ts | 196 +++++++++++++++- test/unit/commands/golden-envelopes.test.ts | 41 ++++ test/unit/commands/record-export.test.ts | 228 ++++++++++++++++++ test/unit/export/svg.test.ts | 220 ++++++++++++++++++ test/unit/protocol/messages.test.ts | 15 ++ test/unit/replay/gridFrames.test.ts | 245 ++++++++++++++++++++ test/unit/storage/artifactStorage.test.ts | 4 + test/unit/tools/review-bundle.test.ts | 1 + 8 files changed, 949 insertions(+), 1 deletion(-) create mode 100644 test/unit/export/svg.test.ts create mode 100644 test/unit/replay/gridFrames.test.ts diff --git a/test/integration/record-export.test.ts b/test/integration/record-export.test.ts index b8b554b..f5ffc43 100644 --- a/test/integration/record-export.test.ts +++ b/test/integration/record-export.test.ts @@ -14,6 +14,17 @@ import { type WaitResult, } from '../helpers.js'; +// SVG export requires the optional native libghostty-vt package; skip the +// svg happy-path cases cleanly when it is unavailable. +let nativeAvailable = false; +try { + await import('@coder/libghostty-vt-node'); + nativeAvailable = true; +} catch { + nativeAvailable = false; +} +const maybeIt = nativeAvailable ? it : it.skip; + interface ErrorEnvelope { ok: false; command: string; @@ -27,7 +38,7 @@ interface ErrorEnvelope { interface RecordExportResult { sessionId: string; - format: 'asciicast' | 'webm'; + format: 'asciicast' | 'webm' | 'svg'; artifactPath: string; bytes: number; sha256: string; @@ -361,6 +372,140 @@ describe('record export integration', { timeout: 120_000 }, () => { ); }); + maybeIt( + 'exports deterministic svg artifacts with expected visible text', + async () => { + const sessionId = createSession(testHome, [ + '/bin/sh', + '-c', + "printf 'ready svg\\n'; exec cat", + ]); + + waitForIdle(testHome, sessionId); + + const exportResult = runCli( + ['record', 'export', sessionId, '--format', 'svg', '--json'], + { AGENT_TTY_HOME: testHome }, + 30_000, + ); + expect(exportResult.status).toBe(0); + expect(exportResult.stderr).toBe(''); + + const envelope = JSON.parse( + exportResult.stdout, + ) as SuccessEnvelope; + expect(envelope.ok).toBe(true); + expect(envelope.command).toBe('record export'); + expect(envelope.result.sessionId).toBe(sessionId); + expect(envelope.result.format).toBe('svg'); + expect(envelope.result.artifactPath.endsWith('.svg')).toBe(true); + expect(envelope.result.capturedAtSeq).toBeGreaterThanOrEqual(0); + expect(envelope.result.durationMs).toBe(0); + expect(envelope.result.metadata).toMatchObject({ + profileName: 'reference-dark', + rendererBackend: 'libghostty-vt', + animated: false, + frameCount: 1, + }); + + const contents = await readFile(envelope.result.artifactPath, 'utf8'); + expect( + contents.startsWith('; + }>(join(testHome, 'sessions', sessionId, 'artifacts', 'manifest.json')); + const svgEntries = artifactManifest.artifacts.filter((entry) => + entry.filename.endsWith('.svg'), + ); + expect(svgEntries.length).toBeGreaterThanOrEqual(1); + expect(svgEntries[0]).toEqual( + expect.objectContaining({ + kind: 'recording', + filename: basename(envelope.result.artifactPath), + sha256: envelope.result.sha256, + bytes: envelope.result.bytes, + capturedAtSeq: envelope.result.capturedAtSeq, + }), + ); + + destroySession(testHome, sessionId); + }, + ); + + maybeIt('exports animated svg with recorded timing', async () => { + const sessionId = createSession(testHome, [ + '/bin/sh', + '-c', + "printf 'first\\n'; sleep 0.3; printf 'second\\n'; exit 0", + ]); + + waitForExit(testHome, sessionId); + + const exportResult = runCli( + ['record', 'export', sessionId, '--format', 'svg', '--animate', '--json'], + { AGENT_TTY_HOME: testHome }, + 30_000, + ); + expect(exportResult.status).toBe(0); + expect(exportResult.stderr).toBe(''); + + const envelope = JSON.parse( + exportResult.stdout, + ) as SuccessEnvelope; + expect(envelope.ok).toBe(true); + expect(envelope.result.format).toBe('svg'); + expect(envelope.result.metadata.animated).toBe(true); + expect( + envelope.result.metadata.frameCount as number, + ).toBeGreaterThanOrEqual(2); + expect(envelope.result.durationMs ?? 0).toBeGreaterThan(0); + + const contents = await readFile(envelope.result.artifactPath, 'utf8'); + expect(contents).toContain(' { const result = runCli( ['record', 'export', 'session-01', '--format', 'bogus', '--json'], @@ -375,4 +520,53 @@ describe('record export integration', { timeout: 120_000 }, () => { expect(envelope.command).toBe('record export'); expect(envelope.error.code).toBe('INVALID_INPUT'); }); + + it('rejects --animate with non-svg formats', () => { + const result = runCli( + [ + 'record', + 'export', + 'session-01', + '--format', + 'asciicast', + '--animate', + '--json', + ], + { AGENT_TTY_HOME: testHome }, + 15_000, + ); + + expect(result.status).not.toBe(0); + expect(result.stderr).toBe(''); + const envelope = JSON.parse(result.stdout) as ErrorEnvelope; + expect(envelope.ok).toBe(false); + expect(envelope.command).toBe('record export'); + expect(envelope.error.code).toBe('INVALID_INPUT'); + expect(envelope.error.message).toContain('--animate'); + }); + + it('rejects --timing with svg exports', () => { + const result = runCli( + [ + 'record', + 'export', + 'session-01', + '--format', + 'svg', + '--timing', + 'recorded', + '--json', + ], + { AGENT_TTY_HOME: testHome }, + 15_000, + ); + + expect(result.status).not.toBe(0); + expect(result.stderr).toBe(''); + const envelope = JSON.parse(result.stdout) as ErrorEnvelope; + expect(envelope.ok).toBe(false); + expect(envelope.command).toBe('record export'); + expect(envelope.error.code).toBe('INVALID_INPUT'); + expect(envelope.error.message).toContain('--timing'); + }); }); diff --git a/test/unit/commands/golden-envelopes.test.ts b/test/unit/commands/golden-envelopes.test.ts index b0dbf6f..b99ae4e 100644 --- a/test/unit/commands/golden-envelopes.test.ts +++ b/test/unit/commands/golden-envelopes.test.ts @@ -1162,6 +1162,47 @@ const goldenResultContracts: readonly GoldenResultContractCase[] = [ profile: 'default', }, }, + { + name: 'record export (svg)', + command: 'record export', + schema: RecordExportResultSchema, + validResult: { + sessionId: '01J0000000TEST000000000000', + format: 'svg', + artifactPath: '/tmp/test.svg', + bytes: 4096, + sha256: 'fed789', + capturedAtSeq: 42, + durationMs: 1500, + metadata: { + width: 80, + height: 24, + profileName: 'reference-dark', + rendererBackend: 'libghostty-vt', + animated: true, + frameCount: 3, + }, + }, + invalidResult: { + sessionId: '01J0000000TEST000000000000', + format: 'svg', + artifactPath: '/tmp/test.svg', + bytes: 0, + sha256: 'fed789', + capturedAtSeq: 42, + metadata: {}, + }, + extraFieldResult: { + sessionId: '01J0000000TEST000000000000', + format: 'svg', + artifactPath: '/tmp/test.svg', + bytes: 4096, + sha256: 'fed789', + capturedAtSeq: 42, + metadata: {}, + animate: true, + }, + }, { name: 'destroy', command: 'destroy', diff --git a/test/unit/commands/record-export.test.ts b/test/unit/commands/record-export.test.ts index f80304d..d6965c0 100644 --- a/test/unit/commands/record-export.test.ts +++ b/test/unit/commands/record-export.test.ts @@ -35,6 +35,8 @@ const mocks = vi.hoisted(() => ({ artifactPath: vi.fn(), recordingFilename: vi.fn(), generateWebmExport: vi.fn(), + captureGridFrames: vi.fn(), + renderGridFramesToSvg: vi.fn(), loadPackageMetadata: vi.fn(), readFile: vi.fn(), stat: vi.fn(), @@ -82,6 +84,14 @@ vi.mock('../../../src/export/webm.js', () => ({ generateWebmExport: mocks.generateWebmExport, })); +vi.mock('../../../src/replay/gridFrames.js', () => ({ + captureGridFrames: mocks.captureGridFrames, +})); + +vi.mock('../../../src/export/svg.js', () => ({ + renderGridFramesToSvg: mocks.renderGridFramesToSvg, +})); + vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal(); return { @@ -481,6 +491,224 @@ describe('record export command', () => { expect(emitSuccessArgs.result.durationMs).toBe(1_500); }); + it('exports still svg artifacts via captureGridFrames', async () => { + mocks.recordingFilename.mockReturnValue('recording-1-svg.svg'); + + const svgContents = 'still\n'; + const svgSha256 = createHash('sha256') + .update(Buffer.from(svgContents, 'utf8')) + .digest('hex'); + const expectedRenderProfileHash = hashProfile( + resolveProfile('reference-dark'), + ); + const gridFrame = { + capturedAtSeq: 1, + cols: 80, + rows: 24, + cursorRow: 0, + cursorCol: 0, + lines: [[{ char: 'h' }]], + holdMs: 1_000, + }; + + mocks.captureGridFrames.mockResolvedValue({ + frames: [gridFrame], + capturedAtSeq: 1, + cols: 80, + rows: 24, + rendererBackend: 'libghostty-vt', + outputEventCount: 1, + resizeEventCount: 1, + timelineDurationMs: 2_500, + }); + mocks.renderGridFramesToSvg.mockReturnValue(svgContents); + + await runRecordExportCommand({ + context: TEST_CONTEXT, + json: true, + sessionId: 'session-01', + format: 'svg', + }); + + expect(mocks.captureGridFrames).toHaveBeenCalledTimes(1); + const captureCall = mocks.captureGridFrames.mock.calls[0] as [ + { + sessionId: string; + manifest: ReturnType; + events: unknown[]; + profile: { name: string }; + mode: string; + }, + ]; + const [captureArgs] = captureCall; + + expect(captureArgs.sessionId).toBe('session-01'); + expect(captureArgs.manifest).toEqual(createSessionRecord()); + expect(captureArgs.events).toHaveLength(2); + expect(captureArgs.profile.name).toBe('reference-dark'); + expect(captureArgs.mode).toBe('final'); + + expect(mocks.renderGridFramesToSvg).toHaveBeenCalledWith({ + profile: expect.objectContaining({ name: 'reference-dark' }) as unknown, + frames: [gridFrame], + animate: false, + }); + expect(mocks.writeTextFileAtomic).toHaveBeenCalledWith({ + path: '/tmp/agent-tty/sessions/session-01/artifacts/recording-1-svg.svg', + pathLabel: 'record export path', + contents: svgContents, + writeErrorMessage: + 'Failed to write record export artifact at /tmp/agent-tty/sessions/session-01/artifacts/recording-1-svg.svg.', + }); + + expect(mocks.createArtifactEntry).toHaveBeenCalledWith({ + kind: 'recording', + filename: 'recording-1-svg.svg', + sessionId: 'session-01', + capturedAtSeq: 1, + sha256: svgSha256, + bytes: Buffer.byteLength(svgContents, 'utf8'), + metadata: { + format: 'svg', + outputPath: + '/tmp/agent-tty/sessions/session-01/artifacts/recording-1-svg.svg', + width: 80, + height: 24, + profileName: 'reference-dark', + renderProfileHash: expectedRenderProfileHash, + rendererBackend: 'libghostty-vt', + animated: false, + frameCount: 1, + outputEventCount: 1, + resizeEventCount: 1, + }, + }); + + expect(mocks.emitSuccess).toHaveBeenCalledTimes(1); + const emitSuccessCall = mocks.emitSuccess.mock.calls[0] as [ + { + result: { + format: string; + bytes: number; + sha256: string; + capturedAtSeq: number; + durationMs?: number; + metadata: Record; + }; + }, + ]; + const [emitSuccessArgs] = emitSuccessCall; + + expect(emitSuccessArgs.result.format).toBe('svg'); + expect(emitSuccessArgs.result.bytes).toBe( + Buffer.byteLength(svgContents, 'utf8'), + ); + expect(emitSuccessArgs.result.sha256).toBe(svgSha256); + expect(emitSuccessArgs.result.capturedAtSeq).toBe(1); + // Still exports represent an instant, not a playback timeline. + expect(emitSuccessArgs.result.durationMs).toBe(0); + expect(emitSuccessArgs.result.metadata.animated).toBe(false); + expect(mocks.generateWebmExport).not.toHaveBeenCalled(); + }); + + it('exports animated svg with the recorded timeline duration', async () => { + mocks.recordingFilename.mockReturnValue('recording-1-svg.svg'); + mocks.captureGridFrames.mockResolvedValue({ + frames: [ + { + capturedAtSeq: 0, + cols: 80, + rows: 24, + cursorRow: 0, + cursorCol: 0, + lines: [], + holdMs: 1_500, + }, + { + capturedAtSeq: 1, + cols: 80, + rows: 24, + cursorRow: 0, + cursorCol: 0, + lines: [], + holdMs: 1_000, + }, + ], + capturedAtSeq: 1, + cols: 80, + rows: 24, + rendererBackend: 'libghostty-vt', + outputEventCount: 1, + resizeEventCount: 1, + timelineDurationMs: 2_500, + }); + mocks.renderGridFramesToSvg.mockReturnValue('animated\n'); + + await runRecordExportCommand({ + context: TEST_CONTEXT, + json: true, + sessionId: 'session-01', + format: 'svg', + animate: true, + }); + + const captureCall = mocks.captureGridFrames.mock.calls[0] as [ + { mode: string }, + ]; + expect(captureCall[0].mode).toBe('timeline'); + expect(mocks.renderGridFramesToSvg).toHaveBeenCalledWith( + expect.objectContaining({ animate: true }), + ); + + const emitSuccessCall = mocks.emitSuccess.mock.calls[0] as [ + { + result: { + durationMs?: number; + metadata: Record; + }; + }, + ]; + const [emitSuccessArgs] = emitSuccessCall; + expect(emitSuccessArgs.result.durationMs).toBe(2_500); + expect(emitSuccessArgs.result.metadata.animated).toBe(true); + expect(emitSuccessArgs.result.metadata.frameCount).toBe(2); + }); + + it('rejects --animate for non-svg formats', async () => { + await expect( + runRecordExportCommand({ + context: TEST_CONTEXT, + json: true, + sessionId: 'session-01', + format: 'asciicast', + animate: true, + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.INVALID_INPUT, + message: '--animate is only supported with --format svg.', + }); + expect(mocks.captureGridFrames).not.toHaveBeenCalled(); + expect(mocks.emitSuccess).not.toHaveBeenCalled(); + }); + + it('rejects --timing for svg exports', async () => { + await expect( + runRecordExportCommand({ + context: TEST_CONTEXT, + json: true, + sessionId: 'session-01', + format: 'svg', + timing: 'recorded', + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.INVALID_INPUT, + message: + '--timing is not supported with --format svg; animated SVG always uses recorded timing.', + }); + expect(mocks.captureGridFrames).not.toHaveBeenCalled(); + expect(mocks.emitSuccess).not.toHaveBeenCalled(); + }); + it('requests rollback for default asciicast artifacts when manifest append fails', async () => { const sessionDirectory = await createTemporaryDirectory( 'agent-tty-record-export-append-asciicast-', diff --git a/test/unit/export/svg.test.ts b/test/unit/export/svg.test.ts new file mode 100644 index 0000000..a909851 --- /dev/null +++ b/test/unit/export/svg.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from 'vitest'; + +import type { SvgGridFrame } from '../../../src/export/svg.js'; +import type { SnapshotCell } from '../../../src/protocol/schemas.js'; + +import { renderGridFramesToSvg } from '../../../src/export/svg.js'; +import { resolveProfile } from '../../../src/renderer/profiles.js'; + +const PROFILE = resolveProfile('reference-dark'); + +function cell(char: string, style: Partial = {}): SnapshotCell { + return { char, ...style }; +} + +function makeFrame(overrides: Partial = {}): SvgGridFrame { + return { + cols: 10, + rows: 3, + cursorRow: 0, + cursorCol: 0, + lines: [], + holdMs: 100, + ...overrides, + }; +} + +function countOccurrences(haystack: string, needle: string): number { + return haystack.split(needle).length - 1; +} + +describe('renderGridFramesToSvg', () => { + it('produces byte-identical output for repeated renders of the same input', () => { + const frames = [ + makeFrame({ + lines: [[cell('h'), cell('i', { fg: '#ff0000', bold: true })]], + holdMs: 250, + }), + makeFrame({ + lines: [[cell('h'), cell('i'), cell('!')]], + holdMs: 750, + }), + ]; + + const first = renderGridFramesToSvg({ + profile: PROFILE, + frames, + animate: true, + }); + const second = renderGridFramesToSvg({ + profile: PROFILE, + frames, + animate: true, + }); + + expect(second).toBe(first); + expect(first).toContain('hi!'); + }); + + it('renders profile background and default foreground fill', () => { + const svg = renderGridFramesToSvg({ + profile: PROFILE, + frames: [makeFrame({ lines: [[cell('x')]] })], + animate: false, + }); + + expect(svg).toContain(''); + expect(svg).toContain('fill="#cdd6f4"'); + // 10 cols x 8.4 = 84, 3 rows x 18 = 54. + expect(svg).toContain('viewBox="0 0 84 54"'); + }); + + it('groups consecutive same-style cells into one positioned text run', () => { + const svg = renderGridFramesToSvg({ + profile: PROFILE, + frames: [ + makeFrame({ + lines: [ + [ + cell('e', { fg: '#ff0000', bold: true }), + cell('r', { fg: '#ff0000', bold: true }), + cell('r', { fg: '#ff0000', bold: true }), + cell(' '), + cell('o', { italic: true, underline: true }), + cell('k', { italic: true, underline: true }), + ], + ], + }), + ], + animate: false, + }); + + expect(countOccurrences(svg, 'err', + ); + // Run starts at col 4 (4 x 8.4 = 33.6) and spans 2 cells (16.8). + expect(svg).toContain( + 'ok', + ); + }); + + it('merges background runs into one rect and spans wide-glyph spacers', () => { + const svg = renderGridFramesToSvg({ + profile: PROFILE, + frames: [ + makeFrame({ + lines: [ + [ + cell('字', { bg: '#00ff00' }), + cell('', { bg: '#00ff00' }), + cell('!', { bg: '#00ff00' }), + ], + ], + }), + ], + animate: false, + }); + + // One run covering 3 cells: width 3 x 8.4 = 25.2. + expect(svg).toContain( + '', + ); + // The wide glyph's spacer contributes width but no text. + expect(svg).toContain('>字!'); + expect(svg).toContain('textLength="25.2"'); + }); + + it('escapes XML special characters and strips control characters', () => { + const svg = renderGridFramesToSvg({ + profile: PROFILE, + frames: [ + makeFrame({ + lines: [[cell('<'), cell('&'), cell('"'), cell("'"), cell('\u0007')]], + }), + ], + animate: false, + }); + + expect(svg).toContain('<&"''); + expect(svg).not.toContain('\u0007'); + }); + + it('draws the cursor as a translucent block at the cursor cell', () => { + const svg = renderGridFramesToSvg({ + profile: PROFILE, + frames: [makeFrame({ cursorRow: 1, cursorCol: 2 })], + animate: false, + }); + + expect(svg).toContain( + '', + ); + }); + + it('renders only the final frame for still exports', () => { + const svg = renderGridFramesToSvg({ + profile: PROFILE, + frames: [ + makeFrame({ + lines: [[cell('f'), cell('i'), cell('r'), cell('s'), cell('t')]], + }), + makeFrame({ lines: [[cell('l'), cell('a'), cell('s'), cell('t')]] }), + ], + animate: false, + }); + + expect(svg).toContain('last'); + expect(svg).not.toContain('first'); + expect(svg).not.toContain(' { + const svg = renderGridFramesToSvg({ + profile: PROFILE, + frames: [ + makeFrame({ lines: [[cell('a')]], holdMs: 100 }), + makeFrame({ lines: [[cell('b')]], holdMs: 200 }), + makeFrame({ lines: [[cell('c')]], holdMs: 700 }), + ], + animate: true, + }); + + expect(countOccurrences(svg, '')).toBe(3); + expect(svg).toContain( + '', + ); + expect(svg).toContain( + '', + ); + expect(svg).toContain( + '', + ); + }); + + it('renders a single animated frame as static content', () => { + const svg = renderGridFramesToSvg({ + profile: PROFILE, + frames: [makeFrame({ lines: [[cell('x')]] })], + animate: true, + }); + + expect(svg).not.toContain('x'); + }); + + it('contains no wall-clock timestamps or random identifiers', () => { + const svg = renderGridFramesToSvg({ + profile: PROFILE, + frames: [ + makeFrame({ lines: [[cell('a')]], holdMs: 100 }), + makeFrame({ lines: [[cell('b')]], holdMs: 900 }), + ], + animate: true, + }); + + expect(svg).not.toContain(String(new Date().getFullYear())); + expect(svg).not.toContain('id='); + }); +}); diff --git a/test/unit/protocol/messages.test.ts b/test/unit/protocol/messages.test.ts index f665eff..24b86aa 100644 --- a/test/unit/protocol/messages.test.ts +++ b/test/unit/protocol/messages.test.ts @@ -705,6 +705,21 @@ describe('RPC message schemas', () => { metadata: {}, }).success, ).toBe(true); + expect( + RecordExportResultSchema.safeParse({ + sessionId: 'session-01', + format: 'svg', + artifactPath: '/tmp/session-01/artifacts/recording-7-svg.svg', + bytes: 4096, + sha256: 'abc123', + capturedAtSeq: 7, + durationMs: 0, + metadata: { + animated: false, + frameCount: 1, + }, + }).success, + ).toBe(true); }); it('rejects invalid record export results', () => { diff --git a/test/unit/replay/gridFrames.test.ts b/test/unit/replay/gridFrames.test.ts new file mode 100644 index 0000000..a474d3b --- /dev/null +++ b/test/unit/replay/gridFrames.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it } from 'vitest'; + +import type { + RendererBackend, + ScreenshotOptions, + SnapshotOptions, +} from '../../../src/renderer/backend.js'; +import type { + ReplayInput, + ReplayState, + ScreenshotResult, + SemanticSnapshot, +} from '../../../src/renderer/types.js'; +import type { + EventRecord, + SessionRecord, +} from '../../../src/protocol/schemas.js'; + +import { captureGridFrames } from '../../../src/replay/gridFrames.js'; +import { resolveProfile } from '../../../src/renderer/profiles.js'; + +const SESSION_ID = 'session-01'; +const PROFILE = resolveProfile('reference-dark'); +const BASE_TS_MS = Date.parse('2026-03-19T12:00:02.000Z'); + +function isoAt(offsetMs: number): string { + return new Date(BASE_TS_MS + offsetMs).toISOString(); +} + +function createSessionRecord(): SessionRecord { + return { + version: 1, + sessionId: SESSION_ID, + createdAt: '2026-03-19T12:00:00.000Z', + updatedAt: '2026-03-19T12:00:01.000Z', + status: 'running', + command: ['/bin/sh'], + cwd: '/tmp/workspace', + cols: 80, + rows: 24, + hostPid: 123, + childPid: 456, + exitCode: null, + exitSignal: null, + }; +} + +/** + * Deterministic in-memory backend: the visible row-0 text is looked up from a + * seq -> text table keyed by the last applied target seq. + */ +class FakeGridBackend implements RendererBackend { + public readonly rendererBackend = 'fake-grid'; + public isBooted = false; + public replayTargetSeqs: number[] = []; + public snapshotCalls: Array = []; + public disposed = false; + + private lastSeq = -1; + + public constructor(private readonly gridBySeq: Map) {} + + public boot(): Promise { + this.isBooted = true; + return Promise.resolve(); + } + + public replayTo(input: ReplayInput): Promise { + this.replayTargetSeqs.push(input.targetSeq); + this.lastSeq = input.targetSeq; + return Promise.resolve({ + lastSeq: input.targetSeq, + cols: 4, + rows: 2, + cursorRow: 0, + cursorCol: 0, + }); + } + + public snapshot(options?: SnapshotOptions): Promise { + this.snapshotCalls.push(options); + const text = this.gridBySeq.get(this.lastSeq); + expect(text).toBeDefined(); + return Promise.resolve({ + sessionId: SESSION_ID, + capturedAtSeq: this.lastSeq, + cols: 4, + rows: 2, + cursorRow: 0, + cursorCol: Math.min(text?.length ?? 0, 3), + isAltScreen: false, + visibleLines: [ + { row: 0, text: text ?? '' }, + { row: 1, text: '' }, + ], + cells: [ + { + lineNumber: 0, + cells: (text ?? '').split('').map((char) => ({ char })), + }, + ], + }); + } + + public screenshot( + _outputPath: string, + _options?: ScreenshotOptions, + ): Promise { + throw new Error('screenshot must not be used by grid capture'); + } + + public getVisibleText(): Promise { + return Promise.resolve(this.gridBySeq.get(this.lastSeq) ?? ''); + } + + public dispose(): Promise { + this.disposed = true; + return Promise.resolve(); + } +} + +function createEvents(): EventRecord[] { + return [ + { seq: 0, ts: isoAt(0), type: 'output', payload: { data: 'a' } }, + // Same instant as seq 0: coalesced into one frame boundary. + { seq: 1, ts: isoAt(0), type: 'output', payload: { data: 'b' } }, + { seq: 2, ts: isoAt(500), type: 'output', payload: { data: 'c' } }, + // Non-visual event: never a frame boundary. + { seq: 3, ts: isoAt(700), type: 'marker', payload: { label: 'mark' } }, + // Produces a grid identical to seq 2's: de-duplicated into held frame. + { seq: 4, ts: isoAt(800), type: 'output', payload: { data: '' } }, + ]; +} + +function createGrids(): Map { + return new Map([ + [1, 'ab'], + [2, 'abc'], + [4, 'abc'], + ]); +} + +describe('captureGridFrames', () => { + it('captures coalesced, de-duplicated timeline frames with recorded holds', async () => { + const backend = new FakeGridBackend(createGrids()); + + const capture = await captureGridFrames( + { + sessionId: SESSION_ID, + manifest: createSessionRecord(), + events: createEvents(), + profile: PROFILE, + mode: 'timeline', + }, + { backendFactory: () => backend }, + ); + + // Boundaries: seq 1 (coalesced 0+1), seq 2, seq 4 (marker skipped). + expect(backend.replayTargetSeqs).toEqual([1, 2, 4]); + expect(backend.snapshotCalls).toEqual([ + { includeCells: true }, + { includeCells: true }, + { includeCells: true }, + ]); + expect(backend.disposed).toBe(true); + + // Frame 'abc' at seq 4 deduplicates into the seq-2 frame, merging its + // 300ms hold with the 1000ms final hold. + expect(capture.frames).toHaveLength(2); + expect(capture.frames[0]).toMatchObject({ + capturedAtSeq: 1, + holdMs: 500, + }); + expect(capture.frames[0]?.lines[0]?.map((cell) => cell.char)).toEqual([ + 'a', + 'b', + ]); + expect(capture.frames[1]).toMatchObject({ + capturedAtSeq: 2, + holdMs: 1_300, + }); + expect(capture.frames[1]?.lines[0]?.map((cell) => cell.char)).toEqual([ + 'a', + 'b', + 'c', + ]); + + expect(capture).toMatchObject({ + capturedAtSeq: 4, + cols: 4, + rows: 2, + rendererBackend: 'fake-grid', + outputEventCount: 4, + resizeEventCount: 0, + timelineDurationMs: 1_800, + }); + }); + + it('captures only the final frame in final mode', async () => { + const backend = new FakeGridBackend(createGrids()); + + const capture = await captureGridFrames( + { + sessionId: SESSION_ID, + manifest: createSessionRecord(), + events: createEvents(), + profile: PROFILE, + mode: 'final', + }, + { backendFactory: () => backend }, + ); + + expect(backend.replayTargetSeqs).toEqual([4]); + expect(capture.frames).toHaveLength(1); + expect(capture.frames[0]).toMatchObject({ + capturedAtSeq: 4, + holdMs: 1_000, + }); + expect(capture.capturedAtSeq).toBe(4); + expect(backend.disposed).toBe(true); + }); + + it('fails with a clear export error when the native backend cannot boot', async () => { + const backend = new FakeGridBackend(createGrids()); + backend.boot = () => + Promise.reject(new Error('Cannot find module @coder/libghostty-vt-node')); + + await expect( + captureGridFrames( + { + sessionId: SESSION_ID, + manifest: createSessionRecord(), + events: createEvents(), + profile: PROFILE, + mode: 'final', + }, + { backendFactory: () => backend }, + ), + ).rejects.toMatchObject({ + code: 'EXPORT_ERROR', + message: expect.stringContaining('@coder/libghostty-vt-node') as string, + }); + expect(backend.disposed).toBe(true); + }); +}); diff --git a/test/unit/storage/artifactStorage.test.ts b/test/unit/storage/artifactStorage.test.ts index aa27093..7ac0e25 100644 --- a/test/unit/storage/artifactStorage.test.ts +++ b/test/unit/storage/artifactStorage.test.ts @@ -76,6 +76,10 @@ describe('artifact paths', () => { expect(recordingFilename(7, 'webm')).toBe('recording-7-webm.webm'); }); + it('generates recording filenames for svg format', () => { + expect(recordingFilename(7, 'svg')).toBe('recording-7-svg.svg'); + }); + it('asserts on unsupported recording formats', () => { expect(() => recordingFilename(7, 'trace')).toThrow( /unsupported recording format: trace/u, diff --git a/test/unit/tools/review-bundle.test.ts b/test/unit/tools/review-bundle.test.ts index a159308..e28594c 100644 --- a/test/unit/tools/review-bundle.test.ts +++ b/test/unit/tools/review-bundle.test.ts @@ -119,6 +119,7 @@ describe('review-bundle helpers', () => { 'videos/demo.webm', ]); expect(classifyBundlePath('screenshots/primary.png')).toBe('screenshot'); + expect(classifyBundlePath('screenshots/demo.svg')).toBe('screenshot'); expect(classifyBundlePath('videos/demo.webm')).toBe('video'); expect(classifyBundlePath('recordings/demo.cast')).toBe('recording'); expect(classifyBundlePath('notes.md')).toBe('notes'); From 8501e4d3c3bdba91e848c5a4da5e552030ce956c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 10:51:48 +0000 Subject: [PATCH 04/20] chore: relock aube after upstream repo transfer The aube repository moved from endevco/aube to jdx/aube. GitHub attestation lookups via the old path now find nothing, so mise 2026.8.x fails 'mise install --locked' with 'Lockfile requires github-attestations provenance ... but verification was not performed', breaking every CI job at setup. Regenerate the aube lockfile entries with current mise (mise lock aube): URLs now point at jdx/aube and the unverifiable provenance pins are dropped. All artifact sha256 checksums are unchanged, so the pinned binaries are identical. --- mise.lock | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/mise.lock b/mise.lock index 13dfd9d..2a74620 100644 --- a/mise.lock +++ b/mise.lock @@ -985,39 +985,33 @@ backend = "github:endevco/aube" [tools.aube."platforms.linux-arm64"] checksum = "sha256:45dc6d46e69f1da58360fa8ac353d83c2b84e1ca2e31edb9be912a7729c839c1" -url = "https://github.com/endevco/aube/releases/download/v1.10.4/aube-v1.10.4-aarch64-unknown-linux-gnu.tar.gz" -url_api = "https://api.github.com/repos/endevco/aube/releases/assets/417134849" -provenance = "github-attestations" +url = "https://github.com/jdx/aube/releases/download/v1.10.4/aube-v1.10.4-aarch64-unknown-linux-gnu.tar.gz" +url_api = "https://api.github.com/repos/jdx/aube/releases/assets/417134849" [tools.aube."platforms.linux-arm64-musl"] checksum = "sha256:8094e14b0906ecb2fcdb573700e7c9536abdeedc203a15051723b4fe6247237e" -url = "https://github.com/endevco/aube/releases/download/v1.10.4/aube-v1.10.4-aarch64-unknown-linux-musl.tar.gz" -url_api = "https://api.github.com/repos/endevco/aube/releases/assets/417112128" -provenance = "github-attestations" +url = "https://github.com/jdx/aube/releases/download/v1.10.4/aube-v1.10.4-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/jdx/aube/releases/assets/417112128" [tools.aube."platforms.linux-x64"] checksum = "sha256:234b5d01ab5818937740ebc773709595e18eb926f2c543273e36670c5c416851" -url = "https://github.com/endevco/aube/releases/download/v1.10.4/aube-v1.10.4-x86_64-unknown-linux-gnu.tar.gz" -url_api = "https://api.github.com/repos/endevco/aube/releases/assets/417115596" -provenance = "github-attestations" +url = "https://github.com/jdx/aube/releases/download/v1.10.4/aube-v1.10.4-x86_64-unknown-linux-gnu.tar.gz" +url_api = "https://api.github.com/repos/jdx/aube/releases/assets/417115596" [tools.aube."platforms.linux-x64-musl"] checksum = "sha256:c6bc24fa4a06f13cc8fa7fdb18bd15c55ec17b2f9ebce478c559b9664bc5abde" -url = "https://github.com/endevco/aube/releases/download/v1.10.4/aube-v1.10.4-x86_64-unknown-linux-musl.tar.gz" -url_api = "https://api.github.com/repos/endevco/aube/releases/assets/417115736" -provenance = "github-attestations" +url = "https://github.com/jdx/aube/releases/download/v1.10.4/aube-v1.10.4-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/jdx/aube/releases/assets/417115736" [tools.aube."platforms.macos-arm64"] checksum = "sha256:963d276cef7f039a5750bf18d30af7c2457f6d5804fa24bd784458e22a4d1a14" -url = "https://github.com/endevco/aube/releases/download/v1.10.4/aube-v1.10.4-aarch64-apple-darwin.tar.gz" -url_api = "https://api.github.com/repos/endevco/aube/releases/assets/417151945" -provenance = "github-attestations" +url = "https://github.com/jdx/aube/releases/download/v1.10.4/aube-v1.10.4-aarch64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/jdx/aube/releases/assets/417151945" [tools.aube."platforms.windows-x64"] checksum = "sha256:631566d6802cbae88c35a54d8746ac36f56d563a0a680b6c5d882db8b9d495d5" -url = "https://github.com/endevco/aube/releases/download/v1.10.4/aube-v1.10.4-x86_64-pc-windows-msvc.zip" -url_api = "https://api.github.com/repos/endevco/aube/releases/assets/417123778" -provenance = "github-attestations" +url = "https://github.com/jdx/aube/releases/download/v1.10.4/aube-v1.10.4-x86_64-pc-windows-msvc.zip" +url_api = "https://api.github.com/repos/jdx/aube/releases/assets/417123778" [[tools.communique]] version = "1.1.3" From e9a5b495805a9b1645d96068d54296d3b8529ef8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 10:57:02 +0000 Subject: [PATCH 05/20] fix: preserve columns across zero-style gap padding cells in svg runs Cursor-forward output (e.g. ESC[10C) surfaces untouched columns as unstyled empty cells; merging them into a matching default-style text run re-anchored the run at the gap's first column and stretched the following glyphs across the gap via textLength. Zero-style empty cells now break the current run so the next glyph anchors at its true column, while styled wide-glyph spacers keep extending their run's cellCount/textLength. Added an invariant that emitted runs cover strictly advancing, non-overlapping columns within the row. --- src/export/svg.ts | 43 +++++++++++++++++++++++++-- test/unit/export/svg.test.ts | 41 +++++++++++++++++++++++++ test/unit/replay/gridFrames.test.ts | 46 +++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 2 deletions(-) diff --git a/src/export/svg.ts b/src/export/svg.ts index a8eb430..3a4deaf 100644 --- a/src/export/svg.ts +++ b/src/export/svg.ts @@ -110,10 +110,32 @@ function styleMatches(run: StyleRun, cell: SnapshotCell): boolean { ); } +/** + * Untouched grid columns (e.g. after an ESC[NC cursor-forward) surface as + * empty cells carrying no style at all. They render nothing and must never + * merge into a text run: merging would re-anchor the run at the gap's first + * column and stretch the following glyphs across the gap via `textLength`. + */ +function isZeroStyleGapCell(cell: SnapshotCell): boolean { + return ( + cell.char === '' && + cell.fg === undefined && + cell.bg === undefined && + !(cell.bold ?? false) && + !(cell.italic ?? false) && + !(cell.underline ?? false) + ); +} + /** * Group one row's cells into consecutive same-style runs. Wide-glyph spacer - * cells (`char: ''`) extend the covered cell count without adding text, so a - * run's `textLength` still spans every column the glyphs occupy. + * cells (`char: ''` carrying the glyph's style) extend the covered cell count + * without adding text, so a run's `textLength` still spans every column the + * glyphs occupy. Zero-style empty cells are gap padding for untouched + * columns: they break the current run so the next glyph anchors at its true + * column. A completely unstyled wide glyph's trailing spacer is + * indistinguishable from gap padding and is treated as a break too — the + * glyph then spans a single cell, but every subsequent column stays correct. */ export function groupRowIntoStyleRuns( cells: readonly SnapshotCell[], @@ -122,6 +144,10 @@ export function groupRowIntoStyleRuns( let current: StyleRun | null = null; for (const [col, cell] of cells.entries()) { + if (isZeroStyleGapCell(cell)) { + current = null; + continue; + } if (current !== null && styleMatches(current, cell)) { current.cellCount += 1; current.text += cell.char; @@ -140,6 +166,19 @@ export function groupRowIntoStyleRuns( runs.push(current); } + let previousEndCol = 0; + for (const run of runs) { + invariant( + run.startCol >= previousEndCol, + 'style runs must cover strictly advancing, non-overlapping columns', + ); + previousEndCol = run.startCol + run.cellCount; + } + invariant( + previousEndCol <= cells.length, + 'style runs must not extend past the row cells', + ); + return runs; } diff --git a/test/unit/export/svg.test.ts b/test/unit/export/svg.test.ts index a909851..9aae675 100644 --- a/test/unit/export/svg.test.ts +++ b/test/unit/export/svg.test.ts @@ -125,6 +125,47 @@ describe('renderGridFramesToSvg', () => { expect(svg).toContain('textLength="25.2"'); }); + it('preserves columns across zero-style gap padding cells', () => { + // Cursor-forward gaps (e.g. ESC[10C) surface as unstyled empty cells; the + // glyph after the gap must render at its true column, not stretched from + // the gap's first column. + const svg = renderGridFramesToSvg({ + profile: PROFILE, + frames: [ + makeFrame({ + cols: 16, + lines: [[...Array.from({ length: 10 }, () => cell('')), cell('X')]], + }), + ], + animate: false, + }); + + // Col 10 x 8.4 = 84; a single cell spans 8.4. + expect(svg).toContain( + 'X', + ); + }); + + it('breaks runs at interior gaps between same-style glyphs', () => { + const svg = renderGridFramesToSvg({ + profile: PROFILE, + frames: [ + makeFrame({ + lines: [[cell('A'), cell(''), cell(''), cell('B')]], + }), + ], + animate: false, + }); + + expect(svg).toContain( + 'A', + ); + // Col 3 x 8.4 = 25.2. + expect(svg).toContain( + 'B', + ); + }); + it('escapes XML special characters and strips control characters', () => { const svg = renderGridFramesToSvg({ profile: PROFILE, diff --git a/test/unit/replay/gridFrames.test.ts b/test/unit/replay/gridFrames.test.ts index a474d3b..c214b5b 100644 --- a/test/unit/replay/gridFrames.test.ts +++ b/test/unit/replay/gridFrames.test.ts @@ -16,9 +16,21 @@ import type { SessionRecord, } from '../../../src/protocol/schemas.js'; +import { renderGridFramesToSvg } from '../../../src/export/svg.js'; import { captureGridFrames } from '../../../src/replay/gridFrames.js'; import { resolveProfile } from '../../../src/renderer/profiles.js'; +// The cursor-forward column test replays through the real libghostty-vt +// backend; skip it cleanly when the optional native package is unavailable. +let nativeAvailable = false; +try { + await import('@coder/libghostty-vt-node'); + nativeAvailable = true; +} catch { + nativeAvailable = false; +} +const maybeIt = nativeAvailable ? it : it.skip; + const SESSION_ID = 'session-01'; const PROFILE = resolveProfile('reference-dark'); const BASE_TS_MS = Date.parse('2026-03-19T12:00:02.000Z'); @@ -220,6 +232,40 @@ describe('captureGridFrames', () => { expect(backend.disposed).toBe(true); }); + maybeIt( + 'renders cursor-forward gaps at their true columns through the native backend', + async () => { + // ESC[10C moves the cursor forward over 10 untouched columns; the 'X' + // must land at col 10 (x = 10 * 8.4 = 84) with a single-cell textLength. + const events: EventRecord[] = [ + { + seq: 0, + ts: isoAt(0), + type: 'output', + payload: { data: '\u001b[10CX' }, + }, + ]; + + const capture = await captureGridFrames({ + sessionId: SESSION_ID, + manifest: createSessionRecord(), + events, + profile: PROFILE, + mode: 'final', + }); + const svg = renderGridFramesToSvg({ + profile: PROFILE, + frames: capture.frames, + animate: false, + }); + + expect(capture.rendererBackend).toBe('libghostty-vt'); + expect(svg).toMatch( + /]*>X<\/text>/u, + ); + }, + ); + it('fails with a clear export error when the native backend cannot boot', async () => { const backend = new FakeGridBackend(createGrids()); backend.boot = () => From c215cdd7f43c7f57bccbfab072de11fd84b623ec Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 11:21:20 +0000 Subject: [PATCH 06/20] fix: address round-2 review findings in svg export - Preserve wide-glyph spans across styles: SnapshotCellSchema gains an optional width (>=2, leading cell only), the libghostty-vt backend propagates the native span (spacers stay width-free), and svg run grouping consumes a wide glyph's trailing spacers regardless of styling, so default-style CJK keeps its full textLength while zero-style gap padding still breaks runs. - Embed fonts in standalone SVGs: always inline the 21 KB JetBrains Mono latin subset as a woff2 data URI; inline the 2.5 MB Symbols Nerd Font only when a rendered frame contains a Private Use Area glyph. Deterministic (base64 of checked-in assets) with an SIL OFL 1.1 attribution comment. - Replace quadratic replay in captureGridFrames with an event cursor: each boundary feeds only the not-yet-applied validated event suffix, so every event is fed exactly once. --- src/export/svg.ts | 126 +++++++++++++++--- src/protocol/schemas.ts | 6 + src/renderer/libghosttyVt/backend.ts | 9 +- src/replay/gridFrames.ts | 32 ++++- test/unit/export/svg.test.ts | 87 +++++++++++- test/unit/protocol/messages.test.ts | 12 ++ .../unit/renderer/libghosttyVtBackend.test.ts | 3 + test/unit/replay/gridFrames.test.ts | 37 +++++ 8 files changed, 285 insertions(+), 27 deletions(-) diff --git a/src/export/svg.ts b/src/export/svg.ts index 3a4deaf..cfcf9b0 100644 --- a/src/export/svg.ts +++ b/src/export/svg.ts @@ -1,5 +1,10 @@ import type { SnapshotCell } from '../protocol/schemas.js'; import type { RenderProfileConfig } from '../renderer/types.js'; +import { + BUNDLED_PRIMARY_FONT_ASSET, + BUNDLED_SYMBOLS_FONT_ASSET, + type BundledFontAsset, +} from '../renderer/bundledFont.js'; import { invariant } from '../util/assert.js'; /** @@ -49,6 +54,67 @@ interface StyleRun { underline: boolean; } +/** + * Both bundled fonts are licensed under the SIL Open Font License 1.1 (see + * src/renderer/ghosttyWeb/assets/FONT-LICENSE.txt); embedded SVGs carry this + * attribution as an XML comment. + */ +const FONT_LICENSE_COMMENT = + ''; + +/** + * Nerd Font glyphs live in the Unicode Private Use Areas: the BMP PUA + * (U+E000-U+F8FF) plus the supplementary PUA-A/PUA-B planes. + */ +function isPrivateUseCodePoint(codePoint: number): boolean { + return ( + (codePoint >= 0xe000 && codePoint <= 0xf8ff) || + (codePoint >= 0xf0000 && codePoint <= 0xffffd) || + (codePoint >= 0x100000 && codePoint <= 0x10fffd) + ); +} + +function framesContainPrivateUseGlyph( + frames: readonly SvgGridFrame[], +): boolean { + for (const frame of frames) { + for (const cells of frame.lines) { + for (const cell of cells) { + for (const character of cell.char) { + const codePoint = character.codePointAt(0); + invariant(codePoint !== undefined, 'iterated character must exist'); + if (isPrivateUseCodePoint(codePoint)) { + return true; + } + } + } + } + } + return false; +} + +function renderFontFace(asset: BundledFontAsset, format: string): string { + invariant(asset.buffer.byteLength > 0, 'bundled font asset must have bytes'); + const dataUri = `data:${asset.contentType};base64,${asset.buffer.toString('base64')}`; + return `@font-face{font-family:"${asset.family}";src:url(${dataUri}) format("${format}");font-weight:${asset.weight};font-style:${asset.style};}`; +} + +/** + * Standalone SVGs must carry their fonts so glyphs render identically without + * locally installed fonts. The 21 KB JetBrains Mono latin subset is always + * embedded; the 2.5 MB Symbols Nerd Font is embedded only when a rendered + * frame actually contains a Private Use Area glyph, keeping ordinary exports + * small. Both are base64 of checked-in asset bytes, so output stays + * deterministic. + */ +function renderFontStyleElement(frames: readonly SvgGridFrame[]): string { + const fontFaces = [renderFontFace(BUNDLED_PRIMARY_FONT_ASSET, 'woff2')]; + if (framesContainPrivateUseGlyph(frames)) { + fontFaces.push(renderFontFace(BUNDLED_SYMBOLS_FONT_ASSET, 'truetype')); + } + return ``; +} + const XML_ESCAPES: Record = { '&': '&', '<': '<', @@ -128,42 +194,60 @@ function isZeroStyleGapCell(cell: SnapshotCell): boolean { } /** - * Group one row's cells into consecutive same-style runs. Wide-glyph spacer - * cells (`char: ''` carrying the glyph's style) extend the covered cell count - * without adding text, so a run's `textLength` still spans every column the - * glyphs occupy. Zero-style empty cells are gap padding for untouched - * columns: they break the current run so the next glyph anchors at its true - * column. A completely unstyled wide glyph's trailing spacer is - * indistinguishable from gap padding and is treated as a break too — the - * glyph then spans a single cell, but every subsequent column stays correct. + * Group one row's cells into consecutive same-style runs. A wide glyph + * declares its span via the leading cell's `width`; its trailing spacer cells + * (`char: ''`) extend the run's covered cell count without adding text — + * regardless of styling — so `textLength` spans every column the glyph + * occupies. Zero-style empty cells NOT covered by a preceding wide glyph are + * gap padding for untouched columns (e.g. ESC[NC): they break the current run + * so the next glyph anchors at its true column. */ export function groupRowIntoStyleRuns( cells: readonly SnapshotCell[], ): StyleRun[] { const runs: StyleRun[] = []; let current: StyleRun | null = null; + let pendingSpacers = 0; for (const [col, cell] of cells.entries()) { + if (pendingSpacers > 0) { + // Column covered by the preceding wide glyph: extend that glyph's run + // whatever the spacer's styling looks like. + pendingSpacers -= 1; + invariant( + cell.char === '', + 'wide-glyph trailing cell must be an empty spacer', + ); + invariant(current !== null, 'wide glyph run must exist for its spacers'); + current.cellCount += 1; + continue; + } if (isZeroStyleGapCell(cell)) { current = null; continue; } + const width = cell.width ?? 1; + invariant( + Number.isInteger(width) && width >= 1, + 'snapshot cell width must be a positive integer when provided', + ); if (current !== null && styleMatches(current, cell)) { current.cellCount += 1; current.text += cell.char; - continue; + } else { + current = { + startCol: col, + cellCount: 1, + text: cell.char, + fg: cell.fg, + bg: cell.bg, + bold: cell.bold ?? false, + italic: cell.italic ?? false, + underline: cell.underline ?? false, + }; + runs.push(current); } - current = { - startCol: col, - cellCount: 1, - text: cell.char, - fg: cell.fg, - bg: cell.bg, - bold: cell.bold ?? false, - italic: cell.italic ?? false, - underline: cell.underline ?? false, - }; - runs.push(current); + pendingSpacers = width - 1; } let previousEndCol = 0; @@ -335,6 +419,8 @@ export function renderGridFramesToSvg(options: SvgRenderOptions): string { const lines: string[] = [ ``, + FONT_LICENSE_COMMENT, + renderFontStyleElement(frames), ``, ]; diff --git a/src/protocol/schemas.ts b/src/protocol/schemas.ts index 26fea15..f2e2b90 100644 --- a/src/protocol/schemas.ts +++ b/src/protocol/schemas.ts @@ -307,6 +307,12 @@ export const SnapshotCellSchema = z italic: z.boolean().optional(), underline: z.boolean().optional(), strikethrough: z.boolean().optional(), + /** + * Terminal cell span of this glyph. Only present on the leading cell of a + * wide glyph (width >= 2); the trailing column(s) follow as `char: ''` + * spacer cells without a width. + */ + width: z.number().int().min(2).optional(), }) .strict(); export type SnapshotCell = z.infer; diff --git a/src/renderer/libghosttyVt/backend.ts b/src/renderer/libghosttyVt/backend.ts index a33b62d..bd5ac95 100644 --- a/src/renderer/libghosttyVt/backend.ts +++ b/src/renderer/libghosttyVt/backend.ts @@ -293,6 +293,9 @@ function toStyledCell(cell: NativeSnapshotCell): SnapshotCell { ...(cell.bold === undefined ? {} : { bold: cell.bold }), ...(cell.italic === undefined ? {} : { italic: cell.italic }), ...(cell.underline === undefined ? {} : { underline: cell.underline }), + // Preserve the native wide-glyph span on the leading cell so consumers + // can distinguish trailing spacers from untouched-column gap padding. + ...(cell.width > 1 ? { width: cell.width } : {}), }; } @@ -336,9 +339,11 @@ function mapNativeCells( cells.push(styled); // A wide glyph covers its trailing column(s): emit an empty spacer // carrying the glyph's styling so the trailing half shades correctly - // and the array index stays aligned with the terminal column. + // and the array index stays aligned with the terminal column. The + // spacer carries no `width` of its own — only the leading cell does. for (let span = 1; span < cell.width; span += 1) { - cells.push({ ...styled, char: '' }); + const { width: _leadingWidth, ...spacerStyle } = styled; + cells.push({ ...spacerStyle, char: '' }); } } return { lineNumber, cells }; diff --git a/src/replay/gridFrames.ts b/src/replay/gridFrames.ts index 4c4a3c1..17b7e0c 100644 --- a/src/replay/gridFrames.ts +++ b/src/replay/gridFrames.ts @@ -166,6 +166,7 @@ function frameContentKey(frame: GridFrame): string { cell.bold ?? false, cell.italic ?? false, cell.underline ?? false, + cell.width ?? 1, ]), ), ]); @@ -243,6 +244,25 @@ export async function captureGridFrames( const frames: GridFrame[] = []; let previousContentKey: string | null = null; + // Event cursor: each boundary replay feeds only the not-yet-applied event + // suffix. Passing the full validated array per boundary would rescan from + // index 0 every time (O(boundaries x events)). The backend skips events at + // or below its last applied seq, so a contiguous suffix slice of the + // already-validated events is equivalent. + let nextEventIndex = 0; + const sliceEventsThrough = (targetSeq: number) => { + const startIndex = nextEventIndex; + while (nextEventIndex < replayInput.events.length) { + const event = replayInput.events[nextEventIndex]; + invariant(event !== undefined, 'event cursor index must be in range'); + if (event.seq > targetSeq) { + break; + } + nextEventIndex += 1; + } + return replayInput.events.slice(startIndex, nextEventIndex); + }; + for (const [index, boundary] of captureBoundaries.entries()) { const nextBoundary = captureBoundaries[index + 1]; const holdMs = @@ -251,7 +271,11 @@ export async function captureGridFrames( : nextBoundary.tsMs - boundary.tsMs; invariant(holdMs > 0, 'frame hold duration must be positive'); - await backend.replayTo({ ...replayInput, targetSeq: boundary.seq }); + await backend.replayTo({ + ...replayInput, + events: sliceEventsThrough(boundary.seq), + targetSeq: boundary.seq, + }); const snapshot = await backend.snapshot({ includeCells: true }); const frame = toGridFrame(snapshot, holdMs); const contentKey = frameContentKey(frame); @@ -267,7 +291,11 @@ export async function captureGridFrames( // Apply any trailing non-visual events so capturedAtSeq covers the log. if (replayInput.targetSeq > (captureBoundaries.at(-1)?.seq ?? -1)) { - await backend.replayTo(replayInput); + await backend.replayTo({ + ...replayInput, + events: sliceEventsThrough(replayInput.targetSeq), + targetSeq: replayInput.targetSeq, + }); } const finalFrame = frames.at(-1); diff --git a/test/unit/export/svg.test.ts b/test/unit/export/svg.test.ts index 9aae675..54fdeab 100644 --- a/test/unit/export/svg.test.ts +++ b/test/unit/export/svg.test.ts @@ -28,6 +28,11 @@ function countOccurrences(haystack: string, needle: string): number { return haystack.split(needle).length - 1; } +/** Drop the embedded font payload (base64 can contain any digits/letters). */ +function stripFontAssets(svg: string): string { + return svg.replace(/', ); + for (const element of renderFrameElements(frame, profile)) { + lines.push(element); + } + lines.push(''); offsetMs = endMs; } } diff --git a/test/unit/export/svg.test.ts b/test/unit/export/svg.test.ts index 86ad29c..535bd05 100644 --- a/test/unit/export/svg.test.ts +++ b/test/unit/export/svg.test.ts @@ -443,6 +443,23 @@ describe('renderGridFramesToSvg', () => { } }); + it('renders very large frame counts without exhausting argument limits', () => { + // Math.max(...spread)/push(...spread) over per-frame arrays throw a + // RangeError past V8's argument limit (~125k); 200k frames must work. + const frames: SvgGridFrame[] = Array.from({ length: 200_000 }, () => + makeFrame({ cols: 1, rows: 1, holdMs: 1, lines: [] }), + ); + + const svg = renderGridFramesToSvg({ + profile: PROFILE, + frames, + animate: false, + }); + + // 1 col x 8.4 = 8.4, 1 row x 18 = 18. + expect(svg).toContain('viewBox="0 0 8.4 18"'); + }); + it('renders a single animated frame as static content', () => { const svg = renderGridFramesToSvg({ profile: PROFILE, From f5e8e3c85400d5f8abd506ae10272f3ea56b9df7 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 13:20:04 +0000 Subject: [PATCH 17/20] fix: bound retained grid cells during animated svg capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Animated capture keeps every distinct frame's dense styled grid in memory until rendering; within the supported 50 MB event-log limit a recording can produce enough distinct frames to exhaust the heap. captureGridFrames now enforces a retained-cell budget (default MAX_ANIMATED_GRID_CELLS = 20M cells, ~10k distinct 80x24 frames, adapting to terminal size; override-able via the maxGridCells option for tests). Exceeding it fails fast with EXPORT_ERROR suggesting a still SVG or WebM export — frames are never silently dropped because exported artifacts are evidence. Documented in docs/USAGE.md. --- docs/USAGE.md | 2 ++ src/replay/gridFrames.ts | 31 +++++++++++++++++++++++++ test/unit/replay/gridFrames.test.ts | 36 +++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/docs/USAGE.md b/docs/USAGE.md index 174000e..596b199 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -207,6 +207,8 @@ WebM export replays with recorded wall-clock timing by default. Pass `--timing a SVG export renders styled grid frames from the event log through the native `libghostty-vt` backend with no browser and no ffmpeg, so it requires the optional `@coder/libghostty-vt-node` package (there is no `ghostty-web` fallback). The output is deterministic: exporting the same session twice produces byte-identical, diffable SVG. `--format svg` writes a still image of the final screen; add `--animate` for an animated SVG of de-duplicated frames replayed with recorded event-log timing (`--timing` is not supported with SVG). +Animated SVG capture retains every distinct frame's styled grid in memory, so it is bounded by a total-cell budget (20M cells, roughly 10,000 distinct 80x24 frames, adapting to terminal size); recordings that exceed it fail with a clear error suggesting a still SVG or WebM export instead. + SVG exports always embed the pinned JetBrains Mono latin subset and additionally embed the Symbols Nerd Font Mono face when the rendered content needs it. Glyphs outside both faces (notably CJK and most emoji) render via the viewer's monospace fallback, mirroring the reference renderer's own system-font fallback for the same glyphs. Text content and layout metrics stay deterministic even when fallback glyph shapes vary: every text run is pinned to the terminal grid via `textLength`, so columns never shift. Use `--renderer ghostty-web`, `AGENT_TTY_RENDERER=ghostty-web`, or Home `config.json` `{ "defaultRenderer": "ghostty-web" }` to force legacy all-browser rendering. Use `--renderer libghostty-vt` only when you intentionally want semantic and screenshot requests routed through the native backend; WebM requests still record `ghostty-web` as the actual video producer. diff --git a/src/replay/gridFrames.ts b/src/replay/gridFrames.ts index 3d7402e..c2a4b1b 100644 --- a/src/replay/gridFrames.ts +++ b/src/replay/gridFrames.ts @@ -17,6 +17,18 @@ import { invariant } from '../util/assert.js'; /** Matches the recorded-timing WebM export's final frame hold. */ const DEFAULT_FINAL_FRAME_HOLD_MS = 1_000; +/** + * Upper bound on the total styled cells retained across all captured frames + * (cols x rows summed per distinct frame). Animated capture keeps every + * distinct frame's dense grid in memory until rendering, so an unbounded + * recording — the 50 MB event-log limit permits far more distinct frames + * than fit on a reasonable heap — must fail fast instead of exhausting + * memory. 20M cells is ~10k distinct 80x24 frames and adapts to terminal + * size; exceeding it raises EXPORT_ERROR rather than silently dropping + * frames, because exported artifacts are evidence. + */ +export const MAX_ANIMATED_GRID_CELLS = 20_000_000; + export type GridFrameMode = 'final' | 'timeline'; export interface GridFrame { @@ -38,6 +50,8 @@ export interface CaptureGridFramesOptions { /** `final` captures only the last frame; `timeline` captures every boundary. */ mode: GridFrameMode; finalFrameHoldMs?: number; + /** Retained-cell budget override; defaults to MAX_ANIMATED_GRID_CELLS. */ + maxGridCells?: number; } export interface GridFrameCapture { @@ -217,6 +231,11 @@ export async function captureGridFrames( Number.isInteger(finalFrameHoldMs) && finalFrameHoldMs > 0, 'finalFrameHoldMs must be a positive integer', ); + const maxGridCells = options.maxGridCells ?? MAX_ANIMATED_GRID_CELLS; + invariant( + Number.isInteger(maxGridCells) && maxGridCells > 0, + 'maxGridCells must be a positive integer', + ); const replayInput = buildReplayInput( options.sessionId, @@ -331,6 +350,7 @@ export async function captureGridFrames( } const frames: GridFrame[] = []; + let retainedGridCells = 0; let previousContentKey: string | null = null; // Event cursor: each boundary replay feeds only the not-yet-applied event // suffix. Passing the full validated array per boundary would rescan from @@ -376,6 +396,17 @@ export async function captureGridFrames( previousFrame.holdMs += holdMs; continue; } + retainedGridCells += frame.cols * frame.rows; + if (retainedGridCells > maxGridCells) { + throw makeCliError(ERROR_CODES.EXPORT_ERROR, { + message: + 'Recording has too many distinct frames for animated SVG export. Use a still SVG export (drop --animate) or a WebM export instead.', + details: { + maxGridCells, + capturedFrames: frames.length, + }, + }); + } frames.push(frame); previousContentKey = contentKey; } diff --git a/test/unit/replay/gridFrames.test.ts b/test/unit/replay/gridFrames.test.ts index 9dabfff..e51d91b 100644 --- a/test/unit/replay/gridFrames.test.ts +++ b/test/unit/replay/gridFrames.test.ts @@ -370,6 +370,42 @@ describe('captureGridFrames', () => { }, ); + it('fails fast when distinct frames exceed the retained-cell budget', async () => { + // Three distinct 4x2 frames retain 8 cells each; a 20-cell budget admits + // two frames and must trip on the third instead of exhausting memory. + const events: EventRecord[] = [ + { seq: 0, ts: isoAt(0), type: 'output', payload: { data: 'a' } }, + { seq: 1, ts: isoAt(1_000), type: 'output', payload: { data: 'b' } }, + { seq: 2, ts: isoAt(2_000), type: 'output', payload: { data: 'c' } }, + ]; + const backend = new FakeGridBackend( + new Map([ + [0, 'a'], + [1, 'ab'], + [2, 'abc'], + ]), + ); + + await expect( + captureGridFrames( + { + sessionId: SESSION_ID, + manifest: createSessionRecord(), + events, + profile: PROFILE, + mode: 'timeline', + maxGridCells: 20, + }, + { backendFactory: () => backend }, + ), + ).rejects.toMatchObject({ + code: 'EXPORT_ERROR', + message: + 'Recording has too many distinct frames for animated SVG export. Use a still SVG export (drop --animate) or a WebM export instead.', + }); + expect(backend.disposed).toBe(true); + }); + it('synthesizes a blank frame for an empty event log without a backend', async () => { const capture = await captureGridFrames( { From 29ac24b21cfe4880318f78bb6b84ed82ce8adcdc Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 13:28:10 +0000 Subject: [PATCH 18/20] fix: report svg canvas dimensions matching the rendered viewBox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For an animated recording that resizes from a larger grid down, renderGridFramesToSvg sizes the canvas to the per-frame maxima of cols/rows, but captureGridFrames reported only the final frame's dimensions — record export then published width/height in CLI JSON and the artifact manifest smaller than the actual viewBox. capture.cols/rows now carry the same per-frame maxima the renderer sizes from (documented on GridFrameCapture). Still exports capture a single frame, so their dimensions are unchanged; the blank-synthesized empty-log capture is likewise unaffected. --- src/replay/gridFrames.ts | 22 ++++++++++- test/unit/replay/gridFrames.test.ts | 59 ++++++++++++++++++++++++++--- 2 files changed, 74 insertions(+), 7 deletions(-) diff --git a/src/replay/gridFrames.ts b/src/replay/gridFrames.ts index c2a4b1b..a920e58 100644 --- a/src/replay/gridFrames.ts +++ b/src/replay/gridFrames.ts @@ -57,6 +57,11 @@ export interface CaptureGridFramesOptions { export interface GridFrameCapture { frames: GridFrame[]; capturedAtSeq: number; + /** + * Canvas dimensions: per-frame maxima of cols/rows across all captured + * frames, matching the viewBox the SVG renderer sizes from. For a + * recording that resizes down, these exceed the final frame's grid. + */ cols: number; rows: number; rendererBackend: string; @@ -423,11 +428,24 @@ export async function captureGridFrames( const finalFrame = frames.at(-1); invariant(finalFrame !== undefined, 'grid capture must produce >=1 frame'); + // Report the same per-frame maxima the SVG renderer sizes its canvas + // from, so CLI JSON / manifest width+height match the rendered viewBox. + let maxCols = 0; + let maxRows = 0; + for (const frame of frames) { + maxCols = Math.max(maxCols, frame.cols); + maxRows = Math.max(maxRows, frame.rows); + } + invariant( + maxCols > 0 && maxRows > 0, + 'capture dimensions must be positive', + ); + return { frames, capturedAtSeq: replayInput.targetSeq, - cols: finalFrame.cols, - rows: finalFrame.rows, + cols: maxCols, + rows: maxRows, rendererBackend: backend.rendererBackend, outputEventCount, resizeEventCount, diff --git a/test/unit/replay/gridFrames.test.ts b/test/unit/replay/gridFrames.test.ts index e51d91b..c3aea08 100644 --- a/test/unit/replay/gridFrames.test.ts +++ b/test/unit/replay/gridFrames.test.ts @@ -71,21 +71,27 @@ class FakeGridBackend implements RendererBackend { private lastSeq = -1; - public constructor(private readonly gridBySeq: Map) {} + public constructor( + private readonly gridBySeq: Map, + private readonly dimsBySeq?: Map, + ) {} public boot(): Promise { this.isBooted = true; return Promise.resolve(); } + private dims(): { cols: number; rows: number } { + return this.dimsBySeq?.get(this.lastSeq) ?? { cols: 4, rows: 2 }; + } + public replayTo(input: ReplayInput): Promise { this.replayTargetSeqs.push(input.targetSeq); this.eventsFed += input.events.length; this.lastSeq = input.targetSeq; return Promise.resolve({ lastSeq: input.targetSeq, - cols: 4, - rows: 2, + ...this.dims(), cursorRow: 0, cursorCol: 0, }); @@ -98,8 +104,7 @@ class FakeGridBackend implements RendererBackend { return Promise.resolve({ sessionId: SESSION_ID, capturedAtSeq: this.lastSeq, - cols: 4, - rows: 2, + ...this.dims(), cursorRow: 0, cursorCol: Math.min(text?.length ?? 0, 3), isAltScreen: false, @@ -370,6 +375,50 @@ describe('captureGridFrames', () => { }, ); + it('reports per-frame maxima dimensions for recordings that resize down', async () => { + // The SVG renderer sizes its canvas to the per-frame maxima, so the + // capture must report those — not the smaller final grid — to keep CLI + // JSON / manifest width+height consistent with the rendered viewBox. + const events: EventRecord[] = [ + { seq: 0, ts: isoAt(0), type: 'output', payload: { data: 'big' } }, + { + seq: 1, + ts: isoAt(1_000), + type: 'resize', + payload: { cols: 80, rows: 24 }, + }, + { seq: 2, ts: isoAt(2_000), type: 'output', payload: { data: 'small' } }, + ]; + const backend = new FakeGridBackend( + new Map([ + [0, 'big'], + [1, 'big'], + [2, 'small'], + ]), + new Map([ + [0, { cols: 100, rows: 30 }], + [1, { cols: 80, rows: 24 }], + [2, { cols: 80, rows: 24 }], + ]), + ); + + const capture = await captureGridFrames( + { + sessionId: SESSION_ID, + manifest: createSessionRecord(), + events, + profile: PROFILE, + mode: 'timeline', + }, + { backendFactory: () => backend }, + ); + + // Final frame is 80x24, but the canvas spans the largest frame. + expect(capture.frames.at(-1)).toMatchObject({ cols: 80, rows: 24 }); + expect(capture.cols).toBe(100); + expect(capture.rows).toBe(30); + }); + it('fails fast when distinct frames exceed the retained-cell budget', async () => { // Three distinct 4x2 frames retain 8 cells each; a 20-cell budget admits // two frames and must trip on the third instead of exhausting memory. From e9cce6c9481fb6378930a79ec114dc057ce95ccb Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 13:38:01 +0000 Subject: [PATCH 19/20] fix: split svg runs between symbols-face and system-fallback glyphs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two-bucket face classification merged adjacent width-1 glyphs where one resolves to the embedded Symbols Nerd Font and the other to the viewer's system fallback (e.g. alpha + U+26A1): different real fonts with different natural advances under one aggregate textLength drifted interior glyphs. GlyphFace now has three buckets — latin, symbols, system — with symbols classified by the pinned SymbolsNerdFontMono-Regular.ttf cmap coverage (dumped offline from the checked-in asset's format-12 cmap: 10,410 code points in 28 ranges, hardcoded with regeneration notes). Run grouping already breaks on face change. The embedding predicate is narrowed to glyphs the symbols face actually covers: system-fallback glyphs (CJK etc.) gain nothing from the 2.5 MB payload and no longer trigger it. --- src/export/svg.ts | 101 ++++++++++++++++++++++++++--------- test/unit/export/svg.test.ts | 45 +++++++++++++++- 2 files changed, 120 insertions(+), 26 deletions(-) diff --git a/src/export/svg.ts b/src/export/svg.ts index e8cd964..10f66de 100644 --- a/src/export/svg.ts +++ b/src/export/svg.ts @@ -50,7 +50,7 @@ export interface SvgRenderOptions { animate: boolean; } -type GlyphFace = 'latin' | 'fallback'; +type GlyphFace = 'latin' | 'symbols' | 'system'; interface StyleRun { startCol: number; @@ -70,16 +70,12 @@ interface StyleRun { * attribution as an XML comment. */ const FONT_LICENSE_COMMENT = - ''; + ''; /** * Exact cmap coverage of the pinned JetBrainsMono-Regular-latin.woff2 asset * (dumped offline from the checked-in file's format-4 cmap: 229 code points). * The asset is hash-pinned via bundledFont.ts, so these ranges are stable. - * Any rendered code point outside this coverage triggers embedding of the - * Symbols Nerd Font Mono fallback face. That over-embeds for glyphs covered - * by neither face (e.g. CJK), which is acceptable: it keeps the predicate a - * simple, deterministic function of rendered content. * * Explicit boundary: glyphs outside BOTH bundled faces — notably CJK and * most emoji — render via the viewer's monospace fallback. This is kept @@ -132,32 +128,89 @@ function isCoveredByPrimaryLatinSubset(codePoint: number): boolean { } /** - * Classify which embedded face renders a glyph: the primary latin subset or - * the symbols/fallback path. Glyphs covered by neither bundled face (CJK - * etc.) classify as 'fallback' too — both resolve past the latin face in the - * font stack, so they share advance behavior distinct from latin glyphs. + * Exact cmap coverage of the pinned SymbolsNerdFontMono-Regular.ttf asset + * (dumped offline from the checked-in file's format-12 cmap: 10,410 code + * points in 28 contiguous ranges). The asset is hash-pinned via + * bundledFont.ts, so these ranges are stable. To regenerate: parse the TTF + * table directory for the `cmap` table, pick the format-12 subtable, and + * collapse its sequential map groups into [start, end] pairs. + */ +const SYMBOLS_FACE_RANGES: ReadonlyArray = [ + [0x23fb, 0x23fe], + [0x2630, 0x2630], + [0x2665, 0x2665], + [0x26a1, 0x26a1], + [0x276c, 0x2771], + [0x2b58, 0x2b58], + [0xe000, 0xe00a], + [0xe0a0, 0xe0a3], + [0xe0b0, 0xe0c8], + [0xe0ca, 0xe0ca], + [0xe0cc, 0xe0d2], + [0xe0d4, 0xe0d4], + [0xe0d6, 0xe0d7], + [0xe200, 0xe2a9], + [0xe300, 0xe3e3], + [0xe5fa, 0xe6b8], + [0xe700, 0xe8ef], + [0xea60, 0xea88], + [0xea8a, 0xea8c], + [0xea8f, 0xeac7], + [0xeac9, 0xeac9], + [0xeacc, 0xeb09], + [0xeb0b, 0xeb4e], + [0xeb50, 0xec1e], + [0xed00, 0xefce], + [0xf000, 0xf381], + [0xf400, 0xf533], + [0xf0001, 0xf1af0], +]; + +function isCoveredBySymbolsFace(codePoint: number): boolean { + for (const [start, end] of SYMBOLS_FACE_RANGES) { + if (codePoint >= start && codePoint <= end) { + return true; + } + } + return false; +} + +/** + * Classify which face actually renders a glyph: the primary latin subset, + * the embedded symbols face, or the viewer's system monospace fallback + * (glyphs covered by neither bundled face, e.g. CJK and most emoji). The + * three resolve to different real fonts with different natural advances, so + * run grouping must not mix them. A cluster containing any system-fallback + * code point classifies as 'system' since the fallback font shapes it. */ function classifyGlyphFace(char: string): GlyphFace { + let sawSymbolsGlyph = false; for (const character of char) { const codePoint = character.codePointAt(0); invariant(codePoint !== undefined, 'iterated character must exist'); - if (!isCoveredByPrimaryLatinSubset(codePoint)) { - return 'fallback'; + if (isCoveredByPrimaryLatinSubset(codePoint)) { + continue; + } + if (isCoveredBySymbolsFace(codePoint)) { + sawSymbolsGlyph = true; + continue; } + return 'system'; } - return 'latin'; + return sawSymbolsGlyph ? 'symbols' : 'latin'; } -function framesContainNonLatinGlyph(frames: readonly SvgGridFrame[]): boolean { +/** + * The symbols face is embedded only when a rendered glyph actually resolves + * to it; 'system' glyphs (covered by neither bundled face) gain nothing from + * embedding, so they no longer trigger the 2.5 MB payload. + */ +function framesContainSymbolsGlyph(frames: readonly SvgGridFrame[]): boolean { for (const frame of frames) { for (const cells of frame.lines) { for (const cell of cells) { - for (const character of cell.char) { - const codePoint = character.codePointAt(0); - invariant(codePoint !== undefined, 'iterated character must exist'); - if (!isCoveredByPrimaryLatinSubset(codePoint)) { - return true; - } + if (classifyGlyphFace(cell.char) === 'symbols') { + return true; } } } @@ -175,13 +228,13 @@ function renderFontFace(asset: BundledFontAsset, format: string): string { * Standalone SVGs must carry their fonts so glyphs render identically without * locally installed fonts. The 21 KB JetBrains Mono latin subset is always * embedded; the 2.5 MB Symbols Nerd Font is embedded only when a rendered - * frame contains a code point outside that latin subset's coverage, keeping - * ordinary exports small. Both are base64 of checked-in asset bytes, so - * output stays deterministic. + * frame contains a glyph the symbols face actually covers, keeping ordinary + * exports small. Both are base64 of checked-in asset bytes, so output stays + * deterministic. */ function renderFontStyleElement(frames: readonly SvgGridFrame[]): string { const fontFaces = [renderFontFace(BUNDLED_PRIMARY_FONT_ASSET, 'woff2')]; - if (framesContainNonLatinGlyph(frames)) { + if (framesContainSymbolsGlyph(frames)) { fontFaces.push(renderFontFace(BUNDLED_SYMBOLS_FONT_ASSET, 'truetype')); } return ``; diff --git a/test/unit/export/svg.test.ts b/test/unit/export/svg.test.ts index 535bd05..14dcb4a 100644 --- a/test/unit/export/svg.test.ts +++ b/test/unit/export/svg.test.ts @@ -159,6 +159,38 @@ describe('renderGridFramesToSvg', () => { expect(svg).not.toContain('>\ue0a0B'); }); + it('splits symbol glyphs from system-fallback glyphs', () => { + // α (U+03B1) is covered by neither bundled face (system fallback) while + // ⚡ (U+26A1) resolves to the embedded symbols face: different real + // fonts, different natural advances — merging them into one aggregate + // textLength run would drift interior glyphs. + const svg = renderGridFramesToSvg({ + profile: PROFILE, + frames: [makeFrame({ lines: [[cell('\u03b1'), cell('\u26a1')]] })], + animate: false, + }); + + expect(svg).toContain( + '\u03b1', + ); + expect(svg).toContain( + '\u26a1', + ); + expect(svg).not.toContain('>\u03b1\u26a1'); + }); + + it('merges consecutive system-fallback glyphs into one run', () => { + const svg = renderGridFramesToSvg({ + profile: PROFILE, + frames: [makeFrame({ lines: [[cell('\u03b1'), cell('\u03b2')]] })], + animate: false, + }); + + expect(svg).toContain( + '\u03b1\u03b2', + ); + }); + it('merges consecutive same-face symbol glyphs into one run', () => { const svg = renderGridFramesToSvg({ profile: PROFILE, @@ -269,7 +301,7 @@ describe('renderGridFramesToSvg', () => { expect(svg).toContain('SIL Open Font License 1.1'); }); - it('embeds the Symbols Nerd Font only when a glyph escapes the latin subset', () => { + it('embeds the Symbols Nerd Font only for glyphs its face covers', () => { const plainSvg = renderGridFramesToSvg({ profile: PROFILE, frames: [makeFrame({ lines: [[cell('x')]] })], @@ -288,7 +320,7 @@ describe('renderGridFramesToSvg', () => { '@font-face{font-family:"Symbols Nerd Font Mono";src:url(data:font/ttf;base64,', ); - // U+E0A0 (powerline branch glyph) sits in the BMP Private Use Area. + // U+E0A0 (powerline branch glyph) is in the symbols face's PUA coverage. const puaSvg = renderGridFramesToSvg({ profile: PROFILE, frames: [makeFrame({ lines: [[cell('\ue0a0')]] })], @@ -297,6 +329,15 @@ describe('renderGridFramesToSvg', () => { expect(puaSvg).toContain( '@font-face{font-family:"Symbols Nerd Font Mono";src:url(data:font/ttf;base64,', ); + + // CJK is covered by neither bundled face (system fallback renders it), + // so embedding the symbols face would add 2.5 MB for nothing. + const cjkSvg = renderGridFramesToSvg({ + profile: PROFILE, + frames: [makeFrame({ lines: [[cell('漢', { width: 2 }), cell('')]] })], + animate: false, + }); + expect(cjkSvg).not.toContain('data:font/ttf'); }); it('preserves columns across zero-style gap padding cells', () => { From 39cc13755fcf19281bef366bff071fc70ccce55c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 13:48:07 +0000 Subject: [PATCH 20/20] fix: position system-fallback glyphs as singleton svg runs Adjacent system-fallback glyphs (covered by neither bundled face) can resolve to different system fonts per script (e.g. Greek + Hebrew), so even a same-bucket merged run scales unequal natural advances under one aggregate textLength and drifts interior glyphs. System-classified glyphs never merge and close their run immediately (wide system glyphs close via the existing spacer branch), so every system glyph is pinned individually via its own x + textLength and cannot drift regardless of which face the viewer picks. Latin and symbols runs keep merging: each is a single known monospace face with uniform advances. Guarantee documented: runs are homogeneous in (style, width, face) and system-fallback glyphs are always singleton runs. --- src/export/svg.ts | 20 ++++++++++++++++---- test/unit/export/svg.test.ts | 12 ++++++++++-- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/export/svg.ts b/src/export/svg.ts index 10f66de..d729b2b 100644 --- a/src/export/svg.ts +++ b/src/export/svg.ts @@ -317,10 +317,15 @@ function styleMatches(run: StyleRun, cell: SnapshotCell): boolean { * fallback font whose wide-glyph advance ratio differs would shift interior * glyphs off their columns — within a homogeneous-width run a monospace * fallback has equal natural advances and every glyph lands exactly on its - * column. Runs also split at FONT-FACE boundaries (latin subset vs the - * symbols/fallback path, see classifyGlyphFace): the two faces have - * different natural advances, so a mixed-face run would drift interior - * glyphs exactly like a mixed-width one. Every OTHER empty cell — zero-style + * column. Runs also split at FONT-FACE boundaries (see classifyGlyphFace): + * different faces have different natural advances, so a mixed-face run would + * drift interior glyphs exactly like a mixed-width one. Guarantee: every run + * is homogeneous in (style, width, face), and system-fallback glyphs are + * ALWAYS singleton runs — their face identity is viewer-dependent (adjacent + * scripts can resolve to different system fonts), so each one is pinned + * individually via its own x + textLength. Latin and symbols runs keep + * merging because each is a single known monospace face with uniform + * advances. Every OTHER empty cell — zero-style * gap padding for untouched columns (e.g. ESC[NC) and styled empties such as * colored field padding — breaks the current run so the next glyph * re-anchors at its true column: merging an empty cell would stretch the @@ -365,6 +370,7 @@ export function groupRowIntoStyleRuns( const face = classifyGlyphFace(cell.char); if ( width === 1 && + face !== 'system' && current !== null && current.face === face && styleMatches(current, cell) @@ -386,6 +392,12 @@ export function groupRowIntoStyleRuns( runs.push(current); } pendingSpacers = width - 1; + if (face === 'system' && pendingSpacers === 0) { + // System-fallback glyphs are singleton runs: close immediately so the + // next glyph starts fresh (wide system glyphs are closed by the spacer + // branch once their trailing columns are consumed). + current = null; + } } let previousEndCol = 0; diff --git a/test/unit/export/svg.test.ts b/test/unit/export/svg.test.ts index 14dcb4a..41d74bc 100644 --- a/test/unit/export/svg.test.ts +++ b/test/unit/export/svg.test.ts @@ -179,7 +179,11 @@ describe('renderGridFramesToSvg', () => { expect(svg).not.toContain('>\u03b1\u26a1'); }); - it('merges consecutive system-fallback glyphs into one run', () => { + it('positions system-fallback glyphs individually', () => { + // Adjacent system-fallback glyphs can resolve to DIFFERENT system fonts + // per script (e.g. Greek vs Hebrew), so even a same-bucket merged run + // would scale unequal natural advances together. Each system glyph is a + // singleton run pinned via its own x + textLength. const svg = renderGridFramesToSvg({ profile: PROFILE, frames: [makeFrame({ lines: [[cell('\u03b1'), cell('\u03b2')]] })], @@ -187,8 +191,12 @@ describe('renderGridFramesToSvg', () => { }); expect(svg).toContain( - '\u03b1\u03b2', + '\u03b1', + ); + expect(svg).toContain( + '\u03b2', ); + expect(svg).not.toContain('>\u03b1\u03b2'); }); it('merges consecutive same-face symbol glyphs into one run', () => {