@jaybeeuu/agent-cortex 1.40.0 → 1.41.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +21 -8
- package/CHANGELOG.md +85 -0
- package/README.md +78 -38
- package/agents/README.md +5 -3
- package/bin/agent-cortex.mjs +36 -4
- package/bin/installers/claude.mjs +171 -21
- package/bin/installers/copilot.mjs +2 -17
- package/bin/installers/pi.mjs +261 -22
- package/claude/settings.json +4 -0
- package/claude-extras/scripts/statusline-command.sh +32 -4
- package/docs/claude-hooks.md +12 -0
- package/docs/ideas/ban-content-assertion-tests.md +73 -0
- package/docs/ideas/bead-progress-notes-for-session-resumption.md +75 -0
- package/docs/ideas/classify-beads-via-deterministic-script-with-agent-fallback.md +70 -0
- package/docs/ideas/ctx-alias-version-and-update-commands.md +65 -0
- package/docs/ideas/ctx-feedback-command-for-capturing-ideas-from-any-machine.md +67 -0
- package/docs/ideas/deployment-beads-for-post-merge-pipelines.md +36 -0
- package/docs/ideas/design-and-delivery-as-separate-bead-trees/design-and-delivery-as-separate-bead-trees.md +99 -0
- package/docs/ideas/enable-signed-commit-enforcement-changesets-bot-commits-must-be-signed.md +33 -0
- package/docs/ideas/extension-manifests.md +55 -14
- package/docs/ideas/improve-pi-system-prompt.md +5 -2
- package/docs/ideas/map-every-cortex-tool-to-a-real-pi-equivalent.md +95 -0
- package/docs/ideas/record-idea-implementation-questions.md +68 -0
- package/docs/ideas/self-flushing-session.md +72 -0
- package/docs/ideas/state-in-beads-not-md.md +69 -0
- package/docs/ideas/task-session-context.local.md +95 -0
- package/docs/ideas/test-data-creation-helpers.md +92 -0
- package/docs/ideas/test-planning-stage-bead-in-feature-planning.md +68 -0
- package/docs/ideas/worktree-housekeeping-for-closed-beads.md +39 -0
- package/docs/technical-direction/harness-config.md +161 -0
- package/docs/technical-direction/migration-execution-labels.md +60 -23
- package/extensions/agent-guardrails/README.md +45 -0
- package/extensions/agent-guardrails/guardrails.test.ts +42 -0
- package/extensions/agent-guardrails/guardrails.ts +66 -0
- package/extensions/agent-guardrails/index.ts +22 -0
- package/extensions/agent-guardrails/node_modules/.bin/pi +21 -0
- package/extensions/agent-guardrails/node_modules/.bin/tsc +21 -0
- package/extensions/agent-guardrails/node_modules/.bin/tsserver +21 -0
- package/extensions/agent-guardrails/node_modules/.bin/tsx +21 -0
- package/extensions/agent-guardrails/package.json +19 -0
- package/extensions/agent-guardrails/tsconfig.json +12 -0
- package/extensions/agent-modes/README.md +9 -3
- package/extensions/agent-modes/index.test.ts +208 -0
- package/extensions/agent-modes/index.ts +23 -6
- package/extensions/agent-modes/package.json +1 -1
- package/lib/cli.mjs +8 -2
- package/lib/pi-packages.mjs +169 -0
- package/package.json +5 -1
- package/pi/settings.json +23 -12
- package/plugin.json +1 -1
- package/scripts/lib/fs.mjs +31 -0
- package/skills/planning/classify-bead/REFERENCE.md +85 -0
- package/skills/planning/classify-bead/SKILL.md +32 -36
- package/skills/planning/classify-bead/scripts/classify-bead.mjs +292 -0
- package/skills/planning/create-task/REFERENCE.md +1 -1
- package/skills/planning/create-task/SKILL.md +20 -11
- package/skills/planning/create-task/scripts/create-chores.ts +4 -0
- package/skills/planning/write-a-prd/SKILL.md +2 -2
- package/skills/style/style-code/REFERENCE.md +32 -2
- package/skills/style/style-code/SKILL.md +9 -21
- package/skills/style/style-tests/EXAMPLES.md +548 -0
- package/skills/style/style-tests/SKILL.md +84 -33
- package/skills/workflow/run-pipeline-stage/SKILL.md +13 -1
- package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +12 -0
- package/test/classify-bead.test.mjs +277 -0
- package/test/cli.test.mjs +55 -11
- package/test/create-chores.test.mjs +73 -0
- package/test/fs.test.mjs +76 -0
- package/test/helpers/fake-bd.mjs +67 -0
- package/test/installer.test.mjs +237 -4
- package/test/pi-installer.test.mjs +343 -4
- package/test/pi-packages.test.mjs +295 -0
- package/token-map.README.md +4 -2
- package/token-map.json +3 -3
|
@@ -23,7 +23,9 @@ description: Defines this project's coding style, conventions, and architectural
|
|
|
23
23
|
- **Clarity over cleverness.** Code is read many more times than it is written. Clever code optimises for the writing moment; clear code optimises for every subsequent reading.
|
|
24
24
|
- **Validate at system edges, trust the type system within.** Inbound data from API ingress, deserialisation, or inter-service communication is untrusted until validated. Within application boundaries, trust the type system to enforce correct calls — don't re-validate at every internal module boundary.
|
|
25
25
|
- **Prefer result objects for domain errors; reserve exceptions for emergencies.** Expected failures — validation errors, not-found, conflict — belong in a typed result the caller can handle. Thrown `Error` should signal something genuinely broken: a programming mistake, an invariant violation, or unrecoverable state.
|
|
26
|
+
- **A function either returns a value or changes state — never both.** This is command/query separation at function scale: a command mutates (writes, I/O, logging) and returns nothing — `void` or `Promise<void>`; a query is pure and returns a value. Returning data *about* a mutation — the saved row, a count, the previous value, a success boolean — makes one function two operations wearing one name. Two carve-outs: a command that can fail may return a typed result (`Result<T, E>`), which reports an outcome rather than data about the world and so composes with the result-object guidance above; and a fluent builder whose whole purpose is construction may return `this` to chain. Neither licenses a passthrough return — `arr.push()`-style returns stay banned.
|
|
26
27
|
- **Deep modules beat shallow ones.** A tight public API with complexity hidden behind it is easier to use, test, and change than a flat sprawl of interdependent helpers.
|
|
28
|
+
- **Be a class when you are stateful.** When a module must hold mutable state and expose methods that transform or manage it, model it as a class with public methods and real private fields (`#name`) — not a factory function returning a closure of methods. Native `#private` is enforced at runtime; TypeScript's `private` modifier is compile-time only and erases away. This is orthogonal to the functional guidance elsewhere: small components, explicit props, and hooks still govern UI and pure transforms, but a stateful object is a class, and pretending otherwise is a functional idiom wrapped around an object.
|
|
27
29
|
- **Abstraction must earn its keep, but local helpers are free.** An abstraction extracted into a shared module before its shape is proven nearly always guesses wrong. But extracting a local helper to clarify domain logic is always worthwhile — even at first use. The goal is readable code, not zero duplication.
|
|
28
30
|
|
|
29
31
|
## Workflow
|
|
@@ -48,6 +50,7 @@ description: Defines this project's coding style, conventions, and architectural
|
|
|
48
50
|
- **Broadening scope.** A change that fixes a bug and also renames three modules and extracts an abstraction is three changes. Split it.
|
|
49
51
|
- **Skipping edge validation.** Inbound data at API ingress, deserialisation, or inter-service boundaries is untrusted until validated. Within the application, trust the type system. A missed edge validation silently poisons downstream callers.
|
|
50
52
|
- **Unstructured errors.** A thrown string or `{ message }` object leaves the caller guessing what failed and why. Prefer typed result objects for expected domain failures — use thrown `Error` only when something has genuinely gone wrong (programming error, invariant violation, unrecoverable state).
|
|
53
|
+
- **A mutator that returns.** A function that changes state *and* returns something about that change — a count, the mutated object, the previous value — is two operations wearing one name. Split it into a command that mutates and a query that reads.
|
|
51
54
|
- **Large modules with mixed concerns.** A file doing data loading, state orchestration, and rendering is three modules waiting to be extracted.
|
|
52
55
|
- **Premature cross-module abstraction.** Extracting a shared utility into a common module before the pattern has proven itself across multiple callers nearly always guesses the wrong shape. Local helpers that clarify intent are fine at first use — keep them within the module boundary until the pattern repeats.
|
|
53
56
|
|
|
@@ -62,6 +65,8 @@ description: Defines this project's coding style, conventions, and architectural
|
|
|
62
65
|
| "The existing code already does it this way" | Existing patterns may predate this guide. Follow the guide unless changing it would inflate scope beyond the task. |
|
|
63
66
|
| "I'll throw an Error for this validation failure" | Expected domain failures should be typed results the caller can handle. Reserve thrown `Error` for things that are truly broken. |
|
|
64
67
|
| "I'll extract this into a shared util — it might be useful later" | Extract locally first if it clarifies the code. Promote to shared only when at least a third real caller emerges. Guessing the shape before then wastes time and creates coupling. |
|
|
68
|
+
| "A factory closure is more functional/pure" | State is state — a class names it. A closure over mutable state is an object with extra steps: the state is still there, the API is less discoverable, and nothing is gained. Use `#private` fields and public methods. |
|
|
69
|
+
| "It's convenient to return the saved row / new count from the write" | That return turns a command into a query mid-flight: the caller now depends on data the mutation happens to produce. Return a typed result for the outcome, and let a separate query read the state if the caller needs it. |
|
|
65
70
|
|
|
66
71
|
## Cross-skill references
|
|
67
72
|
|
|
@@ -77,27 +82,8 @@ description: Defines this project's coding style, conventions, and architectural
|
|
|
77
82
|
|
|
78
83
|
## Examples
|
|
79
84
|
|
|
80
|
-
See `REFERENCE.md` for the full set
|
|
81
|
-
|
|
82
|
-
### Types at boundaries
|
|
83
|
-
|
|
84
|
-
| Instead of… | Write… |
|
|
85
|
-
|---|---|
|
|
86
|
-
| `function handleRequest(raw: any) { ... }` with no validation | `function handleRequest(raw: unknown): HandlerResult { ... }` validated at the ingress boundary |
|
|
87
|
-
| `const user = JSON.parse(raw)` with unchecked cast | `const user = parseUser(JSON.parse(raw))` validated with `parseUser` at the deserialisation edge |
|
|
88
|
-
|
|
89
|
-
### Naming
|
|
90
|
-
|
|
91
|
-
| Instead of… | Write… |
|
|
92
|
-
|---|---|
|
|
93
|
-
| `function process(items) { ... }` | `function resolveOverdueAccounts(accounts: Account[]) { ... }` |
|
|
94
|
-
| `interface Config { ... }` | `interface ExportOptions { ... }` |
|
|
95
|
-
|
|
96
|
-
### Scoping
|
|
97
|
-
|
|
98
|
-
| Instead of… | Write… |
|
|
99
|
-
|---|---|
|
|
100
|
-
| One commit fixing a bug, renaming a module, and adding an abstraction | One commit per concern, each reviewable on its own |
|
|
85
|
+
See `REFERENCE.md` for the full set — common patterns (types at boundaries, naming,
|
|
86
|
+
scoping) and the stateful-object-vs-factory pair.
|
|
101
87
|
|
|
102
88
|
## Verification checklist
|
|
103
89
|
|
|
@@ -109,5 +95,7 @@ See `REFERENCE.md` for the full set. The most common patterns:
|
|
|
109
95
|
- [ ] Types are co-located with the module that owns them
|
|
110
96
|
- [ ] Modules are split by ownership, not just for reshuffling code
|
|
111
97
|
- [ ] State transitions are explicit and encapsulated behind a clear API
|
|
98
|
+
- [ ] Stateful modules with methods are classes using `#private` fields, not factory closures
|
|
99
|
+
- [ ] Functions are commands (mutate, return nothing) or queries (pure, return a value) — never both
|
|
112
100
|
- [ ] Tests and docs updated where behaviour changed
|
|
113
101
|
- [ ] Staged changes scanned with `review-security` before committing
|
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
# Examples
|
|
2
|
+
|
|
3
|
+
`❌ Instead` / `✅ Write` pairs for the rules in `SKILL.md`. Each pair is a pattern seen in
|
|
4
|
+
real test suites; the left column is what to replace, the right is what to replace it with.
|
|
5
|
+
|
|
6
|
+
## Behaviour over implementation
|
|
7
|
+
|
|
8
|
+
Assert the observable outcome. If a refactor that preserves behaviour breaks the test, the test
|
|
9
|
+
was wrong.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
// ❌ Instead — spies on an internal collaborator; proves only that it was wired up
|
|
13
|
+
const spy = vi.spyOn(taxCalculator, "compute");
|
|
14
|
+
checkout(cart);
|
|
15
|
+
expect(spy).toHaveBeenCalledWith(cart);
|
|
16
|
+
|
|
17
|
+
// ✅ Write — asserts what the caller observes
|
|
18
|
+
expect(checkout(cart).total).toBe(90);
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
// ❌ Instead — recomputes the expected value with the implementation's own logic,
|
|
23
|
+
// so a bug in that logic passes the test
|
|
24
|
+
const expected = subtotal * (1 - discountRate);
|
|
25
|
+
expect(computeTotal(cart)).toBe(expected);
|
|
26
|
+
|
|
27
|
+
// ✅ Write — a known-good case with a hard-coded result
|
|
28
|
+
expect(computeTotal(cartOf100With10PercentDiscount)).toBe(90);
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
// ❌ Instead — exports an internal helper so the test can reach it
|
|
33
|
+
export const __testOnlyNormalise = (s: string) => s.trim().toLowerCase();
|
|
34
|
+
|
|
35
|
+
// ✅ Write — drive the behaviour through the public entry point that uses it
|
|
36
|
+
expect(parseEmail(" Ada@Example.COM ")).toStrictEqual({ address: "ada@example.com" });
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Test data: vital visible, incidental hidden
|
|
40
|
+
|
|
41
|
+
The values that drive the case belong in the test body. Everything else takes an obvious
|
|
42
|
+
default in a factory.
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
// ❌ Instead — the value under test is hidden in a top-of-file constant.
|
|
46
|
+
// A reader cannot see which field makes this case "admin".
|
|
47
|
+
const ADMIN_USER: User = { id: "u1", name: "Ada", isAdmin: true, locale: "en-GB" };
|
|
48
|
+
|
|
49
|
+
it("grants admin access", () => {
|
|
50
|
+
expect(canEdit(ADMIN_USER)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// ✅ Write — the driving value is on the assertion's line
|
|
54
|
+
it("grants admin access", () => {
|
|
55
|
+
expect(canEdit(createUser({ isAdmin: true }))).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
// ❌ Instead — one module-scope object, mutated and reused across tests.
|
|
61
|
+
// These tests now depend on each other and on their order.
|
|
62
|
+
const user: User = { id: "u1", name: "Ada", isAdmin: false };
|
|
63
|
+
|
|
64
|
+
it("denies guest access", () => {
|
|
65
|
+
expect(canEdit(user)).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("grants admin access", () => {
|
|
69
|
+
user.isAdmin = true;
|
|
70
|
+
expect(canEdit(user)).toBe(true);
|
|
71
|
+
}); // order-dependent: the first test fails if this one runs first
|
|
72
|
+
|
|
73
|
+
// ✅ Write — fresh data per test, produced by a factory
|
|
74
|
+
const createUser = (overrides: Partial<User> = {}): User => ({
|
|
75
|
+
id: "u1",
|
|
76
|
+
name: "Ada",
|
|
77
|
+
isAdmin: false,
|
|
78
|
+
...overrides,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("grants admin access", () => {
|
|
82
|
+
expect(canEdit(createUser({ isAdmin: true }))).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("denies guest access", () => {
|
|
86
|
+
expect(canEdit(createUser())).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
// ❌ Instead — the full literal is re-spelled in every test, obscuring the one field that changes
|
|
92
|
+
it("grants admin access", () => {
|
|
93
|
+
expect(canEdit({ id: "u1", name: "Ada", isAdmin: true, locale: "en-GB" })).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ✅ Write — a factory defaults the incidental data, overrides carry the case
|
|
97
|
+
interface SomeThing {
|
|
98
|
+
prop: number;
|
|
99
|
+
otherProp: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Colocated with the tests, named for what it builds. Partial is enough for a flat
|
|
103
|
+
// type; reach for a deep-partial only when the type nests.
|
|
104
|
+
const createSomething = (overrides: Partial<SomeThing> = {}): SomeThing => ({
|
|
105
|
+
prop: 0,
|
|
106
|
+
otherProp: "{other-prop}",
|
|
107
|
+
...overrides,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("overrides only the value under test", () => {
|
|
111
|
+
const fixture = createSomething({ prop: 1 });
|
|
112
|
+
|
|
113
|
+
expect(fixture).toStrictEqual({ prop: 1, otherProp: "{other-prop}" });
|
|
114
|
+
});
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Mocking: boundaries, not internals
|
|
118
|
+
|
|
119
|
+
Mock the genuine external edge — network, clock, third-party SDK. Do not mock a collaborator
|
|
120
|
+
inside the application; a real or in-memory one is usually cheaper and never lies.
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
// ❌ Instead — the mock replaces an internal collaborator, so the test
|
|
124
|
+
// proves nothing about the real interaction
|
|
125
|
+
vi.mock("../repository/userRepository");
|
|
126
|
+
await saveUser(user);
|
|
127
|
+
expect(userRepository.save).toHaveBeenCalledWith(user);
|
|
128
|
+
|
|
129
|
+
// ✅ Write — a real in-memory implementation at the genuine boundary
|
|
130
|
+
const repository = new InMemoryUserRepository();
|
|
131
|
+
const service = new UserService(repository);
|
|
132
|
+
|
|
133
|
+
await service.save(user);
|
|
134
|
+
|
|
135
|
+
expect(await repository.get(user.id)).toStrictEqual(user);
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
// ❌ Instead — the test asserts the stub it just configured, not the mapping
|
|
140
|
+
vi.spyOn(http, "get").mockResolvedValue({ status: 200, body: apiUserResponse });
|
|
141
|
+
expect(await fetchUser("u1")).toStrictEqual(apiUserResponse);
|
|
142
|
+
|
|
143
|
+
// ✅ Write — stub the edge, assert the domain mapping the client performs
|
|
144
|
+
vi.spyOn(http, "get").mockResolvedValue({ status: 200, body: apiUserResponse });
|
|
145
|
+
expect(await fetchUser("u1")).toStrictEqual({ id: "u1", displayName: "Ada" });
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Mock as little as possible: if a real collaborator is fast, deterministic, and side-effect free,
|
|
149
|
+
use it.
|
|
150
|
+
|
|
151
|
+
## Integration and e2e
|
|
152
|
+
|
|
153
|
+
These layers prove what units cannot: that your code speaks correctly to a real collaborator,
|
|
154
|
+
and that the assembled system works. They are the layers agents skip, and where flakiness is
|
|
155
|
+
born. See `SKILL.md` for the principles that govern them.
|
|
156
|
+
|
|
157
|
+
### What each layer proves
|
|
158
|
+
|
|
159
|
+
A unit proves logic in isolation. An integration test proves your code against a real
|
|
160
|
+
collaborator across **one seam**. An e2e test proves the assembled system on a user-visible
|
|
161
|
+
journey. Pick the lowest layer that can prove the behaviour.
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
// ❌ Instead — an "integration" test that mocks the collaborator it exists to integrate with
|
|
165
|
+
vi.mock("../db/pool");
|
|
166
|
+
await saveOrder(order);
|
|
167
|
+
expect(pool.query).toHaveBeenCalledWith(expect.stringContaining("INSERT INTO orders"));
|
|
168
|
+
|
|
169
|
+
// ✅ Write — exercises the real collaborator across the seam
|
|
170
|
+
await withPostgres(async (db) => {
|
|
171
|
+
const repository = new OrderRepository(db);
|
|
172
|
+
await repository.save(order);
|
|
173
|
+
|
|
174
|
+
expect(await repository.get(order.id)).toMatchObject({ id: order.id });
|
|
175
|
+
});
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
// ❌ Instead — a unit-tested rule re-proved at e2e: slow, and it proves nothing new
|
|
180
|
+
it("applies a 10% discount to a £100 order", async () => {
|
|
181
|
+
await addToCart(page, productPricedAt(100));
|
|
182
|
+
await openCart(page);
|
|
183
|
+
|
|
184
|
+
expect(await page.textContent(".total")).toBe("£90.00");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ✅ Write — e2e proves the journey and the wiring; the rule stays a unit test
|
|
188
|
+
it("lets a signed-in customer complete checkout", async () => {
|
|
189
|
+
await signIn(page, customer);
|
|
190
|
+
await addToCart(page, product);
|
|
191
|
+
await checkout(page);
|
|
192
|
+
|
|
193
|
+
expect(await page.textContent(".confirmation")).toContain("Thank you");
|
|
194
|
+
});
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Real collaborators, not fakes
|
|
198
|
+
|
|
199
|
+
Run the genuine engine — Postgres, Redis, a queue, object storage — through TestContainers or
|
|
200
|
+
LocalStack. A fake drifts from the real contract and reintroduces exactly the bug the
|
|
201
|
+
integration test was written to catch. Fake only what you genuinely cannot run locally, and
|
|
202
|
+
use a contract double when you do.
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
// ❌ Instead — a hand-rolled fake that does not share the real broker's contract
|
|
206
|
+
const fakeQueue = { send: vi.fn() };
|
|
207
|
+
await publishOrder(queueAdapter(fakeQueue));
|
|
208
|
+
expect(fakeQueue.send).toHaveBeenCalled();
|
|
209
|
+
|
|
210
|
+
// ✅ Write — the real broker in a container
|
|
211
|
+
const queue = await startQueueContainer();
|
|
212
|
+
await publishOrder(queueAdapter(queue));
|
|
213
|
+
|
|
214
|
+
const [received] = await consumeOne(queue, ORDER_TOPIC);
|
|
215
|
+
expect(received).toMatchObject({ id: order.id });
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
// ❌ Instead — an ad-hoc stub invented per test; nothing pins it to the provider's API
|
|
220
|
+
vi.spyOn(stripe, "charge").mockResolvedValue({ id: "ch_1" });
|
|
221
|
+
|
|
222
|
+
// ✅ Write — a contract double generated from the provider's schema
|
|
223
|
+
server.use(http.post("/v1/charges", () => HttpResponse.json(stripeChargeFixture)));
|
|
224
|
+
|
|
225
|
+
expect(await client.charge(customer, 100)).toMatchObject({ id: "ch_1" });
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Isolation at the integration layer
|
|
229
|
+
|
|
230
|
+
State is the enemy here. Give every test its own data and its own schema, namespace, or
|
|
231
|
+
transaction; never share a seeded row or a long-lived database between tests or parallel
|
|
232
|
+
workers. Starting a container is expensive and legitimately belongs in `beforeAll` — data
|
|
233
|
+
does not.
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
// ❌ Instead — a shared seeded row mutated across tests; passes alone, fails in a parallel run
|
|
237
|
+
beforeAll(async () => {
|
|
238
|
+
db = await startPostgresContainer();
|
|
239
|
+
await db.exec(seedSql);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("updates the account", async () => {
|
|
243
|
+
await db.update("accounts", { id: "acc-1" }, { balance: 50 });
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("reads the seeded balance", async () => {
|
|
247
|
+
expect(await db.get("accounts", "acc-1")).toMatchObject({ balance: 100 });
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// ✅ Write — container per suite, data per test
|
|
251
|
+
beforeAll(async () => {
|
|
252
|
+
db = await startPostgresContainer();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("updates the account", async () => {
|
|
256
|
+
const account = await createAccount(db, { balance: 100 });
|
|
257
|
+
await updateBalance(db, account.id, 50);
|
|
258
|
+
|
|
259
|
+
expect(await getAccount(db, account.id)).toMatchObject({ balance: 50 });
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("reads the inserted balance", async () => {
|
|
263
|
+
const account = await createAccount(db, { balance: 100 });
|
|
264
|
+
|
|
265
|
+
expect(await getAccount(db, account.id)).toMatchObject({ balance: 100 });
|
|
266
|
+
});
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Async and eventual consistency
|
|
270
|
+
|
|
271
|
+
Integration surfaces are asynchronous by nature. Await the observable signal with a bounded
|
|
272
|
+
deadline; a fixed sleep is both slow and a race it may still lose.
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
// ❌ Instead — sleeps past the queue, then races it anyway
|
|
276
|
+
await publishOrder(queue, order);
|
|
277
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
278
|
+
|
|
279
|
+
expect(await findProjection(db, order.id)).toBeDefined();
|
|
280
|
+
|
|
281
|
+
// ✅ Write — poll the observable signal with a bounded deadline
|
|
282
|
+
await publishOrder(queue, order);
|
|
283
|
+
await waitFor(async () => (await findProjection(db, order.id)) !== null, {
|
|
284
|
+
timeout: 5_000,
|
|
285
|
+
description: "order projection",
|
|
286
|
+
});
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
// ❌ Instead — asserts a step that has not run yet
|
|
291
|
+
it("emits an audit event on save", async () => {
|
|
292
|
+
await saveUser(user);
|
|
293
|
+
|
|
294
|
+
expect(await auditLog.events()).toHaveLength(1);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// ✅ Write — await the event, then assert it
|
|
298
|
+
it("emits an audit event on save", async () => {
|
|
299
|
+
await saveUser(user);
|
|
300
|
+
const event = await waitForEvent(auditLog, { type: "user.saved" });
|
|
301
|
+
|
|
302
|
+
expect(event).toMatchObject({ userId: user.id });
|
|
303
|
+
});
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### Determinism at the edges
|
|
307
|
+
|
|
308
|
+
Pin the images, freeze the clock, and seed randomness. A test that pulls `latest` or reads the
|
|
309
|
+
wall clock fails eventually for reasons that have nothing to do with the code.
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
// ❌ Instead — floating image tag and the real clock
|
|
313
|
+
const container = await new GenericContainer("postgres:latest").start();
|
|
314
|
+
const invoice = createInvoice({ issuedAt: new Date() });
|
|
315
|
+
|
|
316
|
+
// ✅ Write — pinned image and an injected clock
|
|
317
|
+
const container = await new GenericContainer("postgres:16.4-alpine").start();
|
|
318
|
+
const clock = new FixedClock("2024-01-01T00:00:00Z");
|
|
319
|
+
const invoice = createInvoice({ issuedAt: clock.now() });
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
### What e2e should prove
|
|
323
|
+
|
|
324
|
+
E2E is the most expensive layer, so spend it only on what nothing else can prove: that the
|
|
325
|
+
parts are wired together, migrations ran, configuration loaded, and a user can complete the
|
|
326
|
+
journey. Keep the count small.
|
|
327
|
+
|
|
328
|
+
```ts
|
|
329
|
+
// ❌ Instead — reaches inside the system to assert call order
|
|
330
|
+
it("checks out", async () => {
|
|
331
|
+
await checkout(page);
|
|
332
|
+
|
|
333
|
+
expect(spyOn(inventoryService, "reserve")).toHaveBeenCalledBefore(
|
|
334
|
+
spyOn(paymentService, "charge"),
|
|
335
|
+
);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// ✅ Write — asserts what the journey produced
|
|
339
|
+
it("checks out", async () => {
|
|
340
|
+
await checkout(page);
|
|
341
|
+
|
|
342
|
+
expect(await page.textContent(".confirmation")).toContain("Thank you");
|
|
343
|
+
expect(await api.getOrder(orderRef)).toMatchObject({ state: "confirmed" });
|
|
344
|
+
});
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
## Assertions: targeted, never tautological
|
|
348
|
+
|
|
349
|
+
```ts
|
|
350
|
+
// ❌ Instead — every field asserted; any unrelated field change breaks the test
|
|
351
|
+
expect(created).toStrictEqual({
|
|
352
|
+
id: "u1",
|
|
353
|
+
status: "active",
|
|
354
|
+
createdAt: "2024-01-01T00:00:00.000Z",
|
|
355
|
+
updatedAt: "2024-01-01T00:00:00.000Z",
|
|
356
|
+
version: 3,
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// ✅ Write — assert the fields that carry the behaviour; use `expect.any` for the rest
|
|
360
|
+
expect(created).toMatchObject({ status: "active", id: expect.any(String) });
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
// ❌ Instead — the count is implied by the argument assertion
|
|
365
|
+
expect(mockSave).toHaveBeenCalledTimes(1);
|
|
366
|
+
expect(mockSave).toHaveBeenCalledWith(user);
|
|
367
|
+
|
|
368
|
+
// ✅ Write — the argument assertion already proves a single, correct call
|
|
369
|
+
expect(mockSave).toHaveBeenCalledWith(user);
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
```ts
|
|
373
|
+
// ❌ Instead — cannot fail; delete the implementation and this stays green
|
|
374
|
+
it("handles the request", async () => {
|
|
375
|
+
const result = await handler(request);
|
|
376
|
+
expect(result).toBeDefined();
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// ✅ Write — names the observable contract
|
|
380
|
+
it("returns 200 for a valid request", async () => {
|
|
381
|
+
const response = await handler(request);
|
|
382
|
+
|
|
383
|
+
expect(response.status).toBe(200);
|
|
384
|
+
});
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
```ts
|
|
388
|
+
// ❌ Instead — no assertion at all; a smoke test that proves only "it did not throw"
|
|
389
|
+
it("processes an order", async () => {
|
|
390
|
+
await processOrder(order);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// ✅ Write — assert the effect the call was supposed to have
|
|
394
|
+
it("marks a processed order as fulfilled", async () => {
|
|
395
|
+
await processOrder(order);
|
|
396
|
+
|
|
397
|
+
expect(await orders.get(order.id)).toMatchObject({ state: "fulfilled" });
|
|
398
|
+
});
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
## Structure and naming
|
|
402
|
+
|
|
403
|
+
```ts
|
|
404
|
+
// ❌ Instead — the name describes the implementation, and "works" says nothing
|
|
405
|
+
it("calls send once", ...);
|
|
406
|
+
it("works", ...);
|
|
407
|
+
|
|
408
|
+
// ✅ Write — the name states the behaviour the test proves
|
|
409
|
+
it("sends a confirmation email after a successful checkout", ...);
|
|
410
|
+
it("rejects a checkout with an expired card", ...);
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
```ts
|
|
414
|
+
// ❌ Instead — unrelated behaviours in one test: a failure does not localise
|
|
415
|
+
it("handles users", async () => {
|
|
416
|
+
expect(await createUser(valid)).toHaveProperty("id");
|
|
417
|
+
await expect(createUser(invalid)).rejects.toThrow();
|
|
418
|
+
expect(await listUsers()).toHaveLength(1);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// ✅ Write — one behaviour per test, so the failure name tells you what broke
|
|
422
|
+
it("assigns an id when creating a user", ...);
|
|
423
|
+
it("rejects a user with no email", ...);
|
|
424
|
+
it("lists every created user", ...);
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
```ts
|
|
428
|
+
// ❌ Instead — loops zero times and passes when there is nothing to check
|
|
429
|
+
for (const item of cart.items) {
|
|
430
|
+
expect(item.price).toBeGreaterThan(0);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ✅ Write — assert the collection, so an empty cart fails
|
|
434
|
+
expect(cart.items.map((item) => item.price)).toStrictEqual([10, 20, 30]);
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
## Determinism and async
|
|
438
|
+
|
|
439
|
+
```ts
|
|
440
|
+
// ❌ Instead — a sleep races the work and slows the suite
|
|
441
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
442
|
+
expect(job.state).toBe("done");
|
|
443
|
+
|
|
444
|
+
// ✅ Write — await the observable completion signal
|
|
445
|
+
await job.completion;
|
|
446
|
+
expect(job.state).toBe("done");
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
```ts
|
|
450
|
+
// ❌ Instead — real wall clock and random ids make the expected value unknowable
|
|
451
|
+
const invoice = { id: crypto.randomUUID(), issuedAt: new Date() };
|
|
452
|
+
|
|
453
|
+
// ✅ Write — fixed values in the factory; override only what the case needs
|
|
454
|
+
const createInvoice = (overrides: Partial<Invoice> = {}): Invoice => ({
|
|
455
|
+
id: "invoice-1",
|
|
456
|
+
issuedAt: new Date("2024-01-01T00:00:00Z"),
|
|
457
|
+
...overrides,
|
|
458
|
+
});
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
```ts
|
|
462
|
+
// ❌ Instead — the promise is never returned or awaited, so a rejection
|
|
463
|
+
// becomes an unhandled rejection and the test stays green
|
|
464
|
+
it("rejects an invalid payload", () => {
|
|
465
|
+
expect(handler(bad)).rejects.toThrow();
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
// ✅ Write — await the assertion
|
|
469
|
+
it("rejects an invalid payload", async () => {
|
|
470
|
+
await expect(handler(bad)).rejects.toThrow();
|
|
471
|
+
});
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
## Isolation
|
|
475
|
+
|
|
476
|
+
```ts
|
|
477
|
+
// ❌ Instead — focus and skip markers committed; the suite silently runs less
|
|
478
|
+
it.only("saves the user", ...);
|
|
479
|
+
it.skip("handles a timeout", ...);
|
|
480
|
+
|
|
481
|
+
// ✅ Write — remove the marker; fix the failing test or delete it
|
|
482
|
+
it("saves the user", ...);
|
|
483
|
+
it("handles a timeout", ...);
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
```ts
|
|
487
|
+
// ❌ Instead — a shared row mutated by both tests; order now matters
|
|
488
|
+
let row: Row;
|
|
489
|
+
|
|
490
|
+
beforeEach(async () => {
|
|
491
|
+
row = await db.insert(makeRow());
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it("updates the row", async () => {
|
|
495
|
+
await db.update(row.id, { name: "Ada" });
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("reads the row", async () => {
|
|
499
|
+
expect(await db.get(row.id)).toMatchObject({ name: "Original" });
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// ✅ Write — each test owns its data; no cross-test coupling
|
|
503
|
+
it("updates the row", async () => {
|
|
504
|
+
const row = await db.insert(makeRow());
|
|
505
|
+
await db.update(row.id, { name: "Ada" });
|
|
506
|
+
|
|
507
|
+
expect(await db.get(row.id)).toMatchObject({ name: "Ada" });
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it("reads the row as inserted", async () => {
|
|
511
|
+
const row = await db.insert(makeRow());
|
|
512
|
+
|
|
513
|
+
expect(await db.get(row.id)).toMatchObject({ name: "Original" });
|
|
514
|
+
});
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
`before`/`after` hooks are for expensive, non-observable infrastructure — starting a server,
|
|
518
|
+
opening a connection — never for data a test asserts on.
|
|
519
|
+
|
|
520
|
+
## Content assertions
|
|
521
|
+
|
|
522
|
+
Assert the behaviour the file participates in. A comparison against an expected file is a
|
|
523
|
+
content inventory; it breaks on a comment and proves nothing about the code.
|
|
524
|
+
|
|
525
|
+
```ts
|
|
526
|
+
// ❌ Instead — catalogues the generated file
|
|
527
|
+
const config = await readFile("generated/nginx.conf", "utf8");
|
|
528
|
+
expect(config).toBe("server { listen 80; }");
|
|
529
|
+
|
|
530
|
+
// ✅ Write — asserts what the generator did with the input
|
|
531
|
+
const config = await generateNginxConfig({ port: 8080 });
|
|
532
|
+
|
|
533
|
+
expect(config).toContain("listen 8080;");
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
```ts
|
|
537
|
+
// ❌ Instead — snapshots static markdown and calls it a test
|
|
538
|
+
expect(await renderReadme(project)).toMatchSnapshot();
|
|
539
|
+
|
|
540
|
+
// ✅ Write — asserts the renderer produced the section it was asked for
|
|
541
|
+
const readme = await renderReadme({ ...project, title: "Agent Cortex" });
|
|
542
|
+
|
|
543
|
+
expect(readme).toContain("# Agent Cortex");
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
A golden-output test is legitimate when the expected output is a **product of the behaviour
|
|
547
|
+
under test** — a parser's AST, a compiler's emitted artifact. It is illegitimate when the file
|
|
548
|
+
is static input the test simply re-reads.
|