@ultimat3/testing 27.10.1 → 27.10.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/testing",
3
- "version": "27.10.1",
3
+ "version": "27.10.3",
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.1",
40
- "@ultimat3/cache": "27.10.1",
41
- "@ultimat3/core": "27.10.1",
42
- "@ultimat3/db": "27.10.1",
43
- "@ultimat3/entity": "27.10.1",
44
- "@ultimat3/http": "27.10.1",
45
- "@ultimat3/i18n": "27.10.1",
46
- "@ultimat3/jobs": "27.10.1",
47
- "@ultimat3/mail": "27.10.1",
48
- "@ultimat3/policy": "27.10.1",
49
- "@ultimat3/pwa": "27.10.1",
50
- "@ultimat3/query": "27.10.1",
51
- "@ultimat3/realtime": "27.10.1",
52
- "@ultimat3/render": "27.10.1",
53
- "@ultimat3/time": "27.10.1"
39
+ "@ultimat3/action": "27.10.3",
40
+ "@ultimat3/cache": "27.10.3",
41
+ "@ultimat3/core": "27.10.3",
42
+ "@ultimat3/db": "27.10.3",
43
+ "@ultimat3/entity": "27.10.3",
44
+ "@ultimat3/http": "27.10.3",
45
+ "@ultimat3/i18n": "27.10.3",
46
+ "@ultimat3/jobs": "27.10.3",
47
+ "@ultimat3/mail": "27.10.3",
48
+ "@ultimat3/policy": "27.10.3",
49
+ "@ultimat3/pwa": "27.10.3",
50
+ "@ultimat3/query": "27.10.3",
51
+ "@ultimat3/realtime": "27.10.3",
52
+ "@ultimat3/render": "27.10.3",
53
+ "@ultimat3/time": "27.10.3"
54
54
  }
55
55
  }
@@ -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 = (await evaluateRaw(`Boolean(${expression})`)).result;
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
- const metricsPort = freePort();
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
- const refused: PortProbe[] = [];
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
- refused.push(probe);
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 refused) probe.stop();
57
+ for (const probe of held) probe.stop();
45
58
  }
46
59
  };