diff --git a/.gitignore b/.gitignore index 9a5aced..4d60e70 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,9 @@ coverage # nyc test coverage .nyc_output +# twd-cli recording artifacts +twd-artifacts + # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) .grunt @@ -137,3 +140,6 @@ dist # Vite logs files vite.config.js.timestamp-* vite.config.ts.timestamp-* + +# Superpowers SDD scratch workspace +.superpowers/ diff --git a/CLAUDE.md b/CLAUDE.md index e7e8200..92d9e25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,30 +16,53 @@ twd-cli is a CLI tool for running TWD (Test While Developing) browser-based test ## Architecture -The codebase is a small ESM-only Node.js CLI with two core source files: +The codebase is a small ESM-only Node.js CLI. `bin/twd-cli.js` and `src/index.js` are the spine; every other file in `src/` is a single-purpose helper with a matching `tests/*.test.js`. -**`bin/twd-cli.js`** — CLI entry point. Parses `process.argv` for the `run` command, calls `runTests()`, and exits with code 0 (pass) or 1 (failure). +**`bin/twd-cli.js`**: CLI entry point. Parses `process.argv` for the `run` command via `src/parseArgs.js`, calls `runTests()`, and exits with code 0 (pass) or 1 (failure). -**`src/config.js`** — `loadConfig()` reads `twd.config.json` from `process.cwd()`, merges it with defaults (url, timeout, coverage, headless, puppeteerArgs, retryCount, protocolTimeout, maxFailures, chunkSize), and returns the merged config. Falls back to defaults if the file is missing or unparseable. +**`src/parseArgs.js`**: `parseRunArgs(argv)` returns `{ testFilters, record }`. Supports `--test` (repeatable substring filter) and the recording flags `--record`, `--record-dir`, `--record-speed`. Each accepts both `--flag value` and `--flag=value`. The returned `record` object is passed to `runTests()` as `recordOverrides` and wins over the config file. + +**`src/config.js`**: `loadConfig()` reads `twd.config.json` from `process.cwd()`, merges it with defaults (url, timeout, coverage, coverageDir, nycOutputDir, headless, puppeteerArgs, retryCount, protocolTimeout, maxFailures, chunkSize, record), and returns the merged config. Falls back to defaults if the file is missing or unparseable. `protocolTimeout` (default `300000`, 5 min) is passed to `puppeteer.launch` and bounds each chunk's CDP call. `maxFailures` (default `10`) stops the run after that many cumulative test failures; set to `0` to disable. `chunkSize` (default `10`) controls how many tests run per browser call. -**`src/index.js`** — `runTests()` is the main orchestrator: -1. Loads config via `loadConfig()` -2. Launches Puppeteer with configured headless mode and args -3. Navigates to the configured URL (default: `http://localhost:5173`) -4. Waits for `#twd-sidebar-root` selector (indicates app + TWD are ready) -5. Enumerates all registered test handlers and computes pre-order execution order -6. Runs tests in ordered chunks via `runByIds(chunkIds)`, with chunk size controlled by config; accumulates results in Node so the run can stop after `maxFailures` failures and partial results survive a timeout or crash -7. Prints a relay-style summary block (`formatRunComplete` in `src/testSummary.js`) as the last output: passed/failed/skipped counts, duration, failed tests with `suite > test` paths and error messages, retried tests, and "Not run" count if stopped early. Known infrastructure errors (dev server down, sidebar missing, protocol timeout, Chrome launch failure) get actionable diagnostics from `src/diagnostics.js`. -8. Optionally collects `window.__coverage__` and writes to `.nyc_output/out.json` (skipped whenever the run has failures, including an early bail) -9. Returns boolean `hasFailures` +`record` (`DEFAULT_RECORD`) is the only **nested** config key, so the merge goes two levels deep: `record` merges over `DEFAULT_RECORD`, and `record.viewport` merges over the default viewport. A flat spread would wipe sibling defaults. Recording is off by default and never runs unless explicitly requested. + +**`src/index.js`**: `runTests({ testFilters, recordOverrides })` is the main orchestrator: +1. Loads config via `loadConfig()`, then overlays `recordOverrides` onto a **copy** of `config.record` (never mutate it, it can be the shared `DEFAULT_RECORD` object) +2. Probes for ffmpeg via `assertFfmpegAvailable()` when recording, before anything expensive, so a missing binary fails fast instead of after launch and navigation +3. Launches Puppeteer with configured headless mode and args +4. `page.setViewport(record.viewport)` when recording (a normal run keeps Puppeteer's implicit 800x600) +5. Navigates to the configured URL (default: `http://localhost:5173`) +6. Waits for `#twd-sidebar-root` selector (indicates app + TWD are ready) +7. Injects the framing stylesheet when recording, hiding the sidebar and resetting the html margin twd-js sets inline +8. Enumerates all registered test handlers and computes pre-order execution order +9. Resolves `--test` filters into the id list to run +10. Starts the screencast when recording. This happens **after** filter resolution, because `page.screencast()` fixes the output path up front and the filename is derived from the tests that survived the filter (`src/recordFilename.js`) +11. Runs tests in ordered chunks via `runByIds(chunkIds)`, with chunk size controlled by config; accumulates results in Node so the run can stop after `maxFailures` failures and partial results survive a timeout or crash +12. Stops the recorder, then reports the artifact, but only after checking the file has bytes on disk. A resolved `stop()` is not evidence of a usable video (see the recording notes below) +13. Prints a relay-style summary block (`formatRunComplete` in `src/testSummary.js`) as the last output: passed/failed/skipped counts, duration, failed tests with `suite > test` paths and error messages, retried tests, and "Not run" count if stopped early. Known infrastructure errors (dev server down, sidebar missing, protocol timeout, Chrome launch failure) get actionable diagnostics from `src/diagnostics.js`. +14. Optionally collects `window.__coverage__` and writes to `.nyc_output/out.json` (skipped whenever the run has failures, including an early bail) +15. Returns boolean `hasFailures` + +**`src/recorder.js`** holds the screencast wrapper: `assertFfmpegAvailable()` (pre-flight `spawnSync(ffmpegPath, ['-version'])` probe), `FRAMING_CSS` / `applyRecordingFraming()`, and `startRecording()` which creates the output dir and calls `page.screencast()`. + +### Recording gotchas + +These are load-bearing and easy to undo by accident: + +- **`stopRecorder()` must run before `browser.close()` on both the success and `catch` paths, and at most once.** If the browser closes first, ffmpeg is orphaned and the file is truncated. The closure nulls `recorder` before awaiting, so a throw between the success-path stop and `browser.close()` cannot double-stop. +- **`record.ffmpegPath` has to reach `page.screencast()`, not just the probe.** Puppeteer spawns its own ffmpeg and defaults to a bare `ffmpeg` on PATH, so forwarding only to the probe produces a passing pre-flight followed by a raw `spawnSync ffmpeg ENOENT`. +- **A 0-byte output is a normal outcome, not a crash.** Puppeteer's frame pipeline buffers with `bufferCount(2, 1)` and Chrome only emits screencast frames on a compositor update, so a suite that never repaints (or an empty run) finishes cleanly with an empty file. `recordedFileSize()` gates the success line on real bytes. +- **`viewport.deviceScaleFactor` does not affect the video.** Puppeteer measures the recording with `deviceScaleFactor` forced to 0, so the emulated factor never reaches the encoder, but it *is* live on the page during the run. Default is `1`. Puppeteer's actual output-size knob is `scale`, which this feature does not expose. **`test-example-app/`** — A React demo app with TWD tests integrated, used for manual testing/demonstration. Not part of the published package or test suite. ## Testing -Tests are in `tests/` and use vitest. The test suite mocks `fs` to test config loading and mocks Puppeteer to test the run flow. Coverage is configured for `src/**/*.js` only. +Tests are in `tests/` and use vitest, one file per `src/` module. The suite mocks `fs` to test config loading and mocks Puppeteer to test the run flow. Coverage is configured for `src/**/*.js` only. + +No test may require a real ffmpeg binary or a real browser: `node:child_process` and `page.screencast` are always mocked. Note that `vi.mock('fs')` auto-mocks `fs.statSync` to return `undefined`, so anything reading a `Stats` has to tolerate that. ## Key Dependencies diff --git a/README.md b/README.md index eded0dc..0b6048d 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,40 @@ Notes: - Code coverage collection is skipped while a `--test` filter is active, since a filtered run is a partial (debug) run. +### Recording a run + +Record a test run to a video file, for a PR attachment, a docs clip, or a demo: + +```bash +# Record one flow +npx twd-cli run --record --test "checkout flow" + +# Record at half speed, into a custom directory +npx twd-cli run --record --record-speed 0.5 --record-dir ./clips +``` + +Requires ffmpeg. See [Requirements](#requirements). + +The run produces a single video containing every matched test, back to back, in +declaration order. Note that `--test` matches a substring of the full +`"suite > test"` path, so one filter can match several tests. + +The file is named after what is in it: a single recorded test gets a slug of its +full path (`login-shows-error-on-bad-password.mp4`), and anything else gets +`run.`, where `` comes from `format` (`mp4` by default, or `webm`/`gif` +if you set that). Re-running overwrites the file. + +The TWD sidebar is hidden during recording so the frame is just your app. + +Chrome only emits video frames when the page repaints, so a suite that only +asserts and never changes anything on screen can finish with an empty file. When +that happens the run says so rather than reporting a video you cannot play. + +**A recorded run is a demo artifact, not a substitute for a CI run.** Recording +sets its own viewport (1280x720 by default, versus the 800x600 a normal run +uses) and reflows the app to full width, so a recorded run can pass or fail +differently. Run CI normally and record separately. + ### Configuration Create a `twd.config.json` file in your project root: @@ -86,9 +120,55 @@ Create a `twd.config.json` file in your project root: | `chunkSize` | number | `10` | How many tests run per browser call. Smaller values make the failure limit and timeouts more granular (less work lost if one chunk hangs); larger values reduce overhead. `0` runs everything in one call | | `contracts` | array | — | OpenAPI contract validation specs (see [Contract Validation](#contract-validation)) | | `contractReportPath` | string | — | Path to write a markdown report for CI/PR integration | +| `record` | object | see below | Video recording settings (see [Recording a run](#recording-a-run)) | **Partial Results on Timeout or Crash:** Tests run in chunks (controlled by `chunkSize`), so on a `protocolTimeout` or unexpected crash mid-run, results from completed chunks are printed instead of being lost entirely. +#### Recording Options + +All keys live under `record` in `twd.config.json`. + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `enabled` | boolean | `false` | Turn recording on. Equivalent to passing `--record` | +| `dir` | string | `"./twd-artifacts"` | Directory the video is written to | +| `filename` | string \| null | `null` | Explicit output filename. When `null`, the name is derived from the recorded tests. A known extension (`.mp4`, `.webm`, `.gif`) is respected, otherwise `format` supplies it | +| `format` | string | `"mp4"` | `"mp4"`, `"webm"` or `"gif"`. All three are encoded natively, no conversion step | +| `viewport` | object | `{ "width": 1280, "height": 720, "deviceScaleFactor": 1 }` | Applied only when recording. `width` and `height` set the video dimensions. `deviceScaleFactor` does **not** change the output resolution (Puppeteer measures the recording in CSS pixels), it only changes the page environment under test: raising it makes `srcset` and `image-set` pick 2x assets and sends dpr-branching code down a different path | +| `fps` | number | `30` | Capture frame rate | +| `speed` | number | `1` | Playback speed, e.g. `0.5` for half speed. This is a **uniform stretch of the whole timeline**, not per-command pacing: it slows the fast parts and the already-slow parts equally and cannot hold on a just-clicked element | +| `preRoll` | number | `0` | Milliseconds to hold the opening state before the first test runs. Purely cosmetic | +| `postRoll` | number | `500` | Milliseconds to hold the final state after the last test. **Not cosmetic:** without it the last thing your test did never appears in the video at all. See [Why the ending needs a hold](#why-the-ending-needs-a-hold). Set `0` only if you do not care about the ending | +| `hideSidebar` | boolean | `true` | Hide the TWD sidebar during capture so the frame is just your app | +| `ffmpegPath` | string | `"ffmpeg"` | Path to the ffmpeg binary if it is not on your `PATH` | + +#### Why the ending needs a hold + +Chrome only sends a video frame when the page repaints, and Puppeteer holds each +frame until the *next* one arrives, because the next frame's timestamp is what +says how long to display the current one. The newest frame is therefore never +written, and stopping the recorder pads the tail by repeating the one before it. + +A settled page produces no more repaints, so simply waiting does not help. +Measured against real Chrome: stopping immediately ended two states early, and a +400ms plain wait still ended one state early. + +`postRoll` fixes this by briefly repainting the whole viewport with an invisible +overlay after the last test, which forces the real final frame through and then +holds it. This is why it defaults to on. + +#### Making the video longer + +`postRoll` fixes the *ending*, not the *pace*. Tests run in milliseconds, so a +two-test run is around a second of video. Two things help today: + +- `record.speed` (or `--record-speed 0.5`) stretches the whole timeline +- `record.preRoll` and `record.postRoll` stop it starting and ending abruptly + +Both are blunt. Per-command pacing, where the video dwells on each click and +assertion, has to happen inside `twd-js` because that is where the command loop +lives, and it is not part of this feature yet. + ## How It Works **Important**: Puppeteer is **not** used as a testing framework here. It simply provides a headless browser to load your application — the same way a user would open Chrome. Once the page loads, all test execution happens inside the real browser context through the [TWD runner](https://brikev.github.io/twd/). Your tests interact with real DOM, real components, and real browser APIs — Puppeteer just opens the door and gets out of the way. @@ -302,3 +382,6 @@ Failed validations are included in a collapsible details section with a link to - Node.js >= 20.19.x - A running development server with TWD tests +- ffmpeg, only for `--record`. Install with `brew install ffmpeg` (macOS), + `sudo apt-get install ffmpeg` (Linux), or `winget install ffmpeg` (Windows). + Set `record.ffmpegPath` if it is not on your `PATH`. diff --git a/bin/twd-cli.js b/bin/twd-cli.js index 9d6da54..cd81e16 100755 --- a/bin/twd-cli.js +++ b/bin/twd-cli.js @@ -7,8 +7,8 @@ const command = process.argv[2]; if (command === 'run') { try { - const { testFilters } = parseRunArgs(process.argv.slice(3)); - const hasFailures = await runTests({ testFilters }); + const { testFilters, record } = parseRunArgs(process.argv.slice(3)); + const hasFailures = await runTests({ testFilters, recordOverrides: record }); process.exit(hasFailures ? 1 : 0); } catch (error) { if (!error?.reported) { @@ -25,12 +25,21 @@ Usage: npx twd-cli run --test "" Run only tests whose "suite > test" path contains (case-insensitive). Repeatable; multiple --test values are OR'd. + npx twd-cli run --record Record the run to a video file Examples: npx twd-cli run --test "shows error" npx twd-cli run --test "Login" --test "Signup" Options: + --test "" Filter tests by "suite > test" path (repeatable, OR'd) + --record Record the run to a video file (requires ffmpeg) + --record-dir Output directory (default ./twd-artifacts) + --record-speed Playback speed, e.g. 0.5 for half speed + + --record-dir and --record-speed only set values. Recording still has to be + turned on with --record or "record": { "enabled": true } in twd.config.json. + Create a twd.config.json file in your project root to customize settings. `); process.exit(command ? 1 : 0); diff --git a/docs/superpowers/plans/2026-07-27-cli-video-recording.md b/docs/superpowers/plans/2026-07-27-cli-video-recording.md new file mode 100644 index 0000000..2fd57d3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-cli-video-recording.md @@ -0,0 +1,1195 @@ +# twd-cli Video Recording Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `npx twd-cli run --record` so a scoped test run produces a shareable video file. + +**Architecture:** All changes are inside `twd-cli`. Two new pure-ish modules (`src/recordFilename.js` for artifact naming, `src/recorder.js` for the Puppeteer and ffmpeg surface) plus wiring into the existing `runTests` lifecycle in `src/index.js`. Recording is opt-in and every recording-only side effect is guarded, so non-recording runs behave exactly as they do today. + +**Tech Stack:** Node ESM, Puppeteer `^25.3.0` (`page.screencast`, `page.setViewport`, `page.addStyleTag`), ffmpeg (external binary), Vitest. + +## Global Constraints + +- Repo is `/Users/kevinccbsg/brikev/twd-cli`. Do not modify the `twd` repo. +- Work on branch `docs/cli-video-recording-spec` (already checked out) or a branch off it. +- Node ESM only. Every import needs the `.js` extension. +- No test may require a real ffmpeg binary. `spawnSync` and `page.screencast` are always mocked. +- Recording defaults to OFF. Every recording-only call (`setViewport`, `addStyleTag`, `screencast`, the ffmpeg probe) must be guarded by `config.record?.enabled`. Use optional chaining, because existing tests build config objects with no `record` key at all. +- Follow the existing test convention: one test file per source module in `tests/`, Vitest, `vi.mock` at module top level. +- Conventional Commits for every commit. +- Do not use em-dashes in any file you write. +- Config defaults, copied verbatim from the spec: + ```json + { + "enabled": false, + "dir": "./twd-artifacts", + "filename": null, + "format": "mp4", + "viewport": { "width": 1280, "height": 720, "deviceScaleFactor": 2 }, + "fps": 30, + "speed": 1, + "hideSidebar": true, + "ffmpegPath": "ffmpeg" + } + ``` + +## File Structure + +| File | Responsibility | +|---|---| +| `src/recordFilename.js` (new) | Pure. Slugify a test path, resolve the output filename. | +| `src/recorder.js` (new) | All ffmpeg and Puppeteer recording calls: probe, framing CSS, start, stop. | +| `src/config.js` (modify) | `record` defaults plus a two-level merge for that key. | +| `src/parseArgs.js` (modify) | Parse `--record`, `--record-dir`, `--record-speed`. | +| `src/index.js` (modify) | Lifecycle wiring and teardown. | +| `bin/twd-cli.js` (modify) | Help text. | +| `README.md`, `twd.config.example.json`, `.gitignore` (modify) | Docs and defaults. | + +## Corrected lifecycle + +The spec lists `screencast()` before handler enumeration. That is wrong: the filename depends on how many tests will run, which is only known once `baseIds` is resolved. Correct order, keyed to `src/index.js`: + +1. Probe ffmpeg (recording only), before `puppeteer.launch()` +2. `puppeteer.launch()` +3. `browser.newPage()` +4. `page.setViewport(record.viewport)` (recording only) +5. `page.goto(config.url)` +6. `page.waitForSelector('#twd-sidebar-root')` +7. `page.addStyleTag(FRAMING_CSS)` (recording only, and only if `hideSidebar`) +8. Enumerate handlers +9. Resolve `--test` filters into `baseIds` +10. Compute filename, create `record.dir`, `page.screencast()` (recording only) +11. Run chunks +12. `recorder.stop()` +13. Coverage and contract handling (unchanged) +14. `browser.close()` + +Starting at step 10 means the clip begins at the first test rather than at page load, and the "no tests matched" early return at `src/index.js:83` can never leave a recorder running. + +--- + +### Task 1: Record config defaults and nested merge + +`loadConfig` currently does a flat `{ ...DEFAULT_CONFIG, ...userConfig }`. A nested `record` key would be replaced wholesale rather than merged, so `{"record": {"format": "webm"}}` would silently lose every other record default. `record.viewport` is nested one level deeper and needs the same treatment. + +**Files:** +- Modify: `src/config.js` +- Test: `tests/config.test.js` + +**Interfaces:** +- Consumes: nothing. +- Produces: `DEFAULT_RECORD` (exported object with the nine keys from Global Constraints). `loadConfig()` return value gains a `record` object that is always fully populated. + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/config.test.js`, inside the existing `describe('loadConfig', ...)`: + +```js + it('includes fully populated record defaults when no config file exists', () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + expect(loadConfig().record).toEqual({ + enabled: false, + dir: './twd-artifacts', + filename: null, + format: 'mp4', + viewport: { width: 1280, height: 720, deviceScaleFactor: 2 }, + fps: 30, + speed: 1, + hideSidebar: true, + ffmpegPath: 'ffmpeg', + }); + }); + + it('merges a partial record block instead of replacing it', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({ record: { enabled: true, format: 'webm' } }) + ); + + const { record } = loadConfig(); + + expect(record.enabled).toBe(true); + expect(record.format).toBe('webm'); + // every other default must survive + expect(record.dir).toBe('./twd-artifacts'); + expect(record.fps).toBe(30); + expect(record.speed).toBe(1); + expect(record.hideSidebar).toBe(true); + expect(record.ffmpegPath).toBe('ffmpeg'); + expect(record.viewport).toEqual({ width: 1280, height: 720, deviceScaleFactor: 2 }); + }); + + it('merges a partial record.viewport instead of replacing it', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({ record: { viewport: { width: 1920 } } }) + ); + + expect(loadConfig().record.viewport).toEqual({ + width: 1920, + height: 720, + deviceScaleFactor: 2, + }); + }); + + it('leaves record at defaults when the user config omits it', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ headless: false })); + + const config = loadConfig(); + + expect(config.headless).toBe(false); + expect(config.record.enabled).toBe(false); + expect(config.record.format).toBe('mp4'); + }); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest --run tests/config.test.js` +Expected: the four new tests FAIL because `config.record` is `undefined`. + +Four *existing* tests will also fail once Step 3 lands, because they assert the whole config object with `toEqual`. Step 4 fixes those. Do not fix them yet. + +- [ ] **Step 3: Implement** + +In `src/config.js`, add the exported default above `DEFAULT_CONFIG`, add `record` to `DEFAULT_CONFIG`, and replace the merge: + +```js +export const DEFAULT_RECORD = { + enabled: false, + dir: './twd-artifacts', + filename: null, + format: 'mp4', + viewport: { width: 1280, height: 720, deviceScaleFactor: 2 }, + fps: 30, + speed: 1, + hideSidebar: true, + ffmpegPath: 'ffmpeg', +}; + +const DEFAULT_CONFIG = { + url: 'http://localhost:5173', + timeout: 10000, + coverage: true, + coverageDir: './coverage', + nycOutputDir: './.nyc_output', + headless: true, + puppeteerArgs: ['--no-sandbox', '--disable-setuid-sandbox'], + retryCount: 2, + protocolTimeout: 300000, + maxFailures: 10, + chunkSize: 10, + record: DEFAULT_RECORD, +}; +``` + +Then, inside the `try` block, replace `return { ...DEFAULT_CONFIG, ...userConfig };` with: + +```js + const userRecord = userConfig.record || {}; + return { + ...DEFAULT_CONFIG, + ...userConfig, + record: { + ...DEFAULT_RECORD, + ...userRecord, + viewport: { ...DEFAULT_RECORD.viewport, ...(userRecord.viewport || {}) }, + }, + }; +``` + +Leave the two `return DEFAULT_CONFIG;` paths (no file, invalid JSON) alone. They already carry the full defaults. + +- [ ] **Step 4: Update the four existing whole-object assertions** + +These four tests in `tests/config.test.js` compare the entire config with `toEqual` and now need the `record` key. Add `record: DEFAULT_RECORD,` as the last property of the expected object in each: + +1. `'should load default config when no config file exists'` +2. `'should merge user config with defaults when config file exists'` +3. `'should return defaults and warn when config file has invalid JSON'` + +And in `'should override all default values when user provides full config'`, the assertion is `expect(config).toEqual(userConfig)`. Change it to: + +```js + expect(config).toEqual({ ...userConfig, record: DEFAULT_RECORD }); +``` + +Add `DEFAULT_RECORD` to the import at the top of the file: + +```js +import { loadConfig, DEFAULT_RECORD } from '../src/config.js'; +``` + +- [ ] **Step 5: Run the full config suite** + +Run: `npx vitest --run tests/config.test.js` +Expected: PASS, all tests. + +- [ ] **Step 6: Commit** + +```bash +git add src/config.js tests/config.test.js +git commit -m "feat(config): add record defaults with nested merge" +``` + +--- + +### Task 2: Parse the record CLI flags + +`parseRunArgs` currently returns `{ testFilters }`. It gains a `record` object holding only the keys the user actually set, so config-file values survive for everything else. + +**Files:** +- Modify: `src/parseArgs.js` +- Test: `tests/parseArgs.test.js` + +**Interfaces:** +- Consumes: nothing. +- Produces: `parseRunArgs(argv)` returns `{ testFilters: string[], record: object }`. `record` is `{}` when no record flags are present, and may contain `enabled: true`, `dir: string`, `speed: number`. + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/parseArgs.test.js` inside the existing `describe`: + +```js + it("returns an empty record object when no record flags are present", () => { + expect(parseRunArgs(['--test', 'Login']).record).toEqual({}); + }); + + it("parses --record", () => { + expect(parseRunArgs(['--record']).record).toEqual({ enabled: true }); + }); + + it("parses --record-dir and the = form", () => { + expect(parseRunArgs(['--record-dir', './clips']).record).toEqual({ dir: './clips' }); + expect(parseRunArgs(['--record-dir=./clips']).record).toEqual({ dir: './clips' }); + }); + + it("parses --record-speed as a number, both forms", () => { + expect(parseRunArgs(['--record-speed', '0.5']).record).toEqual({ speed: 0.5 }); + expect(parseRunArgs(['--record-speed=2']).record).toEqual({ speed: 2 }); + }); + + it("ignores a non-numeric or non-positive --record-speed", () => { + expect(parseRunArgs(['--record-speed', 'slow']).record).toEqual({}); + expect(parseRunArgs(['--record-speed', '0']).record).toEqual({}); + expect(parseRunArgs(['--record-speed', '-1']).record).toEqual({}); + }); + + it("ignores trailing record flags with no value", () => { + expect(parseRunArgs(['--record-dir']).record).toEqual({}); + expect(parseRunArgs(['--record-speed']).record).toEqual({}); + }); + + it("combines record flags with --test filters", () => { + expect(parseRunArgs(['--record', '--test', 'checkout', '--record-speed=0.5'])).toEqual({ + testFilters: ['checkout'], + record: { enabled: true, speed: 0.5 }, + }); + }); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest --run tests/parseArgs.test.js` +Expected: the new tests FAIL because `record` is `undefined`. + +- [ ] **Step 3: Implement** + +Replace the whole body of `src/parseArgs.js`: + +```js +export function parseRunArgs(argv) { + const testFilters = []; + const record = {}; + + const readValue = (token, prefix, index) => { + if (token === prefix) { + return { value: argv[index + 1], consumed: argv[index + 1] !== undefined ? 2 : 1 }; + } + return { value: token.slice(prefix.length + 1), consumed: 1 }; + }; + + for (let i = 0; i < argv.length; i++) { + const token = argv[i]; + + if (token === '--test' || token.startsWith('--test=')) { + const { value, consumed } = readValue(token, '--test', i); + if (value !== undefined) testFilters.push(value); + i += consumed - 1; + } else if (token === '--record') { + record.enabled = true; + } else if (token === '--record-dir' || token.startsWith('--record-dir=')) { + const { value, consumed } = readValue(token, '--record-dir', i); + if (value !== undefined) record.dir = value; + i += consumed - 1; + } else if (token === '--record-speed' || token.startsWith('--record-speed=')) { + const { value, consumed } = readValue(token, '--record-speed', i); + const parsed = Number(value); + if (value !== undefined && Number.isFinite(parsed) && parsed > 0) { + record.speed = parsed; + } + i += consumed - 1; + } + } + + return { testFilters, record }; +} +``` + +- [ ] **Step 4: Update the six existing whole-object assertions** + +Every existing test in `tests/parseArgs.test.js` uses `toEqual({ testFilters: [...] })` and now needs `record: {}`. There are six. For example: + +```js + it("returns empty filters when no args", () => { + expect(parseRunArgs([])).toEqual({ testFilters: [], record: {} }); + }); +``` + +Apply the same edit to `"parses a single --test "`, `"parses repeated --test flags in order"`, `"parses the --test= form"`, `"ignores a trailing --test with no value"`, and `"ignores unknown tokens"`. + +- [ ] **Step 5: Run the full parseArgs suite** + +Run: `npx vitest --run tests/parseArgs.test.js` +Expected: PASS, all tests. + +- [ ] **Step 6: Commit** + +```bash +git add src/parseArgs.js tests/parseArgs.test.js +git commit -m "feat(cli): parse --record, --record-dir and --record-speed" +``` + +--- + +### Task 3: Resolve the artifact filename + +Pure module, no I/O. Names the file after what is actually in it rather than after the filter that selected it. + +**Files:** +- Create: `src/recordFilename.js` +- Test: `tests/recordFilename.test.js` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `slugify(text: string): string` + - `resolveRecordFilename({ testNames: string[], filename: string|null, format: string }): string` + +- [ ] **Step 1: Write the failing test** + +Create `tests/recordFilename.test.js`: + +```js +import { describe, it, expect } from "vitest"; +import { slugify, resolveRecordFilename } from "../src/recordFilename.js"; + +describe("slugify", () => { + it("lowercases and collapses non-alphanumerics to single hyphens", () => { + expect(slugify('Login > shows error on bad password')) + .toBe('login-shows-error-on-bad-password'); + }); + + it("trims leading and trailing hyphens", () => { + expect(slugify(' >>> Checkout <<< ')).toBe('checkout'); + }); + + it("truncates long paths without leaving a trailing hyphen", () => { + const slug = slugify('a'.repeat(200)); + expect(slug.length).toBeLessThanOrEqual(80); + expect(slug.endsWith('-')).toBe(false); + }); + + it("returns an empty string when nothing usable remains", () => { + expect(slugify('>>> <<<')).toBe(''); + }); +}); + +describe("resolveRecordFilename", () => { + it("slugifies the single recorded test path", () => { + expect(resolveRecordFilename({ + testNames: ['Login > shows error on bad password'], + filename: null, + format: 'mp4', + })).toBe('login-shows-error-on-bad-password.mp4'); + }); + + it("uses run. when more than one test is recorded", () => { + expect(resolveRecordFilename({ + testNames: ['Login > a', 'Login > b'], + filename: null, + format: 'webm', + })).toBe('run.webm'); + }); + + it("uses run. when no tests are recorded", () => { + expect(resolveRecordFilename({ testNames: [], filename: null, format: 'mp4' })) + .toBe('run.mp4'); + }); + + it("falls back to run. when the single test slugifies to nothing", () => { + expect(resolveRecordFilename({ testNames: ['>>>'], filename: null, format: 'mp4' })) + .toBe('run.mp4'); + }); + + it("honours an explicit filename and appends the format extension", () => { + expect(resolveRecordFilename({ testNames: [], filename: 'demo', format: 'mp4' })) + .toBe('demo.mp4'); + }); + + it("leaves an explicit filename alone when it already has a known extension", () => { + expect(resolveRecordFilename({ testNames: [], filename: 'demo.webm', format: 'mp4' })) + .toBe('demo.webm'); + }); + + it("does not mistake a dot in the name for an extension", () => { + expect(resolveRecordFilename({ testNames: [], filename: 'v1.2-demo', format: 'mp4' })) + .toBe('v1.2-demo.mp4'); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest --run tests/recordFilename.test.js` +Expected: FAIL, cannot resolve `../src/recordFilename.js`. + +- [ ] **Step 3: Implement** + +Create `src/recordFilename.js`: + +```js +const MAX_SLUG_LENGTH = 80; +const KNOWN_EXTENSIONS = /\.(mp4|webm|gif)$/i; + +export function slugify(text) { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .slice(0, MAX_SLUG_LENGTH) + .replace(/^-+|-+$/g, ''); +} + +export function resolveRecordFilename({ testNames, filename, format }) { + const extension = `.${format}`; + + if (filename) { + return KNOWN_EXTENSIONS.test(filename) ? filename : `${filename}${extension}`; + } + + if (testNames.length === 1) { + const slug = slugify(testNames[0]); + if (slug) return `${slug}${extension}`; + } + + return `run${extension}`; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest --run tests/recordFilename.test.js` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/recordFilename.js tests/recordFilename.test.js +git commit -m "feat(record): resolve artifact filename from recorded tests" +``` + +--- + +### Task 4: Recorder module + +Wraps every ffmpeg and Puppeteer recording call so `src/index.js` stays readable and so the pieces are unit-testable without a browser. + +**Files:** +- Create: `src/recorder.js` +- Test: `tests/recorder.test.js` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: + - `FRAMING_CSS: string` + - `assertFfmpegAvailable(ffmpegPath: string): void` (throws on missing binary) + - `applyRecordingFraming(page, record): Promise` + - `startRecording(page, record, outputPath: string): Promise` + +- [ ] **Step 1: Write the failing test** + +Create `tests/recorder.test.js`: + +```js +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock('node:child_process', () => ({ spawnSync: vi.fn() })); +vi.mock('fs'); + +import { spawnSync } from 'node:child_process'; +import fs from 'fs'; +import { + FRAMING_CSS, + assertFfmpegAvailable, + applyRecordingFraming, + startRecording, +} from "../src/recorder.js"; + +const baseRecord = { + dir: './twd-artifacts', + format: 'mp4', + fps: 30, + speed: 1, + hideSidebar: true, + ffmpegPath: 'ffmpeg', +}; + +describe("FRAMING_CSS", () => { + it("hides the sidebar and resets the html margins with !important", () => { + expect(FRAMING_CSS).toContain('#twd-sidebar-root'); + expect(FRAMING_CSS).toContain('display: none !important'); + expect(FRAMING_CSS).toContain('margin-left: 0 !important'); + expect(FRAMING_CSS).toContain('margin-right: 0 !important'); + }); +}); + +describe("assertFfmpegAvailable", () => { + beforeEach(() => vi.clearAllMocks()); + + it("probes with -version and returns when the binary runs", () => { + vi.mocked(spawnSync).mockReturnValue({ error: undefined }); + + expect(() => assertFfmpegAvailable('ffmpeg')).not.toThrow(); + expect(spawnSync).toHaveBeenCalledWith('ffmpeg', ['-version']); + }); + + it("throws with install instructions when the binary is missing", () => { + vi.mocked(spawnSync).mockReturnValue({ error: new Error('spawnSync ffmpeg ENOENT') }); + + expect(() => assertFfmpegAvailable('ffmpeg')).toThrow(/ffmpeg/); + expect(() => assertFfmpegAvailable('ffmpeg')).toThrow(/brew install ffmpeg/); + }); +}); + +describe("applyRecordingFraming", () => { + beforeEach(() => vi.clearAllMocks()); + + it("injects the framing stylesheet when hideSidebar is true", async () => { + const page = { addStyleTag: vi.fn() }; + + await applyRecordingFraming(page, { ...baseRecord, hideSidebar: true }); + + expect(page.addStyleTag).toHaveBeenCalledWith({ content: FRAMING_CSS }); + }); + + it("injects nothing when hideSidebar is false", async () => { + const page = { addStyleTag: vi.fn() }; + + await applyRecordingFraming(page, { ...baseRecord, hideSidebar: false }); + + expect(page.addStyleTag).not.toHaveBeenCalled(); + }); +}); + +describe("startRecording", () => { + beforeEach(() => vi.clearAllMocks()); + + it("creates the output directory and starts the screencast", async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + const recorder = { stop: vi.fn() }; + const page = { screencast: vi.fn().mockResolvedValue(recorder) }; + + const result = await startRecording(page, baseRecord, '/abs/twd-artifacts/run.mp4'); + + expect(fs.mkdirSync).toHaveBeenCalledWith('/abs/twd-artifacts', { recursive: true }); + expect(page.screencast).toHaveBeenCalledWith({ + path: '/abs/twd-artifacts/run.mp4', + format: 'mp4', + fps: 30, + overwrite: true, + }); + expect(result).toBe(recorder); + }); + + it("does not create the directory when it already exists", async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + const page = { screencast: vi.fn().mockResolvedValue({ stop: vi.fn() }) }; + + await startRecording(page, baseRecord, '/abs/twd-artifacts/run.mp4'); + + expect(fs.mkdirSync).not.toHaveBeenCalled(); + }); + + it("passes speed only when it is not 1, to avoid a no-op ffmpeg filter", async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + const page = { screencast: vi.fn().mockResolvedValue({ stop: vi.fn() }) }; + + await startRecording(page, { ...baseRecord, speed: 0.5 }, '/abs/out.mp4'); + + expect(page.screencast).toHaveBeenCalledWith( + expect.objectContaining({ speed: 0.5 }) + ); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest --run tests/recorder.test.js` +Expected: FAIL, cannot resolve `../src/recorder.js`. + +- [ ] **Step 3: Implement** + +Create `src/recorder.js`: + +```js +import fs from 'fs'; +import path from 'path'; +import { spawnSync } from 'node:child_process'; + +/** + * Hides the TWD sidebar for the duration of a recording. + * + * The html margin reset is not optional. twd-js's useLayout hook writes + * `document.documentElement.style.marginLeft = '280px'` as an inline style while + * the sidebar is open, so hiding the sidebar root on its own leaves a blank + * gutter in frame. `!important` is what beats the inline style, and it survives + * re-renders that would otherwise reapply the margin. + */ +export const FRAMING_CSS = [ + '#twd-sidebar-root { display: none !important; }', + 'html { margin-left: 0 !important; margin-right: 0 !important; }', +].join('\n'); + +const FFMPEG_INSTALL_HELP = [ + 'Recording requires ffmpeg, which was not found.', + '', + ' macOS: brew install ffmpeg', + ' Linux: sudo apt-get install ffmpeg', + ' Windows: winget install ffmpeg', + '', + 'Or set record.ffmpegPath in twd.config.json to an explicit binary path.', +].join('\n'); + +/** + * Probes for ffmpeg before the browser launches. + * + * Puppeteer probes internally too, but only once the recorder is constructed, + * which is after launch and navigation. Failing here saves a wasted run and + * gives an actionable message. + */ +export function assertFfmpegAvailable(ffmpegPath) { + const { error } = spawnSync(ffmpegPath, ['-version']); + if (error) { + throw new Error(FFMPEG_INSTALL_HELP); + } +} + +export async function applyRecordingFraming(page, record) { + if (!record.hideSidebar) return; + await page.addStyleTag({ content: FRAMING_CSS }); +} + +export async function startRecording(page, record, outputPath) { + const outputDir = path.dirname(outputPath); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + const options = { + path: outputPath, + format: record.format, + fps: record.fps, + overwrite: true, + }; + + // Puppeteer adds a `setpts` filter for any truthy speed, so a speed of 1 + // would add a no-op filter rather than none at all. + if (record.speed && record.speed !== 1) { + options.speed = record.speed; + } + + return page.screencast(options); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest --run tests/recorder.test.js` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/recorder.js tests/recorder.test.js +git commit -m "feat(record): add ffmpeg probe, framing and screencast wrapper" +``` + +--- + +### Task 5: Wire recording into the run lifecycle + +The teardown is the risky part. If `browser.close()` runs before `recorder.stop()`, the ffmpeg process is orphaned and the file is truncated or unplayable. `stop()` must run exactly once on the success path, the throwing-chunk path, and the interrupted path. + +**Files:** +- Modify: `src/index.js`, `bin/twd-cli.js` +- Test: `tests/runTests.test.js` + +**Interfaces:** +- Consumes: `DEFAULT_RECORD` is not needed here. Uses `resolveRecordFilename` from Task 3 and `assertFfmpegAvailable`, `applyRecordingFraming`, `startRecording` from Task 4. Uses the existing `buildTestPath(id, handlers)` from `src/buildTestPath.js`. +- Produces: `runTests({ testFilters, recordOverrides })`. `recordOverrides` is the `record` object from `parseRunArgs`, merged over `config.record`. + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/runTests.test.js`. First extend the mock page factory so it can record. Replace `createMockPage` with: + +```js +function createMockPage({ handlers = [], testStatus = [], recorder } = {}) { + return { + goto: vi.fn(), + waitForSelector: vi.fn(), + evaluate: vi.fn() + .mockResolvedValueOnce(handlers) // enumeration pass returns handler metadata + .mockResolvedValue(testStatus), // each chunk run returns its testStatus array + exposeFunction: vi.fn(), + setViewport: vi.fn(), + addStyleTag: vi.fn(), + screencast: vi.fn().mockResolvedValue(recorder ?? { stop: vi.fn() }), + }; +} +``` + +Then add a new describe block at the end of the file: + +```js +describe("runTests recording", () => { + const recordConfig = { + enabled: true, + dir: './twd-artifacts', + filename: null, + format: 'mp4', + viewport: { width: 1280, height: 720, deviceScaleFactor: 2 }, + fps: 30, + speed: 1, + hideSidebar: true, + ffmpegPath: 'ffmpeg', + }; + + let consoleSpy; + + beforeEach(() => { + vi.clearAllMocks(); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("does not touch any recording API when recording is disabled", async () => { + vi.mocked(loadConfig).mockReturnValue({ ...defaultMockConfig }); + const page = createMockPage({ + handlers: [{ id: '1', name: 'test1', type: 'test' }], + testStatus: [{ id: '1', status: 'pass' }], + }); + puppeteer.launch.mockResolvedValue(createMockBrowser(page)); + + await runTests(); + + expect(page.setViewport).not.toHaveBeenCalled(); + expect(page.addStyleTag).not.toHaveBeenCalled(); + expect(page.screencast).not.toHaveBeenCalled(); + }); + + it("sets the viewport, injects framing and starts the screencast when enabled", async () => { + vi.mocked(loadConfig).mockReturnValue({ ...defaultMockConfig, record: recordConfig }); + const page = createMockPage({ + handlers: [ + { id: 's', name: 'Login', type: 'suite', children: ['1'] }, + { id: '1', name: 'shows error', type: 'test', parent: 's' }, + ], + testStatus: [{ id: '1', status: 'pass' }], + }); + puppeteer.launch.mockResolvedValue(createMockBrowser(page)); + + await runTests(); + + expect(page.setViewport).toHaveBeenCalledWith(recordConfig.viewport); + expect(page.addStyleTag).toHaveBeenCalled(); + expect(page.screencast).toHaveBeenCalledWith( + expect.objectContaining({ + path: expect.stringContaining('login-shows-error.mp4'), + format: 'mp4', + overwrite: true, + }) + ); + }); + + it("stops the recorder before closing the browser", async () => { + const order = []; + const recorder = { stop: vi.fn(() => { order.push('stop'); }) }; + vi.mocked(loadConfig).mockReturnValue({ ...defaultMockConfig, record: recordConfig }); + const page = createMockPage({ + handlers: [{ id: '1', name: 'test1', type: 'test' }], + testStatus: [{ id: '1', status: 'pass' }], + recorder, + }); + const browser = createMockBrowser(page); + browser.close = vi.fn(() => { order.push('close'); }); + puppeteer.launch.mockResolvedValue(browser); + + await runTests(); + + expect(order).toEqual(['stop', 'close']); + }); + + it("still stops the recorder when a chunk throws", async () => { + const recorder = { stop: vi.fn() }; + vi.mocked(loadConfig).mockReturnValue({ ...defaultMockConfig, record: recordConfig }); + const page = createMockPage({ + handlers: [{ id: '1', name: 'test1', type: 'test' }], + recorder, + }); + page.evaluate = vi.fn() + .mockResolvedValueOnce([{ id: '1', name: 'test1', type: 'test' }]) + .mockRejectedValue(new Error('boom')); + puppeteer.launch.mockResolvedValue(createMockBrowser(page)); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + await expect(runTests()).rejects.toThrow('boom'); + + expect(recorder.stop).toHaveBeenCalledTimes(1); + }); + + it("never starts a recorder when no test matches the filter", async () => { + vi.mocked(loadConfig).mockReturnValue({ ...defaultMockConfig, record: recordConfig }); + const page = createMockPage({ + handlers: [{ id: '1', name: 'test1', type: 'test' }], + }); + puppeteer.launch.mockResolvedValue(createMockBrowser(page)); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + await runTests({ testFilters: ['nothing matches this'] }); + + expect(page.screencast).not.toHaveBeenCalled(); + }); + + it("applies CLI record overrides over the config file", async () => { + vi.mocked(loadConfig).mockReturnValue({ ...defaultMockConfig, record: { ...recordConfig, enabled: false } }); + const page = createMockPage({ + handlers: [{ id: '1', name: 'test1', type: 'test' }], + testStatus: [{ id: '1', status: 'pass' }], + }); + puppeteer.launch.mockResolvedValue(createMockBrowser(page)); + + await runTests({ recordOverrides: { enabled: true, dir: './clips' } }); + + expect(page.screencast).toHaveBeenCalledWith( + expect.objectContaining({ path: expect.stringContaining('clips') }) + ); + }); +}); +``` + +Also add the recorder module to the mocks at the top of the file, so the ffmpeg probe never runs for real: + +```js +vi.mock('../src/recorder.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, assertFfmpegAvailable: vi.fn() }; +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest --run tests/runTests.test.js` +Expected: the new recording tests FAIL. The existing tests must still PASS, because `defaultMockConfig` has no `record` key and every recording call is guarded by optional chaining. + +- [ ] **Step 3: Implement** + +In `src/index.js`, add the imports: + +```js +import { resolveRecordFilename } from './recordFilename.js'; +import { assertFfmpegAvailable, applyRecordingFraming, startRecording } from './recorder.js'; +``` + +Change the signature and add recording state at the top of `runTests`: + +```js +export async function runTests(options = {}) { + const { testFilters = [], recordOverrides = {} } = options; + let browser; + let config; + let startedAt = null; + let partialStatus = []; + let partialHandlers = []; + let recorder = null; + let recordOutput = null; + + // Stops the screencast at most once. Must always run before browser.close(): + // if the browser goes first, ffmpeg is orphaned and the file is truncated. + const stopRecorder = async () => { + if (!recorder) return; + const active = recorder; + recorder = null; + try { + await active.stop(); + } catch (err) { + console.warn(`Warning: could not finalize recording: ${err.message}`); + } + }; + + try { + config = loadConfig(); + const workingDir = process.cwd(); + const record = { ...(config.record || {}), ...recordOverrides }; + const recording = Boolean(record.enabled); + + if (recording) { + assertFfmpegAvailable(record.ffmpegPath); + } +``` + +After `const page = await browser.newPage();` add: + +```js + if (recording) { + await page.setViewport(record.viewport); + } +``` + +After the existing `await page.waitForSelector('#twd-sidebar-root', ...)` add: + +```js + if (recording) { + await applyRecordingFraming(page, record); + } +``` + +After the existing `const chunks = chunk(baseIds, config.chunkSize);` line, and before the `for (const ids of chunks)` loop, add: + +```js + if (recording) { + const testNames = baseIds + .map((id) => buildTestPath(id, registeredHandlers)) + .filter(Boolean); + const filename = resolveRecordFilename({ + testNames, + filename: record.filename, + format: record.format, + }); + recordOutput = path.join(record.dir, filename); + recorder = await startRecording(page, record, path.resolve(workingDir, recordOutput)); + } +``` + +Immediately after the `for (const ids of chunks) { ... }` loop closes, add: + +```js + await stopRecorder(); + if (recording) { + console.log(`Recorded ${executed} test(s) to ${recordOutput}`); + } +``` + +Finally, in the `catch` block, replace `if (browser) await browser.close();` with: + +```js + await stopRecorder(); + if (browser) await browser.close(); +``` + +- [ ] **Step 4: Run the full suite** + +Run: `npx vitest --run` +Expected: PASS, every test file. + +- [ ] **Step 5: Pass the flags through the bin entry** + +In `bin/twd-cli.js`, change the run branch to forward the parsed record overrides: + +```js + const { testFilters, record } = parseRunArgs(process.argv.slice(3)); + const hasFailures = await runTests({ testFilters, recordOverrides: record }); +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/index.js bin/twd-cli.js tests/runTests.test.js +git commit -m "feat(record): wire screencast into the run lifecycle" +``` + +--- + +### Task 6: Documentation and defaults + +**Files:** +- Modify: `README.md`, `twd.config.example.json`, `.gitignore`, `bin/twd-cli.js` + +**Interfaces:** +- Consumes: the flag set and config keys from Tasks 1, 2 and 5. +- Produces: nothing consumed by other tasks. + +- [ ] **Step 1: Add the artifact directory to `.gitignore`** + +Append to `.gitignore`, next to the existing `coverage` and `.nyc_output` entries: + +``` +# twd-cli recording artifacts +twd-artifacts +``` + +- [ ] **Step 2: Add the record block to `twd.config.example.json`** + +```json +{ + "url": "http://localhost:5173", + "timeout": 10000, + "coverage": true, + "coverageDir": "./coverage", + "nycOutputDir": "./.nyc_output", + "headless": true, + "puppeteerArgs": ["--no-sandbox", "--disable-setuid-sandbox"], + "protocolTimeout": 300000, + "record": { + "enabled": false, + "dir": "./twd-artifacts", + "filename": null, + "format": "mp4", + "viewport": { "width": 1280, "height": 720, "deviceScaleFactor": 2 }, + "fps": 30, + "speed": 1, + "hideSidebar": true, + "ffmpegPath": "ffmpeg" + } +} +``` + +- [ ] **Step 3: Update the help text in `bin/twd-cli.js`** + +Add to the usage block, after the `--test` lines: + +``` + npx twd-cli run --record Record the run to a video file +``` + +And add to the Options section: + +``` +Options: + --test "" Filter tests by "suite > test" path (repeatable, OR'd) + --record Record the run to a video file (requires ffmpeg) + --record-dir Output directory (default ./twd-artifacts) + --record-speed Playback speed, e.g. 0.5 for half speed + + --record-dir and --record-speed only set values. Recording still has to be + turned on with --record or "record": { "enabled": true } in twd.config.json. + + Create a twd.config.json file in your project root to customize settings. +``` + +- [ ] **Step 4: Add a Recording section to `README.md`** + +Insert after the existing `### Filtering tests` section and before `### Configuration`: + +````markdown +### Recording a run + +Record a test run to a video file, for a PR attachment, a docs clip, or a demo: + +```bash +# Record one flow +npx twd-cli run --record --test "checkout flow" + +# Record at half speed, into a custom directory +npx twd-cli run --record --record-speed 0.5 --record-dir ./clips +``` + +Requires ffmpeg. See [Requirements](#requirements). + +The run produces a single video containing every matched test, back to back, in +declaration order. Note that `--test` matches a substring of the full +`"suite > test"` path, so one filter can match several tests. + +The file is named after what is in it: a single recorded test gets a slug of its +full path (`login-shows-error-on-bad-password.mp4`), and anything else gets +`run.mp4`. Re-running overwrites the file. + +The TWD sidebar is hidden during recording so the frame is just your app. + +**A recorded run is a demo artifact, not a substitute for a CI run.** Recording +sets its own viewport (1280x720 by default, versus the 800x600 a normal run +uses) and reflows the app to full width, so a recorded run can pass or fail +differently. Run CI normally and record separately. +```` + +- [ ] **Step 5: Add the record options to `### Configuration Options`** + +The existing section is a single markdown table. Add one row to it, matching the existing column order: + +```markdown +| `record` | object | see below | Video recording settings (see [Recording a run](#recording-a-run)) | +``` + +Then add this subsection immediately after the existing "**Partial Results on Timeout or Crash:**" paragraph: + +````markdown +#### Recording Options + +All keys live under `record` in `twd.config.json`. + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `enabled` | boolean | `false` | Turn recording on. Equivalent to passing `--record` | +| `dir` | string | `"./twd-artifacts"` | Directory the video is written to | +| `filename` | string \| null | `null` | Explicit output filename. When `null`, the name is derived from the recorded tests. A known extension (`.mp4`, `.webm`, `.gif`) is respected, otherwise `format` supplies it | +| `format` | string | `"mp4"` | `"mp4"`, `"webm"` or `"gif"`. All three are encoded natively, no conversion step | +| `viewport` | object | `{ "width": 1280, "height": 720, "deviceScaleFactor": 2 }` | Applied only when recording. Video dimensions are viewport times `deviceScaleFactor`, so the scale factor is what makes the clip crisp rather than soft | +| `fps` | number | `30` | Capture frame rate | +| `speed` | number | `1` | Playback speed, e.g. `0.5` for half speed. This is a **uniform stretch of the whole timeline**, not per-command pacing: it slows the fast parts and the already-slow parts equally and cannot hold on a just-clicked element | +| `hideSidebar` | boolean | `true` | Hide the TWD sidebar during capture so the frame is just your app | +| `ffmpegPath` | string | `"ffmpeg"` | Path to the ffmpeg binary if it is not on your `PATH` | +```` + +- [ ] **Step 6: Add ffmpeg to `## Requirements`** + +The existing section is a two-bullet list. Add a third bullet: + +```markdown +- **ffmpeg**, only for `--record`. Install with `brew install ffmpeg` (macOS), + `sudo apt-get install ffmpeg` (Linux), or `winget install ffmpeg` (Windows). + Set `record.ffmpegPath` if it is not on your `PATH`. +``` + +- [ ] **Step 7: Verify nothing regressed** + +Run: `npx vitest --run` +Expected: PASS, every test file. + +- [ ] **Step 8: Commit** + +```bash +git add README.md twd.config.example.json .gitignore bin/twd-cli.js +git commit -m "docs(record): document video recording, flags and ffmpeg requirement" +``` + +--- + +## Manual verification + +The automated tests mock ffmpeg and Puppeteer end to end, so one real run is worth doing before merge. From an example app with a dev server running: + +```bash +npx twd-cli run --record --test "" +open twd-artifacts/*.mp4 +``` + +Check: the file plays, the sidebar is absent, there is no blank gutter on the left or right, and the app fills the frame. + +## Not in this plan + +Per-command pacing and in-page overlays live in twd-js and are specced separately at `twd/specs/2026-07-27-twd-js-pacing-and-overlays-design.md`. Screenshots on failure need a `page.exposeFunction` event bridge and awaited runner events, and get their own spec. diff --git a/docs/superpowers/specs/2026-07-27-cli-video-recording-design.md b/docs/superpowers/specs/2026-07-27-cli-video-recording-design.md new file mode 100644 index 0000000..bf7833a --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-cli-video-recording-design.md @@ -0,0 +1,323 @@ +# Video recording for twd-cli runs - Design + +Date: 2026-07-27 +Status: Ready to plan +Repo: `twd-cli` only. No twd-js changes. + +## Problem + +Cypress and Playwright can record video of a test run. `twd-cli` already runs +Puppeteer (`^25.3.0`) but uses no screenshot or screencast capability, so the +capability is sitting there unused. + +## Primary use case + +A shareable clip of a single flow: + +``` +npx twd-cli run --record --test "checkout flow" +``` + +The user turns their own tests into a product demo, a PR attachment, or a docs +clip. This framing keeps the feature out of the normal CI path entirely: +recording is always explicitly requested, never a default. + +## Scope + +In scope: everything needed to produce a video file from a `twd-cli run`. + +Out of scope, deferred to a later spec in the `twd` repo: pacing the in-page +command loop and drawing in-page overlays (element highlights, step captions). +That is the real differentiator, since TWD owns its own command loop and can +space commands deliberately rather than recording a machine-speed blur, but it +requires changes inside twd-js. This spec ships the capture pipeline that work +will plug into. `record.speed` below is the interim pacing control. + +Also out of scope: screenshots on failure. That needs a per-test event bridge +(see "One clip per run") and gets its own spec. + +## External prerequisite: ffmpeg + +`page.screencast()` spawns ffmpeg. Puppeteer's `ScreenRecorder` constructor does +`spawnSync(ffmpegPath)` and throws if it is missing. ffmpeg is therefore a hard +prerequisite for recording, documented in the README alongside the existing +`npx puppeteer browsers install chrome` step. + +Puppeteer's own probe happens late, after browser launch and page navigation, and +produces an unhelpful error. twd-cli probes first: run +`spawnSync(ffmpegPath, ['-version'])` before `puppeteer.launch()` and fail fast +with install instructions for macOS, Linux, and Windows. + +The probe only runs when recording is enabled, so non-recording users are +unaffected. + +## Format + +Puppeteer encodes `webm`, `mp4`, and `gif` natively, so no post-conversion step +is needed. Default is `mp4`: it embeds directly in GitHub PR comments, Slack, and +social posts, which is what the primary use case is for. + +## Lifecycle + +Ordered against the existing `runTests` flow in `src/index.js`: + +1. Probe ffmpeg (recording only). Fail fast if absent. +2. `puppeteer.launch()` +3. `browser.newPage()` +4. `page.setViewport(record.viewport)` (recording only) +5. `page.goto(config.url)` +6. `page.waitForSelector('#twd-sidebar-root')` +7. `page.addStyleTag(...)` for framing (recording only) +8. Enumerate handlers +9. Resolve `--test` filters into `baseIds` +10. Compute filename, create `record.dir`, `page.screencast()` (recording only) +11. Run chunks +12. `await recorder.stop()` +13. Coverage and contract handling (unchanged) +14. `await browser.close()` + +Recording starts at step 10, not before enumeration, because `screencast()` needs +the output path up front and the filename depends on how many tests will run. +That count only exists once `baseIds` is resolved. + +Two things fall out of this for free. The clip begins at the first test rather +than capturing page load and handler enumeration, and the "no tests matched" +early return at `src/index.js:83` can never leave a recorder running. + +### Teardown is the main correctness risk + +`recorder.stop()` must run before `browser.close()` on **both** the success path +and the `catch` path. If the browser closes first, the ffmpeg process is orphaned +and the output file is truncated or unplayable. The current `catch` block only +does `if (browser) await browser.close()`, so teardown needs restructuring so the +recorder is always stopped exactly once, including when a chunk throws or the run +is interrupted. + +## Framing + +Injected via `page.addStyleTag` when `record.hideSidebar` is true (the default): + +```css +#twd-sidebar-root { display: none !important; } +html { margin-left: 0 !important; margin-right: 0 !important; } +``` + +Both rules are required. twd-js's `useLayout` hook sets +`document.documentElement.style.marginLeft = '280px'` as an **inline style** while +the sidebar is open, so hiding the sidebar root alone leaves a 280px blank gutter +in frame. `!important` is what beats the inline style, and it survives re-renders +that would otherwise reapply the margin. + +A single `addStyleTag` is sufficient for the whole run because twd-js's `visit()` +navigates with `history.pushState` only and never does a hard document +navigation. If TWD ever adds real navigation, this must move to +`page.evaluateOnNewDocument`. + +Hiding the sidebar is safe for execution: the runner lives on +`window.__testRunner` and `window.__TWD_STATE__`, independent of the sidebar UI. + +## Viewport + +twd-cli never calls `setViewport` today, so every run uses Puppeteer's implicit +800x600. With the sidebar open that leaves the app 520px wide, which is a poor +canvas for a shareable clip. + +Recording sets its own viewport, defaulting to 1280x720 at +`deviceScaleFactor: 1`. The width and height are what set the video dimensions. + +`deviceScaleFactor` deliberately stays at 1, because it does **not** affect the +output resolution. Puppeteer's `#getNativePixelDimensions()` forces +`deviceScaleFactor: 0` before measuring the page, so the `ScreenRecorder` is +always constructed with CSS-pixel dimensions and the emulated factor never +reaches the encoder. Measured: recording the same page at `deviceScaleFactor: 2` +and at `1` produced byte-identical files (same md5, same frame count, same +1280x720 dimensions). + +The factor is not inert, though. It is live on the page for the whole run +(`window.devicePixelRatio === 2`), so raising it changes the environment under +test: `srcset` and `image-set` select 2x assets, and dpr-branching code takes a +different path. That adds to the non-determinism described below for zero video +benefit, which is why the default is 1. + +Puppeteer's real output-size knob is the `scale` option on `screencast()`. This +feature does not expose it. Possible follow-up if users ask for a higher +resolution clip than the viewport gives. + +The viewport is applied **only** when recording, so existing non-recording runs +keep their current behavior exactly. + +## One clip per run + +This produces exactly one video file containing every matched test, back to back, +in runner order. + +This is forced, not chosen. `src/index.js:113` runs an entire chunk of up to +`chunkSize` tests inside a single `page.evaluate()`, so Node has no per-test hook +on which to stop and restart the recorder. Per-test files become possible only +once a `page.exposeFunction` event bridge exists (following the existing +`__twdCollectMock` precedent at `src/index.js:43`), which is the screenshot +spec's problem, not this one. + +Two related behaviors worth documenting for users: + +- `--test` is a **substring match on the full `"suite > test"` path** + (`src/filterTests.js`), so a single `--test "Login"` can match several tests. + Multi-test clips are the norm, not an edge case. +- `selectTestIds` iterates `handlers`, not `filters`, so **clip order is + declaration order in the suite tree, not the order the flags were passed**. + `--test "test 3" --test "test 1"` still records test 1 first. + +## Filename + +Named by what is actually in the file, not by the flags that selected it: + +- Exactly one test recorded: slug of that test's full `"suite > test"` path, so + `Login > shows error on bad password` becomes + `login-shows-error-on-bad-password.mp4`. Lowercased, non-alphanumerics + collapsed to single hyphens, trimmed, and truncated to a safe filename length. +- More than one test recorded: `run.mp4`. +- `record.filename` always overrides. The extension comes from `record.format` + unless `filename` already carries one. + +Naming by content avoids labelling a multi-test clip after one substring that +happened to match it. The primary single-flow use case still gets a +self-describing name for free. + +`overwrite: true` is passed to `screencast()`, so re-runs replace the clip. This +matches how `coverage` and `.nyc_output` already behave. + +## `record.speed` + +Puppeteer's `ScreenRecorder` already supports a `speed` option that emits an +ffmpeg `setpts=1/speed*PTS` filter. This is exposed as `record.speed` and is the +only pacing control in this spec. + +It is a **uniform stretch of the entire timeline**. It slows the fast parts and +the already-slow parts equally, and it cannot hold on a just-clicked element. +Real per-command pacing needs the deferred twd-js work. Default `1`. + +## Config + +```json +{ + "record": { + "enabled": false, + "dir": "./twd-artifacts", + "filename": null, + "format": "mp4", + "viewport": { "width": 1280, "height": 720, "deviceScaleFactor": 1 }, + "fps": 30, + "speed": 1, + "hideSidebar": true, + "ffmpegPath": "ffmpeg" + } +} +``` + +### Shallow merge trap + +`loadConfig` in `src/config.js` does `{ ...DEFAULT_CONFIG, ...userConfig }`. With +a nested `record` object, a user setting only `{"record": {"format": "webm"}}` +would **wipe every other record default** rather than merging. + +The merge has to go two levels deep, not one, because `record.viewport` is itself +nested: `{"record": {"viewport": {"width": 1920}}}` must keep the default `height` +and `deviceScaleFactor`. Every other config key stays flat and unchanged. + +This is the first nested key in the config, so the merge behavior needs a test +that pins it at both levels. + +### CLI flags + +`parseArgs.js` currently understands only `--test` and returns +`{ testFilters }`. It grows a general flag parser returning +`{ testFilters, record }`. CLI flags win over `twd.config.json`. + +The v1 flag set is exactly three: + +| Flag | Config key | +|---|---| +| `--record` | `record.enabled = true` | +| `--record-dir ` | `record.dir` | +| `--record-speed ` | `record.speed` | + +Everything else (`format`, `viewport`, `fps`, `hideSidebar`, `ffmpegPath`, +`filename`) is config-file only, to keep the flag surface small. `bin/twd-cli.js` +help text is updated to match. + +`--record` does not require `--test`. Recording an unfiltered run is allowed and +produces `run.mp4`. + +## Output + +One line on completion, alongside the existing coverage and contract report +lines: + +``` +Recorded 3 test(s) to ./twd-artifacts/run.mp4 +``` + +That line is gated on the file actually having bytes, because a resolved +`stop()` is not evidence of a usable artifact. Puppeteer's frame pipeline uses +`bufferCount(2, 1)`, so nothing is written until a second CDP screencast frame +arrives, and Chrome only emits frames on a compositor update. Measured: a 1s +recording of a static page produced 0 bytes with `stop()` resolving cleanly, +while the same recording of an animating page produced 17081 bytes. An +assertion-only suite that never mutates the DOM, and an empty run (`chunk([], n)` +returns `[]`, so the loop body never executes), both land in that hole. + +When the output is missing or empty, the run warns instead: + +``` +Warning: recording produced an empty file at ./twd-artifacts/run.mp4. +Chrome only emits video frames when the page repaints, so a run with no visible changes (or no tests) records nothing. +``` + +The same check covers a `stop()` that rejected, which is otherwise swallowed +into a `could not finalize recording` warning. + +## Recording is not deterministic + +A recorded run can pass or fail differently from a normal run. Setting a viewport +changes layout (today every twd-cli run is implicitly 800x600, and a responsive +test that passes there can fail at 1280x720), and hiding the sidebar reflows the +app to full width. + +The docs must carry this plainly: **a recorded run is a demo artifact, not a +substitute for a CI run.** Users who want both should run CI normally and record +separately. + +## Testing + +Following the existing one-test-file-per-module convention in `tests/`: + +- `config.test.js`: the nested `record` merge, including partial overrides and + the absent-key case. +- `parseArgs.test.js`: the three flags, plus precedence over the config file. +- New unit for the filename rule: one test, many tests, `record.filename` + override, and slug generation from a test path. +- `runTests.test.js`: extend the existing Puppeteer mock so `page.screencast` + returns a fake recorder, and assert (a) `setViewport` and `addStyleTag` are + called only when recording, (b) `recorder.stop()` is called before + `browser.close()`, and (c) `recorder.stop()` still runs when a chunk throws. +- ffmpeg probe: assert the fast-fail path when the probe reports an error, and + that the probe is skipped entirely when recording is disabled. + +The ffmpeg probe and screencast are mocked throughout. No test in CI should +require a real ffmpeg binary. + +## Open questions + +- Whether `record.dir` should be added to `.gitignore` automatically, or just + documented. +- Whether to emit a `gif` variant alongside `mp4` for README embedding, or leave + that to the user via `record.format`. +- Long recordings: no size cap or duration guard is specified. Worth revisiting + if unfiltered full-suite recording turns out to be a common use. + +## Value + +Video "like Cypress and Playwright", delivered through the architecture that +already matches theirs, and shipped in a change that touches only `twd-cli`. The +paced, annotated version that they cannot produce as cleanly then builds on this +same pipeline. diff --git a/src/config.js b/src/config.js index 86a6899..cb91b22 100644 --- a/src/config.js +++ b/src/config.js @@ -1,6 +1,29 @@ import fs from 'fs'; import path from 'path'; +export const DEFAULT_RECORD = { + enabled: false, + dir: './twd-artifacts', + filename: null, + format: 'mp4', + // deviceScaleFactor stays at 1 on purpose. Puppeteer measures the recording + // dimensions with deviceScaleFactor forced to 0, so a higher factor never + // reaches the video, but it is live on the page during the run (srcset picks + // 2x assets, dpr-branching code takes another path). All cost, no benefit. + viewport: { width: 1280, height: 720, deviceScaleFactor: 1 }, + fps: 30, + speed: 1, + // A beat on the opening state before the first test runs. Cosmetic, off by + // default. + preRoll: 0, + // Not cosmetic. Chrome never captures the last thing a test did unless + // something repaints afterwards, so without this the video ends one or two + // states early. See holdFinalFrame in src/recorder.js. 0 disables it. + postRoll: 500, + hideSidebar: true, + ffmpegPath: 'ffmpeg', +}; + const DEFAULT_CONFIG = { url: 'http://localhost:5173', timeout: 10000, @@ -13,6 +36,7 @@ const DEFAULT_CONFIG = { protocolTimeout: 300000, maxFailures: 10, chunkSize: 10, + record: DEFAULT_RECORD, }; export function loadConfig() { @@ -22,7 +46,16 @@ export function loadConfig() { try { const configFile = fs.readFileSync(configPath, 'utf-8'); const userConfig = JSON.parse(configFile); - return { ...DEFAULT_CONFIG, ...userConfig }; + const userRecord = userConfig.record || {}; + return { + ...DEFAULT_CONFIG, + ...userConfig, + record: { + ...DEFAULT_RECORD, + ...userRecord, + viewport: { ...DEFAULT_RECORD.viewport, ...(userRecord.viewport || {}) }, + }, + }; } catch (error) { console.warn('Warning: Could not parse twd.config.json, using defaults:', error.message); return DEFAULT_CONFIG; diff --git a/src/index.js b/src/index.js index 4f083f9..d604e1c 100644 --- a/src/index.js +++ b/src/index.js @@ -10,17 +10,72 @@ import { formatRunComplete } from './testSummary.js'; import { selectTestIds } from './filterTests.js'; import { explainError } from './diagnostics.js'; import { orderedTestIds, chunk } from './testOrder.js'; +import { resolveRecordFilename } from './recordFilename.js'; +import { + assertFfmpegAvailable, + applyRecordingFraming, + startRecording, + holdOpeningFrame, + holdFinalFrame, +} from './recorder.js'; + +/** + * Size in bytes of the recorded artifact, or null when it cannot be determined. + * + * A resolved `stop()` is not evidence of a usable file. Puppeteer's frame + * pipeline buffers with `bufferCount(2, 1)`, so nothing is written until a + * second CDP screencast frame arrives, and Chrome only emits frames on a + * compositor update. A suite that never repaints, or a run with no tests at + * all, finishes cleanly with a 0-byte file. + * + * The null case is unreachable against the real `fs`, where `statSync` either + * returns a `Stats` or throws. It exists so an auto-mocked `fs` in the test + * suite neither crashes here nor invents a bogus warning. + */ +function recordedFileSize(absPath) { + try { + const stats = fs.statSync(absPath); + return stats && typeof stats.size === 'number' ? stats.size : null; + } catch { + // statSync throws ENOENT when the file was never created at all. + return 0; + } +} export async function runTests(options = {}) { - const { testFilters = [] } = options; + const { testFilters = [], recordOverrides = {} } = options; let browser; let config; let startedAt = null; let partialStatus = []; let partialHandlers = []; + let recorder = null; + let recordOutput = null; + let recordOutputPath = null; + + // Stops the screencast at most once. Must always run before browser.close(): + // if the browser goes first, ffmpeg is orphaned and the file is truncated. + const stopRecorder = async () => { + if (!recorder) return; + const active = recorder; + recorder = null; + try { + await active.stop(); + } catch (err) { + console.warn(`Warning: could not finalize recording: ${err.message}`); + } + }; + try { config = loadConfig(); const workingDir = process.cwd(); + // config.record can be a shared default object; copy instead of mutating it. + const record = { ...(config.record || {}), ...recordOverrides }; + const recording = Boolean(record.enabled); + + if (recording) { + assertFfmpegAvailable(record.ffmpegPath); + } // Load contract validators if configured let contractValidators = []; @@ -36,6 +91,10 @@ export async function runTests(options = {}) { const page = await browser.newPage(); + if (recording) { + await page.setViewport(record.viewport); + } + // Register mock collector for contract validation const collectedMocks = new Map(); const occurrenceCounters = new Map(); @@ -58,6 +117,10 @@ export async function runTests(options = {}) { // Wait for the selector to be available await page.waitForSelector('#twd-sidebar-root', { timeout: config.timeout }); + if (recording) { + await applyRecordingFraming(page, record); + } + // Enumerate registered handlers (for the count line and --test filtering) const registeredHandlers = await page.evaluate(() => { const state = window.__TWD_STATE__; @@ -101,6 +164,25 @@ export async function runTests(options = {}) { const baseIds = selectedIds ?? orderedTestIds(registeredHandlers); const chunks = chunk(baseIds, config.chunkSize); + // Recording starts here, not earlier: the output path is fixed up front by + // page.screencast(), and the filename depends on which tests survived the + // filter. Starting after baseIds is resolved also means the "no tests + // matched" early return can never leave a recorder running. + if (recording) { + const testNames = baseIds + .map((id) => buildTestPath(id, registeredHandlers)) + .filter(Boolean); + const filename = resolveRecordFilename({ + testNames, + filename: record.filename, + format: record.format, + }); + recordOutput = path.join(record.dir, filename); + recordOutputPath = path.resolve(workingDir, recordOutput); + recorder = await startRecording(page, record, recordOutputPath); + await holdOpeningFrame(record.preRoll); + } + // Handlers for path-building/summary come from the enumeration so partial // results are always printable even if a chunk never returns. const handlers = registeredHandlers; @@ -152,6 +234,28 @@ export async function runTests(options = {}) { } } + // Must run before stopRecorder(): it is what gets the last test's result + // into the video at all, not just a pause on the end. See holdFinalFrame. + if (recording) { + await holdFinalFrame(page, record.postRoll); + } + + await stopRecorder(); + if (recording) { + // Report what is on disk, not that stop() resolved. This also covers a + // stop() that rejected, which stopRecorder swallows into a warning. + if (recordedFileSize(recordOutputPath) === 0) { + console.warn( + `Warning: recording produced an empty file at ${recordOutput}.` + ); + console.warn( + 'Chrome only emits video frames when the page repaints, so a run with no visible changes (or no tests) records nothing.' + ); + } else { + console.log(`Recorded ${executed} test(s) to ${recordOutput}`); + } + } + const testStatus = partialStatus; const durationMs = Date.now() - startedAt; const notRun = baseIds.length - executed; @@ -254,6 +358,7 @@ export async function runTests(options = {}) { if (error && typeof error === 'object') { error.reported = true; } + await stopRecorder(); if (browser) await browser.close(); throw error; } diff --git a/src/parseArgs.js b/src/parseArgs.js index 8b73f91..9b78d67 100644 --- a/src/parseArgs.js +++ b/src/parseArgs.js @@ -1,18 +1,36 @@ export function parseRunArgs(argv) { const testFilters = []; + const record = {}; + + const readValue = (token, prefix, index) => { + if (token === prefix) { + return { value: argv[index + 1], consumed: argv[index + 1] !== undefined ? 2 : 1 }; + } + return { value: token.slice(prefix.length + 1), consumed: 1 }; + }; for (let i = 0; i < argv.length; i++) { const token = argv[i]; - if (token === '--test') { - const value = argv[i + 1]; - if (value !== undefined) { - testFilters.push(value); - i++; + + if (token === '--test' || token.startsWith('--test=')) { + const { value, consumed } = readValue(token, '--test', i); + if (value !== undefined) testFilters.push(value); + i += consumed - 1; + } else if (token === '--record') { + record.enabled = true; + } else if (token === '--record-dir' || token.startsWith('--record-dir=')) { + const { value, consumed } = readValue(token, '--record-dir', i); + if (value !== undefined) record.dir = value; + i += consumed - 1; + } else if (token === '--record-speed' || token.startsWith('--record-speed=')) { + const { value, consumed } = readValue(token, '--record-speed', i); + const parsed = Number(value); + if (value !== undefined && Number.isFinite(parsed) && parsed > 0) { + record.speed = parsed; } - } else if (token.startsWith('--test=')) { - testFilters.push(token.slice('--test='.length)); + i += consumed - 1; } } - return { testFilters }; + return { testFilters, record }; } diff --git a/src/recordFilename.js b/src/recordFilename.js new file mode 100644 index 0000000..5dd8a90 --- /dev/null +++ b/src/recordFilename.js @@ -0,0 +1,25 @@ +const MAX_SLUG_LENGTH = 80; +const KNOWN_EXTENSIONS = /\.(mp4|webm|gif)$/i; + +export function slugify(text) { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .slice(0, MAX_SLUG_LENGTH) + .replace(/^-+|-+$/g, ''); +} + +export function resolveRecordFilename({ testNames, filename, format }) { + const extension = `.${format}`; + + if (filename) { + return KNOWN_EXTENSIONS.test(filename) ? filename : `${filename}${extension}`; + } + + if (testNames.length === 1) { + const slug = slugify(testNames[0]); + if (slug) return `${slug}${extension}`; + } + + return `run${extension}`; +} diff --git a/src/recorder.js b/src/recorder.js new file mode 100644 index 0000000..3373709 --- /dev/null +++ b/src/recorder.js @@ -0,0 +1,134 @@ +import fs from 'fs'; +import path from 'path'; +import { spawnSync } from 'node:child_process'; + +/** + * Hides the TWD sidebar for the duration of a recording. + * + * The html margin reset is not optional. twd-js's useLayout hook writes + * `document.documentElement.style.marginLeft = '280px'` as an inline style while + * the sidebar is open, so hiding the sidebar root on its own leaves a blank + * gutter in frame. `!important` is what beats the inline style, and it survives + * re-renders that would otherwise reapply the margin. + */ +export const FRAMING_CSS = [ + '#twd-sidebar-root { display: none !important; }', + 'html { margin-left: 0 !important; margin-right: 0 !important; }', +].join('\n'); + +const FFMPEG_INSTALL_HELP = [ + 'Recording requires ffmpeg, which was not found.', + '', + ' macOS: brew install ffmpeg', + ' Linux: sudo apt-get install ffmpeg', + ' Windows: winget install ffmpeg', + '', + 'Or set record.ffmpegPath in twd.config.json to an explicit binary path.', +].join('\n'); + +/** + * Probes for ffmpeg before the browser launches. + * + * Puppeteer probes internally too, but only once the recorder is constructed, + * which is after launch and navigation. Failing here saves a wasted run and + * gives an actionable message. + */ +export function assertFfmpegAvailable(ffmpegPath) { + const { error } = spawnSync(ffmpegPath, ['-version']); + if (error) { + throw new Error(FFMPEG_INSTALL_HELP); + } +} + +export async function applyRecordingFraming(page, record) { + if (!record.hideSidebar) return; + await page.addStyleTag({ content: FRAMING_CSS }); +} + +/** + * Holds the opening state before the first test runs. + * + * A plain wait is enough here. The first screencast frame arrives when capture + * starts, and it is held until the first test changes something, so the opening + * state simply occupies that much of the timeline. + */ +export async function holdOpeningFrame(durationMs) { + if (!durationMs || durationMs <= 0) return; + await new Promise((resolve) => setTimeout(resolve, durationMs)); +} + +/** + * Holds the final state at the end of a recording, and makes sure it is + * actually captured. + * + * A plain wait does NOT work here, which is the whole reason this exists. + * Puppeteer's frame pipeline uses `bufferCount(2, 1)`: every screencast frame is + * held until the next one arrives, because the next frame's timestamp is what + * says how long to display the current one. The newest frame is therefore never + * emitted, and `stop()` pads the tail by repeating the second-newest. A settled + * page produces no further compositor updates, so the last thing a test did is + * lost no matter how long you wait. + * + * Measured against real Chrome: stopping immediately ended two states early; a + * 400ms wait still ended one state early; an 800ms wait with a 1px animated + * probe produced no new frames at all, because a 1px element gets its own + * composited layer and changes no visible surface. + * + * Toggling a viewport-sized overlay between two near-identical alpha values + * repaints the whole surface, so Chrome must emit real frames, while staying + * invisible in the output. That flushes the true final state through the + * pipeline and then holds it. + */ +export async function holdFinalFrame(page, durationMs) { + if (!durationMs || durationMs <= 0) return; + + await page.evaluate(async (ms) => { + const veil = document.createElement('div'); + veil.style.cssText = [ + 'position:fixed', + 'inset:0', + 'pointer-events:none', + 'z-index:2147483647', + 'background:rgba(255,255,255,0)', + ].join(';'); + document.body.appendChild(veil); + + try { + const deadline = performance.now() + ms; + let lit = false; + while (performance.now() < deadline) { + lit = !lit; + veil.style.background = lit ? 'rgba(255,255,255,0.004)' : 'rgba(255,255,255,0)'; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } finally { + veil.remove(); + } + }, durationMs); +} + +export async function startRecording(page, record, outputPath) { + const outputDir = path.dirname(outputPath); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + const options = { + path: outputPath, + format: record.format, + fps: record.fps, + overwrite: true, + // Puppeteer spawns ffmpeg itself and defaults to a bare `ffmpeg` on PATH. + // Without this the pre-flight probe would honor record.ffmpegPath while the + // actual encode still failed with ENOENT, after launch and navigation. + ffmpegPath: record.ffmpegPath, + }; + + // Puppeteer adds a `setpts` filter for any truthy speed, so a speed of 1 + // would add a no-op filter rather than none at all. + if (record.speed && record.speed !== 1) { + options.speed = record.speed; + } + + return page.screencast(options); +} diff --git a/tests/config.test.js b/tests/config.test.js index 84f226f..5475d6c 100644 --- a/tests/config.test.js +++ b/tests/config.test.js @@ -1,5 +1,5 @@ import { expect, describe, it, beforeEach, afterEach, vi } from 'vitest'; -import { loadConfig } from '../src/config.js'; +import { loadConfig, DEFAULT_RECORD } from '../src/config.js'; import fs from 'fs'; import path from 'path'; @@ -35,6 +35,7 @@ describe('loadConfig', () => { protocolTimeout: 300000, maxFailures: 10, chunkSize: 10, + record: DEFAULT_RECORD, }); expect(fs.existsSync).toHaveBeenCalledWith(path.resolve(mockCwd, 'twd.config.json')); }); @@ -62,6 +63,7 @@ describe('loadConfig', () => { protocolTimeout: 300000, maxFailures: 10, chunkSize: 10, + record: DEFAULT_RECORD, }); expect(fs.readFileSync).toHaveBeenCalledWith( path.resolve(mockCwd, 'twd.config.json'), @@ -89,7 +91,7 @@ describe('loadConfig', () => { const config = loadConfig(); - expect(config).toEqual(userConfig); + expect(config).toEqual({ ...userConfig, record: DEFAULT_RECORD }); }); it('should return defaults and warn when config file has invalid JSON', () => { @@ -112,6 +114,7 @@ describe('loadConfig', () => { protocolTimeout: 300000, maxFailures: 10, chunkSize: 10, + record: DEFAULT_RECORD, }); expect(consoleWarnSpy).toHaveBeenCalledWith( expect.stringContaining('Warning: Could not parse twd.config.json'), @@ -175,4 +178,79 @@ describe('loadConfig', () => { expect(config.maxFailures).toBe(0); expect(config.chunkSize).toBe(25); }); + + it('includes fully populated record defaults when no config file exists', () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + expect(loadConfig().record).toEqual({ + enabled: false, + dir: './twd-artifacts', + filename: null, + format: 'mp4', + viewport: { width: 1280, height: 720, deviceScaleFactor: 1 }, + fps: 30, + speed: 1, + preRoll: 0, + postRoll: 500, + hideSidebar: true, + ffmpegPath: 'ffmpeg', + }); + }); + + it('merges a partial postRoll without dropping the other record defaults', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({ record: { postRoll: 0 } }) + ); + + const { record } = loadConfig(); + + expect(record.postRoll).toBe(0); + expect(record.preRoll).toBe(0); + expect(record.format).toBe('mp4'); + expect(record.viewport).toEqual({ width: 1280, height: 720, deviceScaleFactor: 1 }); + }); + + it('merges a partial record block instead of replacing it', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({ record: { enabled: true, format: 'webm' } }) + ); + + const { record } = loadConfig(); + + expect(record.enabled).toBe(true); + expect(record.format).toBe('webm'); + // every other default must survive + expect(record.dir).toBe('./twd-artifacts'); + expect(record.fps).toBe(30); + expect(record.speed).toBe(1); + expect(record.hideSidebar).toBe(true); + expect(record.ffmpegPath).toBe('ffmpeg'); + expect(record.viewport).toEqual({ width: 1280, height: 720, deviceScaleFactor: 1 }); + }); + + it('merges a partial record.viewport instead of replacing it', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({ record: { viewport: { width: 1920 } } }) + ); + + expect(loadConfig().record.viewport).toEqual({ + width: 1920, + height: 720, + deviceScaleFactor: 1, + }); + }); + + it('leaves record at defaults when the user config omits it', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ headless: false })); + + const config = loadConfig(); + + expect(config.headless).toBe(false); + expect(config.record.enabled).toBe(false); + expect(config.record.format).toBe('mp4'); + }); }); \ No newline at end of file diff --git a/tests/parseArgs.test.js b/tests/parseArgs.test.js index 6c9a33f..8cdf671 100644 --- a/tests/parseArgs.test.js +++ b/tests/parseArgs.test.js @@ -3,34 +3,74 @@ import { parseRunArgs } from "../src/parseArgs.js"; describe("parseRunArgs", () => { it("returns empty filters when no args", () => { - expect(parseRunArgs([])).toEqual({ testFilters: [] }); + expect(parseRunArgs([])).toEqual({ testFilters: [], record: {} }); }); it("parses a single --test ", () => { expect(parseRunArgs(['--test', 'shows error'])).toEqual({ testFilters: ['shows error'], + record: {}, }); }); it("parses repeated --test flags in order", () => { expect(parseRunArgs(['--test', 'Login', '--test', 'Signup'])).toEqual({ testFilters: ['Login', 'Signup'], + record: {}, }); }); it("parses the --test= form", () => { expect(parseRunArgs(['--test=Login'])).toEqual({ testFilters: ['Login'], + record: {}, }); }); it("ignores a trailing --test with no value", () => { - expect(parseRunArgs(['--test'])).toEqual({ testFilters: [] }); + expect(parseRunArgs(['--test'])).toEqual({ testFilters: [], record: {} }); }); it("ignores unknown tokens", () => { expect(parseRunArgs(['--verbose', '--test', 'Login'])).toEqual({ testFilters: ['Login'], + record: {}, + }); + }); + + it("returns an empty record object when no record flags are present", () => { + expect(parseRunArgs(['--test', 'Login']).record).toEqual({}); + }); + + it("parses --record", () => { + expect(parseRunArgs(['--record']).record).toEqual({ enabled: true }); + }); + + it("parses --record-dir and the = form", () => { + expect(parseRunArgs(['--record-dir', './clips']).record).toEqual({ dir: './clips' }); + expect(parseRunArgs(['--record-dir=./clips']).record).toEqual({ dir: './clips' }); + }); + + it("parses --record-speed as a number, both forms", () => { + expect(parseRunArgs(['--record-speed', '0.5']).record).toEqual({ speed: 0.5 }); + expect(parseRunArgs(['--record-speed=2']).record).toEqual({ speed: 2 }); + }); + + it("ignores a non-numeric or non-positive --record-speed", () => { + expect(parseRunArgs(['--record-speed', 'slow']).record).toEqual({}); + expect(parseRunArgs(['--record-speed', '0']).record).toEqual({}); + expect(parseRunArgs(['--record-speed', '-1']).record).toEqual({}); + }); + + it("ignores trailing record flags with no value", () => { + expect(parseRunArgs(['--record-dir']).record).toEqual({}); + expect(parseRunArgs(['--record-speed']).record).toEqual({}); + }); + + it("combines record flags with --test filters", () => { + expect(parseRunArgs(['--record', '--test', 'checkout', '--record-speed=0.5'])).toEqual({ + testFilters: ['checkout'], + record: { enabled: true, speed: 0.5 }, }); }); }); diff --git a/tests/recordFilename.test.js b/tests/recordFilename.test.js new file mode 100644 index 0000000..399793e --- /dev/null +++ b/tests/recordFilename.test.js @@ -0,0 +1,66 @@ +import { describe, it, expect } from "vitest"; +import { slugify, resolveRecordFilename } from "../src/recordFilename.js"; + +describe("slugify", () => { + it("lowercases and collapses non-alphanumerics to single hyphens", () => { + expect(slugify('Login > shows error on bad password')) + .toBe('login-shows-error-on-bad-password'); + }); + + it("trims leading and trailing hyphens", () => { + expect(slugify(' >>> Checkout <<< ')).toBe('checkout'); + }); + + it("truncates long paths without leaving a trailing hyphen", () => { + const slug = slugify('a'.repeat(200)); + expect(slug.length).toBeLessThanOrEqual(80); + expect(slug.endsWith('-')).toBe(false); + }); + + it("returns an empty string when nothing usable remains", () => { + expect(slugify('>>> <<<')).toBe(''); + }); +}); + +describe("resolveRecordFilename", () => { + it("slugifies the single recorded test path", () => { + expect(resolveRecordFilename({ + testNames: ['Login > shows error on bad password'], + filename: null, + format: 'mp4', + })).toBe('login-shows-error-on-bad-password.mp4'); + }); + + it("uses run. when more than one test is recorded", () => { + expect(resolveRecordFilename({ + testNames: ['Login > a', 'Login > b'], + filename: null, + format: 'webm', + })).toBe('run.webm'); + }); + + it("uses run. when no tests are recorded", () => { + expect(resolveRecordFilename({ testNames: [], filename: null, format: 'mp4' })) + .toBe('run.mp4'); + }); + + it("falls back to run. when the single test slugifies to nothing", () => { + expect(resolveRecordFilename({ testNames: ['>>>'], filename: null, format: 'mp4' })) + .toBe('run.mp4'); + }); + + it("honours an explicit filename and appends the format extension", () => { + expect(resolveRecordFilename({ testNames: [], filename: 'demo', format: 'mp4' })) + .toBe('demo.mp4'); + }); + + it("leaves an explicit filename alone when it already has a known extension", () => { + expect(resolveRecordFilename({ testNames: [], filename: 'demo.webm', format: 'mp4' })) + .toBe('demo.webm'); + }); + + it("does not mistake a dot in the name for an extension", () => { + expect(resolveRecordFilename({ testNames: [], filename: 'v1.2-demo', format: 'mp4' })) + .toBe('v1.2-demo.mp4'); + }); +}); diff --git a/tests/recorder.test.js b/tests/recorder.test.js new file mode 100644 index 0000000..8e56dac --- /dev/null +++ b/tests/recorder.test.js @@ -0,0 +1,236 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock('node:child_process', () => ({ spawnSync: vi.fn() })); +vi.mock('fs'); + +import { spawnSync } from 'node:child_process'; +import fs from 'fs'; +import { + FRAMING_CSS, + assertFfmpegAvailable, + applyRecordingFraming, + startRecording, + holdOpeningFrame, + holdFinalFrame, +} from "../src/recorder.js"; + +const baseRecord = { + dir: './twd-artifacts', + format: 'mp4', + fps: 30, + speed: 1, + hideSidebar: true, + ffmpegPath: 'ffmpeg', +}; + +describe("FRAMING_CSS", () => { + it("hides the sidebar and resets the html margins with !important", () => { + expect(FRAMING_CSS).toContain('#twd-sidebar-root'); + expect(FRAMING_CSS).toContain('display: none !important'); + expect(FRAMING_CSS).toContain('margin-left: 0 !important'); + expect(FRAMING_CSS).toContain('margin-right: 0 !important'); + }); +}); + +describe("assertFfmpegAvailable", () => { + beforeEach(() => vi.clearAllMocks()); + + it("probes with -version and returns when the binary runs", () => { + vi.mocked(spawnSync).mockReturnValue({ error: undefined }); + + expect(() => assertFfmpegAvailable('ffmpeg')).not.toThrow(); + expect(spawnSync).toHaveBeenCalledWith('ffmpeg', ['-version']); + }); + + it("throws with install instructions when the binary is missing", () => { + vi.mocked(spawnSync).mockReturnValue({ error: new Error('spawnSync ffmpeg ENOENT') }); + + expect(() => assertFfmpegAvailable('ffmpeg')).toThrow(/ffmpeg/); + expect(() => assertFfmpegAvailable('ffmpeg')).toThrow(/brew install ffmpeg/); + }); +}); + +describe("applyRecordingFraming", () => { + beforeEach(() => vi.clearAllMocks()); + + it("injects the framing stylesheet when hideSidebar is true", async () => { + const page = { addStyleTag: vi.fn() }; + + await applyRecordingFraming(page, { ...baseRecord, hideSidebar: true }); + + expect(page.addStyleTag).toHaveBeenCalledWith({ content: FRAMING_CSS }); + }); + + it("injects nothing when hideSidebar is false", async () => { + const page = { addStyleTag: vi.fn() }; + + await applyRecordingFraming(page, { ...baseRecord, hideSidebar: false }); + + expect(page.addStyleTag).not.toHaveBeenCalled(); + }); +}); + +describe("startRecording", () => { + beforeEach(() => vi.clearAllMocks()); + + it("creates the output directory and starts the screencast", async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + const recorder = { stop: vi.fn() }; + const page = { screencast: vi.fn().mockResolvedValue(recorder) }; + + const result = await startRecording(page, baseRecord, '/abs/twd-artifacts/run.mp4'); + + expect(fs.mkdirSync).toHaveBeenCalledWith('/abs/twd-artifacts', { recursive: true }); + expect(page.screencast).toHaveBeenCalledWith({ + path: '/abs/twd-artifacts/run.mp4', + format: 'mp4', + fps: 30, + overwrite: true, + ffmpegPath: 'ffmpeg', + }); + expect(result).toBe(recorder); + }); + + it("forwards a custom ffmpegPath to the screencast", async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + const page = { screencast: vi.fn().mockResolvedValue({ stop: vi.fn() }) }; + + await startRecording( + page, + { ...baseRecord, ffmpegPath: '/opt/homebrew/bin/ffmpeg' }, + '/abs/out.mp4' + ); + + // Puppeteer spawns its own ffmpeg, so the probe honoring this path is not + // enough: the value has to reach page.screencast() or the encode ENOENTs. + expect(page.screencast).toHaveBeenCalledWith( + expect.objectContaining({ ffmpegPath: '/opt/homebrew/bin/ffmpeg' }) + ); + }); + + it("does not create the directory when it already exists", async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + const page = { screencast: vi.fn().mockResolvedValue({ stop: vi.fn() }) }; + + await startRecording(page, baseRecord, '/abs/twd-artifacts/run.mp4'); + + expect(fs.mkdirSync).not.toHaveBeenCalled(); + }); + + it("passes speed only when it is not 1, to avoid a no-op ffmpeg filter", async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + const page = { screencast: vi.fn().mockResolvedValue({ stop: vi.fn() }) }; + + await startRecording(page, { ...baseRecord, speed: 0.5 }, '/abs/out.mp4'); + + expect(page.screencast).toHaveBeenCalledWith( + expect.objectContaining({ speed: 0.5 }) + ); + }); +}); + +describe("holdOpeningFrame", () => { + it("waits for the given duration", async () => { + const started = Date.now(); + await holdOpeningFrame(60); + expect(Date.now() - started).toBeGreaterThanOrEqual(50); + }); + + it("returns immediately for 0, undefined or a negative duration", async () => { + const started = Date.now(); + await holdOpeningFrame(0); + await holdOpeningFrame(undefined); + await holdOpeningFrame(-100); + expect(Date.now() - started).toBeLessThan(30); + }); +}); + +describe("holdFinalFrame", () => { + beforeEach(() => vi.clearAllMocks()); + + it("runs the repaint loop in the page for the given duration", async () => { + const page = { evaluate: vi.fn() }; + + await holdFinalFrame(page, 500); + + expect(page.evaluate).toHaveBeenCalledWith(expect.any(Function), 500); + }); + + it("does nothing when the duration is 0, undefined or negative", async () => { + const page = { evaluate: vi.fn() }; + + await holdFinalFrame(page, 0); + await holdFinalFrame(page, undefined); + await holdFinalFrame(page, -100); + + expect(page.evaluate).not.toHaveBeenCalled(); + }); + + it("toggles a full-viewport overlay and always removes it", async () => { + // Capture the page-side function and run it against a stub DOM, so the veil + // lifecycle is verified rather than just the fact that evaluate was called. + let pageFn; + const page = { evaluate: vi.fn((fn) => { pageFn = fn; }) }; + await holdFinalFrame(page, 120); + + const backgrounds = []; + const veil = { + style: { + cssText: '', + set background(value) { backgrounds.push(value); }, + get background() { return backgrounds[backgrounds.length - 1] ?? ''; }, + }, + remove: vi.fn(), + }; + const appended = []; + const originalDocument = global.document; + global.document = { + createElement: () => veil, + body: { appendChild: (node) => appended.push(node) }, + }; + + try { + await pageFn(120); + } finally { + global.document = originalDocument; + } + + expect(appended).toEqual([veil]); + // Must cover the whole viewport: a small element gets its own composited + // layer and produces no screencast frame at all. + expect(veil.style.cssText).toContain('position:fixed'); + expect(veil.style.cssText).toContain('inset:0'); + expect(veil.style.cssText).toContain('pointer-events:none'); + // Alternating alpha is what forces the repaints. + expect(backgrounds).toContain('rgba(255,255,255,0.004)'); + expect(backgrounds).toContain('rgba(255,255,255,0)'); + expect(veil.remove).toHaveBeenCalled(); + }); + + it("removes the overlay even when the repaint loop throws", async () => { + let pageFn; + const page = { evaluate: vi.fn((fn) => { pageFn = fn; }) }; + await holdFinalFrame(page, 120); + + const veil = { + style: { + cssText: '', + set background(_value) { throw new Error('detached'); }, + }, + remove: vi.fn(), + }; + const originalDocument = global.document; + global.document = { + createElement: () => veil, + body: { appendChild: () => {} }, + }; + + try { + await expect(pageFn(120)).rejects.toThrow('detached'); + } finally { + global.document = originalDocument; + } + + expect(veil.remove).toHaveBeenCalled(); + }); +}); diff --git a/tests/runTests.test.js b/tests/runTests.test.js index 0f99893..1666bf3 100644 --- a/tests/runTests.test.js +++ b/tests/runTests.test.js @@ -13,14 +13,27 @@ vi.mock('../src/contracts.js', () => ({ vi.mock('../src/contractReport.js', () => ({ printContractReport: vi.fn(), })); +// assertFfmpegAvailable is mocked so no test needs a real binary. The two hold +// helpers are mocked so their call ordering is observable here; their real +// behavior is covered in tests/recorder.test.js. +vi.mock('../src/recorder.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + assertFfmpegAvailable: vi.fn(), + holdOpeningFrame: vi.fn(), + holdFinalFrame: vi.fn(), + }; +}); import fs from 'fs'; import puppeteer from 'puppeteer'; import { loadConfig } from '../src/config.js'; import { loadContracts, validateMocks } from '../src/contracts.js'; import { printContractReport } from '../src/contractReport.js'; +import { assertFfmpegAvailable, holdOpeningFrame, holdFinalFrame } from '../src/recorder.js'; -function createMockPage({ handlers = [], testStatus = [] } = {}) { +function createMockPage({ handlers = [], testStatus = [], recorder } = {}) { return { goto: vi.fn(), waitForSelector: vi.fn(), @@ -28,6 +41,9 @@ function createMockPage({ handlers = [], testStatus = [] } = {}) { .mockResolvedValueOnce(handlers) // enumeration pass returns handler metadata .mockResolvedValue(testStatus), // each chunk run returns its testStatus array exposeFunction: vi.fn(), + setViewport: vi.fn(), + addStyleTag: vi.fn(), + screencast: vi.fn().mockResolvedValue(recorder ?? { stop: vi.fn() }), }; } @@ -660,3 +676,397 @@ describe("runTests", () => { errorSpy.mockRestore(); }); }); + +describe("runTests recording", () => { + const recordConfig = { + enabled: true, + dir: './twd-artifacts', + filename: null, + format: 'mp4', + viewport: { width: 1280, height: 720, deviceScaleFactor: 1 }, + fps: 30, + speed: 1, + hideSidebar: true, + ffmpegPath: 'ffmpeg', + }; + + let consoleSpy; + + beforeEach(() => { + vi.clearAllMocks(); + // clearAllMocks keeps implementations, so drop anything a previous test + // queued on these two: a leaked throwing probe or a leaked stat size would + // silently change what every later test exercises. + vi.mocked(assertFfmpegAvailable).mockReset(); + vi.mocked(fs.statSync).mockReset(); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("does not touch any recording API when recording is disabled", async () => { + vi.mocked(loadConfig).mockReturnValue({ ...defaultMockConfig }); + const page = createMockPage({ + handlers: [{ id: '1', name: 'test1', type: 'test' }], + testStatus: [{ id: '1', status: 'pass' }], + }); + puppeteer.launch.mockResolvedValue(createMockBrowser(page)); + + await runTests(); + + expect(page.setViewport).not.toHaveBeenCalled(); + expect(page.addStyleTag).not.toHaveBeenCalled(); + expect(page.screencast).not.toHaveBeenCalled(); + }); + + it("sets the viewport, injects framing and starts the screencast when enabled", async () => { + vi.mocked(loadConfig).mockReturnValue({ ...defaultMockConfig, record: recordConfig }); + const page = createMockPage({ + handlers: [ + { id: 's', name: 'Login', type: 'suite', children: ['1'] }, + { id: '1', name: 'shows error', type: 'test', parent: 's' }, + ], + testStatus: [{ id: '1', status: 'pass' }], + }); + puppeteer.launch.mockResolvedValue(createMockBrowser(page)); + + await runTests(); + + expect(page.setViewport).toHaveBeenCalledWith(recordConfig.viewport); + expect(page.addStyleTag).toHaveBeenCalled(); + expect(page.screencast).toHaveBeenCalledWith( + expect.objectContaining({ + path: expect.stringContaining('login-shows-error.mp4'), + format: 'mp4', + overwrite: true, + }) + ); + }); + + it("stops the recorder before closing the browser", async () => { + const order = []; + // stop() must resolve on a later tick, not synchronously. A synchronous + // mock would keep this assertion green even if index.js dropped the await, + // which in production is the un-awaited ffmpeg finalize that truncates the + // file. + const recorder = { + stop: vi.fn(() => new Promise((resolve) => { + setTimeout(() => { + order.push('stop'); + resolve(); + }, 0); + })), + }; + vi.mocked(loadConfig).mockReturnValue({ ...defaultMockConfig, record: recordConfig }); + const page = createMockPage({ + handlers: [{ id: '1', name: 'test1', type: 'test' }], + testStatus: [{ id: '1', status: 'pass' }], + recorder, + }); + const browser = createMockBrowser(page); + browser.close = vi.fn(() => { order.push('close'); }); + puppeteer.launch.mockResolvedValue(browser); + + await runTests(); + + expect(order).toEqual(['stop', 'close']); + }); + + it("still stops the recorder when a chunk throws", async () => { + const recorder = { stop: vi.fn() }; + vi.mocked(loadConfig).mockReturnValue({ ...defaultMockConfig, record: recordConfig }); + const page = createMockPage({ + handlers: [{ id: '1', name: 'test1', type: 'test' }], + recorder, + }); + page.evaluate = vi.fn() + .mockResolvedValueOnce([{ id: '1', name: 'test1', type: 'test' }]) + .mockRejectedValue(new Error('boom')); + puppeteer.launch.mockResolvedValue(createMockBrowser(page)); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + await expect(runTests()).rejects.toThrow('boom'); + + expect(recorder.stop).toHaveBeenCalledTimes(1); + }); + + it("never starts a recorder when no test matches the filter", async () => { + vi.mocked(loadConfig).mockReturnValue({ ...defaultMockConfig, record: recordConfig }); + const page = createMockPage({ + handlers: [{ id: '1', name: 'test1', type: 'test' }], + }); + puppeteer.launch.mockResolvedValue(createMockBrowser(page)); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + await runTests({ testFilters: ['nothing matches this'] }); + + expect(page.screencast).not.toHaveBeenCalled(); + }); + + it("applies CLI record overrides over the config file", async () => { + vi.mocked(loadConfig).mockReturnValue({ ...defaultMockConfig, record: { ...recordConfig, enabled: false } }); + const page = createMockPage({ + handlers: [{ id: '1', name: 'test1', type: 'test' }], + testStatus: [{ id: '1', status: 'pass' }], + }); + puppeteer.launch.mockResolvedValue(createMockBrowser(page)); + + await runTests({ recordOverrides: { enabled: true, dir: './clips' } }); + + expect(page.screencast).toHaveBeenCalledWith( + expect.objectContaining({ path: expect.stringContaining('clips') }) + ); + }); + + it("stops the recorder exactly once when something throws after the success-path stop", async () => { + // The window between the success-path stopRecorder() and browser.close(): + // coverage collection is the natural thing to throw in it. Without the + // idempotency guard the catch path would stop an already-stopped recorder. + const recorder = { stop: vi.fn().mockResolvedValue(undefined) }; + vi.mocked(loadConfig).mockReturnValue({ + ...defaultMockConfig, + coverage: true, + record: recordConfig, + }); + const page = createMockPage({ + handlers: [{ id: '1', name: 'test1', type: 'test' }], + recorder, + }); + page.evaluate = vi.fn() + .mockResolvedValueOnce([{ id: '1', name: 'test1', type: 'test' }]) // enumeration + .mockResolvedValueOnce([{ id: '1', status: 'pass' }]) // chunk run + .mockRejectedValueOnce(new Error('coverage boom')); // coverage read + puppeteer.launch.mockResolvedValue(createMockBrowser(page)); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + await expect(runTests()).rejects.toThrow('coverage boom'); + + expect(recorder.stop).toHaveBeenCalledTimes(1); + }); + + it("warns instead of claiming success when the artifact is empty", async () => { + vi.mocked(loadConfig).mockReturnValue({ ...defaultMockConfig, record: recordConfig }); + vi.mocked(fs.statSync).mockReturnValue({ size: 0 }); + const page = createMockPage({ + handlers: [{ id: '1', name: 'test1', type: 'test' }], + testStatus: [{ id: '1', status: 'pass' }], + }); + puppeteer.launch.mockResolvedValue(createMockBrowser(page)); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await runTests(); + + const logs = consoleSpy.mock.calls.map((c) => String(c[0])); + expect(logs.some((l) => l.startsWith('Recorded'))).toBe(false); + const warnings = warnSpy.mock.calls.map((c) => String(c[0])); + expect(warnings.some((w) => w.includes('empty file'))).toBe(true); + expect(warnings.some((w) => w.includes('repaints'))).toBe(true); + }); + + it("warns when the artifact was never created at all", async () => { + vi.mocked(loadConfig).mockReturnValue({ ...defaultMockConfig, record: recordConfig }); + vi.mocked(fs.statSync).mockImplementation(() => { + const err = new Error('ENOENT: no such file or directory'); + err.code = 'ENOENT'; + throw err; + }); + const page = createMockPage({ + handlers: [{ id: '1', name: 'test1', type: 'test' }], + testStatus: [{ id: '1', status: 'pass' }], + }); + puppeteer.launch.mockResolvedValue(createMockBrowser(page)); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await runTests(); + + const logs = consoleSpy.mock.calls.map((c) => String(c[0])); + expect(logs.some((l) => l.startsWith('Recorded'))).toBe(false); + const warnings = warnSpy.mock.calls.map((c) => String(c[0])); + expect(warnings.some((w) => w.includes('empty file'))).toBe(true); + }); + + it("reports the recorded artifact when the file has bytes", async () => { + vi.mocked(loadConfig).mockReturnValue({ ...defaultMockConfig, record: recordConfig }); + vi.mocked(fs.statSync).mockReturnValue({ size: 17081 }); + const page = createMockPage({ + handlers: [{ id: '1', name: 'test1', type: 'test' }], + testStatus: [{ id: '1', status: 'pass' }], + }); + puppeteer.launch.mockResolvedValue(createMockBrowser(page)); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await runTests(); + + const logs = consoleSpy.mock.calls.map((c) => String(c[0])); + expect(logs.some((l) => l.startsWith('Recorded 1 test(s) to'))).toBe(true); + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); + +describe("runTests ffmpeg probe", () => { + const recordConfig = { + enabled: true, + dir: './twd-artifacts', + filename: null, + format: 'mp4', + viewport: { width: 1280, height: 720, deviceScaleFactor: 1 }, + fps: 30, + speed: 1, + hideSidebar: true, + ffmpegPath: 'ffmpeg', + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(assertFfmpegAvailable).mockReset(); + vi.mocked(fs.statSync).mockReset(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("probes for ffmpeg when recording is enabled", async () => { + vi.mocked(loadConfig).mockReturnValue({ ...defaultMockConfig, record: recordConfig }); + const page = createMockPage({ + handlers: [{ id: '1', name: 'test1', type: 'test' }], + testStatus: [{ id: '1', status: 'pass' }], + }); + puppeteer.launch.mockResolvedValue(createMockBrowser(page)); + + await runTests(); + + expect(assertFfmpegAvailable).toHaveBeenCalledWith('ffmpeg'); + }); + + it("passes a configured ffmpegPath to the probe", async () => { + vi.mocked(loadConfig).mockReturnValue({ + ...defaultMockConfig, + record: { ...recordConfig, ffmpegPath: '/opt/homebrew/bin/ffmpeg' }, + }); + const page = createMockPage({ + handlers: [{ id: '1', name: 'test1', type: 'test' }], + testStatus: [{ id: '1', status: 'pass' }], + }); + puppeteer.launch.mockResolvedValue(createMockBrowser(page)); + + await runTests(); + + expect(assertFfmpegAvailable).toHaveBeenCalledWith('/opt/homebrew/bin/ffmpeg'); + }); + + it("does not probe for ffmpeg when recording is disabled", async () => { + vi.mocked(loadConfig).mockReturnValue({ ...defaultMockConfig }); + const page = createMockPage({ + handlers: [{ id: '1', name: 'test1', type: 'test' }], + testStatus: [{ id: '1', status: 'pass' }], + }); + puppeteer.launch.mockResolvedValue(createMockBrowser(page)); + + await runTests(); + + expect(assertFfmpegAvailable).not.toHaveBeenCalled(); + }); + + it("fails before launching the browser when ffmpeg is missing", async () => { + // The whole point of the pre-flight probe: no wasted launch + navigation + // before the user learns ffmpeg is not installed. + vi.mocked(loadConfig).mockReturnValue({ ...defaultMockConfig, record: recordConfig }); + vi.mocked(assertFfmpegAvailable).mockImplementation(() => { + throw new Error('Recording requires ffmpeg, which was not found.'); + }); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await expect(runTests()).rejects.toThrow('Recording requires ffmpeg'); + + expect(puppeteer.launch).not.toHaveBeenCalled(); + errorSpy.mockRestore(); + }); +}); + +describe("runTests pre-roll and post-roll", () => { + const rollConfig = { + enabled: true, + dir: './twd-artifacts', + filename: null, + format: 'mp4', + viewport: { width: 1280, height: 720, deviceScaleFactor: 1 }, + fps: 30, + speed: 1, + preRoll: 250, + postRoll: 500, + hideSidebar: true, + ffmpegPath: 'ffmpeg', + }; + + beforeEach(() => { + vi.clearAllMocks(); + // An earlier describe's restoreAllMocks puts these back to their real + // implementations, so neuter them again or the probe actually shells out + // and holdFinalFrame actually drives page.evaluate. + vi.mocked(assertFfmpegAvailable).mockReset(); + vi.mocked(holdOpeningFrame).mockReset(); + vi.mocked(holdFinalFrame).mockReset(); + vi.mocked(fs.statSync).mockReset(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function passingPage(recorder) { + return createMockPage({ + handlers: [{ id: '1', name: 'test1', type: 'test' }], + testStatus: [{ id: '1', status: 'pass' }], + recorder, + }); + } + + it("holds the opening frame after starting the recorder", async () => { + vi.mocked(loadConfig).mockReturnValue({ ...defaultMockConfig, record: rollConfig }); + const page = passingPage(); + puppeteer.launch.mockResolvedValue(createMockBrowser(page)); + + await runTests(); + + expect(holdOpeningFrame).toHaveBeenCalledWith(250); + }); + + it("holds the final frame before the recorder is stopped", async () => { + const order = []; + vi.mocked(holdFinalFrame).mockImplementation(() => { order.push('hold'); }); + const recorder = { + stop: vi.fn(() => new Promise((resolve) => setTimeout(() => { + order.push('stop'); + resolve(); + }, 0))), + }; + vi.mocked(loadConfig).mockReturnValue({ ...defaultMockConfig, record: rollConfig }); + const page = passingPage(recorder); + const browser = createMockBrowser(page); + browser.close = vi.fn(() => { order.push('close'); }); + puppeteer.launch.mockResolvedValue(browser); + + await runTests(); + + // The hold is what gets the last test's result into the video, so it has to + // land before stop(), and stop() before close(). + expect(order).toEqual(['hold', 'stop', 'close']); + expect(holdFinalFrame).toHaveBeenCalledWith(page, 500); + }); + + it("does neither when recording is disabled", async () => { + vi.mocked(loadConfig).mockReturnValue({ ...defaultMockConfig }); + const page = passingPage(); + puppeteer.launch.mockResolvedValue(createMockBrowser(page)); + + await runTests(); + + expect(holdOpeningFrame).not.toHaveBeenCalled(); + expect(holdFinalFrame).not.toHaveBeenCalled(); + }); +}); diff --git a/twd.config.example.json b/twd.config.example.json index 0d7e2da..4b21e07 100644 --- a/twd.config.example.json +++ b/twd.config.example.json @@ -6,5 +6,18 @@ "nycOutputDir": "./.nyc_output", "headless": true, "puppeteerArgs": ["--no-sandbox", "--disable-setuid-sandbox"], - "protocolTimeout": 300000 + "protocolTimeout": 300000, + "record": { + "enabled": false, + "dir": "./twd-artifacts", + "filename": null, + "format": "mp4", + "viewport": { "width": 1280, "height": 720, "deviceScaleFactor": 1 }, + "fps": 30, + "speed": 1, + "preRoll": 0, + "postRoll": 500, + "hideSidebar": true, + "ffmpegPath": "ffmpeg" + } }