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 |
|
|
|
Element screenshot |
|
|
|
Full page |
|
|
Firefox only in the classic API ( |
Mask or hide dynamic regions |
|
|
None; hide the element yourself with script before the call |
Video |
|
|
None in the driver; the |
Trace with DOM snapshots |
Yes ( |
No; Test Replay in Cypress Cloud is the equivalent |
No |
Print to PDF |
|
None; use |
|
Attach to a report |
|
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 } } }],
});
Source: Playwright: TestOptions
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 |
|---|---|
|
File to write. The extension ( |
|
|
|
0 to 100, for |
|
|
|
|
|
Locators whose bounding boxes are painted over with a solid box before the capture. Default colour pink |
|
CSS applied only while the screenshot is taken, piercing shadow DOM and iframes. The way to hide clocks and carets. |
|
|
|
|
|
|
|
Transparent background instead of white. Not for JPEG. |
|
Maximum wait (no timeout by default) and an |
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');
});
Source: Playwright: Screenshots
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 |
|---|---|
|
Nothing (the default for all three options). |
|
Every run is recorded and kept. Traces in this mode are heavy. |
|
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. |
|
Only the first retry of a test is recorded and kept. The usual choice for CI: passing and first-attempt-failing runs cost nothing. |
|
Every retry is recorded and kept. |
|
Only the first run is recorded, and kept if it failed. |
|
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 });
});
Source: Playwright: Test use options
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 |
|---|---|
|
One of the modes in the table above. |
|
|
|
Frame rate, default 25 (since 1.64). Higher rates cost CPU for encoding. Firefox and WebKit capture at most 25 frames per second. |
|
Highlights the element of each action with an outline and a title subtitle: |
|
Overlays the current test (or |
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}`);
});
Source: Playwright: Screencast
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
Source: Playwright: Trace viewer
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);
});
Source: Playwright: Tracing
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(', '));
});
Source: Playwright: TestInfo.attach
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 |
|
|
|
Record video |
|
|
|
Trace |
|
|
|
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);
}
});
},
},
});
Source: Cypress: Configuration
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 |
|---|---|---|
|
|
|
|
|
Selectors painted black. Applies to |
|
none |
|
|
none |
Number or CSS-style array added around an element screenshot. |
|
|
Stops |
|
|
Scale the app to fit the viewport; always |
|
|
Replace a file with the same name instead of adding a numeric suffix such as |
|
none |
Callbacks that may change the DOM before and restore it after; the second receives |
|
|
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}` });
},
});
});
});
Source: Cypress: screenshot
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');
});
});
Source: Cypress: Screenshots and Videos
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")
Source: Selenium: Print Page
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(), Pythondriver.save_full_page_screenshot(path)(alsoget_full_page_screenshot_as_file,_pngand_base64) on a localwebdriver.Firefox. It is not available in Chrome. -
WebDriver BiDi:
browsingContext.captureScreenshothas anoriginofviewport(default) ordocument, plusclipandformatparameters and an element variant, and works for any context without switching to it. It needs thewebSocketUrlcapability (options.enableBiDi()in Java,options.enable_bidi = Truein Python). Withoriginset todocumentit 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)");
}
}
Source: JUnit 5: Lifecycle callbacks
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
Source: docker-selenium: Video recording
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".
| Strategy | Playwright | Cypress | Selenium |
|---|---|---|---|
Failures only |
|
Default screenshots in |
Failure hook as above; a Grid recorder films everything unless |
Retries only |
|
|
Hook that checks the attempt number (for example with a retry extension) |
Always |
|
|
Capture in |
Debugging a flaky test once |
|
Test Replay in Cypress Cloud |
|
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/
Source: GitHub: actions/upload-artifact
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 andtest-results/for raw traces; the report embeds screenshots, videos and traces of the failed tests, soplaywright-report/dataalone is often enough. Open a downloaded trace withnpx playwright show-traceor 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/screenshotsandcypress/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
videosvolume. -
Choose
retention-daysdeliberately; videos and traces are tens of megabytes on a long suite, and artifact storage is billed.compression-level: 0speeds 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 thetraceoption, 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,animationsandcaret, and Cypress’sblackout,disableTimersAndAnimationsandonBeforeScreenshot, address that; for comparisons use the tools in Visual Regression Testing. -
Teardown order in Selenium. Capture before
quit(): see theTestWatcherexample 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 theshow.actions.styleCSS option, which deprecatesfontSize; the samefpsis accepted byrecordVideoandscreencast.start(). -
Playwright 1.62:
page.screenshot()andlocator.screenshot()accepttype: 'webp'with a lossless default,toHaveScreenshotcan store.webpsnapshots (with a globaltypeoption), and screenshots accept anAbortSignal. -
Playwright 1.61:
videosupports all the trace modes (on-all-retries,retain-on-first-failure,retain-on-failure-and-retries) andshowActions()has acursoroption. -
Playwright 1.59: the
page.screencastAPI (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(withorigindocumentfor a full-page capture) andbrowsingContext.printwere used here from Java and Python; the video recorder image tag used here isselenium/video:ffmpeg-8.1-20261010. -
See What’s Changed and Upgrading for the section-wide list.