diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c8fd4a..6556239 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,10 @@ jobs: run: npm run verify:capture-host - name: Terminal and Browser runtime smoke timeout-minutes: 15 + env: + # PRs verify the complete host path with a short fixture; main keeps + # the full 720p render for release confidence. + SEQVIO_CAPTURE_SMOKE_PROFILE: ${{ github.event_name == 'pull_request' && 'quick' || 'full' }} run: npm run smoke:capture-host-runtime build-test: @@ -92,4 +96,7 @@ jobs: - name: Release pipeline smoke (terminal + browser) timeout-minutes: 15 + env: + # Keep the PR gate fast while main/release retain the full render. + SEQVIO_RELEASE_SMOKE_PROFILE: ${{ github.event_name == 'pull_request' && 'quick' || 'full' }} run: npm run smoke:release-pipeline diff --git a/packages/terminal-narrator/src/sample.ts b/packages/terminal-narrator/src/sample.ts index c58f7de..05ad6b8 100644 --- a/packages/terminal-narrator/src/sample.ts +++ b/packages/terminal-narrator/src/sample.ts @@ -3,12 +3,13 @@ import type { TerminalNarratorPlan } from './types'; export function samplePlan(): TerminalNarratorPlan { // PowerShell emits VT color sequences on modern Windows — better VHS demo than cmd echo. const isWin = process.platform === 'win32'; + const quickSmoke = process.env.SEQVIO_CAPTURE_SMOKE_PROFILE === 'quick'; return { version: '1.0', name: 'VHS-style terminal demo', - viewport: { width: 1280, height: 720 }, - renderFps: 30, - maxLines: 220, + viewport: quickSmoke ? { width: 640, height: 360 } : { width: 1280, height: 720 }, + renderFps: quickSmoke ? 10 : 30, + maxLines: quickSmoke ? 80 : 220, presentation: 'vhs', typeDelayMs: 28, startupWaitMs: 5000, @@ -27,7 +28,7 @@ export function samplePlan(): TerminalNarratorPlan { rows: 28, cwd: process.cwd(), }, - inputs: isWin + inputs: (isWin ? [ { id: 'hello', @@ -70,9 +71,9 @@ export function samplePlan(): TerminalNarratorPlan { text: "printf '\\033[35mDone\\033[0m\\n'", afterMs: 700, }, - ], - finalWaitMs: 1400, - timeoutMs: 45_000, + ]).slice(0, quickSmoke ? 1 : 3), + finalWaitMs: quickSmoke ? 250 : 1400, + timeoutMs: quickSmoke ? 20_000 : 45_000, readyPattern: isWin ? 'PS [^\\r\\n>]*>' : undefined, }; } diff --git a/packages/terminal-narrator/tests/sample.test.mjs b/packages/terminal-narrator/tests/sample.test.mjs index 838fa2c..a094f02 100644 --- a/packages/terminal-narrator/tests/sample.test.mjs +++ b/packages/terminal-narrator/tests/sample.test.mjs @@ -16,6 +16,27 @@ test('Windows sample uses the platform PTY default and waits for observed output } }); +test('samplePlan has an explicit short CI profile without changing the default', () => { + const previous = process.env.SEQVIO_CAPTURE_SMOKE_PROFILE; + process.env.SEQVIO_CAPTURE_SMOKE_PROFILE = 'quick'; + try { + const quick = samplePlan(); + assert.deepEqual(quick.viewport, { width: 640, height: 360 }); + assert.equal(quick.renderFps, 10); + assert.equal(quick.startupWaitMs, 5000); + assert.equal(quick.typeDelayMs, 28); + assert.equal(quick.finalWaitMs, 250); + assert.equal(quick.inputs.length, 1); + } finally { + if (previous === undefined) delete process.env.SEQVIO_CAPTURE_SMOKE_PROFILE; + else process.env.SEQVIO_CAPTURE_SMOKE_PROFILE = previous; + } + const full = samplePlan(); + assert.deepEqual(full.viewport, { width: 1280, height: 720 }); + assert.equal(full.renderFps, 30); + assert.equal(full.inputs.length, 3); +}); + test('sampleClaudePlan uses the shared Claude executable resolver', () => { const explicit = sampleClaudePlan({ claudeBin: 'custom-claude' }); assert.equal(explicit.shell.command, 'custom-claude'); diff --git a/scripts/capture-host-runtime-smoke.mjs b/scripts/capture-host-runtime-smoke.mjs index f22408c..ed734e3 100644 --- a/scripts/capture-host-runtime-smoke.mjs +++ b/scripts/capture-host-runtime-smoke.mjs @@ -4,9 +4,13 @@ import * as http from 'node:http'; import * as os from 'node:os'; import * as path from 'node:path'; -function runNode(args) { +function runNode(args, env = {}) { return new Promise((resolve, reject) => { - const child = spawn(process.execPath, args, { cwd: process.cwd(), stdio: 'inherit' }); + const child = spawn(process.execPath, args, { + cwd: process.cwd(), + stdio: 'inherit', + env: { ...process.env, ...env }, + }); child.once('error', reject); child.once('exit', (code) => code === 0 ? resolve() @@ -35,6 +39,8 @@ const requestedKind = process.argv.includes('--kind') if (!['all', 'terminal', 'browser'].includes(requestedKind)) { throw new Error('--kind must be all, terminal, or browser'); } +const quick = process.env.SEQVIO_CAPTURE_SMOKE_PROFILE === 'quick'; +const smokeViewport = quick ? { width: 640, height: 360 } : { width: 1280, height: 720 }; let succeeded = false; const server = http.createServer((_request, response) => { response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); @@ -54,11 +60,11 @@ try { version: '1.0', name: 'Host runtime smoke', startUrl: `http://127.0.0.1:${address.port}`, - viewport: { width: 1280, height: 720 }, - captureFps: 10, - renderFps: 30, + viewport: smokeViewport, + captureFps: quick ? 5 : 10, + renderFps: quick ? 10 : 30, actions: [ - { id: 'run', type: 'click', label: 'Run', selector: '#run', afterMs: 250 }, + { id: 'run', type: 'click', label: 'Run', selector: '#run', afterMs: quick ? 50 : 250 }, ], }, null, 2)}\n`, 'utf8'); @@ -66,7 +72,7 @@ try { await runNode([ 'packages/terminal-narrator/dist/cli.js', 'record', '--sample', '--outputDir', tempRoot, '--jobId', 'terminal', '--json', - ]); + ], quick ? { SEQVIO_CAPTURE_SMOKE_PROFILE: 'quick' } : {}); assertComplete(path.join(tempRoot, 'terminal')); } if (requestedKind === 'all' || requestedKind === 'browser') { diff --git a/scripts/release-pipeline-smoke.mjs b/scripts/release-pipeline-smoke.mjs index 4a6c158..9d14f37 100644 --- a/scripts/release-pipeline-smoke.mjs +++ b/scripts/release-pipeline-smoke.mjs @@ -24,7 +24,8 @@ import { const execFileAsync = promisify(execFile); const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const fps = 30; +const quick = process.env.SEQVIO_RELEASE_SMOKE_PROFILE === 'quick'; +const fps = numericArgument('fps', quick ? 10 : 30); function numericArgument(name, fallback) { const index = process.argv.indexOf(`--${name}`); @@ -34,8 +35,8 @@ function numericArgument(name, fallback) { return Math.round(value); } -const width = numericArgument('width', 1280); -const height = numericArgument('height', 720); +const width = numericArgument('width', quick ? 640 : 1280); +const height = numericArgument('height', quick ? 360 : 720); function requestedKinds() { const kindIndex = process.argv.indexOf('--kind'); @@ -132,7 +133,7 @@ async function createCapture(kind, workDir) { manifest: { kind: 'terminal', name: 'terminal-release-smoke', - durationMs: 1200, + durationMs: quick ? 700 : 1200, viewport: { width, height }, renderFps: fps, cols: terminalCols, @@ -140,9 +141,9 @@ async function createCapture(kind, workDir) { renderOptions: { title: 'Release checks' }, events: [ { timeMs: 0, kind: 'stdout', text: '$ ', transient: false }, - { timeMs: 180, kind: 'stdin', text: 'printf release-ok', transient: true }, + { timeMs: quick ? 100 : 180, kind: 'stdin', text: 'printf release-ok', transient: true }, { - timeMs: 700, + timeMs: quick ? 450 : 700, kind: 'stdout', text: 'printf release-ok\r\nrelease-ok\r\n$ ', transient: false, @@ -152,13 +153,13 @@ async function createCapture(kind, workDir) { { id: 'command', label: 'Run the release check', - timeMs: 180, + timeMs: quick ? 100 : 180, capturedState: { kind: 'terminal', stdout: 'release-ok' }, }, { id: 'result', label: 'Confirm the command output', - timeMs: 700, + timeMs: quick ? 450 : 700, capturedState: { kind: 'terminal', stdout: 'release-ok' }, }, ], @@ -230,8 +231,8 @@ async function createCapture(kind, workDir) { } async function createResolvedAudio(sourceManifest, workDir) { - const durationsMs = [1500, 1550]; - const gapMs = 180; + const durationsMs = quick ? [450, 500] : [1500, 1550]; + const gapMs = quick ? 50 : 180; const tracks = []; const synthesizedNarration = []; let cursorMs = 0; @@ -266,7 +267,7 @@ async function createResolvedAudio(sourceManifest, workDir) { sourceManifest, synthesizedNarration, fps, - { cueGapMs: gapMs, sceneTailMs: 600 }, + { cueGapMs: gapMs, sceneTailMs: quick ? 150 : 600 }, ); return { ...sourceManifest, @@ -326,7 +327,12 @@ async function runPipeline(kind, smokeTempRoot, artifactDir) { '--width', String(width), '--height', String(height), '--fps', String(fps), - '--frames', `0,30,90,${Math.max(0, resolvedManifest.duration - 1)}`, + '--frames', [ + 0, + Math.round(fps * 0.5), + Math.round(fps * 1.5), + Math.max(0, resolvedManifest.duration - 1), + ].filter((frame, index, frames) => frame < resolvedManifest.duration && frames.indexOf(frame) === index).join(','), '--pixelRatio', '1', '--ci', ]);