diff --git a/src/cli.ts b/src/cli.ts index de3be81..abc17e4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -313,7 +313,8 @@ export function createProgram(): Command { .option('--base-url ', 'override baseURL from config') .option('--headed', 'run browser in headed mode (visible window)') .option('--all', 'run pipeline for all demos in demosDir') - .action(async (demo: string | undefined, cmdOpts: { browser?: string; baseUrl?: string; headed?: boolean; all?: boolean }) => { + .option('--retries ', 'retry the recording test on failure (overrides video.retries)', parseInt) + .action(async (demo: string | undefined, cmdOpts: { browser?: string; baseUrl?: string; headed?: boolean; all?: boolean; retries?: number }) => { const configPath = program.opts().config; const loaded = await ensureTTSEngine(await loadConfigForDemo(demo, configPath)); let config = cmdOpts.browser @@ -325,13 +326,13 @@ export function createProgram(): Command { if (cmdOpts.all) { const demos = (await import('./pipeline.js')).discoverDemos(config.demosDir); - const results = await runBatchPipeline(config, { headed: cmdOpts.headed }); + const results = await runBatchPipeline(config, { headed: cmdOpts.headed, retries: cmdOpts.retries }); if (results.length < demos.length) { process.exitCode = 1; } } else if (demo) { validateDemoName(demo); - await runPipeline(demo, config, { headed: cmdOpts.headed }); + await runPipeline(demo, config, { headed: cmdOpts.headed, retries: cmdOpts.retries }); } else { throw new Error('Provide a demo name or use --all to run all demos.'); } diff --git a/src/config.ts b/src/config.ts index a710ca8..d8466a4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -52,6 +52,12 @@ export interface VideoConfig { /** JPEG quality (0-100) for the screencast frame stream. Higher = larger * intermediates and better stitched video. Default: 95. */ jpegQuality?: number; + /** Number of times Playwright will retry the recording test on failure. + * Default 0 (no retries). Useful for transient browser hiccups (paint stalls, + * intermittent network, race conditions in showOverlay loops). Each retry + * re-runs the entire demo from scratch — for long demos consider chunking + * scenes or using checkpoints (see roadmap v2). CLI override: `--retries N`. */ + retries?: number; } export type FilterTransitionType = 'fade-through-black' | 'dissolve' | 'wipe-left' | 'wipe-right'; diff --git a/src/pipeline.ts b/src/pipeline.ts index a5c80e4..ff65b7c 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -33,6 +33,8 @@ import { export interface PipelineOptions { headed?: boolean; + /** Override `video.retries` for this run. */ + retries?: number; } /** @@ -146,6 +148,7 @@ export async function runPipeline( sceneThumbnails: config.video.sceneThumbnails, captureMode: config.video.captureMode, jpegQuality: config.video.jpegQuality, + retries: pipelineOpts?.retries ?? config.video.retries, headed: pipelineOpts?.headed, }); @@ -388,6 +391,22 @@ export async function runPipeline( writeFileSync(join(argoDir, 'scene-report.json'), JSON.stringify(report, null, 2), 'utf-8'); console.log(formatSceneReport(report)); + // Slow-scene warning — surface scenes that took materially longer than the + // median. Helpful signal when a long demo flakes intermittently: a scene + // that drifts from 6s → 18s on retries is the most likely culprit. + if (report.scenes.length >= 3) { + const durations = report.scenes.map((s) => s.durationMs).sort((a, b) => a - b); + const median = durations[Math.floor(durations.length / 2)]; + const slowThreshold = median * 1.75; + const slow = report.scenes.filter((s) => s.durationMs > slowThreshold); + if (slow.length > 0) { + const lines = slow.map((s) => ` · ${s.scene}: ${(s.durationMs / 1000).toFixed(1)}s (median ${(median / 1000).toFixed(1)}s)`); + console.warn( + `\n⚠ Slow scenes (>${(slowThreshold / 1000).toFixed(1)}s, more than 1.75× median):\n${lines.join('\n')}` + ); + } + } + // Pipeline metadata — provenance tracking for voices, settings, resolution const manifest: Array<{ scene: string; voice?: string; speed?: number }> = (() => { try { return JSON.parse(readFileSync(manifestPath, 'utf-8')); } catch { return []; } @@ -457,6 +476,7 @@ export async function runPipeline( sceneThumbnails: config.video.sceneThumbnails, captureMode: config.video.captureMode, jpegQuality: config.video.jpegQuality, + retries: pipelineOpts?.retries ?? config.video.retries, headed: pipelineOpts?.headed, argoSubdir: variantSubdir, }); diff --git a/src/record.ts b/src/record.ts index f110bf6..7499c1f 100644 --- a/src/record.ts +++ b/src/record.ts @@ -28,6 +28,8 @@ export interface RecordOptions { captureMode?: 'webm' | 'jpeg-stitch'; /** JPEG quality 0-100. Used by jpeg-stitch mode. */ jpegQuality?: number; + /** Playwright test retry count on failure. Default 0. */ + retries?: number; } export interface RecordResult { @@ -64,11 +66,13 @@ function createPlaywrightConfig(demoName: string, options: RecordOptions, output // Recording is driven by `narration.startRecording(page)` which calls // page.screencast.start() at the first scene — no Playwright recordVideo here. + const retries = Math.max(0, Math.floor(options.retries ?? 0)); return `import { defineConfig } from '@playwright/test'; export default defineConfig({ preserveOutput: 'always', outputDir: ${JSON.stringify(outputDir)}, + retries: ${retries}, projects: [ { name: 'demos', @@ -272,7 +276,23 @@ export async function record(demoName: string, options: RecordOptions): Promise< } if (error) { const output = [stdout, stderr].filter(Boolean).join('\n'); - reject(new Error(`Playwright recording failed:\n${output}`)); + // Append the last scene the demo entered before failing — `.scene-progress.jsonl` + // is appended on every narration.mark() so its tail is the failure point. + // Helps users map a Playwright stack to a scene without reading line numbers. + let lastScene = ''; + try { + if (existsSync(progressPath)) { + const tail = readFileSync(progressPath, 'utf-8').trim().split('\n').pop(); + if (tail) { + const parsed = JSON.parse(tail) as { scene?: string }; + if (parsed.scene) lastScene = parsed.scene; + } + } + } catch { /* best-effort — never block on diagnostic parsing */ } + const sceneHint = lastScene + ? `\n\nLast scene entered: '${lastScene}' — failure occurred during this scene or its setup.` + : ''; + reject(new Error(`Playwright recording failed:\n${output}${sceneHint}`)); return; } diff --git a/tests/record.test.ts b/tests/record.test.ts index 2fcc2cc..c040b00 100644 --- a/tests/record.test.ts +++ b/tests/record.test.ts @@ -242,6 +242,44 @@ describe('record', () => { ); }); + it('embeds retries: 0 in the generated playwright config by default', async () => { + mockSubprocessSuccess(); + await record('demo', { + demosDir: 'custom-demos', + baseURL: 'http://localhost:3000', + video: { width: 1280, height: 720 }, + browser: 'chromium', + }); + const configPath = join(tempDir, '.argo', 'demo', 'playwright.record.config.mjs'); + expect(readFileSync(configPath, 'utf-8')).toContain('retries: 0'); + }); + + it('embeds the requested retries count in the generated playwright config', async () => { + mockSubprocessSuccess(); + await record('demo', { + demosDir: 'custom-demos', + baseURL: 'http://localhost:3000', + video: { width: 1280, height: 720 }, + browser: 'chromium', + retries: 2, + }); + const configPath = join(tempDir, '.argo', 'demo', 'playwright.record.config.mjs'); + expect(readFileSync(configPath, 'utf-8')).toContain('retries: 2'); + }); + + it('clamps negative retries to 0 and floors fractional retries', async () => { + mockSubprocessSuccess(); + await record('demo', { + demosDir: 'custom-demos', + baseURL: 'http://localhost:3000', + video: { width: 1280, height: 720 }, + browser: 'chromium', + retries: 2.7, + }); + const configPath = join(tempDir, '.argo', 'demo', 'playwright.record.config.mjs'); + expect(readFileSync(configPath, 'utf-8')).toContain('retries: 2'); + }); + it('includes isMobile, hasTouch, and contextOptions in generated config', async () => { mockSubprocessSuccess();