@fougere/testing 0.9.0-alpha.1 → 0.9.3-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/load.d.ts +17 -3
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +25 -5
- package/dist/load.js.map +1 -1
- package/dist/spans.d.ts +18 -0
- package/dist/spans.d.ts.map +1 -0
- package/dist/spans.js +56 -0
- package/dist/spans.js.map +1 -0
- package/dist/statements.d.ts +17 -0
- package/dist/statements.d.ts.map +1 -0
- package/dist/statements.js +37 -0
- package/dist/statements.js.map +1 -0
- package/package.json +20 -10
- package/src/index.ts +3 -1
- package/src/load.ts +46 -7
- package/src/spans.ts +66 -0
- package/src/statements.ts +41 -0
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,9 @@ export { testApp } from './app.js';
|
|
|
4
4
|
export { checkContract, checkOutput, verdictOf, type Verdict } from './doors.js';
|
|
5
5
|
export { stubOf, type Port, type Stub } from './stub.js';
|
|
6
6
|
export { frondOf, type Scope } from './scope.js';
|
|
7
|
-
export { loadScript } from './load.js';
|
|
7
|
+
export { loadScript, reachableOps } from './load.js';
|
|
8
|
+
export { statementsOf } from './statements.js';
|
|
9
|
+
export { spansOf } from './spans.js';
|
|
8
10
|
export { checkDoorContract, checkDoors, } from './comparison.js';
|
|
9
11
|
export { at } from './gql.js';
|
|
10
12
|
export { driftOf, agrees, explain, type CardDrift } from './remotes.js';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE1C,OAAO,EAAE,KAAK,EAAE,KAAK,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAC7D,OAAO,EAAE,OAAO,EAAE,MAAM,UAAU,CAAC;AACnC,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,SAAS,EAAE,KAAK,OAAO,EAAE,MAAM,YAAY,CAAC;AACjF,OAAO,EAAE,MAAM,EAAE,KAAK,IAAI,EAAE,KAAK,IAAI,EAAE,MAAM,WAAW,CAAC;AACzD,OAAO,EAAE,OAAO,EAAE,KAAK,KAAK,EAAE,MAAM,YAAY,CAAC;AACjD,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE1C,OAAO,EAAE,KAAK,EAAE,KAAK,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAC7D,OAAO,EAAE,OAAO,EAAE,MAAM,UAAU,CAAC;AACnC,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,SAAS,EAAE,KAAK,OAAO,EAAE,MAAM,YAAY,CAAC;AACjF,OAAO,EAAE,MAAM,EAAE,KAAK,IAAI,EAAE,KAAK,IAAI,EAAE,MAAM,WAAW,CAAC;AACzD,OAAO,EAAE,OAAO,EAAE,KAAK,KAAK,EAAE,MAAM,YAAY,CAAC;AACjD,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AACrD,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AACrC,OAAO,EACL,iBAAiB,EACjB,UAAU,GACX,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,EAAE,EAAE,MAAM,UAAU,CAAC;AAC9B,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AACxE,OAAO,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,9 @@ export { testApp } from './app.js';
|
|
|
5
5
|
export { checkContract, checkOutput, verdictOf } from './doors.js';
|
|
6
6
|
export { stubOf } from './stub.js';
|
|
7
7
|
export { frondOf } from './scope.js';
|
|
8
|
-
export { loadScript } from './load.js';
|
|
8
|
+
export { loadScript, reachableOps } from './load.js';
|
|
9
|
+
export { statementsOf } from './statements.js';
|
|
10
|
+
export { spansOf } from './spans.js';
|
|
9
11
|
export { checkDoorContract, checkDoors, } from './comparison.js';
|
|
10
12
|
export { at } from './gql.js';
|
|
11
13
|
export { driftOf, agrees, explain } from './remotes.js';
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1C,sDAAsD;AACtD,OAAO,EAAE,KAAK,EAAuB,MAAM,iBAAiB,CAAC;AAC7D,OAAO,EAAE,OAAO,EAAE,MAAM,UAAU,CAAC;AACnC,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,SAAS,EAAgB,MAAM,YAAY,CAAC;AACjF,OAAO,EAAE,MAAM,EAAwB,MAAM,WAAW,CAAC;AACzD,OAAO,EAAE,OAAO,EAAc,MAAM,YAAY,CAAC;AACjD,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1C,sDAAsD;AACtD,OAAO,EAAE,KAAK,EAAuB,MAAM,iBAAiB,CAAC;AAC7D,OAAO,EAAE,OAAO,EAAE,MAAM,UAAU,CAAC;AACnC,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,SAAS,EAAgB,MAAM,YAAY,CAAC;AACjF,OAAO,EAAE,MAAM,EAAwB,MAAM,WAAW,CAAC;AACzD,OAAO,EAAE,OAAO,EAAc,MAAM,YAAY,CAAC;AACjD,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AACrD,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AACrC,OAAO,EACL,iBAAiB,EACjB,UAAU,GACX,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,EAAE,EAAE,MAAM,UAAU,CAAC;AAC9B,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAkB,MAAM,cAAc,CAAC;AACxE,OAAO,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC"}
|
package/dist/load.d.ts
CHANGED
|
@@ -1,17 +1,31 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { FrondDescriptor } from '@fougere/core/descriptor';
|
|
2
|
+
/**
|
|
3
|
+
* What a load script is read from: the fronds, and nothing else.
|
|
4
|
+
*
|
|
5
|
+
* An `App` satisfies it, and so does a scan — which is what lets `fougere load` answer without
|
|
6
|
+
* booting the project it describes. A boot runs migrations and seeds, so a command that wrote a
|
|
7
|
+
* scenario would also write to the target's database.
|
|
8
|
+
*/
|
|
9
|
+
interface Serving {
|
|
10
|
+
fronds: readonly FrondDescriptor[];
|
|
11
|
+
}
|
|
2
12
|
export interface LoadOptions {
|
|
3
13
|
/** Where the calls go. The RPC door of a running app. */
|
|
4
14
|
door?: string;
|
|
15
|
+
/** The topology statement, so an op that crosses a process is given the time to. */
|
|
16
|
+
remotes?: Record<string, string>;
|
|
5
17
|
/** Values the generator cannot invent, by entity name — the id a `ref()` points at. */
|
|
6
18
|
given?: Record<string, Record<string, unknown>>;
|
|
7
19
|
}
|
|
8
20
|
interface Reachable {
|
|
9
21
|
method: string;
|
|
10
22
|
input: unknown;
|
|
23
|
+
/** How many times this op's work crosses a process — what its budget is a function of. */
|
|
24
|
+
hops: number;
|
|
11
25
|
}
|
|
12
26
|
/** Every operation the app answers, with a body for those that take one. */
|
|
13
|
-
export declare function reachableOps(app:
|
|
27
|
+
export declare function reachableOps(app: Serving, given?: LoadOptions['given'], remotes?: Record<string, string>): Reachable[];
|
|
14
28
|
/** A k6 scenario, written from what the app answers. */
|
|
15
|
-
export declare function loadScript(app:
|
|
29
|
+
export declare function loadScript(app: Serving, options?: LoadOptions): string;
|
|
16
30
|
export {};
|
|
17
31
|
//# sourceMappingURL=load.d.ts.map
|
package/dist/load.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"load.d.ts","sourceRoot":"","sources":["../src/load.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"load.d.ts","sourceRoot":"","sources":["../src/load.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAEhE;;;;;;GAMG;AACH,UAAU,OAAO;IACf,MAAM,EAAE,SAAS,eAAe,EAAE,CAAC;CACpC;AAID,MAAM,WAAW,WAAW;IAC1B,yDAAyD;IACzD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oFAAoF;IACpF,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,uFAAuF;IACvF,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACjD;AAED,UAAU,SAAS;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,OAAO,CAAC;IACf,0FAA0F;IAC1F,IAAI,EAAE,MAAM,CAAC;CACd;AAED,4EAA4E;AAC5E,wBAAgB,YAAY,CAC1B,GAAG,EAAE,OAAO,EACZ,KAAK,GAAE,WAAW,CAAC,OAAO,CAAM,EAChC,OAAO,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GACnC,SAAS,EAAE,CAqBb;AAED,wDAAwD;AACxD,wBAAgB,UAAU,CAAC,GAAG,EAAE,OAAO,EAAE,OAAO,GAAE,WAAgB,GAAG,MAAM,CAkE1E"}
|
package/dist/load.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { frameCall } from '@fougere/transport-http';
|
|
2
|
+
import { resolveEffectiveOperations } from '@fougere/core';
|
|
2
3
|
import { sampleInput } from './sample.js';
|
|
3
4
|
/** Every operation the app answers, with a body for those that take one. */
|
|
4
|
-
export function reachableOps(app, given = {}) {
|
|
5
|
+
export function reachableOps(app, given = {}, remotes = {}) {
|
|
6
|
+
const { operations } = resolveEffectiveOperations(app.fronds, { remotes });
|
|
7
|
+
const hops = new Map(operations.map((op) => [`${op.handler.address}.${op.name}`, op.reach.hops]));
|
|
5
8
|
const found = [];
|
|
6
9
|
for (const frond of app.fronds) {
|
|
7
10
|
for (const handler of frond.handlers) {
|
|
@@ -11,9 +14,11 @@ export function reachableOps(app, given = {}) {
|
|
|
11
14
|
continue;
|
|
12
15
|
for (const [op, contract] of handler.operations ?? []) {
|
|
13
16
|
const schema = contract.input;
|
|
17
|
+
const method = `${handler.address}.${op}`;
|
|
14
18
|
found.push({
|
|
15
|
-
method
|
|
19
|
+
method,
|
|
16
20
|
input: schema ? sampleInput(schema, given[handler.address] ?? {}) : undefined,
|
|
21
|
+
hops: hops.get(method) ?? 0,
|
|
17
22
|
});
|
|
18
23
|
}
|
|
19
24
|
}
|
|
@@ -23,7 +28,7 @@ export function reachableOps(app, given = {}) {
|
|
|
23
28
|
/** A k6 scenario, written from what the app answers. */
|
|
24
29
|
export function loadScript(app, options = {}) {
|
|
25
30
|
const door = options.door ?? 'http://127.0.0.1:3000/_fougere/call';
|
|
26
|
-
const ops = reachableOps(app, options.given);
|
|
31
|
+
const ops = reachableOps(app, options.given, options.remotes ?? {});
|
|
27
32
|
// The shape, from the one function that states it. `body` is replaced per iteration.
|
|
28
33
|
const envelope = frameCall({ entity: 'ENTITY', op: 'OP' }, { params: {}, query: {}, input: undefined, state: {} }, 0);
|
|
29
34
|
// What the envelope carries that an iteration does not fill in itself. Keeping
|
|
@@ -40,6 +45,12 @@ const DOOR = ${JSON.stringify(door)};
|
|
|
40
45
|
// Every operation the app serves. A weight of 0 takes one out, visibly.
|
|
41
46
|
const OPS = ${JSON.stringify(ops.map((op) => ({ ...op, weight: 1 })), null, 2)};
|
|
42
47
|
|
|
48
|
+
// Yours: how long an op may take here, and what one process boundary is allowed to add. Two
|
|
49
|
+
// numbers instead of one, because an op that crosses nothing and an op that crosses twice were
|
|
50
|
+
// never the same subject — hops above is read from the code, these two are facts about your
|
|
51
|
+
// network.
|
|
52
|
+
const BUDGET = { base: 300, perHop: 200 };
|
|
53
|
+
|
|
43
54
|
export const options = {
|
|
44
55
|
// Yours: a flat rate draws flat lines and there is nothing to read in them.
|
|
45
56
|
stages: [
|
|
@@ -47,8 +58,17 @@ export const options = {
|
|
|
47
58
|
{ duration: '45s', target: 5 },
|
|
48
59
|
{ duration: '30s', target: 0 },
|
|
49
60
|
],
|
|
50
|
-
// Yours:
|
|
51
|
-
|
|
61
|
+
// Yours: the shape of the run.
|
|
62
|
+
// Derived from BUDGET and each op's hops — an op that crosses two processes is not held to
|
|
63
|
+
// the same figure as one that never leaves. k6 reads a threshold per tag, and every call
|
|
64
|
+
// below is tagged with the op it made.
|
|
65
|
+
thresholds: {
|
|
66
|
+
http_req_failed: ['rate<0.01'],
|
|
67
|
+
...Object.fromEntries(OPS.map((op) => [
|
|
68
|
+
\`http_req_duration{op:\${op.method}}\`,
|
|
69
|
+
[\`p(95)<\${BUDGET.base + op.hops * BUDGET.perHop}\`],
|
|
70
|
+
])),
|
|
71
|
+
},
|
|
52
72
|
};
|
|
53
73
|
|
|
54
74
|
const TOTAL = OPS.reduce((sum, op) => sum + op.weight, 0);
|
package/dist/load.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"load.js","sourceRoot":"","sources":["../src/load.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;
|
|
1
|
+
{"version":3,"file":"load.js","sourceRoot":"","sources":["../src/load.ts"],"names":[],"mappings":"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"}
|
package/dist/spans.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { App } from '@fougere/core';
|
|
2
|
+
import type { FinishedSpan } from '@fougere/observability';
|
|
3
|
+
/**
|
|
4
|
+
* The steps one call opened — the operation, then whatever ran under it.
|
|
5
|
+
*
|
|
6
|
+
* The dual of [statementsOf]: that one counts what a call asked the database, this one holds
|
|
7
|
+
* the shape of the call itself. What it is FOR is a test about structure rather than speed —
|
|
8
|
+
* an op that delegates reports almost no self time, whatever the machine it runs on:
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* const [op] = await spansOf(app, () => door.list());
|
|
12
|
+
* expect(op.selfMs).toBeLessThan(0.1 * op.ms); // it delegates, it does not work
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* A threshold in milliseconds tests the machine. A ratio tests the code.
|
|
16
|
+
*/
|
|
17
|
+
export declare function spansOf(app: App, run: () => Promise<unknown>): Promise<FinishedSpan[]>;
|
|
18
|
+
//# sourceMappingURL=spans.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"spans.d.ts","sourceRoot":"","sources":["../src/spans.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,eAAe,CAAC;AACzC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAK3D;;;;;;;;;;;;;GAaG;AACH,wBAAsB,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAY5F"}
|
package/dist/spans.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** One tracer per app: `app.use` has no undo, so a second call would stack a middleware. */
|
|
2
|
+
const installed = new WeakMap();
|
|
3
|
+
/**
|
|
4
|
+
* The steps one call opened — the operation, then whatever ran under it.
|
|
5
|
+
*
|
|
6
|
+
* The dual of [statementsOf]: that one counts what a call asked the database, this one holds
|
|
7
|
+
* the shape of the call itself. What it is FOR is a test about structure rather than speed —
|
|
8
|
+
* an op that delegates reports almost no self time, whatever the machine it runs on:
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* const [op] = await spansOf(app, () => door.list());
|
|
12
|
+
* expect(op.selfMs).toBeLessThan(0.1 * op.ms); // it delegates, it does not work
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* A threshold in milliseconds tests the machine. A ratio tests the code.
|
|
16
|
+
*/
|
|
17
|
+
export async function spansOf(app, run) {
|
|
18
|
+
const collecting = await tracerOf(app);
|
|
19
|
+
const spans = [];
|
|
20
|
+
collecting.push(spans);
|
|
21
|
+
try {
|
|
22
|
+
await run();
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
collecting.splice(collecting.indexOf(spans), 1);
|
|
26
|
+
}
|
|
27
|
+
return spans;
|
|
28
|
+
}
|
|
29
|
+
async function tracerOf(app) {
|
|
30
|
+
const held = installed.get(app);
|
|
31
|
+
if (held)
|
|
32
|
+
return held;
|
|
33
|
+
const { statementsUnder, tracing } = await observability();
|
|
34
|
+
const collecting = [];
|
|
35
|
+
installed.set(app, collecting);
|
|
36
|
+
// A test IS the diagnosis, so it asks for the detail a process running continuously does not.
|
|
37
|
+
const tracer = tracing([(span) => { for (const into of collecting)
|
|
38
|
+
into.push(span); }], { spanPerStatement: true });
|
|
39
|
+
app.use(tracer.middleware);
|
|
40
|
+
// Both halves, or `selfMs` would read as if the handler did the waiting itself. The
|
|
41
|
+
// subscription lives as long as the test process: `app.use` has no undo either, and one
|
|
42
|
+
// app per file is what a test file has.
|
|
43
|
+
await statementsUnder(tracer);
|
|
44
|
+
return collecting;
|
|
45
|
+
}
|
|
46
|
+
/** Optional, and dynamic for the reason `@fougere/calls` states: this package depends on none. */
|
|
47
|
+
async function observability() {
|
|
48
|
+
try {
|
|
49
|
+
return await import('@fougere/observability');
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
throw new Error('[spansOf] reading what a call cost needs @fougere/observability — install it, '
|
|
53
|
+
+ 'or there is no span to read.');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=spans.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"spans.js","sourceRoot":"","sources":["../src/spans.ts"],"names":[],"mappings":"AAGA,4FAA4F;AAC5F,MAAM,SAAS,GAAG,IAAI,OAAO,EAAyB,CAAC;AAEvD;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,KAAK,UAAU,OAAO,CAAC,GAAQ,EAAE,GAA2B;IACjE,MAAM,UAAU,GAAG,MAAM,QAAQ,CAAC,GAAG,CAAC,CAAC;IACvC,MAAM,KAAK,GAAmB,EAAE,CAAC;IACjC,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IAEvB,IAAI,CAAC;QACH,MAAM,GAAG,EAAE,CAAC;IACd,CAAC;YAAS,CAAC;QACT,UAAU,CAAC,MAAM,CAAC,UAAU,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC;IAClD,CAAC;IAED,OAAO,KAAK,CAAC;AACf,CAAC;AAED,KAAK,UAAU,QAAQ,CAAC,GAAQ;IAC9B,MAAM,IAAI,GAAG,SAAS,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAChC,IAAI,IAAI;QAAE,OAAO,IAAI,CAAC;IAEtB,MAAM,EAAE,eAAe,EAAE,OAAO,EAAE,GAAG,MAAM,aAAa,EAAE,CAAC;IAC3D,MAAM,UAAU,GAAqB,EAAE,CAAC;IACxC,SAAS,CAAC,GAAG,CAAC,GAAG,EAAE,UAAU,CAAC,CAAC;IAC/B,8FAA8F;IAC9F,MAAM,MAAM,GAAG,OAAO,CACpB,CAAC,CAAC,IAAI,EAAE,EAAE,GAAG,KAAK,MAAM,IAAI,IAAI,UAAU;YAAE,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,EAC/D,EAAE,gBAAgB,EAAE,IAAI,EAAE,CAC3B,CAAC;IACF,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;IAC3B,oFAAoF;IACpF,wFAAwF;IACxF,wCAAwC;IACxC,MAAM,eAAe,CAAC,MAAM,CAAC,CAAC;IAE9B,OAAO,UAAU,CAAC;AACpB,CAAC;AAED,kGAAkG;AAClG,KAAK,UAAU,aAAa;IAC1B,IAAI,CAAC;QACH,OAAO,MAAM,MAAM,CAAC,wBAAwB,CAAC,CAAC;IAChD,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,IAAI,KAAK,CACb,gFAAgF;cAC9E,8BAA8B,CACjC,CAAC;IACJ,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { QueryEvent } from '@fougere/adapter-sql';
|
|
2
|
+
/**
|
|
3
|
+
* Every statement a call ran, in order.
|
|
4
|
+
*
|
|
5
|
+
* `testApp` boots on real SQLite, so this is what the handler actually asked the database —
|
|
6
|
+
* one per row is the shape of an N+1, and it is a number a test can refuse. A computed field
|
|
7
|
+
* that reads is the case: the façade hands the presenter the whole PAGE, so one query is
|
|
8
|
+
* possible, and nothing refuses `Promise.all(rows.map(…))` inside the field body.
|
|
9
|
+
*
|
|
10
|
+
* It REFUSES when `@fougere/adapter-sql` is absent, where the tracer degrades quietly: zero
|
|
11
|
+
* is what a passing assertion looks like, so an app observing nothing would turn this into a
|
|
12
|
+
* test that cannot fail.
|
|
13
|
+
*
|
|
14
|
+
* What it counts is the PROCESS, not the call — everything running while the block runs.
|
|
15
|
+
*/
|
|
16
|
+
export declare function statementsOf(run: () => Promise<unknown>): Promise<QueryEvent[]>;
|
|
17
|
+
//# sourceMappingURL=statements.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"statements.d.ts","sourceRoot":"","sources":["../src/statements.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAEvD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,YAAY,CAAC,GAAG,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC,CAYrF"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every statement a call ran, in order.
|
|
3
|
+
*
|
|
4
|
+
* `testApp` boots on real SQLite, so this is what the handler actually asked the database —
|
|
5
|
+
* one per row is the shape of an N+1, and it is a number a test can refuse. A computed field
|
|
6
|
+
* that reads is the case: the façade hands the presenter the whole PAGE, so one query is
|
|
7
|
+
* possible, and nothing refuses `Promise.all(rows.map(…))` inside the field body.
|
|
8
|
+
*
|
|
9
|
+
* It REFUSES when `@fougere/adapter-sql` is absent, where the tracer degrades quietly: zero
|
|
10
|
+
* is what a passing assertion looks like, so an app observing nothing would turn this into a
|
|
11
|
+
* test that cannot fail.
|
|
12
|
+
*
|
|
13
|
+
* What it counts is the PROCESS, not the call — everything running while the block runs.
|
|
14
|
+
*/
|
|
15
|
+
export async function statementsOf(run) {
|
|
16
|
+
const { onQuery } = await sql();
|
|
17
|
+
const ran = [];
|
|
18
|
+
const stop = onQuery((event) => ran.push(event));
|
|
19
|
+
try {
|
|
20
|
+
await run();
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
stop();
|
|
24
|
+
}
|
|
25
|
+
return ran;
|
|
26
|
+
}
|
|
27
|
+
/** Optional, and dynamic for the reason `@fougere/calls` states: this package depends on none. */
|
|
28
|
+
async function sql() {
|
|
29
|
+
try {
|
|
30
|
+
return await import('@fougere/adapter-sql');
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw new Error('[statementsOf] counting statements needs @fougere/adapter-sql — install it, '
|
|
34
|
+
+ 'or the count is zero whatever the handler ran.');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=statements.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"statements.js","sourceRoot":"","sources":["../src/statements.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,GAA2B;IAC5D,MAAM,EAAE,OAAO,EAAE,GAAG,MAAM,GAAG,EAAE,CAAC;IAChC,MAAM,GAAG,GAAiB,EAAE,CAAC;IAC7B,MAAM,IAAI,GAAG,OAAO,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC;IAEjD,IAAI,CAAC;QACH,MAAM,GAAG,EAAE,CAAC;IACd,CAAC;YAAS,CAAC;QACT,IAAI,EAAE,CAAC;IACT,CAAC;IAED,OAAO,GAAG,CAAC;AACb,CAAC;AAED,kGAAkG;AAClG,KAAK,UAAU,GAAG;IAChB,IAAI,CAAC;QACH,OAAO,MAAM,MAAM,CAAC,sBAAsB,CAAC,CAAC;IAC9C,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,IAAI,KAAK,CACb,8EAA8E;cAC5E,gDAAgD,CACnD,CAAC;IACJ,CAAC;AACH,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fougere/testing",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3-alpha.0",
|
|
4
4
|
"description": "Tests derived from the declaration — cases, stubs and a gradient of realities.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"fougere",
|
|
@@ -34,20 +34,22 @@
|
|
|
34
34
|
],
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"json-schema-faker": "^0.6.3",
|
|
37
|
-
"@fougere/container": "0.9.
|
|
38
|
-
"@fougere/
|
|
39
|
-
"@fougere/
|
|
40
|
-
"@fougere/
|
|
41
|
-
"@fougere/
|
|
42
|
-
"@fougere/
|
|
43
|
-
"@fougere/
|
|
37
|
+
"@fougere/container": "0.9.3-alpha.0",
|
|
38
|
+
"@fougere/schema": "0.9.3-alpha.0",
|
|
39
|
+
"@fougere/compiler": "0.9.3-alpha.0",
|
|
40
|
+
"@fougere/defaults": "0.9.3-alpha.0",
|
|
41
|
+
"@fougere/app": "0.9.3-alpha.0",
|
|
42
|
+
"@fougere/core": "0.9.3-alpha.0",
|
|
43
|
+
"@fougere/transport-http": "0.9.3-alpha.0"
|
|
44
44
|
},
|
|
45
45
|
"publishConfig": {
|
|
46
46
|
"access": "public"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"vitest": ">=2",
|
|
50
|
-
"@fougere/
|
|
50
|
+
"@fougere/observability": "0.9.3-alpha.0",
|
|
51
|
+
"@fougere/adapter-sql": "0.9.3-alpha.0",
|
|
52
|
+
"@fougere/adapter-graphql": "0.9.3-alpha.0"
|
|
51
53
|
},
|
|
52
54
|
"peerDependenciesMeta": {
|
|
53
55
|
"vitest": {
|
|
@@ -55,11 +57,19 @@
|
|
|
55
57
|
},
|
|
56
58
|
"@fougere/adapter-graphql": {
|
|
57
59
|
"optional": true
|
|
60
|
+
},
|
|
61
|
+
"@fougere/adapter-sql": {
|
|
62
|
+
"optional": true
|
|
63
|
+
},
|
|
64
|
+
"@fougere/observability": {
|
|
65
|
+
"optional": true
|
|
58
66
|
}
|
|
59
67
|
},
|
|
60
68
|
"devDependencies": {
|
|
61
69
|
"vitest": "^4.1.0",
|
|
62
|
-
"@fougere/adapter-graphql": "0.9.
|
|
70
|
+
"@fougere/adapter-graphql": "0.9.3-alpha.0",
|
|
71
|
+
"@fougere/adapter-sql": "0.9.3-alpha.0",
|
|
72
|
+
"@fougere/observability": "0.9.3-alpha.0"
|
|
63
73
|
},
|
|
64
74
|
"scripts": {
|
|
65
75
|
"build": "rm -rf dist && tsc",
|
package/src/index.ts
CHANGED
|
@@ -5,7 +5,9 @@ export { testApp } from './app.js';
|
|
|
5
5
|
export { checkContract, checkOutput, verdictOf, type Verdict } from './doors.js';
|
|
6
6
|
export { stubOf, type Port, type Stub } from './stub.js';
|
|
7
7
|
export { frondOf, type Scope } from './scope.js';
|
|
8
|
-
export { loadScript } from './load.js';
|
|
8
|
+
export { loadScript, reachableOps } from './load.js';
|
|
9
|
+
export { statementsOf } from './statements.js';
|
|
10
|
+
export { spansOf } from './spans.js';
|
|
9
11
|
export {
|
|
10
12
|
checkDoorContract,
|
|
11
13
|
checkDoors,
|
package/src/load.ts
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
import { frameCall } from '@fougere/transport-http';
|
|
2
|
-
import
|
|
2
|
+
import { resolveEffectiveOperations } from '@fougere/core';
|
|
3
|
+
import type { FrondDescriptor } from '@fougere/core/descriptor';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What a load script is read from: the fronds, and nothing else.
|
|
7
|
+
*
|
|
8
|
+
* An `App` satisfies it, and so does a scan — which is what lets `fougere load` answer without
|
|
9
|
+
* booting the project it describes. A boot runs migrations and seeds, so a command that wrote a
|
|
10
|
+
* scenario would also write to the target's database.
|
|
11
|
+
*/
|
|
12
|
+
interface Serving {
|
|
13
|
+
fronds: readonly FrondDescriptor[];
|
|
14
|
+
}
|
|
3
15
|
import type { SchemaView } from '@fougere/schema';
|
|
4
16
|
import { sampleInput } from './sample.js';
|
|
5
17
|
|
|
6
18
|
export interface LoadOptions {
|
|
7
19
|
/** Where the calls go. The RPC door of a running app. */
|
|
8
20
|
door?: string;
|
|
21
|
+
/** The topology statement, so an op that crosses a process is given the time to. */
|
|
22
|
+
remotes?: Record<string, string>;
|
|
9
23
|
/** Values the generator cannot invent, by entity name — the id a `ref()` points at. */
|
|
10
24
|
given?: Record<string, Record<string, unknown>>;
|
|
11
25
|
}
|
|
@@ -13,10 +27,18 @@ export interface LoadOptions {
|
|
|
13
27
|
interface Reachable {
|
|
14
28
|
method: string;
|
|
15
29
|
input: unknown;
|
|
30
|
+
/** How many times this op's work crosses a process — what its budget is a function of. */
|
|
31
|
+
hops: number;
|
|
16
32
|
}
|
|
17
33
|
|
|
18
34
|
/** Every operation the app answers, with a body for those that take one. */
|
|
19
|
-
export function reachableOps(
|
|
35
|
+
export function reachableOps(
|
|
36
|
+
app: Serving,
|
|
37
|
+
given: LoadOptions['given'] = {},
|
|
38
|
+
remotes: Record<string, string> = {},
|
|
39
|
+
): Reachable[] {
|
|
40
|
+
const { operations } = resolveEffectiveOperations(app.fronds, { remotes });
|
|
41
|
+
const hops = new Map(operations.map((op) => [`${op.handler.address}.${op.name}`, op.reach.hops]));
|
|
20
42
|
const found: Reachable[] = [];
|
|
21
43
|
for (const frond of app.fronds) {
|
|
22
44
|
for (const handler of frond.handlers) {
|
|
@@ -25,9 +47,11 @@ export function reachableOps(app: App, given: LoadOptions['given'] = {}): Reacha
|
|
|
25
47
|
if (handler.surface) continue;
|
|
26
48
|
for (const [op, contract] of handler.operations ?? []) {
|
|
27
49
|
const schema = contract.input as SchemaView | undefined;
|
|
50
|
+
const method = `${handler.address}.${op}`;
|
|
28
51
|
found.push({
|
|
29
|
-
method
|
|
52
|
+
method,
|
|
30
53
|
input: schema ? sampleInput(schema, given[handler.address] ?? {}) : undefined,
|
|
54
|
+
hops: hops.get(method) ?? 0,
|
|
31
55
|
});
|
|
32
56
|
}
|
|
33
57
|
}
|
|
@@ -36,9 +60,9 @@ export function reachableOps(app: App, given: LoadOptions['given'] = {}): Reacha
|
|
|
36
60
|
}
|
|
37
61
|
|
|
38
62
|
/** A k6 scenario, written from what the app answers. */
|
|
39
|
-
export function loadScript(app:
|
|
63
|
+
export function loadScript(app: Serving, options: LoadOptions = {}): string {
|
|
40
64
|
const door = options.door ?? 'http://127.0.0.1:3000/_fougere/call';
|
|
41
|
-
const ops = reachableOps(app, options.given);
|
|
65
|
+
const ops = reachableOps(app, options.given, options.remotes ?? {});
|
|
42
66
|
// The shape, from the one function that states it. `body` is replaced per iteration.
|
|
43
67
|
const envelope = frameCall({ entity: 'ENTITY', op: 'OP' }, { params: {}, query: {}, input: undefined, state: {} } as never, 0);
|
|
44
68
|
// What the envelope carries that an iteration does not fill in itself. Keeping
|
|
@@ -56,6 +80,12 @@ const DOOR = ${JSON.stringify(door)};
|
|
|
56
80
|
// Every operation the app serves. A weight of 0 takes one out, visibly.
|
|
57
81
|
const OPS = ${JSON.stringify(ops.map((op) => ({ ...op, weight: 1 })), null, 2)};
|
|
58
82
|
|
|
83
|
+
// Yours: how long an op may take here, and what one process boundary is allowed to add. Two
|
|
84
|
+
// numbers instead of one, because an op that crosses nothing and an op that crosses twice were
|
|
85
|
+
// never the same subject — hops above is read from the code, these two are facts about your
|
|
86
|
+
// network.
|
|
87
|
+
const BUDGET = { base: 300, perHop: 200 };
|
|
88
|
+
|
|
59
89
|
export const options = {
|
|
60
90
|
// Yours: a flat rate draws flat lines and there is nothing to read in them.
|
|
61
91
|
stages: [
|
|
@@ -63,8 +93,17 @@ export const options = {
|
|
|
63
93
|
{ duration: '45s', target: 5 },
|
|
64
94
|
{ duration: '30s', target: 0 },
|
|
65
95
|
],
|
|
66
|
-
// Yours:
|
|
67
|
-
|
|
96
|
+
// Yours: the shape of the run.
|
|
97
|
+
// Derived from BUDGET and each op's hops — an op that crosses two processes is not held to
|
|
98
|
+
// the same figure as one that never leaves. k6 reads a threshold per tag, and every call
|
|
99
|
+
// below is tagged with the op it made.
|
|
100
|
+
thresholds: {
|
|
101
|
+
http_req_failed: ['rate<0.01'],
|
|
102
|
+
...Object.fromEntries(OPS.map((op) => [
|
|
103
|
+
\`http_req_duration{op:\${op.method}}\`,
|
|
104
|
+
[\`p(95)<\${BUDGET.base + op.hops * BUDGET.perHop}\`],
|
|
105
|
+
])),
|
|
106
|
+
},
|
|
68
107
|
};
|
|
69
108
|
|
|
70
109
|
const TOTAL = OPS.reduce((sum, op) => sum + op.weight, 0);
|
package/src/spans.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { App } from '@fougere/core';
|
|
2
|
+
import type { FinishedSpan } from '@fougere/observability';
|
|
3
|
+
|
|
4
|
+
/** One tracer per app: `app.use` has no undo, so a second call would stack a middleware. */
|
|
5
|
+
const installed = new WeakMap<App, FinishedSpan[][]>();
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The steps one call opened — the operation, then whatever ran under it.
|
|
9
|
+
*
|
|
10
|
+
* The dual of [statementsOf]: that one counts what a call asked the database, this one holds
|
|
11
|
+
* the shape of the call itself. What it is FOR is a test about structure rather than speed —
|
|
12
|
+
* an op that delegates reports almost no self time, whatever the machine it runs on:
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* const [op] = await spansOf(app, () => door.list());
|
|
16
|
+
* expect(op.selfMs).toBeLessThan(0.1 * op.ms); // it delegates, it does not work
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* A threshold in milliseconds tests the machine. A ratio tests the code.
|
|
20
|
+
*/
|
|
21
|
+
export async function spansOf(app: App, run: () => Promise<unknown>): Promise<FinishedSpan[]> {
|
|
22
|
+
const collecting = await tracerOf(app);
|
|
23
|
+
const spans: FinishedSpan[] = [];
|
|
24
|
+
collecting.push(spans);
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
await run();
|
|
28
|
+
} finally {
|
|
29
|
+
collecting.splice(collecting.indexOf(spans), 1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return spans;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function tracerOf(app: App): Promise<FinishedSpan[][]> {
|
|
36
|
+
const held = installed.get(app);
|
|
37
|
+
if (held) return held;
|
|
38
|
+
|
|
39
|
+
const { statementsUnder, tracing } = await observability();
|
|
40
|
+
const collecting: FinishedSpan[][] = [];
|
|
41
|
+
installed.set(app, collecting);
|
|
42
|
+
// A test IS the diagnosis, so it asks for the detail a process running continuously does not.
|
|
43
|
+
const tracer = tracing(
|
|
44
|
+
[(span) => { for (const into of collecting) into.push(span); }],
|
|
45
|
+
{ spanPerStatement: true },
|
|
46
|
+
);
|
|
47
|
+
app.use(tracer.middleware);
|
|
48
|
+
// Both halves, or `selfMs` would read as if the handler did the waiting itself. The
|
|
49
|
+
// subscription lives as long as the test process: `app.use` has no undo either, and one
|
|
50
|
+
// app per file is what a test file has.
|
|
51
|
+
await statementsUnder(tracer);
|
|
52
|
+
|
|
53
|
+
return collecting;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Optional, and dynamic for the reason `@fougere/calls` states: this package depends on none. */
|
|
57
|
+
async function observability(): Promise<typeof import('@fougere/observability')> {
|
|
58
|
+
try {
|
|
59
|
+
return await import('@fougere/observability');
|
|
60
|
+
} catch {
|
|
61
|
+
throw new Error(
|
|
62
|
+
'[spansOf] reading what a call cost needs @fougere/observability — install it, '
|
|
63
|
+
+ 'or there is no span to read.',
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { QueryEvent } from '@fougere/adapter-sql';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Every statement a call ran, in order.
|
|
5
|
+
*
|
|
6
|
+
* `testApp` boots on real SQLite, so this is what the handler actually asked the database —
|
|
7
|
+
* one per row is the shape of an N+1, and it is a number a test can refuse. A computed field
|
|
8
|
+
* that reads is the case: the façade hands the presenter the whole PAGE, so one query is
|
|
9
|
+
* possible, and nothing refuses `Promise.all(rows.map(…))` inside the field body.
|
|
10
|
+
*
|
|
11
|
+
* It REFUSES when `@fougere/adapter-sql` is absent, where the tracer degrades quietly: zero
|
|
12
|
+
* is what a passing assertion looks like, so an app observing nothing would turn this into a
|
|
13
|
+
* test that cannot fail.
|
|
14
|
+
*
|
|
15
|
+
* What it counts is the PROCESS, not the call — everything running while the block runs.
|
|
16
|
+
*/
|
|
17
|
+
export async function statementsOf(run: () => Promise<unknown>): Promise<QueryEvent[]> {
|
|
18
|
+
const { onQuery } = await sql();
|
|
19
|
+
const ran: QueryEvent[] = [];
|
|
20
|
+
const stop = onQuery((event) => ran.push(event));
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
await run();
|
|
24
|
+
} finally {
|
|
25
|
+
stop();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return ran;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Optional, and dynamic for the reason `@fougere/calls` states: this package depends on none. */
|
|
32
|
+
async function sql(): Promise<typeof import('@fougere/adapter-sql')> {
|
|
33
|
+
try {
|
|
34
|
+
return await import('@fougere/adapter-sql');
|
|
35
|
+
} catch {
|
|
36
|
+
throw new Error(
|
|
37
|
+
'[statementsOf] counting statements needs @fougere/adapter-sql — install it, '
|
|
38
|
+
+ 'or the count is zero whatever the handler ran.',
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|