{"title":"Fail CI on Flaky Playwright Tests Without Hiding Evidence","excerpt":"Playwright labels a test that passes on retry as flaky, counts it as ok, and exits zero — so the build goes green and the signal is discarded. This shows you how to read stats.flaky out of the JSON report, gate CI on a flake rate rather than on any single flaky test, and quarantine with tags, owners and expiry dates instead of test.skip().","canonicalUrl":"https://automationtester.in/blog/automation-tutorials/fail-ci-flaky-playwright-tests-evidence","category":{"name":"Automation Tutorials","slug":"automation-tutorials"},"tags":["playwright","flaky-tests","ci-cd","test-reporting","test-quarantine","test-retries"],"author":{"name":"Shashank Rawlani","url":"https://shashank.rawlani.com","linkedin":"https://www.linkedin.com/in/shashankrawlani/"},"datePublished":"2026-09-06T13:30:00.000Z","dateModified":"2026-09-06T10:47:15.133Z","coverImage":{"url":"https://ik.imagekit.io/automationtester/automationtester-in/blog/covers/v2/fail-ci-flaky-playwright-tests-evidence.webp","alt":"Dark technical illustration: across the top, an unbroken chain of five glowing green pipeline stages joined by connectors, reading as a passing build. Beneath it, inside a dashed container, a wide grid of rounded attempt cells forms a retained run history; scattered orange cells mark failed attempts, each followed by a ringed bright green cell marking the retry that passed. Faint dashed orange lines rise from the failed attempts into the green surface above, and a row of small orange tally squares runs along the bottom edge."},"contentHtml":"<div class=\"callout callout-info\"><strong>Quick answer:</strong> Playwright already tells you which tests passed only on retry — it labels them <code>flaky</code> in the run summary and in the JSON report's <code>stats.flaky</code>. It also marks them <code>ok</code>, so the process exits zero and the build goes green. <code>retries</code> is an evidence-collection setting. Turning it on without reading the flaky list is how a team pays for the evidence and then throws it away.</div>\n\n<p>Here is the run that starts this problem. A pull request is green. The check is green, the merge button is green, and in the terminal output, forty lines above the summary, sits this:</p>\n\n<pre class=\"language-text\"><code class=\"language-text\">  1 flaky\n    example.spec.ts:5:2 › second flaky\n  2 passed (4s)</code></pre>\n\n<p>Nobody read it, because nothing asked them to. The exit code was zero, GitHub showed a tick, and the reviewer looked at the diff. Six weeks later the same test fails on the first attempt and all three retries, someone opens the trace, and the failure turns out to be a race the team has been paying for since a config change in March. The signal was never missing. It was emitted, recorded, and discarded on every single run.</p>\n\n<h2 id=\"what-flaky-actually-means\">What Playwright actually means by \"flaky\"</h2>\n\n<p>The retries documentation defines three categories precisely, and the precision matters:</p>\n\n<ul>\n<li><strong>passed</strong> — tests that passed on the first run.</li>\n<li><strong>flaky</strong> — tests that failed on the first run, but passed when retried.</li>\n<li><strong>failed</strong> — tests that failed on the first run and failed all retries.</li>\n</ul>\n\n<p>So \"flaky\" in Playwright is not a heuristic, a score, or a judgement about a test's character. It is a mechanical fact about one execution: at least one attempt was red, and a later attempt was green. A test can be labelled flaky today and passed tomorrow, and both labels are correct records of what happened.</p>\n\n<p>Two different properties encode this, and conflating them is the root of most broken flake tooling.</p>\n\n<p><code>testResult.status</code> is the status of <em>one attempt</em>: <code>'passed' | 'failed' | 'timedOut' | 'skipped' | 'interrupted'</code>. Each retry produces its own <code>TestResult</code> with its own status and its own <code>retry</code> index, and they all live in <code>testCase.results</code>.</p>\n\n<p><code>testCase.outcome()</code> is the verdict over <em>all attempts</em>: <code>'skipped' | 'expected' | 'unexpected' | 'flaky'</code>. The documentation is explicit that outcome is not the same as <code>testResult.status</code>, and gives the two cases that prove it: a test expected to fail that does fail is <code>'expected'</code>, and a test that passes on a second retry is <code>'flaky'</code>.</p>\n\n<p>\"Expected\" is doing real work there. <code>testCase.expectedStatus</code> is <code>'passed'</code> for ordinary tests, <code>'skipped'</code> for anything marked <code>test.skip()</code> or <code>test.fixme()</code>, and <code>'failed'</code> for anything marked <code>test.fail()</code>. Outcome is the comparison of actual against expected, not the raw actual. This is why \"did the test pass\" is the wrong question to ask a reporter, and <code>outcome() === 'flaky'</code> is the right one.</p>\n\n<p>Inside a test, the same fact is available as <code>testInfo.retry</code>: zero on the first run, one on the first retry, and so on. It is readable from any test, hook or fixture.</p>\n\n<h2 id=\"where-the-signal-dies\">Where the signal surfaces, and exactly where it dies</h2>\n\n<p>Playwright surfaces flakiness in every built-in reporter. The list reporter prints a <code>flaky</code> block with the test titles. The dot reporter has a dedicated character for it: <code>±</code> means \"passed on retry (flaky)\", distinct from <code>×</code>, which means \"failed or timed out — and will be retried\". The HTML report keeps the failed attempt alongside the passing one.</p>\n\n<p>The JSON reporter carries it as structured data, in two places. Per test, <code>JSONReportTest.status</code> is one of <code>'skipped' | 'expected' | 'unexpected' | 'flaky'</code>. And at the top of the document, already aggregated for you:</p>\n\n<pre class=\"language-json\"><code class=\"language-json\">{\n  \"stats\": {\n    \"startTime\": \"2026-03-11T08:14:02.118Z\",\n    \"duration\": 412803,\n    \"expected\": 611,\n    \"unexpected\": 0,\n    \"flaky\": 9,\n    \"skipped\": 4\n  }\n}</code></pre>\n\n<p>Now the part that decides everything. In Playwright's own runner, the <code>ok</code> flag on a spec is computed as <code>test.outcome() === 'expected' || test.outcome() === 'flaky'</code>. Flaky counts as ok. And <code>testCase.ok()</code> is documented as \"whether the test is considered running fine. Non-ok tests fail the test run with non-zero exit code.\"</p>\n\n<p>That is the whole failure. Playwright classified the run correctly, wrote nine flaky tests into a machine-readable file, and then exited zero because flaky is ok. CI reads the exit code. Nothing in the default pipeline reads <code>stats.flaky</code>. The evidence exists at full fidelity and has no consumer.</p>\n\n<div class=\"callout callout-warning\"><strong>Institutionalised blindness has a config signature:</strong> <code>retries: 2</code> in <code>playwright.config.ts</code>, the JSON or blob reporter enabled, no job step that opens the report, and no metric anywhere with the word \"flake\" in it. Every element is individually defensible. Together they mean the team is running each unstable test up to three times a day and storing the results where nobody looks.</div>\n\n<h2 id=\"fail-on-rate-not-on-any-one-test\">Gate on flake rate, not on any single flaky test</h2>\n\n<p>Since v1.52 there is a blunt instrument for this: <code>failOnFlakyTests</code> in the config, or <code>--fail-on-flaky-tests</code> on the command line. It exits with an error if any test is marked flaky.</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// playwright.config.ts — the blunt version\nimport { defineConfig } from '@playwright/test';\n\nexport default defineConfig({\n  retries: process.env.CI ? 2 : 0,\n  failOnFlakyTests: !!process.env.CI,\n});</code></pre>\n\n<p>On a small, healthy suite this is the right answer and you should stop reading this section. On a suite of 600 browser tests hitting a real backend it is not, for a reason that is arithmetic rather than ideology. If a single test has a genuine 0.5% first-attempt failure rate from causes outside your test code — a slow CDN response, a container that took an extra second to become healthy — then across 600 tests you expect roughly three flaky labels per run. <code>failOnFlakyTests</code> turns that into a red build on most runs, and a check that is red most of the time gets ignored or bypassed within a fortnight. You have moved the blindness, not removed it.</p>\n\n<p>The gate that survives contact with a large suite is a <strong>rate</strong> gate with a threshold you chose deliberately, plus a named-test gate for repeat offenders. Rate is a property of the suite; individual flaky labels are noise on top of it.</p>\n\n<p>Start by keeping the raw evidence. Reporters compose, so add JSON without giving up readable terminal output:</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 buy evidence: a second data point on whether the failure reproduces.\n  // They do not buy a green build — the gate below decides that.\n  retries: process.env.CI ? 2 : 0,\n\n  // Deliberately NOT failOnFlakyTests. The rate gate owns this decision.\n  failOnFlakyTests: false,\n\n  use: {\n    // Keeps the failed attempt's trace even though a later retry passed.\n    trace: 'retain-on-failure',\n  },\n\n  reporter: process.env.CI\n    ? [\n        ['github'],\n        ['html', { open: 'never' }],\n        ['json', { outputFile: 'flake-evidence/results.json' }],\n      ]\n    : 'list',\n});</code></pre>\n\n<p>Then post-process. This script reads nothing but the JSON report, so it works unchanged whether the report came from one machine or from merged shards:</p>\n\n<pre class=\"language-js\"><code class=\"language-js\">// scripts/flake-gate.mjs\n// Usage: node scripts/flake-gate.mjs flake-evidence/results.json\nimport { readFileSync } from 'node:fs';\n\nconst THRESHOLD_PCT = 1.5;                 // suite-wide budget\nconst REPEAT_OFFENDERS = new Set([         // known-bad, individually gated\n  'checkout.spec.ts:88:5 › applies a promo code',\n]);\n\nconst report = JSON.parse(readFileSync(process.argv[2], 'utf8'));\nconst { expected, unexpected, flaky, skipped } = report.stats;\n\n// Skipped tests never ran, so they do not belong in the denominator.\nconst executed = expected + unexpected + flaky;\nconst rate = executed ? (flaky / executed) * 100 : 0;\n\n// Walk the suite tree to name the offenders — stats alone cannot.\nconst names = [];\nconst walk = (suite) =&gt; {\n  for (const spec of suite.specs ?? [])\n    for (const t of spec.tests)\n      if (t.status === 'flaky')\n        names.push(`${spec.file}:${spec.line}:${spec.column} › ${spec.title}`);\n  for (const child of suite.suites ?? []) walk(child);\n};\nreport.suites.forEach(walk);\n\nconsole.log(`flake rate ${rate.toFixed(2)}% (${flaky}/${executed}), budget ${THRESHOLD_PCT}%`);\nfor (const n of names) console.log(`  flaky: ${n}`);\n\nconst overBudget = rate &gt; THRESHOLD_PCT;\nconst offender = names.find((n) =&gt; REPEAT_OFFENDERS.has(n));\n\nif (offender) {\n  console.error(`gate failed: quarantined test flaked in the main lane — ${offender}`);\n  process.exit(1);\n}\nif (overBudget) {\n  console.error(`gate failed: flake rate ${rate.toFixed(2)}% exceeds ${THRESHOLD_PCT}%`);\n  process.exit(1);\n}\nconsole.log(`gate passed; skipped: ${skipped}`);</code></pre>\n\n<p>Note the two exits are different failures with different owners. Over-budget is a suite health problem for the team. A quarantined test flaking in the main lane means quarantine leaked, which is a process problem.</p>\n\n<p>If you would rather the decision live inside Playwright than in a separate step, a custom reporter can override the run's exit status directly. <code>onEnd</code> is documented as being allowed to override the status and hence affect the exit code, by returning an object with a <code>status</code> field:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// reporters/flake-budget.ts\nimport type { FullResult, Reporter, Suite } from '@playwright/test/reporter';\n\nclass FlakeBudget implements Reporter {\n  private suite!: Suite;\n  constructor(private options: { maxPercent: number } = { maxPercent: 1.5 }) {}\n\n  // Returning false lets Playwright keep a normal terminal reporter alongside.\n  printsToStdio() { return false; }\n\n  onBegin(_config: unknown, suite: Suite) { this.suite = suite; }\n\n  async onEnd(result: FullResult) {\n    const tests = this.suite.allTests();\n    const executed = tests.filter((t) =&gt; t.outcome() !== 'skipped');\n    const flaky = executed.filter((t) =&gt; t.outcome() === 'flaky');\n    const pct = executed.length ? (flaky.length / executed.length) * 100 : 0;\n\n    for (const t of flaky) {\n      const attempts = t.results.map((r) =&gt; r.status).join(' → ');\n      console.log(`FLAKY ${t.titlePath().join(' › ')} [${attempts}]`);\n    }\n\n    if (result.status === 'passed' &amp;&amp; pct &gt; this.options.maxPercent) {\n      console.error(`flake budget blown: ${pct.toFixed(2)}%`);\n      return { status: 'failed' as const };\n    }\n  }\n}\nexport default FlakeBudget;</code></pre>\n\n<p>The <code>t.results.map(r =&gt; r.status)</code> line is the useful part: it prints the attempt sequence, so <code>timedOut → passed</code> and <code>failed → failed → passed</code> stop looking like the same event. They are not. The first is usually an environment or waiting problem; the second is usually a genuine race.</p>\n\n<p>Wire whichever version you chose into CI so that the report is produced even when tests fail:</p>\n\n<pre class=\"language-yaml\"><code class=\"language-yaml\"># .github/workflows/e2e.yml\n- name: Run end-to-end tests\n  run: npx playwright test\n\n- name: Flake gate\n  if: always()\n  run: node scripts/flake-gate.mjs flake-evidence/results.json\n\n- name: Keep the evidence\n  if: always()\n  uses: actions/upload-artifact@v4\n  with:\n    name: flake-evidence\n    path: |\n      flake-evidence/results.json\n      playwright-report/\n    retention-days: 30</code></pre>\n\n<p><code>if: always()</code> on the upload step is not decoration. Without it, the artifact upload is skipped precisely on the runs where the evidence mattered.</p>\n\n<h2 id=\"quarantine-honestly\">Quarantine, done honestly</h2>\n\n<p>A test that flakes repeatedly should leave the blocking lane. The dishonest version of that is <code>test.skip()</code> with a Jira ticket in a comment, which is indistinguishable from deletion after the second sprint: it no longer runs, no longer reports, and no longer appears in any count except <code>stats.skipped</code>.</p>\n\n<p>Honest quarantine keeps the test running, keeps it visible, and keeps it attached to a person. Tags are the mechanism, because a tag is queryable from the CLI and shows up on <code>testCase.tags</code>.</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// checkout.spec.ts\nimport { test, expect } from '@playwright/test';\n\n// Wrong: silent, unowned, permanent.\n// test.skip('applies a promo code', async ({ page }) =&gt; { /* ... */ });\n\n// Right: still runs, in its own lane, with an owner and an expiry.\ntest('applies a promo code', {\n  tag: '@quarantine',\n  annotation: [\n    { type: 'quarantine-owner', description: 'checkout-team' },\n    { type: 'quarantine-until', description: '2026-04-15' },\n    { type: 'issue', description: 'https://example.com/issues/4471' },\n  ],\n}, async ({ page }) =&gt; {\n  // ...\n});</code></pre>\n\n<p>Tags must start with <code>@</code>, and they can also be written as <code>@</code>-tokens in the title, which <code>testCase.tags</code> extracts. The details object is clearer for anything you intend to read programmatically.</p>\n\n<p>Then split the lanes. The blocking job excludes the tag; a second, non-blocking job runs only the tag:</p>\n\n<pre class=\"language-bash\"><code class=\"language-bash\"># Blocking lane: quarantined tests cannot break the merge.\nnpx playwright test --grep-invert @quarantine\n\n# Observation lane: runs the quarantined tests, allowed to fail the job's\n# status without failing the merge. Extra retries here are pure data.\nPLAYWRIGHT_JSON_OUTPUT_NAME=quarantine.json \\\n  npx playwright test --grep @quarantine --retries=4 --repeat-each=5 \\\n  --reporter=json</code></pre>\n\n<p>The observation lane is where quarantine earns its keep. Five repetitions with four retries each gives a real failure rate for that test, rather than the single bit of information a normal run produces. Feed its <code>stats</code> into the same script and you have a per-test number to argue from.</p>\n\n<p>The expiry matters more than the tag. Add a check that reads the annotations and fails if a <code>quarantine-until</code> date has passed. Without it, quarantine is <code>test.skip()</code> with extra steps.</p>\n\n<h2 id=\"evidence-worth-keeping\">Evidence worth keeping</h2>\n\n<p>A quarantined test can only be fixed from the failing attempt, and the failing attempt is the one your defaults are most likely to discard.</p>\n\n<p>The trace modes differ in exactly this respect, and the distinction is documented:</p>\n\n<ul>\n<li><code>'on-first-retry'</code> — records a trace only for the first retry. The <em>original</em> failure is not captured. If the retry passes, you have a trace of a successful run, which is the least useful artefact available.</li>\n<li><code>'retain-on-failure'</code> — records a trace for every run but keeps it only for runs that failed, and a failed run's trace is kept even when a later retry passes. This is the mode that preserves flake evidence.</li>\n<li><code>'retain-on-failure-and-retries'</code> — keeps a trace for any run that failed <em>or</em> that is a retry, so you get the failure and the passing retry side by side. Larger artefacts, best diffing.</li>\n</ul>\n\n<p>Video accepts the same modes. Screenshots use a different set: <code>'off' | 'on' | 'only-on-failure' | 'on-first-failure'</code>.</p>\n\n<p>Beyond artefacts, attach the environment facts a trace cannot contain. <code>testInfo.retry</code> tells you when you are on a retry, so you can label the attempt and capture backend state that will otherwise be gone:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// fixtures/evidence.ts — import this file from your spec files, or add it\n// to the project's setup so the hook applies suite-wide.\nimport { test } from '@playwright/test';\n\ntest.afterEach(async ({ page }, testInfo) =&gt; {\n  if (testInfo.status === testInfo.expectedStatus &amp;&amp; testInfo.retry === 0) return;\n\n  await testInfo.attach(`attempt-${testInfo.retry}-context.json`, {\n    contentType: 'application/json',\n    body: Buffer.from(JSON.stringify({\n      attempt: testInfo.retry,\n      status: testInfo.status,\n      expectedStatus: testInfo.expectedStatus,\n      durationMs: testInfo.duration,\n      url: page.url(),\n      buildSha: process.env.GITHUB_SHA,\n      workerIndex: testInfo.workerIndex,\n    }, null, 2)),\n  });\n});</code></pre>\n\n<p>Comparing <code>testInfo.status</code> against <code>testInfo.expectedStatus</code> rather than checking for <code>'failed'</code> is what makes this correct for tests marked <code>test.fail()</code>, whose expected status is <code>'failed'</code>. Attachments land in <code>testResult.attachments</code> and appear in the HTML report against the specific attempt that produced them.</p>\n\n<p>If you shard, use the blob reporter and merge. Blob reports contain all the details about the test run and exist to make sharded reports mergeable; running your gate against per-shard JSON gives you per-shard rates that are individually too small to be meaningful.</p>\n\n<h2 id=\"three-ways-the-gate-goes-wrong\">Three ways a flake gate goes wrong</h2>\n\n<p><strong>The denominator drifts.</strong> A rate expressed against a test count changes when someone adds 200 tests, and the same absolute number of flaky runs suddenly looks healthier. If your suite size moves a lot, gate on both: a percentage <em>and</em> an absolute ceiling on flaky count.</p>\n\n<p><strong>Serial mode inflates the count.</strong> In <code>test.describe.serial()</code>, all tests in the group are retried together. A single unstable test at position two causes the whole group to re-run, and every test in it that failed then passed is legitimately labelled flaky. One root cause, five flaky labels. Look at whether your flaky list clusters inside serial blocks before concluding the suite is degrading.</p>\n\n<p><strong>The gate is advisory.</strong> A job that prints a warning and exits zero is read for two weeks and ignored thereafter. The gate has to be able to turn the build red, on a rate the team agreed to, or you have rebuilt the original problem with more YAML.</p>\n\n<h2 id=\"apply-this-now\">Apply this now</h2>\n\n<p>Add <code>['json', { outputFile: 'flake-evidence/results.json' }]</code> to your CI reporter array and upload it as an artifact with <code>if: always()</code>. Do not add a gate yet. Run for a week and read <code>stats.flaky</code> against <code>stats.expected + stats.unexpected + stats.flaky</code> on each run.</p>\n\n<p>That gives you your suite's actual flake rate, which is the number you need before choosing a threshold. Set the budget slightly below the observed rate so the gate has something to do on day one, tag the two or three tests responsible for most of the count into <code>@quarantine</code> with an owner and an expiry date, and switch <code>trace</code> to <code>'retain-on-failure'</code> so the next flake arrives with the failing attempt attached.</p>\n\n<h2 id=\"frequently-asked-questions\">Frequently asked questions</h2>\n\n<h3 id=\"faq-exit-code\">Does a flaky test make Playwright exit non-zero?</h3>\n\n<p>No, not by default. Flaky counts as <code>ok</code>, and only non-ok tests fail the run with a non-zero exit code. Set <code>failOnFlakyTests</code> (or pass <code>--fail-on-flaky-tests</code>) if you want any flaky result to fail the run, or compute a rate yourself from the JSON report.</p>\n\n<h3 id=\"faq-retries-zero\">Should we just set <code>retries: 0</code> instead?</h3>\n\n<p>That trades one blind spot for another. With no retries you cannot tell an intermittent failure from a deterministic one without re-running by hand, and the first data point about reproducibility is exactly what a retry gives you. Keep retries, and stop treating a passing retry as the end of the matter.</p>\n\n<h3 id=\"faq-first-attempt-count\">Is flake rate the same as first-attempt failure rate?</h3>\n\n<p>No. Flake rate counts only tests that failed and then passed. A test that fails all its retries is <code>unexpected</code>, not flaky, and never enters the flaky count no matter how unstable it is. If you want first-attempt failure rate, count results with <code>retry === 0</code> and a status other than <code>'passed'</code> across <code>testCase.results</code>.</p>\n\n<h3 id=\"faq-per-file-retries\">Can I give one unstable file more retries without changing the global config?</h3>\n\n<p>Yes. <code>test.describe.configure({ retries: 2 })</code> sets retries for a describe group or a single file. Treat it as a declaration that the file is unstable, not as a fix, and pair it with the same visibility you would apply anywhere else.</p>\n\n<h3 id=\"faq-flaky-locally\">Why does a test flake on CI but never locally?</h3>\n\n<p>Usually because CI runs more tests concurrently against shared external state. Playwright discards the entire worker process and its browser after a failure and starts a new one, so browser state is not the cause. Look at what your tests share outside the browser: seeded rows, fixed ports, accounts, files.</p>\n\n<h2 id=\"primary-references\">Primary references</h2>\n\n<ul>\n<li><a href=\"https://playwright.dev/docs/test-retries\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — Retries</a>: the passed/flaky/failed categories, <code>testInfo.retry</code>, per-describe retries, and serial-mode group retry behaviour</li>\n<li><a href=\"https://playwright.dev/docs/test-reporters\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — Reporters</a>: composing multiple reporters, the dot reporter's <code>±</code> character, JSON and blob reporter options, and the custom reporter skeleton</li>\n<li><a href=\"https://playwright.dev/docs/api/class-testcase\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — TestCase</a>: <code>outcome()</code> versus <code>ok()</code>, <code>expectedStatus</code>, <code>results</code>, and <code>tags</code></li>\n<li><a href=\"https://playwright.dev/docs/api/class-testresult\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — TestResult</a>: the per-attempt <code>status</code> values, <code>retry</code> index, and <code>attachments</code></li>\n<li><a href=\"https://playwright.dev/docs/api/class-reporter\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — Reporter</a>: <code>onEnd</code> overriding the run status and exit code, and <code>printsToStdio</code></li>\n<li><a href=\"https://playwright.dev/docs/api/class-testconfig\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — TestConfig</a>: <code>failOnFlakyTests</code>, added in v1.52</li>\n<li><a href=\"https://playwright.dev/docs/test-use-options\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — Test use options</a>: trace, video and screenshot modes, and which of them retain a failed attempt's artefacts after a passing retry</li>\n<li><a href=\"https://playwright.dev/docs/test-annotations\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — Annotations</a>: tag syntax and filtering with <code>--grep</code> and <code>--grep-invert</code></li>\n</ul>\n"}