Screenshots, Videos & Traces

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

A failed browser test on a CI machine is useful only if it leaves something behind that explains the failure. That something is evidence: a picture of the page at the moment of failure, a film of the whole test, a trace that can replay every step with the DOM, the network and the console, or a PDF of the page. This page covers how Playwright, Cypress and Selenium capture each kind, which options control what is kept, when to capture on failure only and when always, and how to get the files out of a CI machine. It is written for Playwright 1.64, Cypress 16.2 and Selenium 4.51 and was checked on 25 October 2026. Every example for which a run is mentioned was run against the Bookshelf example application: Playwright 1.64.1 in headless Chromium, Cypress 16.2.0 in headless Chrome 155 and Electron, Selenium 4.51.0 (Java with JUnit 5 and Python with pytest) in headless Chrome 155, and Firefox in a Selenium Grid container with a video recorder. Pieces that need accounts or other platforms were not run, and each says so.

Comparing screenshots with approved baselines is a different job, covered in Visual Regression Testing. Inspecting traces interactively, the Inspector and codegen are in Playwright: Debugging, Tracing & Codegen and Cypress: Debugging, Studio, Plugins & Reporters. The retry settings that decide which runs are recorded are explained in Playwright: Parallelism, Sharding & Retries, and the uploading, sharding and caching around them in Running in CI/CD.

What each tool can capture

Evidence Playwright Cypress Selenium

Page screenshot

page.screenshot(), or automatically with the screenshot option

cy.screenshot(), and automatically on failure in cypress run

TakesScreenshot (Java, .NET), get_screenshot_as_file and friends (Python); W3C command

Element screenshot

locator.screenshot()

cy.get(...).screenshot()

WebElement.getScreenshotAs() / element.screenshot()

Full page

fullPage: true

capture: 'fullPage' (the default for cy.screenshot())

Firefox only in the classic API (HasFullPageScreenshot); BiDi origin: document in Chrome and Firefox

Mask or hide dynamic regions

mask, maskColor, style

blackout, onBeforeScreenshot

None; hide the element yourself with script before the call

Video

video option and recordVideo; page.screencast for scripted recordings

video: true in cypress run, MP4 per spec

None in the driver; the selenium/video sidecar of docker-selenium films a Grid Node’s display

Trace with DOM snapshots

Yes (trace option, context.tracing)

No; Test Replay in Cypress Cloud is the equivalent

No

Print to PDF

page.pdf() (Chromium only)

None; use cy.task or a request

PrintsPage / print_page(); BiDi browsingContext.print

Attach to a report

testInfo.attach(); the HTML report shows screenshots, videos and traces

Cypress Cloud shows them; local reporters need a plugin

JUnit, pytest and Allure style reporters, or write files and upload them

The first difference is how much happens for you. Playwright and Cypress decide when to capture from configuration, so a suite can leave evidence for every failure without a line of test code. Selenium captures only when your code asks, so the screenshot on failure that the other two give you has to be built (see Screenshot on failure). The second difference is the depth of what is recorded: a Playwright trace contains DOM snapshots before and after each action, network traffic and console output; a Cypress screenshot or video is a picture, and only Cypress Cloud’s Test Replay is comparable to a trace.

Playwright

Playwright Test can write four kinds of artifact for every test: a screenshot, a video, a trace and (for failures) an error-context.md file. They are controlled by three use options and land in the outputDir (test-results by default), one folder per test. The examples use this configuration, which keeps all three kinds only for failing runs:

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

export default defineConfig({
  testDir: './tests',
  outputDir: './test-results',
  testIgnore: process.env.SHOW_FAILURES ? [] : ['**/failing/**'],
  workers: 1, // every test resets the one shared Bookshelf server
  retries: process.env.RETRIES ? Number(process.env.RETRIES) : 0,
  reporter: [['list'], ['html', { open: 'never' }]],
  use: {
    baseURL: process.env.BASE_URL,
    testIdAttribute: 'data-testid',
    screenshot: { mode: 'only-on-failure', fullPage: true },
    video: { mode: 'retain-on-failure', size: { width: 1280, height: 720 }, fps: 30 },
    trace: { mode: 'retain-on-failure', screenshots: true, snapshots: true, sources: true, attachments: true },
  },
  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'], viewport: { width: 1280, height: 720 } } }],
});

Screenshots with page.screenshot

page.screenshot() returns a PNG Buffer and, when path is given, also writes a file whose extension selects the format. locator.screenshot() captures one element. The full list of options for Playwright 1.64 is:

Option Effect

path

File to write. The extension (png, jpeg, webp) sets the type unless type is given. Without path nothing is saved.

type

png (default), jpeg or webp.

quality

0 to 100, for jpeg (default 80) and webp. WebP at 100 (the default) is lossless, lower values are lossy. Not for PNG.

fullPage

true captures the whole scrollable page instead of the viewport.

clip

{ x, y, width, height } rectangle to crop to.

mask, maskColor

Locators whose bounding boxes are painted over with a solid box before the capture. Default colour pink #FF00FF. Invisible elements are masked too.

style

CSS applied only while the screenshot is taken, piercing shadow DOM and iframes. The way to hide clocks and carets.

animations

disabled fast-forwards finite animations to completion and cancels infinite ones; allow (default) leaves them.

caret

hide (default) hides the text caret; initial leaves it.

scale

device (default, one image pixel per device pixel) or css (one per CSS pixel, smaller files on high-DPI screens).

omitBackground

Transparent background instead of white. Not for JPEG.

timeout, signal

Maximum wait (no timeout by default) and an AbortSignal (since 1.62).

WebP support for page.screenshot() and locator.screenshot() arrived in Playwright 1.62, together with WebP snapshots for toHaveScreenshot. The next test takes the same page in every variant and prints the file sizes:

import { test, expect } from '@playwright/test';
import * as fs from 'node:fs';

test.beforeEach(async ({ request }) => {
  await request.post('/api/test/seed');
});

test('screenshot options: full page, element, clip, mask, formats', async ({ page }, testInfo) => {
  await page.goto('/books');
  await expect(page.getByTestId('result-count')).toHaveText('24 books found');

  const out = (name: string) => testInfo.outputPath(name);
  await page.screenshot({ path: out('viewport.png') });
  await page.screenshot({ path: out('full.png'), fullPage: true });
  await page.getByTestId('result-count').screenshot({ path: out('count.png') });
  await page.screenshot({ path: out('clip.png'), clip: { x: 0, y: 0, width: 400, height: 200 } });
  await page.screenshot({
    path: out('masked.png'),
    mask: [page.getByTestId('result-count')],
    maskColor: '#222222',
  });
  await page.screenshot({ path: out('full.webp'), fullPage: true, type: 'webp', quality: 60 });
  await page.screenshot({ path: out('full.jpeg'), fullPage: true, quality: 60 });
  await page.screenshot({ path: out('retina.png'), scale: 'css', animations: 'disabled', caret: 'hide' });
  const buffer = await page.screenshot({ style: '[data-testid=theme-toggle] { visibility: hidden }' });

  for (const name of ['viewport.png', 'full.png', 'count.png', 'clip.png', 'masked.png', 'full.webp', 'full.jpeg', 'retina.png']) {
    console.log(name.padEnd(14), String(fs.statSync(out(name)).size).padStart(7), 'bytes');
  }
  console.log('buffer         ', String(buffer.length).padStart(7), 'bytes');
});

It passed, and printed:

viewport.png     65809 bytes
full.png         90138 bytes
count.png         2506 bytes
clip.png          7697 bytes
masked.png       63640 bytes
full.webp        31196 bytes
full.jpeg        61324 bytes
retina.png       65809 bytes
buffer           64597 bytes

The full-page image is larger than the viewport one because the 24 books fill more than 720 pixels; the lossy WebP at quality 60 is about a third of the PNG of the same page and about half of the JPEG. retina.png equals viewport.png because the headless Chromium device pixel ratio is 1; on a Retina screen (or with deviceScaleFactor: 2) scale: 'css' is what keeps the images at one pixel per CSS pixel. The default for the screenshot Playwright Test option, by contrast, is off; the screenshots above were requested explicitly, not by configuration.

Automatic screenshots, videos and traces

The three use options choose when an artifact is kept. screenshot accepts off, on, only-on-failure and on-first-failure, or an object with mode, fullPage and omitBackground. video and trace accept the same, richer set of modes (video gained the full set in Playwright 1.61):

Mode What is recorded and kept

off

Nothing (the default for all three options).

on

Every run is recorded and kept. Traces in this mode are heavy.

retain-on-failure

Every run is recorded, but the files of runs that passed are deleted. A failed run keeps its files even when a later retry passes.

on-first-retry

Only the first retry of a test is recorded and kept. The usual choice for CI: passing and first-attempt-failing runs cost nothing.

on-all-retries

Every retry is recorded and kept.

retain-on-first-failure

Only the first run is recorded, and kept if it failed.

retain-on-failure-and-retries

Every run is recorded; kept if it failed or is a retry.

retain-on-failure pays the recording cost on every run in exchange for evidence of the first failure even without retries; on-first-retry pays it only when a test already failed once, but then has nothing from the original failure. A suite with retries: 2 in CI and on-first-retry is the common compromise.

Running the deliberately failing test from the folder tests/failing:

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

test('a test that fails on purpose', async ({ page }) => {
  await page.goto('/books');
  await page.getByTestId('search-input').fill('quiet');
  await page.getByRole('button', { name: 'Search' }).click();
  await expect(page.getByTestId('book-title')).toHaveText('A Title That Does Not Exist', { timeout: 2_000 });
});

with SHOW_FAILURES=1 npx playwright test failing failed in 3 seconds and wrote four files, all listed as attachments in the terminal output:

test-results/failing-fails-a-test-that-fails-on-purpose-chromium/error-context.md
test-results/failing-fails-a-test-that-fails-on-purpose-chromium/test-failed-1.png
test-results/failing-fails-a-test-that-fails-on-purpose-chromium/trace.zip
test-results/failing-fails-a-test-that-fails-on-purpose-chromium/video.webm

test-failed-1.png is the screenshot (full page, as configured), video.webm and trace.zip are the film and the trace (600 KB and 190 KB here). error-context.md is a Markdown file for people and AI assistants: the test name, the error with its call log, an accessibility-tree snapshot of the page at the failure (heading "The Quiet Compiler" [level=3] here) and the test source with an arrow at the failing line. Its first lines tell the reader to explain the failure concisely, so the file can be pasted into a chat as is. With RETRIES=1 the second attempt wrote the same four files in a sibling folder with the suffix -retry1; with retain-on-failure both attempts failed, so both were kept.

Videos

Playwright videos are WebM files, one per page, finished when the page’s browser context closes. The video size defaults to the viewport scaled down to fit 800 by 800 (800 by 450 if the viewport is not configured), and each page is scaled to fit inside the video. The video option takes an object with these fields:

Field Meaning

mode

One of the modes in the table above.

size

{ width, height } of the recording. Set the viewport to the same size to avoid scaling and a grey border.

fps

Frame rate, default 25 (since 1.64). Higher rates cost CPU for encoding. Firefox and WebKit capture at most 25 frames per second.

show.actions

Highlights the element of each action with an outline and a title subtitle: duration (default 500 ms), position, and since 1.64 style with CSS declarations for point, highlight and title (replacing the deprecated fontSize).

show.test

Overlays the current test (or step) title, with level, position and fontSize.

Outside the test runner, pass recordVideo: { dir, size } to browser.newContext() and read the path from page.video().path() after the context is closed. In Playwright Test, attach the path of page.video() to a report or read it in a fixture after await context.close().

The screencast API

Since Playwright 1.59 page.screencast records a video, or streams frames, with explicit start and stop calls, independent of the video option. It also draws chapters and overlays, which makes it the tool for narrated demo recordings and for agents that need a "video receipt" of what they did:

import { test, expect } from '@playwright/test';
import * as fs from 'node:fs';

test('page.screencast records a narrated video', async ({ page }, testInfo) => {
  const file = testInfo.outputPath('narrated.webm');
  await page.goto('/books');
  await page.screencast.start({ path: file, size: { width: 1280, height: 720 }, fps: 30 });
  await page.screencast.showActions({ position: 'top-right', duration: 800 });
  await page.screencast.showChapter('Search', { description: 'Narrow the catalogue', duration: 1000 });
  await page.getByTestId('search-input').fill('quiet');
  await page.getByRole('button', { name: 'Search' }).click();
  await expect(page.getByTestId('book-title')).toHaveText('The Quiet Compiler');
  await page.screencast.stop();
  console.log('narrated.webm', fs.statSync(file).size, 'bytes');
});

test('page.screencast delivers JPEG frames', async ({ page }) => {
  let frames = 0;
  let last = '';
  await page.goto('/books');
  await page.screencast.start({
    onFrame: ({ data, viewportWidth, viewportHeight }) => { frames++; last = `${data.length} bytes, ${viewportWidth}x${viewportHeight}`; },
    size: { width: 640, height: 360 },
    quality: 70,
  });
  await page.getByTestId('theme-toggle').click();
  await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
  await page.waitForTimeout(500);
  await page.screencast.stop();
  console.log(`frames: ${frames > 0}, last frame ${last}`);
});

Both tests passed. The first printed narrated.webm 656712 bytes (a 30 fps film with the action highlights and the chapter card) and the second frames: true, last frame 77973 bytes, 1280x720. The second shows the rule in the documentation: when a screencast is already running (here the runner’s own video and trace screencast), the existing configuration takes precedence, so the requested 640 by 360 was ignored and the frames came at the page’s viewport size. showActions() and showChapter() also return disposables, and showOverlay(html) adds arbitrary HTML on top of the page until it is disposed or its duration ends. fps applies only together with path.

Traces

A trace is a zip file with, for every action, a before and an after DOM snapshot, the action’s locator and call log, screenshots for a filmstrip, console messages and network requests, and the test source. The trace option takes the same modes as video or an object that selects what is recorded: screenshots (the filmstrip, default true), snapshots (DOM and, with { dom, aria, screen }, accessibility-tree and screen captures per action), sources (copy the test files into the trace) and attachments (include testInfo.attach() files, default true). Open one with npx playwright show-trace trace.zip, drop it on https://trace.playwright.dev (loaded entirely in the browser, nothing is uploaded), or read it from the command line; the viewer’s panels are described in Playwright: Debugging, Tracing & Codegen. The command line was run on the trace of the failing test above:

npx playwright trace open test-results/failing-fails-a-test-that-fails-on-purpose-chromium/trace.zip
npx playwright trace actions
npx playwright trace attachments
npx playwright trace close

actions listed Before Hooks, the fixtures that launched the browser, Navigate /books, Fill "quiet", Click and the failing assertion with the duration of each; attachments listed screenshot (image/png), video (video/webm) and error-context (text/markdown), so the video and the screenshot are also inside the trace.

When you drive Playwright without the test configuration, or want several trace files from one context, use context.tracing. Inside Playwright Test the runner already traces the contexts of its fixtures, and calling tracing.start() on them fails with "Tracing has been already started"; the test below turns the configured tracing and video off for its file first:

import { test, expect } from '@playwright/test';
import * as fs from 'node:fs';

// The runner already traces and films the contexts of its fixtures. Turn that off to drive the tracing API by hand.
test.use({ trace: 'off', video: 'off' });

test('context.tracing records a trace with chunks', async ({ browser }, testInfo) => {
  const context = await browser.newContext({
    baseURL: process.env.BASE_URL,
    recordVideo: { dir: testInfo.outputPath('videos'), size: { width: 800, height: 450 } },
  });
  await context.tracing.start({ screenshots: true, snapshots: true, sources: true, title: 'manual trace' });
  const page = await context.newPage();
  await page.goto('/books/1');
  await context.tracing.stopChunk({ path: testInfo.outputPath('load.zip') }); // chunk 1: the page load
  await context.tracing.startChunk({ title: 'interaction' });
  await page.getByRole('heading', { level: 1 }).click();
  await context.tracing.stop({ path: testInfo.outputPath('interaction.zip') }); // chunk 2, then tracing ends
  await context.close(); // the video is finished when the context closes
  const video = await page.video()!.path();
  console.log('load.zip', fs.existsSync(testInfo.outputPath('load.zip')), '| interaction.zip', fs.existsSync(testInfo.outputPath('interaction.zip')), '| video', fs.existsSync(video));
  expect(video.endsWith('.webm')).toBe(true);
});

It passed and printed load.zip true | interaction.zip true | video true. Two details were learned the hard way: test.use() for trace and video must be at the top of the file (inside a describe Playwright throws, because those options force a new worker), and a stop() after a stopChunk() without an open chunk fails with "Must start tracing before stopping", so the last chunk is closed with stop({ path }). The live option (since 1.59) writes the trace to an unarchived file as the run progresses, which is what lets a viewer follow a long test while it runs.

Attachments and the HTML report

testInfo.attach(name, { body | path, contentType }) adds any value or file to the test result; reporters decide how to show it. The HTML reporter lists attachments under each test, with screenshots, videos and traces as viewable items, and the trace viewer’s Attachments tab shows them as well (including the actual, expected and diff images of visual comparisons).

test('attach a screenshot and a note to the report', async ({ page }, testInfo) => {
  await page.goto('/books/1');
  await testInfo.attach('book page', { body: await page.screenshot(), contentType: 'image/png' });
  await testInfo.attach('url', { body: page.url(), contentType: 'text/plain' });
  console.log(testInfo.attachments.map((a) => `${a.name} (${a.contentType})`).join(', '));
});

It passed and printed book page (image/png), url (text/plain). attach copies a path into a location the reporters can read, so the original can be deleted straight after the call. Use body for in-memory data and give the right contentType, since a Buffer without one is stored as application/octet-stream. npx playwright show-report opens the HTML report that the configuration above wrote to playwright-report.

Python, Java and .NET

The other bindings expose the same options with their own naming. They are listed for reference; the screenshots, video and tracing calls below were not run in those languages (the bindings were exercised for other topics in Playwright with Python, Java and .NET).

Task Python Java .NET

Full-page screenshot

page.screenshot(path="a.png", full_page=True)

page.screenshot(new Page.ScreenshotOptions().setPath(Paths.get("a.png")).setFullPage(true))

await Page.ScreenshotAsync(new() { Path = "a.png", FullPage = true })

Record video

browser.new_context(record_video_dir="videos/")

browser.newContext(new Browser.NewContextOptions().setRecordVideoDir(Paths.get("videos/")))

await Browser.NewContextAsync(new() { RecordVideoDir = "videos/" })

Trace

context.tracing.start(screenshots=True, snapshots=True) and context.tracing.stop(path="trace.zip")

context.tracing().start(...) and context.tracing().stop(new Tracing.StopOptions().setPath(Paths.get("trace.zip")))

await Context.Tracing.StartAsync(new() { Screenshots = true, Snapshots = true }) and StopAsync(new() { Path = "trace.zip" })

The pytest plugin has the CLI options --screenshot only-on-failure, --video retain-on-failure and --tracing retain-on-failure for the same modes.

Cypress

Cypress takes failure screenshots and records videos only in cypress run; in cypress open the Command Log time travel replaces them, although an explicit cy.screenshot() works in both modes. Both end up under cypress/screenshots and cypress/videos (the screenshotsFolder and videosFolder options). Before each run Cypress empties both folders completely, including nested folders and files that are not screenshots or videos; set trashAssetsBeforeRuns to false to keep them. On the macOS host used here, a second run over existing screenshots printed We failed to trash the existing run results followed by spawn Unknown system error -86 and carried on without removing them, so the files of the earlier run piled up with (2) suffixes until the folders were deleted by hand; the cause was not investigated, and a Linux CI machine does not do this. The examples use this configuration:

import { defineConfig } from 'cypress';
import * as fs from 'node:fs';

export default defineConfig({
  video: true,
  videoCompression: 32,
  screenshotOnRunFailure: true,
  trashAssetsBeforeRuns: true,
  screenshotsFolder: 'cypress/screenshots',
  videosFolder: 'cypress/videos',
  e2e: {
    baseUrl: process.env.BASE_URL ?? 'http://localhost:3000',
    viewportWidth: 1280,
    viewportHeight: 720,
    retries: { runMode: 1, openMode: 0 },
    setupNodeEvents(on) {
      on('after:screenshot', (details) => {
        console.log(`after:screenshot ${details.name ?? '(failure)'} -> ${details.path.split('/').slice(-2).join('/')} ${details.dimensions.width}x${details.dimensions.height}`);
        return { path: details.path }; // could also return a new path or rename
      });
      on('after:spec', (spec, results) => {
        // keep videos only for specs that failed or needed a retry
        if (results?.video) {
          const failed = results.tests.some((t) => t.attempts.some((a) => a.state === 'failed'));
          if (!failed) fs.unlinkSync(results.video);
        }
      });
    },
  },
});

Failure screenshots and cy.screenshot

When a test fails during cypress run, Cypress takes a screenshot automatically; screenshotOnRunFailure: false (in the configuration or in Cypress.Screenshot.defaults()) turns that off. A failure screenshot is always taken with capture: 'runner' (the whole browser window, including the Command Log when the Runner UI is shown). With Test Replay enabled and the Runner UI hidden, the runner capture shows the application only. For explicit screenshots, cy.screenshot([fileName][, options]) takes these options, which Cypress.Screenshot.defaults() sets for all calls:

Option Default Effect

capture

fullPage

viewport, fullPage or runner. Ignored for element screenshots; always runner for failure screenshots.

blackout

[]

Selectors painted black. Applies to viewport and fullPage, not runner.

clip

none

{ x, y, width, height } to crop to.

padding

none

Number or CSS-style array added around an element screenshot.

disableTimersAndAnimations

true

Stops setTimeout, setInterval and CSS animations while the shot is taken.

scale

false

Scale the app to fit the viewport; always true for runner.

overwrite

false

Replace a file with the same name instead of adding a numeric suffix such as (1) or (2).

onBeforeScreenshot, onAfterScreenshot

none

Callbacks that may change the DOM before and restore it after; the second receives { path, size, dimensions, ... }.

timeout, log

responseTimeout, true

Wait limit and Command Log entry.

Cypress.Screenshot.defaults({
  capture: 'viewport',
  blackout: ['[data-testid=theme-toggle]'],
  disableTimersAndAnimations: true,
  overwrite: false,
  onBeforeScreenshot($el) { $el.find('[data-testid=result-count]').css('visibility', 'hidden'); },
  onAfterScreenshot($el) { $el.find('[data-testid=result-count]').css('visibility', 'visible'); },
});

describe('cy.screenshot', () => {
  beforeEach(() => {
    cy.request('POST', '/api/test/seed');
    cy.visit('/books');
  });

  it('takes viewport, full page, element and clipped screenshots', () => {
    cy.screenshot('viewport');
    cy.screenshot('full-page', { capture: 'fullPage' });
    cy.screenshot('runner', { capture: 'runner' });
    cy.get('[data-testid=book-card]').first().screenshot('first-card', { padding: 10 });
    cy.screenshot('clip', { clip: { x: 0, y: 0, width: 400, height: 200 } });
    cy.screenshot('to-overwrite', { overwrite: true });
    cy.screenshot('to-overwrite', { overwrite: true });
    cy.screenshot('nested/folders/home', {
      onAfterScreenshot($el, props) {
        Cypress.log({ name: 'screenshot', message: `${props.name}: ${props.dimensions.width}x${props.dimensions.height}, ${props.size}` });
      },
    });
  });
});

Together with this failing test:

describe('failure evidence', () => {
  it('fails on purpose', () => {
    cy.visit('/books');
    cy.get('[data-testid=search-input]').type('quiet');
    cy.contains('button', 'Search').click();
    cy.get('[data-testid=book-title]', { timeout: 2000 }).should('have.text', 'A Title That Does Not Exist');
  });
});

npx cypress run --browser chrome (Chrome 155, headless) ran both specs; the after:screenshot handler printed one line per file, and the two attempts of the failing test (one retry configured) each produced a failure screenshot:

after:screenshot (failure) -> failing.cy.ts/failure evidence -- fails on purpose (failed).png 1280x633
    (Attempt 1 of 2) fails on purpose
after:screenshot (failure) -> failing.cy.ts/failure evidence -- fails on purpose (failed) (attempt 2).png 1280x633
after:screenshot viewport -> screenshots.cy.ts/viewport.png 1280x633
after:screenshot full-page -> screenshots.cy.ts/full-page.png 1280x1172
after:screenshot runner -> screenshots.cy.ts/runner.png 1280x633
after:screenshot first-card -> screenshots.cy.ts/first-card.png 206x401
after:screenshot clip -> screenshots.cy.ts/clip.png 400x200
after:screenshot to-overwrite -> screenshots.cy.ts/to-overwrite.png 1280x633
after:screenshot to-overwrite -> screenshots.cy.ts/to-overwrite.png 1280x633
after:screenshot nested/folders/home -> folders/home.png 1280x633

The naming follows {screenshotsFolder}/{spec path}/{suite -- test name}.png for unnamed screenshots ({name}.png for named ones, with folders created from slashes), a (failed) suffix for failures, (attempt n) for retries and a (1), (2) numeric suffix for duplicates unless overwrite is set. The screenshot is not the size of viewportHeight: the headless window and the Runner layout decide, and the heights above (633) differ from the configured 720, so check the size in your own environment before comparing images. Electron (now a deprecated test browser) produced images at twice the size (2560 by 1440) on this Retina host, whereas Chrome produced 1280 wide ones, which is a good reason to take evidence in the browser CI will use. The runner capture matched the viewport one because the Runner UI is hidden in cypress run.

A screenshot takes about 100 ms and is asynchronous, so the page may have changed by the time it is taken; this is most visible in the automatic failure screenshot, which is taken after the failing command has timed out. A named screenshot taken just before the assertion that fails is often more telling than the failure screenshot.

Videos

video: true makes cypress run record one MP4 per spec (the default is false since Cypress 13). After recording, Cypress encodes the file: videoCompression is false by default, which skips compression (a bigger file, faster), true means Constant Rate Factor 32, and a number sets the CRF itself (lower is larger and better; try a higher number on slow virtual machines). The embedded chapter markers, one per test attempt, are written only when compression is on. On this run the compressed video of the failing spec was 64 KB. Because the video of a passing spec is rarely wanted, the after:spec handler in the configuration above deletes it; the summary table then printed Video: false for that spec, and a run with the delete line disabled printed Video: true and left screenshots.cy.ts.mp4 on disk, so the handler is what decides. The handler also decides what gets uploaded to Cypress Cloud, because a deleted video is never uploaded. The results object passed to after:spec contains video, screenshots and each test’s attempts, which is how it finds specs that needed a retry. The full list of node events, including after:screenshot (which can rename or move a file by returning { path }), is in the debugging page.

Test Replay

Test Replay, a feature of Cypress Cloud, is Cypress’s answer to traces: when a run is recorded (cypress run --record), the Cloud stores everything needed to re-open the test at any point in time with the Command Log, the DOM, network requests, console messages, JavaScript errors and element rendering, in the browser, with no reproduction on a developer machine. It is available on all Cloud plans within usage limits, and the Cypress documentation presents it as a replacement for video as the CI debugging artifact. Recording requires an account and a record key, so Test Replay was not run for this page; the Cloud page shows what the terminal prints and what was recorded without an account.

Selenium

Selenium’s driver is a remote-control protocol, not a test runner, so it provides screenshots, PDF output and (through BiDi) richer captures, and leaves when to capture to the suite. There is no video or trace in the driver; for video the project maintains a recorder for Grid containers.

TakesScreenshot, element screenshots and print to PDF

The W3C commands are Take Screenshot, Take Element Screenshot and Print Page. In Java, TakesScreenshot.getScreenshotAs() returns a File, byte[] or Base64 String according to the OutputType; the Chrome, Firefox, Edge and Remote drivers all implement it. WebElement is a TakesScreenshot too. In Python the methods are save_screenshot(path) (alias get_screenshot_as_file), get_screenshot_as_png() and get_screenshot_as_base64(), and element.screenshot(path). PrintsPage.print(PrintOptions) and Python’s driver.print_page(print_options) return a Base64 PDF; the options cover orientation, page range, size, margins, scale, background and shrink-to-fit. The Java test, and the base class and extension it uses, are in the next sections; the first test takes all four captures:

@Test
void viewportElementAndPdf() throws Exception {
  Files.createDirectories(OUT);
  driver.get(BASE_URL + "/books");

  // Classic TakesScreenshot: the viewport, as a file, bytes or Base64.
  File viewport = ((TakesScreenshot) driver).getScreenshotAs(OutputType.FILE);
  Files.copy(viewport.toPath(), OUT.resolve("viewport.png"), java.nio.file.StandardCopyOption.REPLACE_EXISTING);
  String base64 = ((TakesScreenshot) driver).getScreenshotAs(OutputType.BASE64);

  // An element screenshot is clipped to the element's box.
  WebElement count = driver.findElement(By.cssSelector("[data-testid=result-count]"));
  Files.write(OUT.resolve("count.png"), count.getScreenshotAs(OutputType.BYTES));

  // Print to PDF with the W3C print command.
  PrintOptions print = new PrintOptions();
  print.setOrientation(PrintOptions.Orientation.LANDSCAPE);
  print.setBackground(true);
  Pdf pdf = ((PrintsPage) driver).print(print);
  byte[] pdfBytes = Base64.getDecoder().decode(pdf.getContent());
  Files.write(OUT.resolve("catalogue.pdf"), pdfBytes);

  System.out.printf("viewport.png %d bytes, base64 %d chars, count.png %d bytes, catalogue.pdf %d bytes (%s)%n",
      Files.size(OUT.resolve("viewport.png")), base64.length(), Files.size(OUT.resolve("count.png")),
      pdfBytes.length, new String(pdfBytes, 0, 5));
  assertTrue(pdfBytes.length > 1000);
}

It passed in headless Chrome 155 and printed viewport.png 52444 bytes, base64 69928 chars, count.png 2509 bytes, catalogue.pdf 174380 bytes (%PDF-); the Base64 text is a third longer than the PNG it encodes. The Python equivalent:

def test_viewport_element_and_pdf(driver):
    OUT.mkdir(exist_ok=True)
    driver.get(f"{BASE_URL}/books")

    driver.save_screenshot(str(OUT / "viewport.png"))        # also get_screenshot_as_file()
    png = driver.get_screenshot_as_png()                      # bytes
    b64 = driver.get_screenshot_as_base64()                   # str

    count = driver.find_element(By.CSS_SELECTOR, "[data-testid=result-count]")
    count.screenshot(str(OUT / "count.png"))                  # element screenshot

    pdf = base64.b64decode(driver.print_page())               # W3C print command, Base64 PDF
    (OUT / "catalogue.pdf").write_bytes(pdf)

    print(f"viewport {len(png)} bytes, base64 {len(b64)} chars, "
          f"count.png {(OUT / 'count.png').stat().st_size} bytes, pdf {len(pdf)} bytes ({pdf[:5].decode()})")
    assert pdf.startswith(b"%PDF")

It passed with a 154 KB PDF. Printing was tried only in headless Chrome; the print command is part of the W3C standard, but which browsers implement it and how they lay the page out differs, so inspect the PDF of the browsers you use.

Full-page screenshots

The classic W3C screenshot is of the viewport. Two ways to get the whole page:

  • Firefox only: geckodriver adds a non-standard full-page command: Java HasFullPageScreenshot.getFullPageScreenshotAs(), Python driver.save_full_page_screenshot(path) (also get_full_page_screenshot_as_file, _png and _base64) on a local webdriver.Firefox. It is not available in Chrome.

  • WebDriver BiDi: browsingContext.captureScreenshot has an origin of viewport (default) or document, plus clip and format parameters and an element variant, and works for any context without switching to it. It needs the webSocketUrl capability (options.enableBiDi() in Java, options.enable_bidi = True in Python). With origin set to document it captured the whole Bookshelf catalogue in Chrome:

@Test
void bidiScreenshotsOfTheViewportAndTheWholeDocument() throws Exception {
  Files.createDirectories(OUT);
  BrowsingContext context = new BrowsingContext(driver, driver.getWindowHandle());
  context.navigate(BASE_URL + "/books", org.openqa.selenium.bidi.browsingcontext.ReadinessState.COMPLETE);

  byte[] viewport = Base64.getDecoder().decode(context.captureScreenshot());
  byte[] document = Base64.getDecoder().decode(
      context.captureScreenshot(new CaptureScreenshotParameters().origin(CaptureScreenshotParameters.Origin.DOCUMENT)));
  Files.write(OUT.resolve("bidi-viewport.png"), viewport);
  Files.write(OUT.resolve("bidi-document.png"), document);
  System.out.printf("bidi viewport %d bytes, bidi document %d bytes%n", viewport.length, document.length);
  assertTrue(document.length > viewport.length);
}

It passed and printed bidi viewport 52444 bytes, bidi document 89778 bytes. The Python call is driver.browsing_context.capture_screenshot(context=context, origin="document"), which printed bidi viewport 52444 bytes, bidi document 89759 bytes in the same project. The local-driver Firefox command was not run, because Firefox does not start on the host used here; Java was run against Firefox 158 in a container instead:

@Test
void fullPageScreenshotAndRecordedSession() throws Exception {
  FirefoxOptions options = new FirefoxOptions(); // headed: the recorder films the virtual display
  options.setCapability("se:name", "fullPageScreenshotAndRecordedSession");
  options.setCapability("se:recordVideo", true);
  driver = RemoteWebDriver.builder().address(URI.create(GRID)).oneOf(options).build();

  driver.get(APP + "/books");
  byte[] viewport = ((org.openqa.selenium.TakesScreenshot) driver).getScreenshotAs(OutputType.BYTES);
  byte[] full = ((HasFullPageScreenshot) driver).getFullPageScreenshotAs(OutputType.BYTES);
  Path out = Files.createDirectories(Path.of("target", "evidence"));
  Files.write(out.resolve("firefox-full-page.png"), full);
  System.out.println("firefox viewport " + viewport.length + " bytes, full page " + full.length + " bytes");
  assertTrue(full.length > viewport.length);

  for (int page = 0; page < 4; page++) { // keep the session open long enough to be filmed
    driver.findElement(By.cssSelector("[data-testid=search-input]")).sendKeys("a");
    Thread.sleep(1500);
  }
}

The cast to HasFullPageScreenshot works because the remote builder returns an augmented driver. It passed against the Grid below and printed firefox viewport 85028 bytes, full page 107578 bytes.

Screenshot on failure

Selenium has no automatic capture, so the test framework’s lifecycle does it. The hook must run before the browser is closed. In JUnit 5, TestWatcher.testFailed() is called only after the @AfterEach methods, so a watcher that takes the screenshot finds the session gone: the run below printed TestWatcher could not screenshot: NoSuchSessionException. An AfterTestExecutionCallback, which runs right after the test method and before @AfterEach, still has the driver:

public class ScreenshotOnFailure implements AfterTestExecutionCallback {
  @Override
  public void afterTestExecution(ExtensionContext context) throws Exception {
    if (context.getExecutionException().isEmpty()) {
      return;
    }
    HasDriver test = (HasDriver) context.getRequiredTestInstance();
    byte[] png = ((TakesScreenshot) test.driver()).getScreenshotAs(OutputType.BYTES);
    Path dir = Files.createDirectories(Path.of("target", "evidence"));
    Path file = dir.resolve(context.getRequiredTestClass().getSimpleName() + "-" + context.getRequiredTestMethod().getName() + ".png");
    Files.write(file, png);
    System.out.println("failure screenshot: " + file + " (" + png.length + " bytes)");
  }
}

The test base class declares @ExtendWith(ScreenshotOnFailure.class) and implements the small HasDriver interface (WebDriver driver();) so the extension can reach the driver. Running FailingTest, which asserts a title that does not exist, printed failure screenshot: target/evidence/FailingTest-failsOnPurpose.png (52444 bytes). In pytest the same job is done in a fixture, but the test outcome is not visible to fixtures by default; a hook wrapper stores each phase’s report on the test item:

@pytest.hookimpl(wrapper=True, tryfirst=True)
def pytest_runtest_makereport(item, call):
    # Publish each phase's report on the item so fixtures can read it at teardown.
    report = yield
    setattr(item, f"rep_{report.when}", report)
    return report


@pytest.fixture
def driver(request):
    options = webdriver.ChromeOptions()
    options.add_argument("--headless=new")
    options.add_argument("--window-size=1280,720")
    options.enable_bidi = True
    d = webdriver.Chrome(options=options)
    yield d
    # Teardown runs before quit(), so the browser is still alive here.
    report = getattr(request.node, "rep_call", None)
    if report is not None and report.failed:
        EVIDENCE.mkdir(exist_ok=True)
        path = EVIDENCE / f"{request.node.name}.png"
        d.save_screenshot(str(path))
        print(f"failure screenshot: {path} ({path.stat().st_size} bytes)")
    d.quit()

With pytest 8 or later (the wrapper=True hook style) the failing test left evidence/test_fails_on_purpose.png (52444 bytes) while the two passing tests left none. Extend the hook with driver.page_source or the browser log (driver.get_log("browser") in Chromium) when a picture is not enough; Selenium has no trace to attach.

Video with docker-selenium

Video recording belongs to the Grid, not to the driver. The selenium/video image runs ffmpeg against the virtual display of one browser Node container (so the browser must run headed) and writes MP4 files to /videos. The Compose file below starts a Hub, a Firefox Node limited to one session, and one recorder for it; SE_NODE_MAX_SESSIONS=1 matters because a recorder films one display, and SE_VIDEO_FILE_NAME=auto names the file after the se:name capability and the session id:

services:
  hub:
    image: selenium/hub:4.51.0
    ports: ["39621:4444"]
  firefox:
    image: selenium/node-firefox:4.51.0
    shm_size: 2gb
    depends_on: [hub]
    extra_hosts: ["host.docker.internal:host-gateway"]
    environment:
      - SE_EVENT_BUS_HOST=hub
      - SE_EVENT_BUS_PUBLISH_PORT=4442
      - SE_EVENT_BUS_SUBSCRIBE_PORT=4443
      - SE_NODE_MAX_SESSIONS=1   # one session per recorded display
      - SE_VNC_NO_PASSWORD=1
  firefox_video:
    image: selenium/video:ffmpeg-8.1-20261010
    depends_on: [firefox]
    volumes:
      - ./videos:/videos
    environment:
      - DISPLAY_CONTAINER_NAME=firefox
      - SE_NODE_GRID_URL=http://hub:4444
      - SE_VIDEO_FILE_NAME=auto

With the Java test from the previous section (which sets se:name and se:recordVideo) it produced fullPageScreenshotAndRecordedSession_2e443aa2-7c00-400b-a2ac-cea7439a5a37.mp4, 160 KB, a valid MP4 (ftyp header). A single-container attempt, standalone-firefox plus a recorder, did not record: the recorder polled the Node status endpoint on port 5555, which a standalone container does not serve, and pointing it at port 4444 let it connect but produced no file, so use the Hub and Node layout above. The recorder polls for sessions, so sessions shorter than about a second may not be filmed; the test above keeps the session open for six seconds. Details such as the event-driven mode, upload of finished videos and the Dynamic Grid’s --docker-video-image are in Selenium Grid. Cloud grids (BrowserStack, Sauce Labs, TestMu AI) record video and screenshots on their side and expose them through their dashboards; those services were not run for this page.

Capturing on failure versus always

Evidence costs time, disk and upload bandwidth, so the question is which runs should leave it. The frames of a passing test seldom help, but the failure of a flaky test may have no second chance, so the usual pattern is "record always, keep on failure".

flowchart TD A["Test runs"] --> B{"Is the run recorded?"} B -- "Always: Playwright retain-on-failure, Cypress video true" --> C["Run ends"] B -- "Only on retry: Playwright on-first-retry" --> C B -- "Only when asked: Selenium" --> D["Hook takes a screenshot at the failure point"] C --> E{"Failed or retried?"} E -- "Yes" --> F["Keep screenshot, video, trace"] E -- "No" --> G["Delete: Playwright removes it, Cypress after:spec handler deletes it"] D --> F F --> H["Upload as CI artifact, attach to the report"]
Strategy Playwright Cypress Selenium

Failures only

screenshot: only-on-failure, video and trace: retain-on-failure

Default screenshots in cypress run; video: true plus a handler that deletes passing specs

Failure hook as above; a Grid recorder films everything unless se:recordVideo is false

Retries only

trace: on-first-retry, video: on-first-retry

retries plus a handler that keeps a video when an attempt failed

Hook that checks the attempt number (for example with a retry extension)

Always

screenshot: on, video: on, trace: on

video: true, cy.screenshot() calls in the test

Capture in AfterEach or in a fixture, regardless of the outcome

Debugging a flaky test once

--trace on on the command line

Test Replay in Cypress Cloud

se:recordVideo on the single suspicious session

Always-on capture is right for a small smoke suite or while hunting an intermittent failure, and for documentation: the accessibility checks and the user-interface sections of a docs site are illustrated with screenshots taken by tests. For a large suite, evidence of passing runs is a cost with almost no value. Trace and video overhead is real: Playwright’s documentation calls trace: on "heavy", and Cypress notes that Test Replay capture uses resources comparable to video.

CI artifacts

The files exist only on the CI runner until the job uploads them, and a failing job stops its later steps unless they are marked to run regardless. The GitHub Actions step is therefore conditioned on !cancelled() (or failure() for failures only, or always()), and the paths are the output folders described above. This workflow was written from the actions' documentation and was not run, because act is not installed on the host used for this page; the test commands inside it were run locally as shown earlier.

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test
      - uses: actions/upload-artifact@v7
        if: ${{ !cancelled() }}
        with:
          name: playwright-evidence
          path: |
            playwright-report/
            test-results/
          if-no-files-found: ignore
          retention-days: 14
      - run: npx cypress run --browser chrome
      - uses: actions/upload-artifact@v7
        if: ${{ failure() }}
        with:
          name: cypress-evidence
          path: |
            cypress/screenshots
            cypress/videos
          retention-days: 14
      - run: mvn -B test
      - uses: actions/upload-artifact@v7
        if: ${{ failure() }}
        with:
          name: selenium-evidence
          path: |
            target/evidence/
            target/surefire-reports/

This sketch puts the three tools in one job only to show the three shapes; a real project runs one tool per job, and in the Playwright job the second and third if expressions are not needed. Details to remember:

  • Playwright: upload playwright-report/ for the HTML report and test-results/ for raw traces; the report embeds screenshots, videos and traces of the failed tests, so playwright-report/data alone is often enough. Open a downloaded trace with npx playwright show-trace or drop it on trace.playwright.dev. With sharding, each shard produces its own report that a merge step combines (see Parallelism, Sharding & Retries).

  • Cypress: upload cypress/screenshots and cypress/videos; when recording to Cypress Cloud the artifacts and the Test Replay are already stored there and the upload is optional.

  • Selenium: upload the folder your hook writes to, the test reports and, for a Grid, the videos volume.

  • Choose retention-days deliberately; videos and traces are tens of megabytes on a long suite, and artifact storage is billed. compression-level: 0 speeds up the upload of already-compressed MP4, WebM and zip files.

  • Artifacts are downloadable by anyone with read access to the repository. Screenshots, videos and traces contain what the browser showed, including test data and any password typed into a field and every request header recorded in a trace; do not run captured tests with real secrets, mask sensitive regions, and review who can download artifacts.

Pitfalls

  • The file is not there yet. A Playwright video exists only after the context closes; a Cypress video after the spec finishes; page.video().path() before that gives a path to an incomplete file.

  • Videos and traces of the runner’s contexts cannot be duplicated by hand. tracing.start() on a runner-managed context fails with "Tracing has been already started"; use the trace option, or turn it off for that file.

  • Different sizes, different pictures. Headless windows, device pixel ratios (Electron on a Retina host, and headless WebKit in Cypress before it fixed its pixel ratio to 1) and fonts change screenshot size and content. Set the viewport explicitly and take evidence in the browser and operating system the CI uses.

  • The failure screenshot shows the failure, not the cause. It is taken after the command has timed out, and in Cypress a screenshot takes about 100 ms, during which the page may change; a screenshot at the start of the failing step, or a trace, is more useful.

  • Dynamic content. Clocks, carets, animations and random data make pictures differ from run to run. Playwright’s mask, style, animations and caret, and Cypress’s blackout, disableTimersAndAnimations and onBeforeScreenshot, address that; for comparisons use the tools in Visual Regression Testing.

  • Teardown order in Selenium. Capture before quit(): see the TestWatcher example above.

  • Parallel recorders. One Grid recorder films one display; with several concurrent sessions per Node only one is filmed.

  • Headless cannot be filmed by docker-selenium. The recorder needs a display, so use the headed browser inside the container.

  • Privacy. Evidence can contain personal data. Keep retention short, restrict access, and mask or blackout fields that must not be recorded.

What changed recently

  • Playwright 1.64: video gained fps (default 25; Firefox and WebKit capture up to 25 frames per second) and the show.actions.style CSS option, which deprecates fontSize; the same fps is accepted by recordVideo and screencast.start().

  • Playwright 1.62: page.screenshot() and locator.screenshot() accept type: 'webp' with a lossless default, toHaveScreenshot can store .webp snapshots (with a global type option), and screenshots accept an AbortSignal.

  • Playwright 1.61: video supports all the trace modes (on-all-retries, retain-on-first-failure, retain-on-failure-and-retries) and showActions() has a cursor option.

  • Playwright 1.59: the page.screencast API (video recording with start and stop, action annotations, chapters, overlays and frame capture) and live traces.

  • Playwright 1.64.1: failing tests also write error-context.md, a Markdown summary with the error, an accessibility snapshot of the page and the failing source line.

  • Cypress 16: Electron is deprecated as a test browser (use Chrome or another installed browser). The 16.x releases fixed a 16.0.0 regression where headless Electron windows were smaller than requested on Windows (which also made failure screenshots and videos smaller than 1280 by 720) and video chapter markers placed too late. Videos stay off by default, and so does compression (a type-definition default of 32 was corrected to false).

  • Selenium 4.51: BiDi browsingContext.captureScreenshot (with origin document for a full-page capture) and browsingContext.print were used here from Java and Python; the video recorder image tag used here is selenium/video:ffmpeg-8.1-20261010.

  • See What’s Changed and Upgrading for the section-wide list.