@fougere/testing 0.9.2-alpha.0 → 0.10.0-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.
Files changed (96) hide show
  1. package/README.md +1 -1
  2. package/dist/{comparison.d.ts → DoorContractCase.d.ts} +5 -13
  3. package/dist/DoorContractCase.d.ts.map +1 -0
  4. package/dist/{comparison.js → DoorContractCase.js} +31 -31
  5. package/dist/DoorContractCase.js.map +1 -0
  6. package/dist/DoorInput.d.ts +5 -0
  7. package/dist/DoorInput.d.ts.map +1 -0
  8. package/dist/DoorInput.js +2 -0
  9. package/dist/DoorInput.js.map +1 -0
  10. package/dist/DoorOptions.d.ts +7 -0
  11. package/dist/DoorOptions.d.ts.map +1 -0
  12. package/dist/DoorOptions.js +2 -0
  13. package/dist/DoorOptions.js.map +1 -0
  14. package/dist/all.d.ts +4 -4
  15. package/dist/all.d.ts.map +1 -1
  16. package/dist/all.js +3 -3
  17. package/dist/all.js.map +1 -1
  18. package/dist/app/TestApp.d.ts +15 -0
  19. package/dist/app/TestApp.d.ts.map +1 -0
  20. package/dist/{app.js → app/TestApp.js} +3 -3
  21. package/dist/app/TestApp.js.map +1 -0
  22. package/dist/{app.d.ts → app/TestAppOptions.d.ts} +2 -14
  23. package/dist/app/TestAppOptions.d.ts.map +1 -0
  24. package/dist/app/TestAppOptions.js +2 -0
  25. package/dist/app/TestAppOptions.js.map +1 -0
  26. package/dist/facades/CheckOptions.d.ts +6 -0
  27. package/dist/facades/CheckOptions.d.ts.map +1 -0
  28. package/dist/facades/CheckOptions.js +2 -0
  29. package/dist/facades/CheckOptions.js.map +1 -0
  30. package/dist/{doors.d.ts → facades/Verdict.d.ts} +4 -8
  31. package/dist/facades/Verdict.d.ts.map +1 -0
  32. package/dist/{doors.js → facades/Verdict.js} +4 -4
  33. package/dist/facades/Verdict.js.map +1 -0
  34. package/dist/index.d.ts +8 -5
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +7 -5
  37. package/dist/index.js.map +1 -1
  38. package/dist/load.d.ts +19 -5
  39. package/dist/load.d.ts.map +1 -1
  40. package/dist/load.js +29 -9
  41. package/dist/load.js.map +1 -1
  42. package/dist/spans.d.ts +18 -0
  43. package/dist/spans.d.ts.map +1 -0
  44. package/dist/spans.js +56 -0
  45. package/dist/spans.js.map +1 -0
  46. package/dist/statements.d.ts +17 -0
  47. package/dist/statements.d.ts.map +1 -0
  48. package/dist/statements.js +37 -0
  49. package/dist/statements.js.map +1 -0
  50. package/dist/stub/Port.d.ts +3 -0
  51. package/dist/stub/Port.d.ts.map +1 -0
  52. package/dist/stub/Port.js +2 -0
  53. package/dist/stub/Port.js.map +1 -0
  54. package/dist/{stub.d.ts → stub/Stub.d.ts} +2 -3
  55. package/dist/stub/Stub.d.ts.map +1 -0
  56. package/dist/{stub.js → stub/Stub.js} +1 -1
  57. package/dist/stub/Stub.js.map +1 -0
  58. package/dist/{sync.d.ts → sync/SyncDrift.d.ts} +2 -10
  59. package/dist/sync/SyncDrift.d.ts.map +1 -0
  60. package/dist/{sync.js → sync/SyncDrift.js} +5 -18
  61. package/dist/sync/SyncDrift.js.map +1 -0
  62. package/dist/sync/SyncedRemote.d.ts +10 -0
  63. package/dist/sync/SyncedRemote.d.ts.map +1 -0
  64. package/dist/sync/SyncedRemote.js +15 -0
  65. package/dist/sync/SyncedRemote.js.map +1 -0
  66. package/dist/vitest.d.ts.map +1 -1
  67. package/dist/vitest.js +4 -0
  68. package/dist/vitest.js.map +1 -1
  69. package/package.json +20 -10
  70. package/src/{comparison.ts → DoorContractCase.ts} +34 -40
  71. package/src/DoorInput.ts +1 -0
  72. package/src/DoorOptions.ts +7 -0
  73. package/src/all.ts +7 -5
  74. package/src/{app.ts → app/TestApp.ts} +4 -26
  75. package/src/app/TestAppOptions.ts +25 -0
  76. package/src/facades/CheckOptions.ts +6 -0
  77. package/src/{doors.ts → facades/Verdict.ts} +5 -9
  78. package/src/index.ts +8 -8
  79. package/src/load.ts +52 -13
  80. package/src/spans.ts +66 -0
  81. package/src/statements.ts +41 -0
  82. package/src/stub/Port.ts +2 -0
  83. package/src/{stub.ts → stub/Stub.ts} +1 -3
  84. package/src/{sync.ts → sync/SyncDrift.ts} +3 -23
  85. package/src/sync/SyncedRemote.ts +22 -0
  86. package/src/vitest.ts +4 -0
  87. package/dist/app.d.ts.map +0 -1
  88. package/dist/app.js.map +0 -1
  89. package/dist/comparison.d.ts.map +0 -1
  90. package/dist/comparison.js.map +0 -1
  91. package/dist/doors.d.ts.map +0 -1
  92. package/dist/doors.js.map +0 -1
  93. package/dist/stub.d.ts.map +0 -1
  94. package/dist/stub.js.map +0 -1
  95. package/dist/sync.d.ts.map +0 -1
  96. package/dist/sync.js.map +0 -1
@@ -4,9 +4,11 @@ import { Invocation } from '@fougere/core/contract';
4
4
  import { serveRest, serveRpc, tableOf } from '@fougere/app';
5
5
  import { lowerFirst, type SchemaView } from '@fougere/schema';
6
6
  import { listQuery, findQuery, mutationFor, at } from './gql.js';
7
- import { sampleInput, type SampleOptions } from './sample.js';
7
+ import { sampleInput } from './sample.js';
8
+ import type { DoorOptions } from './DoorOptions.js';
9
+ import type { DoorInput } from './DoorInput.js';
8
10
 
9
- /** The rows a door hands back, with its own envelope taken off. */
11
+ /** The rows a facade hands back, with its own envelope taken off. */
10
12
  function rowsOf(value: unknown): unknown {
11
13
  if (Array.isArray(value)) return [...value];
12
14
  const page = value as { items?: unknown } | null;
@@ -23,21 +25,13 @@ function written(value: unknown, sent: Record<string, unknown>): unknown {
23
25
  return wire(Object.fromEntries(Object.keys(sent).map((key) => [key, row[key]])));
24
26
  }
25
27
 
26
- export interface DoorOptions extends SampleOptions {
27
- given?: Record<string, unknown>;
28
- /** The audience, when the app serves named surfaces. */
29
- surface?: string;
30
- }
31
-
32
- interface Doors {
28
+ interface Facades {
33
29
  local: (op: string, call?: DoorInput) => Promise<unknown>;
34
30
  rpc: (op: string, call?: DoorInput) => Promise<unknown>;
35
31
  rest: (op: string, call?: DoorInput) => Promise<unknown>;
36
32
  graphql: (op: string, call?: DoorInput) => Promise<unknown>;
37
33
  }
38
34
 
39
- export interface DoorInput { id?: string; input?: Record<string, unknown> }
40
-
41
35
  export interface DoorContractCase {
42
36
  /** What this case proves — becomes the test name. */
43
37
  name: string;
@@ -48,17 +42,17 @@ export interface DoorContractCase {
48
42
  expected: unknown;
49
43
  }
50
44
 
51
- /** One entity, four doors, the same answers. */
45
+ /** One entity, four facades, the same answers. */
52
46
  export function checkDoors(app: App, entity: SchemaView, options: DoorOptions = {}): void {
53
47
  const name = lowerFirst(entity.name ?? '');
54
- const doors = doorsOf(app, entity, name, options.surface);
48
+ const facades = facadesOf(app, entity, name, options.surface);
55
49
  const inputOf = () => sampleInput(entity, options.given ?? {}, options);
56
50
 
57
- describe(`${entity.name} — the doors agree`, () => {
51
+ describe(`${entity.name} — the facades agree`, () => {
58
52
  it('on create, over what the caller sent', async () => {
59
53
  const sent = inputOf();
60
54
  const answers = await Promise.all(
61
- (['local', 'rpc', 'rest', 'graphql'] as const).map((door) => doors[door]('create', { input: sent })),
55
+ (['local', 'rpc', 'rest', 'graphql'] as const).map((facade) => facades[facade]('create', { input: sent })),
62
56
  );
63
57
 
64
58
  const [local, ...others] = answers.map((answer) => written(answer, sent));
@@ -68,33 +62,33 @@ export function checkDoors(app: App, entity: SchemaView, options: DoorOptions =
68
62
  });
69
63
 
70
64
  it('on list', async () => {
71
- await doors.local('create', { input: inputOf() });
65
+ await facades.local('create', { input: inputOf() });
72
66
 
73
- const local = wire(rowsOf(await doors.local('list')));
67
+ const local = wire(rowsOf(await facades.local('list')));
74
68
  expect(Array.isArray(local) && local.length > 0, 'nothing to compare').toBe(true);
75
69
 
76
- expect(wire(rowsOf(await doors.rpc('list'))), 'rpc ≠ local').toEqual(local);
77
- expect(wire(rowsOf(await doors.rest('list'))), 'rest ≠ local').toEqual(local);
78
- expect(wire(rowsOf(await doors.graphql('list'))), 'graphql ≠ local').toEqual(local);
70
+ expect(wire(rowsOf(await facades.rpc('list'))), 'rpc ≠ local').toEqual(local);
71
+ expect(wire(rowsOf(await facades.rest('list'))), 'rest ≠ local').toEqual(local);
72
+ expect(wire(rowsOf(await facades.graphql('list'))), 'graphql ≠ local').toEqual(local);
79
73
  });
80
74
 
81
75
  it('on findById', async () => {
82
- const row = await doors.local('create', { input: inputOf() }) as { id: string };
76
+ const row = await facades.local('create', { input: inputOf() }) as { id: string };
83
77
 
84
- const local = wire(await doors.local('findById', { id: row.id }));
78
+ const local = wire(await facades.local('findById', { id: row.id }));
85
79
 
86
- expect(wire(await doors.rpc('findById', { id: row.id })), 'rpc ≠ local').toEqual(local);
87
- expect(wire(await doors.rest('findById', { id: row.id })), 'rest ≠ local').toEqual(local);
88
- expect(wire(await doors.graphql('findById', { id: row.id })), 'graphql ≠ local').toEqual(local);
80
+ expect(wire(await facades.rpc('findById', { id: row.id })), 'rpc ≠ local').toEqual(local);
81
+ expect(wire(await facades.rest('findById', { id: row.id })), 'rest ≠ local').toEqual(local);
82
+ expect(wire(await facades.graphql('findById', { id: row.id })), 'graphql ≠ local').toEqual(local);
89
83
  });
90
84
 
91
85
  it('on update, over what the caller sent', async () => {
92
- const rows = await Promise.all([1, 2, 3, 4].map(() => doors.local('create', { input: inputOf() }))) as { id: string }[];
86
+ const rows = await Promise.all([1, 2, 3, 4].map(() => facades.local('create', { input: inputOf() }))) as { id: string }[];
93
87
  const patch = inputOf();
94
88
 
95
89
  const answers = await Promise.all(
96
90
  (['local', 'rpc', 'rest', 'graphql'] as const)
97
- .map((door, index) => doors[door]('update', { id: rows[index].id, input: patch })),
91
+ .map((facade, index) => facades[facade]('update', { id: rows[index].id, input: patch })),
98
92
  );
99
93
 
100
94
  const [local, ...others] = answers.map((answer) => written(answer, patch));
@@ -104,32 +98,32 @@ export function checkDoors(app: App, entity: SchemaView, options: DoorOptions =
104
98
  });
105
99
 
106
100
  it('on delete', async () => {
107
- const rows = await Promise.all([1, 2, 3, 4].map(() => doors.local('create', { input: inputOf() }))) as { id: string }[];
101
+ const rows = await Promise.all([1, 2, 3, 4].map(() => facades.local('create', { input: inputOf() }))) as { id: string }[];
108
102
 
109
103
  const answers = await Promise.all(
110
- (['local', 'rpc', 'rest', 'graphql'] as const).map((door, index) => doors[door]('delete', { id: rows[index].id })),
104
+ (['local', 'rpc', 'rest', 'graphql'] as const).map((facade, index) => facades[facade]('delete', { id: rows[index].id })),
111
105
  );
112
106
 
113
107
  // REST answers a deletion with no content at all, which is the protocol saying yes.
114
108
  const onTheWire = answers.map((answer) => (answer === undefined || answer === null ? true : wire(answer)));
115
- expect(new Set(onTheWire).size, `the doors disagree: ${JSON.stringify(onTheWire)}`).toBe(1);
109
+ expect(new Set(onTheWire).size, `the facades disagree: ${JSON.stringify(onTheWire)}`).toBe(1);
116
110
  });
117
111
 
118
112
  it('on a refusal', async () => {
119
- // A refusal is where the doors diverge most, and where each is most tempted to
113
+ // A refusal is where the facades diverge most, and where each is most tempted to
120
114
  // answer in its own words. What must match is that it WAS refused.
121
115
  const bad = { ...inputOf(), __unknown__: 'x' };
122
116
  const refusals = await Promise.all(
123
- (['local', 'rpc', 'rest', 'graphql'] as const).map((door) => refused(() => doors[door]('create', { input: bad }))),
117
+ (['local', 'rpc', 'rest', 'graphql'] as const).map((facade) => refused(() => facades[facade]('create', { input: bad }))),
124
118
  );
125
119
 
126
120
  expect(refusals[0], 'local accepted a body outside the contract').toBe(true);
127
- expect(refusals, `the doors disagree: ${JSON.stringify(refusals)}`).toEqual([true, true, true, true]);
121
+ expect(refusals, `the facades disagree: ${JSON.stringify(refusals)}`).toEqual([true, true, true, true]);
128
122
  });
129
123
  });
130
124
  }
131
125
 
132
- /** Run one hand-written invocation contract through every door. */
126
+ /** Run one hand-written invocation contract through every facade. */
133
127
  export function checkDoorContract(
134
128
  app: App,
135
129
  entity: SchemaView,
@@ -137,13 +131,13 @@ export function checkDoorContract(
137
131
  options: Pick<DoorOptions, 'surface'> = {},
138
132
  ): void {
139
133
  const name = lowerFirst(entity.name ?? '');
140
- const doors = doorsOf(app, entity, name, options.surface);
134
+ const facades = facadesOf(app, entity, name, options.surface);
141
135
  const names = ['local', 'rpc', 'rest', 'graphql'] as const;
142
136
 
143
- describe(`${entity.name} — its invocation contract crosses every door`, () => {
137
+ describe(`${entity.name} — its invocation contract crosses every facade`, () => {
144
138
  for (const one of cases) {
145
139
  it(one.name, async () => {
146
- const answers = await Promise.all(names.map((door) => doors[door](one.operation, one.input)));
140
+ const answers = await Promise.all(names.map((facade) => facades[facade](one.operation, one.input)));
147
141
  for (const [index, answer] of answers.entries()) {
148
142
  expect(wire(answer), `${names[index]} disagrees with the canonical invocation`).toEqual(wire(one.expected));
149
143
  }
@@ -156,8 +150,8 @@ async function refused(call: () => Promise<unknown>): Promise<boolean> {
156
150
  try { await call(); return false; } catch { return true; }
157
151
  }
158
152
 
159
- /** The four doors, each reduced to `(op, input) => answer` so the tests above read alike. */
160
- function doorsOf(app: App, entity: SchemaView, name: string, surface?: string): Doors {
153
+ /** The four facades, each reduced to `(op, input) => answer` so the tests above read alike. */
154
+ function facadesOf(app: App, entity: SchemaView, name: string, surface?: string): Facades {
161
155
  const run = createLocalRunner(app, surface);
162
156
  const state: Record<string, unknown> = {};
163
157
 
@@ -181,7 +175,7 @@ function doorsOf(app: App, entity: SchemaView, name: string, surface?: string):
181
175
  },
182
176
 
183
177
  rest: async (op, call) => {
184
- // The route the REST door itself would match, read from its own table — rebuilding
178
+ // The route the REST facade itself would match, read from its own table — rebuilding
185
179
  // the path here would be a second opinion on where an entity lives.
186
180
  const route = tableOf(app).find((one) => one.entityName === name && one.operationName === op);
187
181
  if (!route) throw new Error(`[checkDoors] REST serves no ${name}.${op}`);
@@ -0,0 +1 @@
1
+ export interface DoorInput { id?: string; input?: Record<string, unknown> }
@@ -0,0 +1,7 @@
1
+ import { type SampleOptions } from './sample.js';
2
+
3
+ export interface DoorOptions extends SampleOptions {
4
+ given?: Record<string, unknown>;
5
+ /** The audience, when the app serves named surfaces. */
6
+ surface?: string;
7
+ }
package/src/all.ts CHANGED
@@ -1,14 +1,16 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import type { App } from '@fougere/core';
3
3
  import type { SchemaView } from '@fougere/schema';
4
- import { checkContract, checkOutput, type CheckOptions } from './doors.js';
5
- import { checkDoors, type DoorOptions } from './comparison.js';
4
+ import { type CheckOptions } from './facades/CheckOptions.js';
5
+ import { checkContract, checkOutput } from './facades/Verdict.js';
6
+ import { checkDoors } from './DoorContractCase.js';
7
+ import { type DoorOptions } from './DoorOptions.js';
6
8
 
7
9
  export interface CheckAllOptions extends DoorOptions, CheckOptions {
8
10
  /** Entities to leave out, by name — one whose rows a test cannot seed, typically. */
9
11
  except?: string[];
10
- /** Skip the four-door comparison. The contract and the leak are still checked. */
11
- doors?: boolean;
12
+ /** Skip the four-facade comparison. The contract and the leak are still checked. */
13
+ facades?: boolean;
12
14
  }
13
15
 
14
16
  /** Every entity the app SERVES, with its handler. */
@@ -37,6 +39,6 @@ export function checkAll(app: App, options: CheckAllOptions = {}): void {
37
39
  for (const { entity } of served) {
38
40
  checkContract(app, entity, options);
39
41
  checkOutput(app, entity, options);
40
- if (options.doors !== false) checkDoors(app, entity, options);
42
+ if (options.facades !== false) checkDoors(app, entity, options);
41
43
  }
42
44
  }
@@ -2,33 +2,11 @@ import { type App } from '@fougere/core';
2
2
  import { boot } from '@fougere/compiler';
3
3
  import { createContainer } from '@fougere/container';
4
4
  import { resolveStorage, type DbConfig } from '@fougere/defaults';
5
- import { installStubs, type Port, type Stub } from './stub.js';
6
- import { scopeOfRun } from './scope.js';
5
+ import { installStubs, type Stub } from '../stub/Stub.js';
6
+ import type { Port } from '../stub/Port.js';
7
+ import { scopeOfRun } from '../scope.js';
7
8
  import { lowerFirst } from '@fougere/schema';
8
-
9
- export interface TestAppOptions {
10
- /** The project to scan. */
11
- root?: string;
12
- /** Boot only these fronds, by name. Deduced from the position when absent. */
13
- fronds?: string[];
14
- /**
15
- * The running test file, for hosts other than vitest. Vitest is read automatically
16
- * through `expect.getState()`; anything else hands its own path in.
17
- */
18
- testPath?: string;
19
- /** Where the rows go. */
20
- db?: string;
21
- /**
22
- * Follow `remotes:` from the config. False by default — a test that meant to exercise
23
- * one frond should not silently reach for a process that is not running.
24
- */
25
- topology?: boolean;
26
- /**
27
- * Ports answered by a double instead of their realization. A double carries every
28
- * method the port declares and returns nothing until the test says what it returns.
29
- */
30
- stub?: Port[];
31
- }
9
+ import type { TestAppOptions } from './TestAppOptions.js';
32
10
 
33
11
  /** An app, plus the doubles standing in front of its ports and the facts it announced. */
34
12
  export interface TestApp extends App {
@@ -0,0 +1,25 @@
1
+ import type { Port } from '../stub/Port.js';
2
+
3
+ export interface TestAppOptions {
4
+ /** The project to scan. */
5
+ root?: string;
6
+ /** Boot only these fronds, by name. Deduced from the position when absent. */
7
+ fronds?: string[];
8
+ /**
9
+ * The running test file, for hosts other than vitest. Vitest is read automatically
10
+ * through `expect.getState()`; anything else hands its own path in.
11
+ */
12
+ testPath?: string;
13
+ /** Where the rows go. */
14
+ db?: string;
15
+ /**
16
+ * Follow `remotes:` from the config. False by default — a test that meant to exercise
17
+ * one frond should not silently reach for a process that is not running.
18
+ */
19
+ topology?: boolean;
20
+ /**
21
+ * Ports answered by a double instead of their realization. A double carries every
22
+ * method the port declares and returns nothing until the test says what it returns.
23
+ */
24
+ stub?: Port[];
25
+ }
@@ -0,0 +1,6 @@
1
+ import { type SampleOptions } from '../sample.js';
2
+
3
+ export interface CheckOptions extends SampleOptions {
4
+ /** Values the generator cannot invent — the id of a row a `ref()` points at. */
5
+ given?: Record<string, unknown>;
6
+ }
@@ -3,16 +3,17 @@ import { createLocalRunner, validationErrorsOf, type App } from '@fougere/core';
3
3
  import { Invocation } from '@fougere/core/contract';
4
4
  import { lowerFirst, Visibility, type SchemaView, type ValidationError } from '@fougere/schema';
5
5
  import { Cases } from '@fougere/schema';
6
- import { derivedCases } from './derive.js';
7
- import { sampleInput, replaySeed, type SampleOptions } from './sample.js';
6
+ import { derivedCases } from '../derive.js';
7
+ import { sampleInput, replaySeed } from '../sample.js';
8
+ import type { CheckOptions } from './CheckOptions.js';
8
9
 
9
- /** The one shape both the local validator and a door already speak. */
10
+ /** The one shape both the local validator and a facade already speak. */
10
11
  export interface Verdict {
11
12
  success: boolean;
12
13
  errors?: ValidationError[];
13
14
  }
14
15
 
15
- /** What a door answers, in the shape a verdict is compared in. */
16
+ /** What a facade answers, in the shape a verdict is compared in. */
16
17
  export async function verdictOf(call: () => Promise<unknown>): Promise<Verdict> {
17
18
  try {
18
19
  await call();
@@ -29,11 +30,6 @@ function opsFor(entity: SchemaView): { create: string; update: string; name: str
29
30
  return { name, create: 'create', update: 'update' };
30
31
  }
31
32
 
32
- export interface CheckOptions extends SampleOptions {
33
- /** Values the generator cannot invent — the id of a row a `ref()` points at. */
34
- given?: Record<string, unknown>;
35
- }
36
-
37
33
  /** The declared contract, posed to the façade that will receive it. */
38
34
  export function checkContract(app: App, entity: SchemaView, options: CheckOptions = {}): void {
39
35
  const { name, create, update } = opsFor(entity);
package/src/index.ts CHANGED
@@ -1,15 +1,15 @@
1
1
  export { sampleInput } from './sample.js';
2
2
  // The derivation itself lives with the axes it reads.
3
3
  export { Cases, type ValidationCase } from '@fougere/schema';
4
- export { testApp } from './app.js';
5
- export { checkContract, checkOutput, verdictOf, type Verdict } from './doors.js';
6
- export { stubOf, type Port, type Stub } from './stub.js';
4
+ export { testApp } from './app/TestApp.js';
5
+ export { checkContract, checkOutput, verdictOf, type Verdict } from './facades/Verdict.js';
6
+ export { type Port } from './stub/Port.js';
7
+ export { stubOf, type Stub } from './stub/Stub.js';
7
8
  export { frondOf, type Scope } from './scope.js';
8
- export { loadScript } from './load.js';
9
- export {
10
- checkDoorContract,
11
- checkDoors,
12
- } from './comparison.js';
9
+ export { loadScript, reachableOps } from './load.js';
10
+ export { statementsOf } from './statements.js';
11
+ export { spansOf } from './spans.js';
12
+ export { checkDoorContract, checkDoors } from './DoorContractCase.js';
13
13
  export { at } from './gql.js';
14
14
  export { driftOf, agrees, explain, type CardDrift } from './remotes.js';
15
15
  export { checkAll } from './all.js';
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
- /** Where the calls go. The RPC door of a running app. */
8
- door?: string;
19
+ /** Where the calls go. The RPC facade of a running app. */
20
+ facade?: 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,21 +27,31 @@ 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) {
23
- // A named surface is a restricted door; the load of an app is what its default
24
- // door answers, so a surface would count the same operation twice.
45
+ // A named surface is a restricted facade; the load of an app is what its default
46
+ // facade answers, so a surface would count the same operation twice.
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 {
40
- const door = options.door ?? 'http://127.0.0.1:3000/_fougere/call';
41
- const ops = reachableOps(app, options.given);
63
+ export function loadScript(app: Serving, options: LoadOptions = {}): string {
64
+ const facade = options.facade ?? 'http://127.0.0.1:3000/_fougere/call';
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
@@ -51,11 +75,17 @@ export function loadScript(app: App, options: LoadOptions = {}): string {
51
75
  import http from 'k6/http';
52
76
  import { check } from 'k6';
53
77
 
54
- const DOOR = ${JSON.stringify(door)};
78
+ const DOOR = ${JSON.stringify(facade)};
55
79
 
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, () => facade.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
+ }
@@ -0,0 +1,2 @@
1
+ /** Anything a provider can be declared as: a class the container knows how to build. */
2
+ export type Port = abstract new (...args: never[]) => unknown;
@@ -1,9 +1,7 @@
1
1
  import { vi, type Mock } from 'vitest';
2
2
  import type { Container } from '@fougere/container';
3
3
  import type { App } from '@fougere/core';
4
-
5
- /** Anything a provider can be declared as: a class the container knows how to build. */
6
- export type Port = abstract new (...args: never[]) => unknown;
4
+ import type { Port } from './Port.js';
7
5
 
8
6
  /** The double handed in place of a port — one spy per method the port declares. */
9
7
  export type Stub<T> = { [K in keyof T]: T[K] extends (...args: never[]) => unknown ? Mock : T[K] };
@@ -1,16 +1,8 @@
1
- import { readFile } from 'node:fs/promises';
2
1
  import { join } from 'node:path';
3
2
  import { pathToFileURL } from 'node:url';
4
3
  import { Card, type Change, type SchemaDescriptor, type SchemaView } from '@fougere/schema';
5
4
  import type { IdentityCard } from '@fougere/core';
6
-
7
- /** One entry of `.fougere/remotes.json`, written by `fougere sync`. */
8
- export interface SyncedRemote {
9
- name: string;
10
- url: string;
11
- /** Where the synced classes were written. */
12
- path: string;
13
- }
5
+ import type { SyncedRemote } from './SyncedRemote.js';
14
6
 
15
7
  /** What separates a consumer's synced copy from what the producer serves today. */
16
8
  export interface SyncDrift {
@@ -21,18 +13,6 @@ export interface SyncDrift {
21
13
  moved: { entity: string; changes: Change[] }[];
22
14
  }
23
15
 
24
- /** The remotes a project synced, read from the file `fougere sync` writes. */
25
- export async function syncedRemotes(root: string): Promise<SyncedRemote[]> {
26
- try {
27
- const raw = await readFile(join(root, '.fougere', 'remotes.json'), 'utf8');
28
- const parsed = JSON.parse(raw) as Record<string, { url: string; path: string }>;
29
- return Object.entries(parsed).map(([name, one]) => ({ name, ...one }));
30
- } catch {
31
- // No file is the ordinary case: an app with no remote synced nothing.
32
- return [];
33
- }
34
- }
35
-
36
16
  /** The shapes a consumer holds for one remote frond. */
37
17
  export async function heldShapes(remote: SyncedRemote): Promise<Map<string, SchemaDescriptor>> {
38
18
  const cards = new Map<string, SchemaDescriptor>();
@@ -47,12 +27,12 @@ export async function heldShapes(remote: SyncedRemote): Promise<Map<string, Sche
47
27
  return cards;
48
28
  }
49
29
 
50
- /** The shapes a card announces, by the name a door carries. */
30
+ /** The shapes a card announces, by the name a facade carries. */
51
31
  function servedShapes(card: IdentityCard, frond: string): Map<string, unknown> {
52
32
  const served = new Map<string, unknown>();
53
33
  for (const one of card.fronds) {
54
34
  if (one.name !== frond) continue;
55
- for (const door of one.doors) if (door.schema) served.set(door.schema.title ?? door.name, door.schema);
35
+ for (const facade of one.facades) if (facade.schema) served.set(facade.schema.title ?? facade.name, facade.schema);
56
36
  }
57
37
  return served;
58
38
  }