1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
|
# Player — LLM-Driven End-to-End Tests
This directory contains the LLM-driven end-to-end test harness for Player.
Unlike the deterministic Playwright suite at `../e2e-web/`, this layer uses a
Claude agent to interpret natural-language scenario steps, execute them against
a live server, and judge the results. It is designed for cross-stack flows that
are too complex to express as sequences of CSS selectors.
For full background and design rationale, read
[`docs/llm-e2e-design.md`](../../../../docs/llm-e2e-design.md) at the project
root.
---
## Directory layout
```
test/e2e-llm/
├── README.md # this file
├── runner/
│ ├── package.json # Node/TypeScript runner dependencies
│ ├── tsconfig.json # TypeScript configuration
│ └── index.ts # harness entry point
└── scenarios/
├── S01-bootstrap.md # Bootstrap → first user
├── S02-podcast.md # Podcast subscribe → download → mark complete
├── S03-upload.md # Upload via API → verify on web
└── S04-share-link.md # Share-link round-trip
```
---
## Prerequisites
| Requirement | Version | Notes |
|---|---|---|
| Node.js | 18+ | Required for the runner |
| npm | 8+ | Required for the runner |
| Playwright (Chromium) | Latest via Playwright | Install via `../e2e-web/` setup |
| `ask` CLI | Any | Installed globally; used for failure tasks |
| `sqlite3` CLI | Any | For DB assertion checks |
| `ANTHROPIC_API_KEY` | Valid key | Required for screenshot oracle (see below) |
The Player server must be running before any scenario is started. The harness
does not start or stop the server.
### First-time runner setup
```sh
# From this directory:
cd runner
npm install
npm run build
```
---
## Starting the server
The harness defaults to `http://localhost:8080`. Override with `PLAYER_URL`.
Start the server from `player-server/` so it can resolve embedded static assets:
```sh
# From player-server/ — use testdata/media/ as the media library:
MEDIA_ROOT=./testdata/media \
SECURE_COOKIES=false \
DB_PATH=/tmp/player-e2e-llm.db \
./player
```
Key server environment variables:
| Variable | Value | Reason |
|---|---|---|
| `MEDIA_ROOT` | `./testdata/media` | Provides pre-existing media for upload/verify scenarios |
| `SECURE_COOKIES` | `false` | Allows session cookie over plain HTTP |
| `DB_PATH` | `/tmp/player-e2e-llm.db` | Isolates the test database from production |
---
## Environment variables (harness)
| Variable | Required | Default | Description |
|---|---|---|---|
| `PLAYER_URL` | No | `http://localhost:8080` | Base URL of the Player server under test |
| `LLM_E2E_SCREENSHOTS` | No | `false` | Set to `true` to enable Layer 5 screenshot + vision model assertion |
| `OLLAMA_BASE_URL` | No | `https://ollama.com` | Ollama API endpoint; override for a local instance |
| `OLLAMA_API_KEY` | Yes (for cloud) | — | API key from ollama.com; omit only for local unauthenticated instances |
| `OLLAMA_MODEL` | No | `qwen3-vl:235b-instruct` | Vision model on the cloud endpoint; override for a local model |
| `LLM_E2E_OPEN_ISSUE` | No | `false` | Set to `true` to open a Codeberg issue if a failure task is older than 24 h |
| `PLAYER_DB` | No | Inferred from server config | Path to the SQLite database file; used for DB assertion checks |
---
## Running all scenarios
```sh
# From runner/ (after npm run build):
node dist/index.js
# Or run directly during development:
npm run dev
```
This runs S01 through S04 in order. Each scenario is independent; a failure in
one scenario does not stop the remaining scenarios from running.
To run against a non-default server:
```sh
PLAYER_URL=http://myserver:9090 node dist/index.js
```
To enable screenshot assertions (Layer 5, requires an ollama.com API key):
```sh
LLM_E2E_SCREENSHOTS=true \
OLLAMA_API_KEY=<your-ollama.com-key> \
node dist/index.js
```
The oracle defaults to `llama3.2-vision` via `https://ollama.com`. To use a
different model or a local Ollama instance:
```sh
# Different cloud model:
LLM_E2E_SCREENSHOTS=true OLLAMA_API_KEY=<key> OLLAMA_MODEL=llava:13b node dist/index.js
# Local Ollama (no API key needed):
LLM_E2E_SCREENSHOTS=true OLLAMA_BASE_URL=http://localhost:11434 node dist/index.js
```
---
## Running a single scenario manually
Pass the scenario file path as the first argument:
```sh
# From runner/:
node dist/index.js ../scenarios/S01-bootstrap.md
```
Or via `npm run dev` during development:
```sh
npm run dev -- ../scenarios/S01-bootstrap.md
```
The runner prints a per-step summary to stdout and exits with code 0 on
success, 1 on failure.
---
## Adding a new scenario
Each scenario is a single Markdown file under `scenarios/`. The file has two
parts: a YAML front-matter block and a numbered Markdown step list.
### File name convention
```
S<nn>-<short-slug>.md
```
Examples: `S05-android-skeleton.md`, `S06-bulk-sync.md`.
### Skeleton
```markdown
---
id: S05
title: "Short human-readable title"
tags: [auth, web] # free-form tags for filtering
preconditions:
server_state: running # "fresh" = empty DB, "running" = existing DB with data
fixtures: [] # list of fixture keys the harness should load
assertions:
- db: "SELECT count(*) FROM users WHERE role='admin'"
- url_contains: /login.html
skip: false # set to "until-<reason>" to exclude from CI runs
---
1. Navigate to the root URL.
2. Confirm the page redirects to /expected-page.html.
3. Do something meaningful with the UI or API.
4. Confirm the result.
```
### Front-matter fields
| Field | Type | Description |
|---|---|---|
| `id` | string | Unique identifier, e.g. `S05` |
| `title` | string | Human-readable title used in logs and `ask` task names |
| `tags` | list | Free-form tags; not currently used for filtering but useful for documentation |
| `preconditions.server_state` | `fresh` or `running` | `fresh` means the harness creates a temp empty DB; `running` means it uses the existing one |
| `preconditions.fixtures` | list | Named fixture keys the harness loads before running steps |
| `assertions` | list | Post-condition checks the harness runs after all steps complete |
| `skip` | bool or string | `false` to run normally; a string like `"until-integration-test-added"` to skip in CI |
### Assertion types
| Key | Example | What it checks |
|---|---|---|
| `db` | `db: "SELECT id FROM users WHERE role='admin'"` | Runs a read-only SQLite query; passes if at least one row is returned |
| `url_contains` | `url_contains: /login.html` | Checks the browser's current URL |
| `selector_visible` | `selector_visible: ".media-card"` | Playwright DOM check |
| `status_code` | `status_code: "GET /api/v1/health 200"` | HTTP status assertion |
### Writing good steps
- Write steps as numbered plain-English sentences. The agent interprets them; no
special syntax is needed inside the Markdown body.
- Each step should do one thing: navigate, fill a form, call an API, or assert.
- Avoid writing assertions in the steps if you can express them in the YAML
`assertions` block — the YAML block is cheaper (no agent reasoning required).
- Visual checks ("does this look right?") belong in steps, not in the YAML block;
they trigger the Haiku screenshot oracle when `LLM_E2E_SCREENSHOTS=true`.
---
## Assertion layers and cost
The harness uses layered assertions from cheapest to most expensive. Use the
cheapest layer that can detect a failure:
| Layer | Method | Cost |
|---|---|---|
| 1 | HTTP status codes | Zero — checked on every API call |
| 2 | JSON field assertions | Cheap — parse response body |
| 3 | DB state checks (`sqlite3` CLI) | Cheap — direct SQL query |
| 4 | Playwright selector checks | Cheap — deterministic DOM query |
| 5 | Screenshot + Ollama vision check | Self-hosted cost only — gated behind `LLM_E2E_SCREENSHOTS=true` |
Layer 5 sends a PNG to an Ollama vision model (`llama3.2-vision`) with a yes/no
question such as "Is there a media card visible in the grid?" Using a self-hosted
Ollama instance means no per-call API cost beyond compute.
---
## Cost budget
| Run type | Scenarios | Estimated cost |
|---|---|---|
| Full nightly (S01-S04) | 4 | ~$0.18 per run |
| Single scenario | 1 | ~$0.04-0.05 per run |
| Full nightly with retries | 4 + retries | ~$0.27 per run |
At one nightly run the expected spend is **~$5.50/month** (Sonnet for
orchestration, Haiku for screenshots).
**Alert threshold:** If a single run exceeds **$0.50**, treat it as anomalous.
Common causes are a context window blowup (Playwright output not capped) or
excessive retries. The runner caps Playwright CLI output forwarded to the agent
at 4 000 characters to help control this.
**Monthly soft budget:** $10/month for the entire LLM e2e layer. If you
consistently see runs above $0.50, check for long scenario files, verbose
fixtures, or scenarios that retry repeatedly.
---
## Failure handling
The runner uses a one-retry policy:
```
Run scenario
├── Pass → log "PASS", continue to next scenario
└── Fail
├── Wait 5 s, retry once (fresh browser context)
│ ├── Pass → log "FLAKY — passed on retry", continue
│ └── Fail → open ask task, log "FAIL", continue to next scenario
```
When a scenario fails after the retry, the runner opens a task:
```sh
ask add "LLM e2e failure: <scenario title> — <one-line reason>"
```
The task description includes the Playwright HTML report path and a snippet of
the failure output so a developer can reproduce the failure without re-reading
logs.
The runner does **not**:
- retry more than once (cost control)
- stop the run on a single failure (other scenarios still run)
- send email or Slack notifications
- auto-fix failing scenarios
To open a Codeberg issue automatically when a failure task is older than 24 h,
set `LLM_E2E_OPEN_ISSUE=true`. This is off by default.
---
## Nightly schedule
The nightly run is configured via the `/schedule` skill. It runs a Claude Code
agent with the prompt:
> Run the LLM e2e suite at `player-server/test/e2e-llm/` against the
> production server and report results.
The agent reads the scenario files, executes them via the runner, and annotates
an ongoing task with the result. To set up or modify the schedule, use:
```sh
/schedule
```
---
## Relationship to the Playwright smoke suite
| Dimension | Playwright smoke (`../e2e-web/`) | LLM e2e (this suite) |
|---|---|---|
| Assertion style | Deterministic CSS selectors | Natural-language steps + layered assertions |
| When to run | Every PR, fast | Nightly, slower |
| Best for | "Did the page render? Did the API return 200?" | Cross-stack flows, auth boundaries, multi-step sequences |
| Cost | Zero (no LLM calls) | ~$0.18/run |
| Visual checks | No | Yes, via Haiku (opt-in) |
The two suites are complementary. The Playwright smoke suite is the first line
of defence for regressions. The LLM e2e suite catches higher-level integration
failures that selector-based tests cannot easily express.
|