write-a-spec · diff
git:20260920.e759462 to git:20260921.8da7a96
21 added, 5 removed. Audit A to A.
---
name: write-a-spec
description: Write or extend an E2E journey spec in evals/specs that proves a PR's change to a human reviewer. Use when a PR changes user-visible behaviour, when a reviewer asks "show me", or when the coverage decision says a journey is missing.
---
# Skill: Write a Spec
Every `evals/specs/**/*.e2e.test.ts` a PR adds or changes runs in CI on the PR
head and is published as that PR's proof (private report + one PR comment).
Write the spec for the person who will read that report in thirty seconds,
not for the machine that runs it.
## Do not write one when…
- An existing journey covers the behaviour: extend it. One spec per user
journey, not per PR; bug fixes add a step to the journey they escaped from.
- The change is a pure function: write a colocated unit test, not evidence.
- You would import `../../apps|packages|ee`, read source files, or spawn another
runner. That is a unit test in disguise; the boundary ratchet rejects it.
## The proof shape
A proof answers: **who** can now do **what** they could not before, and who
still cannot. Structure every spec as that story. Each beat is one `step()`
with a claim-sized name; each visual beat ends in `user.screenshot()`.
```
persona → the test title names a person: "an owner", "a teammate", "a member without access"
before → what that person sees or cannot do today (features only)
action → the person does the thing, through the UI, as they would
after → what they now see; screenshot at that moment
boundary → who else is affected, and the negative half: who is not
```
+ The review app is generated from the spec. It shows exactly four strings, and
+ each comes from one place in your code; write those strings for the reviewer:
+
+ | Reviewer sees | Comes from | Rule |
+ | --- | --- | --- |
+ | Section heading | the `test("…")` title | names the persona and what they can now do |
+ | Caption under a screenshot | the `step("…")` the `user.screenshot()` ran inside | the old state starts `before:`, the new state `after:`; otherwise a plain claim |
+ | Caption + judgment on a `looks()` image | the first expectation in `user.looks([...])` | judged later; pending until then, so CI proof stays `Incomplete` |
+ | Assertion line | `recordAssertionEvidence(claim, evidence, ok)` | `claim` is the caption, `evidence` the text under it |
+
+ A screenshot taken outside any `step()` is captioned "<title> artifact N",
+ which tells the reviewer nothing. No verbs like "assert", no selectors, no
+ internal names anywhere in those strings.
+
Example title and steps:
```ts
test("an owner enables Code Mode and a teammate turns one chat into a shared Workflow", async ({ user, probe, step }) => {
- await step("before: the teammate's agent has no script tool", …); // screenshot
- await step("the owner enables Code Mode with one switch", …); // screenshot
- await step("the teammate's request now runs as one script", …); // screenshot
- await step("the result is saved as a Workflow the team can open", …); // screenshot
- await step("a member outside the team cannot see it", …); // screenshot
+ await step("before: the teammate's agent has no script tool", …); // screenshot
+ await step("the owner enables Code Mode with one switch", …); // screenshot
+ await step("after: the teammate's request runs as one script", …); // screenshot
+ await step("the result is saved as a Workflow the team can open", …); // screenshot
+ await step("a member outside the team cannot see it", …); // screenshot
});
```
Assertions still live inside each step; they just hang off user-visible
moments. A spec whose assertions are RPC responses collected in the world and
compared in one block is a mechanism check, not a proof. It is still allowed;
it just tells the reviewer nothing.
Rules of thumb:
- At least three steps. Zero steps renders as "no claims declared".
- Every step has at least one `user.*` act or `user.see`. `probe.*` observes;
it does not carry a step alone.
- One screenshot per visual beat. Non-visual work records `recordAssertionEvidence`
with the command or response excerpt as the evidence text.
- For features, show the before state in the same world. Base-vs-head runs are
expensive; a step that starts with the switch off is not.
- For permissions, sharing, or scopes, always include the negative persona.
- Prefer `seed.appWeb` (headless Chrome, real app). Use `seed.desktop` only for
a native capability a browser cannot show, and say why in `nativeReason`.
- No `seed.evalIn` / `probe.eval` in new specs. If you need one, comment why.
## Use the testkit channels
Import `spec` (and `expect`) from `@openwork/testkit`; bind the world:
```ts
const test = spec.world(myWorld, {
resources: { surfaces: ["appWeb"], services: ["den"] },
});
```
| Channel | Use it for |
| --- | --- |
| `seed` | Arrange the world: Den, orgs, members, workspaces, sessions, mocks, faults. Only `seed` writes state. All `seed.*` goes in the world, before the first act. |
| `user` | Act as the person: `click`, `type`, `press`, `reload`, `see`, `notSee`, `looks`, `screenshot`. Trusted CDP input; no JS evaluation. |
| `agent` | Drive the product's automation rail (`window.__openworkControl`): sends, session actions. |
| `probe` | Observe without changing state: `text`, `hash`, `storage`, `api` (GET), `dom`, `eventually`. |
| `step` | Name a claim. Nests. A failed step is recorded and rethrown; later steps show `not-reached`. |
Well-known targets: `"composer"` for the editor; otherwise role, label, text,
placeholder, or test id. Bound every wait; declare external requirements in
`needs()` so a missing dependency skips loudly.
## Claims and witnesses
- Every claim is machine-checkable with an observable assertion and its
negative half: what must not happen to another identity, account, or state.
- Prose is never proof. Screenshots explain an assertion; they cannot replace it.
- Describe product behaviour, not incidental layout.
- If product and claim diverge, change one explicitly; never bend the claim.
- Never smuggle the answer into the prompt: assert the user-facing request
carries no connector or resource IDs.
- Use `mcpMock()` witnesses under `evals/packages/labs/src/`; never call real
providers. Witnesses are deterministic, identity-scoped, and queryable.
## Evidence contract
- Evidence is ambient: `user.screenshot()` records an artifact, `user.looks()`
records a visual validation (judged later; pending until then),
`recordAssertionEvidence()` records a witness assertion. Never create or
pass recorder handles.
- Run it once before pushing and read your own record:
```sh
pnpm evals:e2e <slug> --local
ls evals/results/test-runs/<latest>/ # index.html, test-run.json, NN-*.png
```
If the screenshots would not convince you, they will not convince the reviewer.
+ Read the captions in `index.html` top to bottom: they should tell the
+ before → after story on their own.
- In CI the spec runs on `PR change proof`, one job per spec; the trusted
publisher aggregates every changed spec's records into one report. Failed,
skipped, and cancelled runs stay visible as such; nothing substitutes for them.