{"title":"Create a Failure Triage Toolkit Before the Next Flake","excerpt":"\"Flaky\" is the label teams reach for when the run produced no evidence to say anything more precise. This is the Playwright artifact configuration, diagnostic fixture, reporter and CI wiring that make the next failure classifiable into product bug, test bug, environment, data, or genuine non-determinism.","canonicalUrl":"https://automationtester.in/blog/tools-reviews/failure-triage-toolkit-before-flake","category":{"name":"Tools & Reviews","slug":"tools-reviews"},"tags":["playwright","flaky-tests","test-debugging","trace-viewer","ci-cd","test-reporting"],"author":{"name":"Shashank Rawlani","url":"https://shashank.rawlani.com","linkedin":"https://www.linkedin.com/in/shashankrawlani/"},"datePublished":"2026-09-01T13:30:00.000Z","dateModified":"2026-09-04T04:18:37.205Z","coverImage":{"url":"https://ik.imagekit.io/automationtester/automationtester-in/blog/covers/v2/failure-triage-toolkit-before-flake.webp","alt":"Abstract diagram of an unsorted stream of test failures passing through a classifier and separating into four labelled buckets, where the smallest bucket represents genuine flakiness."},"contentHtml":"<div class=\"callout callout-info\"><strong>Quick answer:</strong> \"Flaky\" is a conclusion you are entitled to only after the evidence rules out a product bug, a test bug, an environment fault and a data collision. Most teams cannot rule those out because they capture artifacts on the retry rather than on the failure. Set <code>trace: 'retain-on-failure'</code> rather than <code>'on-first-retry'</code>, attach console, network and retry metadata from an automatic fixture, and keep a per-run ledger of failures. Then the label becomes a finding instead of a shrug.</div>\n\n<p>The sequence is familiar. A pipeline goes red on a checkout test. Someone reruns it. It goes green. The thread ends with \"flaky, ignore\". Three weeks later the same test fails in the same place, the same rerun clears it, and nobody has learned anything in between.</p>\n\n<p>That loop persists for a structural reason, not a cultural one. By the time somebody looked, the only surviving evidence was a stack trace saying a locator timed out after 30 seconds. From that single artifact, \"flaky\" really is the most defensible claim available. The problem is upstream: the run did not produce enough evidence to support a more precise one.</p>\n\n<h2 id=\"flaky-is-a-diagnosis\">\"Flaky\" is a diagnosis, not an observation</h2>\n\n<p>What you observed is that a test failed once and passed on re-execution. That is compatible with at least five underlying causes, and four of them are bugs you can fix.</p>\n\n<p>Genuine non-determinism — an uncontrollable race, an animation frame, a clock boundary — is real, but in most suites it is the smallest of the five categories. It only looks largest because it is the default label for everything undiagnosed. A suite where 12% of failures are labelled flaky and 0% anything else does not have a flakiness problem; it has an evidence problem. Closing that gap has to happen before the failure, because afterwards the worker is gone, the browser is gone, and the container has been reclaimed.</p>\n\n<h2 id=\"capture-on-first-failure\">Capture on the first failure, not on the retry</h2>\n\n<p>This is the highest-leverage change, and the most commonly misconfigured setting in Playwright projects.</p>\n\n<p>Almost every published starter config uses <code>trace: 'on-first-retry'</code>. That mode records a trace <em>on the first retry only</em> — nothing during the original run. The artifact you end up with describes an execution that <strong>passed</strong>, or at best a second failure that may have failed differently. The failure you care about was never recorded.</p>\n\n<p>Playwright documents seven trace modes, and the differences are not cosmetic:</p>\n\n<table>\n<thead><tr><th>Mode</th><th>Records a trace on</th><th>Keeps the trace when</th></tr></thead>\n<tbody>\n<tr><td><code>'off'</code></td><td>never</td><td>—</td></tr>\n<tr><td><code>'on'</code></td><td>every run</td><td>always</td></tr>\n<tr><td><code>'retain-on-failure'</code></td><td>every run</td><td>that run failed</td></tr>\n<tr><td><code>'retain-on-first-failure'</code></td><td>first run only</td><td>the first run failed</td></tr>\n<tr><td><code>'retain-on-failure-and-retries'</code></td><td>every run</td><td>that run failed, or it is a retry</td></tr>\n<tr><td><code>'on-first-retry'</code></td><td>first retry only</td><td>always</td></tr>\n<tr><td><code>'on-all-retries'</code></td><td>every retry</td><td>always</td></tr>\n</tbody>\n</table>\n\n<p>For triage you want a mode from the \"records on every run\" family. <code>'retain-on-failure'</code> traces every run and discards traces for runs that passed: full evidence for failures, near-zero storage for green runs, at the cost of tracing overhead on passing tests.</p>\n\n<p>Where that overhead is unacceptable, <code>'retain-on-failure-and-retries'</code> beats <code>'on-first-retry'</code>, because it captures the original failure <em>and</em> keeps the retry. Diffing a failed run against its passing retry is the most direct way to separate a data problem from a timing problem, and it is impossible if you kept only one of them.</p>\n\n<h2 id=\"artifact-configuration\">The configuration that makes failures diagnosable</h2>\n\n<p>The trace is the centrepiece but not sufficient alone. Video captures what the browser painted <em>between</em> actions, where CSS transitions and third-party overlays live. Screenshots survive tooling that cannot open a trace. <code>preserveOutput</code> decides whether the raw output directory survives at all.</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// playwright.config.ts\nimport { defineConfig } from '@playwright/test';\n\nexport default defineConfig({\n  // Retries are for keeping the pipeline moving. They are not the diagnosis.\n  retries: process.env.CI ? 2 : 0,\n\n  outputDir: 'test-results',\n  // 'always' (the default) keeps output for passing tests too and grows fast.\n  // 'failures-only' keeps precisely the runs you will look at.\n  preserveOutput: 'failures-only',\n\n  use: {\n    // Records every run, keeps the failures. Do not use 'on-first-retry' here.\n    trace: {\n      mode: 'retain-on-failure',\n      snapshots: true,   // DOM snapshot per action - the \"why did the locator\n                         // not match\" evidence. Defaults to true.\n      screenshots: true, // builds the timeline filmstrip\n      sources: false,    // omit source files; they bloat the zip in CI where\n                         // the checkout is already available\n      attachments: true,\n    },\n    video: 'retain-on-failure',\n    screenshot: 'only-on-failure',\n  },\n\n  reporter: process.env.CI\n    ? [['blob'], ['github'], ['./reporters/flake-ledger.ts']]\n    : [['list'], ['html', { open: 'never' }]],\n});</code></pre>\n\n<p>Two details there are load-bearing. <code>retries: 0</code> locally combined with <code>trace: 'on-first-retry'</code> means a developer running the suite on their machine <em>never</em> produces a trace at all, because there is never a first retry; the mode above behaves identically in both environments. And <code>snapshots</code> is what makes the trace viewer's DOM inspector work — with it off you get an action log and screenshots but cannot query the DOM at the moment the locator failed, which is the fastest way to tell \"never rendered\" from \"rendered but covered by an overlay\". Both fail with the same timeout message.</p>\n\n<h2 id=\"classification-taxonomy\">Five buckets, and the evidence that puts a failure in one</h2>\n\n<p>Triage is a classification task, so it needs mutually exclusive categories and an evidence test for each. Without the evidence test, everyone classifies by intuition and the buckets drift.</p>\n\n<table>\n<thead><tr><th>Bucket</th><th>Claim</th><th>Evidence that confirms it</th></tr></thead>\n<tbody>\n<tr><td>Product bug</td><td>The application did the wrong thing</td><td>A non-2xx response, a console <code>pageerror</code>, or a DOM snapshot showing the app in a state its own spec forbids</td></tr>\n<tr><td>Test bug</td><td>The application was fine; the test asserted wrongly</td><td>DOM snapshot shows the expected state present, but under a different accessible name, in a second matching node, or behind an overlay the test never dismissed</td></tr>\n<tr><td>Environment</td><td>The infrastructure under the test failed</td><td>Failures cluster by <code>workerIndex</code> or by wall-clock window; a worker restart in the run log; DNS or connection-refused entries rather than HTTP statuses</td></tr>\n<tr><td>Data</td><td>Two runs contended for the same record</td><td>Failure correlates with <code>parallelIndex</code>, or reproduces only when a specific other test runs concurrently, or the API returns a 409/422 on a fixture the test believes it owns</td></tr>\n<tr><td>Non-determinism</td><td>A genuine, uncontrolled race</td><td>Everything above ruled out; failure point moves between runs; the failed and passing traces show identical inputs and different interleaving</td></tr>\n</tbody>\n</table>\n\n<p>The last bucket is defined by exclusion on purpose. It is the residue, and if it is not small, the evidence is still incomplete.</p>\n\n<h2 id=\"reading-the-evidence\">Reading the evidence: three discriminators that work</h2>\n\n<h3 id=\"network-status-vs-network-failure\">A 500 is not a network failure</h3>\n\n<p>This trips up nearly every hand-written logging fixture. Playwright's <code>page.on('requestfailed')</code> fires only when the client could not get an HTTP response at all — DNS failure, connection refused, <code>net::ERR_FAILED</code>. A 404 or 503 is a successful exchange from the protocol's point of view, so it emits <code>requestfinished</code> instead.</p>\n\n<p>A fixture listening only for <code>requestfailed</code> will therefore report a clean network on a run where the backend returned 500 to every call, and the test presents as \"timed out waiting for the results table\" — indistinguishable from flakiness, actually a product bug. Listen for responses and filter by status.</p>\n\n<h3 id=\"never-matched-vs-never-actionable\">Never matched versus never actionable</h3>\n\n<p>Action timeouts cover two very different situations with one message. Open the failing action in the trace viewer and check the DOM snapshot at that step:</p>\n\n<ul>\n<li><strong>Zero matching nodes</strong> — the app never reached the state the test expected. That is a product bug or a waiting bug, and the network log at the same timestamp usually says which.</li>\n<li><strong>One matching node, action still timed out</strong> — the element existed but failed an actionability check: covered by a modal, still animating, <code>pointer-events: none</code>, or detached and re-rendered mid-action. That is almost always a test bug or a product bug in the overlay, not non-determinism.</li>\n<li><strong>Two or more matching nodes</strong> — a strict mode violation waiting to happen, and the reason it only fails sometimes is that the second node only renders under some data conditions. That is a test bug with a data trigger.</li>\n</ul>\n\n<h3 id=\"does-it-correlate\">Does it correlate with anything?</h3>\n\n<p>Non-determinism, by definition, correlates with nothing. Everything else correlates with something: a worker, a shard, a time of day, a concurrently running test, a deploy. That is why the ledger below matters more than any single trace — correlation is a property of many runs and invisible in one.</p>\n\n<h2 id=\"diagnostic-fixture\">A fixture that attaches what you will wish you had</h2>\n\n<p>Traces cover browser activity. They do not cover what your test knew and never recorded: which seeded account it used, which flags were on, which build of the API it talked to. Attach that yourself. <code>testInfo.attach()</code> takes either <code>body</code> or <code>path</code>, never both, and infers content type from the path when <code>contentType</code> is omitted (defaulting to <code>text/plain</code> for strings, <code>application/octet-stream</code> for buffers). Attachments appear in the HTML report and travel inside blob reports.</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// fixtures/diagnostics.ts\nimport { test as base } from '@playwright/test';\n\ntype Diagnostics = { diagnostics: void };\n\nexport const test = base.extend&lt;Diagnostics&gt;({\n  diagnostics: [async ({ page }, use, testInfo) =&gt; {\n    const console_: string[] = [];\n    const pageErrors: string[] = [];\n    const httpErrors: string[] = [];\n    const transportErrors: string[] = [];\n\n    page.on('console', msg =&gt; {\n      if (msg.type() === 'error' || msg.type() === 'warning')\n        console_.push(`[${msg.type()}] ${msg.text()}`);\n    });\n\n    // Uncaught exceptions in page scripts. Not the same as console errors.\n    page.on('pageerror', err =&gt; pageErrors.push(err.stack ?? err.message));\n\n    // HTTP-level failures: these do NOT surface via 'requestfailed'.\n    page.on('response', res =&gt; {\n      if (res.status() &gt;= 400)\n        httpErrors.push(`${res.status()} ${res.request().method()} ${res.url()}`);\n    });\n\n    // Transport-level failures: DNS, refused connection, aborted request.\n    page.on('requestfailed', req =&gt;\n      transportErrors.push(`${req.url()} ${req.failure()?.errorText}`));\n\n    await use();\n\n    // Only attach on an unexpected outcome. Attaching on green runs makes the\n    // report unreadable and inflates blob artifacts.\n    if (testInfo.status === testInfo.expectedStatus) return;\n\n    await testInfo.attach('triage-context', {\n      contentType: 'application/json',\n      body: JSON.stringify({\n        retry: testInfo.retry,\n        status: testInfo.status,\n        expectedStatus: testInfo.expectedStatus,\n        workerIndex: testInfo.workerIndex,\n        parallelIndex: testInfo.parallelIndex,\n        durationMs: testInfo.duration,\n        lastUrl: page.url(),\n        buildSha: process.env.GIT_SHA ?? 'unknown',\n        apiBase: process.env.API_BASE_URL ?? 'unknown',\n        httpErrors,\n        transportErrors,\n        pageErrors,\n        console: console_.slice(-50),\n      }, null, 2),\n    });\n\n    // A DOM snapshot the trace cannot give you: the serialised markup at the\n    // moment of failure, greppable without opening a viewer.\n    await testInfo.attach('dom-at-failure.html', {\n      contentType: 'text/html',\n      body: await page.content().catch(() =&gt; '&lt;!-- page already closed --&gt;'),\n    });\n  }, { auto: true }],\n});\n\nexport { expect } from '@playwright/test';</code></pre>\n\n<p>Three notes. <code>{ auto: true }</code> is what makes it run for every test without anyone importing it — a diagnostic fixture you must remember to opt into will not be there on the run that matters. The <code>page.content()</code> call is guarded because a test that crashed the page throws on any further page call, and an exception in teardown replaces the real failure with a confusing one. The console buffer is truncated because a chatty SPA otherwise produces a multi-megabyte attachment per failure.</p>\n\n<h2 id=\"retry-metadata\">Turn retries into a ledger instead of a shrug</h2>\n\n<p>Playwright's report already distinguishes <em>flaky</em> (failed first, passed on retry) from <em>failed</em> (failed every attempt). That distinction is per-run. Triage needs it accumulated across hundreds of runs, keyed so you can ask what a failure correlates with. A reporter is the cheapest place to produce that: <code>onTestEnd</code> receives a <code>TestResult</code> carrying <code>retry</code>, <code>status</code>, <code>duration</code>, <code>errors</code>, <code>attachments</code>, <code>workerIndex</code> and <code>parallelIndex</code>.</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// reporters/flake-ledger.ts\nimport { appendFileSync } from 'node:fs';\nimport type { Reporter, TestCase, TestResult } from '@playwright/test/reporter';\n\nexport default class FlakeLedger implements Reporter {\n  onTestEnd(test: TestCase, result: TestResult) {\n    if (result.status === 'passed' &amp;&amp; result.retry === 0) return;\n\n    appendFileSync('test-results/ledger.ndjson', JSON.stringify({\n      runId: process.env.GITHUB_RUN_ID ?? 'local',\n      shard: process.env.SHARD_INDEX ?? '1',\n      buildSha: process.env.GITHUB_SHA ?? 'local',\n      titlePath: test.titlePath().join(' &gt; '),\n      location: `${test.location.file}:${test.location.line}`,\n      project: test.parent.project()?.name,\n      attempt: result.retry,\n      status: result.status,          // passed | failed | timedOut | skipped | interrupted\n      durationMs: result.duration,\n      startedAt: result.startTime.toISOString(),\n      workerIndex: result.workerIndex,\n      parallelIndex: result.parallelIndex,\n      // First error message only, normalised so identical failures group.\n      error: result.errors[0]?.message\n        ?.split('\\n')[0]\n        .replace(/\\d+/g, 'N')\n        .slice(0, 300),\n      artifacts: result.attachments.map(a =&gt; a.name),\n    }) + '\\n');\n  }\n}</code></pre>\n\n<p>Note that <code>'timedOut'</code> is a status distinct from <code>'failed'</code>. Collapsing the two, which most homegrown dashboards do, destroys the ledger's most useful signal: a timeout was waiting for something that never happened, while an assertion failure got a wrong value promptly. Different investigations.</p>\n\n<p>Normalising digits out of the error message is what makes grouping work. Without it, <code>Timeout 30000ms exceeded</code> and <code>Timeout 15000ms exceeded</code> are two distinct failures and no pattern emerges.</p>\n\n<p>One caveat on <code>parallelIndex</code>: it runs from <code>0</code> to <code>workers - 1</code> and is guaranteed unique only among workers running <em>at the same time</em>. It is a slot number, not a process identifier — use it to detect contention between concurrent tests, not to track a process across a run.</p>\n\n<h2 id=\"ci-wiring\">Wiring CI so the evidence survives the failure</h2>\n\n<p>Artifacts sitting on a reclaimed machine are not evidence. Two things go wrong most often: the upload step is skipped because the test step failed, and sharded runs produce fragmentary reports nobody can merge.</p>\n\n<pre class=\"language-yaml\"><code class=\"language-yaml\"># .github/workflows/e2e.yml\njobs:\n  test:\n    strategy:\n      fail-fast: false          # a red shard must not cancel the others,\n      matrix:                   # or you lose their evidence too\n        shard: [1, 2, 3, 4]\n    steps:\n      - uses: actions/checkout@v6\n      - uses: actions/setup-node@v4\n        with: { node-version: 20 }\n      - run: npm ci\n      - run: npx playwright install --with-deps\n      - run: npx playwright test --shard=${{ matrix.shard }}/4\n        env:\n          GIT_SHA: ${{ github.sha }}\n          SHARD_INDEX: ${{ matrix.shard }}\n\n      # Runs even when the test step failed - which is the only time it matters.\n      - uses: actions/upload-artifact@v4\n        if: ${{ !cancelled() }}\n        with:\n          name: blob-report-${{ matrix.shard }}\n          path: blob-report/\n          retention-days: 30\n\n      - uses: actions/upload-artifact@v4\n        if: ${{ !cancelled() }}\n        with:\n          name: traces-${{ matrix.shard }}\n          path: test-results/\n          retention-days: 14\n\n  report:\n    needs: [test]\n    if: ${{ !cancelled() }}\n    steps:\n      - uses: actions/checkout@v6\n      - run: npm ci\n      - uses: actions/download-artifact@v4\n        with: { path: all-blobs, pattern: blob-report-*, merge-multiple: true }\n      - run: npx playwright merge-reports --reporter=html ./all-blobs</code></pre>\n\n<p>The blob reporter exists for exactly this: it records full run detail and <code>merge-reports</code> reassembles the shards into one HTML report afterwards. Blob files are named <code>report-&lt;hash&gt;-&lt;shard_number&gt;.zip</code>, where the hash derives from the command-line filters, so two differently filtered runs cannot silently overwrite each other.</p>\n\n<p>Once traces sit behind a URL you never need to download them. <code>npx playwright show-trace https://.../trace.zip</code> opens a remote trace directly, and the hosted viewer accepts the trace URL as a query parameter. Putting that link in the failure notification removes the largest single piece of triage friction: the download-and-unzip step that stops people from looking at all.</p>\n\n<div class=\"callout callout-warning\"><strong>Before you upload:</strong> a network log and a DOM snapshot contain whatever your app was handling. Auth tokens in request URLs, PII in API responses, session cookies in headers. Redact in the fixture, not in the review, and set a short retention on the artifact bucket.</div>\n\n<h2 id=\"triage-failure-modes\">Where triage toolkits fail</h2>\n\n<p>Each of these has a different cause and a different fix.</p>\n\n<p><strong>Everything is retained, so nothing is read.</strong> <code>preserveOutput: 'always'</code> plus <code>trace: 'on'</code> produces gigabytes per run and a report that takes a minute to load. People stop opening it. The fix is asymmetric retention: full fidelity for failures, nothing for passes.</p>\n\n<p><strong>Retries mask a real regression.</strong> Two retries turn a product bug that fires 40% of the time into a green pipeline roughly 78% of the time. The ledger is the defence: a test repeatedly marked <em>flaky</em> is a test repeatedly failing, and the ledger shows that even though every run was green.</p>\n\n<p><strong>The diagnostic fixture becomes the flake.</strong> A fixture that awaits a network call, writes a large file, or attaches on every test adds its own timeouts and its own teardown failures. Cap the payload and swallow exceptions inside it.</p>\n\n<p><strong>Classification without a schema.</strong> If the bucket lives in a free-text ticket comment, nobody can count them. Put the five categories in a required field; the monthly distribution then answers \"where should we spend engineering time\" directly.</p>\n\n<h2 id=\"apply-this-now\">Apply this now</h2>\n\n<p>Today, in one commit: change <code>trace</code> from <code>'on-first-retry'</code> to <code>'retain-on-failure'</code>, add <code>video: 'retain-on-failure'</code>, set <code>preserveOutput: 'failures-only'</code>, and confirm the artifact upload step carries <code>if: ${{ !cancelled() }}</code>. This week: add the automatic diagnostics fixture and the ledger reporter and let them run.</p>\n\n<p>The evidence to capture per failure, before anyone is allowed to write \"flaky\": the trace zip from the failing run, the status of every non-2xx response during the test, the count of DOM nodes matching the failing locator at the moment it failed, and the ledger rows for that test over the last thirty days. If all four are unremarkable, the label has been earned.</p>\n\n<h2 id=\"faq\">FAQ</h2>\n\n<h3 id=\"faq-tracing-overhead\">Does <code>retain-on-failure</code> slow the suite down noticeably?</h3>\n\n<p>It records on every run, so the overhead applies to passing tests too, dominated by DOM snapshotting per action. If you measure a real regression, keep the mode and disable <code>sources</code> first, then <code>screenshots</code>, before falling back to <code>'retain-on-failure-and-retries'</code> — which, unlike <code>'on-first-retry'</code>, still captures a failing first run.</p>\n\n<h3 id=\"faq-retries-zero\">Should we set retries to zero so nothing is hidden?</h3>\n\n<p>That surfaces every failure but stops the pipeline on each one, and it removes the passing-retry artifact that makes diffing possible. Keep retries for throughput and treat the <em>flaky</em> count as a first-class metric that must trend to zero. A retry is acceptable when it is recorded and reviewed, not as a substitute for review.</p>\n\n<h3 id=\"faq-attach-vs-outputpath\">When should I use <code>testInfo.attach()</code> rather than <code>testInfo.outputPath()</code>?</h3>\n\n<p>Use <code>attach()</code> when a human should see the file in the report — it copies the file to a reporter-accessible location and registers it, and you may delete your copy once the promise resolves. Use <code>outputPath()</code> for scratch files the test itself consumes; those live in a per-test subdirectory of <code>outputDir</code>, which is what stops parallel tests colliding.</p>\n\n<h3 id=\"faq-third-party\">How do I classify a failure caused by a third-party script?</h3>\n\n<p>Environment. The signature is a <code>requestfailed</code> or a long-tail response on a domain you do not own, while your own API calls are clean. Route-block that domain in test runs once you have the evidence — but not pre-emptively, or you lose the ability to detect it.</p>\n\n<h2 id=\"references\">References</h2>\n\n<ul>\n<li><a href=\"https://playwright.dev/docs/test-use-options\">Test use options</a> — the trace and video mode tables, including which runs each mode records and which recordings it keeps.</li>\n<li><a href=\"https://playwright.dev/docs/api/class-testoptions\">TestOptions API</a> — the object form of <code>trace</code> with <code>mode</code>, <code>snapshots</code>, <code>screenshots</code>, <code>sources</code> and <code>attachments</code>.</li>\n<li><a href=\"https://playwright.dev/docs/api/class-testinfo\">TestInfo API</a> — <code>attach()</code> semantics (<code>body</code> and <code>path</code> are mutually exclusive), <code>retry</code>, and the <code>status</code> versus <code>expectedStatus</code> comparison.</li>\n<li><a href=\"https://playwright.dev/docs/api/class-testresult\">TestResult API</a> — the fields a reporter receives in <code>onTestEnd</code>, including <code>parallelIndex</code>, <code>workerIndex</code> and the distinct <code>timedOut</code> status.</li>\n<li><a href=\"https://playwright.dev/docs/test-retries\">Test retries</a> — worker restart behaviour on failure, and the definition of the \"flaky\" and \"failed\" report categories.</li>\n<li><a href=\"https://playwright.dev/docs/test-reporters\">Reporters</a> — the blob reporter, its file naming, and <code>merge-reports</code> for sharded runs.</li>\n<li><a href=\"https://playwright.dev/docs/api/class-page\">Page API</a> — confirmation that <code>requestfailed</code> excludes HTTP error responses such as 404 and 503.</li>\n<li><a href=\"https://playwright.dev/docs/trace-viewer\">Trace viewer</a> — opening local and remote traces, including <code>trace.playwright.dev</code> query-parameter loading.</li>\n<li><a href=\"https://playwright.dev/docs/api/class-testconfig\">TestConfig API</a> — <code>outputDir</code> per-test subdirectories and the three <code>preserveOutput</code> values.</li>\n</ul>\n"}