Skip to content

Recording Pest browser tests in GitHub Actions

Record selected Pest browser tests on pull requests and publish videos to Diff Stage with a two-job GitHub Actions workflow and OIDC authentication.

Guide 2 of 34 min readPest 5 · Browser 5.1.2 · PHP 8.5

On this page

Before you start

This example uses Pest 5, Browser 5.1.2, PHP 8.5 and Playwright 1.63.0.

Add a separate evidence workflow that records selected browser tests at the pull request’s head commit. Diff Stage posts a player link on the PR so reviewers can watch the change.

Choose a useful browser journey#

Use a test that reaches the changed behaviour, performs the relevant interaction and asserts the visible outcome. Keep the full regression suite in its own jobs.

For example, a checkout message change needs a checkout journey. A homepage smoke test cannot prove that change. One selected file can contain several tests, so inspect every journey it runs.

Pass paths to Pest to limit execution:

Terminalshell
./vendor/bin/pest tests/Browser/CheckoutTest.php --record-videos

--record-videos-only limits recording; it does not limit which tests execute. Do not add arbitrary waits to make footage readable: the recorder and hosted processing provide reading time.

Save the workflow#

Save the following as .github/workflows/browser-evidence.yml. Adapt Prepare Laravel and Playwright and the test environment to your application. The example assumes committed Composer and npm lockfiles, Playwright in package.json, a Vite build and SQLite-compatible migrations.

.github/workflows/browser-evidence.ymlyaml
name: Browser evidence

on:
  pull_request:
    types: [opened, synchronize, reopened, edited]

permissions:
  contents: read

concurrency:
  group: browser-evidence-${{ github.event.pull_request.number }}
  cancel-in-progress: true

jobs:
  record:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: read
    outputs:
      tests: ${{ steps.existing.outputs.tests }}
    env:
      APP_ENV: testing
      APP_DEBUG: 'false'
      DB_CONNECTION: sqlite
      DB_DATABASE: ${{ github.workspace }}/database/database.sqlite
      DB_URL: ''
      FILESYSTEM_DISK: local
      CACHE_STORE: array
      SESSION_DRIVER: array
      MAIL_MAILER: array
      QUEUE_CONNECTION: sync
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
          persist-credentials: false
      - id: select
        uses: diff-stage/recorder/select@10e02da13722139b2f872fc155eb264056e42e3b
      - id: existing
        name: Keep existing selected browser tests
        env:
          TESTS: ${{ steps.select.outputs.tests }}
        run: |
          IFS=, read -ra selected <<< "$TESTS"
          tests=()
          for test in "${selected[@]}"; do
            if [[ "$test" =~ ^tests/Browser/[a-zA-Z0-9_/-]+Test\.php$ && "$test" != *..* && -f "$test" ]]; then
              tests+=("$test")
            fi
          done
          IFS=,
          echo "tests=${tests[*]}" >> "$GITHUB_OUTPUT"
          if (( ${#tests[@]} == 0 )); then
            echo 'No existing selected browser tests; skipping reviewer recordings.'
          fi
      - uses: shivammathur/setup-php@b604ade2a87db23f8871b7182e69ec5e75effb45 # v2
        if: steps.existing.outputs.tests != ''
        with:
          php-version: '8.5'
          extensions: pdo_sqlite, sockets
          coverage: none
      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
        if: steps.existing.outputs.tests != ''
        with:
          node-version: '24'
      - name: Prepare Laravel and Playwright
        if: steps.existing.outputs.tests != ''
        run: |
          composer install --no-interaction --prefer-dist
          cp .env.example .env
          php artisan key:generate --no-interaction
          touch database/database.sqlite
          php artisan migrate --force --no-interaction
          npm ci
          npx --no-install playwright install --with-deps chromium
          npm run build
      - name: Record selected flows
        if: steps.existing.outputs.tests != ''
        env:
          TESTS: ${{ steps.existing.outputs.tests }}
        run: |
          rm -rf tests/Browser/Videos
          mkdir -p tests/Browser/Videos
          git rev-parse HEAD > tests/Browser/Videos/sha.txt
          IFS=, read -ra tests <<< "$TESTS"
          ./vendor/bin/pest "${tests[@]}" --record-videos --record-videos-fast
      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
        if: steps.existing.outputs.tests != ''
        with:
          name: browser-videos-${{ github.event.pull_request.head.sha }}
          path: tests/Browser/Videos
          if-no-files-found: error
          retention-days: 7

  publish:
    needs: record
    if: needs.record.outputs.tests != '' && github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
      id-token: write
    steps:
      - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
        with:
          name: browser-videos-${{ github.event.pull_request.head.sha }}
          path: tests/Browser/Videos
      - name: Check recording provenance
        env:
          HEAD_SHA: ${{ github.event.pull_request.head.sha }}
        run: |
          [[ "$(cat tests/Browser/Videos/sha.txt)" == "$HEAD_SHA" ]]
          compgen -G 'tests/Browser/Videos/*.webm' >/dev/null
      - uses: diff-stage/recorder/publish@dcbfd210e743beec326e16d8ba2bc1b74b4306c8
        with:
          url: https://diffstage.com

The recording job checks out the PR’s head SHA and runs without publishing rights. The publishing job downloads that job’s artifact and checks its recorded SHA before upload. It never checks out or runs application code.

Only publishing receives id-token: write and permission to comment on the PR. GitHub Actions identity authenticates the upload to the connected repository; no upload secret is required. GitHub explains OIDC authentication here.

Select recordings in the PR description#

Add a line with exact browser test paths:

Pull request descriptiontext
Browser videos: tests/Browser/CheckoutTest.php, tests/Browser/BookingTest.php

The selector uses this explicit list. Changed test files are not selected automatically. Paths must match tests/Browser/...Test.php; globs and individual test names are not supported by this selection.

If no browser journey demonstrates the change, leave the line empty or omit it and explain your verification in the PR. That skips evidence recording and publication, while your separate regression jobs still run.

Tell reviewers what the videos prove#

After a local recording, take each flow key from the actual .webm filename without its extension:

Pull request descriptionmarkdown
Browser review:
1. `actual-recording-filename-without-extension`: Check the new validation message after submitting an empty form.

Replace the example key with yours. The publisher orders matching recordings by this list and includes the notes in its PR comment. Unmatched keys produce a warning.

Check the published result#

Open the workflow run. Both jobs must succeed. Then open the Diff Stage comment, confirm its commit matches the PR head and play every selected recording. Fast recordings may still be processing after CI finishes.

Fork and Dependabot PRs retain recordings as GitHub Actions artifacts without hosted publication in this workflow.

To compare a PR against approved behaviour, record the same flows on your default branch and publish with mode: baseline. Keep that recording job separate from the publisher too. Use a push trigger for your actual default branch, record its checked-out SHA in sha.txt, and publish only after recording succeeds. Keep baseline recordings in one consistent workflow so github.run_number gives them a consistent order.

The baseline recording step can use:

Baseline recording stepshell
rm -rf tests/Browser/Videos
mkdir -p tests/Browser/Videos
git rev-parse HEAD > tests/Browser/Videos/sha.txt
./vendor/bin/pest tests/Browser --record-videos --record-videos-fast

After the baseline publisher downloads and verifies that artifact, its final step is:

Baseline publish stepyaml
- uses: diff-stage/recorder/publish@dcbfd210e743beec326e16d8ba2bc1b74b4306c8
  with:
    mode: baseline
    url: https://diffstage.com

Next, compare the recordings before merging.

Troubleshooting#

  • Recording is skipped: check the Browser videos: line and that each selected file exists. An empty selection is expected to skip publication.
  • Upload authentication fails: check the repository’s GitHub App connection, team plan and id-token: write on the publisher. Upload tokens are not supported.
  • SHA mismatch: rerun recording for the current PR head. Do not edit sha.txt to make older footage pass the check.
  • CI passes but no player is ready: inspect the publisher result and Diff Stage’s processing status. An artifact alone is not a hosted recording.
  • A previous comment remains: check its SHA. Removing the evidence selection does not make an older recording evidence for the new commit.

Show your reviewers what changed

Add the recorder to your test suite, and your next pull request gets a video.