Currents Team
Currents Team
•

Playwright Video Recording in CI: Modes, Cost and Fixes

Playwright video recording in CI: pick a mode that fits your retries, measure what video costs, upload videos per shard and fix missing or empty ones.

Playwright Video Recording in CI: Modes, Cost and Fixes

The test passes on your laptop and fails in CI. The log says a click timed out, and that's all it says. Something on the page was different in CI, and a video would show you what. Playwright Test didn't record one. Recording is controlled by the video option in your config, and video is off by default.

Turning video on takes one line, and that line can go wrong in two ways. If you pick a mode that only records retries while retries are off, CI keeps nothing. If you pick a mode that records every run, every passing test pays to encode a video, and the artifacts pile up job after job.

What you want is one useful video for each real failure, kept somewhere you can still download it after the run.

Pick the video mode that matches your retries

Every video mode sets two rules: which runs it records, and which recordings it keeps. Playwright calls a test's initial run the "first run" and every run caused by a retry a "retry", and the modes split along that line:

  • 'off': the default. Nothing is recorded.
  • 'retain-on-failure': records every run and keeps the video only for runs that failed. A failed run's video stays even when a later retry passes.
  • 'retain-on-first-failure': records only the first run, never a retry, and keeps it only if that run failed.
  • 'retain-on-failure-and-retries': records every run and keeps the video of any run that failed or is a retry.
  • 'on-first-retry': records and keeps a video only for a test's first retry.
  • 'on-all-retries': records and keeps a video for every retry.

The last two do nothing unless a test is retried, and Playwright doesn't retry failing tests by default.

The anti-pattern: retry modes with no retries

The common mistake asks for the first retry's video and never turns retries on:

// Anti-pattern: on-first-retry with retries left at the default, so no video is ever kept
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    video: 'on-first-retry',
  },
});

The config loads and the suite runs. Then a CI test breaks. It fails once and nothing retries it, so the first retry never runs and there is no video to download.

What each mode keeps with retries on

Once retries are on, the modes stop looking alike. Here is what each one keeps with retries: 2 for three tests: one that passes on its first run, a flaky one that fails once and then passes, and one that fails all three runs.

ModePasses first timeFlaky: fails, then passesAlways fails
retain-on-failurenothingthe failed first runall three runs
retain-on-first-failurenothingthe failed first runthe first run
retain-on-failure-and-retriesnothingthe failed first run and the passing retryall three runs
on-first-retrynothingthe passing first retrythe first retry
on-all-retriesnothingthe passing first retryboth retries

The flaky column is where the choice matters. Under on-first-retry, the only video of a flaky test is the retry that passed, so what you get is a recording of the test working.

The config we'd ship

The fix derives the mode from the retry count, so the mode never waits for runs that won't happen:

import { defineConfig } from '@playwright/test';

const retries = process.env.CI ? 2 : 0;

export default defineConfig({
  retries,
  use: {
    // on-first-retry only records when a retry runs, so it needs retries above 0.
    // Swap in 'retain-on-first-failure' to keep the failing run of a flaky test.
    video: retries > 0 ? 'on-first-retry' : 'retain-on-failure',
  },
});

If CI runs without retries, use retain-on-failure. Every failed run keeps its video there, while the retry modes would keep nothing. If retries are on, start with on-first-retry. It records one run per failing test and nothing for tests that pass on the first try, so it is the cheapest mode that still catches failures.

Switch to retain-on-first-failure when flaky tests are what you're chasing and you need the run that failed, not the retry that passed. That mode records every test's first run, so passing tests pay for encoding again. If you're still working out why a test fails once and passes on retry, start with what makes a test flaky and how to fix it.

What video costs per test, and how size and fps change it

retain-on-failure looks cheap because the artifact folder stays small. Don't judge a mode by the files it leaves behind. The mode records a video for every run and keeps it only for runs that failed, so every passing test still records and encodes a video that Playwright then throws away. The CPU cost lands on the tests that pass. In a healthy suite, that's nearly every test.

Size and frame rate

Two settings decide how much work each recording takes. If you don't set a size, the video is the viewport scaled down to fit into 800x800. If the viewport isn't configured explicitly either, the video is 800x450. Higher frame rates and larger sizes both use more CPU for encoding. Firefox and WebKit capture at most 25 frames per second, so a higher fps gets you nothing in those browsers.

The encoder changed too. Playwright now encodes videos with VP9 instead of VP8, which takes less CPU and produces smaller files of the same or better quality. Playwright also added an fps option to testOptions.video, next to size.

The object form sets both. This config also reads an environment variable, so you can turn recording off for a measurement run:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    video: {
      mode: process.env.PW_VIDEO === 'off' ? 'off' : 'retain-on-failure',
      // Small frames and a low frame rate keep encoding cheap on every recorded run.
      size: { width: 640, height: 360 },
      fps: 10,
    },
  },
});

For CI evidence, readable beats sharp. A small frame at a low frame rate is enough to see what covered the button.

Measure it on your suite

The cost per test depends on your viewport, your pages and how long your tests run, so measure it on your own suite. Run the suite once with video off and once with it on, and check test-results after each run, before you start the next:

time PW_VIDEO=off npx playwright test
du -sh test-results
time npx playwright test
du -sh test-results
du -sh test-results/* | sort -h | tail -n 10

The difference in wall time is what recording every run costs. The folder size only counts failed runs, because retain-on-failure doesn't keep the rest, so include one failing test in the comparison run. The last command lists the largest per-test folders, which is your size per failure.

Shrinking the frame has a floor. In a Playwright feature request, a user reported poor recording quality at 1280x720 and asked for more control over it. Check that your chosen size is readable before you tune it down further.

The same video option can also turn on action annotations. Leave them off in CI; annotations are for a viewer who needs guiding, and a failure video only needs the page as the test saw it.

Upload videos from CI before the next run wipes them

A video you can't download is no better than no video. Playwright writes video files to the test output directory, typically test-results. It creates a unique subdirectory in it for each test, so tests running in parallel don't conflict.

Playwright also cleans the output directory at the start of every run, so a later run on the same workspace deletes the earlier run's videos as it starts. Don't leave videos in a cached workspace for a later job to collect. Upload them as an artifact in the same job that produced them.

This workflow runs a sharded suite and uploads each job's test-results, including when tests fail:

name: Playwright Tests
on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shardIndex: [1, 2, 3]
        shardTotal: [3]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: lts/*
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
      - uses: actions/upload-artifact@v4
        # Run after a failed test step too: that's the run you need videos from.
        if: ${{ !cancelled() }}
        with:
          name: test-results-${{ matrix.shardIndex }}
          path: test-results/
          retention-days: 7

The upload step is the one that matters. Give it a condition so it runs after the test step fails, since a failing run is the only one you'll want videos from. Give each matrix job's artifact its own name, so the jobs don't collide. Set retention-days to how long a failure stays worth investigating. The example keeps a week, and retention is also the setting that decides what artifact storage adds to your GitHub Actions bill.

Before you trust the setup, break it on purpose. Add a failing assertion to one test, push, and download that job's artifact. You should find the test's subdirectory with a video inside. If the folder is there and the video isn't, check the mode against your retries first.

When a trace makes video redundant

Playwright's docs recommend tracing in CI on the first retry with trace: 'on-first-retry', which produces a trace.zip for each retried test. If you already do that, every retried failure comes with a trace before you record a single frame of video.

The video option supports the same modes as trace, and the two record and keep files by the same rules. Set them to the same mode and you get both artifacts from exactly the same runs. Don't turn both on in every project by default: each retried test then carries two artifacts, including the failures the trace already explains.

We'd rely on the trace alone when failures come down to locators, network calls or app state. Our guide to debugging Playwright tests that fail only in CI covers tracing alongside verbose logs. Add video when someone needs to watch rendering or timing, or when the people triaging failures won't open a trace. A screenshot shows where the test ended up, and a video shows the sequence that got it there. For a failure that only appears mid-flow, the final screenshot won't be enough.

This config traces every project on the first retry and adds video, with the same mode, only to the project whose failures need watching:

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  retries: process.env.CI ? 2 : 0,
  use: {
    trace: 'on-first-retry',
  },
  projects: [
    {
      name: 'chromium',
      testIgnore: /checkout\/.*\.spec\.ts/,
      use: { ...devices['Desktop Chrome'] },
    },
    {
      // Layout and animation failures here are easier to watch than to inspect.
      name: 'checkout-ui',
      testMatch: /checkout\/.*\.spec\.ts/,
      use: { ...devices['Desktop Chrome'], video: 'on-first-retry' },
    },
  ],
});

Because both options use 'on-first-retry', every checkout video sits next to the trace of the same run.

Record outside the test runner with recordVideo and saveAs

Outside Playwright Test, a context records only when you set recordVideo, and then it records every page into recordVideo.dir, whether the flow passes or fails.

Don't keep every one of those files. A script that runs on every deploy fills videos/ with recordings of flows that worked, and you have no reason to open them. Keep the failures and throw the rest away. This script records a checkout, saves the video under a descriptive name with video.saveAs() if the flow failed, and removes it with video.delete() if it passed:

import { chromium } from 'playwright';

async function recordCheckout() {
  const browser = await chromium.launch();
  const context = await browser.newContext({ recordVideo: { dir: 'videos/' } });
  const page = await context.newPage();
  const video = page.video();
  let failed = false;

  try {
    await page.goto('https://example.com/checkout');
    await page.getByRole('button', { name: 'Place order' }).click();
  } catch (error) {
    failed = true;
    console.error(error);
  } finally {
    // The video is only complete once the context closes; close it before the browser.
    await context.close();
  }

  if (video) {
    if (failed) {
      await video.saveAs(`videos/checkout-failed-${Date.now()}.webm`);
    } else {
      await video.delete();
    }
  }

  await browser.close();
}

recordCheckout();

Screencast: record one part of a test, not the whole run

Screencast recording gives you precise start and stop control, as an alternative to recordVideo, so a test can record the checkout and nothing before it. screencast.start() accepts the new fps option too.

This test records only the checkout steps, and the finally block stops the screencast even when a step fails:

import { test, expect } from '@playwright/test';

test('checkout completes', async ({ page }) => {
  await page.goto('https://example.com/cart');

  // Record only the checkout flow, not the setup before it.
  await page.screencast.start({ path: 'screencasts/checkout.webm', fps: 15 });
  try {
    await page.getByRole('button', { name: 'Checkout' }).click();
    await page.getByLabel('Email').fill('user@example.com');
    await page.getByRole('button', { name: 'Place order' }).click();
  } finally {
    await page.screencast.stop();
  }

  await expect(page.getByText('Order confirmed')).toBeVisible();
});

The frame size you pass can lose to settings you forgot about. If a screencast is already active, for example one started by tracing or video recording, the existing configuration takes precedence over the frame size you request. Run screencast tests in a project with video and trace off when the frame size matters.

The agent CLI's video recording commands, with chapter cards, overlays, action callouts and hero scripts run through run-code, are built for documentation and demo videos. Keep CI evidence on the video option, and leave those commands to demos and walkthroughs.

Why videos go missing or come out empty in CI

Playwright Test saves videos when the browser context closes at the end of a test. Custom fixtures are where that breaks. A fixture that creates its own context takes over the job of closing it, and if it never closes the context, the video never gets finished. The file then turns up missing or empty for exactly the test you wanted to watch.

Here is the anti-pattern, a fixture that records and never cleans up:

// Anti-pattern: the context is never closed, so its video is never finished
import { test as base, type Page } from '@playwright/test';

export const test = base.extend<{ recordedPage: Page }>({
  recordedPage: async ({ browser }, use) => {
    const context = await browser.newContext({ recordVideo: { dir: 'videos/' } });
    const page = await context.newPage();
    await use(page);
  },
});

The fix closes the context in teardown, awaited, and saves the video with saveAs():

import { test as base, type Page } from '@playwright/test';

export const test = base.extend<{ recordedPage: Page }>({
  recordedPage: async ({ browser }, use, testInfo) => {
    const context = await browser.newContext({ recordVideo: { dir: 'videos/' } });
    const page = await context.newPage();
    await use(page);

    const video = page.video();
    // Closing the context is what finishes the video file.
    await context.close();
    if (video) {
      // Save into this test's own output folder, which the CI upload already collects.
      await video.saveAs(testInfo.outputPath('recorded-page.webm'));
    }
  },
});

Two more causes come from teardown order and from where the browser runs. If teardown closes the browser first, close the context before it, so the context closes gracefully and its videos are fully flushed. If CI connects to a remote browser server, video.path() throws, so code that reads videos by path breaks in CI and nowhere else. We'd use saveAs() there, since it waits until the video is fully saved.

Where retain modes stop helping at suite scale

Every mode above keeps or drops a run's video as a whole. A test that fails at the end of a long flow leaves the whole flow on video, and you scrub to the last seconds to find what broke. In a Playwright feature request, a user asked for an option to keep only the last N seconds of video and trace recordings, to cut CI artifact size.

The other limit is finding the file. With one artifact per job, the video of one failing test sits in one job's archive. You either know which job ran that test, or you download archives one at a time until you find it.

Currents' Run Attachments tab lists every video of a run in one place, filtered to videos only or by spec, test or CI machine, and opens each one in a viewer that steps through the other files of the same group. Currents collects results from every machine and worker into one run using the CI Build ID, so you no longer stitch per-job uploads together by hand.

None of that helps if the kept video is the wrong one. Before you add the next shard, open the video of the last test that failed in CI and check that it shows the failure. If it shows a passing retry, fix the mode first. Storage and search only matter once the video you keep is the one you need.


Scale your Playwright tests with confidence.
Join hundreds of teams using Currents.
Learn More

Trademarks and logos mentioned in this text belong to their respective owners.

Related Posts