@fougere/testing 0.3.0-alpha.0 → 0.4.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/dist/app.js +3 -3
- package/dist/app.js.map +1 -1
- package/dist/comparison.d.ts.map +1 -1
- package/dist/comparison.js +3 -3
- package/dist/comparison.js.map +1 -1
- package/dist/derive.d.ts +2 -2
- package/dist/derive.d.ts.map +1 -1
- package/dist/derive.js +3 -3
- package/dist/derive.js.map +1 -1
- package/dist/doors.d.ts +1 -1
- package/dist/doors.d.ts.map +1 -1
- package/dist/doors.js +6 -6
- package/dist/doors.js.map +1 -1
- package/dist/gql.d.ts.map +1 -1
- package/dist/gql.js +2 -2
- package/dist/gql.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/remotes.d.ts +1 -1
- package/dist/remotes.js +5 -5
- package/dist/remotes.js.map +1 -1
- package/dist/sample.d.ts +1 -1
- package/dist/sample.d.ts.map +1 -1
- package/dist/sample.js +2 -2
- package/dist/sample.js.map +1 -1
- package/dist/sync.d.ts +3 -3
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +3 -3
- package/dist/sync.js.map +1 -1
- package/dist/vitest.d.ts.map +1 -1
- package/dist/vitest.js +1 -3
- package/dist/vitest.js.map +1 -1
- package/package.json +11 -10
- package/src/all.ts +57 -0
- package/src/app.ts +125 -0
- package/src/comparison.ts +237 -0
- package/src/derive.ts +18 -0
- package/src/doors.ts +103 -0
- package/src/gql.ts +136 -0
- package/src/index.ts +20 -0
- package/src/load.ts +107 -0
- package/src/remotes.ts +125 -0
- package/src/sample.ts +86 -0
- package/src/scope.ts +81 -0
- package/src/stub.ts +80 -0
- package/src/sync.ts +98 -0
- package/src/vitest.ts +48 -0
package/src/gql.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { Anatomy, Role, Visibility, type Field, type SchemaView } from '@fougere/schema';
|
|
2
|
+
|
|
3
|
+
/** One field of a root type, in the shape this file reads it. */
|
|
4
|
+
interface RootField {
|
|
5
|
+
name: string;
|
|
6
|
+
type: { toString(): string };
|
|
7
|
+
args: { name: string }[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The minimum of a GraphQL schema this file reads — no `graphql` import, no second copy. */
|
|
11
|
+
interface Introspectable {
|
|
12
|
+
getQueryType(): { getFields(): Record<string, RootField> } | null | undefined;
|
|
13
|
+
getMutationType?(): { getFields(): Record<string, RootField> } | null | undefined;
|
|
14
|
+
}
|
|
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
|
+
*/
|
|
22
|
+
export function selectionOf(entity: SchemaView): string {
|
|
23
|
+
return Object.entries(Visibility.of(entity.getFields()).output)
|
|
24
|
+
.filter(([, field]) => !Role.of(field as Field).relation)
|
|
25
|
+
.map(([name]) => name)
|
|
26
|
+
.join(' ');
|
|
27
|
+
}
|
|
28
|
+
|
|
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
|
+
*/
|
|
37
|
+
export function queryFieldFor(
|
|
38
|
+
schema: Introspectable,
|
|
39
|
+
entity: SchemaView,
|
|
40
|
+
op: 'list' | 'findById',
|
|
41
|
+
): string | undefined {
|
|
42
|
+
const fields = schema.getQueryType()?.getFields() ?? {};
|
|
43
|
+
const wanted = op === 'list' ? `${entity.name}List` : entity.name;
|
|
44
|
+
|
|
45
|
+
for (const field of Object.values(fields)) {
|
|
46
|
+
// `String(type)` gives the name with its wrappers (`Product!`, `[Product!]!`), which
|
|
47
|
+
// is why this compares on inclusion rather than equality.
|
|
48
|
+
const named = String(field.type).replace(/[![\]]/g, '');
|
|
49
|
+
if (named !== wanted) continue;
|
|
50
|
+
const takesId = field.args.some((arg) => arg.name === 'id');
|
|
51
|
+
if (op === 'findById' ? takesId : !takesId) return field.name;
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A `list` query, and the path at which its rows sit in the answer. */
|
|
57
|
+
export function listQuery(schema: Introspectable, entity: SchemaView): { query: string; at: string[] } | undefined {
|
|
58
|
+
const field = queryFieldFor(schema, entity, 'list');
|
|
59
|
+
if (!field) return undefined;
|
|
60
|
+
// The list type wraps its rows — `ProductList { items }` — so the reader below has to
|
|
61
|
+
// be told where to look rather than assume the answer IS the rows.
|
|
62
|
+
return { query: `{ ${field} { items { ${selectionOf(entity)} } } }`, at: [field, 'items'] };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A `findById` query for one row. */
|
|
66
|
+
export function findQuery(schema: Introspectable, entity: SchemaView, id: string): { query: string; at: string[] } | undefined {
|
|
67
|
+
const field = queryFieldFor(schema, entity, 'findById');
|
|
68
|
+
if (!field) return undefined;
|
|
69
|
+
return { query: `{ ${field}(id: ${JSON.stringify(id)}) { ${selectionOf(entity)} } }`, at: [field] };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Follow a path into a GraphQL answer, tolerating an absence rather than throwing. */
|
|
73
|
+
export function at(data: unknown, path: string[]): unknown {
|
|
74
|
+
return path.reduce<unknown>((value, key) => (value as Record<string, unknown>)?.[key], data);
|
|
75
|
+
}
|
|
76
|
+
|
|
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
|
+
*/
|
|
85
|
+
export function mutationFieldFor(schema: Introspectable, entity: SchemaView, op: string): string | undefined {
|
|
86
|
+
const fields = schema.getMutationType?.()?.getFields() ?? {};
|
|
87
|
+
const candidates = [`${op}${entity.name}`, op];
|
|
88
|
+
return candidates.find((name) => name in fields);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A mutation, with the arguments its operation takes and where its answer sits. */
|
|
92
|
+
export function mutationFor(
|
|
93
|
+
schema: Introspectable,
|
|
94
|
+
entity: SchemaView,
|
|
95
|
+
op: string,
|
|
96
|
+
input: { id?: string; body?: Record<string, unknown> },
|
|
97
|
+
): { query: string; at: string[] } | undefined {
|
|
98
|
+
const field = mutationFieldFor(schema, entity, op);
|
|
99
|
+
if (!field) return undefined;
|
|
100
|
+
|
|
101
|
+
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))}`);
|
|
104
|
+
const call = args.length ? `${field}(${args.join(', ')})` : field;
|
|
105
|
+
|
|
106
|
+
// `delete` answers a Boolean, which takes no sub-selection — asking for one is a syntax
|
|
107
|
+
// error, and the schema is what says which case this is.
|
|
108
|
+
const scalar = String(schema.getMutationType?.()?.getFields()[field]?.type ?? '').replace(/[!]/g, '') === 'Boolean';
|
|
109
|
+
return { query: `mutation { ${call}${scalar ? '' : ` { ${selectionOf(entity)} }`} }`, at: [field] };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The fields the entity declares as a bounded set — GraphQL turns each into an enum. */
|
|
113
|
+
function enumsOf(entity: SchemaView): Set<string> {
|
|
114
|
+
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); })
|
|
116
|
+
.map(([name]) => name));
|
|
117
|
+
}
|
|
118
|
+
|
|
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
|
+
*/
|
|
127
|
+
function literalOf(value: unknown, enums: Set<string> = new Set(), key?: string): string {
|
|
128
|
+
if (value === null) return 'null';
|
|
129
|
+
if (key !== undefined && enums.has(key) && typeof value === 'string') return value;
|
|
130
|
+
if (Array.isArray(value)) return `[${value.map((one) => literalOf(one, enums, key)).join(', ')}]`;
|
|
131
|
+
if (typeof value === 'object') {
|
|
132
|
+
return `{${Object.entries(value as Record<string, unknown>)
|
|
133
|
+
.map(([name, one]) => `${name}: ${literalOf(one, enums, name)}`).join(', ')}}`;
|
|
134
|
+
}
|
|
135
|
+
return JSON.stringify(value);
|
|
136
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { sampleInput, replaySeed, type SampleOptions } from './sample.js';
|
|
2
|
+
export { derivedCases } from './derive.js';
|
|
3
|
+
// The derivation itself lives with the axes it reads.
|
|
4
|
+
export { Cases, type Case } from '@fougere/schema';
|
|
5
|
+
export { testApp, type TestAppOptions, type TestApp } from './app.js';
|
|
6
|
+
export { checkContract, checkOutput, verdictOf, type Verdict, type CheckOptions } from './doors.js';
|
|
7
|
+
export { stubOf, methodsOf, installStubs, type Port, type Stub } from './stub.js';
|
|
8
|
+
export { scopeOf, scopeOfRun, frondOf, rootOf, type Scope } from './scope.js';
|
|
9
|
+
export { loadScript, reachableOps, type LoadOptions } from './load.js';
|
|
10
|
+
export {
|
|
11
|
+
checkDoorContract,
|
|
12
|
+
checkDoors,
|
|
13
|
+
type DoorContractCase,
|
|
14
|
+
type DoorInput,
|
|
15
|
+
type DoorOptions,
|
|
16
|
+
} from './comparison.js';
|
|
17
|
+
export { selectionOf, queryFieldFor, mutationFieldFor, listQuery, findQuery, mutationFor, at } from './gql.js';
|
|
18
|
+
export { driftOf, agrees, explain, type CardDrift } from './remotes.js';
|
|
19
|
+
export { checkAll, servedEntities, type CheckAllOptions } from './all.js';
|
|
20
|
+
export { syncedRemotes, heldShapes, syncDriftOf, inSync, type SyncedRemote, type SyncDrift } from './sync.js';
|
package/src/load.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { frameCall } from '@fougere/transport-http';
|
|
2
|
+
import type { App } from '@fougere/core';
|
|
3
|
+
import type { SchemaView } from '@fougere/schema';
|
|
4
|
+
import { sampleInput } from './sample.js';
|
|
5
|
+
|
|
6
|
+
export interface LoadOptions {
|
|
7
|
+
/** Where the calls go. The RPC door of a running app. */
|
|
8
|
+
door?: string;
|
|
9
|
+
/** Values the generator cannot invent, by entity name — the id a `ref()` points at. */
|
|
10
|
+
given?: Record<string, Record<string, unknown>>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface Reachable {
|
|
14
|
+
method: string;
|
|
15
|
+
body: unknown;
|
|
16
|
+
}
|
|
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
|
+
*/
|
|
26
|
+
export function reachableOps(app: App, given: LoadOptions['given'] = {}): Reachable[] {
|
|
27
|
+
const found: Reachable[] = [];
|
|
28
|
+
for (const frond of app.fronds) {
|
|
29
|
+
for (const handler of frond.handlers) {
|
|
30
|
+
// A named surface is a restricted door; the load of an app is what its default
|
|
31
|
+
// door answers, so a surface would count the same operation twice.
|
|
32
|
+
if (handler.surface) continue;
|
|
33
|
+
for (const [op, contract] of handler.operations ?? []) {
|
|
34
|
+
const schema = contract.input as SchemaView | undefined;
|
|
35
|
+
found.push({
|
|
36
|
+
method: `${handler.address}.${op}`,
|
|
37
|
+
body: schema ? sampleInput(schema, given[handler.address] ?? {}) : undefined,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return found;
|
|
43
|
+
}
|
|
44
|
+
|
|
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
|
+
*/
|
|
56
|
+
export function loadScript(app: App, options: LoadOptions = {}): string {
|
|
57
|
+
const door = options.door ?? 'http://127.0.0.1:3000/_fougere/call';
|
|
58
|
+
const ops = reachableOps(app, options.given);
|
|
59
|
+
// 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);
|
|
61
|
+
// What the envelope carries that an iteration does not fill in itself. Keeping
|
|
62
|
+
// `params` here too put it in the object AND in the spread that overwrites it.
|
|
63
|
+
const perCall = new Set(['method', 'id', 'params']);
|
|
64
|
+
const keys = Object.keys(envelope).filter((key) => !perCall.has(key));
|
|
65
|
+
|
|
66
|
+
return `// Generated by \`fougere load\` — edit the weights, the stages and the thresholds.
|
|
67
|
+
// Everything else is read from what the app answers: regenerate rather than patch.
|
|
68
|
+
import http from 'k6/http';
|
|
69
|
+
import { check } from 'k6';
|
|
70
|
+
|
|
71
|
+
const DOOR = ${JSON.stringify(door)};
|
|
72
|
+
|
|
73
|
+
// Every operation the app serves. A weight of 0 takes one out, visibly.
|
|
74
|
+
const OPS = ${JSON.stringify(ops.map((op) => ({ ...op, weight: 1 })), null, 2)};
|
|
75
|
+
|
|
76
|
+
export const options = {
|
|
77
|
+
// Yours: a flat rate draws flat lines and there is nothing to read in them.
|
|
78
|
+
stages: [
|
|
79
|
+
{ duration: '30s', target: 5 },
|
|
80
|
+
{ duration: '45s', target: 5 },
|
|
81
|
+
{ duration: '30s', target: 0 },
|
|
82
|
+
],
|
|
83
|
+
// Yours: what counts as too slow is a fact about your users.
|
|
84
|
+
thresholds: { http_req_failed: ['rate<0.01'], http_req_duration: ['p(95)<500'] },
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const TOTAL = OPS.reduce((sum, op) => sum + op.weight, 0);
|
|
88
|
+
let id = 0;
|
|
89
|
+
|
|
90
|
+
function pick() {
|
|
91
|
+
let roll = Math.random() * TOTAL;
|
|
92
|
+
for (const op of OPS) if ((roll -= op.weight) < 0) return op;
|
|
93
|
+
return OPS[OPS.length - 1];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export default function () {
|
|
97
|
+
const op = pick();
|
|
98
|
+
const payload = ${JSON.stringify(Object.fromEntries(keys.map((key) => [key, envelope[key as keyof typeof envelope]])))};
|
|
99
|
+
const response = http.post(
|
|
100
|
+
DOOR,
|
|
101
|
+
JSON.stringify({ ...payload, id: ++id, method: op.method, params: { params: {}, query: {}, body: op.body, state: {} } }),
|
|
102
|
+
{ headers: { 'content-type': 'application/json' }, tags: { op: op.method } },
|
|
103
|
+
);
|
|
104
|
+
check(response, { 'answered': (r) => r.status === 200 });
|
|
105
|
+
}
|
|
106
|
+
`;
|
|
107
|
+
}
|
package/src/remotes.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { Card, type Change, type SchemaDescriptor } from '@fougere/schema';
|
|
2
|
+
import type { IdentityCard } from '@fougere/core';
|
|
3
|
+
|
|
4
|
+
/** What separates the copy a consumer holds from what the producer actually serves. */
|
|
5
|
+
export interface CardDrift {
|
|
6
|
+
frond: string;
|
|
7
|
+
/** A door the consumer calls that the producer no longer serves. */
|
|
8
|
+
missingDoors: string[];
|
|
9
|
+
/** An operation the consumer calls that the door no longer has. */
|
|
10
|
+
missingOps: { door: string; ops: string[] }[];
|
|
11
|
+
/** A shape that moved under a door the consumer still calls. */
|
|
12
|
+
shapes: { door: string; changes: Change[] }[];
|
|
13
|
+
/** A fact the consumer subscribes to whose shape moved, or that is gone. */
|
|
14
|
+
facts: { fact: string; changes: Change[] | 'gone' }[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Every door of a card, by name. */
|
|
18
|
+
function doorsOf(card: IdentityCard, frond: string): Map<string, { ops: Set<string>; schema?: SchemaDescriptor }> {
|
|
19
|
+
const found = new Map<string, { ops: Set<string>; schema?: SchemaDescriptor }>();
|
|
20
|
+
for (const one of card.fronds) {
|
|
21
|
+
if (one.name !== frond) continue;
|
|
22
|
+
for (const door of one.doors) {
|
|
23
|
+
found.set(door.name, { ops: new Set(door.ops.map((op) => op.name)), schema: door.schema });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return found;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function factsOf(card: IdentityCard, frond: string): Map<string, SchemaDescriptor | undefined> {
|
|
30
|
+
const found = new Map<string, SchemaDescriptor | undefined>();
|
|
31
|
+
for (const one of card.fronds) {
|
|
32
|
+
if (one.name !== frond) continue;
|
|
33
|
+
for (const fact of one.facts ?? []) found.set(fact.name, fact.schema as SchemaDescriptor | undefined);
|
|
34
|
+
}
|
|
35
|
+
return found;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* What a consumer's synced copy no longer matches in what the producer serves.
|
|
40
|
+
*
|
|
41
|
+
* The gap TypeScript cannot see, and the only place the gradient genuinely lies: the code
|
|
42
|
+
* is identical in-process and split, but one side may have aged. `fougere sync` wrote the
|
|
43
|
+
* consumer's copy three weeks ago, the producer moved on, and it still compiles —
|
|
44
|
+
* production is where that is found today. This is what Pact sells; the material was
|
|
45
|
+
* already here, in `rpc.discover` and in `Card.diff`.
|
|
46
|
+
*
|
|
47
|
+
* Read in ONE direction on purpose: what the consumer holds, checked against what is
|
|
48
|
+
* served. A producer serving MORE than the consumer knows is not drift — it is a producer
|
|
49
|
+
* that moved forward without breaking anyone, which is the whole point of the order the
|
|
50
|
+
* repo already states (re-sync the readers, then deploy the sender).
|
|
51
|
+
*/
|
|
52
|
+
export function driftOf(mine: IdentityCard, theirs: IdentityCard, frond: string): CardDrift {
|
|
53
|
+
const held = doorsOf(mine, frond);
|
|
54
|
+
const served = doorsOf(theirs, frond);
|
|
55
|
+
const drift: CardDrift = { frond, missingDoors: [], missingOps: [], shapes: [], facts: [] };
|
|
56
|
+
|
|
57
|
+
for (const [name, door] of held) {
|
|
58
|
+
const there = served.get(name);
|
|
59
|
+
if (!there) { drift.missingDoors.push(name); continue; }
|
|
60
|
+
|
|
61
|
+
const missing = [...door.ops].filter((op) => !there.ops.has(op));
|
|
62
|
+
if (missing.length > 0) drift.missingOps.push({ door: name, ops: missing.sort() });
|
|
63
|
+
|
|
64
|
+
if (door.schema && there.schema) {
|
|
65
|
+
// `Card.diff` never guesses a rename — a field gone plus a field appeared lands in
|
|
66
|
+
// `ambiguous`, and only a declaration settles it. Here nobody can declare one, so
|
|
67
|
+
// the pair is reported as it is and a human reads it.
|
|
68
|
+
const moved = Card.fromDescriptor(door.schema).diff(Card.fromDescriptor(there.schema));
|
|
69
|
+
if (moved.changes.length > 0) drift.shapes.push({ door: name, changes: moved.changes });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const heldFacts = factsOf(mine, frond);
|
|
74
|
+
const servedFacts = factsOf(theirs, frond);
|
|
75
|
+
for (const [name, shape] of heldFacts) {
|
|
76
|
+
if (!servedFacts.has(name)) { drift.facts.push({ fact: name, changes: 'gone' }); continue; }
|
|
77
|
+
const there = servedFacts.get(name);
|
|
78
|
+
if (!shape || !there) continue;
|
|
79
|
+
const moved = Card.fromDescriptor(shape).diff(Card.fromDescriptor(there));
|
|
80
|
+
if (moved.changes.length > 0) drift.facts.push({ fact: name, changes: moved.changes });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return drift;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Whether anything at all separates the two cards. */
|
|
87
|
+
export function agrees(drift: CardDrift): boolean {
|
|
88
|
+
return drift.missingDoors.length === 0
|
|
89
|
+
&& drift.missingOps.length === 0
|
|
90
|
+
&& drift.shapes.length === 0
|
|
91
|
+
&& drift.facts.length === 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The drift, in the words a deploy needs.
|
|
96
|
+
*
|
|
97
|
+
* A fact says the order out loud, because the repo already states it as a rule and
|
|
98
|
+
* nothing enforced it: a fact is judged strictly, so a reader that has not been re-synced
|
|
99
|
+
* refuses what the sender now announces.
|
|
100
|
+
*/
|
|
101
|
+
export function explain(drift: CardDrift): string[] {
|
|
102
|
+
const lines: string[] = [];
|
|
103
|
+
for (const door of drift.missingDoors) lines.push(`${drift.frond}.${door} — you call it, it is not served`);
|
|
104
|
+
for (const { door, ops } of drift.missingOps) lines.push(`${drift.frond}.${door} — gone: ${ops.join(', ')}`);
|
|
105
|
+
for (const { door, changes } of drift.shapes) {
|
|
106
|
+
for (const change of changes) lines.push(`${drift.frond}.${door} — ${describe(change)}`);
|
|
107
|
+
}
|
|
108
|
+
for (const { fact, changes } of drift.facts) {
|
|
109
|
+
if (changes === 'gone') { lines.push(`${fact} — you subscribe to it, it is no longer announced`); continue; }
|
|
110
|
+
for (const change of changes) lines.push(`${fact} — ${describe(change)} → re-sync and deploy the readers, THEN the sender`);
|
|
111
|
+
}
|
|
112
|
+
return lines;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function describe(change: Change): string {
|
|
116
|
+
switch (change.kind) {
|
|
117
|
+
case 'added': return `+ ${change.field}${change.required ? ' (required)' : ''}`;
|
|
118
|
+
case 'removed': return `- ${change.field}`;
|
|
119
|
+
case 'renamed': return `${change.from} → ${change.to}`;
|
|
120
|
+
case 'retyped': return `${change.field}: ${[...change.from].join('|')} → ${[...change.to].join('|')}`;
|
|
121
|
+
case 'reshaped': return `${change.field}: its bounds moved`;
|
|
122
|
+
case 'required': return `${change.field}: ${change.from ? 'no longer' : 'now'} required`;
|
|
123
|
+
case 'restated': return `${change.field}: its ${change.axis} moved`;
|
|
124
|
+
}
|
|
125
|
+
}
|
package/src/sample.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Role, Visibility, type Field, type Fields, type SchemaView } from '@fougere/schema';
|
|
2
|
+
import { generateSync, type JsonSchema } from 'json-schema-faker';
|
|
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
|
+
*/
|
|
13
|
+
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
|
+
*/
|
|
19
|
+
seed?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Stable across runs and across machines: the entity name is the only input. */
|
|
23
|
+
function seedOf(name: string): number {
|
|
24
|
+
let hash = 0;
|
|
25
|
+
for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) | 0;
|
|
26
|
+
return Math.abs(hash) || 1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
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.
|
|
34
|
+
*/
|
|
35
|
+
function referencesIn(fields: Fields): string[] {
|
|
36
|
+
return Object.entries(fields)
|
|
37
|
+
.filter(([, field]) => Role.of(field as Field).isReference)
|
|
38
|
+
.map(([name]) => name);
|
|
39
|
+
}
|
|
40
|
+
|
|
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
|
+
*/
|
|
48
|
+
let lastSeed: { entity: string; seed: number } | undefined;
|
|
49
|
+
|
|
50
|
+
/** How to reproduce the last generated body, in the words that reproduce it. */
|
|
51
|
+
export function replaySeed(): string {
|
|
52
|
+
return lastSeed
|
|
53
|
+
? `sampleInput(${lastSeed.entity}, {}, { seed: ${lastSeed.seed} })`
|
|
54
|
+
: 'nothing has been sampled yet';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function sampleInput(
|
|
58
|
+
entity: SchemaView,
|
|
59
|
+
given: Record<string, unknown> = {},
|
|
60
|
+
options: SampleOptions = {},
|
|
61
|
+
): Record<string, unknown> {
|
|
62
|
+
const fields = Visibility.of(entity.getFields()).input;
|
|
63
|
+
const missing = referencesIn(fields).filter((name) => !(name in given));
|
|
64
|
+
if (missing.length > 0) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`[sampleInput] ${missing.join(', ')} ${missing.length > 1 ? 'are references' : 'is a reference'} — `
|
|
67
|
+
+ 'a generated id points at no row. Pass the ids: '
|
|
68
|
+
+ `sampleInput(Entity, { ${missing.map((n) => `${n}: '…'`).join(', ')} }).`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const seed = options.seed ?? seedOf(entity.name ?? 'anonymous');
|
|
73
|
+
lastSeed = { entity: entity.name ?? 'Entity', seed };
|
|
74
|
+
const body: Record<string, unknown> = {};
|
|
75
|
+
let nth = 0;
|
|
76
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
77
|
+
if (name in given) { body[name] = given[name]; continue; }
|
|
78
|
+
// One seed per field, derived from one seed per entity: two fields of the same shape
|
|
79
|
+
// would otherwise carry the same value, and a test asserting on `title` would pass
|
|
80
|
+
// while reading `body`.
|
|
81
|
+
// A `Shape` IS a JSON Schema; the two packages declare the same concept and only
|
|
82
|
+
// disagree on `readonly`, which no value crosses.
|
|
83
|
+
body[name] = generateSync((field as Field).shape as JsonSchema, { seed: seed + nth++ });
|
|
84
|
+
}
|
|
85
|
+
return body;
|
|
86
|
+
}
|
package/src/scope.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, sep } from 'node:path';
|
|
3
|
+
import { DEFAULT_CONVENTIONS, loadConfig, resolveConventions } from '@fougere/core/node';
|
|
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
|
+
*/
|
|
17
|
+
export interface Scope {
|
|
18
|
+
/** The project the app boots from — where `fronds/` and `fougere.config.ts` live. */
|
|
19
|
+
root: string;
|
|
20
|
+
/** The frond under test. Absent means every frond is real: several of them, together. */
|
|
21
|
+
frond?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
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
|
+
*/
|
|
30
|
+
export function frondOf(path: string, frondsDir: string = DEFAULT_CONVENTIONS.fronds): string | undefined {
|
|
31
|
+
const parts = path.split(sep);
|
|
32
|
+
const at = parts.lastIndexOf(frondsDir);
|
|
33
|
+
if (at === -1 || at + 1 >= parts.length) return undefined;
|
|
34
|
+
const name = parts[at + 1];
|
|
35
|
+
return name && !name.endsWith('.ts') ? name : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
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
|
+
*/
|
|
45
|
+
export function rootOf(path: string): string | undefined {
|
|
46
|
+
let at = dirname(path);
|
|
47
|
+
let previous = '';
|
|
48
|
+
while (at !== previous) {
|
|
49
|
+
if (existsSync(join(at, 'fougere.config.ts')) || existsSync(join(at, DEFAULT_CONVENTIONS.fronds))) return at;
|
|
50
|
+
previous = at;
|
|
51
|
+
at = dirname(at);
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
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
|
+
*/
|
|
62
|
+
export async function scopeOf(path: string): Promise<Scope | undefined> {
|
|
63
|
+
const root = rootOf(path);
|
|
64
|
+
if (!root) return undefined;
|
|
65
|
+
// The root is known, so its config can say what the fronds directory is called before
|
|
66
|
+
// the position is read against it.
|
|
67
|
+
const { fronds } = resolveConventions((await loadConfig(root)).conventions);
|
|
68
|
+
const frond = frondOf(path, fronds);
|
|
69
|
+
return { root, ...(frond ? { frond } : {}) };
|
|
70
|
+
}
|
|
71
|
+
|
|
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
|
+
*/
|
|
79
|
+
export async function scopeOfRun(testPath: string | undefined): Promise<Scope | undefined> {
|
|
80
|
+
return testPath ? scopeOf(testPath) : undefined;
|
|
81
|
+
}
|
package/src/stub.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { vi, type Mock } from 'vitest';
|
|
2
|
+
import type { Container } from '@fougere/container';
|
|
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;
|
|
7
|
+
|
|
8
|
+
/** The double handed in place of a port — one spy per method the port declares. */
|
|
9
|
+
export type Stub<T> = { [K in keyof T]: T[K] extends (...args: never[]) => unknown ? Mock : T[K] };
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The methods a port declares, read from the prototype chain at runtime.
|
|
13
|
+
*
|
|
14
|
+
* Not written by hand and not read from the AST: `ProviderEntry` keeps `ctor`, `deps` and
|
|
15
|
+
* `filePath` and never the methods, while the prototype has them all along. So a double
|
|
16
|
+
* carries exactly what the port carries and gains a method the day the port does — which
|
|
17
|
+
* is the failure `ports.test.ts` records, a `charge is not a function` from a stand-in
|
|
18
|
+
* that did not carry what its type promised.
|
|
19
|
+
*
|
|
20
|
+
* Walks the chain because a port may itself extend one; stops at `Object.prototype`,
|
|
21
|
+
* whose members belong to no port.
|
|
22
|
+
*/
|
|
23
|
+
export function methodsOf(port: Port): string[] {
|
|
24
|
+
const found = new Set<string>();
|
|
25
|
+
let proto: object | null = port.prototype as object;
|
|
26
|
+
while (proto && proto !== Object.prototype) {
|
|
27
|
+
for (const name of Object.getOwnPropertyNames(proto)) {
|
|
28
|
+
if (name === 'constructor') continue;
|
|
29
|
+
const declared = Object.getOwnPropertyDescriptor(proto, name);
|
|
30
|
+
if (typeof declared?.value === 'function') found.add(name);
|
|
31
|
+
}
|
|
32
|
+
proto = Object.getPrototypeOf(proto) as object | null;
|
|
33
|
+
}
|
|
34
|
+
return [...found];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A double for a port: every method present, every call recorded, nothing returned.
|
|
39
|
+
*
|
|
40
|
+
* What it RETURNS is not derivable and the value is the caller's to state — a service's
|
|
41
|
+
* return type is a bare TypeScript type, erased at runtime, with no declared fields for
|
|
42
|
+
* anything to build from. The same line the whole package sits on: what Fougere's
|
|
43
|
+
* vocabulary declares can be derived, arbitrary code cannot.
|
|
44
|
+
*/
|
|
45
|
+
export function stubOf<T>(port: Port): Stub<T> {
|
|
46
|
+
const double: Record<string, Mock> = {};
|
|
47
|
+
for (const method of methodsOf(port)) double[method] = vi.fn();
|
|
48
|
+
return double as Stub<T>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Put doubles in front of ports, in every frond scope that answers under their name.
|
|
53
|
+
*
|
|
54
|
+
* After the boot rather than through `ports:`, because the container resolves lazily: a
|
|
55
|
+
* provider is built on first `resolve`, so a value registered before any call is the one
|
|
56
|
+
* a handler receives. `registerValue` also marks it as not the container's to dispose,
|
|
57
|
+
* which is right — the test made it.
|
|
58
|
+
*
|
|
59
|
+
* A port nobody answers under is REFUSED by name, for the reason `ports:` refuses a key
|
|
60
|
+
* that matched nothing: a double that silently stands in front of no one reads as a test
|
|
61
|
+
* that covered a case it never reached.
|
|
62
|
+
*/
|
|
63
|
+
export function installStubs(app: App, ports: Port[]): Map<Port, Stub<unknown>> {
|
|
64
|
+
const doubles = new Map<Port, Stub<unknown>>();
|
|
65
|
+
const scopes = app.fronds.map((frond) => app.resolve<Container>(`frond:${frond.name}`));
|
|
66
|
+
|
|
67
|
+
for (const port of ports) {
|
|
68
|
+
const answering = scopes.filter((scope) => scope.has(port.name));
|
|
69
|
+
if (answering.length === 0) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`[stub] ${port.name} — nothing answers under that name in any frond. `
|
|
72
|
+
+ 'A port is a class a provider extends; a class nobody extends is an ordinary service.',
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const double = stubOf(port);
|
|
76
|
+
for (const scope of answering) scope.registerValue(port.name, double);
|
|
77
|
+
doubles.set(port, double);
|
|
78
|
+
}
|
|
79
|
+
return doubles;
|
|
80
|
+
}
|