@ultimat3/testing 29.4.0 → 30.0.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/CLAUDE.md +56 -63
- package/README.md +281 -6
- package/package.json +27 -16
- package/src/ax-node.ts +96 -0
- package/src/cassette-errors.ts +91 -0
- package/src/cassette-format.ts +175 -0
- package/src/cassette-record.ts +120 -0
- package/src/cassette-secret-scan.ts +77 -0
- package/src/cassette.ts +166 -0
- package/src/cdp-browser.ts +5 -3
- package/src/cdp-e2e-page.ts +91 -3
- package/src/cdp-e2e-session.ts +18 -1
- package/src/define-exercise.ts +82 -0
- package/src/drill-budgets.ts +54 -0
- package/src/drill-errors.ts +45 -0
- package/src/drill-fleet.ts +251 -0
- package/src/drill-jobs.ts +79 -0
- package/src/drill-load.ts +166 -0
- package/src/drill-probe.ts +68 -0
- package/src/drill-run.ts +144 -0
- package/src/drill-services.ts +70 -0
- package/src/drill-sync.ts +108 -0
- package/src/drill-verdict.ts +256 -0
- package/src/drill-writes.ts +123 -0
- package/src/drills.ts +109 -0
- package/src/e2e-a11y-run.ts +189 -0
- package/src/e2e-a11y-tabs.ts +113 -0
- package/src/e2e-a11y.ts +153 -0
- package/src/e2e-driver.ts +46 -10
- package/src/e2e-page.ts +28 -3
- package/src/e2e-run.ts +1 -0
- package/src/e2e-spawn.ts +3 -2
- package/src/errors.ts +43 -0
- package/src/exercise-actor.ts +124 -0
- package/src/exercise-crash.ts +46 -0
- package/src/exercise-input.ts +182 -0
- package/src/exercise-override-input.ts +34 -0
- package/src/exercise-report.ts +282 -0
- package/src/exercise-request.ts +38 -0
- package/src/exercise-sweeps.ts +100 -0
- package/src/exercise.ts +448 -0
- package/src/factories.ts +10 -7
- package/src/factory-registry.ts +49 -15
- package/src/fake-identity-provider.ts +191 -0
- package/src/fault-proxy.ts +342 -0
- package/src/file-boundary.ts +4 -1
- package/src/fixture-jobs.ts +3 -1
- package/src/fixture-statements.ts +4 -1
- package/src/free-port.ts +59 -0
- package/src/index.ts +148 -3
- package/src/island-dom.ts +5 -0
- package/src/isolated-plugins.ts +1 -1
- package/src/job-crash-proof.ts +249 -0
- package/src/job-sim-driver.ts +164 -0
- package/src/job-sim-effects.ts +142 -0
- package/src/job-sim-errors.ts +72 -0
- package/src/job-sim-schedule.ts +154 -0
- package/src/job-sim-world.ts +198 -0
- package/src/job-simulate.ts +179 -0
- package/src/mock-route.ts +81 -0
- package/src/module-mock-boundary.ts +85 -0
- package/src/module-mock-error.ts +28 -0
- package/src/plan-oracle.ts +164 -0
- package/src/plan-sweep-fix.ts +123 -0
- package/src/plan-sweep-report.ts +84 -0
- package/src/plan-sweep.ts +148 -0
- package/src/preload.ts +24 -4
- package/src/registry-snapshot.ts +1 -1
- package/src/repro-stand-in.ts +67 -0
- package/src/repro.ts +158 -0
- package/src/request-log.ts +64 -0
- package/src/reusable-database.ts +1 -1
- package/src/scale-sweep-graph.ts +82 -0
- package/src/scale-sweep-run.ts +134 -0
- package/src/scale-sweep.ts +74 -0
- package/src/sealed-network.ts +63 -46
- package/src/seed-graph-error.ts +50 -0
- package/src/seed-graph-order.ts +98 -0
- package/src/seed-graph-pass.ts +68 -0
- package/src/seed-graph-plan.ts +81 -0
- package/src/seed-graph-row.ts +134 -0
- package/src/seed-graph-shape.ts +104 -0
- package/src/seed-graph-values.ts +135 -0
- package/src/seed-graph.ts +250 -0
- package/src/seed-writer.ts +180 -0
- package/src/subject-proof-error.ts +23 -0
- package/src/subject-proof.ts +180 -0
- package/src/template-db-roles.ts +51 -0
- package/src/template-db.ts +8 -2
- package/src/tenant-canary.ts +120 -0
- package/src/tenant-proof-report.ts +62 -0
- package/src/tenant-proof.ts +177 -0
- package/src/test-types.ts +12 -3
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// One tenant's rows at a chosen size, written beside the pass's graph in the same database. Its rows
|
|
2
|
+
// of no tenant (`plans`) are NOT written again — a second copy collides on their unique keys — so
|
|
3
|
+
// every reference to one is pointed at the pass's own row in the same position. Pure.
|
|
4
|
+
|
|
5
|
+
import { seedGraph } from './seed-graph';
|
|
6
|
+
import type { SeedGraph } from './seed-graph-plan';
|
|
7
|
+
import type { SeedEntity } from './seed-graph-shape';
|
|
8
|
+
|
|
9
|
+
export interface ScaleGraph {
|
|
10
|
+
/** The one tenant the sized rows belong to. */
|
|
11
|
+
readonly tenant: string;
|
|
12
|
+
/** What to write: the sized tenant's rows, references to rows of no tenant rewritten. */
|
|
13
|
+
readonly write: SeedGraph;
|
|
14
|
+
/** What an id lookup reads: the written rows and the pass's rows of no tenant they point at. */
|
|
15
|
+
readonly lookup: SeedGraph;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `seedGraph` at `perTable` rows for one tenant, rows of no tenant swapped for `main`'s. The i-th
|
|
20
|
+
* such row of an entity becomes `main`'s (i mod n)-th: a reference keeps pointing at a row that
|
|
21
|
+
* exists, and two rows that pointed at one row still do.
|
|
22
|
+
*/
|
|
23
|
+
export function scaleGraph(
|
|
24
|
+
main: SeedGraph,
|
|
25
|
+
entities: readonly SeedEntity[],
|
|
26
|
+
options: { readonly perTable: number; readonly seed: number },
|
|
27
|
+
): ScaleGraph {
|
|
28
|
+
const sized = seedGraph(Object.fromEntries(entities.map((entity) => [entity.$name, entity])), {
|
|
29
|
+
tenants: 1,
|
|
30
|
+
perTable: options.perTable,
|
|
31
|
+
seed: options.seed,
|
|
32
|
+
});
|
|
33
|
+
const shared = (graph: SeedGraph) => graph.inserts.filter((insert) => insert.tenant === null);
|
|
34
|
+
const theirs = shared(main);
|
|
35
|
+
// (entity, property, value in the sized graph) → the pass's value at the same position.
|
|
36
|
+
const swap = new Map<string, unknown>();
|
|
37
|
+
const key = (entity: string, property: string, value: unknown) =>
|
|
38
|
+
`${entity}\u0000${property}\u0000${JSON.stringify(value)}`;
|
|
39
|
+
const seen = new Map<string, number>();
|
|
40
|
+
for (const insert of shared(sized)) {
|
|
41
|
+
const pool = theirs.filter((row) => row.entity === insert.entity);
|
|
42
|
+
const at = seen.get(insert.entity) ?? 0;
|
|
43
|
+
seen.set(insert.entity, at + 1);
|
|
44
|
+
const target = pool[at % Math.max(1, pool.length)];
|
|
45
|
+
if (target === undefined) continue;
|
|
46
|
+
for (const [property, value] of Object.entries(insert.row)) {
|
|
47
|
+
swap.set(key(insert.entity, property, value), target.row[property]);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const references = new Map(
|
|
51
|
+
entities.map((entity) => [
|
|
52
|
+
entity.$name,
|
|
53
|
+
entity
|
|
54
|
+
.$references()
|
|
55
|
+
.map((ref) => [ref.property, ref.targetEntity, ref.targetProperty] as const),
|
|
56
|
+
]),
|
|
57
|
+
);
|
|
58
|
+
const rewrite = (entity: string, row: Readonly<Record<string, unknown>>) => {
|
|
59
|
+
const out: Record<string, unknown> = { ...row };
|
|
60
|
+
for (const [property, target, targetProperty] of references.get(entity) ?? []) {
|
|
61
|
+
const swapped = key(target, targetProperty, row[property]);
|
|
62
|
+
if (property in row && swap.has(swapped)) out[property] = swap.get(swapped);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
};
|
|
66
|
+
const kept = sized.inserts.flatMap((insert, i) =>
|
|
67
|
+
insert.tenant === null ? [] : [[insert, sized.canaries[i]] as const],
|
|
68
|
+
);
|
|
69
|
+
const write: SeedGraph = {
|
|
70
|
+
...sized,
|
|
71
|
+
inserts: kept.map(([insert]) => ({ ...insert, row: rewrite(insert.entity, insert.row) })),
|
|
72
|
+
canaries: kept.flatMap(([, canary]) => (canary === undefined ? [] : [canary])),
|
|
73
|
+
updates: sized.updates
|
|
74
|
+
.filter((update) => update.tenant !== null)
|
|
75
|
+
.map((update) => ({ ...update, patch: rewrite(update.entity, update.patch) })),
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
tenant: sized.tenants[0] as string,
|
|
79
|
+
write,
|
|
80
|
+
lookup: { ...write, inserts: [...theirs, ...write.inserts] },
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// The scale sweep, run: two more tenants after the pass, one at 2 rows a table and one at 8, and
|
|
2
|
+
// every reached primitive called once in each, in the same order — whatever an earlier call wrote,
|
|
3
|
+
// it wrote at both sizes. A shape whose count grows with the rows is `judgeScale`'s red.
|
|
4
|
+
|
|
5
|
+
import type { Actor } from '@ultimat3/core';
|
|
6
|
+
import { fnv1a, renderThrowable } from '@ultimat3/core';
|
|
7
|
+
import type { ExerciseRoom } from './exercise-report';
|
|
8
|
+
import type { RunJobs } from './fixture-jobs';
|
|
9
|
+
import { testJobs } from './fixture-jobs';
|
|
10
|
+
import type { ScaleProof, StatementTally } from './scale-sweep';
|
|
11
|
+
import { judgeScale, SCALE_SIZES } from './scale-sweep';
|
|
12
|
+
import { scaleGraph } from './scale-sweep-graph';
|
|
13
|
+
import type { SeedGraph, SeedWriter } from './seed-graph-plan';
|
|
14
|
+
import { insertSeedGraph } from './seed-graph-plan';
|
|
15
|
+
import type { SeedEntity } from './seed-graph-shape';
|
|
16
|
+
|
|
17
|
+
/** What the sweep needs of a primitive: the pass's own call. */
|
|
18
|
+
export interface ScaleSubject {
|
|
19
|
+
readonly name: string;
|
|
20
|
+
call(
|
|
21
|
+
input: unknown,
|
|
22
|
+
actor: Actor | null,
|
|
23
|
+
tenant: string,
|
|
24
|
+
jobs: RunJobs,
|
|
25
|
+
): Promise<{ readonly failure?: unknown }>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The pass's recipe for one call in `tenant` of `graph`: its actor and its input, or none. `self`
|
|
30
|
+
* binds a field naming the actor entity to the actor's own row, else to somebody else's — the two
|
|
31
|
+
* readings the control tries.
|
|
32
|
+
*/
|
|
33
|
+
export type ScaleSetup = (
|
|
34
|
+
subject: ScaleSubject,
|
|
35
|
+
graph: SeedGraph,
|
|
36
|
+
tenant: string,
|
|
37
|
+
self: boolean,
|
|
38
|
+
) => Promise<{ readonly actor: Actor | null; readonly input: unknown } | undefined>;
|
|
39
|
+
|
|
40
|
+
export interface ScaleSweepOptions {
|
|
41
|
+
readonly subjects: readonly ScaleSubject[];
|
|
42
|
+
readonly entities: readonly SeedEntity[];
|
|
43
|
+
readonly writer: SeedWriter;
|
|
44
|
+
readonly main: SeedGraph;
|
|
45
|
+
readonly seed: number;
|
|
46
|
+
readonly room: ExerciseRoom;
|
|
47
|
+
readonly setup: ScaleSetup;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type Call = { readonly shapes: readonly StatementTally[] } | { readonly failed: string };
|
|
51
|
+
|
|
52
|
+
async function callAt(
|
|
53
|
+
subject: ScaleSubject,
|
|
54
|
+
graph: SeedGraph,
|
|
55
|
+
tenant: string,
|
|
56
|
+
options: ScaleSweepOptions,
|
|
57
|
+
self: boolean,
|
|
58
|
+
): Promise<Call> {
|
|
59
|
+
let prepared: Awaited<ReturnType<ScaleSetup>>;
|
|
60
|
+
try {
|
|
61
|
+
prepared = await options.setup(subject, graph, tenant, self);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
// An override's input is the app's code, run against this sized graph: its failure is this
|
|
64
|
+
// call's, never the sweep's.
|
|
65
|
+
return { failed: `its exercise.ts input threw: ${renderThrowable(error)}` };
|
|
66
|
+
}
|
|
67
|
+
if (prepared === undefined) return { failed: 'no actor its policy permits' };
|
|
68
|
+
await using jobs = await testJobs();
|
|
69
|
+
options.room.begin();
|
|
70
|
+
try {
|
|
71
|
+
const answer = await subject.call(prepared.input, prepared.actor, tenant, jobs);
|
|
72
|
+
const observed = options.room.end();
|
|
73
|
+
return 'failure' in answer ? { failed: renderThrowable(answer.failure) } : observed;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
options.room.end();
|
|
76
|
+
return { failed: renderThrowable(error) };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Seed both sizes, call each subject at each, judge. Unseedable: every subject `unproven`. */
|
|
81
|
+
export async function scaleSweep(
|
|
82
|
+
options: ScaleSweepOptions,
|
|
83
|
+
): Promise<ReadonlyMap<string, ScaleProof>> {
|
|
84
|
+
const proofs = new Map<string, ScaleProof>();
|
|
85
|
+
if (options.subjects.length === 0) return proofs;
|
|
86
|
+
const sizes = [SCALE_SIZES.small, SCALE_SIZES.large].map((perTable, i) =>
|
|
87
|
+
// Hashed, never `seed + 1`: a later pass on the same database may be seeded with that number.
|
|
88
|
+
scaleGraph(options.main, options.entities, {
|
|
89
|
+
perTable,
|
|
90
|
+
seed: fnv1a(`scale:${options.seed}:${i}`) || 1,
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
try {
|
|
94
|
+
for (const size of sizes) await insertSeedGraph(size.write, options.writer);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
const reason = `the sized seed did not write: ${renderThrowable(error)}`;
|
|
97
|
+
for (const subject of options.subjects)
|
|
98
|
+
proofs.set(subject.name, { verdict: 'unproven', reason });
|
|
99
|
+
return proofs;
|
|
100
|
+
}
|
|
101
|
+
const runs: Call[][] = [];
|
|
102
|
+
// The reading that answered at the small size is the one the large size repeats.
|
|
103
|
+
const selves = options.subjects.map(() => true);
|
|
104
|
+
for (const [at, size] of sizes.entries()) {
|
|
105
|
+
const calls: Call[] = [];
|
|
106
|
+
for (const [i, subject] of options.subjects.entries()) {
|
|
107
|
+
let call = await callAt(subject, size.lookup, size.tenant, options, selves[i] ?? true);
|
|
108
|
+
if (at === 0 && 'failed' in call) {
|
|
109
|
+
const other = await callAt(subject, size.lookup, size.tenant, options, false);
|
|
110
|
+
if (!('failed' in other)) {
|
|
111
|
+
call = other;
|
|
112
|
+
selves[i] = false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
calls.push(call);
|
|
116
|
+
}
|
|
117
|
+
runs.push(calls);
|
|
118
|
+
}
|
|
119
|
+
options.subjects.forEach((subject, i) => {
|
|
120
|
+
const small = runs[0]?.[i];
|
|
121
|
+
const large = runs[1]?.[i];
|
|
122
|
+
if (small === undefined || large === undefined) return;
|
|
123
|
+
if ('failed' in small || 'failed' in large) {
|
|
124
|
+
const failed = 'failed' in small ? small.failed : 'failed' in large ? large.failed : '';
|
|
125
|
+
proofs.set(subject.name, {
|
|
126
|
+
verdict: 'unproven',
|
|
127
|
+
reason: `a sized call did not answer: ${failed}`,
|
|
128
|
+
});
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
proofs.set(subject.name, judgeScale(small.shapes, large.shapes));
|
|
132
|
+
});
|
|
133
|
+
return proofs;
|
|
134
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// The scale verdict: one primitive's statement shapes at a small seed and at a large one. A shape
|
|
2
|
+
// sent more often when the tables hold more rows is a loop over rows — an N+1 no test named — and
|
|
3
|
+
// red, unless `expectedQueryLoop` scoped it, which is reported with its reason. Pure: tallies in.
|
|
4
|
+
|
|
5
|
+
/** One statement shape in one call: every statement, and the ones no `expectedQueryLoop` covered. */
|
|
6
|
+
export interface StatementTally {
|
|
7
|
+
/** `statementFingerprint`: `posts.findById` when attributed, else the collapsed text. */
|
|
8
|
+
readonly fingerprint: string;
|
|
9
|
+
readonly count: number;
|
|
10
|
+
readonly unexpected: number;
|
|
11
|
+
/** The `expectedQueryLoop` reason in force when one of them was sent. */
|
|
12
|
+
readonly expected?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A shape whose count moved between the two sizes. */
|
|
16
|
+
export interface ScaleShape {
|
|
17
|
+
readonly fingerprint: string;
|
|
18
|
+
readonly small: number;
|
|
19
|
+
readonly large: number;
|
|
20
|
+
/** Present on a loop `expectedQueryLoop` declared: why it is deliberate. */
|
|
21
|
+
readonly reason?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ScaleProof =
|
|
25
|
+
/** No shape grew; `expected` lists the declared loops that did, with their reasons. */
|
|
26
|
+
| { readonly verdict: 'flat'; readonly expected: readonly ScaleShape[] }
|
|
27
|
+
| {
|
|
28
|
+
readonly verdict: 'grows';
|
|
29
|
+
readonly shapes: readonly ScaleShape[];
|
|
30
|
+
readonly expected: readonly ScaleShape[];
|
|
31
|
+
}
|
|
32
|
+
/** A call at one size did not answer as at the other: the counts are not comparable. Never red. */
|
|
33
|
+
| { readonly verdict: 'unproven'; readonly reason: string };
|
|
34
|
+
|
|
35
|
+
/** Rows a table per tenant at each size: a loop over them sends 2 statements, then 8. */
|
|
36
|
+
export const SCALE_SIZES = { small: 2, large: 8 } as const;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Per shape, the large run against the small one. Only unexpected statements are judged; a shape
|
|
40
|
+
* that grew only under an `expectedQueryLoop` is listed beside the verdict with its reason. A shape
|
|
41
|
+
* absent at the small size and present at the large one grew from zero.
|
|
42
|
+
*/
|
|
43
|
+
export function judgeScale(
|
|
44
|
+
small: readonly StatementTally[],
|
|
45
|
+
large: readonly StatementTally[],
|
|
46
|
+
): ScaleProof {
|
|
47
|
+
const before = new Map(small.map((tally) => [tally.fingerprint, tally]));
|
|
48
|
+
const shapes: ScaleShape[] = [];
|
|
49
|
+
const expected: ScaleShape[] = [];
|
|
50
|
+
for (const after of large) {
|
|
51
|
+
const was = before.get(after.fingerprint);
|
|
52
|
+
if (after.unexpected > (was?.unexpected ?? 0)) {
|
|
53
|
+
shapes.push({
|
|
54
|
+
fingerprint: after.fingerprint,
|
|
55
|
+
small: was?.unexpected ?? 0,
|
|
56
|
+
large: after.unexpected,
|
|
57
|
+
});
|
|
58
|
+
} else if (after.count > (was?.count ?? 0) && after.expected !== undefined) {
|
|
59
|
+
expected.push({
|
|
60
|
+
fingerprint: after.fingerprint,
|
|
61
|
+
small: was?.count ?? 0,
|
|
62
|
+
large: after.count,
|
|
63
|
+
reason: after.expected,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const byName = (a: ScaleShape, b: ScaleShape): number =>
|
|
68
|
+
a.fingerprint.localeCompare(b.fingerprint);
|
|
69
|
+
shapes.sort(byName);
|
|
70
|
+
expected.sort(byName);
|
|
71
|
+
return shapes.length === 0
|
|
72
|
+
? { verdict: 'flat', expected }
|
|
73
|
+
: { verdict: 'grows', shapes, expected };
|
|
74
|
+
}
|
package/src/sealed-network.ts
CHANGED
|
@@ -3,8 +3,12 @@
|
|
|
3
3
|
// for reasons nobody can reproduce — so the default is "nothing gets out". Three dials are sealed:
|
|
4
4
|
// `fetch` here, `WebSocket` and `Bun.connect` through `sealed-sockets.ts`, all behind one gate.
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { isSelfOrigin, peerAddressClass } from '@ultimat3/core';
|
|
7
7
|
import { NetworkOfflineError, NetworkRaceError, NetworkSealedError } from './errors';
|
|
8
|
+
import type { MockHandler, MockRoute } from './mock-route';
|
|
9
|
+
import { mockRoute, routeMatches } from './mock-route';
|
|
10
|
+
import type { LoggedRequest } from './request-log';
|
|
11
|
+
import { hasStreamBody, headersOf, isOpenEnded, readBody, requestProbe } from './request-log';
|
|
8
12
|
import { installSocketSeal } from './sealed-sockets';
|
|
9
13
|
|
|
10
14
|
export type FetchLike = typeof globalThis.fetch;
|
|
@@ -15,16 +19,10 @@ export type FetchLike = typeof globalThis.fetch;
|
|
|
15
19
|
*/
|
|
16
20
|
export type NetworkState = 'online' | 'offline' | 'dropped';
|
|
17
21
|
|
|
18
|
-
export interface MockRoute {
|
|
19
|
-
/** Exact URL, or a prefix ending in `*`, or a RegExp. */
|
|
20
|
-
readonly match: string | RegExp;
|
|
21
|
-
readonly handler: (request: Request) => Response | Promise<Response>;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
22
|
interface SealState {
|
|
25
23
|
readonly allowed: Set<string>;
|
|
26
24
|
readonly mocks: MockRoute[];
|
|
27
|
-
readonly
|
|
25
|
+
readonly log: LoggedRequest[];
|
|
28
26
|
original: FetchLike | undefined;
|
|
29
27
|
/** `installSocketSeal`'s undo, held beside `original` so one unseal restores all three dials. */
|
|
30
28
|
restoreSockets: (() => void) | undefined;
|
|
@@ -34,22 +32,12 @@ interface SealState {
|
|
|
34
32
|
const state: SealState = {
|
|
35
33
|
allowed: new Set(),
|
|
36
34
|
mocks: [],
|
|
37
|
-
|
|
35
|
+
log: [],
|
|
38
36
|
original: undefined,
|
|
39
37
|
restoreSockets: undefined,
|
|
40
38
|
network: 'online',
|
|
41
39
|
};
|
|
42
40
|
|
|
43
|
-
const matches = (route: MockRoute, url: string): boolean => {
|
|
44
|
-
if (route.match instanceof RegExp) {
|
|
45
|
-
// A `g`/`y` pattern's `test` resumes at `lastIndex`: without the reset, every other call missed.
|
|
46
|
-
route.match.lastIndex = 0;
|
|
47
|
-
return route.match.test(url);
|
|
48
|
-
}
|
|
49
|
-
if (route.match.endsWith('*')) return url.startsWith(route.match.slice(0, -1));
|
|
50
|
-
return route.match === url;
|
|
51
|
-
};
|
|
52
|
-
|
|
53
41
|
const urlOf = (input: RequestInfo | URL): string => {
|
|
54
42
|
if (typeof input === 'string') return input;
|
|
55
43
|
if (input instanceof URL) return input.toString();
|
|
@@ -70,14 +58,14 @@ const methodOf = (input: RequestInfo | URL, init: RequestInit | undefined): stri
|
|
|
70
58
|
*/
|
|
71
59
|
const isThisMachine = (hostname: string): boolean => {
|
|
72
60
|
if (hostname.toLowerCase() === 'localhost') return true;
|
|
73
|
-
const kind =
|
|
61
|
+
const kind = peerAddressClass(hostname);
|
|
74
62
|
return kind === 'loopback' || kind === 'unspecified';
|
|
75
63
|
};
|
|
76
64
|
|
|
77
65
|
/** The socket dials' gate: the same allow-list, offline state and record as `fetch`. No mocks — a
|
|
78
66
|
* socket test injects its transport (`connect`, `client`), it does not intercept the dial. */
|
|
79
67
|
const socketRefusal = (url: string, method: string): Error | undefined => {
|
|
80
|
-
state.
|
|
68
|
+
state.log.push({ method, url, headers: {}, body: null });
|
|
81
69
|
let target: URL;
|
|
82
70
|
try {
|
|
83
71
|
target = new URL(url);
|
|
@@ -102,34 +90,58 @@ export function sealNetwork(): void {
|
|
|
102
90
|
state.restoreSockets = installSocketSeal(socketRefusal);
|
|
103
91
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
104
92
|
const url = urlOf(input);
|
|
105
|
-
|
|
93
|
+
const method = methodOf(input, init);
|
|
94
|
+
const probe = requestProbe(input, init);
|
|
95
|
+
// In the log before the first await, so concurrent calls keep their order; every verdict below
|
|
96
|
+
// is reached before the body is awaited too, so a call is judged by the state it was made in.
|
|
97
|
+
const call = { method, url, headers: headersOf(probe), body: null as string | null };
|
|
98
|
+
state.log.push(call);
|
|
99
|
+
const openEnded = isOpenEnded(input, init);
|
|
100
|
+
/** A finite body is in the entry when `fetch` settles; an open-ended one, once it ends. */
|
|
101
|
+
const logBody = async (source: Request | undefined): Promise<void> => {
|
|
102
|
+
const read = readBody(source).then((body) => {
|
|
103
|
+
call.body = body;
|
|
104
|
+
});
|
|
105
|
+
if (!openEnded) await read;
|
|
106
|
+
};
|
|
106
107
|
// Ahead of the mocks on purpose: a test that mocked Stripe and then went offline is asserting
|
|
107
108
|
// the offline path, and a mock that still answered would be the one thing that path never sees.
|
|
108
109
|
if (state.network !== 'online') {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
mode: state.network,
|
|
113
|
-
});
|
|
110
|
+
const mode = state.network;
|
|
111
|
+
await logBody(probe);
|
|
112
|
+
throw new NetworkOfflineError({ url, method, mode });
|
|
114
113
|
}
|
|
115
|
-
const mock = state.mocks.find((route) =>
|
|
114
|
+
const mock = state.mocks.find((route) => routeMatches(route, call));
|
|
116
115
|
if (mock !== undefined) {
|
|
117
|
-
|
|
116
|
+
const request =
|
|
117
|
+
input instanceof Request && init === undefined ? input : new Request(input, init);
|
|
118
|
+
// The probe left a stream out, and a mock is the one consumer that cannot lose backpressure.
|
|
119
|
+
const logged = hasStreamBody(init) ? request.clone() : probe;
|
|
120
|
+
// Asked before the body is awaited: an answer list goes in CALL order, so a slow POST made
|
|
121
|
+
// first cannot let a later GET take its 503. A thrown answer rejects; it is awaited below.
|
|
122
|
+
const answered = (async () => mock.handler(request))();
|
|
123
|
+
answered.catch(() => undefined);
|
|
124
|
+
await logBody(logged);
|
|
125
|
+
return answered;
|
|
118
126
|
}
|
|
119
127
|
// A server this process booted is not egress — the port is one the kernel just handed us, so
|
|
120
128
|
// there is nothing to allowlist ahead of time. Without this, a socket test's only option is to
|
|
121
129
|
// unseal the network wholesale, which then hides the real egress it was meant to catch.
|
|
122
130
|
const host = safeHost(url);
|
|
123
|
-
|
|
124
|
-
|
|
131
|
+
const passes = isSelfOrigin(url) || (host !== undefined && state.allowed.has(host));
|
|
132
|
+
const allowed = [...state.allowed];
|
|
133
|
+
const original = state.original;
|
|
134
|
+
if (passes) {
|
|
125
135
|
if (original === undefined) throw new NetworkRaceError();
|
|
126
|
-
|
|
136
|
+
// `(input, init)` as handed: `tls`, `unix` and `proxy` are Bun's own and no Request holds them.
|
|
137
|
+
// Dialled before the body is read, so a stream's consumer is the real server, not the log.
|
|
138
|
+
const dialled = original(input, init);
|
|
139
|
+
dialled.catch(() => undefined);
|
|
140
|
+
await logBody(probe);
|
|
141
|
+
return dialled;
|
|
127
142
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
method: methodOf(input, init),
|
|
131
|
-
allowed: [...state.allowed],
|
|
132
|
-
});
|
|
143
|
+
await logBody(probe);
|
|
144
|
+
throw new NetworkSealedError({ url, method, allowed });
|
|
133
145
|
}) as FetchLike;
|
|
134
146
|
}
|
|
135
147
|
|
|
@@ -165,11 +177,16 @@ export function allowHost(host: string): void {
|
|
|
165
177
|
state.allowed.add(host);
|
|
166
178
|
}
|
|
167
179
|
|
|
180
|
+
/**
|
|
181
|
+
* `match` is a URL, a prefix ending in `*` or a RegExp, every verb; a string may name one verb first
|
|
182
|
+
* (`'POST https://…'`). `answer` is a handler, or a list answered in order with the last repeated,
|
|
183
|
+
* where an `Error` is thrown: a 503-then-200 retry test is one registration and no counter.
|
|
184
|
+
*/
|
|
168
185
|
export function mockFetch(
|
|
169
186
|
match: string | RegExp,
|
|
170
|
-
|
|
187
|
+
answer: MockHandler | readonly (Response | Error)[],
|
|
171
188
|
): void {
|
|
172
|
-
state.mocks.unshift(
|
|
189
|
+
state.mocks.unshift(mockRoute(match, answer));
|
|
173
190
|
}
|
|
174
191
|
|
|
175
192
|
/** Convenience for the common case: a JSON body and a status. */
|
|
@@ -184,13 +201,13 @@ export function mockJson(match: string | RegExp, body: unknown, status = 200): v
|
|
|
184
201
|
);
|
|
185
202
|
}
|
|
186
203
|
|
|
187
|
-
/** Every
|
|
188
|
-
export const
|
|
204
|
+
/** Every dial a test attempted, in order, refused ones included — what it sent, and to whom. */
|
|
205
|
+
export const requestLog = (): readonly LoggedRequest[] => [...state.log];
|
|
189
206
|
|
|
190
207
|
export function resetNetwork(): void {
|
|
191
208
|
state.allowed.clear();
|
|
192
209
|
state.mocks.length = 0;
|
|
193
|
-
state.
|
|
210
|
+
state.log.length = 0;
|
|
194
211
|
state.network = 'online';
|
|
195
212
|
}
|
|
196
213
|
|
|
@@ -198,7 +215,7 @@ export function resetNetwork(): void {
|
|
|
198
215
|
export interface NetworkSnapshot {
|
|
199
216
|
readonly allowed: readonly string[];
|
|
200
217
|
readonly mocks: readonly MockRoute[];
|
|
201
|
-
readonly
|
|
218
|
+
readonly log: readonly LoggedRequest[];
|
|
202
219
|
readonly network: NetworkState;
|
|
203
220
|
}
|
|
204
221
|
|
|
@@ -212,7 +229,7 @@ export interface NetworkSnapshot {
|
|
|
212
229
|
export const captureNetwork = (): NetworkSnapshot => ({
|
|
213
230
|
allowed: [...state.allowed],
|
|
214
231
|
mocks: [...state.mocks],
|
|
215
|
-
|
|
232
|
+
log: [...state.log],
|
|
216
233
|
network: state.network,
|
|
217
234
|
});
|
|
218
235
|
|
|
@@ -221,7 +238,7 @@ export function restoreCapturedNetwork(snapshot: NetworkSnapshot): void {
|
|
|
221
238
|
for (const host of snapshot.allowed) state.allowed.add(host);
|
|
222
239
|
state.mocks.length = 0;
|
|
223
240
|
state.mocks.push(...snapshot.mocks);
|
|
224
|
-
state.
|
|
225
|
-
state.
|
|
241
|
+
state.log.length = 0;
|
|
242
|
+
state.log.push(...snapshot.log);
|
|
226
243
|
state.network = snapshot.network;
|
|
227
244
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// The seed graph's refusals: no generated row satisfies a table, or a writer handed a plan for an
|
|
2
|
+
// entity it was not given. Apart from ./errors, which owns the codes, their titles and the one
|
|
3
|
+
// registration, because that catalogue is at its ceiling. A row refusal's fix names the
|
|
4
|
+
// `defineFactory` to write, since a factory is the only input that beats the graph.
|
|
5
|
+
|
|
6
|
+
import { renderCauseValue, renderFixLiteral, UltimateError } from '@ultimat3/core';
|
|
7
|
+
import './errors';
|
|
8
|
+
|
|
9
|
+
/** What could not be generated: a column, a rule a row breaks, or a cycle with no nullable edge. */
|
|
10
|
+
export interface SeedGraphUnsatisfiedInput {
|
|
11
|
+
readonly entity: string;
|
|
12
|
+
readonly what: string;
|
|
13
|
+
/** The columns the factory has to supply. Empty when no column is to blame (a cycle). */
|
|
14
|
+
readonly columns: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ENTITY_PLACEHOLDER = '<the entity the cause names>';
|
|
18
|
+
|
|
19
|
+
/** `defaultFor` and `seedGraph` refuse the same way: the row is the app's to describe. */
|
|
20
|
+
export class SeedGraphUnsatisfiedError extends UltimateError {
|
|
21
|
+
constructor(input: SeedGraphUnsatisfiedInput) {
|
|
22
|
+
const name = renderFixLiteral(input.entity, ENTITY_PLACEHOLDER);
|
|
23
|
+
const body =
|
|
24
|
+
input.columns.length === 0
|
|
25
|
+
? '/* every column */'
|
|
26
|
+
: input.columns.map((column) => `${column}: …`).join(', ');
|
|
27
|
+
super({
|
|
28
|
+
code: 'X_SEED_GRAPH_UNSATISFIED',
|
|
29
|
+
cause: `${input.entity}: ${input.what}`,
|
|
30
|
+
fix: `defineFactory(<the ${input.entity} entity>, { defaults: (index, ids) => ({ ${body} }) }), then seedGraph(entities, { factories: { ${name}: thatFactory } })`,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const seedGraphUnsatisfied = (input: SeedGraphUnsatisfiedInput): SeedGraphUnsatisfiedError =>
|
|
36
|
+
new SeedGraphUnsatisfiedError(input);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `entitySeedWriter` met a row whose entity is not in the set it writes through: the plan and the
|
|
40
|
+
* writer were built from two different entity sets. The fix is one set, handed to both.
|
|
41
|
+
*/
|
|
42
|
+
export class SeedGraphEntityUnknownError extends UltimateError {
|
|
43
|
+
constructor(input: { readonly entity: string; readonly known: readonly string[] }) {
|
|
44
|
+
super({
|
|
45
|
+
code: 'X_SEED_GRAPH_ENTITY_UNKNOWN',
|
|
46
|
+
cause: `the seed plan writes ${renderCauseValue(input.entity)}, and the writer holds only ${renderCauseValue(input.known)}`,
|
|
47
|
+
fix: 'const graph = seedGraph(entities, …); await insertSeedGraph(graph, entitySeedWriter({ entities, … })) # one entity set for both',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Insert order for a seed graph: every parent before its children, over the foreign keys the
|
|
2
|
+
// entities themselves resolve (`$references()`, the record `relationsOf` is built from). A cycle is
|
|
3
|
+
// broken at a NULLABLE edge — inserted null, set by an update once its target exists; a cycle with
|
|
4
|
+
// no nullable edge is refused, because no insert order can satisfy it.
|
|
5
|
+
|
|
6
|
+
import type { ReferenceDescription } from '@ultimat3/entity';
|
|
7
|
+
import { seedGraphUnsatisfied } from './seed-graph-error';
|
|
8
|
+
|
|
9
|
+
export interface OrderInput {
|
|
10
|
+
readonly name: string;
|
|
11
|
+
readonly references: readonly ReferenceDescription[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface InsertOrder {
|
|
15
|
+
readonly order: readonly string[];
|
|
16
|
+
/** `<entity>.<property>` for every key inserted null and set afterwards. */
|
|
17
|
+
readonly deferred: ReadonlySet<string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const edgeKey = (entity: string, property: string): string => `${entity}.${property}`;
|
|
21
|
+
|
|
22
|
+
/** Whether `to` reaches `from` over the live edges, i.e. whether `from -> to` closes a cycle. */
|
|
23
|
+
const reaches = (
|
|
24
|
+
edges: ReadonlyMap<string, readonly ReferenceDescription[]>,
|
|
25
|
+
live: (from: string, reference: ReferenceDescription) => boolean,
|
|
26
|
+
start: string,
|
|
27
|
+
goal: string,
|
|
28
|
+
): boolean => {
|
|
29
|
+
const seen = new Set<string>();
|
|
30
|
+
const stack = [start];
|
|
31
|
+
while (stack.length > 0) {
|
|
32
|
+
const node = stack.pop() as string;
|
|
33
|
+
if (node === goal) return true;
|
|
34
|
+
if (seen.has(node)) continue;
|
|
35
|
+
seen.add(node);
|
|
36
|
+
for (const reference of edges.get(node) ?? []) {
|
|
37
|
+
if (live(node, reference)) stack.push(reference.targetEntity);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Kahn's algorithm in name order, so the same entity set always inserts in the same order. */
|
|
44
|
+
export function insertOrder(entities: readonly OrderInput[]): InsertOrder {
|
|
45
|
+
const names = new Set(entities.map((entity) => entity.name));
|
|
46
|
+
const edges = new Map(entities.map((entity) => [entity.name, entity.references]));
|
|
47
|
+
const deferred = new Set<string>();
|
|
48
|
+
// A self-reference never orders anything: the row's own table is already being written.
|
|
49
|
+
const live = (from: string, reference: ReferenceDescription): boolean =>
|
|
50
|
+
names.has(reference.targetEntity) &&
|
|
51
|
+
reference.targetEntity !== from &&
|
|
52
|
+
!deferred.has(edgeKey(from, reference.property));
|
|
53
|
+
const order: string[] = [];
|
|
54
|
+
const placed = new Set<string>();
|
|
55
|
+
const sorted = [...names].sort();
|
|
56
|
+
while (order.length < sorted.length) {
|
|
57
|
+
const ready = sorted.find(
|
|
58
|
+
(name) =>
|
|
59
|
+
!placed.has(name) &&
|
|
60
|
+
(edges.get(name) ?? []).every(
|
|
61
|
+
(reference) => !live(name, reference) || placed.has(reference.targetEntity),
|
|
62
|
+
),
|
|
63
|
+
);
|
|
64
|
+
if (ready !== undefined) {
|
|
65
|
+
order.push(ready);
|
|
66
|
+
placed.add(ready);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const stuck = sorted.filter((name) => !placed.has(name));
|
|
70
|
+
const breakable = stuck.flatMap((name) =>
|
|
71
|
+
(edges.get(name) ?? [])
|
|
72
|
+
.filter(
|
|
73
|
+
(reference) =>
|
|
74
|
+
reference.nullable &&
|
|
75
|
+
live(name, reference) &&
|
|
76
|
+
!placed.has(reference.targetEntity) &&
|
|
77
|
+
reaches(edges, live, reference.targetEntity, name),
|
|
78
|
+
)
|
|
79
|
+
.map((reference) => edgeKey(name, reference.property)),
|
|
80
|
+
);
|
|
81
|
+
const edge = breakable[0];
|
|
82
|
+
if (edge === undefined) {
|
|
83
|
+
const cyclic = stuck.filter((name) =>
|
|
84
|
+
(edges.get(name) ?? []).some(
|
|
85
|
+
(reference) =>
|
|
86
|
+
live(name, reference) && reaches(edges, live, reference.targetEntity, name),
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
throw seedGraphUnsatisfied({
|
|
90
|
+
entity: cyclic[0] ?? stuck[0] ?? '',
|
|
91
|
+
what: `a cycle of non-nullable foreign keys joins ${cyclic.join(', ')} — no insert order satisfies it; make one key .nullable(), or seed these tables yourself`,
|
|
92
|
+
columns: [],
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
deferred.add(edge);
|
|
96
|
+
}
|
|
97
|
+
return { order, deferred };
|
|
98
|
+
}
|