@ultimat3/testing 27.12.0 → 27.13.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/README.md CHANGED
@@ -205,8 +205,13 @@ there is no flag on the fixture and no code to silence.
205
205
 
206
206
  Each takes an optional `{ timeoutMs }` after the body — `unitTest(name, body, { timeoutMs: 30_000 })`,
207
207
  and `test` takes the same; `evalTest` reads it off its options — handed to `bun:test` as that
208
- test's own deadline (`TestOptions`). Absent is Bun's 5 s default. An `E2eDriver` receives it as its
209
- third argument. `unitTest`, `contractTest`, `liveTest` and `jobTest` hand their body the
208
+ test's own deadline (`TestOptions`), at this machine's pace. Absent is Bun's 5 s default, which
209
+ `x verify` paces the same way. An `E2eDriver` receives it as its third argument.
210
+
211
+ **`paced(ms)`** is a declared deadline at this machine's pace, for a bare `test(…, paced(90_000))`:
212
+ `ms` times `ULTIMATE_TEST_PACE`, which `x verify` measures and sets on every test run it starts —
213
+ 1 on a 4-core CI runner, more on a 2-core or slower box. Absent (a hand-run `bun test`) is 1; a
214
+ value that is not a number at or above 1 is `X_TEST_PACE_INVALID`. `unitTest`, `contractTest`, `liveTest` and `jobTest` hand their body the
210
215
  [fixture bag](#fixtures); `e2eTest` hands its own (`E2eFixtures`).
211
216
 
212
217
  **Registering one, `As of 2026-08-25`.** `@ultimat3/cli`'s `installE2eDriver({ page, baseUrl })` is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/testing",
3
- "version": "27.12.0",
3
+ "version": "27.13.0",
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.12.0",
40
- "@ultimat3/cache": "27.12.0",
41
- "@ultimat3/core": "27.12.0",
42
- "@ultimat3/db": "27.12.0",
43
- "@ultimat3/entity": "27.12.0",
44
- "@ultimat3/http": "27.12.0",
45
- "@ultimat3/i18n": "27.12.0",
46
- "@ultimat3/jobs": "27.12.0",
47
- "@ultimat3/mail": "27.12.0",
48
- "@ultimat3/policy": "27.12.0",
49
- "@ultimat3/pwa": "27.12.0",
50
- "@ultimat3/query": "27.12.0",
51
- "@ultimat3/realtime": "27.12.0",
52
- "@ultimat3/render": "27.12.0",
53
- "@ultimat3/time": "27.12.0"
39
+ "@ultimat3/action": "27.13.0",
40
+ "@ultimat3/cache": "27.13.0",
41
+ "@ultimat3/core": "27.13.0",
42
+ "@ultimat3/db": "27.13.0",
43
+ "@ultimat3/entity": "27.13.0",
44
+ "@ultimat3/http": "27.13.0",
45
+ "@ultimat3/i18n": "27.13.0",
46
+ "@ultimat3/jobs": "27.13.0",
47
+ "@ultimat3/mail": "27.13.0",
48
+ "@ultimat3/policy": "27.13.0",
49
+ "@ultimat3/pwa": "27.13.0",
50
+ "@ultimat3/query": "27.13.0",
51
+ "@ultimat3/realtime": "27.13.0",
52
+ "@ultimat3/render": "27.13.0",
53
+ "@ultimat3/time": "27.13.0"
54
54
  }
55
55
  }
@@ -7,6 +7,7 @@ import type { CdpConnection } from './cdp-connection';
7
7
  import type { E2eTab } from './cdp-e2e-page';
8
8
  import { cdpE2eTab } from './cdp-e2e-page';
9
9
  import { CdpCallFailedError, CdpTimeoutError } from './cdp-errors';
10
+ import { isNetworkProbe, networkAgrees } from './cdp-network-probe';
10
11
  import { offlineScripts } from './cdp-offline-script';
11
12
 
12
13
  export interface E2eSession {
@@ -92,6 +93,8 @@ export async function cdpE2eSession(options: CdpE2eSessionOptions): Promise<E2eS
92
93
  });
93
94
  connection.on('Network.requestWillBeSent', (params) => {
94
95
  const request = params['request'];
96
+ // The switch's own proof, not the app's traffic: a test that counts requests never sees it.
97
+ if (isNetworkProbe(field(request, 'url') ?? '')) return;
95
98
  requests.push(`${field(request, 'method') ?? '?'} ${field(request, 'url') ?? ''}`);
96
99
  });
97
100
  connection.on('Target.attachedToTarget', (params) => {
@@ -232,13 +235,20 @@ export async function cdpE2eSession(options: CdpE2eSessionOptions): Promise<E2eS
232
235
  const confirmed = async (online: boolean): Promise<void> => {
233
236
  const deadline = performance.now() + options.loadTimeoutMs;
234
237
  const open = [...pageSessions].filter((session) => sessions.has(session));
238
+ // `navigator.onLine` first, then a real request: Chrome tells the page before its network
239
+ // service applies the cut, so the first alone let a `fetch` through (`cdp-network-probe.ts`).
235
240
  const answers = await Promise.all(
236
- open.map(async (session) => [session, await pageReads(session, online, deadline)] as const),
241
+ open.map(async (session) => {
242
+ const reads =
243
+ (await pageReads(session, online, deadline)) &&
244
+ (await networkAgrees(send, session, online, deadline));
245
+ return [session, reads] as const;
246
+ }),
237
247
  );
238
248
  const waiting = answers.filter(([, reads]) => !reads).map(([session]) => session);
239
249
  if (waiting.length > 0) {
240
250
  throw new CdpTimeoutError({
241
- method: `${ON_LINE} === ${String(online)} in page session ${waiting.join(', ')}`,
251
+ method: `${ON_LINE} === ${String(online)} and a request ${online ? 'reaching' : 'refused'} in page session ${waiting.join(', ')}`,
242
252
  timeoutMs: options.loadTimeoutMs,
243
253
  });
244
254
  }
@@ -0,0 +1,80 @@
1
+ // One responsibility: proving a page's NETWORK agrees with the offline switch, by sending a request.
2
+ // `navigator.onLine` is not that proof: Chrome tells the renderer before the network service applies
3
+ // the condition (two pipes, no order between them), so a `fetch` on the next line went through under
4
+ // a cut the page already read as offline — `browser-harness.e2e.test.ts`, CI run 39223207154.
5
+
6
+ import type { CdpResult } from './cdp-connection';
7
+
8
+ type Send = (
9
+ method: string,
10
+ params: Record<string, unknown>,
11
+ session: string,
12
+ deadlineMs?: number,
13
+ ) => Promise<CdpResult>;
14
+
15
+ /** The probe's path: under `/_x/`, which no app route owns, so the app answers it 404 and does nothing. */
16
+ export const NETWORK_PROBE_PATH = '/_x/network-probe';
17
+
18
+ /**
19
+ * `HEAD`, because the framework's service worker answers GET only — a GET could be served from its
20
+ * cache or its offline document, and read as `reached` under a cut forever. `none` on a page that is
21
+ * not on http(s) (`about:blank`): it has no origin to ask, and nothing on it can fetch either.
22
+ */
23
+ export const NETWORK_PROBE = `(async () => {
24
+ if (location.protocol !== 'http:' && location.protocol !== 'https:') return 'none';
25
+ try {
26
+ await fetch('${NETWORK_PROBE_PATH}', { method: 'HEAD', cache: 'no-store' });
27
+ return 'reached';
28
+ } catch {
29
+ return 'refused';
30
+ }
31
+ })()`;
32
+
33
+ /** Whether a logged request URL is a probe — kept out of the session's request log. */
34
+ export const isNetworkProbe = (url: string): boolean => {
35
+ try {
36
+ return new URL(url).pathname === NETWORK_PROBE_PATH;
37
+ } catch {
38
+ return false;
39
+ }
40
+ };
41
+
42
+ const probeValue = (answer: CdpResult): unknown => {
43
+ const result = answer.result?.['result'];
44
+ return typeof result === 'object' && result !== null
45
+ ? (result as Record<string, unknown>)['value']
46
+ : undefined;
47
+ };
48
+
49
+ /**
50
+ * Probes one page until a request from it is refused (`online` false) or reaches the server
51
+ * (`online` true), and answers `false` at `deadline`. Each probe is itself a round trip to the
52
+ * network, so the loop paces itself — no sleep. A probe refused by a navigation is asked again.
53
+ */
54
+ export async function networkAgrees(
55
+ send: Send,
56
+ session: string,
57
+ online: boolean,
58
+ deadline: number,
59
+ ): Promise<boolean> {
60
+ const wanted = online ? 'reached' : 'refused';
61
+ for (;;) {
62
+ const remaining = deadline - performance.now();
63
+ if (remaining <= 0) return false;
64
+ try {
65
+ const answer = await send(
66
+ 'Runtime.evaluate',
67
+ { expression: NETWORK_PROBE, returnByValue: true, awaitPromise: true },
68
+ session,
69
+ remaining,
70
+ );
71
+ const value = probeValue(answer);
72
+ if (value === wanted || value === 'none') return true;
73
+ } catch {
74
+ // A navigation destroyed the context, or the call ran out of time: the deadline decides.
75
+ }
76
+ // One macrotask between probes: a refusal that settles as a microtask would otherwise starve
77
+ // every timer and socket in the process until the deadline.
78
+ await new Promise<void>((resolve) => setImmediate(resolve));
79
+ }
80
+ }
package/src/e2e-driver.ts CHANGED
@@ -9,6 +9,7 @@ import { FixtureUnavailableError } from './errors';
9
9
  import { withFailureContext } from './failure-context';
10
10
  import { unavailableFixture } from './fixture-drivers';
11
11
  import { defineFixtures } from './fixtures';
12
+ import { paced } from './test-pace';
12
13
  import type { E2eBody, E2eFixtures, PageLike } from './test-types';
13
14
  import { resetE2eDriver, useE2eDriver } from './test-types';
14
15
 
@@ -107,7 +108,7 @@ export function installE2eDriver(options: E2eDriverOptions): () => void {
107
108
  throw withFailureContext(error);
108
109
  }
109
110
  };
110
- bunTest(name, run, test?.timeoutMs);
111
+ bunTest(name, run, test?.timeoutMs === undefined ? undefined : paced(test.timeoutMs));
111
112
  });
112
113
  return () => {
113
114
  // Both halves, because both were installed. Putting the DECLARATION back — rather than
package/src/e2e-page.ts CHANGED
@@ -62,6 +62,21 @@ const readField = (raw: unknown, key: string): unknown => {
62
62
 
63
63
  const TITLE = '(() => JSON.stringify({ title: document.title }))()';
64
64
 
65
+ /**
66
+ * Where the server's SECOND flush begins: every hole a streamed page reveals is written as one
67
+ * chunk opening with this tag (`revealChunk` in `@ultimat3/render`'s `render-stream.ts`, which the
68
+ * barrel does not export). Everything before it is the first flush — the head and the shell.
69
+ */
70
+ export const REVEAL_MARK = '<template data-x-hole=';
71
+
72
+ /**
73
+ * How long the reader waits for the rest of a flush that arrived in pieces. One server flush is
74
+ * ONE `enqueue`, but the transport may hand it to the page as several chunks — a read of the first
75
+ * network chunk alone cut the shell mid-tag and failed `toContain` on a header it really sent.
76
+ * A pause this long ends the read when no hole has been revealed; a reveal ends it at once.
77
+ */
78
+ export const FLUSH_IDLE_MS = 250;
79
+
65
80
  /**
66
81
  * The first flush, read by a `fetch` INSIDE the page after the navigation, because neither
67
82
  * `ScrapePage` nor `CdpPageLike` exposes a response body — puppeteer's `page.on('response')` is
@@ -70,15 +85,37 @@ const TITLE = '(() => JSON.stringify({ title: document.title }))()';
70
85
  * The cost, stated rather than hidden: this is a SECOND request to the same route, so what it
71
86
  * measures is that route's streaming behaviour and not the byte-for-byte first chunk the open
72
87
  * document received. It runs in the page, so it carries the page's cookies and its origin — a
73
- * `fetch` from the test process would carry neither. The reader is CANCELLED after that chunk: a
74
- * streamed response nobody pulls stays open until its last hole fills, holding one of the page's
75
- * six connections to its origin for the rest of the test.
88
+ * `fetch` from the test process would carry neither. Chunks are joined until the first reveal
89
+ * (cut there) or a pause of `FLUSH_IDLE_MS`, and the reader is then CANCELLED: a streamed response
90
+ * nobody pulls stays open until its last hole fills, holding one of the page's six connections to
91
+ * its origin for the rest of the test.
76
92
  */
77
93
  const firstFlushExpression = (url: string): string =>
78
94
  `(() => fetch(${JSON.stringify(url)}, { credentials: 'same-origin' })
79
- .then((response) => { const reader = response.body.getReader(); return reader.read()
80
- .then((chunk) => { reader.cancel().catch(() => {}); return chunk; }); })
81
- .then((chunk) => JSON.stringify({ html: new TextDecoder().decode(chunk.value || new Uint8Array()) })))()`;
95
+ .then((response) => {
96
+ const reader = response.body.getReader();
97
+ const decoder = new TextDecoder();
98
+ const mark = ${JSON.stringify(REVEAL_MARK)};
99
+ let html = '';
100
+ const finish = () => {
101
+ reader.cancel().catch(() => {});
102
+ const cut = html.indexOf(mark);
103
+ return JSON.stringify({ html: cut === -1 ? html : html.slice(0, cut) });
104
+ };
105
+ const pull = (first) => {
106
+ const read = reader.read();
107
+ const next = first ? read : Promise.race([
108
+ read,
109
+ new Promise((resolve) => setTimeout(() => resolve(null), ${FLUSH_IDLE_MS})),
110
+ ]);
111
+ return next.then((chunk) => {
112
+ if (chunk === null || chunk.done) return finish();
113
+ html += decoder.decode(chunk.value, { stream: true });
114
+ return html.includes(mark) ? finish() : pull(false);
115
+ });
116
+ };
117
+ return pull(true);
118
+ }))()`;
82
119
 
83
120
  /**
84
121
  * The adapter. Every member re-reads the live page: a `PageLike` handed to a test outlives every
package/src/errors.ts CHANGED
@@ -48,6 +48,7 @@ export const TESTING_ERROR_CODES = [
48
48
  'X_TEST_NUMBER_EXPECTED',
49
49
  'X_TEST_ISLAND_STATE_UNKNOWN',
50
50
  'X_TEST_APP_NOT_BOOTED',
51
+ 'X_TEST_PACE_INVALID',
51
52
  ] as const;
52
53
 
53
54
  export type TestingErrorCode = (typeof TESTING_ERROR_CODES)[number];
@@ -88,6 +89,7 @@ export const TESTING_ERROR_TITLES: Readonly<Record<TestingErrorCode, string>> =
88
89
  X_TEST_NUMBER_EXPECTED: 'a matcher expected a finite number and got something else',
89
90
  X_TEST_ISLAND_STATE_UNKNOWN: 'an island state manifest declares no state with that id',
90
91
  X_TEST_APP_NOT_BOOTED: 'describeApp handle read before boot',
92
+ X_TEST_PACE_INVALID: 'ULTIMATE_TEST_PACE is not a number at or above 1',
91
93
  };
92
94
 
93
95
  // Titles must be registered for `format()` to render the contract's first line. Every code above is
package/src/fixtures.ts CHANGED
@@ -18,6 +18,7 @@ import type { TestMail } from './fixture-mail';
18
18
  import type { TestNetwork } from './fixture-network';
19
19
  import type { TestPush } from './fixture-push';
20
20
  import type { TestStatements } from './fixture-statements';
21
+ import { paced } from './test-pace';
21
22
  import type { PageLike, TestOptions } from './test-types';
22
23
 
23
24
  /** Built once per test, on first use. */
@@ -257,8 +258,13 @@ export async function runWithFixtures(body: FixtureRunBody): Promise<void> {
257
258
  * must not be the reason the next file inherits a queue.
258
259
  *
259
260
  * `options.timeoutMs` is the test's own deadline, the same key the six typed helpers take: a
260
- * fixture that builds something slow (an island, a browser) is the test that needs one.
261
+ * fixture that builds something slow (an island, a browser) is the test that needs one. Read at
262
+ * this machine's pace (`paced`), as the typed helpers read theirs.
261
263
  */
262
264
  export function fixtureTest(name: string, body: FixtureBody, options?: TestOptions): void {
263
- bunTest(name, () => runWithFixtures(body), options?.timeoutMs);
265
+ bunTest(
266
+ name,
267
+ () => runWithFixtures(body),
268
+ options?.timeoutMs === undefined ? undefined : paced(options.timeoutMs),
269
+ );
264
270
  }
package/src/index.ts CHANGED
@@ -358,6 +358,7 @@ export {
358
358
  urlFor,
359
359
  workerId,
360
360
  } from './template-db';
361
+ export { paced, TEST_PACE_ENV, TestPaceInvalidError, testPace } from './test-pace';
361
362
  export type {
362
363
  E2eBody,
363
364
  E2eDriver,
@@ -0,0 +1,36 @@
1
+ // How much slower this machine runs than the one a test's declared deadline was measured on.
2
+ // `x verify` measures it (`packages/cli/src/verify-pace.ts`) and hands it to every test run it
3
+ // starts as `ULTIMATE_TEST_PACE`; a test's own timeout is read at that pace, so a 2-core 1.8 GHz
4
+ // box is held to the same bound a CI runner is instead of failing on a number set for the runner.
5
+
6
+ import { UltimateError } from '@ultimat3/core';
7
+
8
+ /** Set by `x verify` on each test run it starts. Absent — a hand-run `bun test` — is pace 1. */
9
+ export const TEST_PACE_ENV = 'ULTIMATE_TEST_PACE';
10
+
11
+ /** A pace that does not parse, or one below 1: a machine is never read as faster than the bound. */
12
+ export class TestPaceInvalidError extends UltimateError {
13
+ constructor(value: string) {
14
+ super({
15
+ code: 'X_TEST_PACE_INVALID',
16
+ cause: `${TEST_PACE_ENV}=${JSON.stringify(value)} is not a number at or above 1`,
17
+ fix: `env -u ${TEST_PACE_ENV} bun test <file> # the gate measures the pace and sets it on each test run itself; unset is pace 1`,
18
+ });
19
+ }
20
+ }
21
+
22
+ /** The pace this process was started at. */
23
+ export function testPace(env: Readonly<Record<string, string | undefined>> = Bun.env): number {
24
+ const raw = env[TEST_PACE_ENV];
25
+ if (raw === undefined || raw.trim() === '') return 1;
26
+ const pace = Number(raw);
27
+ if (!Number.isFinite(pace) || pace < 1) throw new TestPaceInvalidError(raw);
28
+ return pace;
29
+ }
30
+
31
+ /**
32
+ * A declared test deadline at this machine's pace: `test(name, body, paced(90_000))`. The number
33
+ * stays the one measured on a fast machine; only a slower one stretches it.
34
+ */
35
+ export const paced = (ms: number, env?: Readonly<Record<string, string | undefined>>): number =>
36
+ Math.round(ms * testPace(env));
package/src/test-types.ts CHANGED
@@ -6,6 +6,7 @@ import { test } from 'bun:test';
6
6
  import { TestEvalThresholdError } from './errors';
7
7
  import type { FixtureBody } from './fixtures';
8
8
  import { runWithFixtures } from './fixtures';
9
+ import { paced } from './test-pace';
9
10
 
10
11
  export const TEST_TYPES = ['unit', 'contract', 'live', 'job', 'e2e', 'eval'] as const;
11
12
 
@@ -21,7 +22,10 @@ export const testName = (type: TestType, name: string): string => `${type}${SEPA
21
22
  * outlive Bun's 5 s default, which is the one-way rule broken for a number (#589).
22
23
  */
23
24
  export interface TestOptions {
24
- /** This test's own deadline in milliseconds, handed to `bun:test`. Absent is Bun's default. */
25
+ /**
26
+ * This test's own deadline in milliseconds, handed to `bun:test` at this machine's pace
27
+ * (`paced`). Absent is the run's default, which `x verify` paces the same way.
28
+ */
25
29
  readonly timeoutMs?: number;
26
30
  }
27
31
 
@@ -34,7 +38,11 @@ export interface TestOptions {
34
38
  const typed =
35
39
  (type: TestType) =>
36
40
  (name: string, body: FixtureBody, options?: TestOptions): void => {
37
- test(testName(type, name), () => runWithFixtures(body), options?.timeoutMs);
41
+ test(
42
+ testName(type, name),
43
+ () => runWithFixtures(body),
44
+ options?.timeoutMs === undefined ? undefined : paced(options.timeoutMs),
45
+ );
38
46
  };
39
47
 
40
48
  /** Pure logic: no database, no clock, no network. The cheapest test that can fail for real. */
@@ -192,7 +200,7 @@ export function evalTest<TInput, TOutput>(
192
200
  throw new TestEvalThresholdError({ name, threshold: options.threshold, detail });
193
201
  }
194
202
  },
195
- options.timeoutMs,
203
+ options.timeoutMs === undefined ? undefined : paced(options.timeoutMs),
196
204
  );
197
205
  }
198
206