@classytic/arc-testkit 0.4.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 };
@@ -0,0 +1,27 @@
1
+ import { Connection } from "mongoose";
2
+ type MongoClientLike = ReturnType<Connection["getClient"]>;
3
+ interface CountCommandsOptions {
4
+ /** Count only commands against these collections. */
5
+ readonly collections?: readonly string[];
6
+ /** Count only these command names (`find`, `aggregate`, `insert`, …). */
7
+ readonly commands?: readonly string[];
8
+ /** Never count these command names — e.g. `createIndexes` a model may run lazily. */
9
+ readonly ignore?: readonly string[];
10
+ }
11
+ interface WireCommand {
12
+ readonly command: string;
13
+ /** The collection the command targets; `undefined` for session, transaction and admin commands. */
14
+ readonly collection: string | undefined;
15
+ }
16
+ interface CommandCount<T> {
17
+ readonly result: T;
18
+ readonly total: number;
19
+ /** Counted commands, in the order the driver sent them. */
20
+ readonly commands: readonly WireCommand[];
21
+ /** Counts keyed by collection (`(none)` for commands without one). */
22
+ readonly byCollection: Readonly<Record<string, number>>;
23
+ readonly byCommand: Readonly<Record<string, number>>;
24
+ }
25
+ declare function countCommands<T>(source: Connection | MongoClientLike, fn: () => Promise<T>, options?: CountCommandsOptions): Promise<CommandCount<T>>;
26
+ declare function assertConstantCommands<T>(source: Connection | MongoClientLike, run: (size: number) => Promise<T>, sizes: readonly number[], options?: CountCommandsOptions): Promise<readonly CommandCount<T>[]>;
27
+ export { CommandCount, CountCommandsOptions, WireCommand, assertConstantCommands, countCommands };
@@ -0,0 +1,53 @@
1
+ // src/mongo-commands.ts
2
+ async function countCommands(source, fn, options = {}) {
3
+ const client = "getClient" in source ? source.getClient() : source;
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");
5
+ const commands = [];
6
+ const onStarted = (e) => {
7
+ const collection = collectionOf(e.commandName, e.command);
8
+ if (options.ignore?.includes(e.commandName)) return;
9
+ if (options.commands && !options.commands.includes(e.commandName)) return;
10
+ if (options.collections && (collection === void 0 || !options.collections.includes(collection))) return;
11
+ commands.push({
12
+ command: e.commandName,
13
+ collection
14
+ });
15
+ };
16
+ client.on("commandStarted", onStarted);
17
+ let result;
18
+ try {
19
+ result = await fn();
20
+ } finally {
21
+ client.off("commandStarted", onStarted);
22
+ }
23
+ return {
24
+ result,
25
+ total: commands.length,
26
+ commands,
27
+ byCollection: tally(commands.map((c) => c.collection ?? "(none)")),
28
+ byCommand: tally(commands.map((c) => c.command))
29
+ };
30
+ }
31
+ async function assertConstantCommands(source, run, sizes, options = {}) {
32
+ if (new Set(sizes).size < 2) throw new Error("[arc-testkit] assertConstantCommands: needs at least two distinct sizes");
33
+ const counts = [];
34
+ for (const size of sizes) counts.push(await countCommands(source, () => run(size), options));
35
+ const shape = (c) => c.commands.map((w) => `${w.collection ?? "(none)"}.${w.command}`).join(", ");
36
+ const [first] = counts;
37
+ if (!first || first.total === 0) throw new Error(`[arc-testkit] assertConstantCommands: size ${sizes[0]} sent no counted command — the measurement saw nothing`);
38
+ if (counts.some((c) => shape(c) !== shape(first))) {
39
+ const lines = counts.map((c, i) => ` at ${sizes[i]}: ${c.total} [${shape(c)}]`).join("\n");
40
+ throw new Error(`[arc-testkit] assertConstantCommands: the commands grow with the size\n${lines}`);
41
+ }
42
+ return counts;
43
+ }
44
+ function collectionOf(name, command) {
45
+ const target = name === "getMore" ? command.collection : command[name];
46
+ return typeof target === "string" ? target : void 0;
47
+ }
48
+ function tally(keys) {
49
+ const out = {};
50
+ for (const k of keys) out[k] = (out[k] ?? 0) + 1;
51
+ return out;
52
+ }
53
+ export { assertConstantCommands, countCommands };
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Read a MongoDB `explain()` result — find, aggregate, classic or SBE — as the WINNING plan's stage
3
+ * names, the indexes it scans, and its execution stats (`executionStats` verbosity). Rejected plans
4
+ * and query predicates are never read. `assertIndexServed` fails on a COLLSCAN, on a plan scanning
5
+ * no index (an explain of an empty or missing collection), and on every option not met.
6
+ *
7
+ * ```ts
8
+ * const explain = await Model.find({ organizationId, state: "posted" }).explain("executionStats");
9
+ * assertIndexServed(explain, { index: "org_state_date", maxDocsExamined: 40 });
10
+ * ```
11
+ */
12
+ interface ExplainSummary {
13
+ /** Stage names of the winning plan(s), outermost first. */
14
+ readonly stages: readonly string[];
15
+ /** Index names the winning plan scans (`_id_` for an id lookup). */
16
+ readonly indexes: readonly string[];
17
+ /** From the first `executionStats` block; `undefined` under `queryPlanner` verbosity. */
18
+ readonly docsExamined: number | undefined;
19
+ readonly keysExamined: number | undefined;
20
+ readonly returned: number | undefined;
21
+ }
22
+ interface IndexServedOptions {
23
+ /** Every one of these must be scanned. */
24
+ readonly index?: string | readonly string[];
25
+ /** Every one of these stages must appear — e.g. `OR` for a `$or` served arm by arm. */
26
+ readonly stages?: readonly string[];
27
+ /** Ceiling on `totalDocsExamined`; requires `executionStats` verbosity. */
28
+ readonly maxDocsExamined?: number;
29
+ }
30
+ declare function summarizeExplain(explain: unknown): ExplainSummary;
31
+ declare function assertIndexServed(explain: unknown, options?: IndexServedOptions): ExplainSummary;
32
+ export { ExplainSummary, IndexServedOptions, assertIndexServed, summarizeExplain };
@@ -0,0 +1,66 @@
1
+ // src/mongo-explain.ts
2
+ /** Keys whose subtree is a rejected alternative or a query predicate — never plan structure. */
3
+ const SKIPPED = /* @__PURE__ */ new Set([
4
+ "rejectedPlans",
5
+ "allPlansExecution",
6
+ "command",
7
+ "parsedQuery",
8
+ "filter",
9
+ "slotBasedPlan"
10
+ ]);
11
+ const ID_LOOKUPS = /* @__PURE__ */ new Set(["IDHACK", "EXPRESS_IDHACK"]);
12
+ function summarizeExplain(explain) {
13
+ const stages = [];
14
+ const indexes = [];
15
+ let stats;
16
+ const walk = (node, inPlan) => {
17
+ if (Array.isArray(node)) {
18
+ for (const item of node) walk(item, inPlan);
19
+ return;
20
+ }
21
+ if (!isRecord(node)) return;
22
+ if (inPlan && typeof node.stage === "string") {
23
+ stages.push(node.stage);
24
+ if (typeof node.indexName === "string") indexes.push(node.indexName);
25
+ else if (ID_LOOKUPS.has(node.stage)) indexes.push("_id_");
26
+ }
27
+ for (const [key, value] of Object.entries(node)) {
28
+ if (SKIPPED.has(key)) continue;
29
+ if (key === "executionStats" && isRecord(value) && typeof value.totalDocsExamined === "number") stats ??= value;
30
+ walk(value, inPlan || key === "winningPlan");
31
+ }
32
+ };
33
+ walk(explain, false);
34
+ return {
35
+ stages,
36
+ indexes: [...new Set(indexes)],
37
+ docsExamined: numberOr(stats?.totalDocsExamined),
38
+ keysExamined: numberOr(stats?.totalKeysExamined),
39
+ returned: numberOr(stats?.nReturned)
40
+ };
41
+ }
42
+ function assertIndexServed(explain, options = {}) {
43
+ const plan = summarizeExplain(explain);
44
+ const fail = (msg) => {
45
+ throw new Error(`[arc-testkit] assertIndexServed: ${msg} — plan [${plan.stages.join(" > ")}], indexes [${plan.indexes.join(", ")}]`);
46
+ };
47
+ if (plan.stages.length === 0) fail("no winning plan found — pass the result of explain()");
48
+ if (plan.stages.includes("COLLSCAN")) fail("the plan scans the collection");
49
+ if (plan.indexes.length === 0) fail("the plan scans no index — is the collection empty or missing?");
50
+ const unused = (typeof options.index === "string" ? [options.index] : options.index ?? []).filter((i) => !plan.indexes.includes(i));
51
+ if (unused.length > 0) fail(`index ${unused.map((i) => `"${i}"`).join(", ")} is not scanned`);
52
+ const absent = (options.stages ?? []).filter((s) => !plan.stages.includes(s));
53
+ if (absent.length > 0) fail(`stage ${absent.join(", ")} is not in the plan`);
54
+ if (options.maxDocsExamined !== void 0) {
55
+ if (plan.docsExamined === void 0) fail("maxDocsExamined needs explain('executionStats')");
56
+ else if (plan.docsExamined > options.maxDocsExamined) fail(`examined ${plan.docsExamined} documents, ceiling ${options.maxDocsExamined}`);
57
+ }
58
+ return plan;
59
+ }
60
+ function isRecord(value) {
61
+ return typeof value === "object" && value !== null;
62
+ }
63
+ function numberOr(value) {
64
+ return typeof value === "number" ? value : void 0;
65
+ }
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 };