@ultimat3/testing 24.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 (49) hide show
  1. package/CLAUDE.md +12 -12
  2. package/README.md +55 -18
  3. package/package.json +16 -16
  4. package/src/auth-request.ts +1 -1
  5. package/src/cdp-browser-lease.ts +112 -0
  6. package/src/cdp-browser.ts +42 -1
  7. package/src/cdp-connection.ts +18 -4
  8. package/src/cdp-e2e-session.ts +120 -12
  9. package/src/cdp-errors.ts +25 -9
  10. package/src/cdp-launch-attempt.ts +112 -80
  11. package/src/cdp-launch-candidates.ts +62 -0
  12. package/src/cdp-launch-profile.ts +106 -0
  13. package/src/cdp-launch-reap.ts +68 -0
  14. package/src/cdp-launch-wire.ts +171 -0
  15. package/src/cdp-launch.ts +47 -22
  16. package/src/define-island-states.ts +47 -8
  17. package/src/determinism.ts +2 -2
  18. package/src/e2e-app.ts +14 -1
  19. package/src/e2e-errors.ts +14 -3
  20. package/src/e2e-locale.ts +12 -21
  21. package/src/e2e-page.ts +12 -26
  22. package/src/e2e-run.ts +21 -11
  23. package/src/e2e-service-worker.ts +92 -0
  24. package/src/e2e-spawn.ts +57 -9
  25. package/src/errors.ts +20 -65
  26. package/src/fixture-clock.ts +1 -1
  27. package/src/fixture-island.ts +34 -55
  28. package/src/fixture-jobs.ts +7 -7
  29. package/src/fixture-mail.ts +3 -3
  30. package/src/fixture-network.ts +1 -1
  31. package/src/fixture-statements.ts +1 -1
  32. package/src/fixture-subscribe.ts +3 -3
  33. package/src/framework-fixtures.ts +13 -13
  34. package/src/index.ts +17 -10
  35. package/src/island-dom.ts +49 -9
  36. package/src/island-globals.ts +73 -0
  37. package/src/island-scratch.ts +67 -3
  38. package/src/island-state-errors.ts +16 -0
  39. package/src/island-states.ts +23 -0
  40. package/src/live-node.ts +4 -4
  41. package/src/quiet-logs.ts +1 -1
  42. package/src/registry-leak-error.ts +81 -0
  43. package/src/registry-leak-guard.ts +4 -3
  44. package/src/registry-snapshot.ts +19 -1
  45. package/src/render-view.ts +3 -3
  46. package/src/sealed-network.ts +45 -4
  47. package/src/sealed-sockets.ts +59 -0
  48. package/src/template-db.ts +2 -2
  49. package/src/test-types.ts +6 -2
@@ -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,
@@ -53,6 +59,13 @@ export interface ProcessRegistrySnapshot {
53
59
  readonly tasks: readonly TaskHandle[];
54
60
  /** `defineCatalogs()` calls so far — see `@ultimat3/i18n`'s `catalogDeclarationCount`. */
55
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;
56
69
  }
57
70
 
58
71
  export function captureProcessRegistries(): ProcessRegistrySnapshot {
@@ -65,6 +78,7 @@ export function captureProcessRegistries(): ProcessRegistrySnapshot {
65
78
  roleSites: roleDeclarationSites(),
66
79
  tasks: registeredTasks(),
67
80
  catalogDeclarations: catalogDeclarationCount(),
81
+ measurementActor: declaredMeasurementActor(),
68
82
  };
69
83
  }
70
84
 
@@ -81,6 +95,8 @@ export function restoreProcessRegistries(snapshot: ProcessRegistrySnapshot): voi
81
95
  restorePermissions(snapshot.permissions, snapshot.permissionSites);
82
96
  restoreRoles(snapshot.roles, snapshot.roleSites);
83
97
  restoreTasks(snapshot.tasks);
98
+ if (snapshot.measurementActor === undefined) resetMeasurementActor();
99
+ else defineMeasurementActor(snapshot.measurementActor);
84
100
  }
85
101
 
86
102
  /**
@@ -147,6 +163,8 @@ export function mergeSnapshots(
147
163
  roleSites: { ...older.roleSites, ...newer.roleSites },
148
164
  tasks: [...tasks.values()],
149
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,
150
168
  };
151
169
  }
152
170
 
@@ -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,6 +36,7 @@ const state: SealState = {
32
36
  mocks: [],
33
37
  seen: [],
34
38
  original: undefined,
39
+ restoreSockets: undefined,
35
40
  network: 'online',
36
41
  };
37
42
 
@@ -57,10 +62,44 @@ const methodOf = (input: RequestInfo | URL, init: RequestInit | undefined): stri
57
62
  return 'GET';
58
63
  };
59
64
 
60
- /** 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. */
61
99
  export function sealNetwork(): void {
62
100
  if (state.original !== undefined) return;
63
101
  state.original = globalThis.fetch;
102
+ state.restoreSockets = installSocketSeal(socketRefusal);
64
103
  globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
65
104
  const url = urlOf(input);
66
105
  state.seen.push(url);
@@ -98,13 +137,15 @@ export function unsealNetwork(): void {
98
137
  if (state.original === undefined) return;
99
138
  globalThis.fetch = state.original;
100
139
  state.original = undefined;
140
+ state.restoreSockets?.();
141
+ state.restoreSockets = undefined;
101
142
  }
102
143
 
103
144
  /** Whether the patch is installed. The `network` fixture seals before going offline, so that
104
145
  * `offline()` has teeth in a process that deliberately unsealed (`ULTIMATE_TEST_ALLOW_NET=1`). */
105
146
  export const isNetworkSealed = (): boolean => state.original !== undefined;
106
147
 
107
- /** 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. */
108
149
  export function setNetworkState(next: NetworkState): void {
109
150
  state.network = next;
110
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
+ }
@@ -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.
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;