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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 207 additions & 0 deletions .github/actions/record/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
name: 'TWD CLI Record'
description: 'Record TWD browser tests to video in CI and upload the clips as an artifact'
branding:
icon: 'video'
color: 'green'

inputs:
working-directory:
description: 'Directory where twd.config.json lives'
required: false
default: '.'
changed-since:
description: >-
Record only the tests this branch added or changed since this ref, e.g.
github.event.pull_request.base.sha. Needs history, so set fetch-depth: 0
on actions/checkout. Mutually exclusive with `tests`.
required: false
default: ''
tests:
description: >-
Newline-separated test titles. Each becomes one --test filter, and filters
are OR'd. Mutually exclusive with `changed-since`.
required: false
default: ''
pace:
description: >-
Milliseconds held after each command, passed to --record-pace. Leave empty
for the CLI default (300). 0 disables pacing.
required: false
default: ''
install-ffmpeg:
description: >-
Install a known-good ffmpeg 8.x. Set false to use whatever is on PATH.
required: false
default: 'true'
upload-artifact:
description: 'Upload the clips as a workflow artifact'
required: false
default: 'true'
artifact-name:
description: 'Name of the uploaded artifact'
required: false
default: 'twd-recording'
retention-days:
description: 'How long to keep the artifact'
required: false
default: '14'

outputs:
clip-count:
description: >-
How many clips were produced. 0 is a valid, non-failing result: a branch
that changed no tests has nothing to record.
value: ${{ steps.clips.outputs.count }}
dir:
description: 'Directory the clips were written to, for a caller doing its own upload'
value: ${{ steps.resolve.outputs.dir }}
artifact-url:
description: 'URL of the uploaded artifact, when this action uploaded it'
value: ${{ steps.upload.outputs.artifact-url }}

runs:
using: 'composite'
steps:
- name: Validate inputs
shell: bash
env:
TESTS: ${{ inputs.tests }}
CHANGED_SINCE: ${{ inputs.changed-since }}
run: |
if [ -n "$TESTS" ] && [ -n "$CHANGED_SINCE" ]; then
echo "::error::Set either 'tests' or 'changed-since', not both. They are two ways to choose the same thing, and combining them silently records more than you asked for."
exit 1
fi

- name: Install ffmpeg
if: inputs.install-ffmpeg == 'true'
shell: bash
env:
# Pinned to the 8.1 branch, not to a distro package and not to "the
# obvious static build". Puppeteer's screencast passes
# -movflags hybrid_fragmented, which arrived after ffmpeg 7: measured,
# 6.1.1 (ubuntu-24.04) no, 7.0.2 (johnvansickle release) no, 8.1.2 yes.
# apt-get install ffmpeg on a GitHub runner gets you 6.1.1 and a run
# that fails at the first frame.
#
# The gpl variant is deliberate: it carries libx264, which twd-cli uses
# to convert the finished mp4 into something that plays outside Chrome.
# One download covers both requirements.
#
# The URL is stable and the build behind it moves within 8.1.x, so there
# is no checksum to pin. That is acceptable here because twd-cli probes
# the binary's actual capability before it launches a browser, so an
# unusable build fails fast with an actionable message rather than
# silently producing a broken clip.
FFMPEG_URL: 'https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n8.1-latest-linux64-gpl-8.1.tar.xz'
run: |
set -euo pipefail

if [ "$RUNNER_OS" != "Linux" ]; then
echo "::warning::install-ffmpeg only provides a build for Linux runners, and this is $RUNNER_OS. Install ffmpeg 8 or newer yourself, or set install-ffmpeg: false to silence this. twd-cli will tell you if what is on PATH cannot record."
exit 0
fi

DEST="$RUNNER_TEMP/twd-ffmpeg"
mkdir -p "$DEST"
curl -fsSL --retry 3 --retry-delay 2 "$FFMPEG_URL" -o "$RUNNER_TEMP/ffmpeg.tar.xz"
tar -xJf "$RUNNER_TEMP/ffmpeg.tar.xz" -C "$DEST" --strip-components=1
echo "$DEST/bin" >> "$GITHUB_PATH"
"$DEST/bin/ffmpeg" -version | head -n 1

- name: Cache Puppeteer browsers
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: ~/.cache/puppeteer
key: ${{ runner.os }}-puppeteer-${{ hashFiles(format('{0}/package-lock.json', inputs.working-directory)) }}
restore-keys: |
${{ runner.os }}-puppeteer-

- name: Install Chrome for Puppeteer
shell: bash
run: npx puppeteer browsers install chrome

- name: Resolve the recording directory
id: resolve
shell: bash
working-directory: ${{ inputs.working-directory }}
run: |
# Same shape as the contract report lookup in the `run` action: the
# config is the source of truth, and the action only needs to know where
# to look afterwards.
DIR=$(node -e "
const fs = require('fs');
let dir = './twd-artifacts';
try {
const config = JSON.parse(fs.readFileSync('twd.config.json', 'utf-8'));
if (config.record && config.record.dir) dir = config.record.dir;
} catch {}
console.log(dir);
")
echo "dir=$DIR" >> "$GITHUB_OUTPUT"
echo "Clips will be written to $DIR"

- name: Record TWD tests
shell: bash
working-directory: ${{ inputs.working-directory }}
env:
CHANGED_SINCE: ${{ inputs.changed-since }}
TESTS: ${{ inputs.tests }}
PACE: ${{ inputs.pace }}
run: |
set -euo pipefail

# Built as an array so a title containing spaces or quotes reaches the
# CLI as one argument. Every value arrives through the environment
# rather than expression interpolation, so nothing in an input can be
# read as shell.
args=(run --record)

if [ -n "$CHANGED_SINCE" ]; then
args+=(--changed-since "$CHANGED_SINCE")
fi

if [ -n "$TESTS" ]; then
while IFS= read -r title; do
title="${title%$'\r'}"
[ -n "$title" ] || continue
args+=(--test "$title")
done <<< "$TESTS"
fi

if [ -n "$PACE" ]; then
args+=(--record-pace "$PACE")
fi

npx twd-cli "${args[@]}"

- name: Count the clips
id: clips
# always(): a failed run can still have written a partial clip, and that
# clip is evidence. The step above has already failed the action, so
# counting and uploading here cannot turn a red run green.
if: always()
shell: bash
working-directory: ${{ inputs.working-directory }}
env:
DIR: ${{ steps.resolve.outputs.dir }}
run: |
COUNT=0
if [ -n "$DIR" ] && [ -d "$DIR" ]; then
COUNT=$(find "$DIR" -maxdepth 1 -type f \( -name '*.mp4' -o -name '*.webm' -o -name '*.gif' \) | wc -l | tr -d ' ')
fi
echo "count=$COUNT" >> "$GITHUB_OUTPUT"
echo "Recorded $COUNT clip(s)."

- name: Upload the recording
id: upload
# Skipped at zero clips on purpose. "This branch changed no tests" is a
# normal outcome that must not fail the action, and upload-artifact with
# if-no-files-found: error would fail it.
if: always() && inputs.upload-artifact == 'true' && steps.clips.outputs.count != '' && steps.clips.outputs.count != '0'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: ${{ inputs.artifact-name }}
path: ${{ inputs.working-directory }}/${{ steps.resolve.outputs.dir }}
retention-days: ${{ inputs.retention-days }}
if-no-files-found: error
32 changes: 32 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,38 @@ These are load-bearing and easy to undo by accident:
- **The required movflags are puppeteer's, not ours.** `REQUIRED_MOVFLAGS` mirrors `ScreenRecorder#getFormatArgs` in puppeteer-core, so re-read that method on a puppeteer bump. This is why the preflight probes `-h muxer=mp4` rather than pinning a version floor — a floor written from one measurement ("7 or newer") was already wrong on the second.
- **The screencast's own output does not play outside Chrome.** Puppeteer feeds ffmpeg PNG frames with no `-pix_fmt`, so RGB rides into VP9 and the file lands as vp9/`gbrp` in an mp4 container. QuickTime and Preview open neither. `transcodeForPlayback()` re-encodes to h264/`yuv420p` in place after the run; measured on a real capture it also cut 202805 bytes to 49222. Failure there is a warning, never fatal — the untranscoded file is still a correct recording.

## Composite actions

`.github/actions/run` and `.github/actions/record` are siblings and should stay
shaped alike. Both assume the app is **already served** at the url in
`twd.config.json` — starting a dev server belongs to the caller's workflow, not
to the action.

Conventions that are load-bearing in both:

- **Workflow policy stays with the caller.** The trigger, the PR comment, the
label, `timeout-minutes` and `continue-on-error` are per-repo decisions. An
action that comments on a PR also needs `pull-requests: write`, which a caller
should grant deliberately rather than inherit.
- **Every `${{ }}` reaches a script through `env:`**, never interpolated into a
`run` body, and multi-value inputs are built into a bash **array** so a title
containing spaces or quotes stays one argument.
- **Third-party actions are pinned by commit SHA** with a `# v4`-style comment.
- **`if: always()` on the upload steps**, so a failed run still surfaces its
partial evidence without turning the job green.

`record` installs ffmpeg **8.x** from BtbN's `n8.1` build rather than apt:
ubuntu-24.04 ships 6.1.1, which rejects the `-movflags hybrid_fragmented`
puppeteer passes. The `gpl` variant also carries `libx264` for the H.264
conversion, so one download covers both. There is deliberately **no capability
check in the action** — that lives in the CLI preflight, so every user gets it
and not only Actions users. The bundled build is Linux-only; other runners get a
warning and skip.

`clip-count: 0` is a success, not a failure: a branch that changed no tests has
nothing to record. That is why the upload step is skipped at zero rather than
running with `if-no-files-found: error`.

**`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
Expand Down
93 changes: 93 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,99 @@ The action runs in the same job, so coverage data is available for subsequent st
run: npm run collect:coverage:text
```

### Recording a PR's tests (the `record` action)

The sibling of the `run` action, for clips rather than results. It installs a
known-good ffmpeg, records, and uploads the result:

```yaml
- uses: BRIKEV/twd-cli/.github/actions/record@main
with:
changed-since: ${{ github.event.pull_request.base.sha }}
```

`changed-since` is what keeps the clip watchable — it records only the tests the
branch touched, rather than the whole suite. See
[Running only what this branch changed](#running-only-what-this-branch-changed).

#### Record action inputs

| Input | Default | Description |
|-------|---------|-------------|
| `working-directory` | `.` | Directory where `twd.config.json` lives |
| `changed-since` | (empty) | A ref. Records only the tests changed since it. Needs `fetch-depth: 0` on checkout. Mutually exclusive with `tests` |
| `tests` | (empty) | Newline-separated test titles, one `--test` each. Mutually exclusive with `changed-since` |
| `pace` | (empty) | Passed to `--record-pace`. Empty uses the CLI default of 300; `0` disables pacing |
| `install-ffmpeg` | `true` | Install ffmpeg 8.x. Set `false` to use whatever is on `PATH` |
| `upload-artifact` | `true` | Upload the clips as an artifact |
| `artifact-name` | `twd-recording` | Name of the artifact |
| `retention-days` | `14` | How long to keep it |

#### Record action outputs

| Output | Description |
|--------|-------------|
| `clip-count` | How many clips were produced. **`0` is a valid, non-failing result** — a branch that changed no tests has nothing to record |
| `dir` | Where the clips are, for a caller that wants to do its own upload |
| `artifact-url` | URL of the artifact, when the action uploaded it |

#### Why it installs ffmpeg

Because the distro build is not good enough, and finding that out the hard way is
expensive. Puppeteer's screencast passes `-movflags hybrid_fragmented`, which
arrived after ffmpeg 7 — `apt-get install ffmpeg` on `ubuntu-24.04` gets you
6.1.1, which rejects it. The action installs an 8.1.x build whose `gpl` variant
also carries `libx264`, which the H.264 conversion needs. Set
`install-ffmpeg: false` if you manage your own; `twd-cli` checks the binary can
actually do the job before it launches a browser either way.

Only Linux runners get the bundled build. On macOS or Windows the step warns and
skips, so install ffmpeg 8+ yourself there.

#### Reference workflow

Recording is triggered by a label here, but that part is policy — record every PR
to `main` if you prefer. The trigger, the PR comment and the dev server stay in
your workflow rather than the action, exactly as they do for `run`:

```yaml
name: Record a PR's tests
on:
pull_request:
types: [labeled]

jobs:
record:
if: github.event.label.name == 'record'
runs-on: ubuntu-latest
timeout-minutes: 15 # a hung recording must not cost the whole job
permissions: { contents: read, pull-requests: write }
steps:
- uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0 # --changed-since needs history
- uses: actions/setup-node@v5
with: { node-version: 24, cache: npm }
- run: npm ci
- run: |
nohup npm run dev > vite.log 2>&1 &
npx wait-on http://localhost:5173
- uses: BRIKEV/twd-cli/.github/actions/record@main
id: rec
continue-on-error: true # a clip is optional; the PR it describes is not
with:
changed-since: ${{ github.event.pull_request.base.sha }}
- if: steps.rec.outputs.clip-count != '0'
run: gh pr comment "$PR" --body "${{ steps.rec.outputs.clip-count }} clip(s): ${{ steps.rec.outputs.artifact-url }}"
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
```

The `timeout-minutes` and `continue-on-error` are belt, not workaround. A
recording is always optional; the pull request it describes is not.

### Custom setup (without the action)

If you prefer full control, set up each step manually. Puppeteer 24+ no longer auto-downloads Chrome, so you need to install it explicitly:
Expand Down
Loading