@app-studio/qa-harness 0.2.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 +99 -0
- package/dist/app.d.ts +42 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +32 -0
- package/dist/app.js.map +1 -0
- package/dist/factory.d.ts +64 -0
- package/dist/factory.d.ts.map +1 -0
- package/dist/factory.js +72 -0
- package/dist/factory.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/nest/index.d.ts +67 -0
- package/dist/nest/index.d.ts.map +1 -0
- package/dist/nest/index.js +19 -0
- package/dist/nest/index.js.map +1 -0
- package/dist/replay/index.d.ts +76 -0
- package/dist/replay/index.d.ts.map +1 -0
- package/dist/replay/index.js +116 -0
- package/dist/replay/index.js.map +1 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# @app-studio/qa-harness
|
|
2
|
+
|
|
3
|
+
The shape of a test harness, and a record/replay transport whose missing
|
|
4
|
+
fixtures always fail.
|
|
5
|
+
|
|
6
|
+
## The harness
|
|
7
|
+
|
|
8
|
+
Two small types and a helper, deliberately:
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { defineActors, type TestApp } from '@app-studio/qa-harness';
|
|
12
|
+
|
|
13
|
+
export async function createTestApp(): Promise<TestApp<Client>> { /* your bootstrap */ }
|
|
14
|
+
|
|
15
|
+
const actors = defineActors(app, (app) => ({
|
|
16
|
+
signUp: (email: string) => /* … */,
|
|
17
|
+
createWorkspace: (owner: Actor) => /* … */,
|
|
18
|
+
addMember: (workspace: string, actor: Actor, role: Role) => /* … */,
|
|
19
|
+
}));
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
What a harness must *be* is a rule, not a library, and this package cannot
|
|
23
|
+
enforce it — only say it clearly:
|
|
24
|
+
|
|
25
|
+
**The application under test has to be the application that ships.** Same
|
|
26
|
+
composition, same HTTP configuration, same startup. A hand-assembled test app
|
|
27
|
+
validates something that does not exist in production, and the resulting green
|
|
28
|
+
suite is a statement about a program nobody runs. Write the factory against your
|
|
29
|
+
real bootstrap.
|
|
30
|
+
|
|
31
|
+
**Prefer listening on a real port** if the application attaches anything to the
|
|
32
|
+
HTTP server — WebSockets, most commonly. On a server that was never started, the
|
|
33
|
+
socket library's takeover of the request listeners races the framework's own
|
|
34
|
+
handler, and the occasional request falls through as a 404 on a route that
|
|
35
|
+
plainly exists. That failure is intermittent, which is the worst kind.
|
|
36
|
+
|
|
37
|
+
**A fresh client per actor.** Sharing one client across two roles is how an
|
|
38
|
+
authorization test quietly starts proving nothing.
|
|
39
|
+
|
|
40
|
+
Actors are what make authorization testing cheap enough to actually do. When "an
|
|
41
|
+
owner, a member and an outsider" costs three lines, every endpoint gets its
|
|
42
|
+
cross-tenant probe; when it costs thirty, the probe is what gets skipped — and
|
|
43
|
+
cross-tenant access is the single most common real-world API defect.
|
|
44
|
+
|
|
45
|
+
## Record and replay
|
|
46
|
+
|
|
47
|
+
For anything whose answer is neither stable nor free: a model, a payment
|
|
48
|
+
provider, a third-party search.
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { assertReplayBootSafety, createReplayTransport } from '@app-studio/qa-harness/replay';
|
|
52
|
+
|
|
53
|
+
assertReplayBootSafety({ mode: process.env.AI_MODE ?? 'live' });
|
|
54
|
+
|
|
55
|
+
const ask = createReplayTransport<Request, Response>({
|
|
56
|
+
mode: process.env.AI_MODE as ReplayMode,
|
|
57
|
+
dir: 'tests/fixtures/ai',
|
|
58
|
+
live: (request) => realProvider.complete(request),
|
|
59
|
+
source: 'gpt-4o-2026-01',
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Three rules, each the fix for a specific way this goes wrong.
|
|
64
|
+
|
|
65
|
+
**A missing fixture always fails.** Never a fallback, never a default answer. A
|
|
66
|
+
transport that quietly substitutes something plausible turns "this test no
|
|
67
|
+
longer asks the question it was written for" into a green run — the exact
|
|
68
|
+
failure the fixtures exist to prevent.
|
|
69
|
+
|
|
70
|
+
**The key is a digest of the request.** Not a filename somebody chose, and not
|
|
71
|
+
the test's name. Change what you ask and the fixture stops matching, so a suite
|
|
72
|
+
cannot keep replaying the answer to a question it no longer asks. Key order does
|
|
73
|
+
not affect the digest, so refactoring the request object does not invalidate
|
|
74
|
+
every recording.
|
|
75
|
+
|
|
76
|
+
**A fixture says where it came from.** A recording captured against a version of
|
|
77
|
+
a service that has since changed is indistinguishable from a fresh one, and
|
|
78
|
+
tests replaying it assert last year's behaviour with total confidence. The
|
|
79
|
+
provenance block is required on read; `qa-guard` denies committing a fixture
|
|
80
|
+
without one.
|
|
81
|
+
|
|
82
|
+
### The boot guard
|
|
83
|
+
|
|
84
|
+
`assertReplayBootSafety` refuses to start in `live` or `record` mode under
|
|
85
|
+
`NODE_ENV=test`. A guard at the transport is too late and too easy to bypass;
|
|
86
|
+
the point is that a process which *could* reach the live service never starts
|
|
87
|
+
inside a test run in the first place — a run that reaches a real service is
|
|
88
|
+
slow, costs money, and is not reproducible.
|
|
89
|
+
|
|
90
|
+
### Housekeeping
|
|
91
|
+
|
|
92
|
+
`unusedFixtures(dir, usedDigests)` lists recordings nothing asked for. Worth
|
|
93
|
+
checking after a recording session: a fixture nobody replays is either dead
|
|
94
|
+
weight or evidence that a code path stopped being exercised, and both are things
|
|
95
|
+
to know before they are a year old.
|
|
96
|
+
|
|
97
|
+
## Requirements
|
|
98
|
+
|
|
99
|
+
Node.js 20 or newer. No runtime dependencies beyond `@app-studio/qa-core`.
|
package/dist/app.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shape of a test harness.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately thin. What the harness must *be* is a rule, not a library: the
|
|
5
|
+
* application under test has to be the application that ships — the same
|
|
6
|
+
* composition, the same HTTP configuration, the same startup — because a
|
|
7
|
+
* hand-assembled test app validates something that does not exist in
|
|
8
|
+
* production. That rule cannot be enforced by a type, only by writing the
|
|
9
|
+
* factory against the real bootstrap; what this package can do is give the
|
|
10
|
+
* factory a common shape so actors, resets and fixtures compose across
|
|
11
|
+
* projects.
|
|
12
|
+
*
|
|
13
|
+
* One detail is worth stating because it is invisible until it bites: prefer
|
|
14
|
+
* *listening* on a real port over an in-process handler if the application
|
|
15
|
+
* attaches anything to the HTTP server — WebSockets, most commonly. On a server
|
|
16
|
+
* that was never started, the socket library's takeover of the request
|
|
17
|
+
* listeners races the framework's own handler, and the occasional request falls
|
|
18
|
+
* through as a 404 on a route that plainly exists.
|
|
19
|
+
*/
|
|
20
|
+
export interface TestApp<Client = unknown> {
|
|
21
|
+
/** Where the application is listening, e.g. `http://127.0.0.1:53314`. */
|
|
22
|
+
baseUrl: string;
|
|
23
|
+
/**
|
|
24
|
+
* A fresh client per call — one per actor, so their sessions stay separate.
|
|
25
|
+
* Sharing one client across two roles is how an authorization test quietly
|
|
26
|
+
* starts proving nothing.
|
|
27
|
+
*/
|
|
28
|
+
client(): Client;
|
|
29
|
+
close(): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
export type AppFactory<Options, Client> = (options?: Options) => Promise<TestApp<Client>>;
|
|
32
|
+
/**
|
|
33
|
+
* Binds a set of actor helpers to a running application.
|
|
34
|
+
*
|
|
35
|
+
* Actors are what make authorization testing cheap enough to actually do. When
|
|
36
|
+
* "an owner, a member and an outsider" costs three lines, every endpoint gets
|
|
37
|
+
* its cross-tenant probe; when it costs thirty, the probe is the thing that
|
|
38
|
+
* gets skipped — and cross-tenant access is the single most common real-world
|
|
39
|
+
* API defect.
|
|
40
|
+
*/
|
|
41
|
+
export declare function defineActors<Client, Actors extends Record<string, unknown>>(app: TestApp<Client>, build: (app: TestApp<Client>) => Actors): Actors;
|
|
42
|
+
//# sourceMappingURL=app.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,MAAM,WAAW,OAAO,CAAC,MAAM,GAAG,OAAO;IACvC,yEAAyE;IACzE,OAAO,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,MAAM,IAAI,MAAM,CAAC;IACjB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,MAAM,MAAM,UAAU,CAAC,OAAO,EAAE,MAAM,IAAI,CAAC,OAAO,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC;AAE1F;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACzE,GAAG,EAAE,OAAO,CAAC,MAAM,CAAC,EACpB,KAAK,EAAE,CAAC,GAAG,EAAE,OAAO,CAAC,MAAM,CAAC,KAAK,MAAM,GACtC,MAAM,CAER"}
|
package/dist/app.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shape of a test harness.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately thin. What the harness must *be* is a rule, not a library: the
|
|
5
|
+
* application under test has to be the application that ships — the same
|
|
6
|
+
* composition, the same HTTP configuration, the same startup — because a
|
|
7
|
+
* hand-assembled test app validates something that does not exist in
|
|
8
|
+
* production. That rule cannot be enforced by a type, only by writing the
|
|
9
|
+
* factory against the real bootstrap; what this package can do is give the
|
|
10
|
+
* factory a common shape so actors, resets and fixtures compose across
|
|
11
|
+
* projects.
|
|
12
|
+
*
|
|
13
|
+
* One detail is worth stating because it is invisible until it bites: prefer
|
|
14
|
+
* *listening* on a real port over an in-process handler if the application
|
|
15
|
+
* attaches anything to the HTTP server — WebSockets, most commonly. On a server
|
|
16
|
+
* that was never started, the socket library's takeover of the request
|
|
17
|
+
* listeners races the framework's own handler, and the occasional request falls
|
|
18
|
+
* through as a 404 on a route that plainly exists.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Binds a set of actor helpers to a running application.
|
|
22
|
+
*
|
|
23
|
+
* Actors are what make authorization testing cheap enough to actually do. When
|
|
24
|
+
* "an owner, a member and an outsider" costs three lines, every endpoint gets
|
|
25
|
+
* its cross-tenant probe; when it costs thirty, the probe is the thing that
|
|
26
|
+
* gets skipped — and cross-tenant access is the single most common real-world
|
|
27
|
+
* API defect.
|
|
28
|
+
*/
|
|
29
|
+
export function defineActors(app, build) {
|
|
30
|
+
return build(app);
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=app.js.map
|
package/dist/app.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"app.js","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAgBH;;;;;;;;GAQG;AACH,MAAM,UAAU,YAAY,CAC1B,GAAoB,EACpB,KAAuC;IAEvC,OAAO,KAAK,CAAC,GAAG,CAAC,CAAC;AACpB,CAAC"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Factories for test data.
|
|
3
|
+
*
|
|
4
|
+
* The problem they solve is not typing convenience. It is that hand-written
|
|
5
|
+
* fixtures collide: two tests both build "a user" with the same email, they
|
|
6
|
+
* pass alone and fail together, and the failure looks like a bug in the
|
|
7
|
+
* application. Every value that has to be unique comes from a sequence here, so
|
|
8
|
+
* the collision cannot happen.
|
|
9
|
+
*
|
|
10
|
+
* The second thing they solve is intent. `buildUser({ email: 'a@b.c', name: 'x',
|
|
11
|
+
* role: 'ADMIN', workspaceId: 'w1', … })` buries the one field the test is
|
|
12
|
+
* about in nine that it is not. A factory states the default world once and lets
|
|
13
|
+
* each test override only what it is testing — so the override *is* the
|
|
14
|
+
* documentation of what the test is about.
|
|
15
|
+
*/
|
|
16
|
+
export interface FactoryContext {
|
|
17
|
+
/** Increments on every build. Use it for anything that must be unique. */
|
|
18
|
+
sequence: number;
|
|
19
|
+
}
|
|
20
|
+
export type Trait<T> = Partial<T> | ((context: FactoryContext) => Partial<T>);
|
|
21
|
+
export interface FactoryDefinition<T, TraitName extends string = string> {
|
|
22
|
+
/** The ordinary case — what this thing looks like when nothing special is going on. */
|
|
23
|
+
build(context: FactoryContext): T;
|
|
24
|
+
/**
|
|
25
|
+
* Named variations, e.g. `expired`, `admin`, `deleted`.
|
|
26
|
+
*
|
|
27
|
+
* A trait is worth having when a variation recurs across tests; a one-off
|
|
28
|
+
* difference is better expressed as an override at the call site, where the
|
|
29
|
+
* reader can see it.
|
|
30
|
+
*/
|
|
31
|
+
traits?: Record<TraitName, Trait<T>>;
|
|
32
|
+
}
|
|
33
|
+
export interface Factory<T, TraitName extends string = string> {
|
|
34
|
+
build(overrides?: Partial<T>): T;
|
|
35
|
+
buildList(count: number, overrides?: Partial<T> | ((index: number) => Partial<T>)): T[];
|
|
36
|
+
/** A factory with these traits applied, left to right. Does not mutate this one. */
|
|
37
|
+
with(...traits: TraitName[]): Factory<T, TraitName>;
|
|
38
|
+
/**
|
|
39
|
+
* Resets the sequence.
|
|
40
|
+
*
|
|
41
|
+
* Rarely what you want: a suite that needs the sequence reset to pass is a
|
|
42
|
+
* suite asserting on generated values, which will break the next time
|
|
43
|
+
* anything else builds one first. Provided for the case where a snapshot
|
|
44
|
+
* genuinely has to be stable.
|
|
45
|
+
*/
|
|
46
|
+
reset(): void;
|
|
47
|
+
}
|
|
48
|
+
export declare function defineFactory<T, TraitName extends string = string>(definition: FactoryDefinition<T, TraitName>, applied?: TraitName[]): Factory<T, TraitName>;
|
|
49
|
+
/**
|
|
50
|
+
* Pairs a factory with something that persists what it builds.
|
|
51
|
+
*
|
|
52
|
+
* Kept separate from `defineFactory` so the pure half stays usable in a unit
|
|
53
|
+
* test with no database, and so persistence goes through the application's own
|
|
54
|
+
* services rather than an insert — the same rule as the seeds. A record written
|
|
55
|
+
* straight to the database has none of the side effects the real path produces,
|
|
56
|
+
* and every test that then relies on those is testing a world the application
|
|
57
|
+
* cannot make.
|
|
58
|
+
*/
|
|
59
|
+
export declare function persisted<T, Persisted = T, TraitName extends string = string>(factory: Factory<T, TraitName>, persist: (attributes: T) => Promise<Persisted>): {
|
|
60
|
+
build: Factory<T, TraitName>['build'];
|
|
61
|
+
create(overrides?: Partial<T>): Promise<Persisted>;
|
|
62
|
+
createList(count: number, overrides?: Partial<T> | ((index: number) => Partial<T>)): Promise<Persisted[]>;
|
|
63
|
+
};
|
|
64
|
+
//# sourceMappingURL=factory.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"factory.d.ts","sourceRoot":"","sources":["../src/factory.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,MAAM,WAAW,cAAc;IAC7B,0EAA0E;IAC1E,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,MAAM,KAAK,CAAC,CAAC,IAAI,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,cAAc,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC;AAE9E,MAAM,WAAW,iBAAiB,CAAC,CAAC,EAAE,SAAS,SAAS,MAAM,GAAG,MAAM;IACrE,uFAAuF;IACvF,KAAK,CAAC,OAAO,EAAE,cAAc,GAAG,CAAC,CAAC;IAClC;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;CACtC;AAED,MAAM,WAAW,OAAO,CAAC,CAAC,EAAE,SAAS,SAAS,MAAM,GAAG,MAAM;IAC3D,KAAK,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;IACjC,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC;IACxF,oFAAoF;IACpF,IAAI,CAAC,GAAG,MAAM,EAAE,SAAS,EAAE,GAAG,OAAO,CAAC,CAAC,EAAE,SAAS,CAAC,CAAC;IACpD;;;;;;;OAOG;IACH,KAAK,IAAI,IAAI,CAAC;CACf;AAED,wBAAgB,aAAa,CAAC,CAAC,EAAE,SAAS,SAAS,MAAM,GAAG,MAAM,EAChE,UAAU,EAAE,iBAAiB,CAAC,CAAC,EAAE,SAAS,CAAC,EAC3C,OAAO,GAAE,SAAS,EAAO,GACxB,OAAO,CAAC,CAAC,EAAE,SAAS,CAAC,CAyCvB;AAED;;;;;;;;;GASG;AACH,wBAAgB,SAAS,CAAC,CAAC,EAAE,SAAS,GAAG,CAAC,EAAE,SAAS,SAAS,MAAM,GAAG,MAAM,EAC3E,OAAO,EAAE,OAAO,CAAC,CAAC,EAAE,SAAS,CAAC,EAC9B,OAAO,EAAE,CAAC,UAAU,EAAE,CAAC,KAAK,OAAO,CAAC,SAAS,CAAC,GAC7C;IACD,KAAK,EAAE,OAAO,CAAC,CAAC,EAAE,SAAS,CAAC,CAAC,OAAO,CAAC,CAAC;IACtC,MAAM,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC;IACnD,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC,CAAC;CAC3G,CAcA"}
|
package/dist/factory.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Factories for test data.
|
|
3
|
+
*
|
|
4
|
+
* The problem they solve is not typing convenience. It is that hand-written
|
|
5
|
+
* fixtures collide: two tests both build "a user" with the same email, they
|
|
6
|
+
* pass alone and fail together, and the failure looks like a bug in the
|
|
7
|
+
* application. Every value that has to be unique comes from a sequence here, so
|
|
8
|
+
* the collision cannot happen.
|
|
9
|
+
*
|
|
10
|
+
* The second thing they solve is intent. `buildUser({ email: 'a@b.c', name: 'x',
|
|
11
|
+
* role: 'ADMIN', workspaceId: 'w1', … })` buries the one field the test is
|
|
12
|
+
* about in nine that it is not. A factory states the default world once and lets
|
|
13
|
+
* each test override only what it is testing — so the override *is* the
|
|
14
|
+
* documentation of what the test is about.
|
|
15
|
+
*/
|
|
16
|
+
export function defineFactory(definition, applied = []) {
|
|
17
|
+
let sequence = 0;
|
|
18
|
+
const factory = {
|
|
19
|
+
build(overrides = {}) {
|
|
20
|
+
sequence += 1;
|
|
21
|
+
const context = { sequence };
|
|
22
|
+
let value = definition.build(context);
|
|
23
|
+
for (const name of applied) {
|
|
24
|
+
const trait = definition.traits?.[name];
|
|
25
|
+
if (trait === undefined) {
|
|
26
|
+
throw new Error(`This factory has no trait "${name}". Declared: ${Object.keys(definition.traits ?? {}).join(', ') || '(none)'}.`);
|
|
27
|
+
}
|
|
28
|
+
value = { ...value, ...(typeof trait === 'function' ? trait(context) : trait) };
|
|
29
|
+
}
|
|
30
|
+
return { ...value, ...overrides };
|
|
31
|
+
},
|
|
32
|
+
buildList(count, overrides) {
|
|
33
|
+
return Array.from({ length: count }, (_unused, index) => factory.build(typeof overrides === 'function' ? overrides(index) : overrides));
|
|
34
|
+
},
|
|
35
|
+
with(...traits) {
|
|
36
|
+
// A new factory rather than a mutation: `userFactory.with('admin')` must
|
|
37
|
+
// not turn every later `userFactory.build()` into an administrator, which
|
|
38
|
+
// is the kind of action-at-a-distance that costs an afternoon.
|
|
39
|
+
return defineFactory(definition, [...applied, ...traits]);
|
|
40
|
+
},
|
|
41
|
+
reset() {
|
|
42
|
+
sequence = 0;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
return factory;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Pairs a factory with something that persists what it builds.
|
|
49
|
+
*
|
|
50
|
+
* Kept separate from `defineFactory` so the pure half stays usable in a unit
|
|
51
|
+
* test with no database, and so persistence goes through the application's own
|
|
52
|
+
* services rather than an insert — the same rule as the seeds. A record written
|
|
53
|
+
* straight to the database has none of the side effects the real path produces,
|
|
54
|
+
* and every test that then relies on those is testing a world the application
|
|
55
|
+
* cannot make.
|
|
56
|
+
*/
|
|
57
|
+
export function persisted(factory, persist) {
|
|
58
|
+
return {
|
|
59
|
+
build: (overrides) => factory.build(overrides),
|
|
60
|
+
create: async (overrides) => persist(factory.build(overrides)),
|
|
61
|
+
createList: async (count, overrides) => {
|
|
62
|
+
const created = [];
|
|
63
|
+
// Sequentially: concurrent creates against one database routinely deadlock
|
|
64
|
+
// on the same unique index, and the failure reads as flakiness.
|
|
65
|
+
for (const attributes of factory.buildList(count, overrides)) {
|
|
66
|
+
created.push(await persist(attributes));
|
|
67
|
+
}
|
|
68
|
+
return created;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=factory.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"factory.js","sourceRoot":"","sources":["../src/factory.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAsCH,MAAM,UAAU,aAAa,CAC3B,UAA2C,EAC3C,UAAuB,EAAE;IAEzB,IAAI,QAAQ,GAAG,CAAC,CAAC;IAEjB,MAAM,OAAO,GAA0B;QACrC,KAAK,CAAC,SAAS,GAAG,EAAE;YAClB,QAAQ,IAAI,CAAC,CAAC;YACd,MAAM,OAAO,GAAmB,EAAE,QAAQ,EAAE,CAAC;YAC7C,IAAI,KAAK,GAAG,UAAU,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;YAEtC,KAAK,MAAM,IAAI,IAAI,OAAO,EAAE,CAAC;gBAC3B,MAAM,KAAK,GAAG,UAAU,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,CAAC;gBACxC,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;oBACxB,MAAM,IAAI,KAAK,CACb,8BAA8B,IAAI,gBAAgB,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,QAAQ,GAAG,CACjH,CAAC;gBACJ,CAAC;gBACD,KAAK,GAAG,EAAE,GAAG,KAAK,EAAE,GAAG,CAAC,OAAO,KAAK,KAAK,UAAU,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC;YAClF,CAAC;YAED,OAAO,EAAE,GAAG,KAAK,EAAE,GAAG,SAAS,EAAE,CAAC;QACpC,CAAC;QAED,SAAS,CAAC,KAAK,EAAE,SAAS;YACxB,OAAO,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,KAAK,EAAE,EAAE,CAAC,OAAO,EAAE,KAAK,EAAE,EAAE,CACtD,OAAO,CAAC,KAAK,CAAC,OAAO,SAAS,KAAK,UAAU,CAAC,CAAC,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAC9E,CAAC;QACJ,CAAC;QAED,IAAI,CAAC,GAAG,MAAM;YACZ,yEAAyE;YACzE,0EAA0E;YAC1E,+DAA+D;YAC/D,OAAO,aAAa,CAAC,UAAU,EAAE,CAAC,GAAG,OAAO,EAAE,GAAG,MAAM,CAAC,CAAC,CAAC;QAC5D,CAAC;QAED,KAAK;YACH,QAAQ,GAAG,CAAC,CAAC;QACf,CAAC;KACF,CAAC;IAEF,OAAO,OAAO,CAAC;AACjB,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,SAAS,CACvB,OAA8B,EAC9B,OAA8C;IAM9C,OAAO;QACL,KAAK,EAAE,CAAC,SAAS,EAAE,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,SAAS,CAAC;QAC9C,MAAM,EAAE,KAAK,EAAE,SAAS,EAAE,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC;QAC9D,UAAU,EAAE,KAAK,EAAE,KAAK,EAAE,SAAS,EAAE,EAAE;YACrC,MAAM,OAAO,GAAgB,EAAE,CAAC;YAChC,2EAA2E;YAC3E,gEAAgE;YAChE,KAAK,MAAM,UAAU,IAAI,OAAO,CAAC,SAAS,CAAC,KAAK,EAAE,SAAS,CAAC,EAAE,CAAC;gBAC7D,OAAO,CAAC,IAAI,CAAC,MAAM,OAAO,CAAC,UAAU,CAAC,CAAC,CAAC;YAC1C,CAAC;YACD,OAAO,OAAO,CAAC;QACjB,CAAC;KACF,CAAC;AACJ,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { defineActors, type AppFactory, type TestApp } from './app.js';
|
|
2
|
+
export { MissingFixtureError, ReplayBootError, assertReplayBootSafety, createReplayTransport, digestOf, unusedFixtures, type Fixture, type Provenance, type ReplayMode, type ReplayTransportOptions, } from './replay/index.js';
|
|
3
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,KAAK,UAAU,EAAE,KAAK,OAAO,EAAE,MAAM,UAAU,CAAC;AAEvE,OAAO,EACL,mBAAmB,EACnB,eAAe,EACf,sBAAsB,EACtB,qBAAqB,EACrB,QAAQ,EACR,cAAc,EACd,KAAK,OAAO,EACZ,KAAK,UAAU,EACf,KAAK,UAAU,EACf,KAAK,sBAAsB,GAC5B,MAAM,mBAAmB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAiC,MAAM,UAAU,CAAC;AAEvE,OAAO,EACL,mBAAmB,EACnB,eAAe,EACf,sBAAsB,EACtB,qBAAqB,EACrB,QAAQ,EACR,cAAc,GAKf,MAAM,mBAAmB,CAAC"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { TestApp } from '../app.js';
|
|
2
|
+
/**
|
|
3
|
+
* The harness for a Nest application, with the one detail nobody gets right twice.
|
|
4
|
+
*
|
|
5
|
+
* Everything here is the same in any Nest repository: build a testing module,
|
|
6
|
+
* apply the overrides, create the application, hand it to the host's own HTTP
|
|
7
|
+
* configuration, start it, hand back a client factory and a close. What is *not*
|
|
8
|
+
* the same — which modules compose the app, how it is configured, what a client
|
|
9
|
+
* is — is passed in.
|
|
10
|
+
*
|
|
11
|
+
* **It listens on a real port rather than calling `init`.** That is the detail.
|
|
12
|
+
* If the application attaches anything to the HTTP server — a WebSocket gateway,
|
|
13
|
+
* most commonly — then on a server that was never started, the socket library's
|
|
14
|
+
* takeover of the request listeners races the framework's own handler, and the
|
|
15
|
+
* occasional request falls through as a 404 on a route that plainly exists. It
|
|
16
|
+
* looks like flakiness, it reproduces once an hour, and it costs an afternoon
|
|
17
|
+
* every time somebody meets it. Both repositories this was drawn from had found
|
|
18
|
+
* it and written the workaround in a comment; here it is not a comment.
|
|
19
|
+
*
|
|
20
|
+
* Typed structurally, so this package does not resolve its own copy of Nest —
|
|
21
|
+
* two Nest instances is two injection containers that cannot see each other's
|
|
22
|
+
* providers.
|
|
23
|
+
*/
|
|
24
|
+
export interface NestTestingBuilder {
|
|
25
|
+
overrideProvider(token: unknown): {
|
|
26
|
+
useValue(value: unknown): NestTestingBuilder;
|
|
27
|
+
};
|
|
28
|
+
compile(): Promise<{
|
|
29
|
+
createNestApplication(): NestApplication;
|
|
30
|
+
}>;
|
|
31
|
+
}
|
|
32
|
+
export interface NestApplication {
|
|
33
|
+
listen(port: number): Promise<unknown>;
|
|
34
|
+
getHttpServer(): unknown;
|
|
35
|
+
getUrl?(): Promise<string>;
|
|
36
|
+
close(): Promise<void>;
|
|
37
|
+
get<T>(token: unknown): T;
|
|
38
|
+
}
|
|
39
|
+
export interface NestHarnessOptions<Client> {
|
|
40
|
+
/**
|
|
41
|
+
* Builds the testing module. Given the modules to import, so a caller can
|
|
42
|
+
* leave one out and prove the application still composes without it.
|
|
43
|
+
*/
|
|
44
|
+
createBuilder(imports: unknown[]): NestTestingBuilder;
|
|
45
|
+
/** The modules that make the application, in the order production uses. */
|
|
46
|
+
imports(): unknown[];
|
|
47
|
+
/**
|
|
48
|
+
* The host's own HTTP configuration — pipes, filters, cookie parsing.
|
|
49
|
+
*
|
|
50
|
+
* Required rather than optional, and unapologetically: an application
|
|
51
|
+
* assembled differently for tests validates something that does not ship.
|
|
52
|
+
*/
|
|
53
|
+
configure(app: NestApplication): void | Promise<void>;
|
|
54
|
+
/** A fresh client per call, one per actor, so sessions stay separate. */
|
|
55
|
+
client(server: unknown): Client;
|
|
56
|
+
/** Providers to swap at true external boundaries only. */
|
|
57
|
+
overrides?: Array<{
|
|
58
|
+
token: unknown;
|
|
59
|
+
value: unknown;
|
|
60
|
+
}>;
|
|
61
|
+
}
|
|
62
|
+
export interface NestTestApp<Client> extends TestApp<Client> {
|
|
63
|
+
/** The running application, for the rare test that needs a provider. */
|
|
64
|
+
app: NestApplication;
|
|
65
|
+
}
|
|
66
|
+
export declare function createNestTestApp<Client>(options: NestHarnessOptions<Client>): Promise<NestTestApp<Client>>;
|
|
67
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/nest/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEzC;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,MAAM,WAAW,kBAAkB;IACjC,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG;QAAE,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,kBAAkB,CAAA;KAAE,CAAC;IACnF,OAAO,IAAI,OAAO,CAAC;QAAE,qBAAqB,IAAI,eAAe,CAAA;KAAE,CAAC,CAAC;CAClE;AAED,MAAM,WAAW,eAAe;IAC9B,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACvC,aAAa,IAAI,OAAO,CAAC;IACzB,MAAM,CAAC,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;IAC3B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,GAAG,CAAC,CAAC,EAAE,KAAK,EAAE,OAAO,GAAG,CAAC,CAAC;CAC3B;AAED,MAAM,WAAW,kBAAkB,CAAC,MAAM;IACxC;;;OAGG;IACH,aAAa,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,kBAAkB,CAAC;IACtD,2EAA2E;IAC3E,OAAO,IAAI,OAAO,EAAE,CAAC;IACrB;;;;;OAKG;IACH,SAAS,CAAC,GAAG,EAAE,eAAe,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACtD,yEAAyE;IACzE,MAAM,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,CAAC;IAChC,0DAA0D;IAC1D,SAAS,CAAC,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,OAAO,CAAC;QAAC,KAAK,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;CACvD;AAED,MAAM,WAAW,WAAW,CAAC,MAAM,CAAE,SAAQ,OAAO,CAAC,MAAM,CAAC;IAC1D,wEAAwE;IACxE,GAAG,EAAE,eAAe,CAAC;CACtB;AAED,wBAAsB,iBAAiB,CAAC,MAAM,EAAE,OAAO,EAAE,kBAAkB,CAAC,MAAM,CAAC,GAAG,OAAO,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,CAuBjH"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export async function createNestTestApp(options) {
|
|
2
|
+
let builder = options.createBuilder(options.imports());
|
|
3
|
+
for (const { token, value } of options.overrides ?? []) {
|
|
4
|
+
builder = builder.overrideProvider(token).useValue(value);
|
|
5
|
+
}
|
|
6
|
+
const moduleRef = await builder.compile();
|
|
7
|
+
const app = moduleRef.createNestApplication();
|
|
8
|
+
await options.configure(app);
|
|
9
|
+
// Port zero: the operating system picks a free one, so suites never collide.
|
|
10
|
+
await app.listen(0);
|
|
11
|
+
const baseUrl = app.getUrl === undefined ? '' : await app.getUrl();
|
|
12
|
+
return {
|
|
13
|
+
app,
|
|
14
|
+
baseUrl,
|
|
15
|
+
client: () => options.client(app.getHttpServer()),
|
|
16
|
+
close: () => app.close(),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/nest/index.ts"],"names":[],"mappings":"AAgEA,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAS,OAAmC;IACjF,IAAI,OAAO,GAAG,OAAO,CAAC,aAAa,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC;IAEvD,KAAK,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,IAAI,OAAO,CAAC,SAAS,IAAI,EAAE,EAAE,CAAC;QACvD,OAAO,GAAG,OAAO,CAAC,gBAAgB,CAAC,KAAK,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;IAC5D,CAAC;IAED,MAAM,SAAS,GAAG,MAAM,OAAO,CAAC,OAAO,EAAE,CAAC;IAC1C,MAAM,GAAG,GAAG,SAAS,CAAC,qBAAqB,EAAE,CAAC;IAE9C,MAAM,OAAO,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;IAE7B,6EAA6E;IAC7E,MAAM,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;IAEpB,MAAM,OAAO,GAAG,GAAG,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,MAAM,EAAE,CAAC;IAEnE,OAAO;QACL,GAAG;QACH,OAAO;QACP,MAAM,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,aAAa,EAAE,CAAC;QACjD,KAAK,EAAE,GAAG,EAAE,CAAC,GAAG,CAAC,KAAK,EAAE;KACzB,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Record and replay for non-deterministic services — a model, a payment
|
|
3
|
+
* provider, anything whose answer is neither stable nor free.
|
|
4
|
+
*
|
|
5
|
+
* Three rules, each the fix for a specific way this goes wrong.
|
|
6
|
+
*
|
|
7
|
+
* **A missing fixture always fails.** Never a fallback, never a default answer.
|
|
8
|
+
* A transport that quietly substitutes something plausible turns "this test no
|
|
9
|
+
* longer asks the question it was written for" into a green run — the exact
|
|
10
|
+
* failure the fixtures exist to prevent.
|
|
11
|
+
*
|
|
12
|
+
* **The key is a digest of the request.** Not a filename somebody chose, and
|
|
13
|
+
* not the test's name. Change what you ask and the fixture stops matching, so a
|
|
14
|
+
* suite cannot keep replaying the answer to a question it no longer asks.
|
|
15
|
+
*
|
|
16
|
+
* **A fixture says where it came from.** A recording captured against a version
|
|
17
|
+
* of a service that has since changed is indistinguishable from a fresh one,
|
|
18
|
+
* and tests replaying it assert last year's behaviour with total confidence.
|
|
19
|
+
*/
|
|
20
|
+
export type ReplayMode = 'live' | 'record' | 'replay';
|
|
21
|
+
export interface Provenance {
|
|
22
|
+
/** What produced it — a model name, a provider, a version. */
|
|
23
|
+
source: string;
|
|
24
|
+
recordedAt: string;
|
|
25
|
+
requestDigest: string;
|
|
26
|
+
/** Anything else worth knowing when this is read a year from now. */
|
|
27
|
+
notes?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface Fixture<Response> {
|
|
30
|
+
provenance: Provenance;
|
|
31
|
+
response: Response;
|
|
32
|
+
}
|
|
33
|
+
export declare class MissingFixtureError extends Error {
|
|
34
|
+
readonly digest: string;
|
|
35
|
+
readonly dir: string;
|
|
36
|
+
readonly known: string[];
|
|
37
|
+
constructor(digest: string, dir: string, known: string[]);
|
|
38
|
+
}
|
|
39
|
+
export declare class ReplayBootError extends Error {
|
|
40
|
+
constructor(mode: string);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Call this where the application boots.
|
|
44
|
+
*
|
|
45
|
+
* A guard at the transport is too late and too easy to bypass; the point is
|
|
46
|
+
* that a process which *could* reach the live service never starts inside a
|
|
47
|
+
* test run in the first place.
|
|
48
|
+
*/
|
|
49
|
+
export declare function assertReplayBootSafety(options: {
|
|
50
|
+
mode: string;
|
|
51
|
+
nodeEnv?: string | undefined;
|
|
52
|
+
}): void;
|
|
53
|
+
/** Stable digest of anything JSON-serialisable, with object keys sorted. */
|
|
54
|
+
export declare function digestOf(request: unknown): string;
|
|
55
|
+
export interface ReplayTransportOptions<Request, Response> {
|
|
56
|
+
mode: ReplayMode;
|
|
57
|
+
/** Where fixtures live, absolute or relative to the process. */
|
|
58
|
+
dir: string;
|
|
59
|
+
/** Overrides the default digest — use it to drop fields that legitimately vary. */
|
|
60
|
+
digest?: (request: Request) => string;
|
|
61
|
+
/** The real service. Required for `live` and `record`. */
|
|
62
|
+
live?: (request: Request) => Promise<Response>;
|
|
63
|
+
/** What to write into a new recording's provenance. */
|
|
64
|
+
source?: string;
|
|
65
|
+
now?: () => Date;
|
|
66
|
+
}
|
|
67
|
+
export declare function createReplayTransport<Request, Response>(options: ReplayTransportOptions<Request, Response>): (request: Request) => Promise<Response>;
|
|
68
|
+
/**
|
|
69
|
+
* Fixtures in `dir` that nothing asked for during a run.
|
|
70
|
+
*
|
|
71
|
+
* Worth reporting after a recording session: a fixture nobody replays is either
|
|
72
|
+
* dead weight or evidence that a code path stopped being exercised, and both
|
|
73
|
+
* are things you want to know before they are a year old.
|
|
74
|
+
*/
|
|
75
|
+
export declare function unusedFixtures(dir: string, usedDigests: Iterable<string>): string[];
|
|
76
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/replay/index.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAEtD,MAAM,WAAW,UAAU;IACzB,8DAA8D;IAC9D,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,MAAM,CAAC;IACtB,qEAAqE;IACrE,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,OAAO,CAAC,QAAQ;IAC/B,UAAU,EAAE,UAAU,CAAC;IACvB,QAAQ,EAAE,QAAQ,CAAC;CACpB;AAED,qBAAa,mBAAoB,SAAQ,KAAK;IAE1C,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,GAAG,EAAE,MAAM;IACpB,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE;gBAFf,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,EAAE;CAa3B;AAED,qBAAa,eAAgB,SAAQ,KAAK;gBAC5B,IAAI,EAAE,MAAM;CAQzB;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,GAAG,IAAI,CAKpG;AAED,4EAA4E;AAC5E,wBAAgB,QAAQ,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,CAEjD;AAcD,MAAM,WAAW,sBAAsB,CAAC,OAAO,EAAE,QAAQ;IACvD,IAAI,EAAE,UAAU,CAAC;IACjB,gEAAgE;IAChE,GAAG,EAAE,MAAM,CAAC;IACZ,mFAAmF;IACnF,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,MAAM,CAAC;IACtC,0DAA0D;IAC1D,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC/C,uDAAuD;IACvD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;CAClB;AAED,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,QAAQ,EACrD,OAAO,EAAE,sBAAsB,CAAC,OAAO,EAAE,QAAQ,CAAC,GACjD,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CA0CzC;AAaD;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,WAAW,EAAE,QAAQ,CAAC,MAAM,CAAC,GAAG,MAAM,EAAE,CAGnF"}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
export class MissingFixtureError extends Error {
|
|
5
|
+
digest;
|
|
6
|
+
dir;
|
|
7
|
+
known;
|
|
8
|
+
constructor(digest, dir, known) {
|
|
9
|
+
super(`No recorded answer for request ${digest} in ${dir}.\n` +
|
|
10
|
+
'This is a failure, not a reason to fall back: an invented answer would let a test keep passing after ' +
|
|
11
|
+
'it stopped asking the question it was written for.\n' +
|
|
12
|
+
(known.length === 0
|
|
13
|
+
? 'Nothing has been recorded here yet. Run with mode "record" against the real service.'
|
|
14
|
+
: `Recorded here: ${known.slice(0, 5).join(', ')}${known.length > 5 ? `, +${known.length - 5} more` : ''}. ` +
|
|
15
|
+
'If the request changed on purpose, re-record it; the old fixture answers a question nobody asks any more.'));
|
|
16
|
+
this.digest = digest;
|
|
17
|
+
this.dir = dir;
|
|
18
|
+
this.known = known;
|
|
19
|
+
this.name = 'MissingFixtureError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export class ReplayBootError extends Error {
|
|
23
|
+
constructor(mode) {
|
|
24
|
+
super(`Refusing to start in "${mode}" mode under NODE_ENV=test. A test run that reaches a real service is ` +
|
|
25
|
+
'slow, costs money, and — worse — is not reproducible: the suite passes or fails on something outside ' +
|
|
26
|
+
'the repository. Use mode "replay", or unset NODE_ENV if this really is a recording session.');
|
|
27
|
+
this.name = 'ReplayBootError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Call this where the application boots.
|
|
32
|
+
*
|
|
33
|
+
* A guard at the transport is too late and too easy to bypass; the point is
|
|
34
|
+
* that a process which *could* reach the live service never starts inside a
|
|
35
|
+
* test run in the first place.
|
|
36
|
+
*/
|
|
37
|
+
export function assertReplayBootSafety(options) {
|
|
38
|
+
const nodeEnv = options.nodeEnv ?? process.env['NODE_ENV'];
|
|
39
|
+
if (nodeEnv !== 'test')
|
|
40
|
+
return;
|
|
41
|
+
if (options.mode === 'replay')
|
|
42
|
+
return;
|
|
43
|
+
throw new ReplayBootError(options.mode);
|
|
44
|
+
}
|
|
45
|
+
/** Stable digest of anything JSON-serialisable, with object keys sorted. */
|
|
46
|
+
export function digestOf(request) {
|
|
47
|
+
return createHash('sha256').update(canonicalise(request)).digest('hex').slice(0, 16);
|
|
48
|
+
}
|
|
49
|
+
function canonicalise(value) {
|
|
50
|
+
if (value === null || typeof value !== 'object')
|
|
51
|
+
return JSON.stringify(value) ?? 'null';
|
|
52
|
+
if (Array.isArray(value))
|
|
53
|
+
return `[${value.map(canonicalise).join(',')}]`;
|
|
54
|
+
const entries = Object.entries(value)
|
|
55
|
+
.filter(([, entry]) => entry !== undefined)
|
|
56
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
57
|
+
.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalise(entry)}`);
|
|
58
|
+
return `{${entries.join(',')}}`;
|
|
59
|
+
}
|
|
60
|
+
export function createReplayTransport(options) {
|
|
61
|
+
const digestOfRequest = options.digest ?? ((request) => digestOf(request));
|
|
62
|
+
return async function transport(request) {
|
|
63
|
+
const digest = digestOfRequest(request);
|
|
64
|
+
const path = join(options.dir, `${digest}.json`);
|
|
65
|
+
if (options.mode === 'replay') {
|
|
66
|
+
if (!existsSync(path))
|
|
67
|
+
throw new MissingFixtureError(digest, options.dir, recordedDigests(options.dir));
|
|
68
|
+
const fixture = JSON.parse(readFileSync(path, 'utf8'));
|
|
69
|
+
if (fixture.provenance === undefined) {
|
|
70
|
+
throw new Error(`${path} has no provenance block, so there is no way to tell whether it was recorded against a ` +
|
|
71
|
+
'version of the service that still exists. Re-record it.');
|
|
72
|
+
}
|
|
73
|
+
return fixture.response;
|
|
74
|
+
}
|
|
75
|
+
if (options.live === undefined) {
|
|
76
|
+
throw new Error(`Mode "${options.mode}" needs a live implementation to call.`);
|
|
77
|
+
}
|
|
78
|
+
const response = await options.live(request);
|
|
79
|
+
if (options.mode === 'record') {
|
|
80
|
+
mkdirSync(options.dir, { recursive: true });
|
|
81
|
+
const fixture = {
|
|
82
|
+
provenance: {
|
|
83
|
+
source: options.source ?? 'unknown',
|
|
84
|
+
recordedAt: (options.now?.() ?? new Date()).toISOString(),
|
|
85
|
+
requestDigest: digest,
|
|
86
|
+
},
|
|
87
|
+
response,
|
|
88
|
+
};
|
|
89
|
+
writeFileSync(path, `${JSON.stringify(fixture, null, 2)}\n`, 'utf8');
|
|
90
|
+
}
|
|
91
|
+
return response;
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function recordedDigests(dir) {
|
|
95
|
+
try {
|
|
96
|
+
return readdirSync(dir)
|
|
97
|
+
.filter((entry) => entry.endsWith('.json'))
|
|
98
|
+
.map((entry) => entry.replace(/\.json$/, ''))
|
|
99
|
+
.sort();
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Fixtures in `dir` that nothing asked for during a run.
|
|
107
|
+
*
|
|
108
|
+
* Worth reporting after a recording session: a fixture nobody replays is either
|
|
109
|
+
* dead weight or evidence that a code path stopped being exercised, and both
|
|
110
|
+
* are things you want to know before they are a year old.
|
|
111
|
+
*/
|
|
112
|
+
export function unusedFixtures(dir, usedDigests) {
|
|
113
|
+
const used = new Set(usedDigests);
|
|
114
|
+
return recordedDigests(dir).filter((digest) => !used.has(digest));
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/replay/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@app-studio/qa-harness",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "The shape of a test harness — the real application under test, actors that make multi-role tests cheap — plus a record/replay transport whose missing fixtures always fail.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./replay": {
|
|
15
|
+
"types": "./dist/replay/index.d.ts",
|
|
16
|
+
"default": "./dist/replay/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./nest": {
|
|
19
|
+
"types": "./dist/nest/index.d.ts",
|
|
20
|
+
"default": "./dist/nest/index.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=20"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@app-studio/qa-core": "^0.2.0"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "rimraf dist && tsc -p tsconfig.json",
|
|
38
|
+
"test": "vitest run"
|
|
39
|
+
}
|
|
40
|
+
}
|