Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,8 @@ export function createProgram(): Command {
.option('--base-url <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 <count>', '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
Expand All @@ -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.');
}
Expand Down
6 changes: 6 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
20 changes: 20 additions & 0 deletions src/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import {

export interface PipelineOptions {
headed?: boolean;
/** Override `video.retries` for this run. */
retries?: number;
}

/**
Expand Down Expand Up @@ -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,
});

Expand Down Expand Up @@ -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 []; }
Expand Down Expand Up @@ -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,
});
Expand Down
22 changes: 21 additions & 1 deletion src/record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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;
}

Expand Down
38 changes: 38 additions & 0 deletions tests/record.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Loading