@classytic/arc-testkit 0.5.0 → 0.6.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/index.d.mts CHANGED
@@ -1,10 +1,8 @@
1
1
  import { BootModuleAppOptions as BootModuleAppOptions$1, BootModulesInput, ModuleTestApp as ModuleTestApp$1, TestkitContext as TestkitContext$1 } from "@classytic/arc/testing";
2
2
  import { Connection } from "mongoose";
3
- //#region src/index.d.ts
4
3
  type TestkitContext = TestkitContext$1<Connection>;
5
4
  type ModulesInput = BootModulesInput<Connection>;
6
5
  type BootModuleAppOptions = BootModuleAppOptions$1<Connection>;
7
6
  type ModuleTestApp = ModuleTestApp$1<Connection>;
8
7
  declare function bootModuleApp(modules: ModulesInput, options?: BootModuleAppOptions): Promise<ModuleTestApp>;
9
- //#endregion
10
8
  export { BootModuleAppOptions, ModuleTestApp, ModulesInput, TestkitContext, bootModuleApp };
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { bootModuleApp as bootModuleApp$1 } from "@classytic/arc/testing";
2
- //#region src/index.ts
2
+ // src/index.ts
3
3
  /**
4
4
  * @classytic/arc-testkit — boots a REAL arc app around the module(s) under
5
5
  * test on in-memory MongoDB. The contributor contract: if your module boots
@@ -23,5 +23,4 @@ import { bootModuleApp as bootModuleApp$1 } from "@classytic/arc/testing";
23
23
  function bootModuleApp(modules, options = {}) {
24
24
  return bootModuleApp$1(modules, options);
25
25
  }
26
- //#endregion
27
26
  export { bootModuleApp };
@@ -1,5 +1,4 @@
1
1
  import { Connection } from "mongoose";
2
- //#region src/mongo-commands.d.ts
3
2
  type MongoClientLike = ReturnType<Connection["getClient"]>;
4
3
  interface CountCommandsOptions {
5
4
  /** Count only commands against these collections. */
@@ -25,5 +24,4 @@ interface CommandCount<T> {
25
24
  }
26
25
  declare function countCommands<T>(source: Connection | MongoClientLike, fn: () => Promise<T>, options?: CountCommandsOptions): Promise<CommandCount<T>>;
27
26
  declare function assertConstantCommands<T>(source: Connection | MongoClientLike, run: (size: number) => Promise<T>, sizes: readonly number[], options?: CountCommandsOptions): Promise<readonly CommandCount<T>[]>;
28
- //#endregion
29
27
  export { CommandCount, CountCommandsOptions, WireCommand, assertConstantCommands, countCommands };
@@ -1,4 +1,4 @@
1
- //#region src/mongo-commands.ts
1
+ // src/mongo-commands.ts
2
2
  async function countCommands(source, fn, options = {}) {
3
3
  const client = "getClient" in source ? source.getClient() : source;
4
4
  if (client.options.monitorCommands !== true) throw new Error("[arc-testkit] countCommands: the client was created without `monitorCommands: true` — the driver emits no command events, so every count would read 0");
@@ -50,5 +50,4 @@ function tally(keys) {
50
50
  for (const k of keys) out[k] = (out[k] ?? 0) + 1;
51
51
  return out;
52
52
  }
53
- //#endregion
54
53
  export { assertConstantCommands, countCommands };
@@ -1,4 +1,3 @@
1
- //#region src/mongo-explain.d.ts
2
1
  /**
3
2
  * Read a MongoDB `explain()` result — find, aggregate, classic or SBE — as the WINNING plan's stage
4
3
  * names, the indexes it scans, and its execution stats (`executionStats` verbosity). Rejected plans
@@ -30,5 +29,4 @@ interface IndexServedOptions {
30
29
  }
31
30
  declare function summarizeExplain(explain: unknown): ExplainSummary;
32
31
  declare function assertIndexServed(explain: unknown, options?: IndexServedOptions): ExplainSummary;
33
- //#endregion
34
32
  export { ExplainSummary, IndexServedOptions, assertIndexServed, summarizeExplain };
@@ -1,4 +1,4 @@
1
- //#region src/mongo-explain.ts
1
+ // src/mongo-explain.ts
2
2
  /** Keys whose subtree is a rejected alternative or a query predicate — never plan structure. */
3
3
  const SKIPPED = /* @__PURE__ */ new Set([
4
4
  "rejectedPlans",
@@ -63,5 +63,4 @@ function isRecord(value) {
63
63
  function numberOr(value) {
64
64
  return typeof value === "number" ? value : void 0;
65
65
  }
66
- //#endregion
67
66
  export { assertIndexServed, summarizeExplain };
package/dist/mongo.d.mts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { TestServerProvider } from "./pool.mjs";
2
2
  import { Connection } from "mongoose";
3
- //#region src/mongo.d.ts
4
3
  /** WiredTiger cache per server, in GB. 0.25 is mongod's own floor. */
5
4
  declare function cacheSizeGB(): number;
6
5
  /** mongod CLI args that bound a test server's footprint. */
@@ -83,5 +82,4 @@ interface MongoServerProviderOptions {
83
82
  * pool serve both kinds from one process when sharing is on.
84
83
  */
85
84
  declare function mongoServerProvider(options?: MongoServerProviderOptions): TestServerProvider<Connection>;
86
- //#endregion
87
85
  export { MongoServerProviderOptions, MongooseLike, awaitIndexBuilds, cacheSizeGB, mongoServerProvider, mongodMemoryArgs, safePoolSize, safeWorkerCount, withDbName };
package/dist/mongo.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { cpus, freemem, totalmem } from "node:os";
2
- //#region src/mongo.ts
2
+ // src/mongo.ts
3
3
  /**
4
4
  * `@classytic/arc-testkit/mongo` — the Mongo adapter for the driver-agnostic pool.
5
5
  *
@@ -225,5 +225,4 @@ function mongoServerProvider(options = {}) {
225
225
  }
226
226
  };
227
227
  }
228
- //#endregion
229
228
  export { awaitIndexBuilds, cacheSizeGB, mongoServerProvider, mongodMemoryArgs, safePoolSize, safeWorkerCount, withDbName };
package/dist/pool.d.mts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { TestDatabase, TestDatabaseFactory } from "@classytic/arc/testing";
2
- //#region src/pool.d.ts
3
2
  /** Which server shape a suite needs. */
4
3
  type ServerKind = "standalone" | "transactional";
5
4
  /** A running server the pool may hand out namespaces on. */
@@ -186,5 +185,4 @@ declare function createTestDatabasePool<TConn>(provider: TestServerProvider<TCon
186
185
  * `globalSetup` in a run that prefers boot-cost-up-front over laziness.
187
186
  */
188
187
  declare function publishPoolServers(servers: Partial<Record<ServerKind, readonly string[]>>, envPrefix?: string): void;
189
- //#endregion
190
188
  export { ServerKind, TestDatabasePool, TestDatabasePoolOptions, TestServerHandle, TestServerProvider, createTestDatabasePool, publishPoolServers };
package/dist/pool.mjs CHANGED
@@ -1,8 +1,8 @@
1
- import { randomBytes } from "node:crypto";
2
1
  import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
2
  import { join } from "node:path";
5
- //#region src/pool.ts
3
+ import { randomBytes } from "node:crypto";
4
+ import { tmpdir } from "node:os";
5
+ // src/pool.ts
6
6
  /**
7
7
  * `@classytic/arc-testkit/pool` — a POOLED `TestDatabaseFactory`, driver-agnostic.
8
8
  *
@@ -406,5 +406,4 @@ function publishPoolServers(servers, envPrefix = "ARC_TESTKIT") {
406
406
  }
407
407
  writeFileSync(file, JSON.stringify(servers));
408
408
  }
409
- //#endregion
410
409
  export { createTestDatabasePool, publishPoolServers };
@@ -0,0 +1,202 @@
1
+ import { expect } from "vitest";
2
+ type Clauses = string | readonly string[];
3
+ /** `[REQ-1 | Reminders] the reminder reached the holder`. */
4
+ declare function clauseMessage(clauses: Clauses | undefined, what: string): string;
5
+ interface StepContext {
6
+ /** The step's number in the story. */
7
+ readonly n: number;
8
+ readonly title: string;
9
+ readonly clauses: readonly string[];
10
+ /** The tagged message for an assertion in this step: `[clauses] what`. */
11
+ msg(what: string, clauses?: Clauses): string;
12
+ /** `expect(actual, "[clauses] what")`. */
13
+ expect<T>(actual: T, what: string, clauses?: Clauses): ReturnType<typeof expect<T>>;
14
+ /** `expect.soft`: a failure is reported at the end of the step, the step keeps running. */
15
+ soft<T>(actual: T, what: string, clauses?: Clauses): ReturnType<typeof expect.soft<T>>;
16
+ /** A known defect: soft, tagged `KNOWN DEFECT <id>` so the report names the open finding. */
17
+ known<T>(defect: string, actual: T, what: string, clauses?: Clauses): ReturnType<typeof expect.soft<T>>;
18
+ }
19
+ interface StepOptions {
20
+ clauses?: Clauses;
21
+ /** Per-step timeout in ms. */
22
+ timeout?: number;
23
+ }
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> {
27
+ /** Register the next numbered step. */
28
+ step(title: string, run: StepBody<Ctx>): void;
29
+ step(title: string, options: StepOptions, run: StepBody<Ctx>): void;
30
+ /** Number the next step will have. */
31
+ readonly next: number;
32
+ }
33
+ /** The registration function of an extended `test` (`test.extend(...)`), so steps receive its fixtures. */
34
+ type StepRegistrar<Ctx> = (name: string, fn: (ctx: Ctx & {
35
+ skip(note?: string): never;
36
+ }) => Promise<void>, timeout?: number) => unknown;
37
+ interface StoryOptions<Ctx = unknown> {
38
+ /** An extended `test`: steps then get its fixtures as their second argument. Default: vitest's `it`. */
39
+ test?: StepRegistrar<Ctx>;
40
+ /** Names of the extended test's fixtures the steps use; they arrive in the second argument. */
41
+ fixtures?: readonly (keyof Ctx & string)[];
42
+ /** Number of the first step. Default: 1. */
43
+ firstStep?: number;
44
+ /** Skip the remaining steps once one fails. Default: true. */
45
+ bail?: boolean;
46
+ /** Clauses added to every step. */
47
+ clauses?: Clauses;
48
+ }
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;
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;
53
+ type Row = Record<string, unknown>;
54
+ type Awaitable<T> = T | PromiseLike<T>;
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 {
58
+ title?: string;
59
+ message?: string;
60
+ [key: string]: unknown;
61
+ }
62
+ interface StoryPorts<TActor = unknown> {
63
+ /** Deliver everything pending in the transactional outbox (one pass). */
64
+ drain?: () => Awaitable<unknown>;
65
+ /** The domain events seen so far (see {@link createEventLog}). */
66
+ events?: () => Awaitable<readonly Row[]>;
67
+ /** A user's in-app feed, newest first or not: order is never asserted. */
68
+ feed?: (actor: TActor) => Awaitable<readonly FeedItem[]>;
69
+ /** Mails the dev outbox holds. */
70
+ mail?: () => Awaitable<readonly Row[]>;
71
+ /** Audit rows, optionally narrowed by the host-defined filter. */
72
+ audit?: (filter?: Row) => Awaitable<readonly Row[]>;
73
+ /** Field holding an event's type. Default: `type`. */
74
+ eventTypeKey?: string;
75
+ }
76
+ interface Expectation {
77
+ clauses?: Clauses;
78
+ /** Exact number of matching rows. */
79
+ count?: number;
80
+ /** At least this many (default 1 unless `count` is given). */
81
+ min?: number;
82
+ /** Ignore the first `since` events (a mark from {@link EventLog.mark}). */
83
+ since?: number;
84
+ }
85
+ interface RelayOptions {
86
+ timeout?: number;
87
+ interval?: number;
88
+ }
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> {
92
+ /** Drain the outbox `rounds` times (a handler may write further events). */
93
+ drain(rounds?: number): Promise<void>;
94
+ /** Drain, then assert; repeat until the assertion holds (events are relayed asynchronously). */
95
+ relayed<T>(assertion: () => T | Promise<T>, options?: RelayOptions): Promise<T>;
96
+ /** Events matching `match` (after `since`). */
97
+ eventsOf(match: RowMatch, since?: number): Promise<Row[]>;
98
+ expectEvent(match: RowMatch, spec?: Expectation): Promise<Row[]>;
99
+ expectNoEvent(match: RowMatch, spec?: Pick<Expectation, "clauses" | "since">): Promise<void>;
100
+ /** Feed items of `actor` whose title / message match. */
101
+ feedOf(actor: TActor): Promise<readonly FeedItem[]>;
102
+ expectFeed(actor: TActor, match: RowMatch, spec?: Expectation): Promise<readonly FeedItem[]>;
103
+ expectNoFeed(actor: TActor, match: RowMatch, spec?: Pick<Expectation, "clauses">): Promise<void>;
104
+ expectMail(match: RowMatch, spec?: Expectation): Promise<Row[]>;
105
+ expectAudit(filter: Row | undefined, match: RowMatch, spec?: Expectation): Promise<Row[]>;
106
+ }
107
+ declare function createStoryAssertions<TActor = unknown>(ports: StoryPorts<TActor>): StoryAssertions<TActor>;
108
+ interface EventLog<E extends Row = Row> {
109
+ record(event: E): void;
110
+ all(): readonly E[];
111
+ /** The number of events so far: pass it as `since` to look only at what happens next. */
112
+ mark(): number;
113
+ clear(): void;
114
+ /**
115
+ * Subscribe `patterns` through the host's bus (`subscribe(pattern, handler)`, e.g. `app.events.subscribe`)
116
+ * so every matching event is recorded.
117
+ */
118
+ tap(subscribe: (pattern: string, handler: (event: E) => unknown) => unknown, patterns: readonly string[]): void;
119
+ }
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>;
122
+ /**
123
+ * A cast: the named users of a story, each provisioned with roles by a host function that signs the
124
+ * user up on the booted app and returns whatever the story talks through (a client, a token).
125
+ */
126
+ interface CastSpecEntry<TOpts> {
127
+ roles: readonly string[];
128
+ opts?: TOpts;
129
+ }
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> {
133
+ /** Provision every member of `spec` (in order) and remember them by name. */
134
+ build<const S extends Record<string, CastSpec<TOpts>>>(spec: S): Promise<{ [K in keyof S]: TActor; }>;
135
+ /** Provision one member. */
136
+ add(name: string, roles: readonly string[], opts?: TOpts): Promise<TActor>;
137
+ /** A provisioned member; throws a story-readable error when the name was never added. */
138
+ get(name: string): TActor;
139
+ names(): string[];
140
+ }
141
+ declare function createCast<TActor, TOpts = undefined>(provision: (roles: readonly string[], opts: TOpts | undefined, name: string) => Promise<TActor>): Cast<TActor, TOpts>;
142
+ /** `Authorization` headers for a bearer token. */
143
+ declare const bearer: (token: string) => Record<string, string>;
144
+ /**
145
+ * A controllable clock with time-travel helpers, and the civil-date arithmetic a story needs to say
146
+ * "thirty days later" without drifting across a zone's day boundary. The clock satisfies
147
+ * `{ now(): Date }`, the shape hosts inject into their app; the story moves it, the app reads it.
148
+ */
149
+ interface StoryClock {
150
+ /** The `{ now }` the app reads. */
151
+ now(): Date;
152
+ /** The IANA zone civil dates are read in. */
153
+ readonly zone: string;
154
+ /** Jump to an instant. */
155
+ set(at: Date | string | number): void;
156
+ advanceMs(ms: number): void;
157
+ advanceDays(days: number): void;
158
+ /** Move to `hour:00` (default noon) in the zone on a civil date (`YYYY-MM-DD`). */
159
+ travelTo(day: string, hour?: number): Date;
160
+ /** The civil date now. */
161
+ today(): string;
162
+ /** Called after every move with the new instant. */
163
+ onChange(listener: (at: Date) => void): () => void;
164
+ }
165
+ interface StoryClockOptions {
166
+ start?: Date | string | number;
167
+ /** IANA zone for civil dates. Default: UTC. */
168
+ zone?: string;
169
+ }
170
+ /** The civil date (`YYYY-MM-DD`) of an instant in a zone. */
171
+ declare function civilDateOf(at: Date, zone?: string): string;
172
+ /** A civil date plus whole days. */
173
+ declare function addCivilDays(day: string, days: number): string;
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;
176
+ /** Whole days from civil date `a` to civil date `b`. */
177
+ declare function daysBetween(a: string, b: string): number;
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> {
182
+ /** The sweep. */
183
+ run: () => Promise<TResult>;
184
+ /** What must be identical after run 1 and after run 2 (counts, ids, rows). */
185
+ snapshot: () => Promise<TSnapshot>;
186
+ /** Deliver asynchronous effects (outbox drain) before each snapshot. */
187
+ settle?: () => Promise<unknown>;
188
+ clauses?: Clauses;
189
+ /** Names the sweep in the failure. */
190
+ label?: string;
191
+ }
192
+ interface IdempotencyOutcome<TResult, TSnapshot> {
193
+ first: TResult;
194
+ second: TResult;
195
+ afterFirst: TSnapshot;
196
+ afterSecond: TSnapshot;
197
+ }
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>>;
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 };
package/dist/story.mjs ADDED
@@ -0,0 +1,330 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ // src/story/runner.ts
3
+ /**
4
+ * The step runner of an end-to-end story: one continuous scenario through a booted app, each step a
5
+ * numbered vitest test whose failures name the clauses (requirements, contract lines) it proves.
6
+ *
7
+ * describeStory("checkout", (s) => {
8
+ * s.step("customer places an order", { clauses: ["ORD-1"] }, async (t) => {
9
+ * t.expect(res.status, "the order is accepted").toBe(201); // "[ORD-1] the order is accepted"
10
+ * t.known("DEF-7", rows.length, "one row per line").toBe(2); // soft: reported, the step goes on
11
+ * });
12
+ * });
13
+ *
14
+ * Tests inside a file run in order, so a story's steps hand state to each other through the host's
15
+ * own bag (`need` reads it with a clear error); once a step fails, later steps are skipped by
16
+ * default rather than failing in confusing ways.
17
+ */
18
+ /** `[REQ-1 | Reminders] the reminder reached the holder`. */
19
+ function clauseMessage(clauses, what) {
20
+ const list = clauses === void 0 ? [] : typeof clauses === "string" ? [clauses] : [...clauses];
21
+ return list.length === 0 ? what : `[${list.join(" | ")}] ${what}`;
22
+ }
23
+ /**
24
+ * A test function whose first parameter destructures `skip` and `fixtures`. Vitest reads that pattern
25
+ * from the function's source to decide which fixtures to build, and it supports neither a rest element
26
+ * nor a plain `ctx`, so the pattern is spelled out for the names the host lists.
27
+ */
28
+ function withFixtures(fixtures, run) {
29
+ for (const name of fixtures) if (!/^[A-Za-z_$][\w$]*$/.test(name)) throw new Error(`"${name}" is not a fixture name`);
30
+ const names = ["skip", ...fixtures.filter((n) => n !== "skip")];
31
+ return new Function("run", `return async ({ ${names.join(", ")} }) => run({ ${names.join(", ")} });`)(run);
32
+ }
33
+ function listOf(clauses) {
34
+ return clauses === void 0 ? [] : typeof clauses === "string" ? [clauses] : [...clauses];
35
+ }
36
+ /** Registers a `describe` holding the story's numbered steps. */
37
+ function describeStory(title, define, options = {}) {
38
+ const bail = options.bail ?? true;
39
+ let number = options.firstStep ?? 1;
40
+ let failedAt;
41
+ describe(title, () => {
42
+ define({
43
+ get next() {
44
+ return number;
45
+ },
46
+ step(stepTitle, a, b) {
47
+ const run = typeof a === "function" ? a : b;
48
+ const stepOptions = typeof a === "function" ? {} : a;
49
+ if (run === void 0) throw new Error(`step "${stepTitle}" has no body`);
50
+ const n = number++;
51
+ const clauses = [...listOf(options.clauses), ...listOf(stepOptions.clauses)];
52
+ const t = {
53
+ n,
54
+ title: stepTitle,
55
+ clauses,
56
+ msg: (what, extra) => clauseMessage([...clauses, ...listOf(extra)], what),
57
+ expect: (actual, what, extra) => expect(actual, clauseMessage([...clauses, ...listOf(extra)], what)),
58
+ soft: (actual, what, extra) => expect.soft(actual, clauseMessage([...clauses, ...listOf(extra)], what)),
59
+ known: (defect, actual, what, extra) => expect.soft(actual, clauseMessage([
60
+ `KNOWN DEFECT ${defect}`,
61
+ ...clauses,
62
+ ...listOf(extra)
63
+ ], what))
64
+ };
65
+ (options.test ?? it)(`${n}. ${stepTitle}`, withFixtures(options.fixtures ?? [], async (ctx) => {
66
+ if (bail && failedAt !== void 0) ctx.skip(`step ${failedAt} failed; the story cannot continue`);
67
+ try {
68
+ await run(t, ctx);
69
+ } catch (error) {
70
+ failedAt ??= n;
71
+ throw error;
72
+ }
73
+ }), stepOptions.timeout);
74
+ }
75
+ });
76
+ });
77
+ }
78
+ /** Read a handed-over value, with an error that names the missing step instead of a bare `undefined`. */
79
+ function need(bag, key) {
80
+ const value = bag[key];
81
+ if (value === void 0) throw new Error(`the story needs "${key}" from an earlier step (that step failed or did not run)`);
82
+ return value;
83
+ }
84
+ // src/story/assertions.ts
85
+ /**
86
+ * Story assertions against the places an app leaves evidence: the outbox / event log, a user's
87
+ * in-app feed, the dev mail outbox and the audit rows. The kit knows none of them: the host injects
88
+ * a reader per place (`StoryPorts`) and the assertions read through it. A missing port is an error
89
+ * at the call, not a silent pass.
90
+ */
91
+ /** True when `actual` carries every field of `expected` (RegExp and function leaves are tested, not compared). */
92
+ function subsetMatches(actual, expected) {
93
+ if (expected instanceof RegExp) return typeof actual === "string" && expected.test(actual);
94
+ if (typeof expected === "function") return Boolean(expected(actual));
95
+ if (expected === null || typeof expected !== "object") return Object.is(actual, expected);
96
+ if (Array.isArray(expected)) return Array.isArray(actual) && expected.every((e) => actual.some((a) => subsetMatches(a, e)));
97
+ if (actual === null || typeof actual !== "object") return false;
98
+ return Object.entries(expected).every(([k, v]) => subsetMatches(actual[k], v));
99
+ }
100
+ function matches(row, match, keyField) {
101
+ if (typeof match === "string") return row[keyField] === match;
102
+ if (match instanceof RegExp) return typeof row[keyField] === "string" && match.test(row[keyField]);
103
+ if (typeof match === "function") return Boolean(match(row));
104
+ return subsetMatches(row, match);
105
+ }
106
+ function check(count, spec, what) {
107
+ const message = clauseMessage(spec.clauses, what);
108
+ if (spec.count !== void 0) expect(count, `${message} (expected exactly ${spec.count})`).toBe(spec.count);
109
+ else expect(count, `${message} (expected at least ${spec.min ?? 1})`).toBeGreaterThanOrEqual(spec.min ?? 1);
110
+ }
111
+ function port(value, name) {
112
+ if (value === void 0) throw new Error(`this story asserts on ${name}, but no \`${name}\` port was supplied`);
113
+ return value;
114
+ }
115
+ function createStoryAssertions(ports) {
116
+ const typeKey = ports.eventTypeKey ?? "type";
117
+ const eventsAfter = async (since = 0) => [...await port(ports.events, "events")()].slice(since);
118
+ const matching = async (rows, match, key) => [...await rows].filter((r) => matches(r, match, key));
119
+ const feedKey = () => "title";
120
+ const self = {
121
+ async drain(rounds = 3) {
122
+ const drain = port(ports.drain, "drain");
123
+ for (let i = 0; i < rounds; i += 1) await drain();
124
+ },
125
+ relayed(assertion, options = {}) {
126
+ return vi.waitFor(async () => {
127
+ await self.drain();
128
+ return assertion();
129
+ }, {
130
+ timeout: options.timeout ?? 3e4,
131
+ interval: options.interval ?? 50
132
+ });
133
+ },
134
+ async eventsOf(match, since) {
135
+ return matching(await eventsAfter(since), match, typeKey);
136
+ },
137
+ async expectEvent(match, spec = {}) {
138
+ const found = await self.eventsOf(match, spec.since);
139
+ check(found.length, spec, `event ${describeMatch(match)} was recorded`);
140
+ return found;
141
+ },
142
+ async expectNoEvent(match, spec = {}) {
143
+ const found = await self.eventsOf(match, spec.since);
144
+ expect(found.length, clauseMessage(spec.clauses, `no event ${describeMatch(match)} was recorded`)).toBe(0);
145
+ },
146
+ async feedOf(actor) {
147
+ return port(ports.feed, "feed")(actor);
148
+ },
149
+ async expectFeed(actor, match, spec = {}) {
150
+ const found = (await self.feedOf(actor)).filter((i) => matchesFeed(i, match, feedKey()));
151
+ check(found.length, spec, `the feed holds ${describeMatch(match)}`);
152
+ return found;
153
+ },
154
+ async expectNoFeed(actor, match, spec = {}) {
155
+ const found = (await self.feedOf(actor)).filter((i) => matchesFeed(i, match, feedKey()));
156
+ expect(found.length, clauseMessage(spec.clauses, `the feed holds no ${describeMatch(match)}`)).toBe(0);
157
+ },
158
+ async expectMail(match, spec = {}) {
159
+ const found = await matching(port(ports.mail, "mail")(), match, "subject");
160
+ check(found.length, spec, `a mail ${describeMatch(match)} was sent`);
161
+ return found;
162
+ },
163
+ async expectAudit(filter, match, spec = {}) {
164
+ const found = await matching(port(ports.audit, "audit")(filter), match, "action");
165
+ check(found.length, spec, `an audit row ${describeMatch(match)} exists`);
166
+ return found;
167
+ }
168
+ };
169
+ return self;
170
+ }
171
+ /** A feed item matches a string / RegExp on its title or message. */
172
+ function matchesFeed(item, match, key) {
173
+ if (typeof match === "string" || match instanceof RegExp) return matches(item, match, key) || matches(item, match, "message");
174
+ return matches(item, match, key);
175
+ }
176
+ function describeMatch(match) {
177
+ if (typeof match === "string") return `"${match}"`;
178
+ if (match instanceof RegExp) return String(match);
179
+ if (typeof match === "function") return "(predicate)";
180
+ return JSON.stringify(match);
181
+ }
182
+ /** An in-memory event log fed by a tap on the host's event bus; the `events` port reads it. */
183
+ function createEventLog() {
184
+ const events = [];
185
+ return {
186
+ record: (event) => void events.push(event),
187
+ all: () => events,
188
+ mark: () => events.length,
189
+ clear: () => {
190
+ events.length = 0;
191
+ },
192
+ tap(subscribe, patterns) {
193
+ for (const pattern of patterns) subscribe(pattern, (event) => void events.push(event));
194
+ }
195
+ };
196
+ }
197
+ // src/story/cast.ts
198
+ function createCast(provision) {
199
+ const members = /* @__PURE__ */ new Map();
200
+ const add = async (name, roles, opts) => {
201
+ if (members.has(name)) throw new Error(`cast member "${name}" is already provisioned`);
202
+ const actor = await provision(roles, opts, name);
203
+ members.set(name, actor);
204
+ return actor;
205
+ };
206
+ return {
207
+ add,
208
+ async build(spec) {
209
+ const out = {};
210
+ for (const [name, entry] of Object.entries(spec)) {
211
+ const roles = Array.isArray(entry) ? entry : entry.roles;
212
+ const opts = Array.isArray(entry) ? void 0 : entry.opts;
213
+ out[name] = await add(name, roles, opts);
214
+ }
215
+ return out;
216
+ },
217
+ get(name) {
218
+ const actor = members.get(name);
219
+ if (actor === void 0) throw new Error(`the story needs cast member "${name}" (never provisioned)`);
220
+ return actor;
221
+ },
222
+ names: () => [...members.keys()]
223
+ };
224
+ }
225
+ /** `Authorization` headers for a bearer token. */
226
+ const bearer = (token) => ({ authorization: `Bearer ${token}` });
227
+ // src/story/clock.ts
228
+ const MS_PER_DAY = 864e5;
229
+ /** The civil date (`YYYY-MM-DD`) of an instant in a zone. */
230
+ function civilDateOf(at, zone = "UTC") {
231
+ const parts = new Intl.DateTimeFormat("en-CA", {
232
+ timeZone: zone,
233
+ year: "numeric",
234
+ month: "2-digit",
235
+ day: "2-digit"
236
+ }).formatToParts(at);
237
+ const get = (type) => parts.find((p) => p.type === type)?.value ?? "";
238
+ return `${get("year")}-${get("month")}-${get("day")}`;
239
+ }
240
+ /** A civil date plus whole days. */
241
+ function addCivilDays(day, days) {
242
+ return new Date(Date.parse(`${day}T00:00:00Z`) + days * MS_PER_DAY).toISOString().slice(0, 10);
243
+ }
244
+ /** A civil date plus whole months (same day of month; keep stories off the 29th to 31st). */
245
+ function addCivilMonths(day, months) {
246
+ const [y, m, d] = day.split("-").map(Number);
247
+ return new Date(Date.UTC(y, m - 1 + months, d)).toISOString().slice(0, 10);
248
+ }
249
+ /** Whole days from civil date `a` to civil date `b`. */
250
+ function daysBetween(a, b) {
251
+ return Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / MS_PER_DAY);
252
+ }
253
+ function offsetMs(at, zone) {
254
+ const parts = new Intl.DateTimeFormat("en-US", {
255
+ timeZone: zone,
256
+ hourCycle: "h23",
257
+ year: "numeric",
258
+ month: "2-digit",
259
+ day: "2-digit",
260
+ hour: "2-digit",
261
+ minute: "2-digit",
262
+ second: "2-digit"
263
+ }).formatToParts(new Date(at));
264
+ const get = (type) => Number(parts.find((p) => p.type === type)?.value);
265
+ return Date.UTC(get("year"), get("month") - 1, get("day"), get("hour"), get("minute"), get("second")) - Math.floor(at / 1e3) * 1e3;
266
+ }
267
+ /** The instant at `hour:00` (default noon) on a civil date in a zone: a stable "that day". */
268
+ function instantOn(day, zone = "UTC", hour = 12) {
269
+ const guess = Date.parse(`${day}T00:00:00Z`) + hour * 36e5;
270
+ const first = guess - offsetMs(guess, zone);
271
+ return new Date(guess - offsetMs(first, zone));
272
+ }
273
+ function createStoryClock(options = {}) {
274
+ const zone = options.zone ?? "UTC";
275
+ let current = new Date(options.start ?? Date.now());
276
+ const listeners = /* @__PURE__ */ new Set();
277
+ const move = (to) => {
278
+ current = to;
279
+ for (const listener of listeners) listener(new Date(current));
280
+ };
281
+ return {
282
+ zone,
283
+ now: () => new Date(current),
284
+ set: (at) => move(new Date(at)),
285
+ advanceMs: (ms) => move(new Date(current.getTime() + ms)),
286
+ advanceDays: (days) => move(new Date(current.getTime() + days * MS_PER_DAY)),
287
+ travelTo: (day, hour = 12) => {
288
+ const at = instantOn(day, zone, hour);
289
+ move(at);
290
+ return new Date(at);
291
+ },
292
+ today: () => civilDateOf(current, zone),
293
+ onChange: (listener) => {
294
+ listeners.add(listener);
295
+ return () => listeners.delete(listener);
296
+ }
297
+ };
298
+ }
299
+ // src/story/idempotency.ts
300
+ /**
301
+ * Run-twice idempotency for sweeps: run a scheduled job twice on the same clock day and prove the
302
+ * second run changed nothing (no duplicate assignments, notifications, mails). The host supplies the
303
+ * job and a `snapshot` of whatever must not grow; the kit compares.
304
+ */
305
+ /** Run twice; fail unless the snapshot after the second run equals the one after the first. */
306
+ async function expectIdempotent(opts) {
307
+ const first = await opts.run();
308
+ await opts.settle?.();
309
+ const afterFirst = await opts.snapshot();
310
+ const second = await opts.run();
311
+ await opts.settle?.();
312
+ const afterSecond = await opts.snapshot();
313
+ const label = opts.label ?? "the sweep";
314
+ expect(afterSecond, clauseMessage(opts.clauses, `${label} run twice on one day changes nothing the second time`)).toEqual(afterFirst);
315
+ return {
316
+ first,
317
+ second,
318
+ afterFirst,
319
+ afterSecond
320
+ };
321
+ }
322
+ /** {@link expectIdempotent} for several named sweeps sharing one snapshot. */
323
+ async function expectSweepsIdempotent(sweeps, common) {
324
+ for (const [label, run] of Object.entries(sweeps)) await expectIdempotent({
325
+ ...common,
326
+ run,
327
+ label
328
+ });
329
+ }
330
+ export { addCivilDays, addCivilMonths, bearer, civilDateOf, clauseMessage, createCast, createEventLog, createStoryAssertions, createStoryClock, daysBetween, describeStory, expectIdempotent, expectSweepsIdempotent, instantOn, need, subsetMatches };