PRACTICAL GUIDE / Playwright download artifact cleanup CI
Clean up Playwright downloads without deleting the evidence
Separate browser-managed downloads from saved test copies, prove file completion, and clean CI storage without losing the artifact that explains a failure.
In this guide7 sections
- Separate the browser download from your saved copy
- Capture completion before validating content
- Retain failed copies and remove successful ones
- Diagnose the boundary that actually failed
- Two full disks can have different owners
- Put a guarded cleanup step around reusable runners
- Hand off the copy that crossed the boundary
- Roll out cleanup without hiding product failures
What you will learn
- Separate the browser download from your saved copy
- Capture completion before validating content
- Retain failed copies and remove successful ones
- Diagnose the boundary that actually failed
The download test passes, but a reused CI runner slowly fills its disk with exports. Closing the browser removed Playwright's temporary files, yet every call to saveAs() created another copy under a shared workspace directory. A cleanup step then made the opposite mistake and deleted the one failed export the team needed.
There are two lifecycles to manage. Playwright owns the download attached to the browser context. Your test owns any destination it asks saveAs() to create. Cleanup becomes predictable once those files are named, validated, retained, and deleted under separate policies.
Separate the browser download from your saved copy
A Download object is emitted by a page when a transfer starts. That timing matters: receiving the event proves that a download began, not that it finished successfully. Methods such as failure(), path(), saveAs(), delete(), and createReadStream() wait for the transfer state they need.
The browser-managed file has a random GUID filename on disk. download.path() returns that path after a successful local download, but Playwright documents that it throws when the browser connection is remote. The path is also tied to the browser context's lifecycle. Every downloaded file belonging to that context is deleted when the context closes.
The suggested name is different metadata. download.suggestedFilename() is typically derived by the browser from Content-Disposition or an HTML download attribute, and browser logic can differ. It is useful for checking the application's filename contract. It is not a safe global destination by itself.
Consider two tests that both export report.csv. Saving both into workspace/downloads/report.csv creates a collision even though Playwright's internal files are separate. Sanitizing the suggestion does not make it unique. Build the destination from the test attempt's output directory and use a controlled leaf name.
download.saveAs(destination) copies the download to the path your code supplies. It can be called while transfer is in progress and waits for completion. That copy is no longer protected by the browser context's deletion boundary. If the destination is a permanent workspace folder, context closure will not clean it.
download.delete() deletes the downloaded file represented by the Download and waits if necessary. Since saveAs() is documented as a copy operation, manage the destination as a separate test artifact. Deleting the browser-managed original is useful when a long-lived context performs many large transfers, but it is not a substitute for removing saved copies.
The default Playwright Test fixtures normally close their context after the test. Code that creates its own context must close it explicitly. A context kept in beforeAll keeps its browser downloads for the whole suite, which increases peak disk usage and couples cleanup to suite teardown. Prefer the test-scoped context unless sharing is an intentional, measured trade-off.
Capture completion before validating content
Register the event wait before the click. If the action starts and finishes the browser event before waitForEvent is listening, the test waits for a second event that never comes. A timeout increase only makes that race slower.
The helper below starts the waiter, performs the action, checks the completed transfer result, saves to a caller-owned path, and returns metadata. It does not call download.path(), so it also works with a remote browser connection.
import type { Download, Locator, Page } from '@playwright/test';
import { mkdir } from 'node:fs/promises';
import { dirname } from 'node:path';
export type SavedDownload = {
download: Download;
destination: string;
suggestedFilename: string;
sourceUrl: string;
};
export async function saveTriggeredDownload(
page: Page,
trigger: Locator,
destination: string,
): Promise<SavedDownload> {
const downloadPromise = page.waitForEvent('download');
await trigger.click();
const download = await downloadPromise;
const failure = await download.failure();
if (failure !== null) {
throw new Error(`Download failed: ${failure}`);
}
await mkdir(dirname(destination), { recursive: true });
await download.saveAs(destination);
return {
download,
destination,
suggestedFilename: download.suggestedFilename(),
sourceUrl: download.url(),
};
}The helper deliberately does not delete anything. It cannot know whether the caller still needs to validate, attach, upload, or inspect the download. Resource cleanup belongs at the layer that knows when those operations finish and can preserve any earlier failure.
A transfer can succeed while the product output is wrong. A login redirect saved as invoice.pdf is still a successful browser download. Validate content separately from transport. The next test checks a PDF header, records actual byte length and SHA-256, attaches the copy, then deletes the browser-managed original.
import { test, expect } from '@playwright/test';
import { createHash } from 'node:crypto';
import { readFile, rm } from 'node:fs/promises';
import { saveTriggeredDownload } from './download-helper';
test('exports the approved invoice as a PDF', async ({ page }, testInfo) => {
await page.goto('/invoices/approved');
const destination = testInfo.outputPath('downloads', 'approved-invoice.pdf');
const saved = await saveTriggeredDownload(
page,
page.getByRole('button', { name: 'Export PDF' }),
destination,
);
const bytes = await readFile(saved.destination);
expect(bytes.subarray(0, 5).toString('ascii')).toBe('%PDF-');
const evidence = {
suggestedFilename: saved.suggestedFilename,
sourceUrl: saved.sourceUrl,
byteLength: bytes.byteLength,
sha256: createHash('sha256').update(bytes).digest('hex'),
retry: testInfo.retry,
project: testInfo.project.name,
};
await testInfo.attach('download metadata', {
body: Buffer.from(JSON.stringify(evidence, null, 2)),
contentType: 'application/json',
});
await testInfo.attach('approved invoice', {
path: saved.destination,
contentType: 'application/pdf',
});
await rm(saved.destination);
await saved.download.delete();
});The size and checksum are observations from that execution. The test does not claim a universal PDF size. A product contract might add an application-specific minimum or parse the PDF, but that threshold needs evidence from the real export format. Checking only for nonzero bytes catches very little.
Playwright copies a file attachment to a reporter-accessible location when testInfo.attach() completes. That documented behavior means a cleanup fixture can remove the saved working copy after awaiting the attachment. The reporter's attachment is still subject to the reporter and CI retention policy.
The success path removes the saved working copy only after both attachments finish, then deletes the browser-managed file. If validation or attachment fails, execution never reaches those deletions. The saved copy remains in the test output for diagnosis, and normal context teardown cleans the browser-managed file. This ordering also prevents a cleanup exception from hiding an earlier assertion failure.
Suggested filenames need a separate assertion only if they are part of the product requirement. A strict equality check across browsers may be wrong because Playwright documents browser-specific filename logic. Test the response header or rendered download attribute at a lower layer when the exact server contract matters, and keep the end-to-end assertion aligned with supported browsers.
A second worked example is two export buttons that both suggest report.csv. The suggestion is ambiguous, but the actions are not. Run them sequentially, assign product-specific destination names, and validate each schema before starting the next transfer.
import { test, expect } from '@playwright/test';
import { readFile, rm } from 'node:fs/promises';
import { saveTriggeredDownload } from './download-helper';
test('exports line items and summary as separate CSV files', async (
{ page },
testInfo,
) => {
await page.goto('/orders/current');
const exports = [
{
button: 'Export line items',
file: 'line-items.csv',
expectedHeader: 'order_id,sku,quantity',
},
{
button: 'Export summary',
file: 'summary.csv',
expectedHeader: 'order_id,total,currency',
},
] as const;
for (const item of exports) {
const saved = await saveTriggeredDownload(
page,
page.getByRole('button', { name: item.button }),
testInfo.outputPath('downloads', item.file),
);
const csv = await readFile(saved.destination, 'utf8');
const firstLine = csv.split(/\r?\n/, 1)[0];
expect(firstLine).toBe(item.expectedHeader);
await testInfo.attach(item.file, {
path: saved.destination,
contentType: 'text/csv',
});
await rm(saved.destination);
await saved.download.delete();
}
});The loop waits for one trigger, transfer, validation, attachment, and browser-file deletion before starting the next trigger. Event order therefore cannot swap ownership. If the product intentionally begins several downloads from one action, collect every event and correlate files through verified content or product metadata. Do not assume arrival order or a repeated suggested filename identifies the payload.
CSV parsing in the example is limited to the header contract. It does not claim that splitting lines is a complete CSV parser. A test that verifies quoted fields, embedded newlines, or column types should use the CSV library already approved by the project.
If the second export fails, the first attachment remains valid because its copy completed before the next click. That sequencing costs latency compared with concurrent transfers, but it gives each waiter and destination one obvious owner. Use concurrency only when simultaneous download behavior is itself the product requirement.
Retain failed copies and remove successful ones
Per-test output paths avoid cross-test collisions, but they do not express a retention decision. Some teams keep every export. Others need only failed attempts. A test-scoped fixture can create a workspace, allow the test to attach anything important, and remove the working copy after a successful outcome.
This fixture keeps the directory when actual and expected status differ. It removes the directory for expected outcomes. Because the test must await testInfo.attach() before returning, attached evidence has already been copied for reporters.
import { test as base } from '@playwright/test';
import { mkdir, rm } from 'node:fs/promises';
type DownloadFixtures = {
downloadWorkspace: string;
};
export const test = base.extend<DownloadFixtures>({
downloadWorkspace: async ({}, use, testInfo) => {
const directory = testInfo.outputPath('download-workspace');
await mkdir(directory, { recursive: true });
await use(directory);
const unexpected =
testInfo.status !== undefined &&
testInfo.status !== testInfo.expectedStatus;
const keepAlways = process.env.KEEP_DOWNLOAD_WORKSPACES === 'always';
if (!unexpected && !keepAlways) {
await rm(directory, { recursive: true, force: true });
}
},
});The policy has a cost. A flaky case preserves attempt zero because it failed unexpectedly, while the passing retry can delete its working copy after attachments are made. That is usually the useful shape: failed-state working files remain, and the final pass does not double storage. If compliance requires every raw export, set an explicit always-retain policy instead.
Cleanup failure should not silently pass. The fixture's rm error propagates and can fail teardown. On a reusable runner, that signal is preferable to unbounded disk growth. On an ephemeral container that is destroyed after the job, strict per-test deletion may add failure noise without operational benefit. Choose based on the runner lifecycle.
Avoid a run-global downloads folder unless another process truly needs it. A global folder requires run, project, shard, test, and attempt partitioning plus its own cleanup guard. testInfo.outputPath() gives you those test-attempt boundaries without custom naming.
If a test initiates several downloads, assign controlled names such as line-items.csv and summary.csv based on the triggering action. Do not launch both clicks concurrently and assume event order identifies them. Run each trigger with its own pre-registered waiter, or correlate using a product-level identifier validated from content.
A long-lived context changes the policy. Calling download.delete() after validation can lower peak disk use because context closure may be far away. The trade-off is loss of the browser-managed original. Keep the saved and attached copy first, and surface deletion errors separately.
Diagnose the boundary that actually failed
A timeout waiting for the event points to event coordination, not disk cleanup. Confirm that the waiter is created before the trigger, that it belongs to the page emitting the event, and that the click reached the expected element. The Playwright trace can show the click action and page state; the source code shows whether the waiter already existed. Increasing the timeout does not change listener order.
An event followed by a non-null download.failure() is a transfer failure. The event was valid because the download began. Record the failure string and source URL, then investigate cancellation, navigation, server response, or context closure. Calling download.cancel() successfully causes failure() to resolve to canceled, which is useful for a deliberate cancellation test but should not be mistaken for cleanup success.
A successful failure() check followed by invalid content is an application-output defect or validation mismatch. The byte checksum and header are available, and saveAs() completed. Cleanup did not corrupt the file. Open the attached copy and compare it with the response the application intended to produce.
An error from download.path() only on Grid or a remotely connected browser is a topology issue. Playwright explicitly documents that method's remote limitation. Replace path-based copying with saveAs() or stream-based validation rather than skipping the test in CI.
A file that exists before upload and disappears from the CI artifact points to packaging. Capture a manifest immediately before upload. The following Linux diagnostic records relative paths, byte sizes, and checksums without modifying the files.
#!/usr/bin/env bash
set -euo pipefail
root="test-results"
manifest="download-inventory.tsv"
: > "${manifest}"
while IFS= read -r file; do
relative="${file#${root}/}"
size=$(stat --format='%s' "${file}")
digest=$(sha256sum "${file}" | awk '{print $1}')
printf '%s\t%s\t%s\n' "${relative}" "${size}" "${digest}" >> "${manifest}"
done < <(find "${root}" -type f -path '*/downloads/*' -print | sort)
if [[ ! -s "${manifest}" ]]; then
echo "No saved download copies found under ${root}" >&2
else
sed -n '1,200p' "${manifest}"
fiAn empty manifest may be correct when every successful working copy was removed after attachment. Inspect reporter attachment storage too. The diagnostic targets saved copies under a downloads segment; update the pattern if your fixture uses download-workspace. Keep the command aligned with the real layout rather than broadening it to the whole workspace.
A directory that grows after every passing run points to user-owned copies, not Playwright's context files. Search for saveAs() destinations and ordinary filesystem copies. A context-close guarantee cannot reach a path chosen by your code.
A missing file after context close may be expected if code saved only the path returned by download.path() and tried to upload it later. That path referred to the browser-managed file. Copy or attach it before closure; do not extend context lifetime solely to keep a temporary file alive.
A near-miss involves the suggested filename. Chrome and WebKit may choose different suggestions for the same response, yet both files can contain correct data. If the incident is "wrong name," preserve suggestedFilename and browser project. If it is "missing bytes," inspect failure, save completion, and content. Do not combine the two diagnoses.
Two full disks can have different owners
An ENOSPC failure near a download test does not identify which copy consumed the disk. A leaked saveAs() destination and a deliberately retained reporter attachment can both make the next transfer fail at the same filesystem call. Deleting more aggressively inside the test can remove useful evidence while leaving the actual storage owner untouched.
Start with the path printed by the failing operation. A user-owned leak appears under the destination or scratch root selected by test code. Inventories from successive runs show old run directories and saved working files still present there. A reporter or archive-retention problem has the opposite shape: the working scratch directory is empty after its guarded cleanup, but a reporter-accessible output, upload staging directory, or external CI artifact continues to contain the copied attachment. Because testInfo.attach() copies a path attachment after the call is awaited, removing the working file does not reclaim the attachment copy.
Browser-managed accumulation has a third signature. It occurs while a long-lived context remains open and disappears when that context closes. Locally, the browser's files use GUID names rather than the controlled destination names assigned by the test. Do not base a portable diagnostic on download.path(), since that method is unavailable for a remote connection. Instead, correlate context lifetime, the number of completed download events, and disk use inside the browser or runner boundary.
The transfer fields answer a different question. A healthy completed transfer has download.failure() equal to null; a non-null string means the browser reports a transfer error. null is a misleading value when treated as content validation. A server can successfully deliver an authentication page, an empty business export, or a well-formed file for the wrong account. Read sourceUrl, the controlled destination, the actual byte count, digest, and application-specific content checks together.
The manifest output should be interpreted field by field. Its first field is the relative saved path, its second is the byte count, and its third is the SHA-256 digest. A path under the expected test-attempt directory shows ownership. A byte count proves only how much was read. A digest lets two copies be compared without claiming that a particular value is universally correct. When policy requires retention for an unexpected failure, the broken state is absence from both the attempt output and the reporter artifact.
Take inventories at three points: after validation and attachment, immediately before archive upload, and after downloading the archive. The earliest missing point owns the incident. If the first inventory lacks the file, test capture or premature cleanup failed. If the first has it and the second does not, an in-job consumer or cleanup step removed it. If both local inventories have it and the downloaded archive does not, the uploader or artifact service boundary failed. Disk-use totals without these path-level observations cannot distinguish retention from leakage.
Put a guarded cleanup step around reusable runners
Per-test fixtures cannot clean files created by a crashed Node process or a killed CI job. A final workflow step should clean only a run-specific scratch directory, and it should execute even when the test step fails.
Set the scratch path from trusted runner metadata before tests start. The cleanup script below refuses an empty value, resolves both the runner temporary root and target, and deletes only a descendant of pw-downloads for the current run.
#!/usr/bin/env bash
set -euo pipefail
: "${RUNNER_TEMP:?RUNNER_TEMP is required}"
: "${PW_DOWNLOAD_SCRATCH:?PW_DOWNLOAD_SCRATCH is required}"
allowed_root=$(realpath -m "${RUNNER_TEMP}/pw-downloads")
target=$(realpath -m "${PW_DOWNLOAD_SCRATCH}")
case "${target}" in
"${allowed_root}"/*) ;;
*)
echo "Refusing to clean path outside ${allowed_root}: ${target}" >&2
exit 40
;;
esac
if [[ -d "${target}" ]]; then
find "${target}" -type f -printf '%p\t%s bytes\n' | sort
rm -rf -- "${target}"
fiThe guard matters more than convenience. Never point recursive cleanup at the workspace root, home directory, or an unresolved environment variable. A typo in cleanup is more damaging than leaked test data.
Wire the value and the always-run cleanup into CI. Upload required failure evidence before deletion.
name: Download tests
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
env:
PW_DOWNLOAD_SCRATCH: ${{ runner.temp }}/pw-downloads/${{ github.run_id }}-${{ github.run_attempt }}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: lts/*
cache: npm
- run: npm ci
- run: npx playwright install --with-deps
- name: Run download cases
run: npx playwright test tests/downloads
- name: Inventory saved download evidence
if: always()
run: ./ci/inventory-downloads.sh
- name: Upload failed-test output
if: failure()
uses: actions/upload-artifact@v5
with:
name: download-failures-${{ github.run_id }}-${{ github.run_attempt }}
path: |
test-results
download-inventory.tsv
if-no-files-found: warn
- name: Clean this run's scratch directory
if: always()
run: ./ci/cleanup-download-scratch.shif: failure() keeps only failed-run output in this example. That saves storage but removes successful samples that might be needed for audit or trend analysis. Choose always() when the evidence policy requires them. The workflow decision should match the fixture retention decision, or engineers will search for files that an earlier layer intentionally removed.
A killed runner may never execute its final step. Reusable runner maintenance still needs a platform-level age-based cleanup for abandoned run directories. Base that cleanup on trusted directory ownership and recorded job completion. Do not invent a retention duration without measuring job cadence and incident needs.
Hand off the copy that crossed the boundary
Test authors own the trigger, filename requirement, and business assertions. The automation-framework owner owns the shared waiter, saveAs() destination, attachment ordering, and fixture teardown. The CI platform owner owns run-specific scratch allocation, uploader conditions, archive retention, and reusable-runner maintenance. A backend or product team owns a completed but incorrect export. Assigning all four incidents to the person who wrote the test hides the boundary that actually failed.
The handoff should include the CI run and attempt, browser project, testId, retry, source URL, suggested filename, controlled destination, download.failure() value, byte count, digest, and the content assertion that failed. Add the last inventory where the file existed, the first inventory where it did not, whether testInfo.attach() completed, and the cleanup target printed by the guard. For disk pressure, include the paths consuming space rather than only the runner's free-space message.
Do not send sensitive export bytes to a broad infrastructure channel by default. The digest, size, path class, failure string, and redacted metadata are usually enough to route a lifecycle defect. Grant access to the retained attachment only to the team authorized for that data. The handoff itself must respect the same data classification as the export.
Roll out cleanup without hiding product failures
First, inventory current paths and classify them as browser-managed, saved copy, reporter attachment, or CI archive. The same bytes can exist in all four places. Deleting one does not imply the others disappeared.
Second, centralize download capture behind a helper that registers the event before the action and checks failure(). Leave cleanup unchanged for the first rollout. This isolates event correctness from retention changes.
Land observation before deletion. The shared helper and metadata attachment should run with the old retention behavior until reports and CI archives prove that consumers can find the new evidence. A legacy custom uploader often reads the original destination after the test has finished. It is the first component to break when fixture teardown begins removing successful working copies, even though Playwright's own awaited attachment remains safe.
Third, move saved copies under testInfo.outputPath() and use controlled leaf names. Add metadata attachments with suggestion, URL, size, checksum, project, and retry. Confirm the report can open attached files after the working copy is removed.
Change one producer and one browser project first. Keep its working copy during the initial canary, compare the metadata attachment with the file, then enable success deletion and download the resulting report artifact. After that path works, migrate the remaining producers. Enable the runner-level scratch cleanup only after every job that needs the file reads either the attachment or an awaited copy. Removing the old destination and changing archive globs in the same release makes a missing export ambiguous again.
Fourth, enable success cleanup in one project. Force a pass, a transfer failure, invalid content, and a fail-then-pass retry. Verify which attempt directories remain and which attachments upload. The values observed are your evidence; do not substitute illustrative file counts.
Fifth, add the guarded final cleanup for a run-specific scratch root. Log its target and inventory before deletion. Keep the guard in review tests by passing an outside path and asserting refusal.
The added validation costs disk I/O. saveAs() makes a copy, reading it hashes the bytes, attaching can copy it again, and upload transfers it. For large exports, stream validation or a single retained copy may be more efficient. Measure on representative files from the application before optimizing.
Do not delete a saved copy before every consumer has awaited its operation. testInfo.attach() explicitly documents when its copy is safe, but an asynchronous custom uploader may have a different contract. Await it or move upload after the test run.
Skip explicit download.delete() when each test uses a short-lived default context and downloads are small. Context closure already removes browser-managed files. The extra call adds code and another failure path without changing retained copies.
Do not preserve sensitive exports by default. Download tests often contain invoices, user lists, or account data. Redact fixtures, restrict artifact access, and set retention according to the data class. Debug convenience does not override privacy.
Avoid time-based sleeps while waiting for a file to "settle." Playwright's download methods already wait for completion as documented. Use failure(), saveAs(), or createReadStream() and validate the resulting content.
This technique does not catch a semantically wrong but structurally valid export. A PDF header, CSV header, byte count, and successful digest calculation can all pass while rows are missing, another tenant's data is present, totals are stale, or authorization is broken. Those defects need assertions against the product's business contract. Cleanup evidence proves which bytes survived each lifecycle boundary, not that the bytes were safe or correct.
The right cleanup policy is asymmetric: release the browser-managed file when its context or explicit lifecycle ends, retain the test-owned copy long enough to prove the assertion, and remove the saved workspace once the evidence has been attached or uploaded. Each deletion then has one owner and one observable boundary.
// FIELD DISPATCH
Get the QA Field Notes
Weekly QA battles, AI testing guides, and interview drills. Free on Substack.
// LIVE COURSE / THE TESTING ACADEMY
Playwright Automation Mastery
Go beyond Selenium. Master Playwright with JS/TS in 90 days.
From the instructor behind this guide.
Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.
PRIMARY REFERENCES
Verify the details at the source
QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.
- 01Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 02Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 03Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 04Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
Does Playwright delete downloads after each test?
Files that belong to a browser context are deleted when that context closes. A file copied to your own path with `download.saveAs()` is test-owned output, so your suite or CI retention policy must clean that destination.
Why does waitForEvent download time out after the file appears?
The listener was probably registered after the action that started the download, or it was registered on the wrong page. Create the `page.waitForEvent('download')` promise before clicking, then await the click and the promise.
How do I know a Playwright download finished successfully?
Await `download.failure()` and require a null result, then await `download.saveAs()` or another completion-waiting method. The download event itself fires when transfer starts, not when every byte has arrived.
Can I use download.path with a remote browser?
No. Playwright documents that `download.path()` throws when connected remotely. Prefer `saveAs()` to a test-owned destination or `createReadStream()` when a stream fits the validation.
Will download.delete remove the file created by saveAs?
Treat the saved destination as a separate copy. `download.delete()` deletes the browser-managed downloaded file, while code that chose the `saveAs()` path remains responsible for removing that copied artifact.
RELATED GUIDES
Continue the learning route
GUIDE 01
Harden Playwright CI with Pinned Containers, Browser Caches, and Artifacts
Build reproducible Playwright CI with matching pinned containers, measured browser-cache policy, stable workers, and failure artifacts that survive.
GUIDE 02
Playwright CI Debugging Interview Questions with Evidence
Playwright CI debugging interview questions: practical design, implementation, debugging, CI, metrics, and interview guidance for QA, SDET, and automation engineers.
GUIDE 03
Multi-Tenant Playwright Authentication Architecture for Parallel CI
Build tenant-safe Playwright authentication with worker account leases, isolated storage state, parallel CI identity, negative guards, and crash recovery.
GUIDE 04
Set Up Playwright Test Agents for Codex with init-agents
Master Playwright init agents codex with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.
GUIDE 05
Use maxFailures Without Losing Playwright CI Evidence
Master Playwright maxFailures CI with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.