@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/README.md +44 -12
- package/dist/conformance.d.mts +380 -0
- package/dist/conformance.mjs +831 -0
- package/dist/index.d.mts +0 -2
- package/dist/index.mjs +1 -2
- package/dist/mongo-commands.d.mts +27 -0
- package/dist/mongo-commands.mjs +53 -0
- package/dist/mongo-explain.d.mts +32 -0
- package/dist/mongo-explain.mjs +66 -0
- package/dist/mongo.d.mts +0 -2
- package/dist/mongo.mjs +1 -2
- package/dist/pool.d.mts +0 -2
- package/dist/pool.mjs +3 -4
- package/dist/story.d.mts +202 -0
- package/dist/story.mjs +330 -0
- package/llms.txt +33 -0
- package/package.json +31 -3
- package/skills/arc-testkit/SKILL.md +113 -0
- package/skills/arc-testkit/references/conformance.md +104 -0
- package/skills/arc-testkit/references/fixtures.md +92 -0
- package/skills/arc-testkit/references/list-filters.md +3 -0
- package/skills/arc-testkit/references/story.md +80 -0
|
@@ -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.
|