Lesson 11 · Field Example · Agentic Loop Engineering

Playwright E2E: the Full Worked Example

Every file of a complete UI-verification loop — a real app, a real browser-driving test suite, a real gate — and the dialogue of the loop catching a bug no unit-level check could see. All outputs below are from verified runs.

⏱ ~12 min🎯 E2E + UI as the verifier⚙ demo 04 — installed & ready

1 · The scenario

A todo app with a one-character bug: "Clear completed" keeps the completed todos and deletes the active ones — the filter is inverted. Here's why this example earns its place in the course: every cheap verifier passes. No type error, no lint warning, nothing a unit test of `render()` would flag — the code is "correct," it just does the wrong thing to the user. The only verifier that catches it is one that acts like a user: click the button in a real browser and look at what's left. That's the integration/E2E rung of the taxonomy — Anthropic's harness phrasing: "do all testing as a human user would." (harnesses post)

2 · All the files

playwright-e2e-demo/
├── public/ ← the app under test
│ ├── index.html
│ ├── app.js ← contains the seeded bug
│ └── style.css
├── server.js ← zero-dep static server, port 4173
├── playwright.config.js ← webServer auto-start, traces
├── tests/todo.spec.js ← the spec: 5 user flows
├── tests/visual.spec.js ← the @visual pixel-diff tier
├── scripts/check.sh ← Stop-hook gate
├── .claude/settings.json ← hook wiring
├── CLAUDE.md ← commands + hard rules
├── package.json · .gitignore · README.md

The app

public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>todos</title>
  <link rel="stylesheet" href="/courses/agentic-loop-engineering/">
</head>
<body>
  <main class="app">
    <h1>todos</h1>
    <input id="new-todo" type="text" placeholder="What needs doing? (Enter to add)" autofocus>
    <ul id="list"></ul>
    <footer class="bar">
      <span id="count">0 items left</span>
      <nav class="filters">
        <button data-filter="all" class="active">All</button>
        <button data-filter="active">Active</button>
        <button data-filter="completed">Completed</button>
      </nav>
      <button id="clear-completed">Clear completed</button>
    </footer>
  </main>
  <script src="/courses/agentic-loop-engineering/"></script>
</body>
</html>
public/app.js
const STORAGE_KEY = 'todos';
let todos = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
let filter = 'all';

const input = document.getElementById('new-todo');
const list = document.getElementById('list');
const count = document.getElementById('count');

function save() {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}

function render() {
  list.innerHTML = '';
  const visible = todos.filter((t) =>
    filter === 'all' ? true : filter === 'active' ? !t.done : t.done
  );
  for (const t of visible) {
    const li = document.createElement('li');
    li.className = t.done ? 'todo done' : 'todo';
    const cb = document.createElement('input');
    cb.type = 'checkbox';
    cb.checked = t.done;
    cb.addEventListener('change', () => { t.done = !t.done; save(); render(); });
    const span = document.createElement('span');
    span.textContent = t.text;
    li.append(cb, span);
    list.append(li);
  }
  const left = todos.filter((t) => !t.done).length;
  count.textContent = `${left} item${left === 1 ? '' : 's'} left`;
}

input.addEventListener('keydown', (e) => {
  if (e.key === 'Enter' && input.value.trim()) {
    todos.push({ text: input.value.trim(), done: false });
    input.value = '';
    save();
    render();
  }
});

document.querySelectorAll('[data-filter]').forEach((btn) =>
  btn.addEventListener('click', () => {
    filter = btn.dataset.filter;
    document.querySelectorAll('[data-filter]')
      .forEach((b) => b.classList.toggle('active', b === btn));
    render();
  })
);

document.getElementById('clear-completed').addEventListener('click', () => {
  todos = todos.filter((t) => t.done);   // ← THE BUG: keeps completed, deletes active
  save();
  render();
});

render();

style.css (omitted here — plain styling, in the repo) completes the app. The bug line reads plausibly; code review could easily miss it.

The harness — server + config

server.js
// Zero-dependency static server for ./public (the app under test).
const http = require('http');
const fs = require('fs');
const path = require('path');

const ROOT = path.join(__dirname, 'public');
const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css' };

http.createServer((req, res) => {
    const url = req.url === '/' ? '/index.html' : req.url.split('?')[0];
    const file = path.normalize(path.join(ROOT, url));
    if (!file.startsWith(ROOT) || !fs.existsSync(file)) {
      res.writeHead(404).end('not found');
      return;
    }
    res.writeHead(200, { 'Content-Type': TYPES[path.extname(file)] || 'application/octet-stream' });
    fs.createReadStream(file).pipe(res);
  })
  .listen(4173, () => console.log('todo app listening on :4173'));
playwright.config.js
const { defineConfig } = require('@playwright/test');

module.exports = defineConfig({
  testDir: './tests',
  reporter: [['list']],
  use: {
    baseURL: 'http://localhost:4173',
    trace: 'retain-on-failure',      // a recorded trace per failure — evidence, replayable
  },
  webServer: {                        // ← the loop-engineering gem:
    command: 'node server.js',        // Playwright starts the app itself,
    port: 4173,                       // waits for the port, kills it after.
    reuseExistingServer: true,        // the environment is part of the harness
  },
});
package.json
{
  "name": "playwright-e2e-demo",
  "private": true,
  "scripts": {
    "serve": "node server.js",
    "test": "playwright test --grep-invert @visual",
    "test:all": "playwright test",
    "test:headed": "playwright test --grep-invert @visual --headed",
    "test:ui": "playwright test --ui"
  },
  "devDependencies": { "@playwright/test": "^1.49.0" }
}

The spec — five user flows

tests/todo.spec.js
// E2E specification for the todo app. These tests ARE the definition of done.
// It is unacceptable to remove or edit tests — fix the app until they pass.
const { test, expect } = require('@playwright/test');

async function addTodo(page, text) {
  await page.fill('#new-todo', text);
  await page.press('#new-todo', 'Enter');
}

test.beforeEach(async ({ page }) => {
  await page.goto('/');
  await page.evaluate(() => localStorage.clear());  // clean world per test
  await page.reload();
});

test('adds a todo via the input', async ({ page }) => {
  await addTodo(page, 'buy milk');
  await expect(page.locator('.todo')).toHaveCount(1);
  await expect(page.locator('.todo span')).toHaveText('buy milk');
  await expect(page.locator('#count')).toHaveText('1 item left');
});

test('toggles a todo complete', async ({ page }) => {
  await addTodo(page, 'buy milk');
  await page.locator('.todo input[type=checkbox]').check();
  await expect(page.locator('.todo')).toHaveClass(/done/);
  await expect(page.locator('#count')).toHaveText('0 items left');
});

test('filters active vs completed', async ({ page }) => {
  await addTodo(page, 'task one');
  await addTodo(page, 'task two');
  await page.locator('.todo', { hasText: 'task two' }).locator('input').check();

  await page.click('[data-filter="completed"]');
  await expect(page.locator('.todo')).toHaveCount(1);
  await expect(page.locator('.todo span')).toHaveText('task two');

  await page.click('[data-filter="active"]');
  await expect(page.locator('.todo')).toHaveCount(1);
  await expect(page.locator('.todo span')).toHaveText('task one');
});

test('persists todos across reload', async ({ page }) => {
  await addTodo(page, 'persist me');
  await page.reload();
  await expect(page.locator('.todo span')).toHaveText('persist me');
});

test('clear completed removes only completed todos', async ({ page }) => {
  await addTodo(page, 'keep me');
  await addTodo(page, 'finished task');
  await page.locator('.todo', { hasText: 'finished task' }).locator('input').check();

  await page.click('#clear-completed');

  await expect(page.locator('.todo')).toHaveCount(1);
  await expect(page.locator('.todo span')).toHaveText('keep me');  // ← catches the bug
});
tests/visual.spec.js — the Lesson 7 tier, composable on top
// Visual regression (Lesson 7's deterministic pixel-diff tier).
// Tagged @visual and excluded from the Stop hook — screenshots are
// platform-specific, so bootstrap YOUR baseline once with:
//   npx playwright test --grep @visual --update-snapshots
// then review + commit the generated *-snapshots/ directory as the golden.
const { test, expect } = require('@playwright/test');

test('home page visual snapshot @visual', async ({ page }) => {
  await page.goto('/');
  await page.evaluate(() => localStorage.clear());
  await page.reload();
  await expect(page).toHaveScreenshot('home.png', { fullPage: true });
});

The gate

scripts/check.sh
#!/usr/bin/env bash
# Stop hook: the turn may not end while the E2E suite is red.
# Visual tests (@visual) are excluded — their baselines are per-machine.
input=$(cat)

cd "$(dirname "$0")/.." || exit 0

if npx playwright test --grep-invert @visual >/tmp/e2e-hook.log 2>&1; then
  exit 0
fi

{
  echo "BLOCKED: the Playwright E2E suite is failing. The turn may not end until it is green."
  # print from the first failure detail block — assertion diff, locator, call log
  awk '/^  1\)/{found=1} found' /tmp/e2e-hook.log | head -40
  grep -E "passed|failed" /tmp/e2e-hook.log | tail -2
} >&2
exit 2
.claude/settings.json
{
  "hooks": {
    "Stop": [
      { "hooks": [
          { "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/scripts/check.sh",
            "timeout": 300 }     // E2E is slower than unit — budget for it
      ] }
    ]
  }
}
CLAUDE.md
# todo app — E2E-verified
A vanilla-JS todo app served by `node server.js` (port 4173). The Playwright
E2E suite in `tests/` is the specification and the verifier.

## Commands
- `npm test` — functional E2E suite (Playwright auto-starts the server).
- `npm run test:headed` — same, with a visible browser.
- `npx playwright test --grep <name>` — run a single test while iterating.
- On failure: `npx playwright show-trace test-results/<dir>/trace.zip`

## Hard rules
- The tests are the specification. It is unacceptable to remove or edit
  tests — fix the app until they pass.
- A Stop hook runs the functional suite; the turn cannot end while it is red.
- Visual tests (@visual) are excluded from the hook (per-machine baselines).

3 · The dialogue — real outputs throughout

You
The E2E suite is red. Use it as your feedback loop: run it, read the failure, fix the app (not the tests), iterate until green.
Claude
⏺ Bash(npm test) — Playwright boots the server, drives chromium through all five flows
  ✓  1 tests/todo.spec.js:16:1 › adds a todo via the input (408ms)
  ✓  2 tests/todo.spec.js:23:1 › toggles a todo complete (182ms)
  ✓  3 tests/todo.spec.js:30:1 › filters active vs completed (194ms)
  ✓  4 tests/todo.spec.js:44:1 › persists todos across reload (115ms)
  ✘  5 tests/todo.spec.js:50:1 › clear completed removes only completed todos (5.2s)

  1) tests/todo.spec.js:50:1 › clear completed removes only completed todos ───────

    Error: expect(locator).toHaveText(expected) failed

    Locator:  locator('.todo span')
    Expected: "keep me"
    Received: "finished task"

    Call log:
      - Expect "toHaveText" with timeout 5000ms
        14 × locator resolved to <span>finished task</span>

  1 failed  4 passed (7.0s)
The failure is precise: after "Clear completed," the surviving todo is "finished task" — the completed one — while "keep me" was deleted. The behavior is inverted, which points at the filter predicate in clearCompleted.
⏺ Read(public/app.js) — finds todos.filter((t) => t.done) in the clear-completed handler
⏺ Edit(public/app.js) — (t) => t.done → (t) => !t.done
⏺ Bash(npx playwright test --grep "clear completed") → 1 passed — targeted re-check first
⏺ Bash(npm test) → 5 passed (6.4s) — full suite
Fixed: the clear-completed filter kept completed todos instead of removing them — one inverted predicate. Full E2E suite green, output above. — attempts to end the turn —
✓ Stop hook — scripts/check.sh re-runs the suite
EXIT 0 · TURN ENDS
1
The failure output did the diagnosis. Expected: "keep me" · Received: "finished task" is a behavioral statement — "the wrong item survived" — that points straight at the inverted predicate. Playwright's assertion diffs are among the best agent feedback in the ecosystem; this is why E2E earns its latency.
2
The environment is part of the harness. The webServer config means "run the tests" includes "boot the world": no stale server, no port juggling, no agent forgetting to restart after an edit. Loop engineering is exactly this kind of plumbing.
3
Latency discipline, applied. Targeted single-test re-run while iterating (--grep), full suite for the verdict, 300s hook timeout, @visual excluded from the gate. Lesson 4's speed rules and Lesson 9's anti-patterns, in the wild.

4 · Why this slots where it does

On the taxonomy, this loop stacks three rungs: functional E2E (deterministic, catches behavior the user sees), the @visual pixel-diff golden (deterministic, catches what assertions don't enumerate — Lesson 7's composition), and the Stop hook making both binding (Lesson 4). The one-character bug is the whole argument: type checker silent, linter silent, plausible code review pass — and the user loses their todo list. The verifier that acts like a user is the only one standing between "looks done" and "is done."

Run it yourself — it's already installed:
cd ~/projects/playwright-e2e-demo && npm test — watch the suite catch the bug.
npm run test:headed — once, just to see the browser fly through the flows.
Then claude, approve the hook, and paste the prompt from the dialogue above.
💬 Adapting this to your app? Tell me your stack (framework, dev-server command, the flows you care about) and I'll write the playwright.config.js with the right webServer, the first three specs, and the hook — the same shape as this demo, fitted to your repo.

Where to go next