@classytic/arc-testkit 0.6.0 → 0.7.0

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/dist/story.d.mts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { expect } from "vitest";
2
- type Clauses = string | readonly string[];
2
+ export type Clauses = string | readonly string[];
3
3
  /** `[REQ-1 | Reminders] the reminder reached the holder`. */
4
- declare function clauseMessage(clauses: Clauses | undefined, what: string): string;
5
- interface StepContext {
4
+ export declare function clauseMessage(clauses: Clauses | undefined, what: string): string;
5
+ export interface StepContext {
6
6
  /** The step's number in the story. */
7
7
  readonly n: number;
8
8
  readonly title: string;
@@ -16,14 +16,14 @@ interface StepContext {
16
16
  /** A known defect: soft, tagged `KNOWN DEFECT <id>` so the report names the open finding. */
17
17
  known<T>(defect: string, actual: T, what: string, clauses?: Clauses): ReturnType<typeof expect.soft<T>>;
18
18
  }
19
- interface StepOptions {
19
+ export interface StepOptions {
20
20
  clauses?: Clauses;
21
21
  /** Per-step timeout in ms. */
22
22
  timeout?: number;
23
23
  }
24
24
  /** What a step body is: the step context, then the vitest test context (fixtures of an extended `test`). */
25
- type StepBody<Ctx> = (t: StepContext, ctx: Ctx) => void | Promise<void>;
26
- interface Story<Ctx = unknown> {
25
+ export type StepBody<Ctx> = (t: StepContext, ctx: Ctx) => void | Promise<void>;
26
+ export interface Story<Ctx = unknown> {
27
27
  /** Register the next numbered step. */
28
28
  step(title: string, run: StepBody<Ctx>): void;
29
29
  step(title: string, options: StepOptions, run: StepBody<Ctx>): void;
@@ -31,10 +31,10 @@ interface Story<Ctx = unknown> {
31
31
  readonly next: number;
32
32
  }
33
33
  /** The registration function of an extended `test` (`test.extend(...)`), so steps receive its fixtures. */
34
- type StepRegistrar<Ctx> = (name: string, fn: (ctx: Ctx & {
34
+ export type StepRegistrar<Ctx> = (name: string, fn: (ctx: Ctx & {
35
35
  skip(note?: string): never;
36
36
  }) => Promise<void>, timeout?: number) => unknown;
37
- interface StoryOptions<Ctx = unknown> {
37
+ export interface StoryOptions<Ctx = unknown> {
38
38
  /** An extended `test`: steps then get its fixtures as their second argument. Default: vitest's `it`. */
39
39
  test?: StepRegistrar<Ctx>;
40
40
  /** Names of the extended test's fixtures the steps use; they arrive in the second argument. */
@@ -47,19 +47,19 @@ interface StoryOptions<Ctx = unknown> {
47
47
  clauses?: Clauses;
48
48
  }
49
49
  /** Registers a `describe` holding the story's numbered steps. */
50
- declare function describeStory<Ctx = unknown>(title: string, define: (story: Story<Ctx>) => void, options?: StoryOptions<Ctx>): void;
50
+ export declare function describeStory<Ctx = unknown>(title: string, define: (story: Story<Ctx>) => void, options?: StoryOptions<Ctx>): void;
51
51
  /** Read a handed-over value, with an error that names the missing step instead of a bare `undefined`. */
52
- declare function need<T>(bag: Readonly<Record<string, T | undefined>>, key: string): T;
52
+ export declare function need<T>(bag: Readonly<Record<string, T | undefined>>, key: string): T;
53
53
  type Row = Record<string, unknown>;
54
54
  type Awaitable<T> = T | PromiseLike<T>;
55
55
  /** A string / RegExp (tested on the row's key field), a function, or an object matched as a deep subset. */
56
- type RowMatch = string | RegExp | ((row: never) => boolean) | Row;
57
- interface FeedItem {
56
+ export type RowMatch = string | RegExp | ((row: never) => boolean) | Row;
57
+ export interface FeedItem {
58
58
  title?: string;
59
59
  message?: string;
60
60
  [key: string]: unknown;
61
61
  }
62
- interface StoryPorts<TActor = unknown> {
62
+ export interface StoryPorts<TActor = unknown> {
63
63
  /** Deliver everything pending in the transactional outbox (one pass). */
64
64
  drain?: () => Awaitable<unknown>;
65
65
  /** The domain events seen so far (see {@link createEventLog}). */
@@ -73,7 +73,7 @@ interface StoryPorts<TActor = unknown> {
73
73
  /** Field holding an event's type. Default: `type`. */
74
74
  eventTypeKey?: string;
75
75
  }
76
- interface Expectation {
76
+ export interface Expectation {
77
77
  clauses?: Clauses;
78
78
  /** Exact number of matching rows. */
79
79
  count?: number;
@@ -82,13 +82,13 @@ interface Expectation {
82
82
  /** Ignore the first `since` events (a mark from {@link EventLog.mark}). */
83
83
  since?: number;
84
84
  }
85
- interface RelayOptions {
85
+ export interface RelayOptions {
86
86
  timeout?: number;
87
87
  interval?: number;
88
88
  }
89
89
  /** True when `actual` carries every field of `expected` (RegExp and function leaves are tested, not compared). */
90
- declare function subsetMatches(actual: unknown, expected: unknown): boolean;
91
- interface StoryAssertions<TActor> {
90
+ export declare function subsetMatches(actual: unknown, expected: unknown): boolean;
91
+ export interface StoryAssertions<TActor> {
92
92
  /** Drain the outbox `rounds` times (a handler may write further events). */
93
93
  drain(rounds?: number): Promise<void>;
94
94
  /** Drain, then assert; repeat until the assertion holds (events are relayed asynchronously). */
@@ -104,8 +104,8 @@ interface StoryAssertions<TActor> {
104
104
  expectMail(match: RowMatch, spec?: Expectation): Promise<Row[]>;
105
105
  expectAudit(filter: Row | undefined, match: RowMatch, spec?: Expectation): Promise<Row[]>;
106
106
  }
107
- declare function createStoryAssertions<TActor = unknown>(ports: StoryPorts<TActor>): StoryAssertions<TActor>;
108
- interface EventLog<E extends Row = Row> {
107
+ export declare function createStoryAssertions<TActor = unknown>(ports: StoryPorts<TActor>): StoryAssertions<TActor>;
108
+ export interface EventLog<E extends Row = Row> {
109
109
  record(event: E): void;
110
110
  all(): readonly E[];
111
111
  /** The number of events so far: pass it as `since` to look only at what happens next. */
@@ -118,18 +118,18 @@ interface EventLog<E extends Row = Row> {
118
118
  tap(subscribe: (pattern: string, handler: (event: E) => unknown) => unknown, patterns: readonly string[]): void;
119
119
  }
120
120
  /** An in-memory event log fed by a tap on the host's event bus; the `events` port reads it. */
121
- declare function createEventLog<E extends Row = Row>(): EventLog<E>;
121
+ export declare function createEventLog<E extends Row = Row>(): EventLog<E>;
122
122
  /**
123
123
  * A cast: the named users of a story, each provisioned with roles by a host function that signs the
124
124
  * user up on the booted app and returns whatever the story talks through (a client, a token).
125
125
  */
126
- interface CastSpecEntry<TOpts> {
126
+ export interface CastSpecEntry<TOpts> {
127
127
  roles: readonly string[];
128
128
  opts?: TOpts;
129
129
  }
130
130
  /** `roles` shorthand, or `{ roles, opts }` when the host's provisioner takes options (e.g. `{ mfa: false }`). */
131
- type CastSpec<TOpts> = readonly string[] | CastSpecEntry<TOpts>;
132
- interface Cast<TActor, TOpts> {
131
+ export type CastSpec<TOpts> = readonly string[] | CastSpecEntry<TOpts>;
132
+ export interface Cast<TActor, TOpts> {
133
133
  /** Provision every member of `spec` (in order) and remember them by name. */
134
134
  build<const S extends Record<string, CastSpec<TOpts>>>(spec: S): Promise<{ [K in keyof S]: TActor; }>;
135
135
  /** Provision one member. */
@@ -138,15 +138,15 @@ interface Cast<TActor, TOpts> {
138
138
  get(name: string): TActor;
139
139
  names(): string[];
140
140
  }
141
- declare function createCast<TActor, TOpts = undefined>(provision: (roles: readonly string[], opts: TOpts | undefined, name: string) => Promise<TActor>): Cast<TActor, TOpts>;
141
+ export declare function createCast<TActor, TOpts = undefined>(provision: (roles: readonly string[], opts: TOpts | undefined, name: string) => Promise<TActor>): Cast<TActor, TOpts>;
142
142
  /** `Authorization` headers for a bearer token. */
143
- declare const bearer: (token: string) => Record<string, string>;
143
+ export declare const bearer: (token: string) => Record<string, string>;
144
144
  /**
145
145
  * A controllable clock with time-travel helpers, and the civil-date arithmetic a story needs to say
146
146
  * "thirty days later" without drifting across a zone's day boundary. The clock satisfies
147
147
  * `{ now(): Date }`, the shape hosts inject into their app; the story moves it, the app reads it.
148
148
  */
149
- interface StoryClock {
149
+ export interface StoryClock {
150
150
  /** The `{ now }` the app reads. */
151
151
  now(): Date;
152
152
  /** The IANA zone civil dates are read in. */
@@ -162,23 +162,23 @@ interface StoryClock {
162
162
  /** Called after every move with the new instant. */
163
163
  onChange(listener: (at: Date) => void): () => void;
164
164
  }
165
- interface StoryClockOptions {
165
+ export interface StoryClockOptions {
166
166
  start?: Date | string | number;
167
167
  /** IANA zone for civil dates. Default: UTC. */
168
168
  zone?: string;
169
169
  }
170
170
  /** The civil date (`YYYY-MM-DD`) of an instant in a zone. */
171
- declare function civilDateOf(at: Date, zone?: string): string;
171
+ export declare function civilDateOf(at: Date, zone?: string): string;
172
172
  /** A civil date plus whole days. */
173
- declare function addCivilDays(day: string, days: number): string;
173
+ export declare function addCivilDays(day: string, days: number): string;
174
174
  /** A civil date plus whole months (same day of month; keep stories off the 29th to 31st). */
175
- declare function addCivilMonths(day: string, months: number): string;
175
+ export declare function addCivilMonths(day: string, months: number): string;
176
176
  /** Whole days from civil date `a` to civil date `b`. */
177
- declare function daysBetween(a: string, b: string): number;
177
+ export declare function daysBetween(a: string, b: string): number;
178
178
  /** The instant at `hour:00` (default noon) on a civil date in a zone: a stable "that day". */
179
- declare function instantOn(day: string, zone?: string, hour?: number): Date;
180
- declare function createStoryClock(options?: StoryClockOptions): StoryClock;
181
- interface IdempotencyOptions<TResult, TSnapshot> {
179
+ export declare function instantOn(day: string, zone?: string, hour?: number): Date;
180
+ export declare function createStoryClock(options?: StoryClockOptions): StoryClock;
181
+ export interface IdempotencyOptions<TResult, TSnapshot> {
182
182
  /** The sweep. */
183
183
  run: () => Promise<TResult>;
184
184
  /** What must be identical after run 1 and after run 2 (counts, ids, rows). */
@@ -189,14 +189,14 @@ interface IdempotencyOptions<TResult, TSnapshot> {
189
189
  /** Names the sweep in the failure. */
190
190
  label?: string;
191
191
  }
192
- interface IdempotencyOutcome<TResult, TSnapshot> {
192
+ export interface IdempotencyOutcome<TResult, TSnapshot> {
193
193
  first: TResult;
194
194
  second: TResult;
195
195
  afterFirst: TSnapshot;
196
196
  afterSecond: TSnapshot;
197
197
  }
198
198
  /** Run twice; fail unless the snapshot after the second run equals the one after the first. */
199
- declare function expectIdempotent<TResult, TSnapshot>(opts: IdempotencyOptions<TResult, TSnapshot>): Promise<IdempotencyOutcome<TResult, TSnapshot>>;
199
+ export declare function expectIdempotent<TResult, TSnapshot>(opts: IdempotencyOptions<TResult, TSnapshot>): Promise<IdempotencyOutcome<TResult, TSnapshot>>;
200
200
  /** {@link expectIdempotent} for several named sweeps sharing one snapshot. */
201
- declare function expectSweepsIdempotent<TSnapshot>(sweeps: Readonly<Record<string, () => Promise<unknown>>>, common: Pick<IdempotencyOptions<unknown, TSnapshot>, "snapshot" | "settle" | "clauses">): Promise<void>;
202
- export { Cast, CastSpec, CastSpecEntry, Clauses, EventLog, Expectation, FeedItem, IdempotencyOptions, IdempotencyOutcome, RelayOptions, RowMatch, StepBody, StepContext, StepOptions, StepRegistrar, Story, StoryAssertions, StoryClock, StoryClockOptions, StoryOptions, StoryPorts, addCivilDays, addCivilMonths, bearer, civilDateOf, clauseMessage, createCast, createEventLog, createStoryAssertions, createStoryClock, daysBetween, describeStory, expectIdempotent, expectSweepsIdempotent, instantOn, need, subsetMatches };
201
+ export declare function expectSweepsIdempotent<TSnapshot>(sweeps: Readonly<Record<string, () => Promise<unknown>>>, common: Pick<IdempotencyOptions<unknown, TSnapshot>, "snapshot" | "settle" | "clauses">): Promise<void>;
202
+ export {};
package/llms.txt CHANGED
@@ -1,7 +1,7 @@
1
1
  # @classytic/arc-testkit
2
2
 
3
3
  > Test harness for arc apps: boot modules on in-memory MongoDB, pool servers, cost and plan asserts,
4
- > conformance guards and a story kit. Needs @classytic/arc >=2.43.0; the guards and stories need vitest >=3.
4
+ > conformance guards and a story kit. Needs @classytic/arc >=2.49.0 for the root entry (an optional peer); the guards and stories need vitest >=4.1.11.
5
5
 
6
6
  Hand-rolling any row below is a bug. Skill: skills/arc-testkit/SKILL.md (+ references/).
7
7
 
@@ -12,7 +12,7 @@ Hand-rolling any row below is a bug. Skill: skills/arc-testkit/SKILL.md (+ refer
12
12
  | Worker cap by cores and RAM | safeWorkerCount | /mongo |
13
13
  | Wait for index builds | awaitIndexBuilds | /mongo |
14
14
  | N+1 detection | countCommands, assertConstantCommands | /mongo-commands |
15
- | Index plan proof | assertIndexServed, summarizeExplain | /mongo-explain |
15
+ | Index plan proof | assertIndexServed, summarizeExplain, explainCaptured | /mongo-explain |
16
16
  | List routes answer the repo-core envelope | checkListEnvelopes, registerListEnvelopeGuard | /conformance |
17
17
  | Actions and write routes have schemas | checkRouteSchemas, registerRouteSchemaGuard | /conformance |
18
18
  | Verb and route-family naming | checkNaming, registerNamingGuard | /conformance |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/arc-testkit",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Boot a real @classytic/arc app around the module(s) under test on in-memory MongoDB — the ecosystem test harness. Thin Mongo-typed wrapper over @classytic/arc/testing (arc 2.22+). If your arc module boots green here, it composes into any arc host.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -60,11 +60,14 @@
60
60
  "mongodb-memory-server": ">=10.0.0"
61
61
  },
62
62
  "peerDependencies": {
63
- "vitest": ">=5.0.5",
64
- "@classytic/arc": ">=2.46.0",
63
+ "vitest": ">=4.1.11",
64
+ "@classytic/arc": ">=2.49.0",
65
65
  "mongoose": ">=9.11.4"
66
66
  },
67
67
  "peerDependenciesMeta": {
68
+ "@classytic/arc": {
69
+ "optional": true
70
+ },
68
71
  "mongoose": {
69
72
  "optional": true
70
73
  },
@@ -10,7 +10,7 @@ metadata:
10
10
 
11
11
  # @classytic/arc-testkit
12
12
 
13
- Needs `@classytic/arc >=2.43.0`; `mongoose >=9.4.1` for root, `/pool`, `/mongo*`; `/conformance` and `/story` need `vitest >=3` only. Guards are `check*` (returns problems) plus `register*` (vitest tests).
13
+ Root needs `@classytic/arc >=2.49.0`; `mongoose >=9.11.4` for root, `/pool`, `/mongo*`; `/conformance`, `/story` need only `vitest >=4.1.11`. Guards are `check*` (returns problems) + `register*` (vitest tests).
14
14
 
15
15
  ## Do not hand-roll
16
16
 
@@ -21,7 +21,7 @@ Needs `@classytic/arc >=2.43.0`; `mongoose >=9.4.1` for root, `/pool`, `/mongo*`
21
21
  | Worker cap by cores and RAM | `safeWorkerCount(envVar)` | `/mongo` |
22
22
  | Wait for index builds | `awaitIndexBuilds` | `/mongo` |
23
23
  | Count driver commands (N+1) | `countCommands`, `assertConstantCommands` | `/mongo-commands` |
24
- | Prove a query uses an index | `assertIndexServed`, `summarizeExplain` | `/mongo-explain` |
24
+ | Query uses an index | `assertIndexServed`, `summarizeExplain`, `explainCaptured` | `/mongo-explain` |
25
25
  | Every list answers the repo-core envelope | `checkListEnvelopes`, `registerListEnvelopeGuard` | `/conformance` |
26
26
  | Every action/write route has a schema | `checkRouteSchemas`, `registerRouteSchemaGuard` | `/conformance` |
27
27
  | Verb and route-family naming | `checkNaming`, `registerNamingGuard` | `/conformance` |
@@ -95,8 +95,7 @@ describeStory("checkout", (s) => {
95
95
  - `checkErrorContracts` needs a probe per class (`require` narrows them); its unknown-route probe also fails when the query string is echoed.
96
96
  - Fastify's own 404 body fails the contract: set a not-found handler.
97
97
  - `checkRouteSchemas` reads `app.arc.registry`: the app must be booted by arc.
98
- - A pool needs `globalSetup` to preboot, else every worker boots its own mongod. `countCommands` needs `monitorCommands: true`.
99
- - `loadOpenApiDump` throws when the dump is older than the `.ts` under `sources`: regenerate it.
98
+ - A pool needs `globalSetup` to preboot, else every worker boots its own mongod. `countCommands` needs `monitorCommands: true`.- `loadOpenApiDump` throws when the dump is older than the `.ts` under `sources`: regenerate it.
100
99
 
101
100
  ## Never
102
101
 
@@ -65,14 +65,14 @@ Options (`OpenApiOptions`): `spec` (document or getter; `fetchOpenApi(caller, pa
65
65
  ## Client drift
66
66
 
67
67
  `checkClientDrift({ spec, routes, bodyFields, minRoutes })`. `routes`: `[{ method, path, source? }]` the client can emit;
68
- a path may be concrete (`/api/x/507f...`), a template (`/api/x/:id`, `/api/x/{id}`), with a query or trailing slash.
69
- `bodyFields`: `[{ method, path: "/api/x/{id}/action", fields: [...], in: "request" | "response", status: "200", name? }]`
70
- (path spelled as the spec does; fields resolved through `$ref`, `oneOf`, `allOf`, `anyOf`). Failures: `client route
68
+ a path may be concrete (`/api/x/507f...`) or a template (`:id`, `{id}`), with a query or trailing slash.
69
+ `bodyFields`: `[{ method, path: "/api/x/{id}/action", fields: [...], values?: [...], in: "request" | "response", status: "200", name? }]`
70
+ (path as the spec spells it; through `$ref`, `oneOf`, `allOf`, `anyOf`; `values`: sent `enum`/`const` literals). Failures: `client route
71
71
  missing from the document: DELETE /api/x/:id/archive (source)`, `NAME: field "f" is not in the schema`, `NAME: the
72
- document declares no request schema`. Carry the spec between processes: `dumpOpenApi(spec, file)` on the server,
72
+ document declares no request schema`. Across processes: `dumpOpenApi(spec, file)` on the server,
73
73
  `loadOpenApiDump(file, { sources: [dirs], regenerate: "cd apps/server && npm run openapi:dump" })` on the client; it throws
74
- when the dump is missing or older than a `.ts` under `sources`. Exported helpers: `specMatcher`, `missingClientRoutes`,
75
- `bodyFieldProblems`, `propertyNames`, `resolveRef`. Needs the node environment (reads files).
74
+ when the dump is missing or older than a `.ts` under `sources`. Helpers: `specMatcher`, `missingClientRoutes`,
75
+ `bodyFieldProblems`, `propertyNames`, `schemaValues`, `resolveRef`. Node environment.
76
76
 
77
77
  ## Error contract
78
78
 
@@ -94,11 +94,12 @@ registered`, `/api/x/: a collection is addressed by its bare path`, `GET /api/x
94
94
 
95
95
  ## Refusal does no work
96
96
 
97
- `checkRefusalDoesNoWork({ app, anonymous, unprivileged, snapshot, routes, prefix, exclude, paramValue, refusedStatuses, settleMs, minRoutes })`.
98
- Calls every mutating route (default: POST PUT PATCH DELETE of the route table, `:param` filled with `paramValue`; or
99
- `routes: [{ method, path, body? }]`) as `anonymous` and as `unprivileged` (`InjectCaller`s). Each call must answer
100
- 401/403 and leave `snapshot()` (host state: row counts, a phase, an outbox length) deep-equal before and after. A gate
101
- that only calls `reply.send` still answers 403 when 2+ async `preSerialization`/`onSend` hooks defer the write, yet the
102
- handler runs: only the snapshot shows it. `exclude`: `METHOD /path` keys that accept the caller, with the reason.
103
- Failures: `anonymous POST /x: answered 403 but the snapshot changed: the handler ran after the refusal`, `unprivileged
104
- PATCH /x/1: answered 200, expected a refusal (401/403)`, `stale exclusion KEY`. Fix: `return reply.send(...)` or throw.
97
+ `checkRefusalDoesNoWork({ app, anonymous, unprivileged, snapshot, routes, prefix, exclude, paramValue, actionBodies, refusedStatuses, settleMs, minRoutes, name })`.
98
+ Calls every mutating route (default: POST PUT PATCH DELETE of the route table; or `routes: [{ method, path, body? }]`)
99
+ as `anonymous` and `unprivileged`; each must answer 401/403 and leave `snapshot()` deep-equal. A gate that only calls
100
+ `reply.send` answers 403 while the handler still runs: only the snapshot shows it.
101
+ `paramValue`: id or `({ method, path, resource }) => id`. `exclude`: `METHOD /path` or `resource.verb` keys with the
102
+ reason, one list or `{ anonymous, unprivileged }`. `actionBodies: { "resource.verb": body | (ctx) => body }`: one call
103
+ per verb on each action route (`{}` alone is a 400); no body fails. `name` titles the test.
104
+ Failures: `anonymous POST /x: answered 403 but the snapshot changed`, `unprivileged PATCH /x/1: answered 200, expected
105
+ a refusal (401/403)`, `stale exclusion KEY`. Fix: `return reply.send(...)` or throw.
@@ -90,3 +90,15 @@ const { stages, indexes, docsExamined } = summarizeExplain(explain);
90
90
  `assertIndexServed` fails on COLLSCAN, on a plan scanning no index (empty or missing collection), on an index in `index`
91
91
  that is not scanned, a stage in `stages` that is absent, and on `maxDocsExamined` exceeded (needs `executionStats`).
92
92
  Rejected plans and query predicates are never read.
93
+
94
+ A query built inside a repository or plugin is explained as sent, not copied into the test:
95
+
96
+ ```ts
97
+ import { assertIndexServed, explainCaptured } from "@classytic/arc-testkit/mongo-explain";
98
+
99
+ const { explain, command } = await explainCaptured(connection, () => service.claim(now), { collection: "timers", commands: ["findAndModify"] });
100
+ assertIndexServed(explain, { index: "state_due", maxDocsExamined: 6 });
101
+ ```
102
+
103
+ `explainCaptured(source, run, { collection, commands?, index?, verbosity? })` needs `monitorCommands: true`; it fails
104
+ when `run` sent no matching command, or several without `index`. The explain applies no write.