@fougere/testing 0.9.2-alpha.0 → 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 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';
@@ -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;AACvC,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"}
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;AACvC,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"}
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 { App } from '@fougere/core';
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: App, given?: LoadOptions['given']): Reachable[];
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: App, options?: LoadOptions): string;
29
+ export declare function loadScript(app: Serving, options?: LoadOptions): string;
16
30
  export {};
17
31
  //# sourceMappingURL=load.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"load.d.ts","sourceRoot":"","sources":["../src/load.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,eAAe,CAAC;AAIzC,MAAM,WAAW,WAAW;IAC1B,yDAAyD;IACzD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,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;CAChB;AAED,4EAA4E;AAC5E,wBAAgB,YAAY,CAAC,GAAG,EAAE,GAAG,EAAE,KAAK,GAAE,WAAW,CAAC,OAAO,CAAM,GAAG,SAAS,EAAE,CAiBpF;AAED,wDAAwD;AACxD,wBAAgB,UAAU,CAAC,GAAG,EAAE,GAAG,EAAE,OAAO,GAAE,WAAgB,GAAG,MAAM,CAmDtE"}
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: `${handler.address}.${op}`,
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: what counts as too slow is a fact about your users.
51
- thresholds: { http_req_failed: ['rate<0.01'], http_req_duration: ['p(95)<500'] },
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;AAGpD,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAc1C,4EAA4E;AAC5E,MAAM,UAAU,YAAY,CAAC,GAAQ,EAAE,KAAK,GAAyB,EAAE;IACrE,MAAM,KAAK,GAAgB,EAAE,CAAC;IAC9B,KAAK,MAAM,KAAK,IAAI,GAAG,CAAC,MAAM,EAAE,CAAC;QAC/B,KAAK,MAAM,OAAO,IAAI,KAAK,CAAC,QAAQ,EAAE,CAAC;YACrC,+EAA+E;YAC/E,mEAAmE;YACnE,IAAI,OAAO,CAAC,OAAO;gBAAE,SAAS;YAC9B,KAAK,MAAM,CAAC,EAAE,EAAE,QAAQ,CAAC,IAAI,OAAO,CAAC,UAAU,IAAI,EAAE,EAAE,CAAC;gBACtD,MAAM,MAAM,GAAG,QAAQ,CAAC,KAA+B,CAAC;gBACxD,KAAK,CAAC,IAAI,CAAC;oBACT,MAAM,EAAE,GAAG,OAAO,CAAC,OAAO,IAAI,EAAE,EAAE;oBAClC,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC,WAAW,CAAC,MAAM,EAAE,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,SAAS;iBAC9E,CAAC,CAAC;YACL,CAAC;QACH,CAAC;IACH,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,wDAAwD;AACxD,MAAM,UAAU,UAAU,CAAC,GAAQ,EAAE,OAAO,GAAgB,EAAE;IAC5D,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,IAAI,qCAAqC,CAAC;IACnE,MAAM,GAAG,GAAG,YAAY,CAAC,GAAG,EAAE,OAAO,CAAC,KAAK,CAAC,CAAC;IAC7C,qFAAqF;IACrF,MAAM,QAAQ,GAAG,SAAS,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,EAAE,EAAE,IAAI,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,KAAK,EAAE,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,EAAE,EAAW,EAAE,CAAC,CAAC,CAAC;IAC/H,+EAA+E;IAC/E,+EAA+E;IAC/E,MAAM,OAAO,GAAG,IAAI,GAAG,CAAC,CAAC,QAAQ,EAAE,IAAI,EAAE,QAAQ,CAAC,CAAC,CAAC;IACpD,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;IAEtE,OAAO;;;;;eAKM,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC;;;cAGrB,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,GAAG,EAAE,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC;;;;;;;;;;;;;;;;;;;;;;;;oBAwB1D,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,WAAW,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,GAAG,EAAE,QAAQ,CAAC,GAA4B,CAAC,CAAC,CAAC,CAAC,CAAC;;;;;;;;CAQvH,CAAC;AACF,CAAC"}
1
+ {"version":3,"file":"load.js","sourceRoot":"","sources":["../src/load.ts"],"names":[],"mappings":"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"}
@@ -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.2-alpha.0",
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/core": "0.9.2-alpha.0",
38
- "@fougere/container": "0.9.2-alpha.0",
39
- "@fougere/schema": "0.9.2-alpha.0",
40
- "@fougere/app": "0.9.2-alpha.0",
41
- "@fougere/compiler": "0.9.2-alpha.0",
42
- "@fougere/transport-http": "0.9.2-alpha.0",
43
- "@fougere/defaults": "0.9.2-alpha.0"
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/adapter-graphql": "0.9.2-alpha.0"
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.2-alpha.0"
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 type { App } from '@fougere/core';
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(app: App, given: LoadOptions['given'] = {}): Reachable[] {
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: `${handler.address}.${op}`,
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: App, options: LoadOptions = {}): string {
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: what counts as too slow is a fact about your users.
67
- thresholds: { http_req_failed: ['rate<0.01'], http_req_duration: ['p(95)<500'] },
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
+ }