@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.
@@ -0,0 +1,92 @@
1
+ # Fixtures: boot, pool, workers, cost and plan asserts
2
+
3
+ ## bootModuleApp (`@classytic/arc-testkit`)
4
+
5
+ `bootModuleApp(modules, options?)` boots arc (`preset: "testing"`, `auth: false`, `sensible: false`) around module(s) on
6
+ in-memory MongoDB and returns `{ app, uri, connection, exports(name), close() }` (`close` is idempotent).
7
+
8
+ `modules` is a module, an array, or a factory `({ connection, mongoUri }) => module | module[]`.
9
+ `options`: `replset` (single-node replica set, needed for transactions), `database` (a `TestDatabaseFactory`, e.g.
10
+ `pool.factory`), `app` (extra `createApp` options merged over the defaults), `actor` (default actor for requests without
11
+ an `x-test-actor` header), `ports` (host ports for the modules).
12
+
13
+ ```ts
14
+ const t = await bootModuleApp(({ connection }) => [createThingModule({ connection, permissions })], { replset: true });
15
+ const engine = t.exports<ThingEngine>("thing"); // typed module export
16
+ await t.close();
17
+ ```
18
+
19
+ Types: `TestkitContext`, `ModulesInput`, `BootModuleAppOptions`, `ModuleTestApp`.
20
+
21
+ ## Pool (`/pool`, `/mongo`)
22
+
23
+ One server per run, one isolated database per suite. `createTestDatabasePool(provider, options?)` returns
24
+ `{ factory, withPool, leaseNamespace, globalSetup, stats }`.
25
+
26
+ ```ts
27
+ // tests/support/db-pool.ts
28
+ import { mongoServerProvider } from "@classytic/arc-testkit/mongo";
29
+ import { createTestDatabasePool } from "@classytic/arc-testkit/pool";
30
+ import mongoose from "mongoose";
31
+
32
+ export const dbPool = createTestDatabasePool(mongoServerProvider({ mongoose, awaitIndexes: false }), {
33
+ envPrefix: "APP_TEST",
34
+ preboot: ["transactional"],
35
+ });
36
+ // tests/support/global-setup.ts
37
+ export const setup = () => dbPool.globalSetup.setup();
38
+ export const teardown = () => dbPool.globalSetup.teardown();
39
+ // a suite
40
+ const boot = dbPool.withPool(bootModuleApp);
41
+ const lease = await dbPool.leaseNamespace("transactional"); // { uri, namespace, release() }, for a host that connects itself
42
+ ```
43
+
44
+ | Option | Default | Meaning |
45
+ |---|---|---|
46
+ | `envPrefix` | `ARC_TESTKIT` | env prefix of the cross-worker handshake; use one per run kind |
47
+ | `preboot` | `["transactional"]` | kinds `globalSetup.setup()` starts and publishes; `[]` only for a single-process run |
48
+ | `shareTransactionalServer` | `true` | one replica set answers `standalone` and `transactional` |
49
+ | `poolSize` | `1` | servers per kind; size with `safePoolSize(n)` (bounded by free RAM) |
50
+ | `slotOf` | vitest worker id | which server a worker uses |
51
+ | `stopWhenIdle` | `false` | stop servers after the last release (watch mode) |
52
+ | `disabled` | `false` | per-suite private servers |
53
+
54
+ `leaseNamespace(kind, { exact, forcePrivate, createOptions })`: `forcePrivate` gives a suite its own server (use it for
55
+ fail points and `setParameter`); `exact` stops a `standalone` request resolving to the shared replica set.
56
+ `mongoServerProvider({ args, awaitIndexes = true, mongoose })` builds the Mongo provider; it waits for index builds after
57
+ the app boots (`awaitIndexBuilds(connection?)`), which is what makes unique-index assertions deterministic. Inject the
58
+ `mongoose` instance your models register on. `publishPoolServers(servers, envPrefix)` publishes servers a custom
59
+ `globalSetup` started.
60
+
61
+ ## Workers and memory (`/mongo`)
62
+
63
+ - `safeWorkerCount(envVar = "ARC_TEST_WORKERS")`: explicit env value, else min(cores - 2, RAM / 6 GB), at least 2. Use in
64
+ `defineConfig({ test: { maxWorkers: safeWorkerCount("TEST_MAX_WORKERS") } })`.
65
+ - `safePoolSize(requested)`: servers affordable by free RAM, at least 1.
66
+ - `cacheSizeGB()` (env `TEST_MONGO_CACHE_GB`, floor 0.25), `mongodMemoryArgs()`, `withDbName(uri, dbName)`.
67
+ - One heavy command at a time on a shared machine; cap workers before running a whole suite.
68
+
69
+ ## Wire-command counts (`/mongo-commands`)
70
+
71
+ The mongoose connection must be created with `monitorCommands: true`, else `countCommands` throws.
72
+
73
+ ```ts
74
+ import { assertConstantCommands, countCommands } from "@classytic/arc-testkit/mongo-commands";
75
+
76
+ const { total, byCollection, byCommand, commands } = await countCommands(conn, () => placeOrder(), { collections: ["orders"], commands: ["find"], ignore: ["createIndexes"] });
77
+ await assertConstantCommands(conn, (n) => reader.page(n), [5, 50]); // same commands at every size, at least one: a per-item read fails
78
+ ```
79
+
80
+ ## Plan asserts (`/mongo-explain`)
81
+
82
+ ```ts
83
+ import { assertIndexServed, summarizeExplain } from "@classytic/arc-testkit/mongo-explain";
84
+
85
+ const explain = await Model.find(filter).explain("executionStats");
86
+ assertIndexServed(explain, { index: "org_state", stages: ["OR"], maxDocsExamined: 40 });
87
+ const { stages, indexes, docsExamined } = summarizeExplain(explain);
88
+ ```
89
+
90
+ `assertIndexServed` fails on COLLSCAN, on a plan scanning no index (empty or missing collection), on an index in `index`
91
+ that is not scanned, a stage in `stages` that is absent, and on `maxDocsExamined` exceeded (needs `executionStats`).
92
+ Rejected plans and query predicates are never read.
@@ -0,0 +1,3 @@
1
+ # List filters against the route
2
+
3
+ `checkFilterDeclarations({ spec, declarations })`; `declarations`: `[{ route, filters: [{ queryField, op? }], search?, sorts? }]` (op default `eq`, `between` = `gte` + `lte`). A filter must be a `field[op]` query parameter of the route's GET, or in arc's `x-arc-query` (`filterFields`, `operators`, `sortFields`); `search` needs the `search` parameter. Failure: `/api/x: filter status[contains] is not declared by the route`.
@@ -0,0 +1,80 @@
1
+ # Story kit (`@classytic/arc-testkit/story`)
2
+
3
+ One continuous scenario through a booted app. Steps are numbered vitest tests in one file (vitest runs them in order);
4
+ state passes through the host's own object. The kit knows no domain: roles, routes and clause ids are yours.
5
+
6
+ ## Steps
7
+
8
+ `describeStory(title, define, options?)`; `define` receives `s` with `s.step(title, [{ clauses, timeout }], body)`.
9
+ `body(t, ctx)`. Test names are `N. title`.
10
+
11
+ | Option | Default | Meaning |
12
+ |---|---|---|
13
+ | `firstStep` | `1` | number of the first step |
14
+ | `bail` | `true` | after a hard failure the remaining steps are skipped with "step N failed" |
15
+ | `clauses` | none | clause tags added to every step |
16
+ | `test`, `fixtures` | vitest `it` | an extended `test` and the fixture names steps use; they arrive as `ctx` |
17
+
18
+ `t.expect(actual, what)` is `expect` with the message `[CLAUSE-1 | CLAUSE-2] what`. `t.soft(...)` is `expect.soft`: the
19
+ failure is reported, the step continues. `t.known("DEFECT-7", actual, what)` is a soft assertion tagged
20
+ `KNOWN DEFECT DEFECT-7`, for an open finding that must stay visible without hiding later asserts. `t.msg(what)` builds the
21
+ tagged text for other matchers. `clauseMessage(clauses, what)` is the pure form. `need(bag, key)` reads a value an
22
+ earlier step produced and throws "the story needs KEY from an earlier step" instead of a bare `undefined`.
23
+
24
+ Reading a failure: the first line names the clauses and what was expected; the step number is in the test name; steps
25
+ after the first hard failure show as skipped (look at the first red step, not the skipped ones).
26
+
27
+ ```ts
28
+ describeStory("onboarding", (s) => {
29
+ s.step("a hire is captured", { clauses: ["HR-1"] }, async (t) => {
30
+ const res = await cast.get("hr").post("/api/people", payload);
31
+ t.expect(res.status, "the person is created").toBe(201);
32
+ t.known("D-3", res.body.tasks.length, "four onboarding tasks").toBe(4);
33
+ });
34
+ }, { firstStep: 0 });
35
+ ```
36
+
37
+ ## Cast
38
+
39
+ `createCast((roles, opts, name) => provisionedActor)`; the provisioner signs the user up on the app and returns what the
40
+ story talks through (a client, a token). `await cast.build({ ann: ["admin"], bob: { roles: ["viewer"], opts: { mfa: false } } })`
41
+ returns the actors by name; `cast.get(name)`, `cast.add(name, roles, opts?)`, `cast.names()`. `bearer(token)` returns
42
+ `{ authorization: "Bearer ..." }`. Pair with `injectCaller` from `/conformance` for `get`/`post`.
43
+
44
+ ## Clock and civil dates
45
+
46
+ `createStoryClock({ start, zone })` returns `{ now(), zone, set, advanceMs, advanceDays, travelTo(day, hour = 12), today(),
47
+ onChange(listener) }`; `now()` returns a fresh `Date`, so it fits any `{ now(): Date }` clock port. `travelTo` jumps to
48
+ `hour:00` of a civil date in the zone (DST-safe). Pure helpers: `civilDateOf(at, zone)`, `addCivilDays(day, n)`,
49
+ `addCivilMonths(day, n)`, `daysBetween(a, b)`, `instantOn(day, zone, hour)`. Dates are `YYYY-MM-DD` in the operator zone.
50
+
51
+ ## Evidence ports
52
+
53
+ `createStoryAssertions(ports)` where every port is optional and a missing one throws "no `mail` port was supplied" at the
54
+ call: `drain` (outbox pass), `events` (rows), `feed(actor)` (items with `title`, `message`), `mail` (rows), `audit(filter)`
55
+ (rows), `eventTypeKey` (default `type`).
56
+
57
+ | Method | Behaviour |
58
+ |---|---|
59
+ | `drain(rounds = 3)` | drain the outbox repeatedly (a handler may emit more) |
60
+ | `relayed(assertion, { timeout = 30000, interval = 50 })` | drain, then run the assertion, retrying until it holds |
61
+ | `expectEvent(match, { count, min, since, clauses })` | events matching; default at least one |
62
+ | `expectNoEvent(match, { since, clauses })` | none match |
63
+ | `expectFeed(actor, match, spec)`, `expectNoFeed(actor, match, spec)` | feed items whose title or message match |
64
+ | `expectMail(match, spec)` | mail rows; string matches `subject` |
65
+ | `expectAudit(filter, match, spec)` | audit rows; string matches `action` |
66
+ | `eventsOf(match, since)`, `feedOf(actor)` | the matching rows, for custom checks |
67
+
68
+ `match` is a string or RegExp (tested on the type, title, subject or action field), a predicate, or an object matched as a
69
+ deep subset (RegExp and function leaves are tested, arrays match when each expected element is found).
70
+ `createEventLog()` returns `{ record, all, mark, clear, tap(subscribe, patterns) }`; `tap` subscribes your bus
71
+ (`(pattern, handler) => ...`) and records every event; `mark()` returns a count to pass as `since` for "what happened
72
+ next". Feed `events: () => log.all()` into the ports.
73
+
74
+ ## Idempotency
75
+
76
+ `expectIdempotent({ run, snapshot, settle?, clauses?, label? })` runs the sweep, settles, snapshots, runs again, settles,
77
+ snapshots, and fails unless the two snapshots are equal (`LABEL run twice on one day changes nothing the second time`).
78
+ Returns `{ first, second, afterFirst, afterSecond }`. `expectSweepsIdempotent({ name: run, ... }, { snapshot, settle, clauses })`
79
+ does it for several sweeps. Snapshot what must not grow: row counts, ids, mail rows, feed items. Do not advance the
80
+ clock between the runs.