{"title":"Reviewing AI-Generated Playwright Tests: Eight Tells in the Diff","excerpt":"A model writing Playwright reaches for the same constructs every time, so reviewing generated specs is a short checklist rather than an audit — and every item is visible in the diff without running anything. The eight tells: waitForTimeout standing in for a wait condition, an await inside expect() that kills retrying, .first() silencing a strict-mode violation, assertions that cannot fail, an over-broad page.route(), module-level state that breaks under fullyParallel, hardcoded URLs that belong in baseURL and storageState, and toHaveScreenshot() baselines generated on someone's laptop. It ends with the greps, lint rules and repeat-each run that make most of the pass mechanical.","canonicalUrl":"https://automationtester.in/blog/ai-in-testing/review-ai-generated-playwright-tests-before-merge","category":{"name":"AI in Testing","slug":"ai-in-testing"},"tags":["playwright","ai-in-testing","code-review","test-quality","flaky-tests","typescript"],"author":{"name":"Shashank Rawlani","url":"https://shashank.rawlani.com","linkedin":"https://www.linkedin.com/in/shashankrawlani/"},"datePublished":"2026-09-09T13:30:00.000Z","dateModified":"2026-09-07T11:52:04.916Z","coverImage":{"url":"https://ik.imagekit.io/automationtester/automationtester-in/blog/covers/v2/review-ai-generated-playwright-tests-before-merge.webp","alt":"A dark schematic of a review gate: on the left, eight identical machine-generated spec cards queue on a vertical feeder spine, each with the same abstract lines of code. Every lane runs right into a tall dashed gate column where three rounded check cells and a ringed inspection node sit on each lane. Four lanes emerge green, run across the frame and merge into a large ringed node that continues off the right edge; the other four are stopped at crossed-out orange circles just past the gate and their dashed orange paths curve down into a smaller orange rework node at the lower right."},"contentHtml":"<div class=\"callout callout-info\"><strong>Quick answer:</strong> LLM-written Playwright specs fail in a small set of recognisable ways, and every one of them is visible in the diff without running anything. Read the assertions first: an <code>await</code> inside <code>expect(...)</code> makes the check non-retrying, <code>toBeHidden()</code> passes on a locator that matches nothing, and a <code>.first()</code> is usually a strict-mode violation that got silenced rather than fixed. Then read the setup: <code>waitForTimeout</code>, <code>page.route('**/*')</code>, module-level variables and hardcoded URLs are the four that survive review and fail in CI weeks later.</div>\n\n<p>The pull request has eleven new spec files and 640 added lines. Every test passes locally and passes on the branch build. The reviewer opens the diff, sees code that reads like the Playwright documentation, scrolls to the bottom and approves.</p>\n\n<p>Six of those tests cannot fail. Two of them pass because the page never finished loading and the assertion checked something that was already absent. One holds a five-second sleep that will become a thirty-second timeout the first time CI is under load. None of this is exotic — it is the same short list every time, because a language model producing Playwright code reaches for the same constructs. That makes review tractable. You are not auditing the model's reasoning. You are checking eight specific things.</p>\n\n<h2 id=\"tell-1-hard-sleeps\">Tell 1: a hard sleep standing in for a wait condition</h2>\n\n<p>Playwright's own API reference marks <code>page.waitForTimeout()</code> <strong>Discouraged</strong>, with the note that it \"should only be used for debugging\" and that \"tests using the timer in production are going to be flaky\". Generated code uses it constantly, because a sleep is the shortest thing that makes a failing script pass.</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// Generated\nawait page.getByRole('button', { name: 'Place order' }).click();\nawait page.waitForTimeout(3000);\nconst heading = await page.locator('h1').textContent();\nexpect(heading).toBe('Order confirmed');</code></pre>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// Reviewed\nawait page.getByRole('button', { name: 'Place order' }).click();\nawait expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();</code></pre>\n\n<p>The arithmetic is worse than it looks. The default test timeout is 30 seconds, and time spent in the test function, fixture setup and <code>beforeEach</code> hooks all counts against it. Action timeouts, by contrast, have <em>no</em> default — <code>actionTimeout</code> is unset, so a click waits until the test itself runs out. Four three-second sleeps in a spec therefore hand 40% of the test's entire budget to a stopwatch. When the sleep is too short the test fails on a slow runner; when it is long enough to be safe it is charged to every run forever.</p>\n\n<p>If the wait genuinely cannot be expressed as an element state — waiting on a queue to drain, say — the replacement is <code>expect.poll()</code> or <code>expect(...).toPass()</code>, both of which retry a condition rather than burning a fixed interval.</p>\n\n<h2 id=\"tell-2-await-inside-expect\">Tell 2: the <code>await</code> that migrated inside <code>expect()</code></h2>\n\n<p>This is the single highest-value thing to grep for, because the two forms look nearly identical and behave completely differently.</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// Generated — does not retry\nexpect(await page.getByText('welcome').isVisible()).toBe(true);\nexpect(await page.getByTestId('total').textContent()).toBe('$42.00');\n\n// Reviewed — retries until the assertion timeout\nawait expect(page.getByText('welcome')).toBeVisible();\nawait expect(page.getByTestId('total')).toHaveText('$42.00');</code></pre>\n\n<p>Playwright splits its matchers into two documented groups. <strong>Auto-retrying assertions</strong> — <code>toBeVisible()</code>, <code>toHaveText()</code>, <code>toHaveCount()</code>, <code>toHaveValue()</code> and the rest of the locator and page matchers — \"will retry until the assertion passes, or the assertion timeout is reached\". <strong>Non-retrying assertions</strong> are the generic value matchers: <code>toBe()</code>, <code>toEqual()</code>, <code>toContain()</code>, <code>toBeTruthy()</code>. The documentation states plainly that \"using non-retrying assertions can lead to a flaky test\".</p>\n\n<p>The moment you write <code>await locator.isVisible()</code>, you have resolved a boolean at one instant in time and handed it to <code>toBe()</code>, which has nothing left to retry. Playwright's best-practices guide gives exactly this pair as its 👎/👍 example and adds the operative sentence: with <code>isVisible()</code> \"the test won't wait a single second, it will just check the locator is there and return immediately.\"</p>\n\n<p>The assertion timeout is 5 seconds by default and is independent of the 30-second test timeout, so the retrying form costs nothing when the element is already there. A useful review heuristic: any line containing both <code>expect(</code> and <code>await</code> where the <code>await</code> comes second is wrong.</p>\n\n<h2 id=\"tell-3-first-hides-strict-mode\">Tell 3: <code>.first()</code> where a strict-mode violation used to be</h2>\n\n<p>Locators in Playwright are strict: \"all operations on locators that imply some target DOM element will throw an exception if more than one element matches.\" When a generated locator matches three elements, the model's repair is almost always to append <code>.first()</code>, because that is the smallest edit that turns a red run green.</p>\n\n<p>The documentation is unambiguous about the cost: <code>first()</code>, <code>last()</code> and <code>nth()</code> \"are not recommended because when your page changes, Playwright may click on an element you did not intend.\"</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// Generated — clicks whichever \"Add to cart\" happens to render first\nawait page.getByRole('button', { name: 'Add to cart' }).first().click();</code></pre>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// Reviewed — names the row, then acts inside it\nconst product = page\n  .getByRole('listitem')\n  .filter({ has: page.getByRole('heading', { name: 'Ceramic mug' }) });\n\nawait expect(product).toHaveCount(1);\nawait product.getByRole('button', { name: 'Add to cart' }).click();</code></pre>\n\n<p>The <code>toHaveCount(1)</code> line is the part reviewers skip and shouldn't. It converts the ambiguity from a silent behaviour into an assertion: if a second matching row ever appears, the test tells you so instead of quietly acting on the wrong one.</p>\n\n<p>Not every <code>.first()</code> is a defect. Playwright documents two legitimate uses: <code>expect(locator.first()).toBeVisible()</code> to assert that at least one item in a list is visible, and <code>a.or(b).first()</code> for the case where two alternative elements might both appear. The distinguishing question for a reviewer is whether the test <em>means</em> \"any of these\" or means \"the one I could not name\".</p>\n\n<h2 id=\"tell-4-assertions-that-cannot-fail\">Tell 4: assertions that cannot fail</h2>\n\n<p>Two distinct constructions produce a test that is permanently green, and they need different fixes.</p>\n\n<p>The first exploits the definition of <code>toBeHidden()</code>, which \"ensures that Locator either does not resolve to any DOM node, or resolves to a non-visible one\". A locator with a typo in it resolves to nothing, and nothing is hidden:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// Generated — passes whether or not `.error-messge` was ever a real class\nawait expect(page.locator('.error-messge')).toBeHidden();\n\n// Reviewed — pin the positive state, so a wrong locator fails\nawait expect(page.getByRole('status')).toHaveText('Payment accepted');\nawait expect(page.getByRole('alert')).toHaveCount(0);</code></pre>\n\n<p><code>toHaveCount(0)</code> is also an auto-retrying assertion, so you lose nothing by asserting absence that way — but it is paired here with a positive assertion that a misspelled locator would break. An absence check on its own can never distinguish \"the error is gone\" from \"the test is looking in the wrong place\".</p>\n\n<p>The second is the tautological mock. The test stubs a response, then asserts that the stubbed value appears on screen:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// Generated — the assertion restates the fixture\nawait page.route('**/api/cart', route =&gt; route.fulfill({\n  json: { total: 4200, currency: 'USD', itemCount: 3 },\n}));\nawait page.goto('/cart');\nawait expect(page.getByTestId('total')).toContainText('4200');</code></pre>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// Reviewed — asserts the transformation the component actually performs\nawait page.route('**/api/cart', route =&gt; route.fulfill({\n  json: { total: 4200, currency: 'USD', itemCount: 3 },\n}));\nawait page.goto('/cart');\n\n// minor units -&gt; localised currency, and pluralisation from itemCount\nawait expect(page.getByTestId('total')).toHaveText('$42.00');\nawait expect(page.getByTestId('summary')).toHaveText('3 items');</code></pre>\n\n<p>The rule for the reviewer: if you can predict the expected string by reading the mock alone, the assertion is testing the mock. A useful mocked test asserts something the application computed — a format, a total, a sort order, a disabled state — that the fixture does not literally contain.</p>\n\n<h2 id=\"tell-5-over-broad-route\">Tell 5: <code>page.route()</code> with a pattern that swallows the app</h2>\n\n<p>Interception patterns are easy to write too widely, and the failure is delayed: the test passes today and starts failing when someone adds an endpoint.</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// Generated — every request in the page, including HTML and JS, gets this body\nawait page.route('**/*', route =&gt; route.fulfill({\n  status: 200,\n  contentType: 'application/json',\n  body: JSON.stringify({ items: [] }),\n}));</code></pre>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// Reviewed — narrow the pattern, and let everything else through explicitly\nawait page.route('**/api/v1/orders', async route =&gt; {\n  if (route.request().method() !== 'GET') return route.fallback();\n  await route.fulfill({ json: { items: [] } });\n});</code></pre>\n\n<p>Three documented behaviours make this section worth a reviewer's attention. Once routing is enabled, \"every request matching the url pattern will stall unless it's continued, fulfilled or aborted\" — a handler that forgets a branch hangs the request until the test times out, and the failure message points at an assertion rather than at the route. Second, \"if a request matches multiple registered routes, the most recently registered route takes precedence\", so a broad handler installed in <code>beforeEach</code> and a narrow one installed inside a test do not compose the way a reader expects. Third, \"enabling routing disables http cache\", which changes the timing profile of the page under test whether or not you intended to mock anything.</p>\n\n<p>One more thing worth checking in the diff: <code>page.route()</code> does not intercept requests made by a Service Worker. Playwright's recommendation is to set <code>serviceWorkers: 'block'</code> in the context options when you rely on interception. A generated test against a PWA that mocks an endpoint and then asserts real data is usually this.</p>\n\n<h2 id=\"tell-6-shared-state\">Tell 6: module-level state that breaks under <code>fullyParallel</code></h2>\n\n<p>A model writing a spec file writes it as a script, and scripts keep state in variables at the top.</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// Generated — one id shared by every test in the file\nlet orderId: string;\n\ntest('creates an order', async ({ page }) =&gt; {\n  orderId = `order-${Date.now()}`;\n  await page.goto(`/orders/new?id=${orderId}`);\n});\n\ntest('cancels the order', async ({ page }) =&gt; {\n  await page.goto(`/orders/${orderId}`);   // undefined under parallel mode\n});</code></pre>\n\n<p>Playwright's parallelism guide states the constraint directly: \"parallel tests are executed in separate worker processes and cannot share any state or global variables.\" With <code>fullyParallel: true</code> the two tests above may land in different processes, where the second one reads its own module-level <code>orderId</code>, which is still <code>undefined</code>. Under the default mode — tests in one file run in order in one worker — the same code passes. That is why this defect reaches production config changes rather than review.</p>\n\n<p>The documented fix for per-test data is <code>testInfo.testId</code>, which is unique per test:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">test('cancels an order', async ({ page }, testInfo) =&gt; {\n  const orderId = `order-${testInfo.testId}`;\n  await page.goto(`/orders/new?id=${orderId}`);\n  await page.getByRole('button', { name: 'Cancel order' }).click();\n  await expect(page.getByRole('status')).toHaveText('Order cancelled');\n});</code></pre>\n\n<p>Where setup is genuinely expensive, move it into a worker-scoped fixture keyed on <code>testInfo.workerIndex</code>, which the docs use for exactly this — one database user per worker, created once and torn down once. Note the index you key on: <code>workerIndex</code> is unique per worker process, while <code>parallelIndex</code> is between 0 and <code>workers - 1</code> and is <em>reused</em> by a restarted worker after a failure. Playwright's own authentication recipe keys storage-state files on <code>parallelIndex</code> deliberately, so a restarted worker reuses the account file. Keying scratch data on it will collide.</p>\n\n<h2 id=\"tell-7-hardcoded-environment\">Tell 7: the environment compiled into the spec</h2>\n\n<p>Generated tests almost always carry an absolute URL in every <code>goto()</code> and a login sequence in every <code>beforeEach</code>, because each test was produced as a standalone artefact with no knowledge of your config.</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// Generated — in every spec file in the PR\ntest.beforeEach(async ({ page }) =&gt; {\n  await page.goto('https://staging.example.com/login');\n  await page.getByLabel('Email').fill('qa-user@example.com');\n  await page.getByLabel('Password').fill('Passw0rd!');\n  await page.getByRole('button', { name: 'Sign in' }).click();\n});</code></pre>\n\n<p>Set <code>baseURL</code> once in <code>use</code> and the relative form works everywhere; <code>page.goto()</code>, <code>page.route()</code>, <code>page.waitForURL()</code>, <code>page.waitForRequest()</code> and <code>page.waitForResponse()</code> all resolve against it. Then move the login into a setup project that writes storage state, and point the test projects at the file:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// playwright.config.ts\nexport default defineConfig({\n  fullyParallel: true,\n  use: { baseURL: process.env.BASE_URL ?? 'http://localhost:3000' },\n  projects: [\n    { name: 'setup', testMatch: /.*\\.setup\\.ts/ },\n    {\n      name: 'chromium',\n      use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },\n      dependencies: ['setup'],\n    },\n  ],\n});</code></pre>\n\n<p>Playwright's guidance on the state file is worth repeating in a review comment, because generated code has no reason to know it: the storage-state file \"may contain sensitive cookies and headers that could be used to impersonate you or your test account\", and the docs strongly discourage committing it — <code>playwright/.auth</code> belongs in <code>.gitignore</code>. If you write it under the project's <code>outputDir</code> instead, it is cleaned up before every run.</p>\n\n<h2 id=\"tell-8-screenshot-as-catch-all\">Tell 8: <code>toHaveScreenshot()</code> used as a general-purpose assertion</h2>\n\n<p>When a model cannot work out what to assert, it takes a picture. A visual assertion added on a whim behaves very differently from one added deliberately, and the first run hides it: with no baseline on disk, Playwright reports \"A snapshot doesn't exist at ..., writing actual\" and stores what it just saw. The reference committed by that PR is therefore whatever the page happened to look like on the author's machine.</p>\n\n<p>Several defaults matter here. Baselines are named per browser and platform — <code>example-test-1-chromium-darwin.png</code> — so a snapshot generated on macOS has no counterpart on a Linux runner. The comparison uses <code>pixelmatch</code> with a <code>threshold</code> of 0.2 in YIQ colour space, while <code>maxDiffPixels</code> and <code>maxDiffPixelRatio</code> are unset by default, meaning a single pixel over threshold fails the assertion. Animations are already handled: <code>animations</code> defaults to <code>'disabled'</code>, and <code>caret</code> defaults to <code>'hide'</code>, so a generated test that adds a sleep \"to let animations finish\" is doing nothing.</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// Reviewed — scope it, mask what is genuinely volatile, allow a small budget\nawait expect(page.getByTestId('invoice-summary')).toHaveScreenshot('invoice-summary.png', {\n  mask: [page.getByTestId('generated-at'), page.getByTestId('avatar')],\n  maxDiffPixels: 40,\n});</code></pre>\n\n<p>Ask the author two questions: which baselines are in the PR, and on which platform were they generated. If the answer to the second is \"my laptop\" and CI is Linux, the assertion has never actually run.</p>\n\n<h2 id=\"two-more-worth-a-comment\">Two smaller things worth a review comment</h2>\n\n<p><strong><code>test.step</code> that produces unreadable failures.</strong> Generated helpers wrap actions in steps, which is fine, but the failure then points at a line deep inside the helper rather than at the caller. <code>test.step(name, fn, { box: true })</code> changes that: \"an error inside a boxed step points to the step call site.\" For page-object methods and shared login helpers, boxing is what makes a step useful in the HTML report rather than decorative.</p>\n\n<p><strong><code>expect.soft</code> scattered through a flow.</strong> Soft assertions do not terminate the test but do mark it failed, which is right for a batch of independent field checks and wrong before a navigation — the test carries on clicking against a page it has already established is in the wrong state. Where a run of soft assertions must hold before the next step, the documented gate is explicit:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">await expect.soft(page.getByTestId('status')).toHaveText('Success');\nawait expect.soft(page.getByTestId('eta')).toHaveText('1 day');\n\n// Do not continue into the next page if either check above failed.\nexpect(test.info().errors).toHaveLength(0);\nawait page.getByRole('link', { name: 'next page' }).click();</code></pre>\n\n<h2 id=\"apply-this-now\">Apply this now</h2>\n\n<p>Take the most recent AI-assisted test PR in your repository and run three greps over it before reading anything else: <code>waitForTimeout</code>, <code>expect(await</code>, and <code>.first()</code>. Those three cover the majority of what this article describes and take under a minute.</p>\n\n<p>Then make the first two mechanical. Add <code>@typescript-eslint/no-floating-promises</code> to your test lint config — Playwright recommends it specifically to catch missing <code>await</code>s before asynchronous API calls — and run <code>tsc --noEmit</code> in CI so signature errors surface without executing a browser. For the rest, run the branch once with <code>fullyParallel: true</code> and <code>--repeat-each=3</code>; shared module-level state and tautological mocks both show up under repetition in a way they never do in a single ordered run.</p>\n\n<h2 id=\"frequently-asked-questions\">Frequently asked questions</h2>\n\n<h3 id=\"faq-passing-tests\">The generated tests all pass. Isn't that the evidence I need?</h3>\n\n<p>A passing test is evidence only if it is capable of failing. Break the feature deliberately — change the confirmation text, return a 500 from the endpoint the test mocks — and re-run. Any test still green after that is asserting something other than the behaviour it claims to cover, which is the exact failure mode of <code>toBeHidden()</code> on a mistyped locator and of an assertion that restates its own fixture.</p>\n\n<h3 id=\"faq-first-always-wrong\">Is <code>.first()</code> always a defect?</h3>\n\n<p>No. Playwright documents it for \"at least one of these\" cases: <code>expect(page.getByTestId('todo-item').first()).toBeVisible()</code>, and <code>newEmail.or(dialog).first()</code> where either of two elements may appear. It is a defect when it exists to silence a strict-mode error the author could not otherwise resolve. Reading the surrounding assertion usually tells you which you have.</p>\n\n<h3 id=\"faq-css-locators\">Are CSS locators automatically a review failure?</h3>\n\n<p>Not automatically — Playwright supports them and there are elements no role or text locator reaches. What the docs call out as bad practice is the long structural chain, giving <code>#tsf &gt; div:nth-child(2) &gt; div.A8SBwf &gt; div.RNNXgb &gt; div &gt; div.a4bIc &gt; input</code> as the example. A single stable class or attribute is defensible; anything with <code>nth-child</code>, a generated class hash, or four levels of descent is tied to markup that will change.</p>\n\n<h3 id=\"faq-lint-rules\">Can lint rules catch these instead of a human?</h3>\n\n<p>Partly, and it is worth doing. Beyond <code>no-floating-promises</code>, <code>eslint-plugin-playwright</code> ships <code>no-wait-for-timeout</code>, <code>missing-playwright-await</code>, <code>valid-expect</code>, <code>no-force-option</code> and <code>no-conditional-in-test</code> — between them, Tells 1 and 2 stop reaching review at all. What no linter can decide is whether an assertion is meaningful: whether <code>toContainText('4200')</code> is checking the application or restating the mock. That judgement stays human, which is why the assertions are where you should spend the review.</p>\n\n<h3 id=\"faq-regenerate\">Is it faster to regenerate the tests with a better prompt than to review them?</h3>\n\n<p>Regenerating changes which of these defects you get, not whether you get them, because the same constructs are the shortest path to a green run every time. The durable investment is making the environment reject them: <code>baseURL</code> and <code>storageState</code> in config so hardcoding is unnecessary, <code>fullyParallel: true</code> so shared state fails immediately, a banned-API lint rule, and a review pass focused on assertions.</p>\n\n<h2 id=\"primary-references\">Primary references</h2>\n\n<ul>\n<li><a href=\"https://playwright.dev/docs/test-assertions\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — Assertions</a>: the full auto-retrying and non-retrying matcher lists, <code>expect.soft</code>, <code>test.info().errors</code>, <code>expect.poll</code> and <code>expect.toPass</code></li>\n<li><a href=\"https://playwright.dev/docs/best-practices\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — Best Practices</a>: web-first assertions versus <code>expect(await locator.isVisible())</code>, user-facing locators over CSS chains, and the recommended lint setup</li>\n<li><a href=\"https://playwright.dev/docs/locators\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — Locators</a>: strictness, why <code>first()</code>/<code>last()</code>/<code>nth()</code> are not recommended, filtering with <code>has</code> and <code>hasText</code>, and the long-CSS-chain anti-pattern</li>\n<li><a href=\"https://playwright.dev/docs/api/class-locatorassertions\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — LocatorAssertions</a>: <code>toBeHidden()</code> matching a non-existent node, and the <code>toHaveScreenshot()</code> defaults for <code>animations</code>, <code>caret</code>, <code>threshold</code> and <code>maxDiffPixels</code></li>\n<li><a href=\"https://playwright.dev/docs/api/class-page\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — Page</a>: <code>waitForTimeout()</code> marked Discouraged, and <code>route()</code> stalling, precedence, cache and Service Worker behaviour</li>\n<li><a href=\"https://playwright.dev/docs/test-timeouts\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — Timeouts</a>: the 30-second test timeout, the independent 5-second expect timeout, and <code>actionTimeout</code> having no default</li>\n<li><a href=\"https://playwright.dev/docs/test-parallel\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — Parallelism</a>: parallel tests not sharing state, <code>testInfo.testId</code> for per-test data, and <code>workerIndex</code> versus <code>parallelIndex</code> after a restart</li>\n<li><a href=\"https://playwright.dev/docs/auth\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — Authentication</a>: the setup project, <code>storageState</code>, the warning against committing the state file, and per-worker accounts</li>\n<li><a href=\"https://playwright.dev/docs/test-webserver\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — Web server</a>: <code>baseURL</code> and which APIs resolve relative URLs against it</li>\n<li><a href=\"https://playwright.dev/docs/test-snapshots\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — Visual comparisons</a>: baseline generation on first run, platform-suffixed snapshot names, and <code>maxDiffPixels</code></li>\n<li><a href=\"https://playwright.dev/docs/api/class-test\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — Test</a>: <code>test.step()</code> and the <code>box</code> option that reports errors at the step call site</li>\n<li><a href=\"https://playwright.dev/docs/api/class-route\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — Route</a>: <code>fallback()</code> versus <code>continue()</code>, and the reverse-registration order in which matching handlers run</li>\n<li><a href=\"https://github.com/mskelton/eslint-plugin-playwright\" target=\"_blank\" rel=\"noopener noreferrer\">eslint-plugin-playwright</a>: the rule list, including <code>no-wait-for-timeout</code>, <code>missing-playwright-await</code> and <code>valid-expect</code></li>\n</ul>\n"}