@classytic/arc-testkit 0.5.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/README.md +52 -13
- package/dist/conformance.d.mts +439 -0
- package/dist/conformance.mjs +977 -0
- package/dist/index.d.mts +5 -8
- package/dist/index.mjs +1 -2
- package/dist/mongo-commands.d.mts +6 -8
- package/dist/mongo-commands.mjs +1 -2
- package/dist/mongo-explain.d.mts +57 -7
- package/dist/mongo-explain.mjs +72 -3
- package/dist/mongo.d.mts +122 -12
- package/dist/mongo.mjs +296 -10
- package/dist/pool.d.mts +13 -10
- package/dist/pool.mjs +27 -25
- package/dist/process-cleanup-1iBIS_o_.mjs +66 -0
- package/dist/story.d.mts +202 -0
- package/dist/story.mjs +330 -0
- package/llms.txt +33 -0
- package/package.json +26 -3
- package/skills/arc-testkit/SKILL.md +112 -0
- package/skills/arc-testkit/references/conformance.md +105 -0
- package/skills/arc-testkit/references/fixtures.md +104 -0
- package/skills/arc-testkit/references/list-filters.md +3 -0
- package/skills/arc-testkit/references/story.md +80 -0
package/dist/story.d.mts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { expect } from "vitest";
|
|
2
|
+
export type Clauses = string | readonly string[];
|
|
3
|
+
/** `[REQ-1 | Reminders] the reminder reached the holder`. */
|
|
4
|
+
export declare function clauseMessage(clauses: Clauses | undefined, what: string): string;
|
|
5
|
+
export 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
|
+
export 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
|
+
export type StepBody<Ctx> = (t: StepContext, ctx: Ctx) => void | Promise<void>;
|
|
26
|
+
export 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
|
+
export type StepRegistrar<Ctx> = (name: string, fn: (ctx: Ctx & {
|
|
35
|
+
skip(note?: string): never;
|
|
36
|
+
}) => Promise<void>, timeout?: number) => unknown;
|
|
37
|
+
export 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
|
+
export 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
|
+
export 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
|
+
export type RowMatch = string | RegExp | ((row: never) => boolean) | Row;
|
|
57
|
+
export interface FeedItem {
|
|
58
|
+
title?: string;
|
|
59
|
+
message?: string;
|
|
60
|
+
[key: string]: unknown;
|
|
61
|
+
}
|
|
62
|
+
export 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
|
+
export 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
|
+
export 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
|
+
export declare function subsetMatches(actual: unknown, expected: unknown): boolean;
|
|
91
|
+
export 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
|
+
export declare function createStoryAssertions<TActor = unknown>(ports: StoryPorts<TActor>): StoryAssertions<TActor>;
|
|
108
|
+
export 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
|
+
export 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
|
+
export 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
|
+
export type CastSpec<TOpts> = readonly string[] | CastSpecEntry<TOpts>;
|
|
132
|
+
export 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
|
+
export 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
|
+
export 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
|
+
export 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
|
+
export 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
|
+
export declare function civilDateOf(at: Date, zone?: string): string;
|
|
172
|
+
/** A civil date plus whole days. */
|
|
173
|
+
export 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
|
+
export declare function addCivilMonths(day: string, months: number): string;
|
|
176
|
+
/** Whole days from civil date `a` to civil date `b`. */
|
|
177
|
+
export 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
|
+
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
|
+
/** 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
|
+
export 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
|
+
export declare function expectIdempotent<TResult, TSnapshot>(opts: IdempotencyOptions<TResult, TSnapshot>): Promise<IdempotencyOutcome<TResult, TSnapshot>>;
|
|
200
|
+
/** {@link expectIdempotent} for several named sweeps sharing one snapshot. */
|
|
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/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 };
|
package/llms.txt
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# @classytic/arc-testkit
|
|
2
|
+
|
|
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.49.0 for the root entry (an optional peer); the guards and stories need vitest >=4.1.11.
|
|
5
|
+
|
|
6
|
+
Hand-rolling any row below is a bug. Skill: skills/arc-testkit/SKILL.md (+ references/).
|
|
7
|
+
|
|
8
|
+
| Need | Use | Import |
|
|
9
|
+
|---|---|---|
|
|
10
|
+
| Boot module(s) on a real DB | bootModuleApp | @classytic/arc-testkit |
|
|
11
|
+
| One mongod per run, DB per file | createTestDatabasePool, mongoServerProvider | /pool, /mongo |
|
|
12
|
+
| Worker cap by cores and RAM | safeWorkerCount | /mongo |
|
|
13
|
+
| Wait for index builds | awaitIndexBuilds | /mongo |
|
|
14
|
+
| N+1 detection | countCommands, assertConstantCommands | /mongo-commands |
|
|
15
|
+
| Index plan proof | assertIndexServed, summarizeExplain, explainCaptured | /mongo-explain |
|
|
16
|
+
| List routes answer the repo-core envelope | checkListEnvelopes, registerListEnvelopeGuard | /conformance |
|
|
17
|
+
| Actions and write routes have schemas | checkRouteSchemas, registerRouteSchemaGuard | /conformance |
|
|
18
|
+
| Verb and route-family naming | checkNaming, registerNamingGuard | /conformance |
|
|
19
|
+
| OpenAPI shape, security, secrets | checkOpenApiShape, checkSecurity, checkRoutesDocumented, checkAnonymousRefused, checkNoSecrets, registerOpenApiGuard | /conformance |
|
|
20
|
+
| List filters match the route | checkFilterDeclarations | /conformance |
|
|
21
|
+
| Client routes exist in the spec | checkClientDrift, registerClientDriftGuard, dumpOpenApi, loadOpenApiDump | /conformance |
|
|
22
|
+
| Error bodies are ErrorContract | checkErrorContracts, expectErrorContract, registerErrorContractGuard | /conformance |
|
|
23
|
+
| Bare collection paths | checkCollectionRoutes, registerCollectionRoutesGuard | /conformance |
|
|
24
|
+
| Refusal leaves no side effect | registerRefusalDoesNoWorkGuard | /conformance |
|
|
25
|
+
| App as caller, spec, route table | injectCaller, fetchOpenApi, routeTable, expectClean | /conformance |
|
|
26
|
+
| Numbered, clause-tagged steps | describeStory, need, clauseMessage | /story |
|
|
27
|
+
| Users with roles | createCast, bearer | /story |
|
|
28
|
+
| Time travel, civil dates | createStoryClock, civilDateOf, addCivilDays, addCivilMonths, daysBetween, instantOn | /story |
|
|
29
|
+
| Outbox, events, feed, mail, audit asserts | createStoryAssertions, createEventLog | /story |
|
|
30
|
+
| Sweep twice changes nothing | expectIdempotent, expectSweepsIdempotent | /story |
|
|
31
|
+
|
|
32
|
+
Rules: every guard is check* (returns problems) + register* (vitest tests); exception lists fail when stale;
|
|
33
|
+
fixture-based hosts call check* then expectClean inside the test; pass getters, not values, for an app booted in beforeAll.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/arc-testkit",
|
|
3
|
-
"version": "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": {
|
|
@@ -8,6 +8,14 @@
|
|
|
8
8
|
"types": "./dist/index.d.mts",
|
|
9
9
|
"default": "./dist/index.mjs"
|
|
10
10
|
},
|
|
11
|
+
"./conformance": {
|
|
12
|
+
"types": "./dist/conformance.d.mts",
|
|
13
|
+
"default": "./dist/conformance.mjs"
|
|
14
|
+
},
|
|
15
|
+
"./story": {
|
|
16
|
+
"types": "./dist/story.d.mts",
|
|
17
|
+
"default": "./dist/story.mjs"
|
|
18
|
+
},
|
|
11
19
|
"./pool": {
|
|
12
20
|
"types": "./dist/pool.d.mts",
|
|
13
21
|
"default": "./dist/pool.mjs"
|
|
@@ -29,6 +37,8 @@
|
|
|
29
37
|
"types": "./dist/index.d.mts",
|
|
30
38
|
"sideEffects": false,
|
|
31
39
|
"files": [
|
|
40
|
+
"skills",
|
|
41
|
+
"llms.txt",
|
|
32
42
|
"dist",
|
|
33
43
|
"README.md"
|
|
34
44
|
],
|
|
@@ -41,6 +51,7 @@
|
|
|
41
51
|
"scripts": {
|
|
42
52
|
"build": "tsdown",
|
|
43
53
|
"typecheck": "tsc --noEmit",
|
|
54
|
+
"check:skills": "vitest run --root ../.. packages/arc-testkit/tests/skills.test.ts",
|
|
44
55
|
"test": "vitest run --root ../.. packages/arc-testkit/tests",
|
|
45
56
|
"lint": "biome check src/",
|
|
46
57
|
"prepublishOnly": "node ../../scripts/prepublish-gate.mjs"
|
|
@@ -49,8 +60,20 @@
|
|
|
49
60
|
"mongodb-memory-server": ">=10.0.0"
|
|
50
61
|
},
|
|
51
62
|
"peerDependencies": {
|
|
52
|
-
"
|
|
53
|
-
"
|
|
63
|
+
"vitest": ">=4.1.11",
|
|
64
|
+
"@classytic/arc": ">=2.49.0",
|
|
65
|
+
"mongoose": ">=9.11.4"
|
|
66
|
+
},
|
|
67
|
+
"peerDependenciesMeta": {
|
|
68
|
+
"@classytic/arc": {
|
|
69
|
+
"optional": true
|
|
70
|
+
},
|
|
71
|
+
"mongoose": {
|
|
72
|
+
"optional": true
|
|
73
|
+
},
|
|
74
|
+
"vitest": {
|
|
75
|
+
"optional": true
|
|
76
|
+
}
|
|
54
77
|
},
|
|
55
78
|
"keywords": [
|
|
56
79
|
"arc",
|