@ultimat3/testing 27.10.1 → 27.10.2
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/package.json +16 -16
- package/src/cdp-e2e-page.ts +37 -1
- package/src/e2e-spawn.ts +4 -2
- package/src/free-port.ts +18 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/testing",
|
|
3
|
-
"version": "27.10.
|
|
3
|
+
"version": "27.10.2",
|
|
4
4
|
"description": "Test harness: cloned template DBs per worker, frozen clock, sealed network, 6 test types",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -36,20 +36,20 @@
|
|
|
36
36
|
"test": "bun test"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@ultimat3/action": "27.10.
|
|
40
|
-
"@ultimat3/cache": "27.10.
|
|
41
|
-
"@ultimat3/core": "27.10.
|
|
42
|
-
"@ultimat3/db": "27.10.
|
|
43
|
-
"@ultimat3/entity": "27.10.
|
|
44
|
-
"@ultimat3/http": "27.10.
|
|
45
|
-
"@ultimat3/i18n": "27.10.
|
|
46
|
-
"@ultimat3/jobs": "27.10.
|
|
47
|
-
"@ultimat3/mail": "27.10.
|
|
48
|
-
"@ultimat3/policy": "27.10.
|
|
49
|
-
"@ultimat3/pwa": "27.10.
|
|
50
|
-
"@ultimat3/query": "27.10.
|
|
51
|
-
"@ultimat3/realtime": "27.10.
|
|
52
|
-
"@ultimat3/render": "27.10.
|
|
53
|
-
"@ultimat3/time": "27.10.
|
|
39
|
+
"@ultimat3/action": "27.10.2",
|
|
40
|
+
"@ultimat3/cache": "27.10.2",
|
|
41
|
+
"@ultimat3/core": "27.10.2",
|
|
42
|
+
"@ultimat3/db": "27.10.2",
|
|
43
|
+
"@ultimat3/entity": "27.10.2",
|
|
44
|
+
"@ultimat3/http": "27.10.2",
|
|
45
|
+
"@ultimat3/i18n": "27.10.2",
|
|
46
|
+
"@ultimat3/jobs": "27.10.2",
|
|
47
|
+
"@ultimat3/mail": "27.10.2",
|
|
48
|
+
"@ultimat3/policy": "27.10.2",
|
|
49
|
+
"@ultimat3/pwa": "27.10.2",
|
|
50
|
+
"@ultimat3/query": "27.10.2",
|
|
51
|
+
"@ultimat3/realtime": "27.10.2",
|
|
52
|
+
"@ultimat3/render": "27.10.2",
|
|
53
|
+
"@ultimat3/time": "27.10.2"
|
|
54
54
|
}
|
|
55
55
|
}
|
package/src/cdp-e2e-page.ts
CHANGED
|
@@ -30,6 +30,20 @@ const evaluated = (result: Record<string, unknown> | undefined): unknown => {
|
|
|
30
30
|
return (remote as Record<string, unknown>)['value'];
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* What Chrome answers a `Runtime.evaluate` that was in flight when the document committed (#774):
|
|
35
|
+
* the call is refused at the protocol level, not thrown in the page. The same first text is also
|
|
36
|
+
* what a CLOSED tab answers, which is why `waitFor` asks for the target before it polls again.
|
|
37
|
+
*/
|
|
38
|
+
const NAVIGATED_AWAY = [
|
|
39
|
+
'Inspected target navigated or closed',
|
|
40
|
+
'Execution context was destroyed',
|
|
41
|
+
'Cannot find context with specified id',
|
|
42
|
+
] as const;
|
|
43
|
+
|
|
44
|
+
const navigatedAway = (error: unknown): error is CdpCallFailedError =>
|
|
45
|
+
error instanceof CdpCallFailedError && NAVIGATED_AWAY.some((text) => error.cause.includes(text));
|
|
46
|
+
|
|
33
47
|
/** One tab: the port the driver drives, plus what a multi-tab acceptance suite asks of one. */
|
|
34
48
|
export interface E2eTab extends E2eBrowserPage {
|
|
35
49
|
readonly targetId: string;
|
|
@@ -96,11 +110,33 @@ export function cdpE2eTab(options: CdpE2eTabOptions): E2eTab {
|
|
|
96
110
|
// leaves the next poll reading a document whose `<body>` is not parsed, and
|
|
97
111
|
// `document.body.textContent` throws there once and holds a poll later. The last throw is kept
|
|
98
112
|
// and named at the deadline, so an expression that can never evaluate still says why.
|
|
113
|
+
//
|
|
114
|
+
// A poll the browser REFUSED because the document committed under it is the same "not yet"
|
|
115
|
+
// (#774) — but only while the tab is still there: a closed tab answers with the same text, and
|
|
116
|
+
// still refuses at once. Any other refusal (a dead connection) refuses at once too.
|
|
117
|
+
const tabOpen = (): Promise<boolean> =>
|
|
118
|
+
send('Target.getTargetInfo', { targetId: options.targetId }).then(
|
|
119
|
+
() => true,
|
|
120
|
+
() => false,
|
|
121
|
+
);
|
|
122
|
+
const poll = async (expression: string): Promise<Record<string, unknown> | 'navigated'> => {
|
|
123
|
+
try {
|
|
124
|
+
return (await evaluateRaw(`Boolean(${expression})`)).result ?? {};
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (navigatedAway(error) && (await tabOpen())) return 'navigated';
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
99
130
|
const waitFor = async (expression: string, what: string, timeoutMs = options.loadTimeoutMs) => {
|
|
100
131
|
// Only the PAGE's throw is swallowed: a connection that died still refuses at once.
|
|
101
132
|
let threw: string | undefined;
|
|
102
133
|
for (let waited = 0; waited < timeoutMs; waited += POLL_MS) {
|
|
103
|
-
const answer =
|
|
134
|
+
const answer = await poll(expression);
|
|
135
|
+
if (answer === 'navigated') {
|
|
136
|
+
threw = 'the document committed while the poll was in flight';
|
|
137
|
+
await Bun.sleep(POLL_MS);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
104
140
|
threw = thrownIn(answer);
|
|
105
141
|
if (threw === undefined && evaluated(answer) === true) return;
|
|
106
142
|
await Bun.sleep(POLL_MS);
|
package/src/e2e-spawn.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { get } from 'node:http';
|
|
|
10
10
|
import { dirname, join } from 'node:path';
|
|
11
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
|
+
import { freePort, freePortApartFrom } from './free-port';
|
|
14
14
|
|
|
15
15
|
/** `x dev` (sync included), or the production entry `apps/web/server.ts` under `ROLE=web`. */
|
|
16
16
|
export type E2eAppMode = 'dev' | 'serve';
|
|
@@ -116,7 +116,9 @@ export async function spawnE2eApp(options: SpawnE2eAppOptions): Promise<SpawnedE
|
|
|
116
116
|
const port = freePort();
|
|
117
117
|
// Its own scrape port too: every role opens one, the default is a fixed 9090, and a second app —
|
|
118
118
|
// or a developer's `x dev` — already holding it is an app that dies at boot with X_PORT_IN_USE.
|
|
119
|
-
|
|
119
|
+
// Apart from the web port AND its neighbour, which `x dev` binds the sync node on: the OS may hand
|
|
120
|
+
// a just-released port straight back (`freePortApartFrom`).
|
|
121
|
+
const metricsPort = freePortApartFrom([port, port + 1]);
|
|
120
122
|
const base = `http://localhost:${String(port)}`;
|
|
121
123
|
const command =
|
|
122
124
|
options.mode === 'serve'
|
package/src/free-port.ts
CHANGED
|
@@ -28,19 +28,32 @@ const listenOnAnyPort = (): PortProbe => {
|
|
|
28
28
|
* open. A refused port is KEPT listening while the next is asked for, so the OS cannot answer with
|
|
29
29
|
* it again: the walk ends after at most one ask per refused port, with no counter and no retry.
|
|
30
30
|
*/
|
|
31
|
-
export const freePort = (listen: () => PortProbe = listenOnAnyPort): number =>
|
|
32
|
-
|
|
31
|
+
export const freePort = (listen: () => PortProbe = listenOnAnyPort): number =>
|
|
32
|
+
freePortApartFrom([], listen);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The same, and none of `taken` — the ports this caller already handed out and RELEASED, which the
|
|
36
|
+
* OS is free to answer with again. Two bare `freePort()` calls for one app gave its web role and
|
|
37
|
+
* its scrape listener the same number (CI, 2026-10-14). Both bound it — `localhost` names `::1` and
|
|
38
|
+
* `127.0.0.1` there — and the browser's requests reached `/metrics`'s `not found` instead of the
|
|
39
|
+
* app. Held like a refused port, so the walk still ends.
|
|
40
|
+
*/
|
|
41
|
+
export const freePortApartFrom = (
|
|
42
|
+
taken: readonly number[],
|
|
43
|
+
listen: () => PortProbe = listenOnAnyPort,
|
|
44
|
+
): number => {
|
|
45
|
+
const held: PortProbe[] = [];
|
|
33
46
|
try {
|
|
34
47
|
for (;;) {
|
|
35
48
|
const probe = listen();
|
|
36
|
-
if (BROWSER_REFUSED_PORTS.has(probe.port)) {
|
|
37
|
-
|
|
49
|
+
if (BROWSER_REFUSED_PORTS.has(probe.port) || taken.includes(probe.port)) {
|
|
50
|
+
held.push(probe);
|
|
38
51
|
continue;
|
|
39
52
|
}
|
|
40
53
|
probe.stop();
|
|
41
54
|
return probe.port;
|
|
42
55
|
}
|
|
43
56
|
} finally {
|
|
44
|
-
for (const probe of
|
|
57
|
+
for (const probe of held) probe.stop();
|
|
45
58
|
}
|
|
46
59
|
};
|