AI agents are increasingly used to write UI tests straight from a ticket description: give them the flow, point them at the frontend repo, and out comes a working Playwright test. But there's a gap most setups don't handle well. When that test fails, someone still has to open the run, look at a screenshot, dig through a stack trace, and explain the failure back to the agent by hand. The loop isn't closed. This post walks through a small, practical way to close it: capturing a screenshot and a compact DOM snapshot automatically at the moment of failure, so the agent has everything it needs to diagnose the problem on its own.
The fix is simple in principle. Instead of manually reproducing a failure to describe it, wire the test framework to save two artifacts the instant a test fails: a screenshot of the page, and a structured snapshot of the DOM. Both get written to disk as regular files. When you hand the failure back to an LLM, you attach the screenshot as an image and paste the snapshot as text. The agent now has the same picture a human debugger would have, without anyone needing to reproduce the bug or narrate what happened.
This only needs to be set up once per project, as a shared fixture, not per test. The spec files stay exactly as they were.
The naive approach is to dump the full page HTML (page.content()) into the prompt. Don't do that. A real page's HTML includes scripts, inline styles, tracking snippets, and deeply nested wrapper divs that add nothing but noise, and it burns through context fast.
Playwright's ARIA snapshot is a much better fit. It renders the page as a compact, YAML-style tree of roles, accessible names, and states, essentially the same information a screen reader would use. It's smaller, it's semantic, and it maps closely to how you'd describe the UI in plain English, which makes it far easier for an LLM to reason about ("the Submit button lost its accessible name" is immediately legible; a 4,000-line HTML dump is not).
To show the pattern doing real work, you need a failure that a screenshot alone can't explain. The demo used here is a one-page checkout: an email field, a submit button, and a confirmation message. Adding ?bug=1 to the URL flips a single detail, the submit button's accessible name, and changes nothing else about the page.
// demo-site/script.js
// Add ?bug=1 to the URL to simulate a UI regression: the submit button's
// accessible name silently changes from "Submit Order" to "Place Order".
const params = new URLSearchParams(window.location.search);
if (params.get('bug') === '1') {
document.getElementById('submit-btn').textContent = 'Place Order';
}
This is deliberately the least interesting-looking kind of regression: same layout, same colors, same position, one word different on a button. A full-page screenshot of the failure looks almost identical to a screenshot of a passing run, which is exactly why the DOM snapshot matters. The screenshot tells the agent the page rendered and nothing exploded; the snapshot tells it what the button is actually called now.
The obvious place for this logic is an afterEach hook, and that works, but it has to be repeated in (or imported into) every spec file that wants it. A cleaner option is to override Playwright's built-in page fixture. The code before use(page) runs as setup, the test body runs inside the use call, and everything after it is teardown, which is where the capture belongs.
// tests/fixtures.ts
import { test as base, expect } from '@playwright/test';
import fs from 'fs';
import path from 'path';
const FAILURES_DIR = 'failures';
export const test = base.extend({
page: async ({ page }, use, testInfo) => {
await use(page); // the test body runs here
if (testInfo.status !== testInfo.expectedStatus) {
fs.mkdirSync(FAILURES_DIR, { recursive: true });
const name = testInfo.title.replace(/\s+/g, '_');
// Screenshot at the moment of failure
await page.screenshot({
path: path.join(FAILURES_DIR, `${name}.png`),
fullPage: true,
});
// Compact, semantic DOM snapshot, not raw HTML
const snapshot = await page.locator('body').ariaSnapshot();
fs.writeFileSync(path.join(FAILURES_DIR, `${name}.yaml`), snapshot);
}
},
});
export { expect };
Two details are worth calling out. The condition is testInfo.status !== testInfo.expectedStatus, not status === 'failed', so a test marked test.fail() that unexpectedly passes also gets captured. And the file re-exports expect alongside test, so specs can pull both from one place instead of importing half their API from Playwright and half from the fixture.
The payoff is that nothing about the test itself changes. The only difference from a stock Playwright spec is the import path: ./fixtures instead of @playwright/test. Every test in the file inherits the capture behavior, and anyone writing a new spec gets it for free without knowing it exists.
// tests/checkout.spec.ts
import { test, expect } from './fixtures';
test('user can submit an order', async ({ page }) => {
await page.goto('/');
await page.getByLabel('Email').fill('user@example.com');
await page.getByRole('button', { name: 'Submit Order' }).click();
await expect(page.getByText('Order confirmed')).toBeVisible();
});
// Same flow against the ?bug=1 version of the page, where the button's
// accessible name silently becomes "Place Order". Fails on purpose.
test('user can submit an order (bug simulation)', async ({ page }) => {
await page.goto('/?bug=1');
await page.getByLabel('Email').fill('user@example.com');
await page.getByRole('button', { name: 'Submit Order' }).click();
await expect(page.getByText('Order confirmed')).toBeVisible();
});
The first test passes. The second one times out on the click, because getByRole('button', { name: 'Submit Order' }) no longer resolves to anything. That timeout is the entire error message you'd otherwise hand to an agent, and on its own it's nearly useless: it tells you what the test wanted, not what the page had.
The config is mostly unremarkable, but one line in it is a habit worth keeping regardless of this pattern. Playwright's webServer block can start the app under test, and by default it will happily attach to an already-running server on the same port. In a project where the port might be occupied by an unrelated dev server, that means the suite quietly tests the wrong site.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
const BASE_URL = 'http://127.0.0.1:3100';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
webServer: {
command: 'node demo-site/server.js',
url: BASE_URL,
// Never silently attach to a server this project did not start. If
// something else owns the port, fail immediately instead of testing
// the wrong site.
reuseExistingServer: false,
stdout: 'pipe',
stderr: 'pipe',
},
use: {
baseURL: BASE_URL,
},
});
Setting reuseExistingServer: false turns that ambiguity into an immediate, loud failure. It matters more than usual here: the whole point of the pattern is that the artifacts on disk are a trustworthy account of what the browser saw. A snapshot of the wrong application is worse than no snapshot, because an agent will reason confidently from it.
Run the suite and a failures/ folder appears, named after the failing test. The screenshot is the boring half. The YAML is where the answer is.
# failures/user_can_submit_an_order_(bug_simulation).yaml
- main:
- heading "Checkout" [level=1]
- paragraph: Demo page for the Playwright failure-capture pattern.
- text: Email
- textbox "Email":
- /placeholder: you@example.com
- text: user@example.com
- button "Place Order"
Eight lines, and every one of them carries meaning. The form is present and rendered, so this isn't a routing or load failure. The email field was filled successfully with the expected value, so the test got further than the error message suggests. And the button, the thing the test was waiting for, is right there in the tree under a different accessible name. Compare that to the equivalent raw HTML dump, which would have said the same thing somewhere inside a few thousand lines of markup, wrappers, and inline scripts.
Once you have both artifacts, the handoff is straightforward: attach the screenshot as an image and paste the ARIA snapshot as text alongside the test code and the error message. A capable agent can now cross-reference all three instead of guessing from a stack trace. In this case the reasoning is mechanical: the test waits for a button named "Submit Order," the snapshot shows a button named "Place Order" in the same position, nothing else on the page moved, therefore the locator is stale and the fix is a one-word change.
The more valuable outcome is the opposite verdict. If the snapshot had shown no button at all, or a form that never rendered, the correct response isn't a locator fix, it's a bug report. That distinction, "the test is wrong" versus "the app is wrong," is the one an agent working from a timeout message alone cannot make, and it's the one the snapshot restores.
Worth knowing before you build anything: recent Playwright versions already write an error-context.md file into test-results/ on failure, containing an ARIA page snapshot, the error details, and the relevant test source, formatted as a prompt for an LLM. If that covers your needs, use it and skip the fixture entirely.
The fixture still earns its place in a few situations. You control the format and location, so the artifacts can go somewhere your CI already publishes rather than into a results folder that gets wiped between retries. You get a real screenshot next to the snapshot, under one predictable name. And you can scope the snapshot to a specific component instead of the whole body, which is the difference between a prompt that fits comfortably in context and one that doesn't. Think of the fixture as the version you tune, and the built-in file as the version you get for free.
Watch your context budget. Even ARIA snapshots get large on a real application. The demo page produces eight lines; a data-heavy dashboard can produce hundreds. Scope the snapshot to the component under test, page.getByRole('dialog').ariaSnapshot() rather than the whole body, when you know where the failure lives.
Scrub sensitive data. Screenshots and DOM snapshots contain whatever was on screen. If your test environment carries real customer records, order details, or a session token rendered somewhere in the page, these files will capture it, and pasting them into a hosted model sends it off your machine. Sanitize the environment, or gate what leaves it.
Treat it as a diagnostic aid, not an oracle. An agent handed a snapshot will confidently propose a locator change, because that's the cheapest explanation available to it. Deciding whether the test drifted or the product broke is still a human call, and the artifacts exist to inform that call rather than to replace it.
A single snapshot at the point of failure answers "what did the page look like when it broke." It doesn't answer "what happened three steps earlier that made it break." When the failure is a race, a stale request, or a state transition that went wrong long before the assertion, you need the sequence, not the final frame.
That's what Playwright's trace viewer is for. Setting trace: 'retain-on-failure' in the config records a DOM snapshot, screenshot, network activity, and console log at every action in the test. It's heavier than two files on disk and less convenient to paste into a prompt, which is exactly why it's the next step rather than the first one: start with the cheap artifacts, and reach for the trace when they aren't enough.
The complete demo project shown above, the checkout page, the fixture, the specs, and the config, is available on our GitHub repository. Clone it, run the suite, and watch the failures folder fill itself in.