@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.
- package/CLAUDE.md +12 -12
- package/README.md +55 -18
- package/package.json +16 -16
- package/src/auth-request.ts +1 -1
- package/src/cdp-browser-lease.ts +112 -0
- package/src/cdp-browser.ts +42 -1
- package/src/cdp-connection.ts +18 -4
- package/src/cdp-e2e-session.ts +120 -12
- package/src/cdp-errors.ts +25 -9
- package/src/cdp-launch-attempt.ts +112 -80
- 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 +47 -22
- package/src/define-island-states.ts +47 -8
- package/src/determinism.ts +2 -2
- package/src/e2e-app.ts +14 -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 +21 -11
- package/src/e2e-service-worker.ts +92 -0
- package/src/e2e-spawn.ts +57 -9
- package/src/errors.ts +20 -65
- package/src/fixture-clock.ts +1 -1
- package/src/fixture-island.ts +34 -55
- package/src/fixture-jobs.ts +7 -7
- 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/index.ts +17 -10
- package/src/island-dom.ts +49 -9
- package/src/island-globals.ts +73 -0
- package/src/island-scratch.ts +67 -3
- package/src/island-state-errors.ts +16 -0
- package/src/island-states.ts +23 -0
- package/src/live-node.ts +4 -4
- package/src/quiet-logs.ts +1 -1
- package/src/registry-leak-error.ts +81 -0
- package/src/registry-leak-guard.ts +4 -3
- package/src/registry-snapshot.ts +19 -1
- package/src/render-view.ts +3 -3
- package/src/sealed-network.ts +45 -4
- package/src/sealed-sockets.ts +59 -0
- package/src/template-db.ts +2 -2
- package/src/test-types.ts +6 -2
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,
|
|
@@ -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
|
|
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,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
|
-
/**
|
|
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 — `
|
|
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
|
+
}
|
package/src/template-db.ts
CHANGED
|
@@ -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.
|
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;
|