@fougere/testing 0.6.0-alpha.0 → 0.7.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 (71) hide show
  1. package/README.md +1 -1
  2. package/dist/all.d.ts +2 -17
  3. package/dist/all.d.ts.map +1 -1
  4. package/dist/all.js +2 -17
  5. package/dist/all.js.map +1 -1
  6. package/dist/app.d.ts +4 -35
  7. package/dist/app.d.ts.map +1 -1
  8. package/dist/app.js +2 -17
  9. package/dist/app.js.map +1 -1
  10. package/dist/comparison.d.ts +3 -20
  11. package/dist/comparison.d.ts.map +1 -1
  12. package/dist/comparison.js +29 -58
  13. package/dist/comparison.js.map +1 -1
  14. package/dist/derive.d.ts +3 -9
  15. package/dist/derive.d.ts.map +1 -1
  16. package/dist/derive.js +1 -7
  17. package/dist/derive.js.map +1 -1
  18. package/dist/doors.d.ts +4 -22
  19. package/dist/doors.d.ts.map +1 -1
  20. package/dist/doors.js +6 -24
  21. package/dist/doors.js.map +1 -1
  22. package/dist/gql.d.ts +5 -24
  23. package/dist/gql.d.ts.map +1 -1
  24. package/dist/gql.js +11 -37
  25. package/dist/gql.js.map +1 -1
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js.map +1 -1
  29. package/dist/load.d.ts +3 -20
  30. package/dist/load.d.ts.map +1 -1
  31. package/dist/load.js +6 -23
  32. package/dist/load.js.map +1 -1
  33. package/dist/remotes.d.ts +1 -6
  34. package/dist/remotes.d.ts.map +1 -1
  35. package/dist/remotes.js +1 -6
  36. package/dist/remotes.js.map +1 -1
  37. package/dist/sample.d.ts +2 -14
  38. package/dist/sample.d.ts.map +1 -1
  39. package/dist/sample.js +8 -16
  40. package/dist/sample.js.map +1 -1
  41. package/dist/scope.d.ts +5 -38
  42. package/dist/scope.d.ts.map +1 -1
  43. package/dist/scope.js +4 -26
  44. package/dist/scope.js.map +1 -1
  45. package/dist/stub.d.ts +3 -32
  46. package/dist/stub.d.ts.map +1 -1
  47. package/dist/stub.js +3 -32
  48. package/dist/stub.js.map +1 -1
  49. package/dist/sync.d.ts +2 -19
  50. package/dist/sync.d.ts.map +1 -1
  51. package/dist/sync.js +5 -22
  52. package/dist/sync.js.map +1 -1
  53. package/dist/vitest.d.ts +1 -20
  54. package/dist/vitest.d.ts.map +1 -1
  55. package/dist/vitest.js +1 -20
  56. package/dist/vitest.js.map +1 -1
  57. package/package.json +9 -9
  58. package/src/all.ts +2 -17
  59. package/src/app.ts +5 -41
  60. package/src/comparison.ts +34 -63
  61. package/src/derive.ts +3 -9
  62. package/src/doors.ts +7 -25
  63. package/src/gql.ts +9 -35
  64. package/src/index.ts +1 -1
  65. package/src/load.ts +7 -24
  66. package/src/remotes.ts +1 -6
  67. package/src/sample.ts +10 -30
  68. package/src/scope.ts +5 -38
  69. package/src/stub.ts +3 -32
  70. package/src/sync.ts +5 -22
  71. package/src/vitest.ts +1 -20
package/src/comparison.ts CHANGED
@@ -6,13 +6,7 @@ import { lowerFirst, type SchemaView } from '@fougere/schema';
6
6
  import { listQuery, findQuery, mutationFor, at } from './gql.js';
7
7
  import { sampleInput, type SampleOptions } from './sample.js';
8
8
 
9
- /**
10
- * The rows a door hands back, with its own envelope taken off.
11
- *
12
- * Each door wraps differently by construction — REST answers a page, GraphQL nests under
13
- * its field, RPC returns the value — and comparing the wrappers would compare the
14
- * protocols. What must agree is what is inside.
15
- */
9
+ /** The rows a door hands back, with its own envelope taken off. */
16
10
  function rowsOf(value: unknown): unknown {
17
11
  if (Array.isArray(value)) return [...value];
18
12
  const page = value as { items?: unknown } | null;
@@ -22,13 +16,7 @@ function rowsOf(value: unknown): unknown {
22
16
  /** Through the wire and back, so a `Date` and its ISO string are not read as a divergence. */
23
17
  const wire = (value: unknown): unknown => JSON.parse(JSON.stringify(value ?? null));
24
18
 
25
- /**
26
- * What is the SAME row seen twice, and what is merely a second row.
27
- *
28
- * A write creates a different row at each door — different id, different `createdAt` —
29
- * so comparing values would compare clocks. The generated fields are dropped and what the
30
- * caller sent is what remains, which is the part the doors must agree on.
31
- */
19
+ /** What is the SAME row seen twice, and what is merely a second row. */
32
20
  function written(value: unknown, sent: Record<string, unknown>): unknown {
33
21
  const row = value as Record<string, unknown> | null;
34
22
  if (!row || typeof row !== 'object') return wire(row);
@@ -42,13 +30,13 @@ export interface DoorOptions extends SampleOptions {
42
30
  }
43
31
 
44
32
  interface Doors {
45
- local: (op: string, input?: DoorInput) => Promise<unknown>;
46
- rpc: (op: string, input?: DoorInput) => Promise<unknown>;
47
- rest: (op: string, input?: DoorInput) => Promise<unknown>;
48
- graphql: (op: string, input?: DoorInput) => Promise<unknown>;
33
+ local: (op: string, call?: DoorInput) => Promise<unknown>;
34
+ rpc: (op: string, call?: DoorInput) => Promise<unknown>;
35
+ rest: (op: string, call?: DoorInput) => Promise<unknown>;
36
+ graphql: (op: string, call?: DoorInput) => Promise<unknown>;
49
37
  }
50
38
 
51
- export interface DoorInput { id?: string; body?: Record<string, unknown> }
39
+ export interface DoorInput { id?: string; input?: Record<string, unknown> }
52
40
 
53
41
  export interface DoorContractCase {
54
42
  /** What this case proves — becomes the test name. */
@@ -60,28 +48,17 @@ export interface DoorContractCase {
60
48
  expected: unknown;
61
49
  }
62
50
 
63
- /**
64
- * One entity, four doors, the same answers.
65
- *
66
- * The claim runs through the whole repo — a frond runs in-process or behind JSON-RPC with
67
- * identical user code, and REST, GraphQL and RPC are three projections of one contract —
68
- * and nothing compared REST to GraphQL until now. `transport-swap.test.ts` compares three
69
- * TRANSPORTS, which is a different sentence.
70
- *
71
- * The five CRUD operations, reads and writes. A CUSTOM op is not compared: REST addresses
72
- * it by a path the table states, GraphQL by a mutation whose input type is its own, and
73
- * matching the two means guessing which is which — a guess this file exists to avoid.
74
- */
51
+ /** One entity, four doors, the same answers. */
75
52
  export function checkDoors(app: App, entity: SchemaView, options: DoorOptions = {}): void {
76
53
  const name = lowerFirst(entity.name ?? '');
77
54
  const doors = doorsOf(app, entity, name, options.surface);
78
- const bodyOf = () => sampleInput(entity, options.given ?? {}, options);
55
+ const inputOf = () => sampleInput(entity, options.given ?? {}, options);
79
56
 
80
57
  describe(`${entity.name} — the doors agree`, () => {
81
58
  it('on create, over what the caller sent', async () => {
82
- const sent = bodyOf();
59
+ const sent = inputOf();
83
60
  const answers = await Promise.all(
84
- (['local', 'rpc', 'rest', 'graphql'] as const).map((door) => doors[door]('create', { body: sent })),
61
+ (['local', 'rpc', 'rest', 'graphql'] as const).map((door) => doors[door]('create', { input: sent })),
85
62
  );
86
63
 
87
64
  const [local, ...others] = answers.map((answer) => written(answer, sent));
@@ -91,7 +68,7 @@ export function checkDoors(app: App, entity: SchemaView, options: DoorOptions =
91
68
  });
92
69
 
93
70
  it('on list', async () => {
94
- await doors.local('create', { body: bodyOf() });
71
+ await doors.local('create', { input: inputOf() });
95
72
 
96
73
  const local = wire(rowsOf(await doors.local('list')));
97
74
  expect(Array.isArray(local) && local.length > 0, 'nothing to compare').toBe(true);
@@ -102,7 +79,7 @@ export function checkDoors(app: App, entity: SchemaView, options: DoorOptions =
102
79
  });
103
80
 
104
81
  it('on findById', async () => {
105
- const row = await doors.local('create', { body: bodyOf() }) as { id: string };
82
+ const row = await doors.local('create', { input: inputOf() }) as { id: string };
106
83
 
107
84
  const local = wire(await doors.local('findById', { id: row.id }));
108
85
 
@@ -112,12 +89,12 @@ export function checkDoors(app: App, entity: SchemaView, options: DoorOptions =
112
89
  });
113
90
 
114
91
  it('on update, over what the caller sent', async () => {
115
- const rows = await Promise.all([1, 2, 3, 4].map(() => doors.local('create', { body: bodyOf() }))) as { id: string }[];
116
- const patch = bodyOf();
92
+ const rows = await Promise.all([1, 2, 3, 4].map(() => doors.local('create', { input: inputOf() }))) as { id: string }[];
93
+ const patch = inputOf();
117
94
 
118
95
  const answers = await Promise.all(
119
96
  (['local', 'rpc', 'rest', 'graphql'] as const)
120
- .map((door, index) => doors[door]('update', { id: rows[index].id, body: patch })),
97
+ .map((door, index) => doors[door]('update', { id: rows[index].id, input: patch })),
121
98
  );
122
99
 
123
100
  const [local, ...others] = answers.map((answer) => written(answer, patch));
@@ -127,23 +104,23 @@ export function checkDoors(app: App, entity: SchemaView, options: DoorOptions =
127
104
  });
128
105
 
129
106
  it('on delete', async () => {
130
- const rows = await Promise.all([1, 2, 3, 4].map(() => doors.local('create', { body: bodyOf() }))) as { id: string }[];
107
+ const rows = await Promise.all([1, 2, 3, 4].map(() => doors.local('create', { input: inputOf() }))) as { id: string }[];
131
108
 
132
109
  const answers = await Promise.all(
133
110
  (['local', 'rpc', 'rest', 'graphql'] as const).map((door, index) => doors[door]('delete', { id: rows[index].id })),
134
111
  );
135
112
 
136
113
  // REST answers a deletion with no content at all, which is the protocol saying yes.
137
- const said = answers.map((answer) => (answer === undefined || answer === null ? true : wire(answer)));
138
- expect(new Set(said).size, `the doors disagree: ${JSON.stringify(said)}`).toBe(1);
114
+ 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);
139
116
  });
140
117
 
141
118
  it('on a refusal', async () => {
142
119
  // A refusal is where the doors diverge most, and where each is most tempted to
143
120
  // answer in its own words. What must match is that it WAS refused.
144
- const bad = { ...bodyOf(), __unknown__: 'x' };
121
+ const bad = { ...inputOf(), __unknown__: 'x' };
145
122
  const refusals = await Promise.all(
146
- (['local', 'rpc', 'rest', 'graphql'] as const).map((door) => refused(() => doors[door]('create', { body: bad }))),
123
+ (['local', 'rpc', 'rest', 'graphql'] as const).map((door) => refused(() => doors[door]('create', { input: bad }))),
147
124
  );
148
125
 
149
126
  expect(refusals[0], 'local accepted a body outside the contract').toBe(true);
@@ -152,13 +129,7 @@ export function checkDoors(app: App, entity: SchemaView, options: DoorOptions =
152
129
  });
153
130
  }
154
131
 
155
- /**
156
- * Run one hand-written invocation contract through every door.
157
- *
158
- * `checkDoors` derives the generic CRUD gradient. This is its small explicit companion
159
- * for semantics only the handler can observe — notably omitted versus null input. A new
160
- * adapter joins the same harness instead of inventing its own interpretation.
161
- */
132
+ /** Run one hand-written invocation contract through every door. */
162
133
  export function checkDoorContract(
163
134
  app: App,
164
135
  entity: SchemaView,
@@ -190,43 +161,43 @@ function doorsOf(app: App, entity: SchemaView, name: string, surface?: string):
190
161
  const run = createLocalRunner(app, surface);
191
162
  const state: Record<string, unknown> = {};
192
163
 
193
- const invocation = (input: DoorInput = {}) => ({
164
+ const invocation = (call: DoorInput = {}) => ({
194
165
  ...EMPTY_INVOCATION,
195
- ...(input.id !== undefined ? { params: { id: input.id } } : {}),
196
- ...(input.body !== undefined ? { body: input.body } : {}),
166
+ ...(call.id !== undefined ? { params: { id: call.id } } : {}),
167
+ ...(call.input !== undefined ? { input: call.input } : {}),
197
168
  });
198
169
 
199
170
  return {
200
- local: (op, input) => run({ entity: name, op }, invocation(input)),
171
+ local: (op, call) => run({ entity: name, op }, invocation(call)),
201
172
 
202
- rpc: async (op, input) => {
173
+ rpc: async (op, call) => {
203
174
  const answer = await serveRpc(app, {
204
175
  path: '',
205
- body: { jsonrpc: '2.0', id: 1, method: `${name}.${op}`, params: invocation(input) },
176
+ body: { jsonrpc: '2.0', id: 1, method: `${name}.${op}`, params: invocation(call) },
206
177
  state,
207
178
  }) as { result?: unknown; error?: { message: string } };
208
179
  if (answer.error) throw new Error(answer.error.message);
209
180
  return answer.result;
210
181
  },
211
182
 
212
- rest: async (op, input) => {
183
+ rest: async (op, call) => {
213
184
  // The route the REST door itself would match, read from its own table — rebuilding
214
185
  // the path here would be a second opinion on where an entity lives.
215
186
  const route = tableOf(app).find((one) => one.entityName === name && one.operationName === op);
216
187
  if (!route) throw new Error(`[checkDoors] REST serves no ${name}.${op}`);
217
- const path = route.segments.map((segment) => (segment.startsWith(':') ? input?.id ?? '' : segment)).join('/');
188
+ const path = route.segments.map((segment) => (segment.startsWith(':') ? call?.id ?? '' : segment)).join('/');
218
189
 
219
- const answer = await serveRest(app, { method: route.method, path, query: {}, body: input?.body, state });
190
+ const answer = await serveRest(app, { method: route.method, path, query: {}, body: call?.input, state });
220
191
  if (answer.kind !== 'ok') throw new Error(`[checkDoors] REST answered ${answer.kind} on ${name}.${op}`);
221
192
  return answer.body;
222
193
  },
223
194
 
224
- graphql: async (op, input) => {
195
+ graphql: async (op, call) => {
225
196
  const { executeOn, schemaOf } = await import('@fougere/adapter-graphql');
226
197
  const schema = schemaOf(app as never) as never;
227
198
  const built = op === 'list' ? listQuery(schema, entity)
228
- : op === 'findById' ? findQuery(schema, entity, input?.id ?? '')
229
- : mutationFor(schema, entity, op, { id: input?.id, body: input?.body });
199
+ : op === 'findById' ? findQuery(schema, entity, call?.id ?? '')
200
+ : mutationFor(schema, entity, op, { id: call?.id, input: call?.input });
230
201
  if (!built) throw new Error(`[checkDoors] GraphQL serves no ${entity.name} ${op}`);
231
202
 
232
203
  const answer = await executeOn(app as never, { query: built.query, state });
package/src/derive.ts CHANGED
@@ -1,18 +1,12 @@
1
- import { Cases, type Case } from '@fougere/schema';
1
+ import { Cases, type ValidationCase } from '@fougere/schema';
2
2
  import type { SchemaView } from '@fougere/schema';
3
3
  import { sampleInput, type SampleOptions } from './sample.js';
4
4
 
5
- /**
6
- * The cases, with the valid body generated rather than handed in.
7
- *
8
- * `Cases` lives in `@fougere/schema` because deriving them reads the four axes and
9
- * nothing else. This is the half that needs a generator, which is why it is here: a
10
- * 426 KB faker has no business in the package a browser loads.
11
- */
5
+ /** The cases, with the valid body generated rather than handed in. */
12
6
  export function derivedCases(
13
7
  entity: SchemaView,
14
8
  given: Record<string, unknown> = {},
15
9
  options: SampleOptions = {},
16
- ): readonly Case[] {
10
+ ): readonly ValidationCase[] {
17
11
  return Cases.of(entity, sampleInput(entity, given, options)).all;
18
12
  }
package/src/doors.ts CHANGED
@@ -6,19 +6,13 @@ import { Cases } from '@fougere/schema';
6
6
  import { derivedCases } from './derive.js';
7
7
  import { sampleInput, replaySeed, type SampleOptions } from './sample.js';
8
8
 
9
- /** The one shape both the local judge and a door already speak. */
9
+ /** The one shape both the local validator and a door already speak. */
10
10
  export interface Verdict {
11
11
  success: boolean;
12
12
  errors?: ValidationError[];
13
13
  }
14
14
 
15
- /**
16
- * What a door answers, in the shape a verdict is compared in.
17
- *
18
- * A refusal reaches a caller as a thrown `FougereError` carrying `details`, not as a
19
- * returned value — so the translation happens once, here, and every reader below compares
20
- * the same thing.
21
- */
15
+ /** What a door answers, in the shape a verdict is compared in. */
22
16
  export async function verdictOf(call: () => Promise<unknown>): Promise<Verdict> {
23
17
  try {
24
18
  await call();
@@ -40,13 +34,7 @@ export interface CheckOptions extends SampleOptions {
40
34
  given?: Record<string, unknown>;
41
35
  }
42
36
 
43
- /**
44
- * The declared contract, posed to the façade that will receive it.
45
- *
46
- * Declares one `it` per case rather than looping inside a single one: a failure names the
47
- * case, and a suite that lists what it checked is the point — the list comes from the
48
- * entity, not from what the author remembered.
49
- */
37
+ /** The declared contract, posed to the façade that will receive it. */
50
38
  export function checkContract(app: App, entity: SchemaView, options: CheckOptions = {}): void {
51
39
  const { name, create, update } = opsFor(entity);
52
40
  const run = createLocalRunner(app);
@@ -57,7 +45,7 @@ export function checkContract(app: App, entity: SchemaView, options: CheckOption
57
45
  it(one.why, async () => {
58
46
  const verdict = await verdictOf(() => run(
59
47
  { entity: name, op: one.patch ? update : create },
60
- { ...EMPTY_INVOCATION, params: one.patch ? { id: '__absent__' } : {}, body: one.body },
48
+ { ...EMPTY_INVOCATION, params: one.patch ? { id: '__absent__' } : {}, input: one.input },
61
49
  ));
62
50
 
63
51
  expect(Cases.holds(one.expect, verdict), `${JSON.stringify(verdict)} — replay: ${replaySeed()}`).toBe(true);
@@ -66,13 +54,7 @@ export function checkContract(app: App, entity: SchemaView, options: CheckOption
66
54
  });
67
55
  }
68
56
 
69
- /**
70
- * What may leave, checked against what the entity says may leave.
71
- *
72
- * Costs nothing to state: `Visibility.output` already answers it, and a field the boundary
73
- * closes has no business in any response. A `password: text({ boundary: 'writeOnly' })`
74
- * that reaches a caller is the one leak no reviewer catches by reading a handler.
75
- */
57
+ /** What may leave, checked against what the entity says may leave. */
76
58
  export function checkOutput(app: App, entity: SchemaView, options: CheckOptions = {}): void {
77
59
  const { name, create } = opsFor(entity);
78
60
  const run = createLocalRunner(app);
@@ -83,7 +65,7 @@ export function checkOutput(app: App, entity: SchemaView, options: CheckOptions
83
65
  it(closed.length > 0 ? `keeps ${closed.join(', ')} in` : 'closes no field, and says so', async () => {
84
66
  const row = await run(
85
67
  { entity: name, op: create },
86
- { ...EMPTY_INVOCATION, body: sampleInput(entity, options.given ?? {}, options) },
68
+ { ...EMPTY_INVOCATION, input: sampleInput(entity, options.given ?? {}, options) },
87
69
  ) as Record<string, unknown>;
88
70
 
89
71
  expect(Object.keys(row).filter((field) => closed.includes(field))).toEqual([]);
@@ -92,7 +74,7 @@ export function checkOutput(app: App, entity: SchemaView, options: CheckOptions
92
74
  it('answers with fields the entity declares, and no others', async () => {
93
75
  const row = await run(
94
76
  { entity: name, op: create },
95
- { ...EMPTY_INVOCATION, body: sampleInput(entity, options.given ?? {}, options) },
77
+ { ...EMPTY_INVOCATION, input: sampleInput(entity, options.given ?? {}, options) },
96
78
  ) as Record<string, unknown>;
97
79
 
98
80
  // A computed field from a presenter is declared by the presenter, not the entity,
package/src/gql.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Anatomy, Role, Visibility, type Field, type SchemaView } from '@fougere/schema';
1
+ import { Shapes, Role, Visibility, type Field, type SchemaView } from '@fougere/schema';
2
2
 
3
3
  /** One field of a root type, in the shape this file reads it. */
4
4
  interface RootField {
@@ -13,12 +13,7 @@ interface Introspectable {
13
13
  getMutationType?(): { getFields(): Record<string, RootField> } | null | undefined;
14
14
  }
15
15
 
16
- /**
17
- * The scalar fields of an entity, as a GraphQL selection.
18
- *
19
- * Relations are left out: they resolve to an object, so naming one without a sub-selection
20
- * is a syntax error, and following it would compare a neighbour's rows rather than these.
21
- */
16
+ /** The scalar fields of an entity, as a GraphQL selection. */
22
17
  export function selectionOf(entity: SchemaView): string {
23
18
  return Object.entries(Visibility.of(entity.getFields()).output)
24
19
  .filter(([, field]) => !Role.of(field as Field).relation)
@@ -26,14 +21,7 @@ export function selectionOf(entity: SchemaView): string {
26
21
  .join(' ');
27
22
  }
28
23
 
29
- /**
30
- * The Query field that answers an operation, asked of the schema rather than recomputed.
31
- *
32
- * `pluralize` is written privately in `adapter/rest/src/routes.ts` AND in
33
- * `adapter/graphql/src/pothos.ts`; a third copy here would be the one that drifts. The
34
- * schema already states the answer, so it is read: a list is the field whose type is the
35
- * entity's list type, and a find is the field of the entity's own type that takes an id.
36
- */
24
+ /** The Query field that answers an operation, asked of the schema rather than recomputed. */
37
25
  export function queryFieldFor(
38
26
  schema: Introspectable,
39
27
  entity: SchemaView,
@@ -74,14 +62,7 @@ export function at(data: unknown, path: string[]): unknown {
74
62
  return path.reduce<unknown>((value, key) => (value as Record<string, unknown>)?.[key], data);
75
63
  }
76
64
 
77
- /**
78
- * The Mutation field that answers an operation.
79
- *
80
- * By NAME here, unlike the Query side: `createProduct` and `quote` both take a single
81
- * `input` argument and both return `Product!`, so their shapes do not separate them. Two
82
- * candidates are tried against the real fields — `<op><Entity>` for a CRUD write, and the
83
- * bare op for a custom one — which needs no pluralization and invents nothing.
84
- */
65
+ /** The Mutation field that answers an operation. */
85
66
  export function mutationFieldFor(schema: Introspectable, entity: SchemaView, op: string): string | undefined {
86
67
  const fields = schema.getMutationType?.()?.getFields() ?? {};
87
68
  const candidates = [`${op}${entity.name}`, op];
@@ -93,14 +74,14 @@ export function mutationFor(
93
74
  schema: Introspectable,
94
75
  entity: SchemaView,
95
76
  op: string,
96
- input: { id?: string; body?: Record<string, unknown> },
77
+ sent: { id?: string; input?: Record<string, unknown> },
97
78
  ): { query: string; at: string[] } | undefined {
98
79
  const field = mutationFieldFor(schema, entity, op);
99
80
  if (!field) return undefined;
100
81
 
101
82
  const args: string[] = [];
102
- if (input.id !== undefined) args.push(`id: ${JSON.stringify(input.id)}`);
103
- if (input.body !== undefined) args.push(`input: ${literalOf(input.body, enumsOf(entity))}`);
83
+ if (sent.id !== undefined) args.push(`id: ${JSON.stringify(sent.id)}`);
84
+ if (sent.input !== undefined) args.push(`input: ${literalOf(sent.input, enumsOf(entity))}`);
104
85
  const call = args.length ? `${field}(${args.join(', ')})` : field;
105
86
 
106
87
  // `delete` answers a Boolean, which takes no sub-selection — asking for one is a syntax
@@ -112,18 +93,11 @@ export function mutationFor(
112
93
  /** The fields the entity declares as a bounded set — GraphQL turns each into an enum. */
113
94
  function enumsOf(entity: SchemaView): Set<string> {
114
95
  return new Set(Object.entries(entity.getFields())
115
- .filter(([, field]) => { const base = Anatomy.of(field.shape).base; return base?.type === 'string' && Array.isArray(base.enum); })
96
+ .filter(([, field]) => { const base = Shapes.of(field.shape).base; return base?.type === 'string' && Array.isArray(base.enum); })
116
97
  .map(([name]) => name));
117
98
  }
118
99
 
119
- /**
120
- * A JS value as a GraphQL literal.
121
- *
122
- * `JSON.stringify` is not it, twice over: an input object's keys are NAMES, so
123
- * `{"sku": "x"}` is a syntax error where `{sku: "x"}` is the value — and an ENUM value is
124
- * a name too, so `status: "draft"` is refused where `status: draft` is taken. Which
125
- * fields are enums is read from the entity (`shape.enum`), not guessed from the string.
126
- */
100
+ /** A JS value as a GraphQL literal. */
127
101
  function literalOf(value: unknown, enums: Set<string> = new Set(), key?: string): string {
128
102
  if (value === null) return 'null';
129
103
  if (key !== undefined && enums.has(key) && typeof value === 'string') return value;
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { sampleInput, replaySeed, type SampleOptions } from './sample.js';
2
2
  export { derivedCases } from './derive.js';
3
3
  // The derivation itself lives with the axes it reads.
4
- export { Cases, type Case } from '@fougere/schema';
4
+ export { Cases, type ValidationCase } from '@fougere/schema';
5
5
  export { testApp, type TestAppOptions, type TestApp } from './app.js';
6
6
  export { checkContract, checkOutput, verdictOf, type Verdict, type CheckOptions } from './doors.js';
7
7
  export { stubOf, methodsOf, installStubs, type Port, type Stub } from './stub.js';
package/src/load.ts CHANGED
@@ -12,17 +12,10 @@ export interface LoadOptions {
12
12
 
13
13
  interface Reachable {
14
14
  method: string;
15
- body: unknown;
15
+ input: unknown;
16
16
  }
17
17
 
18
- /**
19
- * Every operation the app answers, with a body for those that take one.
20
- *
21
- * Enumerated rather than chosen, and that is the whole point: a scenario written by hand
22
- * holds the operations its author thought of — `demos/observability/load.js` exercises
23
- * three — while this one holds all of them, and an operation given `weight: 0` becomes a
24
- * decision visible in the file instead of an omission nobody can see.
25
- */
18
+ /** Every operation the app answers, with a body for those that take one. */
26
19
  export function reachableOps(app: App, given: LoadOptions['given'] = {}): Reachable[] {
27
20
  const found: Reachable[] = [];
28
21
  for (const frond of app.fronds) {
@@ -34,7 +27,7 @@ export function reachableOps(app: App, given: LoadOptions['given'] = {}): Reacha
34
27
  const schema = contract.input as SchemaView | undefined;
35
28
  found.push({
36
29
  method: `${handler.address}.${op}`,
37
- body: schema ? sampleInput(schema, given[handler.address] ?? {}) : undefined,
30
+ input: schema ? sampleInput(schema, given[handler.address] ?? {}) : undefined,
38
31
  });
39
32
  }
40
33
  }
@@ -42,22 +35,12 @@ export function reachableOps(app: App, given: LoadOptions['given'] = {}): Reacha
42
35
  return found;
43
36
  }
44
37
 
45
- /**
46
- * A k6 scenario, written from what the app answers.
47
- *
48
- * k6 runs on its own runtime and cannot import from here, so the file is GENERATED rather
49
- * than made to import `frameCall` — but the envelope in it comes from `frameCall` itself,
50
- * called once at generation time. `demos/observability/load.js` spells that envelope by
51
- * hand, so it will go on claiming to be JSON-RPC the day the format moves.
52
- *
53
- * What stays the author's: the weights, the stages and the thresholds. The scan knows
54
- * which operations exist; it knows nothing about the traffic they receive.
55
- */
38
+ /** A k6 scenario, written from what the app answers. */
56
39
  export function loadScript(app: App, options: LoadOptions = {}): string {
57
40
  const door = options.door ?? 'http://127.0.0.1:3000/_fougere/call';
58
41
  const ops = reachableOps(app, options.given);
59
42
  // The shape, from the one function that states it. `body` is replaced per iteration.
60
- const envelope = frameCall({ entity: 'ENTITY', op: 'OP' }, { params: {}, query: {}, body: undefined, state: {} } as never, 0);
43
+ const envelope = frameCall({ entity: 'ENTITY', op: 'OP' }, { params: {}, query: {}, input: undefined, state: {} } as never, 0);
61
44
  // What the envelope carries that an iteration does not fill in itself. Keeping
62
45
  // `params` here too put it in the object AND in the spread that overwrites it.
63
46
  const perCall = new Set(['method', 'id', 'params']);
@@ -98,10 +81,10 @@ export default function () {
98
81
  const payload = ${JSON.stringify(Object.fromEntries(keys.map((key) => [key, envelope[key as keyof typeof envelope]])))};
99
82
  const response = http.post(
100
83
  DOOR,
101
- JSON.stringify({ ...payload, id: ++id, method: op.method, params: { params: {}, query: {}, body: op.body, state: {} } }),
84
+ JSON.stringify({ ...payload, id: ++id, method: op.method, params: { params: {}, query: {}, input: op.input, state: {} } }),
102
85
  { headers: { 'content-type': 'application/json' }, tags: { op: op.method } },
103
86
  );
104
- check(response, { 'answered': (r) => r.status === 200 });
87
+ validate(response, { 'answered': (r) => r.status === 200 });
105
88
  }
106
89
  `;
107
90
  }
package/src/remotes.ts CHANGED
@@ -1,7 +1,2 @@
1
- /**
2
- * Contract drift moved to `@fougere/core`, beside the card it compares.
3
- *
4
- * Republished here because a test states its subject by where it sits, and `agrees(driftOf(…))`
5
- * is a testing gesture — the function is core's, the assertion is this package's.
6
- */
1
+ /** Contract drift moved to `@fougere/core`, beside the card it compares. */
7
2
  export { driftOf, agrees, explain, type CardDrift } from '@fougere/core';
package/src/sample.ts CHANGED
@@ -1,21 +1,9 @@
1
1
  import { Role, Visibility, type Field, type Fields, type SchemaView } from '@fougere/schema';
2
2
  import { generateSync, type JsonSchema } from 'json-schema-faker';
3
3
 
4
- /**
5
- * A body a client could legitimately send, built from what the entity declares.
6
- *
7
- * The shape IS JSON Schema, so the value itself is not ours to invent — `json-schema-faker`
8
- * honours `minLength`, `enum`, `format`, `pattern`, `items` and `required`. What is ours is
9
- * WHICH fields belong in a body, and that is `Visibility.input`: the one reader of the boundary
10
- * and lifecycle axes the façade and the form already stand on. Re-deriving "not primary,
11
- * not stamped, not read-only" here would make this a second opinion on the axes.
12
- */
4
+ /** A body a client could legitimately send, built from what the entity declares. */
13
5
  export interface SampleOptions {
14
- /**
15
- * Fixes what is generated. Defaults to a value derived from the entity name, so two
16
- * runs agree and a failure is replayable — a body drawn afresh every time produces the
17
- * test that fails once in twenty and cannot be reproduced.
18
- */
6
+ /** Fixes what is generated. */
19
7
  seed?: number;
20
8
  }
21
9
 
@@ -27,10 +15,8 @@ function seedOf(name: string): number {
27
15
  }
28
16
 
29
17
  /**
30
- * A relation has no value of its own to invent — `ref(Author)` names a row that must
31
- * exist, and a made-up id points at nothing. So it is REFUSED by name rather than
32
- * omitted: a body silently missing a required reference is a body the judge rejects for
33
- * a reason that has nothing to do with the test.
18
+ * A relation has no value of its own to invent — `ref(Author)` names a row that must exist, and a
19
+ * made-up id points at nothing.
34
20
  */
35
21
  function referencesIn(fields: Fields): string[] {
36
22
  return Object.entries(fields)
@@ -38,13 +24,7 @@ function referencesIn(fields: Fields): string[] {
38
24
  .map(([name]) => name);
39
25
  }
40
26
 
41
- /**
42
- * The seed the last sample used, so a failure can say how to replay it.
43
- *
44
- * A stable seed nobody can read is a stable seed for nothing: the value has to reach the
45
- * person looking at the red line. Held here rather than returned, so the signature stays
46
- * the body a caller wanted.
47
- */
27
+ /** The seed the last sample used, so a failure can say how to replay it. */
48
28
  let lastSeed: { entity: string; seed: number } | undefined;
49
29
 
50
30
  /** How to reproduce the last generated body, in the words that reproduce it. */
@@ -71,16 +51,16 @@ export function sampleInput(
71
51
 
72
52
  const seed = options.seed ?? seedOf(entity.name ?? 'anonymous');
73
53
  lastSeed = { entity: entity.name ?? 'Entity', seed };
74
- const body: Record<string, unknown> = {};
54
+ const input: Record<string, unknown> = {};
75
55
  let nth = 0;
76
56
  for (const [name, field] of Object.entries(fields)) {
77
- if (name in given) { body[name] = given[name]; continue; }
57
+ if (name in given) { input[name] = given[name]; continue; }
78
58
  // One seed per field, derived from one seed per entity: two fields of the same shape
79
59
  // would otherwise carry the same value, and a test asserting on `title` would pass
80
- // while reading `body`.
60
+ // while reading `title`.
81
61
  // A `Shape` IS a JSON Schema; the two packages declare the same concept and only
82
62
  // disagree on `readonly`, which no value crosses.
83
- body[name] = generateSync((field as Field).shape as JsonSchema, { seed: seed + nth++ });
63
+ input[name] = generateSync((field as Field).shape as JsonSchema, { seed: seed + nth++ });
84
64
  }
85
- return body;
65
+ return input;
86
66
  }
package/src/scope.ts CHANGED
@@ -2,18 +2,7 @@ import { existsSync } from 'node:fs';
2
2
  import { dirname, join, sep } from 'node:path';
3
3
  import { DEFAULT_CONVENTIONS, loadConfig, resolveConventions } from '@fougere/core/node';
4
4
 
5
- /**
6
- * What a test file's position states about its subject.
7
- *
8
- * The same reading the scan already performs on `entities/` and `handlers/`: a directory
9
- * is a declaration. A file under `fronds/blog/tests/` says its subject is `blog`, so that
10
- * frond is real and its neighbours are not — which is a TOPOLOGY, the very thing
11
- * `remotes:` states in production, and not a mode of testing.
12
- *
13
- * The sub-directory below `tests/` carries nothing. A name like `it('refuses a payment')`
14
- * is prose, and prose deciding how an app is wired is the hidden runtime the doctrine
15
- * refuses; a path is a position, which a reader sees by looking at where the file sits.
16
- */
5
+ /** What a test file's position states about its subject. */
17
6
  export interface Scope {
18
7
  /** The project the app boots from — where `fronds/` and `fougere.config.ts` live. */
19
8
  root: string;
@@ -21,12 +10,7 @@ export interface Scope {
21
10
  frond?: string;
22
11
  }
23
12
 
24
- /**
25
- * The frond a path sits in, or nothing.
26
- *
27
- * The LAST `fronds/` segment wins: a frond may hold a synced copy of a neighbour under
28
- * `.fougere/remotes/`, and a test that ever lands beside one is about the inner frond.
29
- */
13
+ /** The frond a path sits in, or nothing. */
30
14
  export function frondOf(path: string, frondsDir: string = DEFAULT_CONVENTIONS.fronds): string | undefined {
31
15
  const parts = path.split(sep);
32
16
  const at = parts.lastIndexOf(frondsDir);
@@ -35,13 +19,7 @@ export function frondOf(path: string, frondsDir: string = DEFAULT_CONVENTIONS.fr
35
19
  return name && !name.endsWith('.ts') ? name : undefined;
36
20
  }
37
21
 
38
- /**
39
- * Where the project starts: the first ancestor holding a config or a `fronds/`.
40
- *
41
- * The config is probed FIRST, which is what lets the rest of this file ask it where fronds
42
- * live: a project that renamed the directory still declares one, and only a project with
43
- * no config at all is found by the convention.
44
- */
22
+ /** Where the project starts. */
45
23
  export function rootOf(path: string): string | undefined {
46
24
  let at = dirname(path);
47
25
  let previous = '';
@@ -53,12 +31,7 @@ export function rootOf(path: string): string | undefined {
53
31
  return undefined;
54
32
  }
55
33
 
56
- /**
57
- * The scope a test file declares by where it sits.
58
- *
59
- * Returns nothing when the file sits outside any project — a caller then states `root`
60
- * itself, which is what this package's own tests do against their fixtures.
61
- */
34
+ /** The scope a test file declares by where it sits. */
62
35
  export async function scopeOf(path: string): Promise<Scope | undefined> {
63
36
  const root = rootOf(path);
64
37
  if (!root) return undefined;
@@ -69,13 +42,7 @@ export async function scopeOf(path: string): Promise<Scope | undefined> {
69
42
  return { root, ...(frond ? { frond } : {}) };
70
43
  }
71
44
 
72
- /**
73
- * The path of the running test file, from vitest.
74
- *
75
- * Read rather than guessed: `expect.getState()` is vitest's own API. Handed in by the
76
- * caller because this module is ESM and cannot `require`, and because a package that
77
- * imports vitest at the top level stops being loadable outside a test run.
78
- */
45
+ /** The path of the running test file, from vitest. */
79
46
  export async function scopeOfRun(testPath: string | undefined): Promise<Scope | undefined> {
80
47
  return testPath ? scopeOf(testPath) : undefined;
81
48
  }