@ultimat3/testing 23.0.0 → 25.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 +20 -20
- package/README.md +95 -13
- package/package.json +16 -16
- package/src/auth-request.ts +1 -1
- package/src/budget-store-conformance.ts +81 -0
- package/src/cdp-browser-lease.ts +112 -0
- package/src/cdp-browser.ts +47 -11
- package/src/cdp-connection.ts +41 -10
- package/src/cdp-e2e-session.ts +130 -13
- package/src/cdp-errors.ts +129 -8
- package/src/cdp-launch-attempt.ts +224 -0
- package/src/cdp-launch-candidates.ts +62 -0
- package/src/cdp-launch-profile.ts +106 -0
- package/src/cdp-launch-reap.ts +68 -0
- package/src/cdp-launch-wire.ts +171 -0
- package/src/cdp-launch.ts +92 -140
- package/src/cdp-offline-script.ts +33 -3
- package/src/cdp-wire-watch.ts +113 -0
- package/src/define-island-states.ts +47 -8
- package/src/determinism.ts +35 -6
- package/src/e2e-app.ts +17 -1
- package/src/e2e-errors.ts +14 -3
- package/src/e2e-locale.ts +12 -21
- package/src/e2e-page.ts +12 -26
- package/src/e2e-run.ts +22 -12
- package/src/e2e-service-worker.ts +92 -0
- package/src/e2e-spawn.ts +58 -17
- package/src/errors.ts +34 -66
- package/src/factories.ts +4 -6
- package/src/file-boundary.ts +12 -7
- package/src/fixture-clock.ts +1 -1
- package/src/fixture-island.ts +50 -98
- package/src/fixture-jobs.ts +16 -12
- package/src/fixture-mail.ts +3 -3
- package/src/fixture-network.ts +1 -1
- package/src/fixture-statements.ts +1 -1
- package/src/fixture-subscribe.ts +3 -3
- package/src/framework-fixtures.ts +13 -13
- package/src/free-port.ts +46 -0
- package/src/harness.ts +2 -1
- package/src/index.ts +29 -10
- package/src/island-dom.ts +57 -13
- package/src/island-globals.ts +73 -0
- package/src/island-html-entities.ts +27 -0
- package/src/island-scratch.ts +105 -0
- package/src/island-state-errors.ts +16 -0
- package/src/island-states.ts +23 -0
- package/src/job-driver-checks.ts +177 -0
- package/src/job-driver-conformance.ts +21 -0
- package/src/live-node.ts +6 -4
- package/src/preload.ts +2 -8
- package/src/quiet-logs.ts +1 -1
- package/src/registry-leak-error.ts +81 -0
- package/src/registry-leak-guard.ts +7 -4
- package/src/registry-snapshot.ts +41 -2
- package/src/render-view.ts +3 -3
- package/src/sealed-network.ts +50 -5
- package/src/sealed-sockets.ts +59 -0
- package/src/template-db.ts +8 -5
- package/src/test-types.ts +6 -2
package/src/preload.ts
CHANGED
|
@@ -12,8 +12,7 @@ import { registerFrameworkFixtures } from './framework-fixtures';
|
|
|
12
12
|
import './matchers';
|
|
13
13
|
import { installAppJsxLoader } from './app-jsx-loader';
|
|
14
14
|
import { onFileBoundary } from './file-boundary';
|
|
15
|
-
import {
|
|
16
|
-
import { releasePluginsAfterIsolatedFile } from './isolated-plugins';
|
|
15
|
+
import { ISOLATED_ENV, releasePluginsAfterIsolatedFile } from './isolated-plugins';
|
|
17
16
|
import { installPerTestReset } from './per-test-reset';
|
|
18
17
|
import { installRegistryLeakGuard } from './registry-leak-guard';
|
|
19
18
|
import { sealNetwork } from './sealed-network';
|
|
@@ -44,11 +43,6 @@ Bun.env['ULTIMATE_TEST_SLOT_HELD'] ??= '1';
|
|
|
44
43
|
// fails a later file in another package, for a reason nothing in that file explains.
|
|
45
44
|
installRegistryLeakGuard();
|
|
46
45
|
|
|
47
|
-
// A worker runs many files in one global unless the repo opted into `--isolate` (22.7): a mount a
|
|
48
|
-
// file never disposed is disposed between files, and `file-boundary.ts` then puts `globalThis`
|
|
49
|
-
// back to what the first file saw.
|
|
50
|
-
onFileBoundary(disposeLiveIslands);
|
|
51
|
-
|
|
52
46
|
// And per TEST: the jobs event bus stores what it is handed, so one test's published answer
|
|
53
47
|
// resumed the next test's waiting run (`per-test-reset.ts`).
|
|
54
48
|
installPerTestReset();
|
|
@@ -56,7 +50,7 @@ installPerTestReset();
|
|
|
56
50
|
// A shared worker only (an isolated file is a fresh registry anyway): the app's
|
|
57
51
|
// `defineApi({ pathStyle })` evaluates once per worker, in whichever file first imports it, and
|
|
58
52
|
// must not be refused because an EARLIER file derived action paths under the default style.
|
|
59
|
-
if (Bun.env[
|
|
53
|
+
if (Bun.env[ISOLATED_ENV] !== '1') {
|
|
60
54
|
const { forgetHandedOutActionPaths } = await import('@ultimat3/action');
|
|
61
55
|
onFileBoundary(forgetHandedOutActionPaths);
|
|
62
56
|
}
|
package/src/quiet-logs.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// line collected nothing and failed for an environment variable. The level is therefore pinned at
|
|
12
12
|
// `info` for the one moment core loads, and what the author named decides what is PRINTED.
|
|
13
13
|
//
|
|
14
|
-
// A test that asserts on log output hands `
|
|
14
|
+
// A test that asserts on log output hands `structuredLogger({ level, writer })` its own writer, or
|
|
15
15
|
// installs its own `setLogSink` and restores the previous one. Never `process.stdout`.
|
|
16
16
|
|
|
17
17
|
import type { LogLevel } from '@ultimat3/core';
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Single responsibility: `X_TEST_REGISTRY_LEAK`'s message — which file left which cache tags and
|
|
2
|
+
// tiers registered, the isolating lines to paste, and the `bun test` re-run. The CODE is declared
|
|
3
|
+
// in `errors.ts`, the package's registry, which re-exports this class; the shape it reports is
|
|
4
|
+
// `registry-leak-guard.ts`'s. Split from `errors.ts` at the 500-line ceiling.
|
|
5
|
+
|
|
6
|
+
import { isFixShellSafe, renderCauseValue, renderFixLiteral, UltimateError } from '@ultimat3/core';
|
|
7
|
+
// Type-only, so the cycle with the guard that throws it is erased at build.
|
|
8
|
+
import type { RegistryLeak } from './registry-leak-guard';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Every value in this message is uncontrolled: the path arrives from `Bun.plugin`'s `onLoad`, and
|
|
12
|
+
* the names are whatever the app under test passed to `declareTags()` / `registerTier()`. So the
|
|
13
|
+
* sentence renders them (bounded, never throwing while describing a leak) and the command quotes
|
|
14
|
+
* them (a fix has to parse after a path with a space or a quote in it lands in the middle of it).
|
|
15
|
+
*/
|
|
16
|
+
const describeLeak = (leak: RegistryLeak): string => {
|
|
17
|
+
const left = [
|
|
18
|
+
...(leak.tags.length > 0 ? [`cache tags declared ${renderCauseValue(leak.tags)}`] : []),
|
|
19
|
+
...(leak.tiers.length > 0 ? [`cache tiers registered ${renderCauseValue(leak.tiers)}`] : []),
|
|
20
|
+
];
|
|
21
|
+
return `${renderCauseValue(leak.file)} left ${left.join(' and ')} after its last test`;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** The placeholder is the cause's own sentence: it already names every file, in order. */
|
|
25
|
+
const FILE_PLACEHOLDER = '<the file the cause names>';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Inside double quotes `$`, a backtick and `!` are still live, and `renderFixLiteral` answers double
|
|
29
|
+
* quotes — so a path carrying one becomes the quoted placeholder in the `bun test` command. Any other
|
|
30
|
+
* path stays the JSON literal: an ordinary path, or one with a quote JSON escapes, still runs.
|
|
31
|
+
*/
|
|
32
|
+
const DOUBLE_QUOTE_LIVE = /[$`!]/;
|
|
33
|
+
|
|
34
|
+
const rerunArg = (file: string): string =>
|
|
35
|
+
isFixShellSafe(file) || !DOUBLE_QUOTE_LIVE.test(file)
|
|
36
|
+
? renderFixLiteral(file, FILE_PLACEHOLDER)
|
|
37
|
+
: `'${FILE_PLACEHOLDER}'`;
|
|
38
|
+
|
|
39
|
+
/** Every leaked file as `bun test` arguments — one screen, so the fix-shell-arg guard can see it. */
|
|
40
|
+
const rerunFileArgs = (leaks: readonly RegistryLeak[]): string =>
|
|
41
|
+
leaks.map((leak) => rerunArg(leak.file)).join(' ');
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The edit, spelled as the lines to paste — the imports included, since neither call is global.
|
|
45
|
+
*
|
|
46
|
+
* Both repairs isolate; neither resets. `afterAll(resetTiers)` was the first spelling of the tier
|
|
47
|
+
* half and it is the bug wearing the fix's clothes: it drops the tiers, the revalidator and both
|
|
48
|
+
* logs a NEIGHBOUR registered, and the guard reports additions only, so the damage lands on an
|
|
49
|
+
* innocent file with nothing pointing back here. An error whose instruction causes the next defect
|
|
50
|
+
* is worse than no instruction.
|
|
51
|
+
*/
|
|
52
|
+
const repairFor = (leak: RegistryLeak): string => {
|
|
53
|
+
const imports: string[] = [];
|
|
54
|
+
const calls: string[] = [];
|
|
55
|
+
if (leak.tags.length > 0) {
|
|
56
|
+
imports.push('isolateDeclaredTags');
|
|
57
|
+
calls.push('const restoreTags = isolateDeclaredTags(); afterAll(restoreTags);');
|
|
58
|
+
}
|
|
59
|
+
if (leak.tiers.length > 0) {
|
|
60
|
+
imports.push('isolateTiers');
|
|
61
|
+
calls.push('const restoreTiers = isolateTiers(); afterAll(restoreTiers);');
|
|
62
|
+
}
|
|
63
|
+
const file = renderFixLiteral(leak.file, FILE_PLACEHOLDER);
|
|
64
|
+
return `in ${file} add: import { ${imports.join(', ')} } from '@ultimat3/cache'; ${calls.join(' ')}`;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* One error for every leaker in the run, not one per file: the run has already finished by the
|
|
69
|
+
* time the last file can be judged, and two throws would report the second as an unhandled one.
|
|
70
|
+
* The trailing command reproduces the failure on its own — the guard judges each file against its
|
|
71
|
+
* own baseline, so one leaking file fails a one-file run exactly as it failed the whole suite.
|
|
72
|
+
*/
|
|
73
|
+
export class RegistryLeakError extends UltimateError {
|
|
74
|
+
constructor(input: { readonly leaks: readonly RegistryLeak[] }) {
|
|
75
|
+
super({
|
|
76
|
+
code: 'X_TEST_REGISTRY_LEAK',
|
|
77
|
+
cause: input.leaks.map(describeLeak).join('; '),
|
|
78
|
+
fix: `${input.leaks.map(repairFor).join('; ')} — then re-run: bun test ${rerunFileArgs(input.leaks)}`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -7,7 +7,7 @@ import { afterAll } from 'bun:test';
|
|
|
7
7
|
import { knownTags, registeredTiers } from '@ultimat3/cache';
|
|
8
8
|
import { isAppRoot } from './app-jsx-loader';
|
|
9
9
|
import { RegistryLeakError } from './errors';
|
|
10
|
-
import { runFileBoundary } from './file-boundary';
|
|
10
|
+
import { runBoundaryHooks, runFileBoundary } from './file-boundary';
|
|
11
11
|
import type { ProcessRegistrySnapshot } from './registry-snapshot';
|
|
12
12
|
import {
|
|
13
13
|
captureProcessRegistries,
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
* "Idiomatic to leave filled" says nothing about a file that calls `clear*()`/`reset*()` and takes
|
|
33
33
|
* a module-scope declaration from every file after it — a module evaluates once per process, so the
|
|
34
34
|
* next file's own `import` is a cache hit that declares nothing. That is what `registry-snapshot.ts`
|
|
35
|
-
* repairs, and it repairs
|
|
35
|
+
* repairs, and it repairs SIX registries out of the set that has one:
|
|
36
36
|
*
|
|
37
37
|
* | registry | reset export | owner | in the snapshot? |
|
|
38
38
|
* |---|---|---|---|
|
|
@@ -41,8 +41,9 @@ import {
|
|
|
41
41
|
* | routes | `clearRoutes` | `@ultimat3/render` | **no** |
|
|
42
42
|
* | jobs | `resetJobs` | `@ultimat3/jobs` | **no** |
|
|
43
43
|
* | tasks | `restoreTasks` | `@ultimat3/jobs` | yes (22.7) |
|
|
44
|
-
* |
|
|
45
|
-
* |
|
|
44
|
+
* | measurement actor | `resetMeasurementActor` | `@ultimat3/core` | yes |
|
|
45
|
+
* | actions | `resetActions` | `@ultimat3/action` | **no** |
|
|
46
|
+
* | queries | `resetQueries` | `@ultimat3/query` | **no** |
|
|
46
47
|
* | models / prompts / agents | `resetModels` / `resetPrompts` / `resetAgents` | `@ultimat3/ai` | **no** |
|
|
47
48
|
* | mails | `resetMails` | `@ultimat3/mail` | **no** |
|
|
48
49
|
* | entities | `clearRegistry` | `@ultimat3/entity` | no, by decision — `registry-isolation.ts` |
|
|
@@ -219,6 +220,8 @@ export function installRegistryLeakGuard(): void {
|
|
|
219
220
|
});
|
|
220
221
|
|
|
221
222
|
afterAll(() => {
|
|
223
|
+
// The run's LAST file has no boundary after it: its undisposed island mounts end here.
|
|
224
|
+
runBoundaryHooks();
|
|
222
225
|
close();
|
|
223
226
|
if (leaks.length > 0) throw new RegistryLeakError({ leaks });
|
|
224
227
|
});
|
package/src/registry-snapshot.ts
CHANGED
|
@@ -3,11 +3,17 @@
|
|
|
3
3
|
// module declares at MODULE scope evaluates once per `bun test` process — a neighbour's clear is
|
|
4
4
|
// permanent and there is no second evaluation left to redo it.
|
|
5
5
|
//
|
|
6
|
-
//
|
|
6
|
+
// SIX registries, out of roughly nine that publish a `clear*`/`reset*`. The missing ones and what
|
|
7
7
|
// each still needs are tabulated in `registry-leak-guard.ts` beside `RegistrySample` — every one of
|
|
8
8
|
// them needs a RESTORE in its owning package first, the way `restorePermissions` / `restoreRoles`
|
|
9
9
|
// were added to `@ultimat3/policy` for the two rows below.
|
|
10
10
|
|
|
11
|
+
import type { MeasurementActorFactory } from '@ultimat3/core';
|
|
12
|
+
import {
|
|
13
|
+
declaredMeasurementActor,
|
|
14
|
+
defineMeasurementActor,
|
|
15
|
+
resetMeasurementActor,
|
|
16
|
+
} from '@ultimat3/core';
|
|
11
17
|
import type { Catalog, Locale, LocaleConfig } from '@ultimat3/i18n';
|
|
12
18
|
import {
|
|
13
19
|
catalogDeclarationCount,
|
|
@@ -24,6 +30,7 @@ import { registeredTasks, restoreTasks } from '@ultimat3/jobs';
|
|
|
24
30
|
import type { RoleMap } from '@ultimat3/policy';
|
|
25
31
|
import {
|
|
26
32
|
knownPermissions,
|
|
33
|
+
permissionDeclarationSites,
|
|
27
34
|
restorePermissions,
|
|
28
35
|
restoreRoles,
|
|
29
36
|
roleDeclarationSites,
|
|
@@ -39,6 +46,8 @@ export interface ProcessRegistrySnapshot {
|
|
|
39
46
|
readonly locales: LocaleConfig;
|
|
40
47
|
readonly catalogs: readonly (readonly [Locale, Catalog])[];
|
|
41
48
|
readonly permissions: readonly string[];
|
|
49
|
+
/** Restored WITH the names: a site-less permission is one the policy step can never judge. */
|
|
50
|
+
readonly permissionSites: Readonly<Record<string, readonly string[]>>;
|
|
42
51
|
readonly roles: RoleMap;
|
|
43
52
|
/** Kept beside the map: restoring through `defineRoles()` would rewrite every site. */
|
|
44
53
|
readonly roleSites: Readonly<Record<string, string>>;
|
|
@@ -50,6 +59,13 @@ export interface ProcessRegistrySnapshot {
|
|
|
50
59
|
readonly tasks: readonly TaskHandle[];
|
|
51
60
|
/** `defineCatalogs()` calls so far — see `@ultimat3/i18n`'s `catalogDeclarationCount`. */
|
|
52
61
|
readonly catalogDeclarations: number;
|
|
62
|
+
/**
|
|
63
|
+
* `defineMeasurementActor()`'s factory, `undefined` for core's default. An `app.config.ts`
|
|
64
|
+
* declares it at module scope, so a test body that imported a tracked app's config
|
|
65
|
+
* (`buildIslands(examples/dummy)`) made every later file's `x build` measure as that app's
|
|
66
|
+
* member — and `prerender-actor.test.ts`'s policy-guarded load was denied X_FORBIDDEN.
|
|
67
|
+
*/
|
|
68
|
+
readonly measurementActor: MeasurementActorFactory | undefined;
|
|
53
69
|
}
|
|
54
70
|
|
|
55
71
|
export function captureProcessRegistries(): ProcessRegistrySnapshot {
|
|
@@ -57,10 +73,12 @@ export function captureProcessRegistries(): ProcessRegistrySnapshot {
|
|
|
57
73
|
locales: localeConfig(),
|
|
58
74
|
catalogs: registeredLocales().map((locale) => [locale, catalogFor(locale)] as const),
|
|
59
75
|
permissions: knownPermissions(),
|
|
76
|
+
permissionSites: permissionDeclarationSites(),
|
|
60
77
|
roles: roleDefinitions(),
|
|
61
78
|
roleSites: roleDeclarationSites(),
|
|
62
79
|
tasks: registeredTasks(),
|
|
63
80
|
catalogDeclarations: catalogDeclarationCount(),
|
|
81
|
+
measurementActor: declaredMeasurementActor(),
|
|
64
82
|
};
|
|
65
83
|
}
|
|
66
84
|
|
|
@@ -74,9 +92,11 @@ export function restoreProcessRegistries(snapshot: ProcessRegistrySnapshot): voi
|
|
|
74
92
|
// a partial call can never widen `supported` back.
|
|
75
93
|
configureLocales(snapshot.locales);
|
|
76
94
|
restoreCatalogs(snapshot.catalogs);
|
|
77
|
-
restorePermissions(snapshot.permissions);
|
|
95
|
+
restorePermissions(snapshot.permissions, snapshot.permissionSites);
|
|
78
96
|
restoreRoles(snapshot.roles, snapshot.roleSites);
|
|
79
97
|
restoreTasks(snapshot.tasks);
|
|
98
|
+
if (snapshot.measurementActor === undefined) resetMeasurementActor();
|
|
99
|
+
else defineMeasurementActor(snapshot.measurementActor);
|
|
80
100
|
}
|
|
81
101
|
|
|
82
102
|
/**
|
|
@@ -138,9 +158,28 @@ export function mergeSnapshots(
|
|
|
138
158
|
locales: newer.locales,
|
|
139
159
|
catalogs: [...catalogs],
|
|
140
160
|
permissions: [...new Set([...older.permissions, ...newer.permissions])],
|
|
161
|
+
permissionSites: mergeSites(older.permissionSites, newer.permissionSites),
|
|
141
162
|
roles: { ...older.roles, ...newer.roles },
|
|
142
163
|
roleSites: { ...older.roleSites, ...newer.roleSites },
|
|
143
164
|
tasks: [...tasks.values()],
|
|
144
165
|
catalogDeclarations: Math.max(older.catalogDeclarations, newer.catalogDeclarations),
|
|
166
|
+
// Last declaration wins, as `defineMeasurementActor` itself has it; "none" never erases one.
|
|
167
|
+
measurementActor: newer.measurementActor ?? older.measurementActor,
|
|
145
168
|
};
|
|
146
169
|
}
|
|
170
|
+
|
|
171
|
+
/** Per name, every module either snapshot saw declare it — the union the names themselves are. */
|
|
172
|
+
function mergeSites(
|
|
173
|
+
older: Readonly<Record<string, readonly string[]>>,
|
|
174
|
+
newer: Readonly<Record<string, readonly string[]>>,
|
|
175
|
+
): Readonly<Record<string, readonly string[]>> {
|
|
176
|
+
const merged = new Map<string, Set<string>>();
|
|
177
|
+
for (const side of [older, newer]) {
|
|
178
|
+
for (const [name, at] of Object.entries(side)) {
|
|
179
|
+
const into = merged.get(name) ?? new Set<string>();
|
|
180
|
+
for (const site of at) into.add(site);
|
|
181
|
+
merged.set(name, into);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return Object.fromEntries([...merged].map(([name, at]) => [name, [...at]]));
|
|
185
|
+
}
|
package/src/render-view.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// them. No browser, no server, no cast: a test asserts on markup and on visible text.
|
|
4
4
|
|
|
5
5
|
import type { Actor } from '@ultimat3/core';
|
|
6
|
-
import { assert,
|
|
6
|
+
import { assert, ctxOf, runWithContext } from '@ultimat3/core';
|
|
7
7
|
import type { IslandDirective, RouteConfig, RouteParams } from '@ultimat3/render';
|
|
8
8
|
|
|
9
9
|
/** What a component rendered. */
|
|
@@ -101,7 +101,7 @@ export async function renderRoute<TData>(
|
|
|
101
101
|
);
|
|
102
102
|
const url = request.url ?? DEFAULT_URL;
|
|
103
103
|
const ctx = { params: request.params ?? {}, url };
|
|
104
|
-
const islands = render.
|
|
104
|
+
const islands = render.islandCollector({ file: url, hydrate: config.hydrate });
|
|
105
105
|
|
|
106
106
|
const run = async (): Promise<RenderedRoute<TData>> => {
|
|
107
107
|
const data = await render.routeDataFor(config, ctx);
|
|
@@ -114,5 +114,5 @@ export async function renderRoute<TData>(
|
|
|
114
114
|
};
|
|
115
115
|
|
|
116
116
|
if (request.actor === undefined) return run();
|
|
117
|
-
return runWithContext(
|
|
117
|
+
return runWithContext(ctxOf({ actor: request.actor }), run);
|
|
118
118
|
}
|
package/src/sealed-network.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// Sealed network. Any egress a test did not explicitly mock or allow fails the test with the URL
|
|
2
2
|
// and the line that fixes it. A test that quietly reaches the internet is a test that fails in CI
|
|
3
|
-
// for reasons nobody can reproduce — so the default is "nothing gets out".
|
|
3
|
+
// for reasons nobody can reproduce — so the default is "nothing gets out". Three dials are sealed:
|
|
4
|
+
// `fetch` here, `WebSocket` and `Bun.connect` through `sealed-sockets.ts`, all behind one gate.
|
|
4
5
|
|
|
5
|
-
import { isSelfOrigin } from '@ultimat3/core';
|
|
6
|
+
import { classifyAddress, isSelfOrigin } from '@ultimat3/core';
|
|
6
7
|
import { NetworkOfflineError, NetworkRaceError, NetworkSealedError } from './errors';
|
|
8
|
+
import { installSocketSeal } from './sealed-sockets';
|
|
7
9
|
|
|
8
10
|
export type FetchLike = typeof globalThis.fetch;
|
|
9
11
|
|
|
@@ -24,6 +26,8 @@ interface SealState {
|
|
|
24
26
|
readonly mocks: MockRoute[];
|
|
25
27
|
readonly seen: string[];
|
|
26
28
|
original: FetchLike | undefined;
|
|
29
|
+
/** `installSocketSeal`'s undo, held beside `original` so one unseal restores all three dials. */
|
|
30
|
+
restoreSockets: (() => void) | undefined;
|
|
27
31
|
network: NetworkState;
|
|
28
32
|
}
|
|
29
33
|
|
|
@@ -32,11 +36,16 @@ const state: SealState = {
|
|
|
32
36
|
mocks: [],
|
|
33
37
|
seen: [],
|
|
34
38
|
original: undefined,
|
|
39
|
+
restoreSockets: undefined,
|
|
35
40
|
network: 'online',
|
|
36
41
|
};
|
|
37
42
|
|
|
38
43
|
const matches = (route: MockRoute, url: string): boolean => {
|
|
39
|
-
if (route.match instanceof RegExp)
|
|
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
|
+
}
|
|
40
49
|
if (route.match.endsWith('*')) return url.startsWith(route.match.slice(0, -1));
|
|
41
50
|
return route.match === url;
|
|
42
51
|
};
|
|
@@ -53,10 +62,44 @@ const methodOf = (input: RequestInfo | URL, init: RequestInit | undefined): stri
|
|
|
53
62
|
return 'GET';
|
|
54
63
|
};
|
|
55
64
|
|
|
56
|
-
/**
|
|
65
|
+
/**
|
|
66
|
+
* A raw socket to THIS machine is not egress, whichever port: it is how a test reaches the
|
|
67
|
+
* scripted server it `Bun.listen`ed and the compose services the live suites run against, none of
|
|
68
|
+
* which announce through `markListening`. What fails unreproducibly in CI is the internet, and a
|
|
69
|
+
* loopback dial with nothing behind it fails the same way on every machine.
|
|
70
|
+
*/
|
|
71
|
+
const isThisMachine = (hostname: string): boolean => {
|
|
72
|
+
if (hostname.toLowerCase() === 'localhost') return true;
|
|
73
|
+
const kind = classifyAddress(hostname);
|
|
74
|
+
return kind === 'loopback' || kind === 'unspecified';
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** The socket dials' gate: the same allow-list, offline state and record as `fetch`. No mocks — a
|
|
78
|
+
* socket test injects its transport (`connect`, `client`), it does not intercept the dial. */
|
|
79
|
+
const socketRefusal = (url: string, method: string): Error | undefined => {
|
|
80
|
+
state.seen.push(url);
|
|
81
|
+
let target: URL;
|
|
82
|
+
try {
|
|
83
|
+
target = new URL(url);
|
|
84
|
+
} catch {
|
|
85
|
+
return undefined; // Not a URL: the real constructor refuses it with its own SyntaxError.
|
|
86
|
+
}
|
|
87
|
+
// Offline before loopback, the order `fetch` checks in: offline cuts EVERY dial, this machine's
|
|
88
|
+
// included, or a socket would reach a host the same test's `fetch` is refused.
|
|
89
|
+
if (state.network !== 'online') {
|
|
90
|
+
return new NetworkOfflineError({ url, method, mode: state.network });
|
|
91
|
+
}
|
|
92
|
+
if (isThisMachine(target.hostname)) return undefined;
|
|
93
|
+
if (state.allowed.has(target.host)) return undefined;
|
|
94
|
+
return new NetworkSealedError({ url, method, allowed: [...state.allowed], transport: 'socket' });
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** Replace global fetch, `WebSocket` and `Bun.connect`. Idempotent: sealing twice keeps the one
|
|
98
|
+
* set of originals around. */
|
|
57
99
|
export function sealNetwork(): void {
|
|
58
100
|
if (state.original !== undefined) return;
|
|
59
101
|
state.original = globalThis.fetch;
|
|
102
|
+
state.restoreSockets = installSocketSeal(socketRefusal);
|
|
60
103
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
61
104
|
const url = urlOf(input);
|
|
62
105
|
state.seen.push(url);
|
|
@@ -94,13 +137,15 @@ export function unsealNetwork(): void {
|
|
|
94
137
|
if (state.original === undefined) return;
|
|
95
138
|
globalThis.fetch = state.original;
|
|
96
139
|
state.original = undefined;
|
|
140
|
+
state.restoreSockets?.();
|
|
141
|
+
state.restoreSockets = undefined;
|
|
97
142
|
}
|
|
98
143
|
|
|
99
144
|
/** Whether the patch is installed. The `network` fixture seals before going offline, so that
|
|
100
145
|
* `offline()` has teeth in a process that deliberately unsealed (`ULTIMATE_TEST_ALLOW_NET=1`). */
|
|
101
146
|
export const isNetworkSealed = (): boolean => state.original !== undefined;
|
|
102
147
|
|
|
103
|
-
/** The one writer of the offline gate — `
|
|
148
|
+
/** The one writer of the offline gate — `testNetwork()`. Never call it from a test body. */
|
|
104
149
|
export function setNetworkState(next: NetworkState): void {
|
|
105
150
|
state.network = next;
|
|
106
151
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// The socket half of the sealed network: `WebSocket` and `Bun.connect`, the two dials that are not
|
|
2
|
+
// `fetch`. Only the patching lives here; WHETHER a dial may leave is `sealed-network.ts`'s one
|
|
3
|
+
// gate, handed in, so fetch and sockets cannot disagree about the allow-list or the offline state.
|
|
4
|
+
|
|
5
|
+
/** A dial's verdict: the error to raise, or `undefined` to let it through to the real one. */
|
|
6
|
+
export type SocketGate = (url: string, method: 'WEBSOCKET' | 'CONNECT') => Error | undefined;
|
|
7
|
+
|
|
8
|
+
/** `Bun.connect`'s two option shapes, read structurally: TCP names a host, a unix socket a path. */
|
|
9
|
+
interface ConnectOptions {
|
|
10
|
+
readonly hostname?: string;
|
|
11
|
+
readonly port?: number;
|
|
12
|
+
readonly unix?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type Connect = (options: ConnectOptions) => Promise<unknown>;
|
|
16
|
+
|
|
17
|
+
/** A dial target as a URL, so the gate parses one shape. IPv6 literals need their brackets. */
|
|
18
|
+
const tcpUrl = (hostname: string, port: number | undefined): string => {
|
|
19
|
+
const host = hostname.includes(':') && !hostname.startsWith('[') ? `[${hostname}]` : hostname;
|
|
20
|
+
return `tcp://${host}:${port ?? 0}`;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Patch both dials and return the uninstaller, which puts back exactly what was there — the same
|
|
25
|
+
* originals, so `globalThis.WebSocket === original` holds again. Assigned through `Reflect.set`:
|
|
26
|
+
* `Bun.connect` is a writable data property, but the type declares it a namespace function.
|
|
27
|
+
*/
|
|
28
|
+
export function installSocketSeal(gate: SocketGate): () => void {
|
|
29
|
+
const OriginalWebSocket = globalThis.WebSocket;
|
|
30
|
+
const originalConnect = Reflect.get(Bun, 'connect') as Connect;
|
|
31
|
+
|
|
32
|
+
// A subclass, not a wrapper function: `instanceof WebSocket`, the static `OPEN`/`CLOSED` and
|
|
33
|
+
// every option a caller passes keep working. Code before `super()` may not touch `this`, and
|
|
34
|
+
// does not — the refusal is thrown before the native constructor ever dials.
|
|
35
|
+
class SealedWebSocket extends OriginalWebSocket {
|
|
36
|
+
constructor(url: string | URL, protocols?: string | string[]) {
|
|
37
|
+
const refusal = gate(String(url), 'WEBSOCKET');
|
|
38
|
+
if (refusal !== undefined) throw refusal;
|
|
39
|
+
super(url, protocols);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A promise either way, as the real `Bun.connect` answers a dial it cannot make. A unix socket is
|
|
44
|
+
// a path on this machine and is never egress.
|
|
45
|
+
const sealedConnect: Connect = (options) => {
|
|
46
|
+
if (options.unix === undefined && options.hostname !== undefined) {
|
|
47
|
+
const refusal = gate(tcpUrl(options.hostname, options.port), 'CONNECT');
|
|
48
|
+
if (refusal !== undefined) return Promise.reject(refusal);
|
|
49
|
+
}
|
|
50
|
+
return originalConnect(options);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
Reflect.set(globalThis, 'WebSocket', SealedWebSocket);
|
|
54
|
+
Reflect.set(Bun, 'connect', sealedConnect);
|
|
55
|
+
return () => {
|
|
56
|
+
Reflect.set(globalThis, 'WebSocket', OriginalWebSocket);
|
|
57
|
+
Reflect.set(Bun, 'connect', originalConnect);
|
|
58
|
+
};
|
|
59
|
+
}
|
package/src/template-db.ts
CHANGED
|
@@ -18,7 +18,7 @@ export interface TemplateDbConfig {
|
|
|
18
18
|
/** Connection URL with rights to CREATE DATABASE. Absent means "fall back to PGlite". */
|
|
19
19
|
readonly adminUrl?: string | undefined;
|
|
20
20
|
readonly templateName?: string;
|
|
21
|
-
/** Applied
|
|
21
|
+
/** Applied into the template under an advisory lock, which every worker's clone also holds. */
|
|
22
22
|
readonly migrate?: (url: string) => Promise<void>;
|
|
23
23
|
}
|
|
24
24
|
|
|
@@ -65,7 +65,7 @@ export function urlFor(adminUrl: string, database: string): string {
|
|
|
65
65
|
return url.toString();
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
export const
|
|
68
|
+
export const templateSql = (template: string): string =>
|
|
69
69
|
`CREATE DATABASE "${template}" TEMPLATE template0`;
|
|
70
70
|
|
|
71
71
|
export const cloneSql = (template: string, target: string): string =>
|
|
@@ -132,7 +132,7 @@ export async function acquireWorkerDatabase(
|
|
|
132
132
|
await admin.exec(lockSql(template));
|
|
133
133
|
try {
|
|
134
134
|
try {
|
|
135
|
-
await admin.exec(
|
|
135
|
+
await admin.exec(templateSql(template));
|
|
136
136
|
} catch (error) {
|
|
137
137
|
// Tolerated for the CREATE alone: "already exists" means another worker got here first, or
|
|
138
138
|
// a Postgres that outlives one run still holds last run's template.
|
|
@@ -144,11 +144,14 @@ export async function acquireWorkerDatabase(
|
|
|
144
144
|
// serialises them, so a failure here is a real failure and `alreadyExists` (a substring match
|
|
145
145
|
// on the message) would read `relation "x_jobs" already exists` as success.
|
|
146
146
|
if (config.migrate !== undefined) await config.migrate(urlFor(adminUrl, template));
|
|
147
|
+
// INSIDE the lock: `CREATE DATABASE … TEMPLATE` waits 5 s for every session on the template
|
|
148
|
+
// to leave, then refuses — and the next worker's migration opens one the moment the lock is
|
|
149
|
+
// free. Measured: a clone outside it failed whenever that migration ran past 5 s.
|
|
150
|
+
await admin.exec(dropSql(database));
|
|
151
|
+
await admin.exec(cloneSql(template, database));
|
|
147
152
|
} finally {
|
|
148
153
|
await admin.exec(unlockSql(template));
|
|
149
154
|
}
|
|
150
|
-
await admin.exec(dropSql(database));
|
|
151
|
-
await admin.exec(cloneSql(template, database));
|
|
152
155
|
} catch (error) {
|
|
153
156
|
await admin.close();
|
|
154
157
|
throw new TestDatabaseUnavailableError({
|
package/src/test-types.ts
CHANGED
|
@@ -79,8 +79,12 @@ export interface PageLike {
|
|
|
79
79
|
gotoStreamed(url: string): Promise<{ readonly html: string }>;
|
|
80
80
|
/** Called only by `x g route`'s generated e2e template — no driver runs it yet. */
|
|
81
81
|
reload(): Promise<unknown>;
|
|
82
|
-
/**
|
|
83
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Resolves once the service worker controls the page, so the offline assertions are not racy. A
|
|
84
|
+
* page the worker cannot claim — the first of a run, committed before it activated — is reloaded
|
|
85
|
+
* once (that navigation IS answered by the worker). `timeoutMs` raises this call's budget.
|
|
86
|
+
*/
|
|
87
|
+
waitForServiceWorker(options?: { readonly timeoutMs?: number | undefined }): Promise<void>;
|
|
84
88
|
/** Called only by `x g route`'s generated e2e template — no driver runs it yet. */
|
|
85
89
|
title(): Promise<string>;
|
|
86
90
|
url(): string;
|