@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.
- package/README.md +1 -1
- package/dist/{comparison.d.ts → DoorContractCase.d.ts} +5 -13
- package/dist/DoorContractCase.d.ts.map +1 -0
- package/dist/{comparison.js → DoorContractCase.js} +31 -31
- package/dist/DoorContractCase.js.map +1 -0
- package/dist/DoorInput.d.ts +5 -0
- package/dist/DoorInput.d.ts.map +1 -0
- package/dist/DoorInput.js +2 -0
- package/dist/DoorInput.js.map +1 -0
- package/dist/DoorOptions.d.ts +7 -0
- package/dist/DoorOptions.d.ts.map +1 -0
- package/dist/DoorOptions.js +2 -0
- package/dist/DoorOptions.js.map +1 -0
- package/dist/all.d.ts +4 -4
- package/dist/all.d.ts.map +1 -1
- package/dist/all.js +3 -3
- package/dist/all.js.map +1 -1
- package/dist/app/TestApp.d.ts +15 -0
- package/dist/app/TestApp.d.ts.map +1 -0
- package/dist/{app.js → app/TestApp.js} +3 -3
- package/dist/app/TestApp.js.map +1 -0
- package/dist/{app.d.ts → app/TestAppOptions.d.ts} +2 -14
- package/dist/app/TestAppOptions.d.ts.map +1 -0
- package/dist/app/TestAppOptions.js +2 -0
- package/dist/app/TestAppOptions.js.map +1 -0
- package/dist/facades/CheckOptions.d.ts +6 -0
- package/dist/facades/CheckOptions.d.ts.map +1 -0
- package/dist/facades/CheckOptions.js +2 -0
- package/dist/facades/CheckOptions.js.map +1 -0
- package/dist/{doors.d.ts → facades/Verdict.d.ts} +4 -8
- package/dist/facades/Verdict.d.ts.map +1 -0
- package/dist/{doors.js → facades/Verdict.js} +4 -4
- package/dist/facades/Verdict.js.map +1 -0
- package/dist/index.d.ts +8 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -5
- package/dist/index.js.map +1 -1
- package/dist/load.d.ts +19 -5
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +29 -9
- package/dist/load.js.map +1 -1
- package/dist/spans.d.ts +18 -0
- package/dist/spans.d.ts.map +1 -0
- package/dist/spans.js +56 -0
- package/dist/spans.js.map +1 -0
- package/dist/statements.d.ts +17 -0
- package/dist/statements.d.ts.map +1 -0
- package/dist/statements.js +37 -0
- package/dist/statements.js.map +1 -0
- package/dist/stub/Port.d.ts +3 -0
- package/dist/stub/Port.d.ts.map +1 -0
- package/dist/stub/Port.js +2 -0
- package/dist/stub/Port.js.map +1 -0
- package/dist/{stub.d.ts → stub/Stub.d.ts} +2 -3
- package/dist/stub/Stub.d.ts.map +1 -0
- package/dist/{stub.js → stub/Stub.js} +1 -1
- package/dist/stub/Stub.js.map +1 -0
- package/dist/{sync.d.ts → sync/SyncDrift.d.ts} +2 -10
- package/dist/sync/SyncDrift.d.ts.map +1 -0
- package/dist/{sync.js → sync/SyncDrift.js} +5 -18
- package/dist/sync/SyncDrift.js.map +1 -0
- package/dist/sync/SyncedRemote.d.ts +10 -0
- package/dist/sync/SyncedRemote.d.ts.map +1 -0
- package/dist/sync/SyncedRemote.js +15 -0
- package/dist/sync/SyncedRemote.js.map +1 -0
- package/dist/vitest.d.ts.map +1 -1
- package/dist/vitest.js +4 -0
- package/dist/vitest.js.map +1 -1
- package/package.json +20 -10
- package/src/{comparison.ts → DoorContractCase.ts} +34 -40
- package/src/DoorInput.ts +1 -0
- package/src/DoorOptions.ts +7 -0
- package/src/all.ts +7 -5
- package/src/{app.ts → app/TestApp.ts} +4 -26
- package/src/app/TestAppOptions.ts +25 -0
- package/src/facades/CheckOptions.ts +6 -0
- package/src/{doors.ts → facades/Verdict.ts} +5 -9
- package/src/index.ts +8 -8
- package/src/load.ts +52 -13
- package/src/spans.ts +66 -0
- package/src/statements.ts +41 -0
- package/src/stub/Port.ts +2 -0
- package/src/{stub.ts → stub/Stub.ts} +1 -3
- package/src/{sync.ts → sync/SyncDrift.ts} +3 -23
- package/src/sync/SyncedRemote.ts +22 -0
- package/src/vitest.ts +4 -0
- package/dist/app.d.ts.map +0 -1
- package/dist/app.js.map +0 -1
- package/dist/comparison.d.ts.map +0 -1
- package/dist/comparison.js.map +0 -1
- package/dist/doors.d.ts.map +0 -1
- package/dist/doors.js.map +0 -1
- package/dist/stub.d.ts.map +0 -1
- package/dist/stub.js.map +0 -1
- package/dist/sync.d.ts.map +0 -1
- 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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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((
|
|
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
|
|
65
|
+
await facades.local('create', { input: inputOf() });
|
|
72
66
|
|
|
73
|
-
const local = wire(rowsOf(await
|
|
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
|
|
77
|
-
expect(wire(rowsOf(await
|
|
78
|
-
expect(wire(rowsOf(await
|
|
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
|
|
76
|
+
const row = await facades.local('create', { input: inputOf() }) as { id: string };
|
|
83
77
|
|
|
84
|
-
const local = wire(await
|
|
78
|
+
const local = wire(await facades.local('findById', { id: row.id }));
|
|
85
79
|
|
|
86
|
-
expect(wire(await
|
|
87
|
-
expect(wire(await
|
|
88
|
-
expect(wire(await
|
|
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(() =>
|
|
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((
|
|
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(() =>
|
|
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((
|
|
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
|
|
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
|
|
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((
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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((
|
|
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
|
|
160
|
-
function
|
|
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
|
|
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}`);
|
package/src/DoorInput.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export interface DoorInput { id?: string; input?: Record<string, unknown> }
|
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 {
|
|
5
|
-
import {
|
|
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-
|
|
11
|
-
|
|
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.
|
|
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
|
|
6
|
-
import {
|
|
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
|
+
}
|
|
@@ -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 '
|
|
7
|
-
import { sampleInput, replaySeed
|
|
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
|
|
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
|
|
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 './
|
|
6
|
-
export {
|
|
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
|
-
|
|
11
|
-
|
|
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
|
|
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
|
|
8
|
-
|
|
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(
|
|
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
|
|
24
|
-
//
|
|
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
|
|
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:
|
|
40
|
-
const
|
|
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(
|
|
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:
|
|
67
|
-
|
|
96
|
+
// Yours: the shape of the run.
|
|
97
|
+
// Derived from BUDGET and each op's hops — an op that crosses two processes is not held to
|
|
98
|
+
// the same figure as one that never leaves. k6 reads a threshold per tag, and every call
|
|
99
|
+
// below is tagged with the op it made.
|
|
100
|
+
thresholds: {
|
|
101
|
+
http_req_failed: ['rate<0.01'],
|
|
102
|
+
...Object.fromEntries(OPS.map((op) => [
|
|
103
|
+
\`http_req_duration{op:\${op.method}}\`,
|
|
104
|
+
[\`p(95)<\${BUDGET.base + op.hops * BUDGET.perHop}\`],
|
|
105
|
+
])),
|
|
106
|
+
},
|
|
68
107
|
};
|
|
69
108
|
|
|
70
109
|
const TOTAL = OPS.reduce((sum, op) => sum + op.weight, 0);
|
package/src/spans.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { App } from '@fougere/core';
|
|
2
|
+
import type { FinishedSpan } from '@fougere/observability';
|
|
3
|
+
|
|
4
|
+
/** One tracer per app: `app.use` has no undo, so a second call would stack a middleware. */
|
|
5
|
+
const installed = new WeakMap<App, FinishedSpan[][]>();
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The steps one call opened — the operation, then whatever ran under it.
|
|
9
|
+
*
|
|
10
|
+
* The dual of [statementsOf]: that one counts what a call asked the database, this one holds
|
|
11
|
+
* the shape of the call itself. What it is FOR is a test about structure rather than speed —
|
|
12
|
+
* an op that delegates reports almost no self time, whatever the machine it runs on:
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* const [op] = await spansOf(app, () => 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
|
+
}
|
package/src/stub/Port.ts
ADDED
|
@@ -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
|
|
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
|
|
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
|
}
|