<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged playwright</title>
<link>https://coderlegion.com/tag/playwright</link>
<description>Powered by Question2Answer</description>
<item>
<title>Why the LLM in my automation agent never clicks anything</title>
<link>https://coderlegion.com/32729/why-the-llm-in-my-automation-agent-never-clicks-anything</link>
<description>&lt;p&gt;I'm a computer engineering student in Manaus, Brazil, and for my research project I've been building &lt;strong&gt;ANCHOR&lt;/strong&gt;, a web automation agent: you describe a task in plain language (&quot;Register Maria Silva, IT department, contractor, accept the terms and save&quot;), and it runs the task in the browser. Everything runs locally, with small models (Qwen 3.5, 4B and 9B, through Ollama) on a GTX 1050 Ti with 4 GB.&lt;/p&gt;
&lt;p&gt;This post is about the one design decision that made the biggest difference, and a few things I learned by measuring everything.&lt;/p&gt;
&lt;h3 id=&quot;the-problem&quot;&gt;The problem&lt;/h3&gt;
&lt;p&gt;Classic RPA depends on fixed selectors written by a developer:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;page.locator('[data-test=&quot;add-to-cart&quot;]').click()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It works until the page changes, and every automation needs someone who codes, far from the person who actually knows the task.&lt;/p&gt;
&lt;p&gt;The trendy answer is to put an LLM in control: it looks at the page and decides where to click. With small models, that has a serious problem, which I measured (below): the model &lt;strong&gt;says it's done when it isn't&lt;/strong&gt;.&lt;/p&gt;
&lt;h3 id=&quot;the-decision-the-model-only-plans&quot;&gt;The decision: the model only plans&lt;/h3&gt;
&lt;p&gt;In ANCHOR, the model &lt;strong&gt;never clicks anything&lt;/strong&gt;. It gets the request and a summary of the page (the elements, numbered), and returns a plan: goals and steps, using the elements' names (&quot;fill Full name&quot;, &quot;check Contractor&quot;, &quot;click Save registration&quot;).&lt;/p&gt;
&lt;p&gt;A deterministic engine, with no AI, carries it out:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;A heuristic finds each element&lt;/strong&gt; from the step's description, combining text, label, role and context (synonyms, nearby text), and &lt;strong&gt;refuses when it isn't sure&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;When it refuses, the user decides&lt;/strong&gt;: the candidates are numbered on the page, and you pick one (or click the right element).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The choice is remembered&lt;/strong&gt;, so the next run doesn't ask.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Each action's effect is checked&lt;/strong&gt; (did the page change? did the field keep the value? did an error appear?), and the end of the run is checked against the request.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/LucasDantas2701/ANCHOR/main/docs/images/user_choice_remembered.gif&quot; alt=&quot;The site changed, ANCHOR asks, remembers, and the next run asks nothing&quot;&gt;&lt;/p&gt;
&lt;p&gt;In the GIF, the site changed after the automation was saved (&quot;Add to cart&quot; became &quot;Add to bag&quot;). Instead of guessing, ANCHOR asks which button to use, remembers the answer, and the next run goes on its own.&lt;/p&gt;
&lt;h3 id=&quot;learn-once-replay-without-the-model&quot;&gt;Learn once, replay without the model&lt;/h3&gt;
&lt;p&gt;A task that worked can be saved. The first run plans with the model (about 70 seconds on my GPU); the next ones replay the approved plan &lt;strong&gt;without calling the model&lt;/strong&gt;, in about 3 seconds, even once per row of a spreadsheet. When the site changes and a saved step breaks, the automation recovers and &lt;strong&gt;records what it changed&lt;/strong&gt;, and that record can be reviewed and undone.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/LucasDantas2701/ANCHOR/main/docs/images/saved_automation_spreadsheet.gif&quot; alt=&quot;A saved automation replaying a registration once per spreadsheet row, with no model calls&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;what-the-measurements-showed&quot;&gt;What the measurements showed&lt;/h3&gt;
&lt;p&gt;I built a resilience benchmark: 30 tasks on pages altered in 5 levels (renamed IDs, synonyms, restructured layouts, cookie banners and hidden sections, lookalike decoys with injected instructions), 150 runs per executor and model, with no user to help:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Executor &lt;/th&gt;&lt;th&gt;Tasks done &lt;/th&gt;&lt;th&gt;False successes&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Fixed-selector script (classic RPA) &lt;/td&gt;&lt;td&gt;53% &lt;/td&gt;&lt;td&gt;0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;LLM in control (4B / 9B) &lt;/td&gt;&lt;td&gt;68% / 72% &lt;/td&gt;&lt;td&gt;33 / 41&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;ANCHOR (4B / 9B) &lt;/td&gt;&lt;td&gt;82% / 84% &lt;/td&gt;&lt;td&gt;2 / 1&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The column that surprised me most was &lt;strong&gt;false successes&lt;/strong&gt;: the LLM-in-control agent claimed it had done the task, without doing it, in about one run in four. For whoever relies on the automation, that's worse than failing, because nobody finds out.&lt;/p&gt;
&lt;p&gt;ANCHOR's weak spot is pages with part of the content hidden behind a &quot;Show more&quot; button (47%, vs 57–60% for the LLM in control). And an honest caveat: these are development numbers, where I tuned the system looking at the tasks. For 1.0, there's a closed set of 80 requests written by colleagues who never saw the project, which will run only once, at the end.&lt;/p&gt;
&lt;h3 id=&quot;three-lessons&quot;&gt;Three lessons&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;1. Re-measure everything after every change.&lt;/strong&gt; The first full benchmark run revealed four bugs no test had caught (for example, the barrier against destructive actions let a &quot;Delete all&quot; through when the request was &quot;delete Bruno Lima&quot;). And two of my first fixes created new problems, which only showed up because I re-ran the other evaluations too.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. A prompt change affects tasks that have nothing to do with it.&lt;/strong&gt; When I added table extraction, I put two lines in the prompt explaining the new actions. The 4B model started stopping a search after filling the box, and the 9B one left the contract type out of a registration. The fix was to show those lines only when the request is about reading data; for every other request, the prompt is identical, byte for byte, to the previous version.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Never put your password in a request to an AI.&lt;/strong&gt; Testing on LinkedIn, I wrote my e-mail and password in the request, and it typed them. The password never left my computer, but it went through the model, which shouldn't happen. That became version 0.3.1: ANCHOR never types a password, refuses a request with a password before it reaches the model, and when a task needs a login, it stops and waits for you to log in by hand (the session is kept for the next runs).&lt;/p&gt;
&lt;h3 id=&quot;try-it&quot;&gt;Try it&lt;/h3&gt;
&lt;p&gt;The project is on GitHub, with the setup steps in the README:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/LucasDantas2701/ANCHOR&quot;&gt;https://github.com/LucasDantas2701/ANCHOR&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Testers and opinions are very welcome, especially failure cases on real sites: that's the most useful feedback. The next version (0.4) adds file downloads and uploads, new tabs and iframes.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Disclosure: the code was written with help from an AI assistant (Claude), which also helped me write this post; the project, the decisions and the evaluation are mine. The code is source-available (PolyForm Strict): you can read and use it, but not modify or redistribute it.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32729/why-the-llm-in-my-automation-agent-never-clicks-anything</guid>
<pubDate>Tue, 27 Oct 2026 16:57:45 +0000</pubDate>
</item>
<item>
<title>STOP reproducing bugs twice. Record once, get both a Markdown Bug Report and a Playwright Regression</title>
<link>https://coderlegion.com/30910/stop-reproducing-bugs-twice-record-once-get-both-markdown-report-playwright-regression</link>
<description>&lt;p&gt;📝Every QA engineer and developer knows the drill:&lt;br&gt;
You find a bug. You manually write down steps to reproduce, take screenshots, and format a ticket for Jira or GitHub. Then, you open your IDE and write the exact same steps all over again in code for your automated regression suite.&lt;/p&gt;
&lt;p&gt;Why do the same work twice?&lt;/p&gt;
&lt;p&gt;I built QA Bug → Regression Test to bridge the gap between manual reporting and automated regression testing. 🎯&lt;/p&gt;
&lt;p&gt;💡 How It Works:&lt;br&gt;
Record the Issue: Click Start Bug Session in your browser and reproduce the bug naturally. Clicks, inputs, navigation, and timing are recorded cleanly.&lt;br&gt;
Capture Inline Assertions: Highlight the visible error message, banner, or missing element right on the page to insert an immediate assertion.&lt;/p&gt;
&lt;p&gt;Instant Dual Output:&lt;br&gt;
🐞 Markdown Bug Report: Clean, formatted steps to reproduce with environment details and evidence, ready for GitHub Issues, Jira, or Linear.&lt;/p&gt;
&lt;p&gt;⚡ Playwright Test (.spec.ts): Production-ready code using resilient locators (getByRole, getByLabel, getByText) that drops straight into your test repository.&lt;/p&gt;
&lt;p&gt;🔒 Built for Privacy &amp;amp; Real-World QA:&lt;br&gt;
Zero-Leak Redaction: Passwords, API keys, and session tokens are automatically detected and replaced with REPLACE_WITH_TEST_VALUE in code and [REDACTED] in reports.&lt;br&gt;
100% Local &amp;amp; Offline-First: Manifest V3 compliant. Zero external tracking or servers—everything runs entirely inside your browser.&lt;/p&gt;
&lt;p&gt;🔗 Chrome Web Store: &lt;a rel=&quot;nofollow&quot; href=&quot;https://chromewebstore.google.com/detail/qa-bug-%E2%86%92-regression-test/khoelciicbkdeieibchdndccdncpelbg?authuser=1&amp;amp;hl=tr&quot;&gt;https://chromewebstore.google.com/detail/qa-bug-→-regression-test/khoelciicbkdeieibchdndccdncpelbg?authuser=1&amp;amp;hl=tr&lt;/a&gt;&lt;br&gt;
🎬 Quick Demo (YouTube): &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.youtube.com/watch?v=GrtotYTfhbg&quot;&gt;https://www.youtube.com/watch?v=GrtotYTfhbg&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;I’d love to hear your feedback on how this fits into your daily QA/developer workflow! Try it out and let me know your thoughts below! 👇&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30910/stop-reproducing-bugs-twice-record-once-get-both-markdown-report-playwright-regression</guid>
<pubDate>Tue, 13 Oct 2026 08:53:10 +0000</pubDate>
</item>
<item>
<title>Testing on production without polluting your data (and the day my tests voted 8 times)</title>
<link>https://coderlegion.com/30010/testing-on-production-without-polluting-your-data-and-the-day-my-tests-voted-8-times</link>
<description>&lt;p&gt;Every release of &lt;a rel=&quot;nofollow&quot; href=&quot;https://cavienchien.net/en/game/&quot;&gt;Cá Viên Chiên&lt;/a&gt; ends with the same step: walk every changed flow &lt;strong&gt;on production&lt;/strong&gt;, in Chromium and WebKit, at phone and desktop sizes, in both languages. Co-op rooms run on the real room server, share codes come from the real API, rewards are written to the real database.&lt;/p&gt;
&lt;p&gt;That's a lot of fake players. Early on they all showed up in Google Analytics and in my own player count, so I wrote down one rule:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Test traffic never goes to Google Analytics. Our own API and database are used for real, with a test tag, and the tagged data is deleted when the tests are done.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Here is how that works on Cloudflare Pages + D1, and where it broke.&lt;/p&gt;
&lt;h3 id=&quot;1-nothing-reaches-google-analytics&quot;&gt;1. Nothing reaches Google Analytics&lt;/h3&gt;
&lt;p&gt;Every Playwright context goes through one helper before any page opens:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;export async function guardContext(context, { presence = 'tag' } = {}) {
  // GA4's own opt-out flag, so gtag sends nothing even where its script loads
  await context.addInitScript({ content: `window['ga-disable-${GA_ID}'] = true;` });
  // Silence: the game's Web Audio graph runs into a gain of 0
  await context.addInitScript({ content: MUTE_INIT });
  // Drop analytics and ad hosts before the request leaves the browser
  for (const pattern of BLOCKED) await context.route(pattern, (r) =&amp;gt; r.abort('blockedbyclient'));
  // Our own player count goes through, tagged as a test browser
  if (presence === 'tag') {
    await context.route('**/api/presence**', (r) =&amp;gt;
      r.continue({ headers: { ...r.request().headers(), 'x-cvc-test': '1' } }));
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;BLOCKED&lt;/code&gt; covers Tag Manager, &lt;code&gt;google-analytics.com&lt;/code&gt;, &lt;code&gt;analytics.google.com&lt;/code&gt;, DoubleClick and AdSense. A local &lt;code&gt;astro build&lt;/code&gt; loads GA4 too, so the guard applies to localhost as well.&lt;/p&gt;
&lt;p&gt;There is a test for the guard itself: it scans every script in &lt;code&gt;scripts/&lt;/code&gt; that opens the site and fails if one doesn't import &lt;code&gt;guardContext&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Chrome DevTools tabs driven over MCP can't block requests, so they get the same thing as an init script: the GA opt-out flag, the mute, and a &lt;code&gt;fetch&lt;/code&gt; wrapper that adds the header.&lt;/p&gt;
&lt;h3 id=&quot;2-the-real-api-with-a-tag&quot;&gt;2. The real API, with a tag&lt;/h3&gt;
&lt;p&gt;I don't fake our own endpoints. A faked &lt;code&gt;/api/presence&lt;/code&gt; means no play record, which means no share code, which means the referral reward flow can't be tested at all. I found that out the hard way, by skipping the reward test on production once.&lt;/p&gt;
&lt;p&gt;So the server stores test browsers like anyone else, with a flag:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// functions/api/presence.ts
const test = request.headers.get('X-CVC-Test') === '1' ? 1 : null;
await db.prepare(
  'INSERT OR IGNORE INTO installs (id, …, test) VALUES (?, …, ?)'
).bind(id, …, test).run();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The statistics page and &lt;code&gt;pnpm stats&lt;/code&gt; skip &lt;code&gt;installs.test = 1&lt;/code&gt;. Everything that hangs off an install (play days, first-session steps, replays, share codes, referral rewards, poster events) can be traced back to a tagged record.&lt;/p&gt;
&lt;h3 id=&quot;3-delete-it-when-you-re-done&quot;&gt;3. Delete it when you're done&lt;/h3&gt;
&lt;p&gt;After every run, local or production:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;node scripts/clean-test-data.mjs --remote        # delete
node scripts/clean-test-data.mjs --remote --dry  # count only: must be 0 everywhere
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The script selects by the tag only, deletes dependents first, then the records, all in one call, then counts again and exits non-zero if anything is left:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const T = 'SELECT id FROM installs WHERE test = 1';
const TABLES = [
  ['referral_rewards', `newcomer_install_id IN (${T}) OR code IN (SELECT code FROM share_codes WHERE install_id IN (${T}))`],
  ['share_codes',   `install_id IN (${T})`],
  ['replays',       `install_id IN (${T})`],
  ['install_steps', `install_id IN (${T})`],
  ['poster_events', `install_id IN (${T})`],
  ['install_days',  `install_id IN (${T})`],
  ['installs',      'test = 1'],
];
run(TABLES.map(([t, w]) =&amp;gt; `DELETE FROM ${t} WHERE ${w};`).join(' '));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The release checklist has &lt;code&gt;test-rules&lt;/code&gt; before the deploy and &lt;code&gt;test-cleanup&lt;/code&gt; after it. The deploy script refuses to run until the before items are answered, and the after items must pass before the release record is committed.&lt;/p&gt;
&lt;p&gt;After the 1.37.0 release walk it deleted 129 test installs and everything attached to them. A real player's row is never selected.&lt;/p&gt;
&lt;h3 id=&quot;4-the-table-that-had-no-tag&quot;&gt;4. The table that had no tag&lt;/h3&gt;
&lt;p&gt;Then I tested the new rating stars.&lt;/p&gt;
&lt;p&gt;The game page has a &quot;rate this game&quot; card. My check opened it, hovered, and voted 4 stars, at two widths, in two languages, in two engines. Eight votes. Locally that was fine. On production, the log told the story on its own:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;4 stars saved | 4.42/5 · 57 player ratings → 4.41/5 · 58 player ratings
…
4 stars saved | 4.38/5 · 64 player ratings → 4.37/5 · 65 player ratings
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;ratings&lt;/code&gt; table has a voter id, a score, a hashed IP and timestamps, but &lt;strong&gt;no install id and no test flag&lt;/strong&gt;. My cleanup script had no way to find these rows, and every vote moved the public average shown on every page and in the structured data.&lt;/p&gt;
&lt;p&gt;Cleaning it up carefully:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;List the newest rows, read-only. Eight rows from the same IP hash, alternating vi / en, all 4 stars, inside a three-minute window, matching the four Chromium and four WebKit runs exactly. The vote just before them came from a different address and stayed.&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Count before deleting&lt;/strong&gt;, with exactly the conditions the delete would use:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT COUNT(*) FROM ratings
WHERE ip_hash LIKE 'a2c680bee7%' AND score = 4
  AND created_at BETWEEN strftime('%s','2026-10-03 13:50:00')*1000
                     AND strftime('%s','2026-10-03 13:53:30')*1000;
-- 8
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Run the delete with the same &lt;code&gt;WHERE&lt;/code&gt;: &lt;code&gt;changes: 8&lt;/code&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;Check the result: back to 57 votes and 4.42. The public summary is cached at the edge for 5 minutes, so the API caught up a few minutes later.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Then I made sure it can't happen the same way twice:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The rating check now stubs the &lt;code&gt;POST&lt;/code&gt; when it runs on production and reads the live summary for its assertions. The real vote is tested only on the local stack.&lt;/li&gt;
&lt;li&gt;The testing rules say it in one line: &lt;em&gt;a test never votes on production&lt;/em&gt;, because a rating can't be tagged or found afterwards.&lt;/li&gt;
&lt;li&gt;The script was run once more against production with the stub: 24 / 24 checks, still 57 votes.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;what-i-d-tell-myself-a-month-ago&quot;&gt;What I'd tell myself a month ago&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Block analytics in the browser, not in a dashboard filter.&lt;/strong&gt; GA4's internal-traffic filters only apply from the day you turn them on.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tag at the source.&lt;/strong&gt; One request header, one column, and every derived row can be traced back to it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Make cleanup a script with a count, not a habit.&lt;/strong&gt; Exit non-zero if anything is left.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;List every table a test can write to.&lt;/strong&gt; Any table that can't be traced back to a tagged record needs its writes stubbed on production.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Count before you delete on production&lt;/strong&gt;, with exactly the &lt;code&gt;WHERE&lt;/code&gt; you're going to use.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The game is free in the browser at &lt;a rel=&quot;nofollow&quot; href=&quot;https://cavienchien.net/en/game/&quot;&gt;cavienchien.net&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30010/testing-on-production-without-polluting-your-data-and-the-day-my-tests-voted-8-times</guid>
<pubDate>Tue, 06 Oct 2026 13:36:20 +0000</pubDate>
</item>
<item>
<title>AI-Driven Playwright E2E Tests From One Reference Doc</title>
<link>https://coderlegion.com/25801/ai-driven-playwright-e2e-tests-from-one-reference-doc</link>
<description>&lt;h3 id=&quot;writing-e2e-tests-with-one-doc-instead-of-new-tooling&quot;&gt;Writing E2E Tests With One Doc Instead of New Tooling&lt;/h3&gt;
&lt;p&gt;We had a growing monorepo with two frontend apps and zero end-to-end coverage. The blocker was never Playwright itself. It was that every new feature meant babysitting an AI agent through the tests, click by click, and the output still needed rewriting by hand.&lt;/p&gt;
&lt;p&gt;Here is what actually fixed it, and the part I got wrong on the first try.&lt;/p&gt;
&lt;h3 id=&quot;why-a-dedicated-package-not-a-shared-test-util&quot;&gt;Why a dedicated package, not a shared test util&lt;/h3&gt;
&lt;p&gt;The first instinct was to drop Playwright into the existing shared testing package. That does not work, for boring but real reasons:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The unit-test runner and Playwright's runner cannot cohabit in one package.&lt;/li&gt;
&lt;li&gt;E2E runs must not be build-cached, while unit tests are cacheable. One package can't be both.&lt;/li&gt;
&lt;li&gt;Browser binaries and report output have no business leaking into a shared utility package.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So the e2e suite is its own package, built from scratch. That separation is what let the rest of the decisions stay clean.&lt;/p&gt;
&lt;h3 id=&quot;the-structure-that-made-tests-cheap-to-write&quot;&gt;The structure that made tests cheap to write&lt;/h3&gt;
&lt;p&gt;Three rules, enforced by the setup rather than by review comments:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Selectors live only in Page Object Models.&lt;/strong&gt; Spec files never contain a raw locator.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Page objects are injected as fixtures.&lt;/strong&gt; No manual instantiation in a spec, ever.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Auth runs once.&lt;/strong&gt; A setup project logs in, saves the session state to disk, and every test loads it. No login flow inside a test.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The result is that a spec reads like the user story and nothing else:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;test('user can open the create form', async ({ createItemPage }) =&amp;gt; {
  await createItemPage.goto();
  await expect(createItemPage.heading).toBeVisible();
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Already authenticated, page object already wired in, no selector in sight.&lt;/p&gt;
&lt;h3 id=&quot;the-part-i-got-wrong-first&quot;&gt;The part I got wrong first&lt;/h3&gt;
&lt;p&gt;With the structure in place, I expected the AI-assisted authoring to be the easy win. It was the opposite. Pointing Codegen and the agent at a page produced tests that looked plausible and were quietly wrong. The agent guessed expected values it had no way to know, and every generated spec came back to my desk for a manual rewrite. It was slower than just writing them myself.&lt;/p&gt;
&lt;p&gt;The missing piece was context. The agent knew how to click a button. It did not know what the feature was supposed to &lt;em&gt;do&lt;/em&gt; — the state machine, the validation limits, which behaviors were correct and which were known bugs.&lt;/p&gt;
&lt;p&gt;So I wrote a skill for one feature that spelled all of that out: the flows, the rules, the routes, the confirmed bugs. The next tests it wrote were good. Not &quot;good for AI&quot; good. Better than what I was writing by hand, because it now had the right direction and never lost the thread halfway through a complex flow.&lt;/p&gt;
&lt;p&gt;Then I generalized it.&lt;/p&gt;
&lt;h3 id=&quot;one-doc-per-feature-not-a-skill-per-feature&quot;&gt;One doc per feature, not a skill per feature&lt;/h3&gt;
&lt;p&gt;A skill per feature does not scale. The insight was that the &lt;em&gt;skill&lt;/em&gt; was generic; only the &lt;em&gt;facts&lt;/em&gt; were feature-specific. So I split them.&lt;/p&gt;
&lt;p&gt;The authoring engine became one reusable skill. It reads a per-feature reference doc, discovers the routes, reuses or extends the page objects, drives the real app, captures the actual outcomes as assertions, then runs and self-heals the spec. On top of that sits a thin layer: the official Playwright browser-driving skill for the low-level interaction, and one dedicated skill for the single most complex domain that earned extra shortcuts.&lt;/p&gt;
&lt;p&gt;What a new feature needs now is one document: entities, state machine, validation rules, and a numbered list of known bugs. That is it. The reference doc is the contract. If it does not exist, the agent is told to stop and ask for it instead of inventing rules from the UI, which is exactly the failure mode that cost me the first week.&lt;/p&gt;
&lt;p&gt;Before: walk the agent through every flow, then rewrite the output.&lt;br&gt;
After: write one ground-truth doc, point the generic skill at it, review real tests.&lt;/p&gt;
&lt;h3 id=&quot;the-reusable-idea&quot;&gt;The reusable idea&lt;/h3&gt;
&lt;p&gt;The lesson had nothing to do with Playwright. Agents do not fail e2e work because they can't drive a browser. They fail because they guess at behavior no one wrote down. Encode the business truth in a document the agent is forced to read, keep the authoring engine generic, and adding coverage for the next feature costs one doc instead of a new pile of tooling.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25801/ai-driven-playwright-e2e-tests-from-one-reference-doc</guid>
<pubDate>Sun, 30 Aug 2026 08:34:31 +0000</pubDate>
</item>
<item>
<title>The Waiting Problem Behind Most Flaky Suites</title>
<link>https://coderlegion.com/25774/the-waiting-problem-behind-most-flaky-suites</link>
<description>&lt;p&gt;Most of us have had a test suite that passed locally, failed once in CI, then passed on the rerun until nobody trusted it as evidence of anything. This is about why that happens, and why one framework's default behavior removes most of it.&lt;/p&gt;
&lt;h3 id=&quot;flake-is-usually-not-a-bug&quot;&gt;Flake Is Usually Not A Bug&lt;/h3&gt;
&lt;p&gt;A failing browser test is rarely finding something real. It is acting on a page that has not finished arriving. The markup is there, the element is not interactive yet, and the click lands on nothing.&lt;/p&gt;
&lt;p&gt;The usual patch is a fixed sleep. Long enough to be safe and the suite crawls, short enough to be fast and it is flaky again the first time CI has a slow day. Retries hide the problem rather than fix it, which is worse, because now a real failure looks like noise.&lt;/p&gt;
&lt;h3 id=&quot;auto-waiting-as-a-default&quot;&gt;Auto-Waiting As A Default&lt;/h3&gt;
&lt;p&gt;Playwright makes the wait part of the action instead of a line you remembered to write. Before it clicks, it checks that the element is attached to the DOM, visible, stable and enabled. Suites converted from older tools normally lose their sleep statements outright.&lt;/p&gt;
&lt;p&gt;It is also why the same library gets used well outside testing, for scraping, PDF generation and screenshot pipelines, where the timing problem is identical and nobody writes assertions.&lt;/p&gt;
&lt;h3 id=&quot;no-translator-in-the-middle&quot;&gt;No Translator In The Middle&lt;/h3&gt;
&lt;p&gt;WebDriver based tools put a separate process between your script and the browser. Playwright speaks the browser's own debugging protocol, the Chrome DevTools Protocol on Chromium, with Firefox and WebKit implementations maintained by the same team.&lt;/p&gt;
&lt;p&gt;The commonly quoted two to five times speedup over an equivalent Selenium suite comes mostly out of that removed hop, and it widens as the suite grows. The more interesting consequence is that network interception, request mocking and multi-page control behave like first class features rather than bolt-ons.&lt;/p&gt;
&lt;h3 id=&quot;isolation-removes-the-second-kind-of-flake&quot;&gt;Isolation Removes The Second Kind Of Flake&lt;/h3&gt;
&lt;p&gt;Every test runs in its own browser context, effectively a fresh incognito profile with its own cookies and storage, but cheap enough to create per test. Login state stops leaking between tests and execution order stops mattering, which kills a whole class of failure that has nothing to do with timing.&lt;/p&gt;
&lt;h3 id=&quot;the-takeaway&quot;&gt;The Takeaway&lt;/h3&gt;
&lt;p&gt;Benchmark the thing that actually costs you time. Not how fast a green suite runs, but how many reruns you needed last month before you believed a result. A fuller breakdown, selectors, the Python, Java and C# bindings and the honest comparisons against Selenium and Puppeteer, is in &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.webbrowserbot.com/playwright/&quot;&gt;this Playwright guide&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25774/the-waiting-problem-behind-most-flaky-suites</guid>
<pubDate>Sat, 29 Aug 2026 21:40:45 +0000</pubDate>
</item>
<item>
<title>How Browser Agents Read A Page, And When A Script Is Still Better</title>
<link>https://coderlegion.com/25772/how-browser-agents-read-a-page-and-when-a-script-is-still-better</link>
<description>&lt;p&gt;Most of us here have written a scraper or a browser test suite that worked beautifully until someone shipped a redesign and every selector went stale overnight. This is a look at what actually changes when an agent reads the page instead of replaying a recorded path, and where that tradeoff stops paying for itself.&lt;/p&gt;
&lt;h3 id=&quot;why-recorded-steps-break&quot;&gt;Why Recorded Steps Break&lt;/h3&gt;
&lt;p&gt;Traditional web automation targets elements by a fixed path. A class name, an xpath, a nth-child chain. That works right up until the markup moves, and then the script is not wrong in an obvious way, it is confidently clicking nothing. Anyone who has maintained a suite across a design refresh knows the failure is rarely a crash. It is a green run that tested an empty page.&lt;/p&gt;
&lt;p&gt;An agent driving the same browser reasons about the page as it exists at that moment and finds the element by its purpose rather than its address. The login button is whatever currently behaves like the login button. That is the entire difference, and it is why the same agent can complete a task on a site it has never seen.&lt;/p&gt;
&lt;h3 id=&quot;three-ways-an-agent-perceives-a-page&quot;&gt;Three Ways An Agent Perceives A Page&lt;/h3&gt;
&lt;p&gt;Under the surface there are only a few representations in play, and most tool differences come down to how they mix them.&lt;/p&gt;
&lt;p&gt;The DOM gives precise targeting and full text, and it drowns you in presentational noise while hiding the meaning that only layout carries. The accessibility tree, the semantic view browsers already build for screen readers, strips the clutter and labels each element by role and purpose, which usually makes it the cleanest input a model can get. And screenshots with numbered overlays on the interactive elements let a vision model say element 7 instead of describing a position in prose.&lt;/p&gt;
&lt;p&gt;The setups that hold up in production combine them. Structure for exact targeting, the visual pass for understanding and disambiguation. A deeper breakdown of how agents control and perceive a browser is in this &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.autolearningagents.com/ai-browser-automation/&quot;&gt;guide to AI browser automation&lt;/a&gt;, including where the frameworks and protocols underneath fit.&lt;/p&gt;
&lt;h3 id=&quot;the-part-that-still-bites&quot;&gt;The Part That Still Bites&lt;/h3&gt;
&lt;p&gt;Perception does not save you from timing. Most of the modern web arrives nearly empty and fills itself in after running scripts, so an agent that reads too early acts on a page that does not exist yet. It sees a skeleton, decides confidently, and clicks a placeholder. This is the same wait-for-content problem the testing world has fought for a decade, and adding a language model does not remove it. It just makes the wrong answer more fluent.&lt;/p&gt;
&lt;h3 id=&quot;when-a-plain-script-still-wins&quot;&gt;When A Plain Script Still Wins&lt;/h3&gt;
&lt;p&gt;The honest limit is cost and determinism. An agent run costs model tokens and takes longer, and it can make a different choice on two identical pages. If a clean API exists, call it. If the target is one stable page you control, a plain script is cheaper, faster and easier to debug at 3am.&lt;/p&gt;
&lt;p&gt;Browser agents earn their keep on the messy end: many sites with different layouts, no programmatic interface, and a task description that survives a redesign better than a selector does. That is a real category of work, and it is worth reaching for exactly there rather than everywhere.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25772/how-browser-agents-read-a-page-and-when-a-script-is-still-better</guid>
<pubDate>Sat, 29 Aug 2026 17:44:46 +0000</pubDate>
</item>
<item>
<title>Adding Visual Regression Tests To A Playwright Suite</title>
<link>https://coderlegion.com/24867/adding-visual-regression-tests-to-a-playwright-suite</link>
<description>&lt;p&gt;Most of us here already have a Playwright or Cypress suite running green in CI. This post is about the layer that suite does not cover, and how to bolt it on without drowning the team in false diffs.&lt;/p&gt;
&lt;p&gt;Functional assertions confirm that an element exists and behaves. They say nothing about where it sits, what it overlaps, or whether a font that failed to load left the heading unreadable. That gap is what a &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.webbrowserbot.com/visual-testing/&quot;&gt;visual testing&lt;/a&gt; layer fills, and if you are already on Playwright most of the machinery is sitting in your repo unused.&lt;/p&gt;
&lt;h3 id=&quot;where-screenshot-assertions-belong-in-an-existing-suite&quot;&gt;Where Screenshot Assertions Belong In An Existing Suite&lt;/h3&gt;
&lt;p&gt;The cheapest place to start is inside tests you already wrote. A checkout test that asserts the confirmation message appears can also capture the confirmation page:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;await expect(page).toHaveScreenshot('checkout-confirmation.png');
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One line per test, no new tool, no new service. Playwright captures on the first run, stores the baseline next to the spec, and compares on every run after that. The &lt;code&gt;--update-snapshots&lt;/code&gt; flag re-baselines when a change was intentional.&lt;/p&gt;
&lt;p&gt;The other placement is component level. If you maintain a Storybook, every story is already a visual test case with no test code to write, including the states nobody exercises in an end to end flow: empty, error, loading, and text overflow. Story level diffs are smaller and faster to review than full page diffs, so regressions get caught earlier and cheaper. Running both is the strongest setup, stories for component regressions and end to end shots for composition problems.&lt;/p&gt;
&lt;h3 id=&quot;making-the-rendering-environment-reproducible&quot;&gt;Making The Rendering Environment Reproducible&lt;/h3&gt;
&lt;p&gt;This is the step teams skip, and it is why most visual suites get abandoned in month two. A page rendered on your Mac and the same page rendered on a Linux CI runner produce different images: different font stacks, different anti-aliasing, different sub-pixel hinting. Compare across those two environments and every screenshot fails.&lt;/p&gt;
&lt;p&gt;The fix is to make one environment authoritative. Playwright ships official Docker images for exactly this. Capture baselines inside the container, run comparisons inside the same container, and commit only container-produced images. Local runs become a preview and CI becomes the source of truth. Once that holds, the diff percentage on an unchanged page drops to a real zero instead of hovering around 0.3 percent for reasons nobody can explain.&lt;/p&gt;
&lt;h3 id=&quot;baselines-belong-in-version-control&quot;&gt;Baselines Belong In Version Control&lt;/h3&gt;
&lt;p&gt;Store the approved images next to the code that produces them. When a developer intentionally changes a component, the same commit carries the code change and the new baseline, and the reviewer sees both together in one pull request.&lt;/p&gt;
&lt;p&gt;The alternative, approving visual changes in a dashboard detached from the diff, is how a team ends up with baselines nobody can trace back to a decision. Version control also answers the question &quot;when did this page start looking like that&quot;, which is otherwise a very unpleasant afternoon.&lt;/p&gt;
&lt;h3 id=&quot;threshold-tuning-and-the-noise-worth-fixing-first&quot;&gt;Threshold Tuning And The Noise Worth Fixing First&lt;/h3&gt;
&lt;p&gt;Start with a tight threshold, around a tenth of a percent of pixels, and treat every failure as real until proven otherwise. Loosening the threshold to make a flaky test pass hides the actual defect, which is an unstable rendering pipeline.&lt;/p&gt;
&lt;p&gt;Fix the noise at its source instead. Inject CSS that disables animations and transitions. Mask timestamps, usernames, avatars and anything else that changes per run. Await &lt;code&gt;document.fonts.ready&lt;/code&gt; and image load before capturing. Blur the focused element so a blinking cursor never lands in the shot. Use fixed seed data or mocked API responses so the layout is not data dependent. Each of these takes minutes and removes a whole category of false positives permanently.&lt;/p&gt;
&lt;p&gt;A visual suite is either trusted or ignored, and the difference is almost entirely noise discipline. Get five high value pages to a clean zero-diff baseline, hold them there for a couple of weeks, and expand only after the team has stopped reflexively clicking approve.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/24867/adding-visual-regression-tests-to-a-playwright-suite</guid>
<pubDate>Tue, 18 Aug 2026 20:18:09 +0000</pubDate>
</item>
<item>
<title>Playwright vs TWD: A Frontend Developer's Honest Comparison</title>
<link>https://coderlegion.com/20648/playwright-vs-twd-a-frontend-developers-honest-comparison</link>
<description>&lt;p&gt;I had both &lt;a rel=&quot;nofollow&quot; href=&quot;https://playwright.dev/docs/intro&quot;&gt;Playwright&lt;/a&gt; and &lt;a rel=&quot;nofollow&quot; href=&quot;twd.dev&quot;&gt;TWD&lt;/a&gt; pointed at the same small app for a working day. Same backend, same UI, the same bugs to chase. The point wasn't to declare a winner. It was to notice what each one feels like to live with, especially when an AI agent is in the loop too.&lt;/p&gt;
&lt;p&gt;Two different rhythms. Different things they make easy. Different things they make slower. One of them I ended up reaching for much more often.&lt;/p&gt;
&lt;h3 id=&quot;the-first-thing-you-notice-where-the-tests-live&quot;&gt;The first thing you notice: where the tests live&lt;/h3&gt;
&lt;p&gt;Playwright spawns its own browser. A separate window, a separate context, a separate world. You write tests in Node, run them, watch them happen in a window you can look at but not really use.&lt;/p&gt;
&lt;p&gt;TWD lives in the tab you're already developing in. The sidebar sits on top of your app while you work. Click run on a test, the test drives the same page you were just clicking around on, then hands it back to you. You can keep using the app.&lt;/p&gt;
&lt;p&gt;That single difference shapes most of what comes after. When you can keep using the app while tests run, you reach for them more often. When tests take you out of the app to another window, you reach less.&lt;/p&gt;
&lt;h3 id=&quot;the-feedback-loop&quot;&gt;The feedback loop&lt;/h3&gt;
&lt;p&gt;Inside TWD, the cycle is short: write a line, save, click run, watch. If something fails, the failure shows up next to the code that produced it. You're never out of the dev tab.&lt;/p&gt;
&lt;p&gt;Playwright's loop is longer in feel, even when the run itself is fast. There's a separate browser, a terminal, a report, sometimes a trace viewer. Each piece is fine on its own. Together they add up to context-switching cost.&lt;/p&gt;
&lt;p&gt;It isn't that one runs in CI and the other doesn't. Both have a headless CLI runner. Both are fine in CI. The difference is which side of the day each one is designed around. TWD is shaped for the inside of a dev session and treats CI as the export target. Playwright is shaped for the run-the-whole-thing pass and treats local dev as a smaller slice of that.&lt;/p&gt;
&lt;h3 id=&quot;the-debugging-story-and-why-it-matters-more-now-&quot;&gt;The debugging story (and why it matters more now)&lt;/h3&gt;
&lt;p&gt;Both runners are good at telling you why something failed. They tell you in different shapes.&lt;/p&gt;
&lt;p&gt;When a TWD test fails, the error is usually about something specific. &quot;Couldn't find this label.&quot; &quot;The request payload didn't match.&quot; &quot;This element wasn't visible.&quot; It's the vocabulary you used to write the test, applied to what just happened. The diagnosis is the test reading itself back to you, in a sentence or two.&lt;/p&gt;
&lt;p&gt;When a Playwright test fails, you get richer artifacts. Screenshots, traces, a structured dump of the accessibility tree. The diagnosis is forensic. It's good at telling you what the browser looked like just before things went wrong.&lt;/p&gt;
&lt;p&gt;That forensic detail has a cost that didn't matter much when humans were the only audience. Now that the audience is often an AI agent reading test output as part of a session, the cost shows up. A single Playwright failure can drop kilobytes of locator HTML, class lists, and page snapshots into the context window. The agent reads all of it. The token bill scales accordingly. On a long debugging loop where the agent runs the suite, reads the failure, edits a file, runs the suite again, that footprint stacks up fast.&lt;/p&gt;
&lt;p&gt;TWD's failures are one-liners. The label name. The payload diff. The selector that didn't match. Cheap to read, cheap for an agent to act on, almost always enough to point at the line that broke.&lt;/p&gt;
&lt;p&gt;If your debugging loop involves an LLM in any way, the per-error footprint stops being a stylistic preference and starts being a budget line.&lt;/p&gt;
&lt;h3 id=&quot;what-you-get-without-having-to-build-it&quot;&gt;What you get without having to build it&lt;/h3&gt;
&lt;p&gt;This is where the gap shows up the most.&lt;/p&gt;
&lt;p&gt;TWD ships with coverage. Run the headless runner and you get a report. It also ships with contract testing: every mock you write gets validated against your OpenAPI spec on every run. You don't wire it up. It just runs.&lt;/p&gt;
&lt;p&gt;Playwright's job is testing. Coverage and contract testing aren't part of that job by design. You can add them. There are packages and recipes for both. None of it is hard, but none of it is free either. You write fixtures, capture data, post-process the output, integrate with reporters. The work isn't huge, but it's the kind of work that quietly gets deprioritized.&lt;/p&gt;
&lt;p&gt;This isn't a knock on Playwright. It's a question of which tool defines its scope to include the surrounding tooling, and which one stays narrow on purpose.&lt;/p&gt;
&lt;h3 id=&quot;how-they-actually-compose&quot;&gt;How they actually compose&lt;/h3&gt;
&lt;p&gt;The clean answer for choosing Playwright is real cross-browser. If your app has to work identically across Chromium, Firefox, and WebKit, Playwright gives you that natively. TWD runs in whichever browser you're developing in, one engine at a time. That's the lane. It's a real one. Most apps don't actually live in it, though.&lt;/p&gt;
&lt;p&gt;But the framing that earned the most ground for me during the session wasn't &quot;pick one.&quot; It was layering them.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;TWD on the inside.&lt;/strong&gt; Every day. The tab you're already developing in. Component mocks, network mocks, fast feedback. Coverage and contract testing carried into CI as part of the same stack.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Playwright at the gate.&lt;/strong&gt; A small set of true black-box smokes that run in real Chromium, Firefox, and WebKit before a deploy. Login flow, checkout, anything that has to behave identically across engines. Half a dozen tests at most.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Most teams don't need 200 Playwright tests. They need 200 TWD tests and 6 Playwright tests. The math gets cheaper, the dev loop stays fast, and the cross-browser worry stays answered.&lt;/p&gt;
&lt;p&gt;That's the stack worth running, if you're going to run both.&lt;/p&gt;
&lt;h3 id=&quot;what-the-session-left-me-with&quot;&gt;What the session left me with&lt;/h3&gt;
&lt;p&gt;Both tools earned their place by the end. Just not in the same place.&lt;/p&gt;
&lt;p&gt;TWD was the right hand for everything I wrote and rewrote during the session: tests in the dev tab, instant feedback, errors short enough to read at a glance. The headless mode brought coverage and contract checks into CI without a separate setup. That's a lot of testing surface from one config.&lt;/p&gt;
&lt;p&gt;Playwright was the right hand for the cross-engine question. Not for every spec. For the small set that has to behave identically across browsers.&lt;/p&gt;
&lt;p&gt;Two tools, two scopes, one healthy boundary between them. That's the shape of it.&lt;/p&gt;
&lt;p&gt;The TWD runner is at &lt;a rel=&quot;nofollow&quot; href=&quot;https://twd.dev&quot;&gt;twd.dev&lt;/a&gt;. The repo is at &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/BRIKEV/twd-docs-tutorial/tree/demo/playwright-vs-twd&quot;&gt;BRIKEV/twd-docs-tutorial&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/20648/playwright-vs-twd-a-frontend-developers-honest-comparison</guid>
<pubDate>Mon, 15 Jun 2026 16:52:22 +0000</pubDate>
</item>
<item>
<title>From mock-only-works to real-world-works: 48 hours of reCAPTCHA debugging</title>
<link>https://coderlegion.com/18839/from-mock-only-works-to-real-world-works-48-hours-of-recaptcha-debugging</link>
<description>&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Honest framing first&lt;/strong&gt;: &lt;code&gt;mk-qa-master&lt;/code&gt; is an open-source MCP server for QA engineers. The reCAPTCHA solver in it is a &lt;strong&gt;Tier 3 fallback&lt;/strong&gt; for testing your own apps when Tier 1 (Google's official test keys) and Tier 2 (feature flags / IP allowlist) aren't available. It is &lt;strong&gt;not&lt;/strong&gt; a &quot;beat captcha&quot; tool. It refuses to run on Google / Apple / Microsoft / Discord login pages regardless of consent flag. With that out of the way…&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This is a diary about the 48 hours it took to go from &quot;shipped a reCAPTCHA solver, all unit tests green&quot; to &quot;it actually works against the real Google demo.&quot; Four versions (&lt;code&gt;v0.7.0&lt;/code&gt; → &lt;code&gt;v0.7.4&lt;/code&gt;), three broken intermediate ones, and a bunch of lessons that I want to write down before I forget.&lt;/p&gt;
&lt;h3 id=&quot;the-setup&quot;&gt;The setup&lt;/h3&gt;
&lt;p&gt;The idea behind the solver is simple. Two atomic MCP tools:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;inspect_visual_challenge&lt;/code&gt; — finds the captcha iframe on the current page, screenshots it, returns the tile grid coordinates + a screenshot.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;solve_visual_challenge&lt;/code&gt; — accepts the AI client's tile selection (which tiles contain buses, which contain crosswalks, etc.), clicks them, presses Verify, returns the token.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The AI client (Claude Code, Cursor, etc.) sees the screenshot, decides which tiles match the prompt, and calls solve. The server is the eyes and hands; the AI is the brain. Multimodal models like Claude 4.7 are surprisingly good at this — they were trained on the open web, which has a lot of bus pictures.&lt;/p&gt;
&lt;p&gt;So far so good in theory.&lt;/p&gt;
&lt;h3 id=&quot;day-1-v0-7-0-ships&quot;&gt;Day 1 — v0.7.0 ships&lt;/h3&gt;
&lt;p&gt;The first version landed Monday. It detected the reCAPTCHA &lt;code&gt;iframe[src*=&quot;bframe&quot;]&lt;/code&gt;, screenshotted it, computed tile coordinates by dividing the iframe's bounding box into a 3×3 or 4×4 grid, and clicked at the center of each selected tile.&lt;/p&gt;
&lt;p&gt;Unit tests passed. The bundled mock fixture (a self-contained HTML page that mimics reCAPTCHA's structure) round-tripped end-to-end. I wrote a PRD, shipped a release, posted a &lt;code&gt;Dev.to&lt;/code&gt; walkthrough. Felt great.&lt;/p&gt;
&lt;p&gt;The mock fixture's structure was:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;table class=&quot;rc-imageselect-table&quot;&amp;gt;
  &amp;lt;tr&amp;gt;&amp;lt;td&amp;gt;...&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;...&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;...&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;
  ...
&amp;lt;/table&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Selectors in the fingerprint:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;&quot;tile_table_selector&quot;: &quot;.rc-imageselect-table&quot;,
&quot;tile_cell_selector&quot;: &quot;td&quot;,
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What could go wrong?&lt;/p&gt;
&lt;h3 id=&quot;day-2-v0-7-1-adds-hcaptcha&quot;&gt;Day 2 — v0.7.1 adds hCaptcha&lt;/h3&gt;
&lt;p&gt;The next day I extended the fingerprint table to support hCaptcha. Same architecture — different selectors. No new MCP tools. Tests stayed green. I felt good about the design: when a vendor changes, you add a row to the fingerprint table, you're done.&lt;/p&gt;
&lt;p&gt;I didn't run a real-world dogfood for hCaptcha either. (We'll come back to this.)&lt;/p&gt;
&lt;h3 id=&quot;day-3-v0-7-2-the-first-fix-&quot;&gt;Day 3 — v0.7.2: the first &quot;fix&quot;&lt;/h3&gt;
&lt;p&gt;I wrote a tiny dogfood script — open Chromium, navigate to &lt;code&gt;https://www.google.com/recaptcha/api2/demo&lt;/code&gt;, click the anchor to trigger an image challenge, call &lt;code&gt;inspect_visual_challenge&lt;/code&gt;, save the screenshot, ask the AI for tile indices, call &lt;code&gt;solve_visual_challenge&lt;/code&gt;, see if a token comes back.&lt;/p&gt;
&lt;p&gt;The first run came back with status &lt;code&gt;failed&lt;/code&gt;. I asked the user (in this case: me) what they saw in the browser. The answer was unsettling: &lt;em&gt;&quot;I told it to click 2, 5, and 8 — only 5 and 8 actually got highlighted.&quot;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;I dug into the coordinate math. The iframe-divide approach split the full iframe into rows × cols cells. But the iframe contains a header banner (the prompt text) above the grid and a footer (the Verify button) below. So:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;For a 3×3 grid in a 400×580 iframe with header ~130px and footer ~130px:&lt;ul&gt;
&lt;li&gt;The actual grid is 320px tall, ~106px per row.&lt;/li&gt;
&lt;li&gt;Naive iframe-divide gives 193px per row.&lt;/li&gt;
&lt;li&gt;Row 0's computed center lands in the header banner.&lt;/li&gt;
&lt;li&gt;Row 2's computed center lands in the footer.&lt;/li&gt;
&lt;li&gt;Only row 1 happens to be roughly correct.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I wrote v0.7.2 to fix this. Instead of dividing the iframe, I'd read each cell's real &lt;code&gt;bounding_box()&lt;/code&gt; from the DOM via Playwright:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;for index in range(tile_count):
    bb = cells.nth(index).bounding_box()
    if not is_real_dict(bb):
        # fall back to iframe-divide for mock fixtures
        break
    candidate.append({&quot;viewport_x&quot;: bb[&quot;x&quot;], ...})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The unit test (against the mock fixture) immediately confirmed the fix. I bumped to v0.7.2, opened a PR, merged, released, published to PyPI. Done.&lt;/p&gt;
&lt;h3 id=&quot;day-4-morning-wait-it-s-still-broken&quot;&gt;Day 4 morning — wait, it's still broken&lt;/h3&gt;
&lt;p&gt;Next morning, ran the dogfood again. Console output for &lt;code&gt;inspect&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;&quot;tiles&quot;: [
  {&quot;index&quot;: 0, &quot;viewport_x&quot;: 85, &quot;viewport_y&quot;: 92,  &quot;w&quot;: 133, &quot;h&quot;: 193},
  {&quot;index&quot;: 1, &quot;viewport_x&quot;: 218, &quot;viewport_y&quot;: 92, &quot;w&quot;: 133, &quot;h&quot;: 193},
  ...
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;133 × 193&lt;/code&gt; rectangles. &lt;strong&gt;The exact dimensions you'd get from dividing a 400×580 iframe by 3×3.&lt;/strong&gt; Which meant the per-cell &lt;code&gt;bounding_box()&lt;/code&gt; path was returning &lt;code&gt;None&lt;/code&gt; on every cell in real reCAPTCHA, silently falling back to the same broken iframe-divide math.&lt;/p&gt;
&lt;p&gt;Looked at the code path: it had a &lt;code&gt;try / except&lt;/code&gt; swallowing the error. I added a debug field &lt;code&gt;_coord_method&lt;/code&gt; so the inspect response would show which path actually fired:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;&quot;_coord_method&quot;: &quot;iframe_divide&quot;  // ← v0.7.2's &quot;fix&quot; never ran
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So &lt;code&gt;v0.7.2&lt;/code&gt; fixed the mock fixture and shipped to PyPI. In production, against real Google reCAPTCHA, it behaved identically to &lt;code&gt;v0.7.0&lt;/code&gt;. The unit test was green because the mock fixture's &lt;code&gt;&amp;lt;td&amp;gt;&lt;/code&gt; elements had real CSS dimensions; in real reCAPTCHA the tiles aren't &lt;code&gt;&amp;lt;td&amp;gt;&lt;/code&gt;. I just didn't know that yet.&lt;/p&gt;
&lt;h3 id=&quot;day-4-afternoon-going-dom-spelunking&quot;&gt;Day 4 afternoon — going DOM-spelunking&lt;/h3&gt;
&lt;p&gt;Wrote a one-off debug script that opened the real reCAPTCHA bframe and ran arbitrary JavaScript inside it. The first query was: &lt;em&gt;&quot;does &lt;code&gt;.rc-imageselect-table&lt;/code&gt; even exist?&quot;&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;tableExists&quot;: false,
  &quot;altSelectors&quot;: {
    &quot;table[class*=\&quot;rc-imageselect\&quot;]&quot;: true,
    &quot;.rc-imageselect-target&quot;: true,
    &quot;.rc-image-tile-wrapper&quot;: true
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;false&lt;/code&gt;. The class I'd been targeting since v0.7.0 doesn't exist in production.&lt;/p&gt;
&lt;p&gt;The real DOM looks like this:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Element &lt;/th&gt;&lt;th&gt;Mock fixture &lt;/th&gt;&lt;th&gt;Real Google reCAPTCHA&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Table class &lt;/td&gt;&lt;td&gt;&lt;code&gt;rc-imageselect-table&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;rc-imageselect-table-33&lt;/code&gt; (or &lt;code&gt;-44&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Tile element &lt;/td&gt;&lt;td&gt;&lt;code&gt;&amp;lt;td&amp;gt;&lt;/code&gt; with real CSS &lt;/td&gt;&lt;td&gt;&lt;code&gt;&amp;lt;div class=&quot;rc-image-tile-wrapper&quot;&amp;gt;&lt;/code&gt; (the &lt;code&gt;&amp;lt;td&amp;gt;&lt;/code&gt; is 0×0 because tiles are absolutely-positioned)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Challenge text &lt;/td&gt;&lt;td&gt;&lt;code&gt;.rc-imageselect-desc&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;.rc-imageselect-desc-no-canonical&lt;/code&gt; (in dynamic-replace mode)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The whole fingerprint table had been wrong all along. Unit tests passed because &lt;em&gt;I wrote the mock fixture&lt;/em&gt; to match the selectors I'd hardcoded. Tautology. The mock fixture lied because the person who wrote it was the same person who wrote the selectors.&lt;/p&gt;
&lt;h3 id=&quot;day-4-evening-v0-7-3-actually-fixes-it&quot;&gt;Day 4 evening — v0.7.3 actually fixes it&lt;/h3&gt;
&lt;p&gt;I rewrote the fingerprint to chain both real and mock selectors via the CSS comma operator (which means &quot;or&quot;):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;&quot;challenge_text_selector&quot;: (
    &quot;.rc-imageselect-desc-no-canonical, .rc-imageselect-desc&quot;
),
&quot;tile_table_selector&quot;: (
    'table[class*=&quot;rc-imageselect-table&quot;], '
    '.rc-imageselect-target, .rc-imageselect-table'
),
&quot;tile_cell_selector&quot;: &quot;.rc-image-tile-wrapper, .rc-imageselect-table td&quot;,
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now the same fingerprint matches both production reCAPTCHA AND the mock fixture. The per-cell &lt;code&gt;bounding_box()&lt;/code&gt; path finally runs against real DOM, returning real 95×95 squares instead of distorted 133×193 rectangles. Tile 0 sits at &lt;code&gt;y=211&lt;/code&gt; (just below the 200px header), not &lt;code&gt;y=92&lt;/code&gt; (inside the header banner).&lt;/p&gt;
&lt;p&gt;I also fixed a different UX problem in the same release. The MCP server was returning the screenshot as a base64 string embedded in a JSON &lt;code&gt;TextContent&lt;/code&gt;. Multimodal AI clients can't &quot;see&quot; base64 — they see a giant string of &lt;code&gt;iVBORw0KG...&lt;/code&gt;. The fix: return the screenshot as a native MCP &lt;code&gt;ImageContent&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;return [
    ImageContent(type=&quot;image&quot;, data=b64, mimeType=&quot;image/png&quot;),
    TextContent(type=&quot;text&quot;, text=json.dumps(metadata)),
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now Claude Code receives the screenshot as if you'd dragged it into the chat. No manual screenshot juggling.&lt;/p&gt;
&lt;h3 id=&quot;day-4-night-v0-7-4-closes-the-multi-round-gap&quot;&gt;Day 4 night — v0.7.4 closes the multi-round gap&lt;/h3&gt;
&lt;p&gt;One more dogfood run, this time the challenge text was different: &lt;em&gt;&quot;Select all images with buses. Click verify once there are none left.&quot;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;This is reCAPTCHA's &lt;strong&gt;dynamic-replace mode&lt;/strong&gt;. Click a matching tile, the tile gets replaced with a new image. You have to keep selecting until no buses remain, then click Verify. v0.7.3 always clicked Verify after the first round, so it always failed against this mode even with perfect tile judgment.&lt;/p&gt;
&lt;p&gt;v0.7.4 added a new return status: &lt;code&gt;&quot;continue&quot;&lt;/code&gt;. When solve detects dynamic mode (the prompt contains &quot;none left&quot; / equivalent), it does the clicks, waits for the replace animation, re-screenshots the iframe, and returns &lt;code&gt;status: &quot;continue&quot;&lt;/code&gt; with a fresh screenshot + new tile geometry. The AI client looks at the new grid, finds any remaining matches, calls solve again. When the AI sees no more matches, it passes an empty &lt;code&gt;selected_tile_indices: []&lt;/code&gt; to signal &quot;click Verify now.&quot;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-jsonc&quot;&gt;// Round 1 response
{
  &quot;status&quot;: &quot;continue&quot;,
  &quot;rounds_used&quot;: 1,
  &quot;screenshot_base64&quot;: &quot;...new grid...&quot;,
  &quot;tiles&quot;: [...],
  &quot;hint&quot;: &quot;Dynamic-replace round 1/5. Look at the new screenshot and call solve again.&quot;
}

// Round 2 AI sees no more buses
{ &quot;selected_tile_indices&quot;: [], &quot;confirm&quot;: true }

// Round 2 response
{ &quot;status&quot;: &quot;passed&quot;, &quot;token&quot;: &quot;03AGdBq25...&quot; }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Hard cap of 5 rounds prevents infinite loops on pathological challenges. Static mode (no marker phrase) is unchanged — legacy flow runs verbatim.&lt;/p&gt;
&lt;p&gt;And — the lesson from this entire saga — I added a &lt;strong&gt;weekly GitHub Action that runs the dogfood script against the real Google reCAPTCHA demo&lt;/strong&gt; and asserts &lt;code&gt;_coord_method != &quot;iframe_divide&quot;&lt;/code&gt;. If Google ships a DOM change next week that breaks the fingerprint again, I'll get a CI failure email within seven days instead of finding out from a user issue six months later.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;on:
  schedule:
    - cron: &quot;0 2 * * 0&quot;  # Sunday 02:00 UTC
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;what-works-now&quot;&gt;What works now&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;✅ reCAPTCHA v2 image-grid (3×3 + 4×4) — verified against the real Google demo&lt;/li&gt;
&lt;li&gt;✅ hCaptcha image-select — same fingerprint infrastructure, &lt;strong&gt;fixture verified, real-vendor TBD&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;✅ Multi-round dynamic-replace — unit-test verified, &lt;strong&gt;end-to-end real-vendor TBD&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;✅ MCP &lt;code&gt;ImageContent&lt;/code&gt; — multimodal clients see screenshots natively&lt;/li&gt;
&lt;li&gt;✅ Consent gate, domain allowlist, hard-stop blacklist (Google / Apple / Microsoft / Discord login pages)&lt;/li&gt;
&lt;li&gt;✅ Weekly real-world CI guard&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;what-doesn-t-yet-&quot;&gt;What doesn't (yet)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;❌ Mobile WebView — &lt;code&gt;v0.8.0&lt;/code&gt; mini-PRD drafted, ~6 working days of implementation ahead&lt;/li&gt;
&lt;li&gt;❌ reCAPTCHA v3 — pure behavior scoring, no visible challenge, &lt;strong&gt;out of scope by design&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;❌ Cloudflare Turnstile — same reason&lt;/li&gt;
&lt;li&gt;❌ Audio captcha fallback — accessibility tier, low usage in QA context&lt;/li&gt;
&lt;li&gt;❌ The dynamic-replace loop on real Google reCAPTCHA with AI in the loop — that's my next dogfood session&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;lessons-i-want-to-remember&quot;&gt;Lessons I want to remember&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Mock fixtures can lie.&lt;/strong&gt; When the same person writes both the production selectors and the mock that tests them, the mock matches by construction. There's no signal. The fix is dogfood against the real thing — and if you can't dogfood, at minimum run a recorded HAR of the real DOM and assert against that.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Silent fallbacks are the worst kind of bug.&lt;/strong&gt; v0.7.2's &lt;code&gt;try / except&lt;/code&gt; swallowed the failure of every per-cell &lt;code&gt;bounding_box()&lt;/code&gt; and quietly fell back to broken math. A &lt;code&gt;_coord_method&lt;/code&gt; debug field that surfaces which path actually fired would have caught this in minutes. I now add a debug field every time I have more than one code path for the same output.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Multi-round is UX, not a bug.&lt;/strong&gt; reCAPTCHA's &quot;Click verify once there are none left&quot; isn't an edge case — it's the dominant mode on hard challenges. I built the static-only solver, said &quot;ship it,&quot; and was surprised when most real-world challenges fell into the dynamic-replace bucket I hadn't designed for.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Weekly CI catches what unit tests can't.&lt;/strong&gt; The dogfood workflow runs once a week against a third-party demo. It's noisy, it depends on a vendor's continued cooperation, and it'd be wrong to depend on it for blocking merges. But as a &lt;em&gt;background&lt;/em&gt; signal that catches selector drift, it's exactly the right level of investment.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;try-it-1&quot;&gt;Try it&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pip install mk-qa-master==0.7.4

# In your MCP host (Claude Code config, Cursor, etc.)
{
  &quot;mcpServers&quot;: {
    &quot;qa-master&quot;: {
      &quot;command&quot;: &quot;python&quot;,
      &quot;args&quot;: [&quot;-m&quot;, &quot;mk_qa_master.server&quot;],
      &quot;env&quot;: {
        &quot;QA_VISUAL_CHALLENGE_CONSENT&quot;: &quot;true&quot;,
        &quot;QA_VISUAL_CHALLENGE_AUTHORIZED_DOMAINS&quot;: &quot;your-staging.example.com&quot;
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then ask Claude: &lt;em&gt;&quot;Test the signup flow on staging. If you hit a captcha, solve it.&quot;&lt;/em&gt; The MCP tools take it from there.&lt;/p&gt;
&lt;p&gt;Repo + walkthrough: &lt;code&gt;https://github.com/kao273183/mk-qa-master&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id=&quot;what-s-next&quot;&gt;What's next&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;v0.8.0&lt;/code&gt; — mobile WebView captcha via Maestro CLI (same fingerprint table, new driver). PRD is up in the repo. Probably another diary entry when that one ships.&lt;/p&gt;
&lt;p&gt;If you find a bug, the dogfood script lives at &lt;code&gt;scripts/dogfood-inspect-only.py&lt;/code&gt; — run it against the page that broke and the inspect output will tell you exactly which coordinate path fired. Beats debugging blind.&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/18839/from-mock-only-works-to-real-world-works-48-hours-of-recaptcha-debugging</guid>
<pubDate>Mon, 25 May 2026 02:50:17 +0000</pubDate>
</item>
<item>
<title>A Playwright Reporter That Sends Structured, Interactive Slack Messages</title>
<link>https://coderlegion.com/16169/a-playwright-reporter-that-sends-structured-interactive-slack-messages</link>
<description>&lt;h3 id=&quot;what-this-is&quot;&gt;What this is&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;@playwright-labs/reporter-slack&lt;/code&gt; is a Playwright &lt;code&gt;Reporter&lt;/code&gt; implementation that sends formatted Slack messages when a test run ends. It handles structuring results by status, surfacing error details inline, masking sensitive environment variables, and posting via either Incoming Webhooks or the Slack Web API.&lt;/p&gt;
&lt;p&gt;This post covers the full API.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;installation&quot;&gt;Installation&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pnpm add -D @playwright-labs/reporter-slack @playwright-labs/slack-buildkit
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;slack-buildkit&lt;/code&gt; is a peer dependency used internally and also available for writing custom templates. If you only use built-in templates, you still need it installed.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;basic-configuration&quot;&gt;Basic configuration&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// playwright.config.ts
import { defineConfig } from &quot;@playwright/test&quot;;
import { WithOptionsTemplate } from &quot;@playwright-labs/reporter-slack/templates&quot;;

export default defineConfig({
  reporter: [
    [&quot;@playwright-labs/reporter-slack&quot;, {
      send: { webhook: process.env.SLACK_WEBHOOK_URL },
      template: WithOptionsTemplate,
    }],
  ],
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The reporter:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Implements &lt;code&gt;onTestEnd(test, result)&lt;/code&gt; — accumulates &lt;code&gt;[TestCase, TestResult][]&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Implements &lt;code&gt;onEnd(result)&lt;/code&gt; — resolves &lt;code&gt;template(result, testCases)&lt;/code&gt; → &lt;code&gt;SlackBlock[]&lt;/code&gt; → posts to Slack&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The &lt;code&gt;template&lt;/code&gt; option accepts a function, a &lt;code&gt;SlackBlock[]&lt;/code&gt; array, a &lt;code&gt;SlackMessage&lt;/code&gt; object, or an async function returning any of the above.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;three-built-in-templates&quot;&gt;Three built-in templates&lt;/h3&gt;
&lt;h4 id=&quot;basetemplate&quot;&gt;&lt;code&gt;BaseTemplate&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Minimal. Header, total/duration summary, list of failed tests (max 10 by default), optional report button.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { BaseTemplate } from &quot;@playwright-labs/reporter-slack/templates&quot;;

template: (result, testCases) =&amp;gt;
  BaseTemplate(result, testCases, {
    projectName: &quot;My App&quot;,
    reportUrl: process.env.REPORT_URL,
  }),
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Config:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;type BaseTemplateOptions = {
  projectName?: string;  // default: &quot;Playwright&quot;
  reportUrl?: string;
  maxFailures?: number;  // default: 10
};
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;withoptionstemplate&quot;&gt;&lt;code&gt;WithOptionsTemplate&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Status-grouped with an interactive &lt;code&gt;static_select&lt;/code&gt; element. Groups are ordered: &lt;code&gt;failed → timedOut → interrupted → skipped → passed&lt;/code&gt;. Filter options are built dynamically — if no tests timed out, no &quot;Timed out&quot; option appears.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { WithOptionsTemplate } from &quot;@playwright-labs/reporter-slack/templates&quot;;

template: (result, testCases) =&amp;gt;
  WithOptionsTemplate(result, testCases, {
    projectName:   &quot;My App&quot;,
    reportUrl:     process.env.REPORT_URL,
    maxPerStatus:  5,
    showTestNames: true,
    show: {
      passed:   false,  // omit passed group from the message
    },
  }),
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Config:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;type WithOptionsTemplateConfig = {
  projectName?: string;       // default: &quot;Playwright&quot;
  reportUrl?: string;
  maxPerStatus?: number;      // default: 10
  showTestNames?: boolean;    // default: true
  show?: {
    failed?: boolean;         // default: true
    passed?: boolean;         // default: true
    skipped?: boolean;        // default: true
    timedOut?: boolean;       // default: true
    interrupted?: boolean;    // default: true
  };
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Overflow behavior: when &lt;code&gt;testCases.length &amp;gt; maxPerStatus&lt;/code&gt;, the section ends with &lt;code&gt;_… and N more_&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Error display: for &lt;code&gt;failed&lt;/code&gt; and &lt;code&gt;timedOut&lt;/code&gt; tests, the first line of &lt;code&gt;result.errors[0].message&lt;/code&gt; is rendered below the test name in italics.&lt;/p&gt;
&lt;h4 id=&quot;withtabletemplate&quot;&gt;&lt;code&gt;WithTableTemplate&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Same test summary as &lt;code&gt;WithOptionsTemplate&lt;/code&gt;, but prepends a GFM table of environment variables (rendered via Slack's &lt;code&gt;markdown&lt;/code&gt; block type).&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { WithTableTemplate } from &quot;@playwright-labs/reporter-slack/templates&quot;;

template: (result, testCases) =&amp;gt;
  WithTableTemplate(result, testCases, {
    projectName:    &quot;My App&quot;,
    reportUrl:      process.env.REPORT_URL,
    tableTitle:     &quot;Build Environment&quot;,
    env: {
      CI:           process.env.CI,
      BRANCH:       process.env.BRANCH,
      BUILD_ID:     process.env.BUILD_ID,
      COMMIT_SHA:   process.env.COMMIT_SHA,
      DEPLOY_ENV:   process.env.DEPLOY_ENV,
      DB_HOST:      process.env.DB_HOST,
      DB_PASSWORD:  process.env.DB_PASSWORD,  // auto-masked
      API_KEY:      process.env.API_KEY,       // auto-masked
      OPTIONAL_VAR: process.env.OPTIONAL_VAR, // omitted if undefined
    },
    showRunSummary: true,
    rowsPerChunk:   30,
  }),
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Config:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;type WithTableTemplateConfig = {
  projectName?: string;                        // default: &quot;Playwright&quot;
  reportUrl?: string;
  env: Record&amp;lt;string, string | undefined&amp;gt;;     // required; undefined values omitted
  tableTitle?: string;                         // default: &quot;Environment&quot;
  mask?: boolean | string[];                   // default: true
  showRunSummary?: boolean;                    // default: true
  rowsPerChunk?: number;                       // default: 30
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;rowsPerChunk&lt;/code&gt; controls how many rows per &lt;code&gt;markdown&lt;/code&gt; block before the table is split. Slack's text limit per block is ~3000 characters; 30 rows is a safe conservative default.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;auto-masking&quot;&gt;Auto-masking&lt;/h3&gt;
&lt;p&gt;When &lt;code&gt;mask: true&lt;/code&gt; (default), values are replaced with &lt;code&gt;••••••••&lt;/code&gt; for any key matching:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/token|secret|password|pass(?:word)?|credential|auth|api[_-]?key/i
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Examples of keys that match: &lt;code&gt;DB_PASSWORD&lt;/code&gt;, &lt;code&gt;AUTH_TOKEN&lt;/code&gt;, &lt;code&gt;API_KEY&lt;/code&gt;, &lt;code&gt;SECRET_KEY&lt;/code&gt;, &lt;code&gt;STRIPE_CREDENTIAL&lt;/code&gt;, &lt;code&gt;OAUTH_CLIENT_SECRET&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Masking happens in the template before any data is serialized to JSON. The raw value never appears in the Slack payload.&lt;/p&gt;
&lt;p&gt;Override options:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;mask: [&quot;CUSTOM_KEY&quot;, &quot;INTERNAL_TOKEN&quot;]  // explicit list — only these keys
mask: false                              // no masking
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;transport-configuration&quot;&gt;Transport configuration&lt;/h3&gt;
&lt;h4 id=&quot;incoming-webhook&quot;&gt;Incoming Webhook&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;send: { webhook: &quot;https://hooks.slack.com/services/...&quot; }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;POSTs &lt;code&gt;{ blocks: SlackBlock[] }&lt;/code&gt; to the webhook URL. No OAuth, no scopes. Channel is fixed by the webhook app configuration.&lt;/p&gt;
&lt;h4 id=&quot;web-api-chat-postmessage-&quot;&gt;Web API (&lt;code&gt;chat.postMessage&lt;/code&gt;)&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;send: {
  token:   &quot;xoxb-...&quot;,    // bot token with chat:write scope
  channel: &quot;#ci-reports&quot;,
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;POSTs to &lt;code&gt;https://slack.com/api/chat.postMessage&lt;/code&gt; with &lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt;. Supports all Slack API features including thread replies and channel selection at post time.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;writing-a-custom-template&quot;&gt;Writing a custom template&lt;/h3&gt;
&lt;p&gt;A template is a function:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;type SlackTemplate = (
  result: FullResult,
  testCases: [TestCase, TestResult][],
) =&amp;gt; SlackBlock[] | SlackMessage | Promise&amp;lt;SlackBlock[] | SlackMessage&amp;gt;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can return raw &lt;code&gt;SlackBlock[]&lt;/code&gt; JSON, or use the JSX primitives from &lt;code&gt;@playwright-labs/slack-buildkit/react&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;/** @jsxImportSource @playwright-labs/slack-buildkit/react */
import {
  Actions, Blocks, Button, Context, Divider,
  Header, Section, Table, Td, Th, Tr,
} from &quot;@playwright-labs/slack-buildkit/react&quot;;
import type { FullResult, TestCase, TestResult } from &quot;@playwright/test/reporter&quot;;

export function MyTemplate(
  result: FullResult,
  testCases: [TestCase, TestResult][],
) {
  const failed = testCases.filter(([, r]) =&amp;gt; r.status === &quot;failed&quot;);
  const passed = testCases.filter(([, r]) =&amp;gt; r.status === &quot;passed&quot;);
  const emoji = result.status === &quot;passed&quot; ? &quot;:white_check_mark:&quot; : &quot;:x:&quot;;

  return (
    &amp;lt;Blocks&amp;gt;
      &amp;lt;Header&amp;gt;{`${emoji} My App — ${result.status === &quot;passed&quot; ? &quot;All clear&quot; : &quot;Tests failed&quot;}`}&amp;lt;/Header&amp;gt;
      &amp;lt;Section&amp;gt;{`*Total:* ${testCases.length}  •  ✅ ${passed.length}  •  ❌ ${failed.length}`}&amp;lt;/Section&amp;gt;

      {failed.length &amp;gt; 0 &amp;amp;&amp;amp; (
        &amp;lt;&amp;gt;
          &amp;lt;Divider /&amp;gt;
          &amp;lt;Table&amp;gt;
            &amp;lt;Tr&amp;gt;&amp;lt;Th&amp;gt;Test&amp;lt;/Th&amp;gt;&amp;lt;Th&amp;gt;First error&amp;lt;/Th&amp;gt;&amp;lt;/Tr&amp;gt;
            {failed.map(([test, r]) =&amp;gt; (
              &amp;lt;Tr&amp;gt;
                &amp;lt;Td&amp;gt;{`\`${test.titlePath().slice(1).join(&quot; › &quot;)}\``}&amp;lt;/Td&amp;gt;
                &amp;lt;Td&amp;gt;{r.errors[0]?.message?.split(&quot;\n&quot;)[0] ?? &quot;—&quot;}&amp;lt;/Td&amp;gt;
              &amp;lt;/Tr&amp;gt;
            ))}
          &amp;lt;/Table&amp;gt;
        &amp;lt;/&amp;gt;
      )}

      &amp;lt;Divider /&amp;gt;
      &amp;lt;Actions&amp;gt;
        &amp;lt;Button url={process.env.REPORT_URL!} style=&quot;primary&quot; action_id=&quot;view_report&quot;&amp;gt;
          View Full Report
        &amp;lt;/Button&amp;gt;
      &amp;lt;/Actions&amp;gt;
      &amp;lt;Context&amp;gt;{`My App • ${new Date().toUTCString()}`}&amp;lt;/Context&amp;gt;
    &amp;lt;/Blocks&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;JSX runtime notes:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Activated via &lt;code&gt;/** @jsxImportSource @playwright-labs/slack-buildkit/react */&lt;/code&gt; or &lt;code&gt;tsconfig.json&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;No React, no DOM, no reconciler — purely synchronous function calls&lt;/li&gt;
&lt;li&gt;&lt;code&gt;&amp;lt;Table&amp;gt;/&amp;lt;Tr&amp;gt;/&amp;lt;Th&amp;gt;/&amp;lt;Td&amp;gt;&lt;/code&gt; converts to a &lt;code&gt;{ type: &quot;markdown&quot; }&lt;/code&gt; block with GFM table syntax&lt;/li&gt;
&lt;li&gt;&lt;code&gt;&amp;lt;Blocks&amp;gt;&lt;/code&gt; flattens all children into a flat &lt;code&gt;SlackBlock[]&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Register the template the same way as built-ins:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;template: MyTemplate,
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;testing-templates&quot;&gt;Testing templates&lt;/h3&gt;
&lt;p&gt;Templates return plain JSON arrays, so unit tests are straightforward:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { test, expect } from &quot;@playwright/test&quot;;
import type { FullResult } from &quot;@playwright/test/reporter&quot;;
import { WithTableTemplate } from &quot;@playwright-labs/reporter-slack/templates&quot;;

const failedRun: FullResult = {
  status: &quot;failed&quot;,
  duration: 20000,
  startTime: new Date(&quot;2025-04-28T10:00:00Z&quot;),
};

test(&quot;sensitive values are masked&quot;, () =&amp;gt; {
  const blocks = WithTableTemplate(failedRun, [], {
    env: { DB_PASSWORD: &quot;supersecret&quot;, BRANCH: &quot;main&quot; },
  });
  const text = blocks
    .filter((b) =&amp;gt; b.type === &quot;markdown&quot;)
    .map((b) =&amp;gt; (b as any).text)
    .join(&quot;\n&quot;);
  expect(text).not.toContain(&quot;supersecret&quot;);
  expect(text).toContain(&quot;••••••••&quot;);
});

test(&quot;undefined env values are omitted from table&quot;, () =&amp;gt; {
  const blocks = WithTableTemplate(failedRun, [], {
    env: { PRESENT: &quot;yes&quot;, ABSENT: undefined },
  });
  const text = blocks
    .filter((b) =&amp;gt; b.type === &quot;markdown&quot;)
    .map((b) =&amp;gt; (b as any).text)
    .join(&quot;\n&quot;);
  expect(text).toContain(&quot;`PRESENT`&quot;);
  expect(text).not.toContain(&quot;`ABSENT`&quot;);
});

test(&quot;actions block absent when reportUrl is not set&quot;, () =&amp;gt; {
  const blocks = WithTableTemplate(failedRun, [], { env: {} });
  expect(blocks.find((b) =&amp;gt; b.type === &quot;actions&quot;)).toBeUndefined();
});

test(&quot;actions block present when reportUrl is set&quot;, () =&amp;gt; {
  const blocks = WithTableTemplate(failedRun, [], {
    env: {},
    reportUrl: &quot;https://ci.example.com&quot;,
  });
  expect(blocks.find((b) =&amp;gt; b.type === &quot;actions&quot;)).toBeDefined();
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The test suite uses Playwright's runner — no additional test framework needed.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;source&quot;&gt;Source&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/vitaliharadkou/playwright-labs&quot;&gt;github.com/vitaliharadkou/playwright-labs&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/16169/a-playwright-reporter-that-sends-structured-interactive-slack-messages</guid>
<pubDate>Thu, 30 Apr 2026 07:30:00 +0000</pubDate>
</item>
<item>
<title>5 Things This Playwright SQL Fixture Does So You Don't Have To</title>
<link>https://coderlegion.com/14599/5-things-this-playwright-sql-fixture-does-so-you-dont-have-to</link>
<description>&lt;h2 id=&quot;5-things-this-playwright-sql-fixture-does-so-you-don-t-have-to&quot;&gt;5 Things This Playwright SQL Fixture Does So You Don't Have To&lt;/h2&gt;
&lt;p&gt;If you write Playwright tests that touch a database, you know the boilerplate: connect, seed, clean up, close. Repeat for every file. Hope nothing leaks.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;@playwright-labs/fixture-sql&lt;/code&gt; handles that boilerplate. Here is exactly what it takes off your plate.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-opens-and-closes-connections-automatically&quot;&gt;1. Opens and closes connections automatically&lt;/h3&gt;
&lt;p&gt;Without the fixture, every test file that needs a database connection looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;let client: SqlClient;
beforeEach(async () =&amp;gt; { client = await createConnection(); });
afterEach(async () =&amp;gt; { await client.close(); });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With the fixture, it is one line:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;test.use({ sqlAdapter: sqliteAdapter(':memory:') });

test('my test', async ({ sql: db }) =&amp;gt; {
  // db is ready — no setup needed
  // db.close() is called by the fixture after this function, even if it throws
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The connection lifecycle is entirely handled by Playwright's fixture system.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-prevents-connection-leaks-when-tests-fail&quot;&gt;2. Prevents connection leaks when tests fail&lt;/h3&gt;
&lt;p&gt;When a test throws an error, code in &lt;code&gt;afterEach&lt;/code&gt; might not run correctly depending on how the error propagates. Fixtures are different — Playwright guarantees their teardown phase runs regardless of test outcome.&lt;/p&gt;
&lt;p&gt;Every connection opened through &lt;code&gt;sql&lt;/code&gt; or &lt;code&gt;useSql&lt;/code&gt; is tracked and closed in teardown. You cannot leak a connection through a failing test.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-catches-sql-errors-at-compile-time&quot;&gt;3. Catches SQL errors at compile time&lt;/h3&gt;
&lt;p&gt;The fixture integrates with &lt;code&gt;@playwright-labs/sql-core&lt;/code&gt;'s compile-time SQL validator. Write your queries with &lt;code&gt;sql(&quot;…&quot;)&lt;/code&gt; instead of a raw string:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// ❌ runtime error
await db.query('SELECT * WHERE id = ?', [1]);

// ✅ compile error — TypeScript catches missing FROM before tests run
await db.query(sql(&quot;SELECT * WHERE id = ?&quot;), [1]);
//             ~~~~~~~~~~~~~~~~~~~~~~~~~~~ never
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The type system models SQL grammar as a finite-state machine. Invalid structure → &lt;code&gt;never&lt;/code&gt;. Wrong number of params → compile error.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const q = sql(&quot;SELECT name FROM users WHERE id = ?&quot;);
// typeof q → SqlStatement&amp;lt;[unknown]&amp;gt;

await db.query(q, [1]);        // ✅
await db.query(q);             // ❌ params required
await db.query(q, [1, 2, 3]); // ❌ too many params
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-handles-multiple-connections-with-one-call&quot;&gt;4. Handles multiple connections with one call&lt;/h3&gt;
&lt;p&gt;Need to test replication, or talk to two databases in one test? &lt;code&gt;useSql&lt;/code&gt; opens additional connections and registers them all for teardown:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;test('primary → replica', async ({ useSql }) =&amp;gt; {
  const primary = await useSql(pgAdapter(process.env.PRIMARY_URL!));
  const replica  = await useSql(pgAdapter(process.env.REPLICA_URL!));

  await primary.execute(sql(&quot;INSERT INTO events (type) VALUES ($1)&quot;), ['login']);

  const { rows } = await replica.query(sql(&quot;SELECT * FROM events WHERE type = $1&quot;), ['login']);
  expect(rows).toHaveLength(1);
});
// both connections closed automatically
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No manual cleanup. No try/finally.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-works-with-postgresql-mysql-and-sqlite-out-of-the-box&quot;&gt;5. Works with PostgreSQL, MySQL, and SQLite out of the box&lt;/h3&gt;
&lt;p&gt;Same API, three drivers:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// SQLite — in-memory, zero infrastructure
import { sqliteAdapter } from '@playwright-labs/fixture-sql/sqlite';
test.use({ sqlAdapter: sqliteAdapter(':memory:') });

// PostgreSQL
import { pgAdapter } from '@playwright-labs/fixture-sql/pg';
test.use({ sqlAdapter: pgAdapter(process.env.DATABASE_URL!) });

// MySQL / MariaDB
import { mysqlAdapter } from '@playwright-labs/fixture-sql/mysql';
test.use({ sqlAdapter: mysqlAdapter(process.env.MYSQL_URL!) });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Drivers are optional peer dependencies — install only the one you use.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;bonus-editor-autocomplete&quot;&gt;Bonus: editor autocomplete&lt;/h3&gt;
&lt;p&gt;Pair with &lt;code&gt;@playwright-labs/ts-plugin-sql&lt;/code&gt; and add a schema file to get table/column autocomplete and diagnostics inside &lt;code&gt;sql&lt;/code&gt; templates directly in your editor.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# generate schema types from your live DB
pnpm playwright-labs-sql-core pull --adapter pg --url postgresql://localhost/mydb --out ./src/db-types.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;// tsconfig.json
{
  &quot;compilerOptions&quot;: {
    &quot;plugins&quot;: [{ &quot;name&quot;: &quot;@playwright-labs/ts-plugin-sql&quot;, &quot;schemaFile&quot;: &quot;./src/db-types.ts&quot; }]
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;get-started&quot;&gt;Get started&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pnpm add -D @playwright-labs/fixture-sql
pnpm add -D better-sqlite3   # or pg / mysql2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Source: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/vitalics/playwright-labs&quot;&gt;github.com/vitalics/playwright-labs&lt;/a&gt; — MIT license.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/14599/5-things-this-playwright-sql-fixture-does-so-you-dont-have-to</guid>
<pubDate>Mon, 13 Apr 2026 07:53:00 +0000</pubDate>
</item>
<item>
<title>Angular-Aware E2E Testing: Query Components by @Input and Signals in Playwright</title>
<link>https://coderlegion.com/13831/angular-aware-e2e-testing-query-components-by-input-and-signals-in-playwright</link>
<description>&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=5952343712647183147&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-gap-between-angular-and-its-tests&quot;&gt;The Gap Between Angular and Its Tests&lt;/h3&gt;
&lt;p&gt;Angular applications are composed of typed components with declared &lt;code&gt;@Input()&lt;/code&gt; properties, &lt;code&gt;signal()&lt;/code&gt; state, and &lt;code&gt;@Output()&lt;/code&gt; events. Yet most E2E tests interact with these apps through raw CSS selectors — completely ignoring the component model.&lt;/p&gt;
&lt;p&gt;This leads to tests that know too much about the DOM and too little about the Angular contract. &lt;code&gt;@playwright-labs/selectors-angular&lt;/code&gt; fills that gap.&lt;/p&gt;
&lt;h3 id=&quot;installation-1&quot;&gt;Installation&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install -D @playwright-labs/selectors-angular
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;quick-setup&quot;&gt;Quick Setup&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// playwright.config.ts
import { selectors, defineConfig } from &quot;@playwright/test&quot;;
import { AngularEngine } from &quot;@playwright-labs/selectors-angular&quot;;

selectors.register(&quot;angular&quot;, AngularEngine);

export default defineConfig({
  use: { baseURL: &quot;http://localhost:4200&quot; },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or use the all-in-one import that registers the engine automatically:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { test, expect } from &quot;@playwright-labs/selectors-angular&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-angular-selector&quot;&gt;The &lt;code&gt;angular=&lt;/code&gt; Selector&lt;/h3&gt;
&lt;p&gt;Query components by their &lt;code&gt;@Input()&lt;/code&gt; values instead of DOM attributes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// By exact @Input value
page.locator('angular=app-button[label=&quot;Submit&quot;]')

// By nested property
page.locator('angular=app-user-card[user.role=&quot;admin&quot;]')

// By substring
page.locator('angular=app-button[type*=&quot;primary&quot;]')

// Multiple conditions (AND)
page.locator('angular=app-button[type=&quot;danger&quot;][disabled]')

// Regex
page.locator('angular=app-button[label=/^Sub/i]')

// Case-insensitive
page.locator('angular=app-button[label=&quot;submit&quot; i]')
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Supported operators mirror CSS attribute selectors:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Syntax &lt;/th&gt;&lt;th&gt;Meaning&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;[prop]&lt;/code&gt; &lt;/td&gt;&lt;td&gt;truthy&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;[prop=&quot;val&quot;]&lt;/code&gt; &lt;/td&gt;&lt;td&gt;exact match&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;[prop*=&quot;val&quot;]&lt;/code&gt; &lt;/td&gt;&lt;td&gt;contains&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;[prop^=&quot;val&quot;]&lt;/code&gt; &lt;/td&gt;&lt;td&gt;starts with&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;[prop$=&quot;val&quot;]&lt;/code&gt; &lt;/td&gt;&lt;td&gt;ends with&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;[prop~=&quot;val&quot;]&lt;/code&gt; &lt;/td&gt;&lt;td&gt;word in list&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;[prop\|=&quot;val&quot;]&lt;/code&gt; &lt;/td&gt;&lt;td&gt;exact or hyphen-prefix&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;the-ng-fixture&quot;&gt;The &lt;code&gt;$ng&lt;/code&gt; Fixture&lt;/h3&gt;
&lt;p&gt;Access component state directly from test code:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;test(&quot;reads component properties&quot;, async ({ page, $ng }) =&amp;gt; {
  await page.goto(&quot;/&quot;);

  // @Input value
  const label = await $ng(&quot;app-button&quot;).first().input&amp;lt;string&amp;gt;(&quot;label&quot;);
  expect(label).toBe(&quot;Submit&quot;);

  // WritableSignal value
  const count = await $ng(&quot;app-counter&quot;).first().signal&amp;lt;number&amp;gt;(&quot;count&quot;);
  expect(count).toBe(0);

  // List inputs
  const inputs = await $ng(&quot;app-button&quot;).first().inputs();
  expect(inputs).toContain(&quot;disabled&quot;);

  // Check directives
  const dirs = await $ng(&quot;router-outlet&quot;).directives();
  expect(dirs.some((d) =&amp;gt; d.includes(&quot;RouterOutlet&quot;))).toBe(true);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;custom-matchers&quot;&gt;Custom Matchers&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { expect } from &quot;@playwright-labs/selectors-angular&quot;;

test(&quot;component matchers&quot;, async ({ page }) =&amp;gt; {
  const btn = page.locator(&quot;app-button&quot;).first();
  const counter = page.locator(&quot;app-counter&quot;).first();

  await expect(btn).toBeNgComponent();
  await expect(btn).toHaveNgInput(&quot;label&quot;);
  await expect(btn).toBeNgInput(&quot;label&quot;, &quot;Submit&quot;);
  await expect(btn).toHaveNgOutput(&quot;clicked&quot;);

  await expect(counter).toHaveNgSignal(&quot;count&quot;);
  await expect(counter).toBeNgSignal(&quot;count&quot;, 0);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;a-before-after-comparison&quot;&gt;A Before/After Comparison&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// Depends on template class names — breaks on refactor
const deleteBtn = page.locator(&quot;button.danger:disabled&quot;);
await expect(deleteBtn).toHaveCount(1);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;After:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// Queries the Angular component contract directly
const deleteBtn = page.locator('angular=app-button[type=&quot;danger&quot;][disabled]');
await expect(deleteBtn).toHaveCount(1);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The second version continues to work even if the template is rewritten to use different class names, a different element, or a different disabled mechanism — because it queries the component, not the DOM.&lt;/p&gt;
&lt;h3 id=&quot;signal-based-inputs-angular-17-&quot;&gt;Signal-Based Inputs (Angular 17+)&lt;/h3&gt;
&lt;p&gt;The engine transparently handles &lt;code&gt;input()&lt;/code&gt; signal-based inputs alongside traditional &lt;code&gt;@Input()&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;@Component({ selector: &quot;app-badge&quot; })
export class BadgeComponent {
  count = input&amp;lt;number&amp;gt;(0);   // signal-based input
  @Input() label = &quot;&quot;;         // traditional input
}

// Both work identically in selectors:
page.locator('angular=app-badge[count=5]')
page.locator('angular=app-badge[label=&quot;New&quot;]')
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;requirements&quot;&gt;Requirements&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Angular 14+ in &lt;strong&gt;development mode&lt;/strong&gt; (&lt;code&gt;ng serve&lt;/code&gt; or &lt;code&gt;--configuration=development&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Playwright 1.40+&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;By teaching Playwright to speak Angular's language, &lt;code&gt;@playwright-labs/selectors-angular&lt;/code&gt; makes E2E tests that are more resilient, more readable, and more aligned with how the application is actually built.&lt;/p&gt;
&lt;p&gt;Source: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/vitalics/playwright-labs&quot;&gt;github.com/vitalics/playwright-labs&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/13831/angular-aware-e2e-testing-query-components-by-input-and-signals-in-playwright</guid>
<pubDate>Thu, 02 Apr 2026 12:12:00 +0000</pubDate>
</item>
<item>
<title>4 new Playwright email templates with shadcn/ui — and how to make shadcn work in email</title>
<link>https://coderlegion.com/13560/4-new-playwright-email-templates-with-shadcn-ui-and-how-to-make-shadcn-work-in-email</link>
<description>&lt;h3 id=&quot;what-is-this-&quot;&gt;What is this?&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;@playwright-labs/reporter-email&lt;/code&gt; is a Playwright reporter that emails your team the test results after each CI run. This release adds 4 new templates built on shadcn/ui.&lt;/p&gt;
&lt;h3 id=&quot;the-core-challenge&quot;&gt;The core challenge&lt;/h3&gt;
&lt;p&gt;shadcn/ui is not email-compatible out of the box. Three things break:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Radix UI&lt;/strong&gt; — shadcn uses Radix for interactive behaviour (Select, Dialog, etc.). Radix calls browser APIs (&lt;code&gt;window&lt;/code&gt;, &lt;code&gt;document&lt;/code&gt;) at import time. &lt;code&gt;@react-email/render&lt;/code&gt; runs in Node.js with none of these.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CSS variables&lt;/strong&gt; — shadcn default config generates &lt;code&gt;hsl(var(--foreground))&lt;/code&gt;. Email clients don't support CSS variables.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;JSX transform conflict&lt;/strong&gt; — in a Playwright project, esbuild replaces React's JSX with Playwright's own internal representation. That breaks &lt;code&gt;@react-email/render&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;fixes&quot;&gt;Fixes&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;Radix:&lt;/strong&gt; Rewrote all primitives as plain HTML. &lt;code&gt;SelectItem&lt;/code&gt; is a &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; with a conditional &lt;code&gt;✓&lt;/code&gt; span. &lt;code&gt;Button&lt;/code&gt; is an &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt; tag. No Radix import anywhere.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;CSS variables:&lt;/strong&gt; Set &lt;code&gt;&quot;cssVariables&quot;: false&lt;/code&gt; in &lt;code&gt;components.json&lt;/code&gt;. shadcn then generates &lt;code&gt;bg-slate-900&lt;/code&gt; (concrete value) instead of &lt;code&gt;bg-background&lt;/code&gt; (CSS variable).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;JSX transform:&lt;/strong&gt; Added &lt;code&gt;/* @jsxImportSource react */&lt;/code&gt; pragma to every &lt;code&gt;.tsx&lt;/code&gt; component file. esbuild respects per-file pragma overrides, so React's JSX runtime is used instead of Playwright's.&lt;/p&gt;
&lt;h3 id=&quot;new-templates&quot;&gt;New templates&lt;/h3&gt;
&lt;h4 id=&quot;base-chart-pass-rate-bar&quot;&gt;&lt;code&gt;base-chart&lt;/code&gt; — pass-rate bar&lt;/h4&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=11343803965389159841&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import { PlaywrightReportShadcnChartEmail }
  from &quot;@playwright-labs/reporter-email/templates/shadcn/base-chart&quot;;

&amp;lt;PlaywrightReportShadcnChartEmail result={result} testCases={testCases} &amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Adds a stacked horizontal bar showing the percentage of passed / failed / skipped tests. Pure CSS, no images.&lt;/p&gt;
&lt;h4 id=&quot;base-button-cta-links&quot;&gt;&lt;code&gt;base-button&lt;/code&gt; — CTA links&lt;/h4&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=14993057654724926920&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import { PlaywrightReportShadcnButtonEmail }
  from &quot;@playwright-labs/reporter-email/templates/shadcn/base-button&quot;;

&amp;lt;PlaywrightReportShadcnButtonEmail
  result={result}
  testCases={testCases}
  reportUrl=&quot;https://ci.example.com/report/42&quot;
&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A footer with &quot;View Full Report&quot; and optionally &quot;View N Failure(s)&quot;. Buttons are &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt; tags styled with shadcn button variants.&lt;/p&gt;
&lt;h4 id=&quot;base-themes-6-color-themes&quot;&gt;&lt;code&gt;base-themes&lt;/code&gt; — 6 color themes&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import { PlaywrightReportShadcnThemesEmail }
  from &quot;@playwright-labs/reporter-email/templates/shadcn/base-themes&quot;;

&amp;lt;PlaywrightReportShadcnThemesEmail result={result} testCases={testCases} theme=&quot;green&quot; &amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Themes: &lt;code&gt;slate&lt;/code&gt; | &lt;code&gt;zinc&lt;/code&gt; | &lt;code&gt;rose&lt;/code&gt; | &lt;code&gt;blue&lt;/code&gt; | &lt;code&gt;green&lt;/code&gt; | &lt;code&gt;orange&lt;/code&gt;. Each changes the accent bar, headings, and row backgrounds.&lt;/p&gt;
&lt;h3 id=&quot;import-only-what-you-need&quot;&gt;Import only what you need&lt;/h3&gt;
&lt;p&gt;Each template is a separate bundle under &lt;code&gt;dist/templates/shadcn/&lt;/code&gt;. Individual imports:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// Only loads base-select bundle, nothing else
import { PlaywrightReportShadcnSelectEmail }
  from &quot;@playwright-labs/reporter-email/templates/shadcn/base-select&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or use the barrel for multiple templates:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import {
  PlaywrightReportShadcnChartEmail,
  PlaywrightReportShadcnThemesEmail,
} from &quot;@playwright-labs/reporter-email/templates/shadcn&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Requires &lt;code&gt;&quot;moduleResolution&quot;: &quot;bundler&quot;&lt;/code&gt; in &lt;code&gt;tsconfig.json&lt;/code&gt; to resolve the &lt;code&gt;exports&lt;/code&gt; subpaths.&lt;/p&gt;
&lt;h3 id=&quot;run-the-preview-locally&quot;&gt;Run the preview locally&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git clone https://github.com/vitalics/playwright-labs
cd playwright-labs &amp;amp;&amp;amp; pnpm install
pnpm --filter @playwright-labs/reporter-email build
cd packages/reporter-email/examples &amp;amp;&amp;amp; pnpm email:preview
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Opens at &lt;code&gt;http://localhost:3000&lt;/code&gt;. Edit any file in &lt;code&gt;emails/&lt;/code&gt; and the server hot-reloads.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/vitalics/playwright-labs&quot;&gt;https://github.com/vitalics/playwright-labs&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;npm:&lt;/strong&gt; &lt;code&gt;@playwright-labs/reporter-email&lt;/code&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/13560/4-new-playwright-email-templates-with-shadcn-ui-and-how-to-make-shadcn-work-in-email</guid>
<pubDate>Mon, 30 Mar 2026 13:28:00 +0000</pubDate>
</item>
<item>
<title>How We Eliminated Container Boilerplate from Playwright Integration Tests</title>
<link>https://coderlegion.com/13525/how-we-eliminated-container-boilerplate-from-playwright-integration-tests</link>
<description>&lt;p&gt;Integration tests should be honest. When a service talks to Postgres, the test should talk to Postgres too — not a mock that pretends to be Postgres but lacks constraints, triggers, and the dozen behaviors that actually matter in production.&lt;/p&gt;
&lt;p&gt;The blocker has never been philosophy. It has been plumbing. Starting a container, waiting for it to be ready, passing the connection details to the test, stopping it after — and doing all of that reliably when a test throws — is tedious boilerplate that sits between you and the test you actually want to write.&lt;/p&gt;
&lt;p&gt;We solved this with &lt;code&gt;@playwright-labs/fixture-testcontainers&lt;/code&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-was-wrong-with-the-existing-approach&quot;&gt;What was wrong with the existing approach&lt;/h3&gt;
&lt;p&gt;The Testcontainers Node.js library is excellent. The missing piece was ergonomic integration with Playwright's test lifecycle. The typical pattern before this package looked like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { GenericContainer, StartedTestContainer } from &quot;testcontainers&quot;;
import { test } from &quot;@playwright/test&quot;;

let pg: StartedTestContainer;

test.beforeAll(async () =&amp;gt; {
  pg = await new GenericContainer(&quot;postgres:16&quot;)
    .withEnvironment({ POSTGRES_PASSWORD: &quot;secret&quot; })
    .withExposedPorts(5432)
    .start();
});

test.afterAll(async () =&amp;gt; {
  await pg?.stop();
});

test(&quot;insert and query&quot;, async () =&amp;gt; {
  // finally, the actual test
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This pattern has several failure modes:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Shared state across tests.&lt;/strong&gt; Multiple tests modify the same database instance, producing order-dependent failures that are nearly impossible to reproduce in isolation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Leaking containers.&lt;/strong&gt; If &lt;code&gt;beforeAll&lt;/code&gt; throws after partially initializing, &lt;code&gt;afterAll&lt;/code&gt; may receive &lt;code&gt;undefined&lt;/code&gt; for &lt;code&gt;pg&lt;/code&gt; and fail to clean up.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;File-level coupling.&lt;/strong&gt; Adding a second container means another &lt;code&gt;let&lt;/code&gt;, another &lt;code&gt;beforeAll&lt;/code&gt; call, another &lt;code&gt;afterAll&lt;/code&gt; call. The infrastructure setup grows in proportion to your test file, not to your test logic.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-fixture-model-fixes-all-three&quot;&gt;The fixture model fixes all three&lt;/h3&gt;
&lt;p&gt;Playwright's fixture system already manages this lifecycle for browsers, pages, and any other shared state. Fixtures are set up on demand, scoped to the test (or file, or worker), and torn down after the test ends — even if the test throws.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;@playwright-labs/fixture-testcontainers&lt;/code&gt; brings that same model to Docker containers:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { test } from &quot;@playwright-labs/fixture-testcontainers&quot;;
import { Wait } from &quot;testcontainers&quot;;

test(&quot;insert and query&quot;, async ({ useContainer }) =&amp;gt; {
  const pg = await useContainer(&quot;postgres:16&quot;, {
    ports: 5432,
    environment: { POSTGRES_PASSWORD: &quot;secret&quot; },
    waitStrategy: Wait.forLogMessage(&quot;ready to accept connections&quot;),
  });

  // this container is scoped to this test
  // it stops automatically when the test ends
  const port = pg.getMappedPort(5432);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each test that needs a database gets its own container. No shared state, no cleanup code, no coupling between tests.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;realistic-usage-building-domain-fixtures&quot;&gt;Realistic usage: building domain fixtures&lt;/h3&gt;
&lt;p&gt;The most powerful use of this package is not the raw &lt;code&gt;useContainer&lt;/code&gt; fixture — it is using &lt;code&gt;useContainer&lt;/code&gt; as a building block for your own fixtures.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// test-helpers/fixtures.ts
import { test as base } from &quot;@playwright-labs/fixture-testcontainers&quot;;
import { Wait } from &quot;testcontainers&quot;;
import { Pool } from &quot;pg&quot;;

type Fixtures = {
  db: Pool;
  redisUrl: string;
};

export const test = base.extend&amp;lt;Fixtures&amp;gt;({
  db: async ({ useContainer }, use) =&amp;gt; {
    const container = await useContainer(&quot;postgres:16&quot;, {
      ports: 5432,
      environment: { POSTGRES_PASSWORD: &quot;secret&quot; },
      waitStrategy: Wait.forLogMessage(&quot;ready to accept connections&quot;),
    });
    const pool = new Pool({
      host: container.getHost(),
      port: container.getMappedPort(5432),
      password: &quot;secret&quot;,
      database: &quot;postgres&quot;,
      user: &quot;postgres&quot;,
    });
    await use(pool);
    await pool.end();
  },

  redisUrl: async ({ useContainer }, use) =&amp;gt; {
    const container = await useContainer(&quot;redis:8&quot;, { ports: 6379 });
    await use(`redis://${container.getHost()}:${container.getMappedPort(6379)}`);
  },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Tests import from &lt;code&gt;fixtures.ts&lt;/code&gt; and receive ready-to-use clients:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { test } from &quot;./test-helpers/fixtures&quot;;
import { expect } from &quot;@playwright-labs/fixture-testcontainers&quot;;

test(&quot;user record persists&quot;, async ({ db }) =&amp;gt; {
  await db.query(`INSERT INTO users (name) VALUES ($1)`, [&quot;Alice&quot;]);
  const { rows } = await db.query(`SELECT name FROM users WHERE name = $1`, [&quot;Alice&quot;]);
  expect(rows[0].name).toBe(&quot;Alice&quot;);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The test has no idea containers are involved. It receives a &lt;code&gt;Pool&lt;/code&gt;. The infrastructure is an implementation detail of the fixture.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;all-the-options-none-of-the-extra-api&quot;&gt;All the options, none of the extra API&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;ContainerOpts&lt;/code&gt; maps one-to-one with &lt;code&gt;GenericContainer.with*&lt;/code&gt; methods. There is nothing new to learn — if you know Testcontainers, you know the options:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Option &lt;/th&gt;&lt;th&gt;Testcontainers method&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;ports&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;withExposedPorts&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;environment&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;withEnvironment&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;waitStrategy&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;withWaitStrategy&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;healthCheck&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;withHealthCheck&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;network&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;withNetwork&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;bindMounts&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;withBindMounts&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;copyFiles&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;withCopyFilesToContainer&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;pullPolicy&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;withPullPolicy&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;resourcesQuota&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;withResourcesQuota&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;... and 20 more &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;For cases where you need the full builder, pass a pre-configured &lt;code&gt;GenericContainer&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { GenericContainer } from &quot;testcontainers&quot;;

const container = await useContainer(
  new GenericContainer(&quot;postgres:16&quot;)
    .withEnvironment({ POSTGRES_PASSWORD: &quot;secret&quot; })
    .withExposedPorts(5432)
    .withReuse(),
);
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;container-assertions&quot;&gt;Container assertions&lt;/h3&gt;
&lt;p&gt;The package exports an extended &lt;code&gt;expect&lt;/code&gt; with matchers specific to &lt;code&gt;StartedTestContainer&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { expect } from &quot;@playwright-labs/fixture-testcontainers&quot;;

// state
await expect(container).toBeContainerRunning();
await expect(container).toBeContainerHealthy();
await expect(container).not.toBeContainerStopped();

// logs
await expect(container).toMatchContainerLogMessage(&quot;ready to accept connections&quot;);
await expect(container).not.toMatchContainerLogMessage(&quot;FATAL&quot;);

// ports
expect(container).toBeContainerPort(5432);
expect(container).toMatchContainerPortInRange(5432, { min: 1024, max: 65535 });

// metadata
expect(container).toHaveContainerLabel(&quot;env&quot;, &quot;test&quot;);
expect(container).toHaveContainerNetwork(&quot;app-net&quot;);
await expect(container).toHaveContainerUser(&quot;postgres&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These matchers turn container state inspection from raw Docker API calls into readable assertions that belong in test output.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;getting-started&quot;&gt;Getting started&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install -D @playwright-labs/fixture-testcontainers testcontainers
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Requirements:&lt;/strong&gt; &lt;code&gt;@playwright/test &amp;gt;= 1.57.0&lt;/code&gt;, &lt;code&gt;testcontainers &amp;gt;= 10.0.0&lt;/code&gt;, Docker available in your environment.&lt;/p&gt;
&lt;p&gt;The package is part of &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/vitalics/playwright-labs&quot;&gt;playwright-labs&lt;/a&gt;, an open-source collection of Playwright utilities.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;If you maintain a Playwright test suite and have been using mocks for anything that should really be a database or a cache, this is a practical path to replacing them without rewriting your entire test setup.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/13525/how-we-eliminated-container-boilerplate-from-playwright-integration-tests</guid>
<pubDate>Thu, 26 Mar 2026 19:20:06 +0000</pubDate>
</item>
<item>
<title>AI Watch Tester (AWT)</title>
<link>https://coderlegion.com/12173/ai-watch-tester-awt</link>
<description>&lt;p&gt;Every new project, same story: write login tests, write form validation tests, write navigation tests. Copy-paste from the last project, tweak selectors, pray nothing breaks.&lt;/p&gt;
&lt;p&gt;After 25 years in IT, I decided to automate the boring part. I built &lt;strong&gt;AWT (AI Watch Tester)&lt;/strong&gt; — an open-source tool where you enter a URL, and AI writes the tests for you.&lt;/p&gt;
&lt;h3 id=&quot;how-it-works&quot;&gt;How It Works&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Enter a URL&lt;/strong&gt; — that's your only input&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AI scans the page&lt;/strong&gt; — analyzes DOM structure + takes screenshots&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Generates test scenarios&lt;/strong&gt; — login flows, form validation, navigation checks&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Runs them with Playwright&lt;/strong&gt; — real browser, real clicks, real screenshots&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;No selectors to write. No test scripts to maintain. AI handles the planning, Playwright handles the execution.&lt;/p&gt;
&lt;h3 id=&quot;-can-t-claude-gpt-just-do-this-with-computer-use-&quot;&gt;&quot;Can't Claude/GPT Just Do This with Computer Use?&quot;&lt;/h3&gt;
&lt;p&gt;Fair question. I get it a lot.&lt;/p&gt;
&lt;p&gt;Computer Use is a general-purpose GUI agent — it can click buttons and type text. But for E2E testing, you'd still need:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Docker environment setup&lt;/li&gt;
&lt;li&gt;Screenshot pipeline management&lt;/li&gt;
&lt;li&gt;Result parsing and storage&lt;/li&gt;
&lt;li&gt;CI/CD integration&lt;/li&gt;
&lt;li&gt;Scenario tracking across runs&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And each test costs &lt;strong&gt;$0.50–2.00&lt;/strong&gt; because the AI processes every screenshot.&lt;/p&gt;
&lt;p&gt;AWT uses AI &lt;strong&gt;only for test generation&lt;/strong&gt; (analyzing what to test), then runs tests with Playwright — no per-screenshot AI cost. A typical scan costs &lt;strong&gt;$0.002–0.03&lt;/strong&gt;. That's &lt;strong&gt;10–100x cheaper&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Think of it this way: &lt;strong&gt;Computer Use is the hammer. AWT is the furniture store.&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id=&quot;what-makes-it-different&quot;&gt;What Makes It Different&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;AWT &lt;/th&gt;&lt;th&gt;Playwright/Cypress &lt;/th&gt;&lt;th&gt;testRigor/Applitools&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Test writing &lt;/td&gt;&lt;td&gt;AI writes them &lt;/td&gt;&lt;td&gt;You write them &lt;/td&gt;&lt;td&gt;AI assists&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Cost &lt;/td&gt;&lt;td&gt;Free (MIT) + BYOK &lt;/td&gt;&lt;td&gt;Free &lt;/td&gt;&lt;td&gt;$800+/mo&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;AI provider &lt;/td&gt;&lt;td&gt;Your choice (OpenAI, Anthropic, Ollama*) &lt;/td&gt;&lt;td&gt;N/A &lt;/td&gt;&lt;td&gt;Locked in&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Local mode &lt;/td&gt;&lt;td&gt;Yes (Ollama, experimental) &lt;/td&gt;&lt;td&gt;Yes &lt;/td&gt;&lt;td&gt;No&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;*Ollama adapter is included but experimental — works best with larger models (70B+). Results may vary with smaller models.&lt;/p&gt;
&lt;h3 id=&quot;the-honest-limitations&quot;&gt;The Honest Limitations&lt;/h3&gt;
&lt;p&gt;This is v1.0 by a solo developer. Let me be upfront:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;✅ Works well on &lt;strong&gt;simple login/form pages&lt;/strong&gt; (SauceDemo, standard auth flows)&lt;/li&gt;
&lt;li&gt;⚠️ Complex SPAs with heavy dynamic content — still improving&lt;/li&gt;
&lt;li&gt;⚠️ No cancel button for long scans yet&lt;/li&gt;
&lt;li&gt;⚠️ Free plan is limited (5 pages per scan)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;tech-stack&quot;&gt;Tech Stack&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Backend&lt;/strong&gt;: Python, FastAPI, Playwright&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Frontend&lt;/strong&gt;: Next.js, TypeScript&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Database&lt;/strong&gt;: PostgreSQL (Supabase)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AI&lt;/strong&gt;: OpenAI / Anthropic / Ollama adapters&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;License&lt;/strong&gt;: MIT&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;try-it-2&quot;&gt;Try It&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Cloud&lt;/strong&gt;: &lt;a rel=&quot;nofollow&quot; href=&quot;https://ai-watch-tester.vercel.app&quot;&gt;https://ai-watch-tester.vercel.app&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GitHub&lt;/strong&gt;: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ksgisang/AI-Watch-Tester&quot;&gt;https://github.com/ksgisang/AI-Watch-Tester&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Sign up → Settings → Enter your OpenAI key → Start scanning.&lt;/p&gt;
&lt;p&gt;Ollama adapter is also included for local execution, though it's still experimental — best results with larger models.&lt;/p&gt;
&lt;h3 id=&quot;what-i-learned-building-this&quot;&gt;What I Learned Building This&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;AI is great at generating test plans, bad at executing them.&lt;/strong&gt; That's why I separated generation (AI) from execution (Playwright). Trying to do both with AI is expensive and fragile.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Language detection matters.&lt;/strong&gt; My first users got Korean test scenarios on English sites. Lesson: always detect the target site's language before generating.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Assert validation is critical.&lt;/strong&gt; AI sometimes generates structurally invalid assertions. A post-processing validator that auto-corrects the schema saved me from shipping broken tests.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Bug reports, feedback, and PRs are all welcome. What edge cases should I try next?&lt;/p&gt;
</description>
<category>Launches</category>
<guid isPermaLink="true">https://coderlegion.com/12173/ai-watch-tester-awt</guid>
<pubDate>Wed, 25 Feb 2026 12:50:30 +0000</pubDate>
</item>
<item>
<title>Graceful Test Cancellation: Why Your Playwright Tests Need AbortSignal</title>
<link>https://coderlegion.com/11781/graceful-test-cancellation-why-your-playwright-tests-need-abortsignal</link>
<description>&lt;p&gt;There is a class of bugs that only shows up at scale. Not in your code. Not in your infrastructure. In the gap between them -- where your test suite meets your staging environment.&lt;/p&gt;
&lt;p&gt;I am talking about resource leaks from timed-out tests.&lt;/p&gt;
&lt;h3 id=&quot;the-scenario-you-have-definitely-seen&quot;&gt;The Scenario You Have Definitely Seen&lt;/h3&gt;
&lt;p&gt;A developer pushes a change. CI kicks off 300 Playwright tests across 8 workers. Fifteen of those tests hit a slow endpoint -- maybe the service is under load, maybe there is a network hiccup. They time out after 30 seconds each.&lt;/p&gt;
&lt;p&gt;Playwright marks them as failed and moves on. Good. That is what it should do.&lt;/p&gt;
&lt;p&gt;But here is what does not happen: the HTTP requests those tests initiated do not get cancelled. They are still in flight. The server is still processing them. The TCP connections are still open.&lt;/p&gt;
&lt;p&gt;Fifteen tests, each making two or three API calls. That is 30 to 45 orphaned requests still hammering your staging environment. They will finish eventually -- or time out on their own after another 30 seconds. In the meantime, they consume server threads, database connections, and memory.&lt;/p&gt;
&lt;p&gt;Now imagine this happens every CI run. Multiple times a day. Across multiple teams sharing the same staging environment.&lt;/p&gt;
&lt;p&gt;This is not hypothetical. This is Tuesday.&lt;/p&gt;
&lt;h3 id=&quot;why-playwright-does-not-handle-this&quot;&gt;Why Playwright Does Not Handle This&lt;/h3&gt;
&lt;p&gt;This is not a Playwright bug. It is a fundamental constraint of how JavaScript async operations work.&lt;/p&gt;
&lt;p&gt;When Playwright times out a test, it stops waiting for the test function to resolve. But &lt;code&gt;fetch&lt;/code&gt;, &lt;code&gt;axios&lt;/code&gt;, WebSocket connections, and other async operations do not know the test has ended. They were started, they are running, and nothing has told them to stop.&lt;/p&gt;
&lt;p&gt;JavaScript has no built-in mechanism to kill arbitrary async operations from outside. You cannot reach into a running &lt;code&gt;fetch&lt;/code&gt; and terminate it. You cannot walk the promise chain and cancel everything.&lt;/p&gt;
&lt;p&gt;What you can do is provide a cancellation token upfront -- a signal that says &quot;when I tell you to stop, stop.&quot; This is what &lt;code&gt;AbortController&lt;/code&gt; and &lt;code&gt;AbortSignal&lt;/code&gt; are for.&lt;/p&gt;
&lt;h3 id=&quot;abortsignal-a-quick-refresher&quot;&gt;AbortSignal: A Quick Refresher&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;AbortController&lt;/code&gt; is part of the web platform and is also available in Node.js. It provides a simple cancellation mechanism:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;const controller = new AbortController();

// Start an operation with the signal
fetch('/api/data', { signal: controller.signal });

// Cancel it from outside
controller.abort();
// The fetch promise rejects with an AbortError
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The key insight is that you decide at the start of the operation that it should be cancellable. You pass the signal in. Later, when you need to cancel, you call &lt;code&gt;abort()&lt;/code&gt; on the controller.&lt;/p&gt;
&lt;p&gt;This is the missing piece for Playwright tests.&lt;/p&gt;
&lt;h3 id=&quot;wiring-abortsignal-into-the-test-lifecycle&quot;&gt;Wiring AbortSignal Into the Test Lifecycle&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;@playwright-labs/fixture-abort&lt;/code&gt; package provides this wiring. It extends Playwright's &lt;code&gt;test&lt;/code&gt; with fixtures:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;abortController&lt;/code&gt; -- a fresh &lt;code&gt;AbortController&lt;/code&gt; for the current test&lt;/li&gt;
&lt;li&gt;&lt;code&gt;signal&lt;/code&gt; -- the associated &lt;code&gt;AbortSignal&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;useAbortController(options?)&lt;/code&gt; -- returns the controller with optional abort callback&lt;/li&gt;
&lt;li&gt;&lt;code&gt;useSignalWithTimeout(ms)&lt;/code&gt; -- returns a signal that auto-aborts after a duration&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The controller is automatically aborted when the test times out. Every operation that received the signal cancels immediately.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { test, expect } from '@playwright-labs/fixture-abort';

test('should fetch data with cancellation support', async ({ signal }) =&amp;gt; {
  const response = await fetch('https://api.example.com/data', {
    signal
  });
  const data = await response.json();
  expect(data).toBeDefined();
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If this test times out, &lt;code&gt;signal&lt;/code&gt; fires. The fetch call is cancelled. The server stops processing. The connection is freed. No cleanup code required.&lt;/p&gt;
&lt;h3 id=&quot;where-this-pattern-really-shines&quot;&gt;Where This Pattern Really Shines&lt;/h3&gt;
&lt;h4 id=&quot;polling-loops&quot;&gt;Polling Loops&lt;/h4&gt;
&lt;p&gt;Tests that poll an endpoint are the most common source of zombie requests. Without an abort signal, a polling loop can run indefinitely after a timeout:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { test, expect } from '@playwright-labs/fixture-abort';

test('should handle long polling', async ({ signal }) =&amp;gt; {
  while (!signal.aborted) {
    const response = await fetch('/api/poll', {
      signal
    });

    if (response.status === 200) {
      const data = await response.json();
      expect(data.ready).toBe(true);
      return;
    }

    await new Promise(resolve =&amp;gt; setTimeout(resolve, 1000));
  }
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two layers of protection here. The &lt;code&gt;while (!signal.aborted)&lt;/code&gt; check prevents the loop from continuing after cancellation. The &lt;code&gt;signal&lt;/code&gt; on the fetch call cancels any in-flight request. If the test times out mid-request, the fetch is cancelled. If it times out between requests, the loop condition catches it.&lt;/p&gt;
&lt;h4 id=&quot;parallel-request-batches&quot;&gt;Parallel Request Batches&lt;/h4&gt;
&lt;p&gt;When tests fire multiple requests simultaneously, all of them need to be cancellable:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { test, expect } from '@playwright-labs/fixture-abort';

test('should aggregate data from multiple services', async ({ signal }) =&amp;gt; {
  const [users, products, analytics] = await Promise.all([
    fetch('/api/users', { signal }),
    fetch('/api/products', { signal }),
    fetch('/api/analytics', { signal })
  ]);

  // Process results...
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One signal, shared across all requests. When it fires, everything stops.&lt;/p&gt;
&lt;h4 id=&quot;retry-logic&quot;&gt;Retry Logic&lt;/h4&gt;
&lt;p&gt;Tests with retry mechanisms are particularly dangerous without abort signals. A retry loop can keep hammering a failing endpoint long after the test has timed out:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { test, expect } from '@playwright-labs/fixture-abort';

test('should retry on failure', async ({ signal }) =&amp;gt; {
  let lastError: Error | null = null;

  for (let attempt = 0; attempt &amp;lt; 5 &amp;amp;&amp;amp; !signal.aborted; attempt++) {
    try {
      const response = await fetch('/api/flaky-endpoint', {
        signal
      });
      if (response.ok) {
        const data = await response.json();
        expect(data).toBeDefined();
        return;
      }
    } catch (err) {
      lastError = err as Error;
      if (signal.aborted) break;
      await new Promise(resolve =&amp;gt; setTimeout(resolve, 1000 * (attempt + 1)));
    }
  }
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The signal check in the loop condition, the signal on the fetch call, and the signal check in the catch block create a robust cancellation pattern that exits quickly no matter where in the retry cycle the timeout occurs.&lt;/p&gt;
&lt;h3 id=&quot;timeout-signals&quot;&gt;Timeout Signals&lt;/h3&gt;
&lt;p&gt;Need a signal that auto-aborts after a specific duration, independent of the test timeout? Use &lt;code&gt;useSignalWithTimeout&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { test, expect } from '@playwright-labs/fixture-abort';

test('should complete API call within 5 seconds', async ({ useSignalWithTimeout }) =&amp;gt; {
  const timeoutSignal = useSignalWithTimeout(5000);

  const response = await fetch('https://api.example.com/slow', {
    signal: timeoutSignal
  });
  const data = await response.json();
  expect(data).toBeDefined();
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;custom-expect-matchers&quot;&gt;Custom Expect Matchers&lt;/h3&gt;
&lt;p&gt;The package includes matchers for asserting abort states:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { test, expect } from '@playwright-labs/fixture-abort';

test('should verify signal states', async ({ signal, abortController }) =&amp;gt; {
  expect(signal).toBeActive();
  expect(abortController).toHaveActiveSignal();

  abortController.abort('done');

  expect(signal).toBeAborted();
  expect(signal).toBeAbortedWithReason('done');
  expect(abortController).toHaveAbortedSignal();
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-bigger-picture-tests-as-good-citizens&quot;&gt;The Bigger Picture: Tests as Good Citizens&lt;/h3&gt;
&lt;p&gt;There is a broader principle here. Your tests are not running in isolation. They share infrastructure with other tests, other teams, and often with real users (if staging is a shared environment).&lt;/p&gt;
&lt;p&gt;A test that leaks resources is like a function that leaks memory. It might not matter once. It matters a lot when it happens thousands of times a day.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;AbortSignal&lt;/code&gt; integration is not just about preventing flaky tests. It is about making your test suite a responsible consumer of shared resources. It is about ensuring that when a test ends -- whether it passes, fails, or times out -- it cleans up after itself completely.&lt;/p&gt;
&lt;h3 id=&quot;the-cost-of-not-doing-this&quot;&gt;The Cost of Not Doing This&lt;/h3&gt;
&lt;p&gt;Without abort signal integration, you are relying on:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Server-side timeouts&lt;/strong&gt; to eventually clean up orphaned requests (adding load until they fire).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Connection pool limits&lt;/strong&gt; to cap the damage (causing legitimate requests to queue).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Manual investigation&lt;/strong&gt; when staging environments degrade (burning engineering time).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of these are solutions. They are damage control.&lt;/p&gt;
&lt;h3 id=&quot;getting-started-1&quot;&gt;Getting Started&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install @playwright-labs/fixture-abort
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The package provides raw Playwright fixtures. Import &lt;code&gt;test&lt;/code&gt; and &lt;code&gt;expect&lt;/code&gt; from &lt;code&gt;@playwright-labs/fixture-abort&lt;/code&gt; instead of &lt;code&gt;@playwright/test&lt;/code&gt;, use &lt;code&gt;signal&lt;/code&gt; in your async operations, and your tests clean up after themselves automatically.&lt;/p&gt;
&lt;p&gt;Full source code and documentation: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/vitalics/playwright-labs&quot;&gt;github.com/vitalics/playwright-labs&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The change is small. The impact on your infrastructure reliability is not.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/11781/graceful-test-cancellation-why-your-playwright-tests-need-abortsignal</guid>
<pubDate>Mon, 16 Feb 2026 19:41:08 +0000</pubDate>
</item>
</channel>
</rss>