@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/e2e-errors.ts
CHANGED
|
@@ -95,11 +95,22 @@ export class E2eLocatorAmbiguousError extends UltimateError {
|
|
|
95
95
|
* test that hangs, and CI then reports a runner timeout with no assertion anywhere in it.
|
|
96
96
|
*/
|
|
97
97
|
export class E2eServiceWorkerAbsentError extends UltimateError {
|
|
98
|
-
constructor(input: {
|
|
98
|
+
constructor(input: {
|
|
99
|
+
readonly url: string;
|
|
100
|
+
readonly timeoutMs: number;
|
|
101
|
+
/**
|
|
102
|
+
* True when a worker was ACTIVE and the page was reloaded once for it to answer — a first
|
|
103
|
+
* page `clients.claim()` missed (#678) — and the reload was not controlled either.
|
|
104
|
+
*/
|
|
105
|
+
readonly reloaded?: boolean | undefined;
|
|
106
|
+
}) {
|
|
99
107
|
super({
|
|
100
108
|
code: 'X_E2E_SERVICE_WORKER_ABSENT',
|
|
101
|
-
cause:
|
|
102
|
-
|
|
109
|
+
cause:
|
|
110
|
+
input.reloaded === true
|
|
111
|
+
? `a service worker is active for ${renderCauseValue(input.url)} but did not control it, even reloaded once, within ${String(input.timeoutMs)}ms`
|
|
112
|
+
: `no service worker took control of ${renderCauseValue(input.url)} within ${String(input.timeoutMs)}ms`,
|
|
113
|
+
fix: `x build --target static — the worker is generated by the pwa build; then confirm the route at ${renderFixLiteral(input.url, URL_PLACEHOLDER)} registers it, or raise this call's budget: await page.waitForServiceWorker({ timeoutMs: 30_000 })`,
|
|
103
114
|
});
|
|
104
115
|
}
|
|
105
116
|
}
|
package/src/e2e-locale.ts
CHANGED
|
@@ -1,30 +1,21 @@
|
|
|
1
|
-
// The locale an e2e run's browser asks for: the app's
|
|
2
|
-
// the
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
import { join } from 'node:path'; // why: Bun ships no path join.
|
|
6
|
-
|
|
7
|
-
/** The app root's config module and the export holding the config — `x new` writes both. */
|
|
8
|
-
const APP_CONFIG_FILE = 'app.config.ts';
|
|
9
|
-
const APP_CONFIG_EXPORT = 'config';
|
|
1
|
+
// The locale an e2e run's browser asks for: the default the app's `defineCatalogs()` declared —
|
|
2
|
+
// the one place an app names its locales since 25.0.0 (`app.config.ts`'s `defaultLocale` and
|
|
3
|
+
// `locales` keys were deleted). Read through `@ultimat3/i18n/app-catalogs`, the one reader the CLI
|
|
4
|
+
// shares; what is this file's own is the answer to a module that will not load.
|
|
10
5
|
|
|
11
6
|
/**
|
|
12
|
-
* The app's
|
|
13
|
-
* Never a guessed `en`:
|
|
14
|
-
*
|
|
15
|
-
* The import runs the
|
|
7
|
+
* The app's default locale, or `undefined` when it cannot be read — and then nothing is pinned.
|
|
8
|
+
* Never a guessed `en`: `loadAppCatalogs` answers only when a `defineCatalogs()` was declared.
|
|
9
|
+
* Pinning the wrong language would make every run photograph and assert on a page no visitor at
|
|
10
|
+
* the unprefixed URL sees. The import runs the app's module, which may validate an environment
|
|
11
|
+
* this process lacks — so a module that throws is "nothing pinned", never a failed run.
|
|
16
12
|
*/
|
|
17
13
|
export async function e2eDefaultLocale(root: string): Promise<string | undefined> {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
let module: Record<string, unknown>;
|
|
14
|
+
// Dynamic, like every non-core dependency of this package: only the e2e preload pays for it.
|
|
15
|
+
const { loadAppCatalogs } = await import('@ultimat3/i18n/app-catalogs');
|
|
21
16
|
try {
|
|
22
|
-
|
|
17
|
+
return (await loadAppCatalogs(root))?.defaultLocale;
|
|
23
18
|
} catch {
|
|
24
19
|
return undefined;
|
|
25
20
|
}
|
|
26
|
-
const config = Object.hasOwn(module, APP_CONFIG_EXPORT) ? module[APP_CONFIG_EXPORT] : undefined;
|
|
27
|
-
if (typeof config !== 'object' || config === null) return undefined;
|
|
28
|
-
const locale = (config as Record<string, unknown>)['defaultLocale'];
|
|
29
|
-
return typeof locale === 'string' && locale !== '' ? locale : undefined;
|
|
30
21
|
}
|
package/src/e2e-page.ts
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
// may import the other — so the join is here, in the one package allowed to know about both.
|
|
4
4
|
|
|
5
5
|
import { finiteCount } from '@ultimat3/core';
|
|
6
|
-
import { E2eServiceWorkerAbsentError } from './e2e-errors';
|
|
7
6
|
import { evaluateClosure } from './e2e-evaluate';
|
|
8
7
|
import { e2eLocator } from './e2e-locator';
|
|
9
8
|
import type { E2eSelection } from './e2e-selection';
|
|
9
|
+
import { callBudget, waitForServiceWorker } from './e2e-service-worker';
|
|
10
10
|
import type { LocatorLike, PageLike } from './test-types';
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -39,7 +39,10 @@ export interface E2ePageOptions {
|
|
|
39
39
|
readonly baseUrl: string;
|
|
40
40
|
/** Per-navigation deadline, handed to the driver rather than enforced here. */
|
|
41
41
|
readonly timeoutMs?: number | undefined;
|
|
42
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* How long `waitForServiceWorker()` waits before refusing, the handover reload included. Bounded
|
|
44
|
+
* IN THE PAGE. One test raises its own with `waitForServiceWorker({ timeoutMs })`.
|
|
45
|
+
*/
|
|
43
46
|
readonly serviceWorkerTimeoutMs?: number | undefined;
|
|
44
47
|
}
|
|
45
48
|
|
|
@@ -77,24 +80,6 @@ const firstFlushExpression = (url: string): string =>
|
|
|
77
80
|
.then((chunk) => { reader.cancel().catch(() => {}); return chunk; }); })
|
|
78
81
|
.then((chunk) => JSON.stringify({ html: new TextDecoder().decode(chunk.value || new Uint8Array()) })))()`;
|
|
79
82
|
|
|
80
|
-
/**
|
|
81
|
-
* `ready` alone is not control: a first load activates a worker that is not yet the page's
|
|
82
|
-
* controller, and an offline assertion made in that window tests nothing. So this waits for
|
|
83
|
-
* `controllerchange` too — an EVENT, not a poll, so the harness still has exactly one retry loop.
|
|
84
|
-
*/
|
|
85
|
-
const serviceWorkerExpression = (timeoutMs: number): string =>
|
|
86
|
-
`(() => {
|
|
87
|
-
if (!navigator.serviceWorker) return JSON.stringify({ controlled: false });
|
|
88
|
-
const controlled = new Promise((resolve) => {
|
|
89
|
-
if (navigator.serviceWorker.controller) { resolve(true); return; }
|
|
90
|
-
navigator.serviceWorker.addEventListener('controllerchange', () => resolve(true), { once: true });
|
|
91
|
-
});
|
|
92
|
-
const deadline = new Promise((resolve) => setTimeout(() => resolve(false), ${String(timeoutMs)}));
|
|
93
|
-
return Promise.race([navigator.serviceWorker.ready.then(() => controlled), deadline])
|
|
94
|
-
.catch(() => false)
|
|
95
|
-
.then((ok) => JSON.stringify({ controlled: ok === true }));
|
|
96
|
-
})()`;
|
|
97
|
-
|
|
98
83
|
/**
|
|
99
84
|
* The adapter. Every member re-reads the live page: a `PageLike` handed to a test outlives every
|
|
100
85
|
* navigation the test makes, so nothing here may capture a URL, a document or an element.
|
|
@@ -132,12 +117,13 @@ export function e2ePage(options: E2ePageOptions): PageLike {
|
|
|
132
117
|
const html = readField(await page.evaluate(firstFlushExpression(target)), 'html');
|
|
133
118
|
return { html: typeof html === 'string' ? html : '' };
|
|
134
119
|
},
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
120
|
+
// The handover is `e2e-service-worker.ts`'s: an unclaimed first page is reloaded once (#678).
|
|
121
|
+
waitForServiceWorker: async (waitOptions) =>
|
|
122
|
+
waitForServiceWorker({
|
|
123
|
+
page,
|
|
124
|
+
timeoutMs: callBudget(swTimeout, waitOptions?.timeoutMs),
|
|
125
|
+
navigationTimeoutMs: timeout,
|
|
126
|
+
}),
|
|
141
127
|
evaluate: <T>(fn: () => T): Promise<T> => evaluateClosure(page, fn) as unknown as Promise<T>,
|
|
142
128
|
locator: (selector) => locate({ kind: 'css', selector, first: false }),
|
|
143
129
|
getByRole: (role, roleOptions) =>
|
package/src/e2e-run.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
import { finiteCount } from '@ultimat3/core';
|
|
7
7
|
import type { E2eBrowser } from './cdp-browser';
|
|
8
|
+
import { E2E_BROWSER_CLOSE_MS, E2E_BROWSER_OPEN_MS } from './cdp-browser';
|
|
8
9
|
import type { E2eApp } from './e2e-app';
|
|
10
|
+
import { E2E_APP_STOP_MS } from './e2e-app';
|
|
9
11
|
import { e2eBrowser, publishE2eRun, republishE2eBrowser } from './e2e-browser-handle';
|
|
10
12
|
import type { E2eDriverOptions } from './e2e-driver';
|
|
11
13
|
import type { E2eBrowserPage } from './e2e-page';
|
|
@@ -35,8 +37,9 @@ export interface E2eRunDeps {
|
|
|
35
37
|
readonly app: E2eApp;
|
|
36
38
|
readonly openBrowser: () => Promise<E2eBrowser>;
|
|
37
39
|
readonly install: (options: E2eDriverOptions) => () => void;
|
|
38
|
-
|
|
39
|
-
readonly
|
|
40
|
+
/** Bun's, in the preload: the second argument is the hook's deadline, never left to the 5 s default. */
|
|
41
|
+
readonly beforeEach: (hook: () => Promise<void>, timeoutMs: number) => void;
|
|
42
|
+
readonly afterAll: (hook: () => Promise<void>, timeoutMs: number) => void;
|
|
40
43
|
readonly probeMs?: number | undefined;
|
|
41
44
|
}
|
|
42
45
|
|
|
@@ -74,19 +77,26 @@ export async function startE2eRun(deps: E2eRunDeps): Promise<void> {
|
|
|
74
77
|
// call (run 8). So each test starts with a short probe, and a browser that fails it — or one a
|
|
75
78
|
// deploy ran under — is closed and relaunched: the app is untouched, and the test gets a fresh
|
|
76
79
|
// profile, a fresh SharedWorker and a fresh tab on the same origin.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
//
|
|
81
|
+
// Deadlines are the designed lengths of the work, because Bun's 5 s default killed both hooks
|
|
82
|
+
// mid-work: a relaunch cut short left its open in flight, assigned later over the next one's
|
|
83
|
+
// browser and never closed; a close cut short as the run exited left the profile half-removed.
|
|
84
|
+
deps.beforeEach(
|
|
85
|
+
async () => {
|
|
86
|
+
const alive = !deployed && (await answersWithin(e2eBrowser().page, probeMs));
|
|
87
|
+
if (alive) return;
|
|
88
|
+
deployed = false;
|
|
89
|
+
await browser.close();
|
|
90
|
+
browser = await deps.openBrowser();
|
|
91
|
+
republishE2eBrowser(browser);
|
|
92
|
+
},
|
|
93
|
+
probeMs + E2E_BROWSER_CLOSE_MS + E2E_BROWSER_OPEN_MS,
|
|
94
|
+
);
|
|
85
95
|
|
|
86
96
|
deps.afterAll(async () => {
|
|
87
97
|
setFailureContext(undefined);
|
|
88
98
|
uninstall();
|
|
89
|
-
browser.close();
|
|
99
|
+
await browser.close();
|
|
90
100
|
await app.stop();
|
|
91
|
-
});
|
|
101
|
+
}, E2E_BROWSER_CLOSE_MS + E2E_APP_STOP_MS);
|
|
92
102
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Single responsibility: `waitForServiceWorker()` — wait for the worker to control the page, and
|
|
2
|
+
// hand the page over when it cannot claim it. The FIRST page of a run registers its worker on
|
|
3
|
+
// `load`, so it is committed before the worker activates, and `clients.claim()` can miss it: `ready`
|
|
4
|
+
// resolves, no `controllerchange` ever fires, and waiting longer waits for nothing (#678).
|
|
5
|
+
|
|
6
|
+
import { finiteCount } from '@ultimat3/core';
|
|
7
|
+
import { E2eServiceWorkerAbsentError } from './e2e-errors';
|
|
8
|
+
import type { E2eBrowserPage } from './e2e-page';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* How long a page whose worker is ACTIVE waits to be claimed before it is reloaded. Activation
|
|
12
|
+
* sets `registration.active` — which resolves `ready` — before the activate handler reaches its
|
|
13
|
+
* `clients.claim()`, so the claim trails `ready` by the cache cleanup in between. A second is
|
|
14
|
+
* that gap with room; past it, the reload is cheaper than any further wait.
|
|
15
|
+
*/
|
|
16
|
+
export const CLAIM_GRACE_MS = 1_000;
|
|
17
|
+
|
|
18
|
+
/** What the page answers. `unclaimed`: an active worker, and a document it does not control. */
|
|
19
|
+
export type ServiceWorkerState = 'controlled' | 'unclaimed' | 'absent';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The in-page half: `ready`, then the claim — an EVENT, not a poll, so the harness keeps exactly
|
|
23
|
+
* one retry loop. Bounded IN THE PAGE by `timeoutMs`: an unbounded wait is a hung test, and CI then
|
|
24
|
+
* reports a runner timeout with no assertion anywhere in it.
|
|
25
|
+
*/
|
|
26
|
+
export const serviceWorkerExpression = (timeoutMs: number): string =>
|
|
27
|
+
`(() => {
|
|
28
|
+
const sw = navigator.serviceWorker;
|
|
29
|
+
if (!sw) return JSON.stringify({ state: 'absent' });
|
|
30
|
+
const claimed = new Promise((resolve) => {
|
|
31
|
+
if (sw.controller) { resolve('controlled'); return; }
|
|
32
|
+
sw.addEventListener('controllerchange', () => resolve('controlled'), { once: true });
|
|
33
|
+
});
|
|
34
|
+
const grace = () => new Promise((resolve) =>
|
|
35
|
+
setTimeout(() => resolve(sw.controller ? 'controlled' : 'unclaimed'), ${String(CLAIM_GRACE_MS)}));
|
|
36
|
+
const deadline = new Promise((resolve) => setTimeout(() => resolve('absent'), ${String(timeoutMs)}));
|
|
37
|
+
return Promise.race([sw.ready.then(() => Promise.race([claimed, grace()])), deadline])
|
|
38
|
+
.catch(() => 'absent')
|
|
39
|
+
.then((state) => JSON.stringify({ state }));
|
|
40
|
+
})()`;
|
|
41
|
+
|
|
42
|
+
const stateOf = (raw: unknown): ServiceWorkerState | undefined => {
|
|
43
|
+
const decoded = typeof raw === 'string' ? (JSON.parse(raw) as unknown) : raw;
|
|
44
|
+
const state =
|
|
45
|
+
typeof decoded === 'object' && decoded !== null
|
|
46
|
+
? (decoded as Record<string, unknown>)['state']
|
|
47
|
+
: undefined;
|
|
48
|
+
return state === 'controlled' || state === 'unclaimed' || state === 'absent' ? state : undefined;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The URL to reload, fragment dropped: a navigation to the same URL plus a `#fragment` is a
|
|
53
|
+
* same-document navigation in Chromium, which scrolls and never replaces the uncontrolled
|
|
54
|
+
* document — the one thing the handover reload is for.
|
|
55
|
+
*/
|
|
56
|
+
const withoutFragment = (href: string): string => {
|
|
57
|
+
const url = new URL(href);
|
|
58
|
+
url.hash = '';
|
|
59
|
+
return url.href;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export interface ServiceWorkerWait {
|
|
63
|
+
readonly page: E2eBrowserPage;
|
|
64
|
+
/** The whole budget, both checks and the reload between them. Screened by the caller. */
|
|
65
|
+
readonly timeoutMs: number;
|
|
66
|
+
/** The per-navigation deadline the reload is handed. */
|
|
67
|
+
readonly navigationTimeoutMs: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Wait for control; an unclaimed page is reloaded ONCE — that navigation is answered by the active
|
|
72
|
+
* worker, which is what control means — then asked again with what is left of the budget. A page
|
|
73
|
+
* with no worker at all is refused without a reload: the same request would answer the same.
|
|
74
|
+
*/
|
|
75
|
+
export async function waitForServiceWorker(wait: ServiceWorkerWait): Promise<void> {
|
|
76
|
+
const { page, timeoutMs } = wait;
|
|
77
|
+
const began = performance.now();
|
|
78
|
+
const first = stateOf(await page.evaluate(serviceWorkerExpression(timeoutMs)));
|
|
79
|
+
if (first === 'controlled') return;
|
|
80
|
+
if (first === 'unclaimed') {
|
|
81
|
+
await page.goto(withoutFragment(page.url()), { timeout: wait.navigationTimeoutMs });
|
|
82
|
+
const left = Math.max(CLAIM_GRACE_MS, Math.round(timeoutMs - (performance.now() - began)));
|
|
83
|
+
const second = stateOf(await page.evaluate(serviceWorkerExpression(left)));
|
|
84
|
+
if (second === 'controlled') return;
|
|
85
|
+
throw new E2eServiceWorkerAbsentError({ url: page.url(), timeoutMs, reloaded: true });
|
|
86
|
+
}
|
|
87
|
+
throw new E2eServiceWorkerAbsentError({ url: page.url(), timeoutMs, reloaded: false });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A per-call budget, screened where it is read — it is interpolated into the in-page source. */
|
|
91
|
+
export const callBudget = (fallback: number, timeoutMs: number | undefined): number =>
|
|
92
|
+
timeoutMs === undefined ? fallback : finiteCount('waitForServiceWorker', 'timeoutMs', timeoutMs);
|
package/src/e2e-spawn.ts
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
import { get } from 'node:http';
|
|
9
9
|
// why: Bun exposes no path API — the CLI's bin is joined onto its package directory.
|
|
10
10
|
import { dirname, join } from 'node:path';
|
|
11
|
-
import { assert } from '@ultimat3/core';
|
|
11
|
+
import { assert, DRAIN_DEADLINE_DEFAULT_MS, READINESS_GRACE_DEFAULT_MS } from '@ultimat3/core';
|
|
12
12
|
import { E2eAppFailedError } from './e2e-errors';
|
|
13
|
+
import { freePort } from './free-port';
|
|
13
14
|
|
|
14
15
|
/** `x dev` (sync included), or the production entry `apps/web/server.ts` under `ROLE=web`. */
|
|
15
16
|
export type E2eAppMode = 'dev' | 'serve';
|
|
@@ -42,6 +43,29 @@ export async function xBin(root: string): Promise<string> {
|
|
|
42
43
|
}
|
|
43
44
|
const POLL_MS = 250;
|
|
44
45
|
|
|
46
|
+
/**
|
|
47
|
+
* How long one `/readyz` probe may hang before it counts as "not yet": an app that accepted the
|
|
48
|
+
* connection mid-boot and has not answered. The readiness deadline can overshoot by one of these.
|
|
49
|
+
*/
|
|
50
|
+
export const READY_PROBE_MS = POLL_MS * 4;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* What a SIGTERM'd app is given before it is killed outright: its own designed drain at the
|
|
54
|
+
* defaults — the readiness grace a deployed app holds, then `drain.deadlineMs` — because a stop
|
|
55
|
+
* shorter than the drain kills an app that was doing what it was told.
|
|
56
|
+
*/
|
|
57
|
+
const TERM_GRACE_MS = READINESS_GRACE_DEFAULT_MS + DRAIN_DEADLINE_DEFAULT_MS;
|
|
58
|
+
|
|
59
|
+
/** After SIGKILL: the kernel's to deliver, bounded for a container whose init never reaps. */
|
|
60
|
+
const KILL_GRACE_MS = 5_000;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The longest `stop()` is DESIGNED to take: the drain, then the kill. THE deadline a hook that
|
|
64
|
+
* stops an e2e app derives from — unbounded, a wedged app held the hook until Bun killed it, and
|
|
65
|
+
* the browser close behind it in the same hook never ran.
|
|
66
|
+
*/
|
|
67
|
+
export const E2E_APP_STOP_MS = TERM_GRACE_MS + KILL_GRACE_MS;
|
|
68
|
+
|
|
45
69
|
export interface SpawnedE2eApp {
|
|
46
70
|
/** `http://localhost:<port>`, no trailing slash. */
|
|
47
71
|
readonly base: string;
|
|
@@ -62,24 +86,24 @@ export interface SpawnE2eAppOptions {
|
|
|
62
86
|
readonly readyTimeoutMs: number;
|
|
63
87
|
/** `xBin(root)`, when the caller already resolved it; resolved here otherwise, in `dev` mode only. */
|
|
64
88
|
readonly bin?: string | undefined;
|
|
89
|
+
/** How long a SIGTERM'd app has before SIGKILL. Defaults to the app's own drain at its defaults. */
|
|
90
|
+
readonly termGraceMs?: number | undefined;
|
|
65
91
|
}
|
|
66
92
|
|
|
67
|
-
/** A port nothing holds right now, asked of the OS and handed to the child. */
|
|
68
|
-
const freePort = (): number => {
|
|
69
|
-
const probe = Bun.serve({ port: 0, fetch: () => new Response() });
|
|
70
|
-
const port = probe.port ?? 0;
|
|
71
|
-
probe.stop(true);
|
|
72
|
-
return port;
|
|
73
|
-
};
|
|
74
|
-
|
|
75
93
|
/**
|
|
76
94
|
* This process's environment minus what makes the child a TEST process. Spawned from `bun test`,
|
|
77
95
|
* `NODE_ENV=test` rode along and the app resolved its environment as `test` — so a development-only
|
|
78
96
|
* seam (the demo viewer an app installs instead of a sign-in route) was off and every page 401'd.
|
|
79
97
|
* The app under e2e is a development app unless the caller's `env` says otherwise.
|
|
98
|
+
*
|
|
99
|
+
* Minus the two bindings `ULTIMATE_STATE_DIR` relocates, too — the database and the disk. The
|
|
100
|
+
* harness boots on a throwaway state directory, and a developer's exported `DATABASE_URL` (their
|
|
101
|
+
* integration-test Postgres) turned that into a reset of a database the run does not own: `x db
|
|
102
|
+
* reset` refused it, and the e2e step went red for an environment reason (#674). A run that WANTS
|
|
103
|
+
* an external one passes it in `startE2eApp({ env })`, which is spread after this.
|
|
80
104
|
*/
|
|
81
105
|
export const inherited = (): Record<string, string | undefined> => {
|
|
82
|
-
const { NODE_ENV: _node, ...rest } = Bun.env;
|
|
106
|
+
const { NODE_ENV: _node, DATABASE_URL: _db, S3_ENDPOINT: _disk, ...rest } = Bun.env;
|
|
83
107
|
return { ...rest, ULTIMATE_ENV: 'development' };
|
|
84
108
|
};
|
|
85
109
|
|
|
@@ -114,14 +138,17 @@ export async function spawnE2eApp(options: SpawnE2eAppOptions): Promise<SpawnedE
|
|
|
114
138
|
stdout: 'pipe',
|
|
115
139
|
stderr: 'pipe',
|
|
116
140
|
});
|
|
141
|
+
const termGraceMs = options.termGraceMs ?? TERM_GRACE_MS;
|
|
142
|
+
// On the monotonic clock: counted in polls, every probe an app wedged mid-boot held for its
|
|
143
|
+
// whole `READY_PROBE_MS` stretched a 90 s deadline to 450 s, past any hook derived from it.
|
|
117
144
|
const ready = async (child: ReturnType<typeof spawnApp>, tail: () => string): Promise<void> => {
|
|
118
|
-
|
|
145
|
+
const until = performance.now() + deadline;
|
|
146
|
+
while (performance.now() < until) {
|
|
119
147
|
if (child.exitCode !== null) break;
|
|
120
148
|
if (await answersOk(`${base}/readyz`)) return;
|
|
121
149
|
await Bun.sleep(POLL_MS);
|
|
122
150
|
}
|
|
123
|
-
child
|
|
124
|
-
await child.exited;
|
|
151
|
+
await end(child, termGraceMs);
|
|
125
152
|
throw refuse(`${command.join(' ')} never answered ${base}/readyz`, tail());
|
|
126
153
|
};
|
|
127
154
|
|
|
@@ -133,8 +160,7 @@ export async function spawnE2eApp(options: SpawnE2eAppOptions): Promise<SpawnedE
|
|
|
133
160
|
const stop = async (): Promise<void> => {
|
|
134
161
|
if (stopped) return;
|
|
135
162
|
stopped = true;
|
|
136
|
-
child
|
|
137
|
-
await child.exited;
|
|
163
|
+
await end(child, termGraceMs);
|
|
138
164
|
};
|
|
139
165
|
try {
|
|
140
166
|
await ready(child, tail);
|
|
@@ -156,8 +182,7 @@ export async function spawnE2eApp(options: SpawnE2eAppOptions): Promise<SpawnedE
|
|
|
156
182
|
);
|
|
157
183
|
// The same port and the same state directory — a deploy, not a second app: a tab already
|
|
158
184
|
// open on `base` sees the new build on its next request, and the data it wrote is still there.
|
|
159
|
-
child
|
|
160
|
-
await child.exited;
|
|
185
|
+
await end(child, termGraceMs);
|
|
161
186
|
child = spawnApp(next);
|
|
162
187
|
tail = drainTail(child.stdout, child.stderr);
|
|
163
188
|
await ready(child, tail);
|
|
@@ -165,6 +190,22 @@ export async function spawnE2eApp(options: SpawnE2eAppOptions): Promise<SpawnedE
|
|
|
165
190
|
};
|
|
166
191
|
}
|
|
167
192
|
|
|
193
|
+
const within = (ms: number, work: Promise<unknown>): Promise<unknown> =>
|
|
194
|
+
Promise.race([work, Bun.sleep(ms)]);
|
|
195
|
+
|
|
196
|
+
/** SIGTERM, the grace, then SIGKILL: bounded at `termGraceMs + KILL_GRACE_MS`, never forever. */
|
|
197
|
+
async function end(child: Bun.Subprocess, termGraceMs: number): Promise<void> {
|
|
198
|
+
child.kill();
|
|
199
|
+
const exited =
|
|
200
|
+
(await within(
|
|
201
|
+
termGraceMs,
|
|
202
|
+
child.exited.then(() => true),
|
|
203
|
+
)) === true;
|
|
204
|
+
if (exited) return;
|
|
205
|
+
child.kill('SIGKILL');
|
|
206
|
+
await within(KILL_GRACE_MS, child.exited);
|
|
207
|
+
}
|
|
208
|
+
|
|
168
209
|
const TAIL_CHARS = 16_000;
|
|
169
210
|
|
|
170
211
|
/** Read both streams to their end in the background; answer the last `TAIL_CHARS` of either. */
|
package/src/errors.ts
CHANGED
|
@@ -6,10 +6,9 @@ import {
|
|
|
6
6
|
renderFixLiteral,
|
|
7
7
|
UltimateError,
|
|
8
8
|
} from '@ultimat3/core';
|
|
9
|
-
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
import type { RegistryLeak } from './registry-leak-guard';
|
|
9
|
+
|
|
10
|
+
// `X_TEST_REGISTRY_LEAK` is declared below, in this registry; its message lives beside it.
|
|
11
|
+
export { RegistryLeakError } from './registry-leak-error';
|
|
13
12
|
|
|
14
13
|
export const TESTING_ERROR_CODES = [
|
|
15
14
|
'X_TEST_NETWORK_SEALED',
|
|
@@ -48,6 +47,7 @@ export const TESTING_ERROR_CODES = [
|
|
|
48
47
|
'X_TEST_OPENAPI_EXPECTED',
|
|
49
48
|
'X_TEST_NUMBER_EXPECTED',
|
|
50
49
|
'X_TEST_ISLAND_STATE_UNKNOWN',
|
|
50
|
+
'X_TEST_APP_NOT_BOOTED',
|
|
51
51
|
] as const;
|
|
52
52
|
|
|
53
53
|
export type TestingErrorCode = (typeof TESTING_ERROR_CODES)[number];
|
|
@@ -87,6 +87,7 @@ export const TESTING_ERROR_TITLES: Readonly<Record<TestingErrorCode, string>> =
|
|
|
87
87
|
X_TEST_OPENAPI_EXPECTED: 'a matcher expected an OpenAPI document and got something else',
|
|
88
88
|
X_TEST_NUMBER_EXPECTED: 'a matcher expected a finite number and got something else',
|
|
89
89
|
X_TEST_ISLAND_STATE_UNKNOWN: 'an island state manifest declares no state with that id',
|
|
90
|
+
X_TEST_APP_NOT_BOOTED: 'describeApp handle read before boot',
|
|
90
91
|
};
|
|
91
92
|
|
|
92
93
|
// Titles must be registered for `format()` to render the contract's first line. Every code above is
|
|
@@ -105,15 +106,29 @@ registerErrorCodes(
|
|
|
105
106
|
// answered 404, host included, on every error it has ever thrown; restating the replacement here
|
|
106
107
|
// would be the same constant in eight places waiting to drift again.
|
|
107
108
|
|
|
108
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* A test reached the network without a mock or an allowlist entry. Always a bug, never a flake.
|
|
111
|
+
* `transport` picks the repair: a `fetch` is mocked, a socket (`WebSocket`, `Bun.connect`) has no
|
|
112
|
+
* mock — the code under test takes its transport as a parameter, and the test hands one in.
|
|
113
|
+
*/
|
|
109
114
|
export class NetworkSealedError extends UltimateError {
|
|
110
|
-
constructor(input: {
|
|
115
|
+
constructor(input: {
|
|
116
|
+
url: string;
|
|
117
|
+
method: string;
|
|
118
|
+
allowed: readonly string[];
|
|
119
|
+
transport?: 'fetch' | 'socket' | undefined;
|
|
120
|
+
}) {
|
|
121
|
+
const allowCall = `allowHost('${hostOf(input.url)}')`;
|
|
122
|
+
const allow = `${allowCall} if it must be real`;
|
|
111
123
|
super({
|
|
112
124
|
code: 'X_TEST_NETWORK_SEALED',
|
|
113
125
|
cause: `${input.method} ${input.url} was not mocked (allowed hosts: ${
|
|
114
126
|
input.allowed.length > 0 ? input.allowed.join(', ') : 'none'
|
|
115
127
|
})`,
|
|
116
|
-
fix:
|
|
128
|
+
fix:
|
|
129
|
+
input.transport === 'socket'
|
|
130
|
+
? `${allowCall} # only if it must be real — otherwise inject the transport: pass the code under test its \`connect\`/\`client\` option, or dial a Bun.listen server on 127.0.0.1 (loopback is never sealed)`
|
|
131
|
+
: `mockFetch('${input.url}', () => new Response('{}')) — or ${allow}`,
|
|
117
132
|
});
|
|
118
133
|
}
|
|
119
134
|
}
|
|
@@ -135,7 +150,18 @@ export class NondeterministicError extends UltimateError {
|
|
|
135
150
|
super({
|
|
136
151
|
code: 'X_TEST_NONDETERMINISTIC',
|
|
137
152
|
cause: `${input.what} produced "${input.first}" then "${input.second}"`,
|
|
138
|
-
fix: '
|
|
153
|
+
fix: "assertDeterministic('…', () => { installDeterminism(); return body(); })",
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** `describeApp`'s `app()` read while the block was still being registered, before `beforeAll`. */
|
|
159
|
+
export class AppNotBootedError extends UltimateError {
|
|
160
|
+
constructor(input: { suite: string }) {
|
|
161
|
+
super({
|
|
162
|
+
code: 'X_TEST_APP_NOT_BOOTED',
|
|
163
|
+
cause: `describeApp('${input.suite}') — app() was called while the describe block was being registered, before its beforeAll booted the app`,
|
|
164
|
+
fix: "test('…', () => { const handle = app(); })",
|
|
139
165
|
});
|
|
140
166
|
}
|
|
141
167
|
}
|
|
@@ -346,64 +372,6 @@ export class NetworkRaceError extends UltimateError {
|
|
|
346
372
|
}
|
|
347
373
|
}
|
|
348
374
|
|
|
349
|
-
/**
|
|
350
|
-
* Every value in this message is uncontrolled: the path arrives from `Bun.plugin`'s `onLoad`, and
|
|
351
|
-
* the names are whatever the app under test passed to `declareTags()` / `registerTier()`. So the
|
|
352
|
-
* sentence renders them (bounded, never throwing while describing a leak) and the command quotes
|
|
353
|
-
* them (a fix has to parse after a path with a space or a quote in it lands in the middle of it).
|
|
354
|
-
*/
|
|
355
|
-
const describeLeak = (leak: RegistryLeak): string => {
|
|
356
|
-
const left = [
|
|
357
|
-
...(leak.tags.length > 0 ? [`cache tags declared ${renderCauseValue(leak.tags)}`] : []),
|
|
358
|
-
...(leak.tiers.length > 0 ? [`cache tiers registered ${renderCauseValue(leak.tiers)}`] : []),
|
|
359
|
-
];
|
|
360
|
-
return `${renderCauseValue(leak.file)} left ${left.join(' and ')} after its last test`;
|
|
361
|
-
};
|
|
362
|
-
|
|
363
|
-
/** The placeholder is the cause's own sentence: it already names every file, in order. */
|
|
364
|
-
const FILE_PLACEHOLDER = '<the file the cause names>';
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
* The edit, spelled as the lines to paste — the imports included, since neither call is global.
|
|
368
|
-
*
|
|
369
|
-
* Both repairs isolate; neither resets. `afterAll(resetTiers)` was the first spelling of the tier
|
|
370
|
-
* half and it is the bug wearing the fix's clothes: it drops the tiers, the revalidator and both
|
|
371
|
-
* logs a NEIGHBOUR registered, and the guard reports additions only, so the damage lands on an
|
|
372
|
-
* innocent file with nothing pointing back here. An error whose instruction causes the next defect
|
|
373
|
-
* is worse than no instruction.
|
|
374
|
-
*/
|
|
375
|
-
const repairFor = (leak: RegistryLeak): string => {
|
|
376
|
-
const imports: string[] = [];
|
|
377
|
-
const calls: string[] = [];
|
|
378
|
-
if (leak.tags.length > 0) {
|
|
379
|
-
imports.push('isolateDeclaredTags');
|
|
380
|
-
calls.push('const restoreTags = isolateDeclaredTags(); afterAll(restoreTags);');
|
|
381
|
-
}
|
|
382
|
-
if (leak.tiers.length > 0) {
|
|
383
|
-
imports.push('isolateTiers');
|
|
384
|
-
calls.push('const restoreTiers = isolateTiers(); afterAll(restoreTiers);');
|
|
385
|
-
}
|
|
386
|
-
const file = renderFixLiteral(leak.file, FILE_PLACEHOLDER);
|
|
387
|
-
return `in ${file} add: import { ${imports.join(', ')} } from '@ultimat3/cache'; ${calls.join(' ')}`;
|
|
388
|
-
};
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* One error for every leaker in the run, not one per file: the run has already finished by the
|
|
392
|
-
* time the last file can be judged, and two throws would report the second as an unhandled one.
|
|
393
|
-
* The trailing command reproduces the failure on its own — the guard judges each file against its
|
|
394
|
-
* own baseline, so one leaking file fails a one-file run exactly as it failed the whole suite.
|
|
395
|
-
*/
|
|
396
|
-
export class RegistryLeakError extends UltimateError {
|
|
397
|
-
constructor(input: { readonly leaks: readonly RegistryLeak[] }) {
|
|
398
|
-
const files = input.leaks.map((leak) => renderFixLiteral(leak.file, FILE_PLACEHOLDER));
|
|
399
|
-
super({
|
|
400
|
-
code: 'X_TEST_REGISTRY_LEAK',
|
|
401
|
-
cause: input.leaks.map(describeLeak).join('; '),
|
|
402
|
-
fix: `${input.leaks.map(repairFor).join('; ')} — then re-run: bun test ${files.join(' ')}`,
|
|
403
|
-
});
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
|
|
407
375
|
/** Neither path is controlled by the framework: both arrive from the test's own call. */
|
|
408
376
|
const ISLAND_PLACEHOLDER = '<the island path the cause names>';
|
|
409
377
|
|
package/src/factories.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// caller composes, an association is a column whose value comes from another factory built with
|
|
7
7
|
// the SAME strategy — `build()` leaves the parent in memory, `create()` writes it.
|
|
8
8
|
|
|
9
|
+
import { fnv1a } from '@ultimat3/core';
|
|
9
10
|
import { seededRandom, seededUuid } from './determinism';
|
|
10
11
|
import { FactoryTraitUnknownError } from './errors';
|
|
11
12
|
import { persistRow } from './factory-persist';
|
|
@@ -91,15 +92,12 @@ export interface Factory<TRow, TTrait extends string = string> {
|
|
|
91
92
|
|
|
92
93
|
/**
|
|
93
94
|
* Two factories with the same seed emit the same uuids, so a default of `1` everywhere gave a user
|
|
94
|
-
* and a post the same id — rows that only look related. FNV-1a over the table name keeps
|
|
95
|
-
* table on its own stream while staying a pure function of the schema, so the ids are still
|
|
95
|
+
* and a post the same id — rows that only look related. Core's FNV-1a over the table name keeps
|
|
96
|
+
* every table on its own stream while staying a pure function of the schema, so the ids are still
|
|
96
97
|
* identical run to run and machine to machine.
|
|
97
98
|
*/
|
|
98
99
|
export function seedFor(table: string): number {
|
|
99
|
-
|
|
100
|
-
for (let i = 0; i < table.length; i += 1) {
|
|
101
|
-
hash = Math.imul(hash ^ table.charCodeAt(i), 0x01000193) >>> 0;
|
|
102
|
-
}
|
|
100
|
+
const hash = fnv1a(table);
|
|
103
101
|
return hash === 0 ? 1 : hash;
|
|
104
102
|
}
|
|
105
103
|
|
package/src/file-boundary.ts
CHANGED
|
@@ -119,13 +119,8 @@ export function restoreGlobals(snapshot: Baseline, host: object = globalThis): r
|
|
|
119
119
|
return touched;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
/**
|
|
123
|
-
export function
|
|
124
|
-
if (baseline === undefined) {
|
|
125
|
-
baseline = captureGlobals();
|
|
126
|
-
envBaseline = { ...process.env };
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
122
|
+
/** Every `onFileBoundary` hook, in order — also run once after the run's last file. */
|
|
123
|
+
export function runBoundaryHooks(): void {
|
|
129
124
|
for (const hook of hooks) {
|
|
130
125
|
try {
|
|
131
126
|
hook();
|
|
@@ -133,6 +128,16 @@ export function runFileBoundary(): void {
|
|
|
133
128
|
// A hook that throws (an island's own disposer) must not stop the rest of the reset.
|
|
134
129
|
}
|
|
135
130
|
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Called by the leak guard as each test file loads. The first call only takes the baseline. */
|
|
134
|
+
export function runFileBoundary(): void {
|
|
135
|
+
if (baseline === undefined) {
|
|
136
|
+
baseline = captureGlobals();
|
|
137
|
+
envBaseline = { ...process.env };
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
runBoundaryHooks();
|
|
136
141
|
restoreGlobals(baseline);
|
|
137
142
|
if (envBaseline !== undefined) restoreEnv(envBaseline);
|
|
138
143
|
}
|
package/src/fixture-clock.ts
CHANGED
|
@@ -33,7 +33,7 @@ export interface TestClock {
|
|
|
33
33
|
* installed determinism once for the whole process, and uninstalling it here would hand the REAL
|
|
34
34
|
* `Date` and the REAL `Math.random` to everything after. Restore only what was found.
|
|
35
35
|
*/
|
|
36
|
-
export async function
|
|
36
|
+
export async function testClock(): Promise<TestClock> {
|
|
37
37
|
const { toMs } = await import('@ultimat3/time');
|
|
38
38
|
const captured = captureDeterminism();
|
|
39
39
|
return {
|