<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ playwright - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ playwright - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Tue, 20 Oct 2026 15:32:21 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/playwright/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Make Your Coding Agent Check Its Work in the Browser ]]>
                </title>
                <description>
                    <![CDATA[ I got tired of coding agents telling me the frontend work was done when nobody had opened the frontend. You've probably seen the routine. The agent changes some code, runs the formatter, the type chec ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-make-your-coding-agent-check-its-work-in-the-browser/</link>
                <guid isPermaLink="false">6ad731804f84a96f720e9e9d</guid>
                
                    <category>
                        <![CDATA[ playwright ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Testing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Frontend Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ timothy ogbemudia ]]>
                </dc:creator>
                <pubDate>Tue, 20 Oct 2026 09:16:48 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/cbbc37b8-4405-4aa3-a691-0d7c7301baf2.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>I got tired of coding agents telling me the frontend work was done when nobody had opened the frontend.</p>
<p>You've probably seen the routine. The agent changes some code, runs the formatter, the type checker, and the tests, everything comes back green, and you get some version of "Implemented and verified."</p>
<p>But verified how, exactly? A passing test suite is useful evidence, but it isn't the same as opening the app and using the thing that just changed.</p>
<p>I've had plenty of changes where the code made sense and the tests passed, and the UI still had the kind of problem a person spots in about three seconds. A control overlapping something else. A selected state that exists in the store but isn't highlighted on screen. Reset working for one path and not another. A filter that updates state but leaves the old data on the page. Save looking fine until you refresh. A map layer drawn in the wrong order. A modal that technically opens but doesn't fit on the screen.</p>
<p>The agent had tested its code, but nobody had tested the application. So I started making browser review part of the definition of done for anything user-facing, and a few weeks in, that habit turned into a tool. I'll get to that near the end.</p>
<p>In this article, you'll learn how to give a coding agent a real browser with Playwright MCP, and how to write the instructions that make it test the change it just made instead of confirming that a page loads.</p>
<p>You'll see how to have the agent start your app the way your project already starts it, how to split those instructions between <code>AGENTS.md</code> and a reusable skill, why a separate reviewer agent catches bugs the author misses, and how to make the agent clean up only what it started.</p>
<p>In the last part, I'll walk through katalepsis, the open source tool this turned into, including how it decides whether a check really passed.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-loop-agents-are-missing">The Loop Agents Are Missing</a></p>
</li>
<li><p><a href="#heading-why-a-browser-on-its-own-falls-short">Why a Browser on Its Own Falls Short</a></p>
</li>
<li><p><a href="#heading-how-to-start-the-real-app">How to Start the Real App</a></p>
</li>
<li><p><a href="#heading-where-the-instructions-live">Where the Instructions Live</a></p>
</li>
<li><p><a href="#heading-why-the-reviewer-should-work-independently">Why the Reviewer Should Work Independently</a></p>
</li>
<li><p><a href="#heading-clean-up-after-yourself-and-only-yourself">Clean Up After Yourself and Only Yourself</a></p>
</li>
<li><p><a href="#heading-when-a-skill-stopped-being-enough">When a Skill Stopped Being Enough</a></p>
</li>
<li><p><a href="#heading-how-to-try-katalepsis">How to Try katalepsis</a></p>
</li>
<li><p><a href="#heading-how-katalepsis-works-under-the-hood">How katalepsis Works Under the Hood</a></p>
</li>
<li><p><a href="#heading-what-happened-when-i-used-it">What Happened When I Used It</a></p>
</li>
<li><p><a href="#heading-where-i-landed">Where I Landed</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>This isn't a beginner tutorial, so it helps to have:</p>
<ul>
<li><p>Some experience with a coding agent that supports MCP (Model Context Protocol), such as Claude Code, Codex, or Cursor, and an idea of how you give it instructions.</p>
</li>
<li><p>A web app you can run locally with a dev script, written in JavaScript or TypeScript. The examples use a Node project with <code>pnpm</code>, but nothing depends on a specific framework.</p>
</li>
<li><p>Basic Playwright knowledge: what a test, a locator, and an assertion look like. You don't need an existing test suite to follow the first half.</p>
</li>
<li><p>Node.js 20 or newer for Playwright MCP, and Node.js 22.12 or newer plus Git if you want to install katalepsis.</p>
</li>
<li><p>Comfort with the terminal and Git, since the agent will be starting processes and checking <code>git status</code>.</p>
</li>
</ul>
<h2 id="heading-the-loop-agents-are-missing">The Loop Agents Are Missing</h2>
<p>Coding agents got useful quickly in large part because they already have a decent feedback loop for code. They read, edit, and run the formatter and tests, read the failure, and try again. Nobody has to get it right in one heroic attempt.</p>
<p>For frontend work, the clear missing input is the frontend itself. The loop I want goes a bit further: edit the code, run the tests, start the app, use the changed feature, look at what happened, fix whatever's broken, and then use it again.</p>
<p>That last step matters more than you might think. Agents are very comfortable telling you a fix "should now work", and I'd rather they just open the page again and find out.</p>
<p>Playwright MCP is a good way to give them that. I already used Playwright for E2E tests, but the MCP server is useful for a different reason: it hands the agent a browser directly. It can open localhost, read the page, find controls, click and type, check what changed, and take screenshots.</p>
<p>That isn't the same as asking the agent to run <code>pnpm playwright test</code>. The test suite tells you whether the scenarios you already wrote down still pass. Browser review asks what this particular change could have affected and goes to look.</p>
<p>If an agent refactors some shared map-layer utilities, the tests might cover a few helpers. But from the diff, it can also work out that the code runs during initial render, selection, filtering, and reset, and try each of those. That's a lot closer to how I review my own frontend work.</p>
<p>Setup is small. You need Node 20 or newer and an MCP-capable client:</p>
<pre><code class="language-json">{
  "mcpServers": {
    "playwright": {
      "command": "npx",
      "args": ["@playwright/mcp@latest"]
    }
  }
}
</code></pre>
<p>A quick sanity check is to ask the agent to open the Playwright TodoMVC demo, add a todo called "Browser QA," and confirm it shows up. If it manages that, it has a browser. What it does with one is the harder part.</p>
<h2 id="heading-why-a-browser-on-its-own-falls-short">Why a Browser on Its Own Falls Short</h2>
<p>Giving an agent a browser only gives it the ability to look. It doesn't tell the agent what to look at, how far to go, or what counts as proof, and without that it falls back to the cheapest thing that technically involves a browser. An agent with Playwright can still do hilariously weak QA. I've had reports like this:</p>
<pre><code class="language-text">Opened /route.
Page loaded successfully.
Browser verification complete.
</code></pre>
<p>That's a report about opening a page, not about testing a change. Nothing in it would have caught any of the bugs from the start of this article.</p>
<p>So the browser needed instructions to go with it. What follows are the rules I wrote for how an agent should review its own frontend work: what to test, what to check, and what evidence to bring back. They started as a few lines I kept repeating in prompts, and they later became the browser-review skill you'll see in the next sections.</p>
<p>The first rule I settled on is that the review has to come from the change. If it changed a filter, use the filter. If it changed reset logic, change some state first and then reset it. If it touched persistence, refresh the page. If it changed a modal, open and close the modal, and if it changed responsive layout, resize the viewport.</p>
<p>That all sounds obvious when a person says it, but it isn't obvious at all if your agent instructions just say "verify that the app works".</p>
<p>In practice, this means the agent reads the diff and turns it into a short test plan before it touches the browser. For a change to a filter bar, the plan I'd expect looks something like this:</p>
<pre><code class="language-text">Changed: src/features/projects/FilterBar.tsx, src/features/projects/useFilters.ts

Plan:
1. Open /projects and note how many rows are showing.
2. Filter by Status: Active. Every row should say Active.
3. Add Owner: me on top. The list should narrow again.
4. Clear filters. The original row count should come back.
5. Apply a filter and refresh. The filter lives in the URL, so it should survive.
</code></pre>
<p>Then it carries out that plan through the browser tools Playwright MCP exposes. A trimmed version of the calls for the first few steps looks like this:</p>
<pre><code class="language-text">browser_navigate   { "url": "http://localhost:5173/projects" }
browser_snapshot   {}                      → table with 40 rows
browser_click      { "element": "Status filter", "ref": "e14" }
browser_click      { "element": "Active option", "ref": "e31" }
browser_snapshot   {}                      → table with 12 rows, all "Active"
browser_click      { "element": "Clear filters", "ref": "e15" }
browser_snapshot   {}                      → table with 40 rows
</code></pre>
<p>The <code>ref</code> values come from the accessibility snapshot, which is how the agent finds a control without guessing at CSS selectors.</p>
<p>This paid off on a PR that fixed a cursor getting stuck in its hover state on an interactive map. The PR's new unit tests passed against the old code too, so they didn't prove the fix did anything. What did prove it was the agent opening the app, moving on and off the interactive layers, and watching the cursor change and reset exactly when it should.</p>
<p>The second rule is to check the end result rather than the interaction. "I clicked Reset and nothing threw" doesn't tell you much. What you actually care about is that reset puts things back the way they were. Same with saving: the real claim is that you can change a setting, save, refresh, and it's still there.</p>
<p>Written as a Playwright test, that claim looks like this. The important line is the last one, which runs after the reload:</p>
<pre><code class="language-ts">import { test, expect } from "@playwright/test";

test("weekly digest setting survives a reload", async ({ page }) =&gt; {
  await page.goto("/settings/notifications");
  await page.getByRole("switch", { name: "Weekly digest" }).click();
  await page.getByRole("button", { name: "Save" }).click();

  await page.reload();

  await expect(page.getByRole("switch", { name: "Weekly digest" })).toBeChecked();
});
</code></pre>
<p>The agent doesn't need to write this test to follow the rule. Through MCP it does the same thing by hand: click, save, <code>browser_navigate</code> to the same URL, and read the switch's state from a fresh snapshot.</p>
<p>I also ask for different evidence depending on the question. Whether the URL changed, a button is disabled, or a dialog opened is a question for the DOM and browser state, not a screenshot. Whether that dialog is clipped, two things overlap, or text wraps badly on a phone is a visual question, and that's where screenshots are useful. Sometimes you need both, since a modal can sit happily in the DOM while being unusable on screen.</p>
<p>For a layout change, that means resizing and capturing what a person would actually see:</p>
<pre><code class="language-text">browser_resize            { "width": 375, "height": 812 }
browser_click             { "element": "Export button", "ref": "e22" }
browser_take_screenshot   { "filename": "export-dialog-375.png" }
browser_resize            { "width": 1440, "height": 900 }
browser_take_screenshot   { "filename": "export-dialog-1440.png" }
</code></pre>
<p>While it's in there, the agent may as well look at the console and, when the feature depends on an API call, the network. I only care about errors the change introduced, though. A warning that's been on <code>main</code> for six months shouldn't derail every review, and a client-only transition doesn't need a network audit. If a save that "worked" actually got a <code>500</code>, I want to hear about it.</p>
<p><code>browser_console_messages</code> and <code>browser_network_requests</code> return what the page logged and requested, so a failed save shows up as a line like this even when the UI looked fine:</p>
<pre><code class="language-text">[PUT] http://localhost:5173/api/settings =&gt; [500] Internal Server Error
</code></pre>
<p>Put together, the rules turn the weak report from the start of this section into something I can actually check:</p>
<pre><code class="language-text">Route: /settings/notifications
Did: turned on "Weekly digest", clicked Save, reloaded the page.
Checked: switch still on after reload (aria-checked="true");
  PUT /api/settings returned 200; no new console errors.
Screenshots: settings-375.png, settings-1440.png (label wraps cleanly on mobile)
Tests: pnpm test settings (14 passed)
Found and fixed: Save stayed disabled after the first save. Fixed in
  SettingsForm.tsx, then went through the flow again.
</code></pre>
<h2 id="heading-how-to-start-the-real-app">How to Start the Real App</h2>
<p>The agent can't test anything until the app is running, and this is where it's most tempting for it to take shortcuts.</p>
<p>I didn't want to write "run <code>pnpm dev</code>" into every repository, so the procedure tells the agent to work out how the project starts. It checks the <code>dev</code> script in <code>package.json</code> first, then whatever the README documents, and only falls back to an existing Docker setup if the app can't reasonably run otherwise.</p>
<p>The procedure here is the "Start the application" section of the browser-review skill, which you'll see in full in the next section. In a typical Vite or Next.js project, following it looks something like this:</p>
<pre><code class="language-bash"># 1. How does this project start?
jq .scripts package.json
# {
#   "dev": "vite",
#   "build": "vite build",
#   "test": "vitest run",
#   "e2e": "playwright test"
# }

# 2. Start the dev server in the background, in a temp folder the agent owns,
#    and record the PID so it can stop exactly this process later.
QA_DIR=$(mktemp -d)
pnpm dev --port 5173 &gt; "$QA_DIR/dev.log" 2&gt;&amp;1 &amp;
echo $! &gt; "$QA_DIR/dev.pid"

# 3. Wait until the server answers before opening a browser.
until curl -sf http://localhost:5173 &gt; /dev/null; do sleep 1; done
</code></pre>
<p>If there's no <code>dev</code> script, the README is next, and if the README says the app needs Postgres and Redis from <code>docker compose up</code>, the agent uses that compose file and records the containers it started. It doesn't write a new one.</p>
<p>What I really don't want is the agent changing the app to make it easier to test. No temporary auth bypass, no fake server, no edits to <code>.env</code>, and no throwaway route that exists only because it couldn't work out the real setup. The point is to exercise the software I'm actually shipping.</p>
<p>This is the kind of change I mean, and agents really do reach for it when a login page is in the way:</p>
<pre><code class="language-diff">- if (!session) redirect("/login");
+ if (!session &amp;&amp; process.env.NODE_ENV !== "development") redirect("/login");
</code></pre>
<p>If the agent can't get past authentication with the project's documented test account or seed data, the right move is to stop and report that browser review is blocked and why. A blocked review is honest. A review of an app with its auth switched off isn't a review of my app.</p>
<h2 id="heading-where-the-instructions-live">Where the Instructions Live</h2>
<p>My first instinct was to keep adding to <code>AGENTS.md</code>. It already held the rules every agent in the repo should know: structure, conventions, testing philosophy, keep changes surgical, and no pointless comments.</p>
<p>I was about to add another hundred lines about starting the app, browser setup, what to click, screenshots, console errors and teardown...but at some point that file stops being instructions and turns into a small religious text.</p>
<p>It helps to know how each of these files is actually used. <code>AGENTS.md</code> is a plain Markdown file that coding agents such as Codex read automatically at the start of every session in that repository, and Claude Code does the same with <code>CLAUDE.md</code>. Everything in it lands in the agent's context for every task, whether the task is a CSS fix or a database migration. So it costs context on every run, and it applies everywhere.</p>
<p>A skill works differently. It's a folder with a <code>SKILL.md</code> file that starts with a short <code>name</code> and <code>description</code>. At the start of a session the agent only sees that name and description, a line or two per skill. When a task matches the description, or another instruction points at the skill, the agent reads the rest of the file and follows it. So you can have a long, detailed procedure that costs almost nothing until it's needed.</p>
<p>What I landed on is treating <code>AGENTS.md</code> as the constitution and skills as the playbooks. A constitution is short, applies to everything, and says what must be true without explaining how to do it. A playbook is a step-by-step procedure for one kind of situation, and you only pull it out when you're in that situation.</p>
<p>In this setup, <code>AGENTS.md</code> says that user-visible work isn't done until it's been checked in a browser, and the browser-review skill says how to do the checking:</p>
<pre><code class="language-text">/
├── AGENTS.md
└── .agents/
    └── skills/
        ├── browser-review/
        │   └── SKILL.md
        ├── pr-review/
        │   └── SKILL.md
        └── grill-me/
            └── SKILL.md
</code></pre>
<p>The rule in <code>AGENTS.md</code> stays short and always visible:</p>
<pre><code class="language-md">## Browser Verification

User-visible work is not complete until the affected behavior has been
verified in the running application.

For user-visible changes, follow `.agents/skills/browser-review/SKILL.md`.

A successful build, type check, unit test suite, or E2E test suite does
not replace browser verification.

Do not commit browser-verification artifacts. Stop anything you started
for verification and clean up before hand-off.
</code></pre>
<p>The skill holds the procedure and only gets loaded when it's needed. I use Codex and Claude Code, so there's one global <code>AGENTS.md</code> that Claude's <code>CLAUDE.md</code> imports, and one shared skills folder both of them read. Whichever agent I'm in that day, it follows the same playbook.</p>
<p>The import is a single line, because Claude Code lets <code>CLAUDE.md</code> pull in another file with <code>@</code>:</p>
<pre><code class="language-md">@~/.codex/AGENTS.md
</code></pre>
<p>Here's a shortened version of the one I first wrote:</p>
<pre><code class="language-md">---
name: browser-review
description: Verify user-visible changes in the running application with Playwright MCP.
---

# Browser Review

## Before verification

Run `git status --short` and remember what was already there. Don't touch
pre-existing work.

Read the task and the diff. Work out the affected routes, the changed flow,
the loading, empty, error, selected and disabled states that matter,
whether anything needs to survive a refresh, and what nearby behavior
could regress.

## Start the application

Use the repository's dev script, then its documented local setup, then its
existing Docker setup. Don't invent a new startup config and don't change
app behavior to make QA easier. Record every process and container you start.

## Exercise the change

Don't stop once the page loads. Go through the whole changed flow and check
the final result. Use browser or DOM state for functional claims and
screenshots for visual ones. Report new relevant console errors and failed
requests, not old warnings.

If something is broken, fix the smallest cause, rerun the affected tests
and go through the flow again. Don't assume the fix works.

## Cleanup

Teardown is required even when verification fails. Close browsers, stop
servers and containers, and delete screenshots, traces and other artifacts,
but only the ones you created. Run `git status --short` again before
hand-off. Never run `git add`, `git commit` or `git push`.

## Report

Say which routes you tested, what you did, which states you checked, what
tests you ran and what you found and fixed. If you couldn't finish, say why.
</code></pre>
<p>None of that is clever. The value is that it's the default expectation, so I'm not relying on the agent to decide by itself that it should open a browser.</p>
<h2 id="heading-why-the-reviewer-should-work-independently">Why the Reviewer Should Work Independently</h2>
<p>The clear weakness is that the agent deciding whether the work is done is the same one that wrote it. That's still worth something, since I test my own code too, but it starts with a theory of how its implementation works.</p>
<p>By theory I mean the author's mental model of its own change: which code it touched, what it believes that code affects, and, just as important, what it believes it didn't affect. Its test plan comes straight from that model. So it tests the paths it meant to change, they work, and it never looks at the path it didn't realize was involved. That's the same blind spot people have when they test their own code, and it's exactly where the bugs that survive review tend to live.</p>
<p>So when a second agent reviews a branch, I tell it to work out the test scope for itself. My global rules now require a fresh reviewer with no conversation history: it gets the original request, the diff, and the review skill, and nothing about why the author thinks it works. It decides what the code could affect and tests that, without leaning on the author's screenshots, QA notes, or green test run.</p>
<p>The prompt for that reviewer is short on purpose:</p>
<pre><code class="language-text">You are reviewing a branch you did not write.

Original request:
  Make the E2E app URL configurable through E2E_BASE_URL so the
  tests can run against any port.

Run `git diff main...HEAD` to see the change. Follow the browser-review
skill. Work out for yourself what this change could affect, test that,
and report what you checked with evidence.
</code></pre>
<p>The best catch so far came from a PR that made an end-to-end test setup's app URL configurable. The reviewer noticed the seeded test data still hardcoded image URLs to the old port, and running the app on a different port confirmed it. Three image requests went to the wrong port and failed, and the page quietly rendered fallback cards where the images should have been. Nothing threw.</p>
<p>Simplified, the global setup looked like this after the change:</p>
<pre><code class="language-ts">// e2e/global-setup.ts
const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:8080";

await seedDatabase({
  media: [
    { id: "m1", url: "http://localhost:8080/test-media/cover-1.jpg" },
    { id: "m2", url: "http://localhost:8080/test-media/cover-2.jpg" },
    { id: "m3", url: "http://localhost:8080/test-media/cover-3.jpg" },
  ],
});
</code></pre>
<p>The author's theory was "the app URL now comes from <code>E2E_BASE_URL</code>", and every test it ran used the default port, where the hardcoded URLs happened to be right. The reviewer had no such theory. It read the whole setup file the diff touched, ran the app on port 8081, and came back with this:</p>
<pre><code class="language-text">Found: seeded media URLs in e2e/global-setup.ts still point at :8080.
Ran the app with E2E_BASE_URL=http://localhost:8081.
Network: 3 requests to http://localhost:8080/test-media/... failed
  (net::ERR_CONNECTION_REFUSED).
Screen: the gallery rendered 3 fallback cards instead of images
  (gallery-8081.png).
Suggested fix: build the seeded URLs from E2E_BASE_URL, or make them
  relative.
</code></pre>
<p>The fix was one line per URL:</p>
<pre><code class="language-ts">{ id: "m1", url: new URL("/test-media/cover-1.jpg", baseURL).href },
</code></pre>
<p>A test that checks the cards exist passes either way, because a fallback card is still a card. You only see it by looking at the page and the network tab, which is the whole argument of this article in one bug.</p>
<h2 id="heading-clean-up-after-yourself-and-only-yourself">Clean Up After Yourself and Only Yourself</h2>
<p>Once agents can start servers and browsers, cleanup is part of the design. Otherwise every session leaves a layer behind: a stray node process, a Chromium window, screenshots, a Playwright trace, a temporary auth file, or a Docker container.</p>
<p>"Clean everything" is the wrong fix, though. <code>pkill node</code> isn't cleanup, it's an agent going on a small rampage, and it'll happily kill the dev server you started in another terminal. <code>git clean -fd</code> will delete an untracked file that has nothing to do with the agent.</p>
<p>The rule is to clean up what you created and leave everything else alone, and the <code>git status</code> before and after is there so browser QA doesn't quietly end up in my next commit.</p>
<p>This is why the startup step records a PID in a temp folder. Cleanup can then be precise:</p>
<pre><code class="language-bash"># Not cleanup
pkill node
git clean -fd

# Cleanup
kill "$(cat "$QA_DIR/dev.pid")"
rm -rf "$QA_DIR"
</code></pre>
<p>And this is roughly what I expect to see at the end of a review:</p>
<pre><code class="language-text">Cleanup
- Stopped the dev server I started (pid 48213, pnpm dev --port 5173).
- Closed the Playwright MCP browser.
- Deleted export-dialog-375.png and export-dialog-1440.png.
- Left running: pid 39102 (pnpm dev on :3000). It was already running
  before I started, so it isn't mine.

git status --short before review:
 M src/features/export/ExportDialog.tsx
?? notes/todo.md

git status --short after cleanup:
 M src/features/export/ExportDialog.tsx
?? notes/todo.md
</code></pre>
<p>The two <code>git status</code> blocks match, so nothing from the review leaked into the working tree, and the untracked notes file that was already there is still there.</p>
<p>I didn't make this rule up in the abstract, either. While building the tool I'm about to describe, the agent testing it ran a cleanup command that closed every Playwright browser on my machine, not just its own. To be fair, it owned up to it in its summary.</p>
<h2 id="heading-when-a-skill-stopped-being-enough">When a Skill Stopped Being Enough</h2>
<p>The skill worked, in the sense that agents started actually opening the app. But what I got back was still prose. The agent would tell me it opened the page, clicked through the flow and everything looked right, and I had to take its word for it.</p>
<p>It's the same problem I started with, just one level up. I didn't want a nicer sentence about the browser. I wanted the evidence: what was checked, what passed, what failed, and the screenshots and traces to back it up, in a form I could drop into a PR for whoever reviews it.</p>
<p>So I wrote a build plan and spent a Saturday building it, pairing with Claude Code through milestones and independent review rounds. It's called <a href="https://www.katalepsis.glamboyosa.xyz">katalepsis</a>, the Stoic word for grasping something so clearly you can't be wrong about it, which felt like a fair bar for a pull request.</p>
<p>You point it at a preview URL and it writes a report: HTML, Markdown you can paste into the PR, and a JSON manifest.</p>
<p>It does two things. <code>katalepsis run</code> runs your existing Playwright tests against the preview, and Playwright's result is final. katalepsis sorts, redacts and bundles it, and never reinterprets an assertion.</p>
<p><code>katalepsis verify</code> takes plain-English checks for behavior nobody has written a test for, and a model drives a real browser with your own Anthropic, OpenAI, or OpenRouter key. There's also an MCP server, so the agent that just made a change can run either one itself and read the report back.</p>
<h2 id="heading-how-to-try-katalepsis">How to Try katalepsis</h2>
<p>katalepsis isn't on npm yet, so the installer clones the repository and builds it. It needs Node 22.12 or newer, Git, and pnpm 10 (or corepack, which ships with Node):</p>
<pre><code class="language-bash">curl -fsSL https://www.katalepsis.glamboyosa.xyz/install.sh | sh
</code></pre>
<p>That puts two launchers, <code>katalepsis</code> and <code>katalepsis-mcp</code>, in <code>~/.local/bin</code>.</p>
<p>To run your existing tests against a preview, your Playwright config has to use the URL katalepsis passes in. It's also worth turning on failure evidence so the report has something to show:</p>
<pre><code class="language-ts">// playwright.config.ts
use: {
  baseURL: process.env.KATALEPSIS_BASE_URL ?? "http://localhost:3000",
  screenshot: "only-on-failure",
  video: "retain-on-failure",
  trace: "retain-on-failure",
},
</code></pre>
<p>The repository includes a small demo app, a signup form with a deliberate bug: submitting the empty form does nothing instead of showing an error. It has three Playwright tests. Here's a real run against it:</p>
<pre><code class="language-text">$ katalepsis run --cwd apps/demo-form --url http://127.0.0.1:4173
katalepsis: FAILED
  2 passed, 1 failed, 0 blocked, 0 not tested
  Preview commit: not reported, so unconfirmed
  Report: artifacts/katalepsis/2026-10-19T08-21-25-288Z-3dba5a/report.html
  Markdown: artifacts/katalepsis/2026-10-19T08-21-25-288Z-3dba5a/report.md
</code></pre>
<p>The Markdown report is meant to be pasted into a PR. The top of it looks like this:</p>
<pre><code class="language-md"># katalepsis report: Failed

**2 passed, 1 failed, 0 blocked** out of 3 checks run.

- Preview: http://127.0.0.1:4173/
- Preview commit: not reported, so unconfirmed
- Runner: katalepsis 0.1.0, Playwright 1.63.0, Node 22.22.0

## Checks

| Status | Check | Project | Location | Duration |
| --- | --- | --- | --- | --- |
| Failed | signup form › empty submission shows a required-field error | chromium | signup.spec.ts:8 | 3.2 s |
| Passed | signup form › valid submission shows a confirmation | chromium | signup.spec.ts:13 | 418 ms |
| Passed | signup form › malformed email shows an email error and does not submit | chromium | signup.spec.ts:20 | 449 ms |

### Failed: signup form › empty submission shows a required-field error

- Failure type: assertion
- Evidence: screenshot, video, log, trace
</code></pre>
<p>Below that, it includes Playwright's own failure message (<code>getByRole('alert')</code> never appeared) and a list of limitations, such as the preview not reporting which commit it was built from.</p>
<p>For behavior without a test, you write checks in plain English. Each one has an intent, which is what to do, and a pass condition, which is what must be true at the end:</p>
<pre><code class="language-json">{
  "checks": [
    {
      "id": "empty-form",
      "name": "Empty form validation",
      "intent": "Submit the signup form without filling it",
      "passCondition": "A required-field error is visible"
    },
    {
      "id": "invalid-email",
      "name": "Invalid email",
      "intent": "Fill the name, enter an invalid email address and submit",
      "passCondition": "An email validation error is visible and the success confirmation is not shown"
    }
  ]
}
</code></pre>
<p>Then you give katalepsis a key and run them. The key is stored in your OS keychain or read from an environment variable, and it's never passed on the command line:</p>
<pre><code class="language-text">$ katalepsis auth set openrouter
$ katalepsis verify --cwd apps/demo-form --url http://127.0.0.1:4173 --checks checks.json \
    --provider openrouter --model anthropic/claude-haiku-5.5
katalepsis: FAILED (openrouter/anthropic/claude-haiku-5.5)
  2 passed, 1 failed, 0 blocked
  Preview commit: not reported, so unconfirmed
  Report: artifacts/katalepsis/2026-10-10T12-45-05-343Z-b51a0a/report.html
</code></pre>
<p>The demo's full <code>checks.json</code> has three checks, and the agent checks find the same empty-form bug the Playwright test did, without anyone having written that test.</p>
<p>Finally, so the coding agent can do all of this itself, you register the MCP server. With Codex, that's:</p>
<pre><code class="language-bash">codex mcp add katalepsis -- katalepsis-mcp --root /path/to/your-app
</code></pre>
<p>The server exposes three tools, <code>verification_run_tests</code>, <code>verification_check_browser</code>, and <code>verification_get_report</code>, and only works inside the folder you give it. In practice the agent's instruction becomes something like "after changing the signup form, run <code>verification_check_browser</code> with a check for the change and paste the Markdown report into your summary."</p>
<h2 id="heading-how-katalepsis-works-under-the-hood">How katalepsis Works Under the Hood</h2>
<p>A few of the decisions came straight out of the problems in this article. Here's how they work in the code.</p>
<p>katalepsis is a TypeScript monorepo with five packages:</p>
<ul>
<li><p><code>core</code> (the result schema, the Playwright runner, and the report renderers)</p>
</li>
<li><p><code>agent</code> (the browser tools and evidence rules for <code>verify</code>)</p>
</li>
<li><p><code>credentials</code> (keys and preferences)</p>
</li>
<li><p><code>mcp</code> (the MCP server)</p>
</li>
<li><p><code>cli</code></p>
</li>
</ul>
<p>It uses Effect for the parts where errors, cancellation, and dependencies matter, such as running Playwright and probing the preview, and Effect Schema for the report format. The model loop in <code>verify</code> uses the Vercel AI SDK, so the same code works with Anthropic, OpenAI, and OpenRouter.</p>
<h3 id="heading-the-report-is-a-schema-not-a-log">The Report is a Schema, Not a Log</h3>
<p>Every run, whether it came from Playwright or from a model, decodes into the same <code>VerificationRun</code> structure before anything is written. A trimmed version of a single check looks like this:</p>
<pre><code class="language-ts">export const CheckStatus = Schema.Literals(["passed", "failed", "blocked"]);

export const VerificationCheck = Schema.Struct({
  id: Schema.String,
  source: Schema.Literals(["playwright-test", "agent"]),
  title: Schema.String,
  status: CheckStatus,
  flaky: Schema.Boolean,
  failure: Schema.NullOr(Schema.Struct({ code: FailureCode, message: Schema.String })),
  attempts: Schema.Array(Attempt),
  evidence: Schema.Array(BundlePath),
});
</code></pre>
<p>Tests that never ran go into a separate <code>notTested</code> list with a reason, which is how you end up with four outcomes: passed, failed, blocked, or not tested. Every evidence file is stored with its SHA-256 hash, and <code>BundlePath</code> rejects absolute paths and <code>..</code>, so a report can't point outside its own folder.</p>
<h3 id="heading-blocked-isnt-failed">Blocked isn't Failed</h3>
<p>If the preview is down, you get one blocked run, not three failed tests that send you hunting for bugs that don't exist. Before starting Playwright, katalepsis requests the preview URL once. This is the core of that probe:</p>
<pre><code class="language-ts">const UNAVAILABLE_STATUSES = new Set([502, 503, 504]);

const response = await fetch(url, {
  redirect: "manual",
  signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]),
});
if (UNAVAILABLE_STATUSES.has(response.status)) {
  return { reachable: false, reason: `the preview answered with HTTP ${response.status}` };
}
</code></pre>
<p>A network error or a gateway status blocks every selected test, and Playwright never starts. Pointing the demo at a port with nothing on it gives you this:</p>
<pre><code class="language-text">$ katalepsis run --cwd apps/demo-form --url http://127.0.0.1:4999
katalepsis: BLOCKED
  0 passed, 0 failed, 3 blocked, 0 not tested
  preview-unavailable: http://127.0.0.1:4999/ did not respond (connect ECONNREFUSED 127.0.0.1:4999). Playwright was not run.
</code></pre>
<h3 id="heading-playwrights-verdict-is-final">Playwright's Verdict is Final</h3>
<p><code>run</code> starts the project's own Playwright with <code>--reporter=json</code> and reads the result file, rather than reimplementing anything. Playwright already marks a test that passed on retry as <code>flaky</code>, so katalepsis keeps that flag and lists every attempt.</p>
<p>What it does add is classification. A failure whose message names the preview's own origin as unreachable becomes blocked, but a dead third-party API stays failed, because that's a real problem with your app. A browser that couldn't launch is blocked too, which is exactly what I got the first time I ran the demo for this article on a machine with the wrong browser build installed.</p>
<h3 id="heading-the-model-doesnt-get-to-call-something-a-pass">The Model Doesn't Get to Call Something a Pass</h3>
<p>The model in <code>verify</code> can click and type and read the page, but it doesn't get to call something a pass. A check only passes if the model points to a browser assertion that katalepsis ran, that passed, and that shows something that wasn't on the page before the model acted. Otherwise it comes back blocked.</p>
<p>Here's how that works. The model only gets a small set of tools: <code>navigate</code>, <code>snapshot</code>, <code>click</code>, <code>fill</code>, <code>press</code>, <code>assert</code>, and <code>finish</code>. <code>assert</code> doesn't return the model's opinion. katalepsis runs the check in the browser itself, takes a screenshot, and records the result with an ID like <code>a3</code>. When the model calls <code>finish</code>, it has to cite those IDs:</p>
<pre><code class="language-ts">finish: tool({
  description: "Report the verdict once, citing assertion IDs such as a1.",
  inputSchema: schema(
    Schema.Struct({
      verdict: Schema.Literals(["passed", "failed", "blocked"]),
      summary: Schema.String,
      assertionIds: Schema.Array(Schema.String),
    }),
  ),
  execute: (verdict) =&gt; {
    onFinish(verdict);
    return "Recorded.";
  },
}),
</code></pre>
<p>Then a plain function, <code>decideOutcome</code>, compares the claim with the evidence. A trimmed version of the pass rule:</p>
<pre><code class="language-ts">if (!cited.every((assertion) =&gt; assertion.passed)) {
  return "The agent reported a pass, but a cited assertion failed.";
}
const shown = cited.filter((assertion) =&gt; assertion.expectation === "visible");
if (shown.length === 0) {
  return "The agent reported a pass citing only hidden or URL assertions.";
}
if (!observeOnly &amp;&amp; shown.every((assertion) =&gt; assertion.trueAtStart === true)) {
  return "The agent reported a pass, but everything it cited was already on the page before it acted.";
}
</code></pre>
<p>Any of those messages turns the check into blocked with the code <code>no-evidence</code>. Citing an assertion ID that never ran is blocked too. The function also blocks a pass if an uncited assertion failed after the last cited one, because the latest evidence disagrees with the verdict.</p>
<p>That <code>trueAtStart</code> condition exists because one of the models I tested tried to prove a form worked by citing the label of the email field, which had been there all along. T</p>
<p>o catch it, each check opens two copies of the start page in separate browser contexts. The model works in one, and the other stays untouched. When the model asserts that something is visible, katalepsis looks for the same element on the untouched copy, and if it's there too, the assertion gets <code>trueAtStart: true</code> and can't carry a pass on its own.</p>
<h3 id="heading-one-action-at-a-time">One Action at a Time</h3>
<p>The first live runs also caught parallel tool calls stepping on each other, and a name ending up typed into the email field. Models like to send several tool calls at once, and two <code>fill</code> calls racing each other type into whichever field has focus. The fix is a promise queue that every browser action goes through:</p>
<pre><code class="language-ts">private exclusive&lt;A&gt;(action: () =&gt; Promise&lt;A&gt;): Promise&lt;A&gt; {
  const next = this.queue.then(action, action);
  this.queue = next.catch(() =&gt; undefined);
  return next;
}
</code></pre>
<p>Each action waits for the previous one to settle, whether it succeeded or failed, and a failure doesn't poison the queue for the next action.</p>
<h3 id="heading-budgets-and-boundaries">Budgets and Boundaries</h3>
<p>Each agent check runs in its own browser context with tracing on, so the report always has a trace to replay. Navigation is limited to the preview's origin plus anything you allow with <code>--allow-origin</code>. The model has a step limit (<code>--max-steps</code>, 15 by default), a token limit (<code>--max-tokens</code>, 200,000), and a time limit (<code>--check-timeout</code>, 120 seconds), and running out of any of them is reported as blocked with the reason, not as a failure. Query strings are redacted from URLs in the report.</p>
<h3 id="heading-how-it-was-built">How it Was Built</h3>
<p>Before writing code, I wrote a build plan split into milestones. M0 was a review of how two of my earlier projects handled credentials and providers, plus the first architecture decision record and the monorepo tooling. M1 was the Playwright runner and the report, M2 was agent checks with your own key, and M3 was the MCP server. Each milestone ended with a fresh agent with no history reviewing the work against the plan, the same independent-reviewer rule from earlier in this article.</p>
<p>Those reviews shaped the tool more than the plan did. The first one came back with 15 findings, including the rule that only an unreachable preview counts as blocked while other hosts stay failed. The M2 review found that a model could get a pass by citing an assertion that proved nothing, which is where the stricter pass rule and the untouched copy of the page came from. The M3 review found that a client quitting mid-run left Playwright and its browsers running, and that a crafted symlink could let the report tool read files outside the project. All of those are fixed and covered by tests now.</p>
<h2 id="heading-what-happened-when-i-used-it">What Happened When I Used It</h2>
<p>Before telling anyone about it, I ran it against two of my own projects. Conclave's suite came back with 95 passed. A larger app came back with 137 passed and one failure, an assertion on a zoom level that was slightly off.</p>
<p>That was a test I already had and hadn't noticed was failing, so katalepsis didn't find the bug so much as make it impossible to miss. Both projects also hardcoded their <code>baseURL</code>, which meant their tests never touched the URL I passed in. katalepsis now warns about that, because a green run against the wrong server is worse than no run.</p>
<p>After I posted it on LinkedIn, someone in the comments pointed out that the commit you have checked out isn't necessarily the commit the preview was built from. They were right, so the report now records both and marks them confirmed, mismatched, or unknown, and a mismatch blocks a run that would otherwise pass, since a green run against a stale preview tells you nothing about your diff.</p>
<p>The preview reports its commit with an <code>x-commit-sha</code> response header or a <code>&lt;meta name="commit-sha"&gt;</code> tag, which the same probe that checks availability reads. If your host can't do either, you can pass <code>--preview-commit</code> yourself.</p>
<p>I'm dogfooding it in the same instructions this article describes. The browser-review skill now tells my agents to run katalepsis first, and to fall back to plain browser automation for what it can't check yet: field values, persistence, console and network, and layout across viewports.</p>
<p>The first Codex session to pick that up went looking for a "katalepsis skill" because of how I'd worded the line in <code>AGENTS.md</code>, didn't find one, and fell back to its old browser review. Wrong for a silly reason, but the fallback did exactly what it was supposed to, and the fix was saying plainly that katalepsis is a CLI and browser-review is the skill.</p>
<p>A few days later I used it on a real work branch, a change that made GeoJSON the main download button and moved CSV and KML into a dropdown. The agent stopped before running anything, because <code>verify</code> sends page content to the model provider and my rules say it needs my OK before doing that for an app. That's the behavior I want</p>
<p>With seeded synthetic data and an explicit OK, the check passed: one visible primary button, and the other formats showing up once the dropdown opened, with the screenshots and trace in the report. The skill still keeps pages with real data on the fallback path.</p>
<h2 id="heading-where-i-landed">Where I Landed</h2>
<p>Not every task needs any of this. I don't need Playwright to confirm a Markdown typo got fixed, and a backend refactor that can't change what renders doesn't need Chromium. I do want it for components, forms, navigation, dialogs, client state, maps, filtering, responsive layout, persistence, and any user-visible bug fix.</p>
<p>My rule of thumb is that if you're making a claim about how the UI behaves at runtime, the runtime UI should be part of the evidence.</p>
<p>None of this replaces good tests, code review, or me looking at changes that matter. It just adds the feedback loop that was missing. The agent already has the repository and the shell, and it knows what it changed. With a browser it can start answering whether the thing it built actually works, and with a report it can show me instead of telling me. If it can write the button, it can click it and show me what happened before it says it's done.</p>
<p>katalepsis is MIT licensed and on <a href="https://github.com/glamboyosa/katalepsis">GitHub</a>. If an agent check ever gives you a result you don't believe, I'd like to hear about it.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How I Tested Malaysia's Open Data Portals with Plain English ]]>
                </title>
                <description>
                    <![CDATA[ Most end-to-end test suites drive a real browser and click through an app like a user. They check whether a page renders and whether elements appear. But they don't check whether the numbers on those  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-i-tested-malaysia-s-open-data-portals-with-plain-english/</link>
                <guid isPermaLink="false">69eaad32904b915438ce46f9</guid>
                
                    <category>
                        <![CDATA[ postmark ]]>
                    </category>
                
                    <category>
                        <![CDATA[ playwright ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Testing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation testing  ]]>
                    </category>
                
                    <category>
                        <![CDATA[ breakingappshackathon ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Tech With RJ ]]>
                </dc:creator>
                <pubDate>Thu, 23 Apr 2026 23:37:22 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/605584805f8d5121697263ca/d4859bd4-15d5-4bb7-ba9e-d4693c90163d.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most end-to-end test suites drive a real browser and click through an app like a user. They check whether a page renders and whether elements appear.</p>
<p>But they don't check whether the numbers on those elements are correct. A data-pipeline bug that shows Malaysia's population as 3.4 million instead of the real 34 million slips past every selector test in the suite.</p>
<p>The element still exists. A number still renders. The page still looks right. But the bug ships and sits there until a human notices.</p>
<p>I work as a full-stack engineer. Writing end-to-end (E2E) tests with <a href="https://playwright.dev">Playwright</a> and unit tests with <a href="https://jestjs.io">Jest</a> is part of my day job. I also use <a href="https://github.com/microsoft/playwright-mcp">Playwright MCP</a>, the bridge between AI assistants like Claude and a running browser, when I need to generate first-draft test code or debug a flow.</p>
<p>None of that tooling closes the maintenance tax on selector-based suites. Every E2E suite I keep alive at work accumulates <code>data-testid</code> selectors, <code>waitForSelector</code> calls, and tests that break because someone renamed a button.</p>
<p>Bug0's <a href="https://hashnode.com/hackathons/breaking-things">Breaking Apps Hackathon</a> gave me a pretext to try something different. Over a weekend, <a href="https://github.com/LeeRenJie/passmark-hackathon">I built an automated regression suite</a> for Malaysia's three public open data portals, <a href="https://data.gov.my">data.gov.my</a>, <a href="https://open.dosm.gov.my">OpenDOSM</a>, and <a href="https://data.moh.gov.my">KKMNow</a>, using <a href="https://github.com/bug0inc/passmark">Passmark</a>, Bug0's open-source AI-driven Playwright library.</p>
<p>The tests are written in plain English. Two AI models verify each assertion. A third arbitrates disagreements.</p>
<h3 id="heading-what-youll-find-below">What You'll Find Below:</h3>
<ul>
<li><p>How to write an E2E test that checks whether a dashboard's numbers are correct, not only whether the page renders</p>
</li>
<li><p>A specific assertion pattern (range-bounded KPIs) that catches an entire class of data-pipeline bug that selector tests miss, with working examples ready to copy</p>
</li>
<li><p>A cross-field math assertion that takes one sentence in Passmark and around a hundred lines of code without it</p>
</li>
<li><p>How Passmark's own failure explanations became my debugging loop (the single biggest shift in how I'll write E2E tests going forward)</p>
</li>
<li><p>The real limits: a 14% cache-hit rate, a dependency on OpenRouter, and what two-model voting fails to catch</p>
</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-why-malaysias-open-data-portals">Why Malaysia's Open Data Portals</a>?</p>
</li>
<li><p><a href="#heading-what-is-passmark">What Is Passmark</a>?</p>
</li>
<li><p><a href="#heading-the-hero-spec-range-bounded-assertions">The Hero Spec: Range-Bounded Assertions</a></p>
<ul>
<li><a href="#heading-what-two-model-voting-doesnt-catch">What Two-Model Voting Doesn't Catch</a></li>
</ul>
</li>
<li><p><a href="#heading-going-further-cross-field-math">Going Further: Cross-Field Math</a></p>
</li>
<li><p><a href="#heading-what-i-found-across-three-runs">What I Found Across Three Runs</a></p>
<ul>
<li><p><a href="#heading-the-debugging-loop">The Debugging Loop</a></p>
</li>
<li><p><a href="#heading-the-two-specs-that-still-fail-are-the-most-interesting">The Two Specs That Still Fail Are the Most Interesting</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-what-it-cost-and-why-cache-rate-is-cost-rate">What It Cost, and Why Cache Rate Is Cost Rate</a></p>
</li>
<li><p><a href="#heading-the-pattern-worth-stealing">The Pattern Worth Stealing</a></p>
</li>
<li><p><a href="#heading-honest-verdict">Honest Verdict</a></p>
</li>
<li><p><a href="#heading-resources">Resources</a></p>
</li>
</ul>
<h2 id="heading-why-malaysias-open-data-portals">Why Malaysia's Open Data Portals?</h2>
<p>The hackathon suggested targets like Vercel Commerce, Cal.com, and Hashnode. These all would've been solid picks.</p>
<p>But I wanted to test something local and closer to my day-to-day work instead. I also wanted a data-heavy site where the numbers on screen have to be accurate, as I work with numbers too on a daily basis.</p>
<p>Malaysia has three public open-data portals:</p>
<ul>
<li><p><a href="https://data.gov.my">data.gov.my</a>, run by MAMPU, the government's digital transformation agency</p>
</li>
<li><p><a href="https://open.dosm.gov.my">OpenDOSM</a>, run by the Department of Statistics</p>
</li>
<li><p><a href="https://data.moh.gov.my">KKMNow</a>, run by the Ministry of Health</p>
</li>
</ul>
<p>They're public, no authentication required, with documented APIs. Seemed like a good fit for an automated test suite. The data on them is what Malaysians use every day, so accuracy isn't optional.</p>
<h2 id="heading-what-is-passmark">What Is Passmark?</h2>
<p>Passmark is a Playwright library where the tests read like specs. Here's an example:</p>
<pre><code class="language-typescript">await runSteps({
  page,
  userFlow: "population dashboard smoke",
  steps: [
    { description: "Navigate to https://data.gov.my/dashboard/kawasanku" },
    {
      description: "Wait for the country-level Malaysia view to render",
      waitUntil: "A headline population number is visible",
    },
  ],
  assertions: [
    {
      assertion:
        "The page shows Malaysia's total population as a number greater than 20 million and less than 40 million",
    },
  ],
  test,
  expect,
});
</code></pre>
<p>There are no selectors, no <code>data-testid</code>, and no <code>page.locator()</code>. The assertion expresses what I care about, in the words I would use with a colleague.</p>
<p>On the first run, an AI agent drives the page and caches the resolved Playwright action to Redis. Every run after that replays at native Playwright speed with zero model calls.</p>
<p>When the UI changes and a cached action fails, the AI re-engages only for that step. Two assertion models (Claude and Gemini) vote. A third model arbitrates disagreements.</p>
<h2 id="heading-the-hero-spec-range-bounded-assertions">The Hero Spec: Range-Bounded Assertions</h2>
<p>Range-bounded assertions were the first shape of test I wrote, and the one I came back to most across the suite.</p>
<p>The idea is straightforward: check that a number on the page falls inside a sensible range, not that a specific element exists.</p>
<p>The image below is the Playwright report from the population spec, with all four range-bounded assertions passing.</p>
<img src="https://cdn.hashnode.com/uploads/covers/605584805f8d5121697263ca/4a5f70e6-8a75-489b-8d0d-7d4226653b1a.png" alt="Playwright HTML report detail for the population spec. Passmark's annotation reads: &quot;Total Population (2025) with a value of 34.2 million, which is between 20 million and 40 million.&quot; All four range-bounded assertions pass." style="display: block;" width="1019" height="1569" loading="lazy">

<p>The range-bounded population test is the one that shows Passmark's real value.</p>
<p>Traditional Playwright asserts DOM structure. It confirms that an element with class <code>kpi-total</code> contains the text <code>34.2 million</code>. That tells you the page rendered, not whether the number makes sense.</p>
<p>A bug that shows Malaysia's population as <code>3.42 million</code> sails past any selector test. The DOM is correct. The number renders. Nothing breaks in the conventional sense.</p>
<p>Passmark reads the page, evaluates the claim, and fails because <code>3.42 million</code> falls outside the sane range. Two models vote. A hallucination by one model alone produces no false pass.</p>
<h3 id="heading-what-two-model-voting-doesnt-catch">What Two-Model Voting Doesn't Catch</h3>
<p>Voting defends against one model misreading the page. It doesn't defend against both models misreading the page the same way. If Claude and Gemini both parse "32.4 million" as "3.24 million" because of the same unusual spacing in the DOM, they agree, they vote pass, and the bug ships.</p>
<p>The mitigation is assertion design. Write assertions that are hard to misread. A range check ("between 20 million and 40 million") is harder for a model to get wrong than a prose check ("roughly 34 million"). Numerical bounds leave less room for interpretation than adjectives. The more your assertion looks like a unit test written in English, the less room the models have to disagree.</p>
<h2 id="heading-going-further-cross-field-math">Going Further: Cross-Field Math</h2>
<p>Range-bounded assertions are a good first step. They catch "is this number in the right ballpark?" But they don't catch "do these numbers agree with each other?"</p>
<p>For that, you need cross-field math. If a dashboard shows a total population and a breakdown by gender, those two things are supposed to agree. Male plus female should equal total. Ethnicity breakdown percentages should sum to 100.</p>
<pre><code class="language-typescript">test("Cross-field math: sex breakdown sums to total population", async ({ page }) =&gt; {
  test.setTimeout(180_000);
  await runSteps({
    page,
    userFlow: "population sex breakdown consistency",
    steps: [
      { description: "Navigate to https://data.gov.my/dashboard/kawasanku" },
      {
        description: "Wait for the Malaysia country-level view with breakdown data",
        waitUntil:
          "A headline total population figure is visible and a breakdown by sex is shown on the page",
      },
    ],
    assertions: [
      {
        assertion:
          "The male and female population values shown on the page add up to approximately the headline total population, within a 5% margin",
      },
      {
        assertion:
          "Any percentage-based breakdowns visible on the page (by sex, age, or ethnicity) sum to approximately 100% within a 2 percentage-point margin",
      },
      {
        assertion: "No breakdown value is negative or greater than the headline total",
      },
    ],
    test,
    expect,
  });
});
</code></pre>
<p>Try writing that in vanilla Playwright. You need selectors for the headline number, selectors for the breakdown components, number parsing with a comma-aware regex, and a margin calculation. Seventy to a hundred lines of code to verify three invariants a primary school student would call obvious.</p>
<p>The Passmark version is one spec. I ran it against <a href="https://data.gov.my/dashboard/kawasanku">Kawasanku's</a> live country view. All three assertions passed in 1.4 minutes. Passmark's annotation, verbatim:</p>
<blockquote>
<p><em>"The headline total population figure 'Malaysia has a population of 32,447,385 people.' is visible, and 'Gender And Age Distribution' is shown, which implies a breakdown by sex (male, female) will be available."</em></p>
</blockquote>
<p>Two models read the page, extract the numbers, do the arithmetic, and agree. When the dashboard changes layout in three months, the same assertion still works, because it never named a selector.</p>
<p>This is the class of test I want running against every dashboard product that I touch. Financial totals matching their line items. Percentages that sum to 100. Inventory counts equal to the sum of warehouse locations. This rarely gets checked today, because writing the check by hand outweighs the perceived value of running it.</p>
<h2 id="heading-what-i-found-across-three-runs">What I Found Across Three Runs</h2>
<table>
<thead>
<tr>
<th>Run</th>
<th>Passed</th>
<th>Key change</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>4 of 13 (31%)</td>
<td>Baseline. Wrote specs without looking at the target pages</td>
</tr>
<tr>
<td>2</td>
<td>8 of 13 (62%)</td>
<td>Rewrote five over-specified assertions using Passmark's own feedback</td>
</tr>
<tr>
<td>3</td>
<td>12 of 13 (92%)</td>
<td>Dropped one more wrong assertion, bumped timeouts, added retry, installed WebKit</td>
</tr>
</tbody></table>
<img src="https://cdn.hashnode.com/uploads/covers/605584805f8d5121697263ca/0181e92e-3341-4f1a-9830-cd4acd305e1c.png" alt="Playwright HTML report overview page showing the final run. 11 tests passed, 2 failed, total time 21.1 minutes across 13 specs." style="display: block;" width="1012" height="1460" loading="lazy">

<p>Every passing spec after run 1 came from Passmark telling me, in plain English, why my assertion didn't match the page.</p>
<p>Here are three examples from run 1:</p>
<p>For <code>dataset-detail.spec.ts</code>, I asserted "an API usage snippet (curl or JS) is shown." knowingly that the page is using Python and I wanted to see what the result was. Passmark replied:</p>
<blockquote>
<p><em>"The page contains API usage snippets, but they are specifically for Python using the requests library. There are no snippets provided in curl or JavaScript formats."</em></p>
</blockquote>
<p>The page had snippets. I asked for the wrong languages. Fix: accept any language.</p>
<p>For <code>dashboard-population.spec.ts</code>, I asserted "a chart visualizing population by age or ethnicity is rendered." Passmark replied:</p>
<blockquote>
<p><em>"The current page displays charts for vital statistics such as Live Births, Deaths, and Natural Increase over time, but there is no chart visualizing population specifically by age groups or ethnicity."</em></p>
</blockquote>
<p>The charts are there. Not the slice I guessed. Fix: accept any chart about population.</p>
<p>For <code>kkmnow/hospital-utilisation.spec.ts</code>, I asserted a "headline bed-utilisation percentage." Passmark replied:</p>
<blockquote>
<p><em>"While there are multiple bed-utilisation percentages listed in tables and rankings further down the page, there is no prominent, top-level headline KPI figure displaying the overall bed-utilisation percentage."</em></p>
</blockquote>
<p>The numbers are there. I had asked for a layout the designers didn't build.</p>
<p><strong>This is the killer feature:</strong> Passmark's failure messages aren't stack traces. They're explanations. The AI read the page, compared it against my words, and pointed me at the fix. Nothing like a selector-based test throwing <code>TimeoutError: waiting for locator</code>.</p>
<h3 id="heading-the-debugging-loop">The Debugging Loop</h3>
<p>Once I saw the pattern, the loop became my main technique. Here's the procedure:</p>
<ol>
<li><p>Read the failure message word for word. Don't skim.</p>
</li>
<li><p>Trust it as a description of what is on the page. The AI has read the page. Your assertion has not.</p>
</li>
<li><p>Rewrite the assertion so it matches what's on the page. Broaden, narrow, or restate.</p>
</li>
<li><p>Run it again.</p>
</li>
</ol>
<p>The discipline is to not argue with the tool. The page is what the page is. Your assertion is what is wrong. Every time I tried to "fix" the page (convinced my assertion was right and the site was broken), I lost some time. Every time I took the failure message at face value and rewrote, the test passed on the next run.</p>
<p>This is the one of the changes in how I'll write E2E tests going forward. The feedback loop is the tool. Every failed assertion is a draft of the correct one.</p>
<h3 id="heading-the-two-specs-that-still-fail-are-the-most-interesting">The Two Specs That Still Fail Are the Most Interesting</h3>
<h4 id="heading-1-the-two-models-disagreed-and-the-arbiter-call-failed">1. The two models disagreed and the arbiter call failed.</h4>
<p>On <code>catalogue-search.spec.ts</code>, Claude voted fail (72% confidence) and Gemini voted pass (100% confidence) on the same assertion. I had written the assertion in a way that read two ways.</p>
<p>Passmark escalated to an arbiter model through OpenRouter. The call came back with a 504 from Cloudflare. The arbiter never ran. The suite failed the spec.</p>
<p>This is an honest limit, not a fluke. Any CI that runs Passmark depends on OpenRouter's availability. External gateway errors happen. My fix for the final run was a global retry wrapper around the OpenRouter call, and the 504 stopped being a problem in practice.</p>
<p>If you bring this to production CI, plan for retries and treat OpenRouter outages as a first-class failure mode in your runbook.</p>
<img src="https://cdn.hashnode.com/uploads/covers/605584805f8d5121697263ca/9e08d49a-00e6-4803-a86e-decfa0534308.png" alt="Playwright HTML report detail for the catalogue-search failure. Shows Claude and Gemini returning different verdicts on the same assertion, Passmark escalating to an arbiter model, and the arbiter call aborting with a 504 from Cloudflare." style="display: block;" width="995" height="1259" loading="lazy">

<p>This failure taught me something about assertion design: my wording was ambiguous. Claude's reading was reasonable. Gemini's reading was reasonable. When you write tests in English, being precise about what you mean is part of writing a good test.</p>
<h4 id="heading-2-the-wait-condition-fired-too-early">2. The wait condition fired too early.</h4>
<p>On the KKMNow spec, I had <code>waitUntil: "A utilisation metric is visible"</code>. The page showed the section label "Hospital Bed Utilisation (%)" before the numbers finished loading. The wait step saw the label, decided the condition was met, and moved on. By the time the numbers rendered, the test had run out of time. Once the page was fully loaded, the range assertions would have passed on content.</p>
<blockquote>
<p><em>"The page displays multiple bed-utilisation percentages within the specified range (0% to 120%). For example, the ranked list shows Perlis at 93.1% and Melaka at 88.2%."</em></p>
</blockquote>
<img src="https://cdn.hashnode.com/uploads/covers/605584805f8d5121697263ca/2a9c8ade-f2ab-4c58-a56b-f7714bcabef5.png" alt="Playwright HTML report detail for the KKMNow spec. The test times out on the initial waitUntil, but Passmark's annotations show the range and state-selector assertions passed on content once the dashboard hydrated. Example quoted: &quot;Perlis at 93.1% and Melaka at 88.2%.&quot;" style="display: block;" width="1000" height="1152" loading="lazy">

<p>The lesson: your <code>waitUntil</code> wording needs the same care as your assertion wording. Both are read by AI. A vague wait is as bad as a vague assertion.</p>
<h2 id="heading-what-it-cost-and-why-cache-rate-is-cost-rate">What It Cost, and Why Cache Rate Is Cost Rate</h2>
<p>Each of the three runs took about 20 minutes on 13 specs with a single worker. The hackathon's pooled OpenRouter key covered the AI costs, so I have no personal dollar figure to report.</p>
<p>The more useful cost finding is what gets cached.</p>
<pre><code class="language-bash">$ docker exec passmark-redis redis-cli DBSIZE
5
</code></pre>
<p>Five steps out of roughly 35 were cached across three runs. A 14% cache-hit rate. The Passmark README explains why:</p>
<blockquote>
<p><em>Only steps that produced a single tool call get cached. Multi-step sequences are considered non-deterministic.</em></p>
</blockquote>
<p>Most of my steps described multi-tool sequences. "Open the area selector and choose Selangor, then wait for navigation" becomes click, wait, verify. Those don't cache by design.</p>
<p>This matters for your budget. An 86% miss rate means 86% of your steps call a model on every run. The cost model is per-tool-call via OpenRouter.</p>
<p>To estimate your own bill: count non-atomic steps in your suite, multiply by your chosen model's per-call price at current OpenRouter rates, and the product is your recurring cost per run. Cache rate is cost rate.</p>
<p>The fix is authoring discipline. Split compound descriptions into atomic steps. Treat cache fill rate as a metric you track, not an implementation detail to ignore. A suite with 80% atomic steps costs a fifth of a suite with 14%.</p>
<h2 id="heading-the-pattern-worth-stealing">The Pattern Worth Stealing</h2>
<p>The idea here is bigger than Passmark.</p>
<p><strong>Check that the numbers on your dashboards make sense.</strong> Most teams don't. They should.</p>
<p>A one-line assertion like "the headline number is between 20 million and 40 million" catches several classes of bug regular tests miss.</p>
<p>Here are four common ones:</p>
<ul>
<li><p>The data pipeline divided by the wrong thing, so the number on screen is ten times too small.</p>
</li>
<li><p>A timezone bug made yesterday's total show up under tomorrow's date.</p>
</li>
<li><p>The data never refreshed, so users are looking at last week's numbers.</p>
</li>
<li><p>A locale flip swapped commas and decimals, so 1,234,567 is now reading as 1.234567.</p>
</li>
</ul>
<p>Civic portals were my target. The pattern applies anywhere a dashboard shows numbers. Fintech reports, SaaS analytics, healthcare metrics, e-commerce admin panels. Any screen where a number is supposed to mean something.</p>
<p>Most of these numbers never get tested. Writing the check by hand is tedious. You need a selector to find the number, code to parse it, code to handle units, and a margin calculation. Fifty lines for one check. Nobody bothers.</p>
<p>You don't need Passmark to steal the idea. The same check works in plain Playwright with <code>page.evaluate</code> and number parsing. The Passmark version is just more efficient to write and readable by anyone on the team, not only engineers.</p>
<h2 id="heading-honest-verdict">Honest Verdict</h2>
<p>Passmark works. Across three runs I went from 4 of 13 passing to 12 of 13 without touching a selector, guided by the tool's own feedback.</p>
<p>Still, the caveats are real:</p>
<ul>
<li><p>On a cold cache, every step waits for a model. Budget more wall-clock time than a selector suite.</p>
</li>
<li><p>In my suite only 14% of steps cached. The other 86% pays model cost on every run. Authoring discipline (atomic steps) is the difference between cents and dollars per run.</p>
</li>
<li><p>Two-model voting doesn't protect against both models misreading the same way. Write assertions that are hard to misread.</p>
</li>
<li><p>Every assertion depends on OpenRouter's availability. External gateway errors need a retry strategy before this runs in CI.</p>
</li>
</ul>
<p>What stuck with me: Passmark didn't make me better at Playwright. It made me write tests I would have skipped otherwise.</p>
<p>What I imagine myself doing at work:</p>
<ul>
<li><p>Run a small nightly Passmark suite against the critical dashboards, focused on range and freshness checks.</p>
</li>
<li><p>Keep traditional Playwright and Jest for everything that has to be fast and deterministic.</p>
</li>
<li><p>Treat every Passmark failure message as a specification of the page, not an error to argue with.</p>
</li>
</ul>
<p>Try this, even if you never touch Passmark. Pick a number on a dashboard you work with. Write a test that fails if the number is outside a sane range. See what breaks. That is the whole pattern and purpose of this article.</p>
<h2 id="heading-resources">Resources</h2>
<ul>
<li><p>Repo: <a href="https://github.com/LeeRenJie/passmark-hackathon">github.com/LeeRenJie/passmark-hackathon</a></p>
</li>
<li><p>Passmark: <a href="https://github.com/bug0inc/passmark">github.com/bug0inc/passmark</a></p>
</li>
<li><p>Breaking Apps Hackathon: <a href="https://hashnode.com/hackathons/breaking-things">hashnode.com/hackathons/breaking-things</a></p>
</li>
<li><p>Test targets: <a href="https://data.gov.my">data.gov.my</a>, <a href="https://open.dosm.gov.my">OpenDOSM</a>, <a href="https://data.moh.gov.my">KKMNow</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Dynamic Web Scraper App with Playwright and React: A Step-by-Step Guide ]]>
                </title>
                <description>
                    <![CDATA[ Today we are going to build a small web scraper app. This application will scrape data from the Airbnb website and display it in a nice grid view. We will also add a Refresh button that will be able to trigger a new scraping round and update the resu... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-a-dynamic-web-scraper-app-with-playwright-and-react/</link>
                <guid isPermaLink="false">6787cb727ee491a35577e9d5</guid>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                    <category>
                        <![CDATA[ webscraping  ]]>
                    </category>
                
                    <category>
                        <![CDATA[ playwright ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Mihail Gaberov ]]>
                </dc:creator>
                <pubDate>Wed, 15 Jan 2025 14:51:30 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1736952641440/0aa6255b-45eb-4ae8-b5cb-d87648590e18.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Today we are going to build a small web scraper app. This application will scrape data from the Airbnb website and display it in a nice grid view. We will also add a Refresh button that will be able to trigger a new scraping round and update the results.</p>
<p>In order to make our app a bit more performant, we will utilize the browser’s local storage to store already scraped data so that we don’t trigger new scraping requests every time the browser is refreshed.</p>
<p>Here’s what it will look like:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1736256647942/56c176ad-2615-478a-94f8-e33b3b437b92.png" alt="Web Scrapper app interface" class="image--center mx-auto" width="2516" height="1708" loading="lazy"></p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-how-to-spin-up-the-app-with-vite">How to Spin Up the App with Vite</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-build-the-server">How to Build the Server</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-build-the-front-end">How to Build the Front End</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-heading-how-to-deploy-to-rendercom">How to Deploy to</a> <a target="_blank" href="http://render.com">render.com</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<p>If you want to jump straight into the code, <a target="_blank" href="https://github.com/mihailgaberov/web-scraper">here</a> <a target="_blank" href="https://orderbook-mihailgaberov.vercel.app/">💁</a> is the GitHub repository with a detailed <a target="_blank" href="https://github.com/mihailgaberov/web-scraper/blob/main/README.md">README 🙌,</a> and <a target="_blank" href="https://scraper-fe.onrender.com/">here</a> you can see the live demo.</p>
<p>Now if you’re ready, let’s go step by step and see how to build and the deploy the app.</p>
<p>First, let’s get everything set up and ready to go.</p>
<h2 id="heading-how-to-spin-up-the-app-with-vite">How to Spin Up the App with Vite</h2>
<p>We will use the <a target="_blank" href="https://vite.dev/">Vite</a> build tool to quickly spin up a bare bones React application, equipped with TailwindCSS for styling. In order to do that, run this in your terminal app:</p>
<pre><code class="lang-bash">npm create vite@latest web-scraper -- --template react
</code></pre>
<p>And then install and configure TailwindCSS as follows:</p>
<pre><code class="lang-bash">npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
</code></pre>
<p>Add the paths to all of your template files in your <code>tailwind.config.js</code> file like this:</p>
<pre><code class="lang-javascript"><span class="hljs-comment">/** <span class="hljs-doctag">@type <span class="hljs-type">{import('tailwindcss').Config}</span> </span>*/</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> {
  <span class="hljs-attr">content</span>: [
    <span class="hljs-string">"./index.html"</span>,
    <span class="hljs-string">"./src/**/*.{js,ts,jsx,tsx}"</span>,
  ],
  <span class="hljs-attr">theme</span>: {
    <span class="hljs-attr">extend</span>: {},
  },
  <span class="hljs-attr">plugins</span>: [],
}
</code></pre>
<p>Now you should have a brand new React application with Tailwind installed and configured.</p>
<p>Let’s start our work with the server.</p>
<h2 id="heading-how-to-build-the-server">How to Build the Server</h2>
<p>Since we are building a full stack application, the bare minimum we need to have in place is a server, a client, and an API. The API will live in the server world and the client app will call the endpoints it exposes in order to fetch the data we need to display on the front end.</p>
<h3 id="heading-set-up-the-http-server-with-expressjs">Set Up the HTTP Server with Express.js</h3>
<p>We are going to use the Express.js library to spin up an HTTP server that will handle our API requests. To do so, follow these steps:</p>
<p>First, install the necessary packages with this command:</p>
<pre><code class="lang-bash">npm install express cors playwright
</code></pre>
<p>Then create an empty <code>server.js</code> file in the project's root folder and add the following code:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">import</span> express <span class="hljs-keyword">from</span> <span class="hljs-string">"express"</span>;
<span class="hljs-keyword">import</span> { chromium } <span class="hljs-keyword">from</span> <span class="hljs-string">"playwright"</span>;
<span class="hljs-keyword">import</span> cors <span class="hljs-keyword">from</span> <span class="hljs-string">"cors"</span>;
<span class="hljs-keyword">import</span> { scrapeListings } <span class="hljs-keyword">from</span> <span class="hljs-string">"./utils/scraper.js"</span>;

<span class="hljs-keyword">const</span> app = express();
<span class="hljs-keyword">const</span> PORT = <span class="hljs-number">5001</span>;

app.use(cors());

app.get(<span class="hljs-string">"/scrape"</span>, <span class="hljs-keyword">async</span> (req, res) =&gt; {
  <span class="hljs-keyword">let</span> browser;
  <span class="hljs-keyword">try</span> {
    browser = <span class="hljs-keyword">await</span> chromium.launch();
    <span class="hljs-keyword">const</span> listings = <span class="hljs-keyword">await</span> scrapeListings({ browser, <span class="hljs-attr">retryCount</span>: <span class="hljs-number">3</span> });
    res.json(listings);
  } <span class="hljs-keyword">catch</span> (error) {
    res.status(<span class="hljs-number">500</span>).json({ <span class="hljs-attr">error</span>: error.message });
  }
});

app.listen(PORT, <span class="hljs-function">() =&gt;</span> {
  <span class="hljs-built_in">console</span>.log(<span class="hljs-string">`Scraper server running on http://localhost:<span class="hljs-subst">${PORT}</span>`</span>);
});
</code></pre>
<p>Before we continue with the scraper, let me first explain what we are doing here.</p>
<p>This is a pretty simple setup of an Express server that is exposing an endpoint called “scrape“. Our client-side application (the front end) can send GET requests to this endpoint and receive the data returned as a result.</p>
<p>What’s important here is the async callback function that we pass to the <code>app.get</code> method. This is where we call our scraping function within a try/catch block. It will return the scraped data or an error if something goes wrong.</p>
<p>The last few lines indicate that our server will listen on the specified PORT, which is set to 5001 here, and display a message in the terminal to show that the server is running.</p>
<h3 id="heading-what-is-web-scraping">What is Web Scraping?</h3>
<p>Before diving into the code, I want to briefly explain web scraping if you’re unfamiliar with it. Web scraping involves automatically reading content from websites using a piece of software. This software is called a “web scraper“. In our case, the scraper is what’s in the <code>scrapeListing</code> function.</p>
<p>An essential part of the scraping process is finding something in the DOM tree of the target website that you can use to select the data we want to scrape. This something is known as a <strong>selector</strong>. Selectors can be different HTML elements, such as tags (h3, p, table) or attributes, like class names or IDs.</p>
<p>You can use various programming techniques or features of the programming language you’re using to create the scraper, aiming for better results when implementing the selecting part of the scraper.</p>
<p>In our case, we’re using <code>[itemprop="itemListElement"]</code> as the selector. But you might wonder, how did we figure this out and decide to use that? How do you know which selector to use?</p>
<p>This is where it gets tricky. You have to manually inspect the DOM tree of the target website and determine what would work best. That’s the case unless the site provides an API specifically designed for scrapers.</p>
<p>Here is how this looks like in practice. This is a screenshot from the Airbnb website:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1736588995918/fe2eb87f-e1cb-4474-894f-169fffb8a216.png" alt="Finding a selector from Airbnb website DOM tree" class="image--center mx-auto" width="2348" height="1258" loading="lazy"></p>
<p>Usually, you’ll need the information you’re scraping for some particular purpose, which means you’ll need to store it somewhere and then process it. This processing often involve some kind of visualization of the data. This is where our client application comes into play.</p>
<p>We will store the results of our scraping in the browser's local storage. Then, we will easily display those results in a grid layout using React and TailwindCSS. But before we get to all this, let’s go back to the code to understand how the scraping process is done.</p>
<h3 id="heading-set-up-playwright">Set Up Playwright</h3>
<p>For the scraping functionality, we will use another library that has gotten pretty famous over the last few years: <a target="_blank" href="https://playwright.dev/">Playwright</a>. It mainly serves as an e2e testing solution, but, as you’ll see now, we can use it for scraping the web as well.</p>
<p>We will put the scraping function in a separate file so that we have it all in order and keep the separation of concerns in place.</p>
<p>Create a new folder in the root directory and name it utils. Inside this folder, add a new file named scraper.js and include the following code:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> MAX_RETRIES = <span class="hljs-number">3</span>;

<span class="hljs-keyword">const</span> validateListing = <span class="hljs-function">(<span class="hljs-params">listing</span>) =&gt;</span> {
  <span class="hljs-keyword">return</span> (
    <span class="hljs-keyword">typeof</span> listing.title === <span class="hljs-string">"string"</span> &amp;&amp;
    <span class="hljs-keyword">typeof</span> listing.price === <span class="hljs-string">"string"</span> &amp;&amp;
    <span class="hljs-keyword">typeof</span> listing.link === <span class="hljs-string">"string"</span>
  );
};

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> scrapeListings = <span class="hljs-keyword">async</span> ({ browser, retryCount }) =&gt; {
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">const</span> page = <span class="hljs-keyword">await</span> browser.newPage();

    <span class="hljs-keyword">try</span> {
      <span class="hljs-keyword">await</span> page.goto(<span class="hljs-string">"https://www.airbnb.com/"</span>, { <span class="hljs-attr">waitUntil</span>: <span class="hljs-string">"load"</span> });

      <span class="hljs-keyword">await</span> page.waitForSelector(<span class="hljs-string">'[itemprop="itemListElement"]'</span>, {
        <span class="hljs-attr">timeout</span>: <span class="hljs-number">10000</span>,
      });

      <span class="hljs-keyword">const</span> listings = <span class="hljs-keyword">await</span> page.$$eval(
        <span class="hljs-string">'[itemprop="itemListElement"]'</span>,
        <span class="hljs-function">(<span class="hljs-params">elements</span>) =&gt;</span> {
          <span class="hljs-keyword">return</span> elements.slice(<span class="hljs-number">0</span>, <span class="hljs-number">10</span>).map(<span class="hljs-function">(<span class="hljs-params">element</span>) =&gt;</span> {
            <span class="hljs-keyword">const</span> title =
              element.querySelector(<span class="hljs-string">".t1jojoys"</span>)?.innerText || <span class="hljs-string">"N/A"</span>;
            <span class="hljs-keyword">const</span> price =
              element.querySelector(<span class="hljs-string">"._11jcbg2"</span>)?.innerText || <span class="hljs-string">"N/A"</span>;
            <span class="hljs-keyword">const</span> link = element.querySelector(<span class="hljs-string">"a"</span>)?.href || <span class="hljs-string">"N/A"</span>;
            <span class="hljs-keyword">return</span> { title, price, link };
          });
        }
      );

      <span class="hljs-keyword">const</span> validListings = listings.filter(validateListing);

      <span class="hljs-keyword">if</span> (validListings.length === <span class="hljs-number">0</span>) {
        <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(<span class="hljs-string">"No listings found"</span>);
      }

      <span class="hljs-keyword">return</span> validListings;
    } <span class="hljs-keyword">catch</span> (pageError) {
      <span class="hljs-keyword">if</span> (retryCount &lt; MAX_RETRIES) {
        <span class="hljs-built_in">console</span>.log(<span class="hljs-string">`Retrying... (<span class="hljs-subst">${retryCount + <span class="hljs-number">1</span>}</span>/<span class="hljs-subst">${MAX_RETRIES}</span>)`</span>);
        <span class="hljs-keyword">return</span> <span class="hljs-keyword">await</span> scrapeListings(retryCount + <span class="hljs-number">1</span>);
      } <span class="hljs-keyword">else</span> {
        <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(
          <span class="hljs-string">`Failed to scrape data after <span class="hljs-subst">${MAX_RETRIES}</span> attempts: <span class="hljs-subst">${pageError.message}</span>`</span>
        );
      }
    } <span class="hljs-keyword">finally</span> {
      <span class="hljs-keyword">await</span> page.close();
    }
  } <span class="hljs-keyword">catch</span> (browserError) {
    <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(<span class="hljs-string">`Failed to launch browser: <span class="hljs-subst">${browserError.message}</span>`</span>);
  } <span class="hljs-keyword">finally</span> {
    <span class="hljs-keyword">if</span> (browser) {
      <span class="hljs-keyword">await</span> browser.close();
    }
  }
};
</code></pre>
<h3 id="heading-retry-mechanism">Retry Mechanism</h3>
<p>At the top of the file, there's a constant called <code>MAX_RETRIES</code> used to implement a <strong>retry mechanism</strong>. This tactic is often used by web scrapers to bypass or overcome protections that some websites have against scraping. We will see how to use it below.</p>
<p>It's important to mention the legal aspect here as well. Always respect the terms and conditions along with the privacy policy of the website you plan to scrape. Use these techniques only to handle technical challenges, not to break the law.</p>
<p>A small helper function follows that you can use to validate the received data. Nothing interesting here.</p>
<p>Next is the main scraping function. We’re passing the browser object, provided by Playwright, and the number of retry attempts as arguments to the function.</p>
<p>There are two try/catch blocks to handle possible failures: one for launching the browser (in headless mode, meaning you won't see anything) and one for the scraping process. In the latter, we’ll use Playwright's features to request the website, wait until the page is fully loaded, and then locate the selector we defined.</p>
<p>In the callback function we pass to <code>$$eval</code>, we access the elements returned by the scraping, allowing us to process them and get the data we want. In this case, I’m using three selectors to fetch the title, price, and link of the property. The first two are class names, and the last is the HTML tag <code>&lt;a&gt;</code>.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1736589673793/79cd8e05-eef7-4887-b3cb-6d2d95e9d5dc.png" alt="Selecting the price" class="image--center mx-auto" width="2360" height="1244" loading="lazy"></p>
<p>Then we return an object, { title, price, link }, with the fetched data, that is the values of the three properties. And in the end of the try part, we validate the results before returning them to the front end.</p>
<p>What follows in the catch part is the implementation of the retry mechanism we talked about a minute ago:</p>
<pre><code class="lang-javascript"> } <span class="hljs-keyword">catch</span> (pageError) {
      <span class="hljs-keyword">if</span> (retryCount &lt; MAX_RETRIES) {
        <span class="hljs-built_in">console</span>.log(<span class="hljs-string">`Retrying... (<span class="hljs-subst">${retryCount + <span class="hljs-number">1</span>}</span>/<span class="hljs-subst">${MAX_RETRIES}</span>)`</span>);
        <span class="hljs-keyword">return</span> <span class="hljs-keyword">await</span> scrapeListings(retryCount + <span class="hljs-number">1</span>);
      } <span class="hljs-keyword">else</span> {
        <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(
          <span class="hljs-string">`Failed to scrape data after <span class="hljs-subst">${MAX_RETRIES}</span> attempts: <span class="hljs-subst">${pageError.message}</span>`</span>
        );
      }
    }
</code></pre>
<p>If an error occurs during the reading process, we enter the catch phase and check if the retry count is below the maximum limit we set. If it is, we try again by running the function recursively. Otherwise, we throw an error indicating that the scraping failed and the maximum retry attempts have been reached.</p>
<p>That's all you need to set up basic web scraping of the Airbnb homepage.</p>
<p>You can see all this in the <a target="_blank" href="https://github.com/mihailgaberov/web-scraper">Github repo</a> of the project so don’t worry if you missed something here.</p>
<h2 id="heading-how-to-build-the-front-end">How to Build the Front End</h2>
<p>Now it's time to put the scraped data to use.</p>
<p>Let's display the last 10 properties in a grid layout, allowing you (or anyone) to open them by clicking on their links. We will also add a <code>Refresh</code> feature that lets you perform a new scrape to get the most up-to-date data.</p>
<p>This is how the structure of the front end part of the project looks:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1736591529574/5cf5c62f-283b-4cfc-afa4-3b2b92a3ddae.png" alt="Project structure" class="image--center mx-auto" width="410" height="452" loading="lazy"></p>
<p>We have a simple app structure: one main container (App.jsx) that holds all the components and includes some logic for making requests to the API and storing the data in local storage.</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">import</span> { useEffect, useState } <span class="hljs-keyword">from</span> <span class="hljs-string">"react"</span>;
<span class="hljs-keyword">import</span> { useLocalStorage } <span class="hljs-keyword">from</span> <span class="hljs-string">"@uidotdev/usehooks"</span>;
<span class="hljs-keyword">import</span> axios <span class="hljs-keyword">from</span> <span class="hljs-string">"axios"</span>;
<span class="hljs-keyword">import</span> Footer <span class="hljs-keyword">from</span> <span class="hljs-string">"./components/Footer"</span>;
<span class="hljs-keyword">import</span> Header <span class="hljs-keyword">from</span> <span class="hljs-string">"./components/Header"</span>;
<span class="hljs-keyword">import</span> RefreshButton <span class="hljs-keyword">from</span> <span class="hljs-string">"./components/RefreshButton"</span>;
<span class="hljs-keyword">import</span> Grid <span class="hljs-keyword">from</span> <span class="hljs-string">"./components/Grid"</span>;
<span class="hljs-keyword">import</span> Loader <span class="hljs-keyword">from</span> <span class="hljs-string">"./components/Loader"</span>;

<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">App</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-keyword">const</span> [listings, setListings] = useLocalStorage(<span class="hljs-string">"properties"</span>, []);
  <span class="hljs-keyword">const</span> [loading, setLoading] = useState(<span class="hljs-literal">false</span>);
  <span class="hljs-keyword">const</span> [error, setError] = useState(<span class="hljs-string">""</span>);

  <span class="hljs-keyword">const</span> fetchListings = <span class="hljs-keyword">async</span> () =&gt; {
    setLoading(<span class="hljs-literal">true</span>);
    setError(<span class="hljs-string">""</span>);
    setListings([]);

    <span class="hljs-keyword">try</span> {
      <span class="hljs-keyword">const</span> response = <span class="hljs-keyword">await</span> axios.get(<span class="hljs-string">"http://localhost:5001/scrape"</span>);
      <span class="hljs-keyword">if</span> (response.data.length === <span class="hljs-number">0</span>) {
        <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(<span class="hljs-string">"No listings found"</span>);
      }
      setListings(response.data);
    } <span class="hljs-keyword">catch</span> (err) {
      setError(
        err.response?.data?.error ||
          <span class="hljs-string">"Failed to fetch listings. Please try again."</span>
      );
    } <span class="hljs-keyword">finally</span> {
      setLoading(<span class="hljs-literal">false</span>);
    }
  };

  useEffect(<span class="hljs-function">() =&gt;</span> {
    <span class="hljs-keyword">if</span> (listings.length === <span class="hljs-number">0</span>) {
      fetchListings();
    }
  }, []);

  <span class="hljs-keyword">return</span> (
    <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">className</span>=<span class="hljs-string">"flex flex-col items-center justify-center min-h-screen bg-gray-100"</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">Header</span> /&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">RefreshButton</span> <span class="hljs-attr">callback</span>=<span class="hljs-string">{fetchListings}</span> <span class="hljs-attr">loading</span>=<span class="hljs-string">{loading}</span> /&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">main</span> <span class="hljs-attr">className</span>=<span class="hljs-string">"flex flex-col items-center justify-center flex-1 w-full px-4 relative"</span>&gt;</span>
        {error &amp;&amp; <span class="hljs-tag">&lt;<span class="hljs-name">p</span> <span class="hljs-attr">className</span>=<span class="hljs-string">"text-red-500"</span>&gt;</span>{error}<span class="hljs-tag">&lt;/<span class="hljs-name">p</span>&gt;</span>}
        {loading ? <span class="hljs-tag">&lt;<span class="hljs-name">Loader</span> /&gt;</span> : <span class="hljs-tag">&lt;<span class="hljs-name">Grid</span> <span class="hljs-attr">listings</span>=<span class="hljs-string">{listings}</span> /&gt;</span>}
      <span class="hljs-tag">&lt;/<span class="hljs-name">main</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">Footer</span> /&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span></span>
  );
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> App;
</code></pre>
<p>All components are placed in the <a target="_blank" href="https://github.com/mihailgaberov/web-scraper/tree/main/src/components">components</a> directory (this is what I call a surprise, ah:)). Most of the components are quite simple, and I included them to give the app a more complete appearance.</p>
<p>The <a target="_blank" href="https://github.com/mihailgaberov/web-scraper/blob/main/src/components/Header.jsx">Header</a> displays the top bar. The <a target="_blank" href="https://github.com/mihailgaberov/web-scraper/blob/main/src/components/RefreshButton.jsx">RefreshButton</a> is used to send a new request and get the latest data. In the <code>&lt;main&gt;</code> section, we either show an error message if fetching fails, or we display a <a target="_blank" href="https://github.com/mihailgaberov/web-scraper/blob/main/src/components/Loader.jsx">Loader</a> component and a <a target="_blank" href="https://github.com/mihailgaberov/web-scraper/blob/main/src/components/Grid.jsx">Grid</a> component.</p>
<p>The loading part is straightforward. The Grid component is the interesting one. We pass the scraping results to it using a prop called 'listings'. Inside, we use a simple map() function to go through them and display the properties. We use Tailwind to style the grid, ensuring the properties are neatly listed and look good on both desktop and mobile screens.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1736593771753/0f56930c-2944-452c-a2cd-4c6b69c1d996.png" alt="The app looks good on smaller screens as well." class="image--center mx-auto" width="1338" height="1732" loading="lazy"></p>
<p>And in the end with we have the <a target="_blank" href="https://github.com/mihailgaberov/web-scraper/blob/main/src/components/Footer.jsx">Footer</a> component that renders a simple bar with text. Again, I’ve added it just for completeness.</p>
<h2 id="heading-how-to-deploy-to-rendercom">How to Deploy to render.com</h2>
<p>Maybe a little over a year ago, I needed a place to deploy full-stack applications, ideally for free, since they were just for educational purposes.</p>
<p>After some research, I found a platform called <a target="_blank" href="https://dashboard.render.com/">Render</a> and managed to deploy an app with both client and server parts, getting it to work online. I left it there until now. Since our scraper requires both parts to function properly, we will deploy it there and have it working online, as you can see <a target="_blank" href="https://scraper-fe.onrender.com/">here</a>.</p>
<p>To do this, you need to create an account with Render and use their dashboard application. The process is simple, but I'll include a few screenshots below for clarity.</p>
<p>This is the Overview page where you can see all your projects:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1736594350231/eac21d82-f43d-44be-8027-6ac25c86f740.png" alt="Render platform overview page" class="image--center mx-auto" width="2494" height="784" loading="lazy"></p>
<p>Here is the Project page where you can view and manage your projects. In our case, we can see both the server and the client app as separate services.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1736594435708/a4329d74-f529-46a0-a193-d6a1ade145ad.png" alt="a4329d74-f529-46a0-a193-d6a1ade145ad" class="image--center mx-auto" width="1874" height="1130" loading="lazy"></p>
<p>You can click on each service to open its page, where you can view the deployments and the commits that triggered them. You can find even more details if you explore further.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1736594566165/39e78a87-d8e7-4df3-8e1b-02eb0eee799c.png" alt="39e78a87-d8e7-4df3-8e1b-02eb0eee799c" class="image--center mx-auto" width="1846" height="1690" loading="lazy"></p>
<p>You should be able to manage the deployment process on your own, as everything is clearly explained. But if you need help, feel free to reach out.</p>
<p>I should mention that I am not affiliated with Render in any way and I am not receiving any benefits for mentioning them here. I just found it to be a useful tool and wanted to share it with you – so I’ve used it here.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>A web scraper app can be a powerful tool for gathering data, but there are several areas for improvement and important considerations to keep in mind here.</p>
<p>Firstly, you can enhance the app's performance and efficiency by optimizing the scraping process and ensuring that the data is stored and processed effectively. You can also implement more robust error handling and retry mechanisms to improve the reliability of the scraper.</p>
<p>Also, keep in mind that ethical scraping is crucial, and it's important to always respect the terms of service and privacy policies of the websites you are scraping. This includes not overloading the website with requests and ensuring that the data is used responsibly. Always seek permission from the site if required and consider using APIs provided by the website as a more ethical and reliable alternative.</p>
<p>Lastly, respecting the law is paramount. Ensure that your scraping activities comply with legal regulations and guidelines to avoid any potential legal issues. By focusing on these aspects, you can build a more effective, ethical, and legally compliant web scraper app.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
