@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.
Files changed (60) hide show
  1. package/CLAUDE.md +20 -20
  2. package/README.md +95 -13
  3. package/package.json +16 -16
  4. package/src/auth-request.ts +1 -1
  5. package/src/budget-store-conformance.ts +81 -0
  6. package/src/cdp-browser-lease.ts +112 -0
  7. package/src/cdp-browser.ts +47 -11
  8. package/src/cdp-connection.ts +41 -10
  9. package/src/cdp-e2e-session.ts +130 -13
  10. package/src/cdp-errors.ts +129 -8
  11. package/src/cdp-launch-attempt.ts +224 -0
  12. package/src/cdp-launch-candidates.ts +62 -0
  13. package/src/cdp-launch-profile.ts +106 -0
  14. package/src/cdp-launch-reap.ts +68 -0
  15. package/src/cdp-launch-wire.ts +171 -0
  16. package/src/cdp-launch.ts +92 -140
  17. package/src/cdp-offline-script.ts +33 -3
  18. package/src/cdp-wire-watch.ts +113 -0
  19. package/src/define-island-states.ts +47 -8
  20. package/src/determinism.ts +35 -6
  21. package/src/e2e-app.ts +17 -1
  22. package/src/e2e-errors.ts +14 -3
  23. package/src/e2e-locale.ts +12 -21
  24. package/src/e2e-page.ts +12 -26
  25. package/src/e2e-run.ts +22 -12
  26. package/src/e2e-service-worker.ts +92 -0
  27. package/src/e2e-spawn.ts +58 -17
  28. package/src/errors.ts +34 -66
  29. package/src/factories.ts +4 -6
  30. package/src/file-boundary.ts +12 -7
  31. package/src/fixture-clock.ts +1 -1
  32. package/src/fixture-island.ts +50 -98
  33. package/src/fixture-jobs.ts +16 -12
  34. package/src/fixture-mail.ts +3 -3
  35. package/src/fixture-network.ts +1 -1
  36. package/src/fixture-statements.ts +1 -1
  37. package/src/fixture-subscribe.ts +3 -3
  38. package/src/framework-fixtures.ts +13 -13
  39. package/src/free-port.ts +46 -0
  40. package/src/harness.ts +2 -1
  41. package/src/index.ts +29 -10
  42. package/src/island-dom.ts +57 -13
  43. package/src/island-globals.ts +73 -0
  44. package/src/island-html-entities.ts +27 -0
  45. package/src/island-scratch.ts +105 -0
  46. package/src/island-state-errors.ts +16 -0
  47. package/src/island-states.ts +23 -0
  48. package/src/job-driver-checks.ts +177 -0
  49. package/src/job-driver-conformance.ts +21 -0
  50. package/src/live-node.ts +6 -4
  51. package/src/preload.ts +2 -8
  52. package/src/quiet-logs.ts +1 -1
  53. package/src/registry-leak-error.ts +81 -0
  54. package/src/registry-leak-guard.ts +7 -4
  55. package/src/registry-snapshot.ts +41 -2
  56. package/src/render-view.ts +3 -3
  57. package/src/sealed-network.ts +50 -5
  58. package/src/sealed-sockets.ts +59 -0
  59. package/src/template-db.ts +8 -5
  60. 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 { disposeLiveIslands } from './fixture-island';
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['ULTIMATE_TEST_ISOLATED'] !== '1') {
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 `createLogger({ level, writer })` its own writer, or
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 FOUR registries out of the set that has one:
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
- * | actions | `resetRegistry` | `@ultimat3/action` | **no** |
45
- * | queries | `resetRegistry` | `@ultimat3/query` | **no** |
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
  });
@@ -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
- // FOUR registries, out of roughly nine that publish a `clear*`/`reset*`. The missing ones and what
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
+ }
@@ -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, createContext, runWithContext } from '@ultimat3/core';
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.createIslandCollector({ file: url, hydrate: config.hydrate });
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(createContext({ actor: request.actor }), run);
117
+ return runWithContext(ctxOf({ actor: request.actor }), run);
118
118
  }
@@ -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) return route.match.test(url);
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
- /** Replace global fetch. Idempotent: sealing twice keeps the one original around. */
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 — `createTestNetwork()`. Never call it from a test body. */
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
+ }
@@ -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 once, into the template, under an advisory lock. */
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 createTemplateSql = (template: string): string =>
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(createTemplateSql(template));
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
- /** Resolves once the service worker controls the page, so the offline assertions are not racy. */
83
- waitForServiceWorker(): Promise<void>;
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;