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.
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)
<!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>
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.
// 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'));
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
},
});
{
"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" }
}
// 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 });
// 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 }); });
#!/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
{
"hooks": {
"Stop": [
{ "hooks": [
{ "type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/scripts/check.sh",
"timeout": 300 } // E2E is slower than unit — budget for it
] }
]
}
}
# 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).
✓ 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)
clearCompleted.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.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.--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.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."
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.claude, approve the hook, and paste the prompt from the dialogue above.playwright.config.js with the right webServer, the first three specs, and the hook — the same shape as this demo, fitted to your repo.