@ultimat3/testing 20.2.1 → 22.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/src/errors.ts CHANGED
@@ -44,6 +44,9 @@ export const TESTING_ERROR_CODES = [
44
44
  'X_TEST_ISLAND_STATE_JSON_INVALID',
45
45
  'X_TEST_ISLAND_STATE_CLOCK_INVALID',
46
46
  'X_TEST_ISLAND_STATE_STUB_INVALID',
47
+ 'X_TEST_POLICY_EXPECTED',
48
+ 'X_TEST_OPENAPI_EXPECTED',
49
+ 'X_TEST_NUMBER_EXPECTED',
47
50
  ] as const;
48
51
 
49
52
  export type TestingErrorCode = (typeof TESTING_ERROR_CODES)[number];
@@ -79,6 +82,9 @@ export const TESTING_ERROR_TITLES: Readonly<Record<TestingErrorCode, string>> =
79
82
  X_TEST_ISLAND_STATE_JSON_INVALID: 'an island state carries a value JSON does not',
80
83
  X_TEST_ISLAND_STATE_CLOCK_INVALID: 'an island state manifest pins a clock that is not pinnable',
81
84
  X_TEST_ISLAND_STATE_STUB_INVALID: 'an island route stub is not "<METHOD> <pathname>"',
85
+ X_TEST_POLICY_EXPECTED: 'a matcher expected a policy and got something else',
86
+ X_TEST_OPENAPI_EXPECTED: 'a matcher expected an OpenAPI document and got something else',
87
+ X_TEST_NUMBER_EXPECTED: 'a matcher expected a finite number and got something else',
82
88
  };
83
89
 
84
90
  // Titles must be registered for `format()` to render the contract's first line. Every code above is
@@ -9,11 +9,11 @@
9
9
 
10
10
  import type { Actor } from '@ultimat3/core';
11
11
  import { UltimateError } from '@ultimat3/core';
12
+ import type { LiveReplicator } from '@ultimat3/realtime/server';
13
+ import { startLiveReplicator } from '@ultimat3/realtime/server';
12
14
  import type { LiveFeed, LiveFeedPatch, LiveTarget } from './fixture-drivers';
13
15
  import type { LiveConnection, LiveNodeHandle } from './live-node';
14
16
  import { createLiveNode } from './live-node';
15
- import type { LiveReplicator } from './live-replicator';
16
- import { startLiveReplicator } from './live-replicator';
17
17
 
18
18
  interface Row {
19
19
  readonly id: string;
package/src/index.ts CHANGED
@@ -23,6 +23,44 @@ export {
23
23
  // `test` is OURS (fixture-injecting); everything else passes through. Re-exported so an app
24
24
  // test has one import line, and so `expect` carries this package's matchers already installed.
25
25
  export { afterAll, afterEach, beforeAll, beforeEach, describe, expect } from 'bun:test';
26
+ // The browser-backed e2e driver and the raw-CDP browser under it — moved here from
27
+ // `@ultimat3/cli` in 22.0.0. `installE2eDriver` is the ONE entry point an app's test preload calls;
28
+ // `openE2eBrowserIfAvailable()` answers `undefined` on a machine with no Chrome, so a
29
+ // browser-backed suite SKIPS rather than turning a gate red. RAW CDP over Bun's own `WebSocket`:
30
+ // no dependency, and no `@ultimat3/scraping` / `puppeteer-core` behind it.
31
+ export type { E2eBrowser, OpenE2eBrowserOptions } from './cdp-browser';
32
+ export {
33
+ DEFAULT_CDP_TIMEOUT_MS,
34
+ openE2eBrowser,
35
+ openE2eBrowserIfAvailable,
36
+ } from './cdp-browser';
37
+ export type {
38
+ CdpConnection,
39
+ CdpConnectionOptions,
40
+ CdpEventListener,
41
+ CdpResult,
42
+ CdpTransport,
43
+ } from './cdp-connection';
44
+ export { cdpConnect, cdpConnectOver } from './cdp-connection';
45
+ export type { CdpE2eTabOptions, E2eTab } from './cdp-e2e-page';
46
+ export { cdpE2eTab } from './cdp-e2e-page';
47
+ export type { CdpE2eSessionOptions, E2eSession } from './cdp-e2e-session';
48
+ export { cdpE2eSession } from './cdp-e2e-session';
49
+ export {
50
+ CdpBrowserMissingError,
51
+ CdpCallFailedError,
52
+ CdpLaunchFailedError,
53
+ CdpTimeoutError,
54
+ } from './cdp-errors';
55
+ export type { LaunchedBrowser, LaunchOptions } from './cdp-launch';
56
+ export {
57
+ CHROME_CANDIDATES,
58
+ CHROME_PATH_ENV,
59
+ CONTAINER_CHROME_ARGS,
60
+ findChrome,
61
+ launchChrome,
62
+ launchFoundChrome,
63
+ } from './cdp-launch';
26
64
  // The island-state vocabulary. Pure data by design: a `*.island.states.ts` file is read by the
27
65
  // command that photographs the states, by the harness page and by a guard test — none of which has
28
66
  // a bundler, and only one of which has a browser.
@@ -47,6 +85,36 @@ export {
47
85
  seededUuid,
48
86
  setFrozenClock,
49
87
  } from './determinism';
88
+ export type { E2eApp, E2eAppMode, StartE2eAppOptions } from './e2e-app';
89
+ export { startE2eApp } from './e2e-app';
90
+ export { E2E_ROOT_ENV, e2eApp, e2eBaseUrl, e2eBrowser } from './e2e-browser-handle';
91
+ export type { E2eDriverOptions } from './e2e-driver';
92
+ export { e2eFixtures, installE2eDriver } from './e2e-driver';
93
+ export type { E2eErrorCode } from './e2e-error-codes';
94
+ export { E2E_ERROR_CODES, E2E_ERROR_TITLES } from './e2e-error-codes';
95
+ export {
96
+ E2eAppFailedError,
97
+ E2eEvaluateCapturedError,
98
+ E2eEvaluateThrewError,
99
+ E2eEvaluateUnsupportedError,
100
+ E2eLocatorAmbiguousError,
101
+ E2eLocatorEmptyError,
102
+ E2eServiceWorkerAbsentError,
103
+ } from './e2e-errors';
104
+ export type { EvaluablePage } from './e2e-evaluate';
105
+ export { closureSource, evaluateClosure, evaluateExpression } from './e2e-evaluate';
106
+ export type { LocatablePage } from './e2e-locator';
107
+ export { e2eLocator, resetLocatorMarks } from './e2e-locator';
108
+ export type { E2eBrowserPage, E2ePageOptions } from './e2e-page';
109
+ export { DEFAULT_E2E_TIMEOUT_MS, DEFAULT_SERVICE_WORKER_TIMEOUT_MS, e2ePage } from './e2e-page';
110
+ export type { E2eResolution, E2eSelection } from './e2e-selection';
111
+ export {
112
+ MARK_ATTRIBUTE,
113
+ markSelector,
114
+ selectionCall,
115
+ selectionExpression,
116
+ unmarkExpression,
117
+ } from './e2e-selection';
50
118
  export type { TestingErrorCode } from './errors';
51
119
  export {
52
120
  FixtureUnavailableError,
@@ -184,8 +252,6 @@ export {
184
252
  } from './island-states-resolve';
185
253
  export type { LiveConnection, LiveNodeHandle, LiveNodeOptions } from './live-node';
186
254
  export { createLiveNode } from './live-node';
187
- export type { LiveReplicator, LiveReplicatorOptions } from './live-replicator';
188
- export { startLiveReplicator } from './live-replicator';
189
255
  /**
190
256
  * The budget `toBeVisible(options?)` takes. Exported because it is in a public matcher's signature;
191
257
  * `retryUntil` and `RetryBudget` deliberately are NOT — nothing outside this package calls them,
package/src/island-dom.ts CHANGED
@@ -332,7 +332,24 @@ export class FakeTemplate extends FakeElement {
332
332
  }
333
333
  }
334
334
 
335
- const VOID_TAGS = new Set(['br', 'hr', 'img', 'input', 'meta', 'link']);
335
+ // The whole HTML void-element set. It held six, so `<picture><source><img></picture>` parsed the
336
+ // img INSIDE the source — a tree no browser builds, and one an island's querySelector disagrees with.
337
+ const VOID_TAGS = new Set([
338
+ 'area',
339
+ 'base',
340
+ 'br',
341
+ 'col',
342
+ 'embed',
343
+ 'hr',
344
+ 'img',
345
+ 'input',
346
+ 'link',
347
+ 'meta',
348
+ 'param',
349
+ 'source',
350
+ 'track',
351
+ 'wbr',
352
+ ]);
336
353
  const TOKEN =
337
354
  /<(\/?)([a-zA-Z][\w-]*)((?:\s+[^\s=/>]+(?:=(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*(\/?)>|([^<]+)/g;
338
355
  const ATTRIBUTE = /([^\s=/>]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
@@ -78,8 +78,11 @@ export class FakeResizeObserver<TElement extends object = object> {
78
78
  ) {
79
79
  registry.add(this);
80
80
  }
81
+ /** Back INTO the registry: `disconnect()` removed it, and an island that re-observes on its next
82
+ * effect must hear the next resize. `Set.add` is idempotent, so a first observe costs nothing. */
81
83
  observe(target: TElement): void {
82
84
  this.targets.add(target);
85
+ this.registry.add(this);
83
86
  }
84
87
  unobserve(target: TElement): void {
85
88
  this.targets.delete(target);
package/src/live-node.ts CHANGED
@@ -77,11 +77,8 @@ export interface LiveNodeOptions {
77
77
  readonly buildId?: string;
78
78
  /** Pins the reconnect epoch, so a test can force a refetch by changing it. */
79
79
  readonly epoch?: string;
80
- // No `onMutate`. `SyncNodeOptions` takes one — `{ socket, name, key, seq, input }`, the actor
81
- // read off the socket — and nothing here needs it: the mutation half of a live subscription is
82
- // the CLIENT's local store and offline queue, which this driver deliberately does not hold. A
83
- // forwarded option no test passes is a declaration nothing reads, which is what 4.0.0 spent a
84
- // major deleting. It arrives with its first caller, in that caller's shape.
80
+ // No `onMutate`: the socket carries no writes any more (plan 101, decision 2) — a mutation goes
81
+ // over HTTP as an action, and `createSyncNode` takes no mutation hook to forward.
85
82
  }
86
83
 
87
84
  export interface LiveNodeHandle {
@@ -0,0 +1,39 @@
1
+ // Single responsibility: the refusals three matchers give a receiver of the wrong TYPE. Thrown,
2
+ // never `pass: false` — under `.not` a `pass: false` is a pass, so `.not.toDenyPolicy` held on
3
+ // `undefined`. Its own module because `errors.ts` sits at the size ceiling; the codes are
4
+ // registered there, beside every other testing code.
5
+
6
+ import { describeValue, UltimateError } from '@ultimat3/core';
7
+
8
+ /** `toDenyPolicy` handed something with neither `run()` nor `evaluate()`. */
9
+ export class TestPolicyExpectedError extends UltimateError {
10
+ constructor(received: unknown) {
11
+ super({
12
+ code: 'X_TEST_POLICY_EXPECTED',
13
+ cause: `toDenyPolicy expects a policy — an object with run() (@ultimat3/policy) or evaluate() — and received ${describeValue(received)}`,
14
+ fix: "expect(post.policy).toDenyPolicy(ctx) — pass the policy object (@ultimat3/policy's, with run() or evaluate()), never the action or its result",
15
+ });
16
+ }
17
+ }
18
+
19
+ /** `toMatchOpenApi` handed something that is not a generated document. */
20
+ export class TestOpenApiExpectedError extends UltimateError {
21
+ constructor(received: unknown) {
22
+ super({
23
+ code: 'X_TEST_OPENAPI_EXPECTED',
24
+ cause: `toMatchOpenApi expects an OpenAPI document — an object with operations: [{ operationId, required? }] — and received ${describeValue(received)}`,
25
+ fix: 'expect(await app.openapi()).toMatchOpenApi(committed) — the receiver is the generated document: an object with operations: [{ operationId, required? }]',
26
+ });
27
+ }
28
+ }
29
+
30
+ /** `toBeWithinBudget` handed something that is not a finite number. */
31
+ export class TestNumberExpectedError extends UltimateError {
32
+ constructor(received: unknown) {
33
+ super({
34
+ code: 'X_TEST_NUMBER_EXPECTED',
35
+ cause: `toBeWithinBudget expects a finite number to compare against the budget, and received ${describeValue(received)}`,
36
+ fix: 'expect(bytes).toBeWithinBudget(limit) — pass the measured number itself, e.g. (await Bun.file(path).arrayBuffer()).byteLength, never a Promise or a string',
37
+ });
38
+ }
39
+ }
package/src/matchers.ts CHANGED
@@ -6,6 +6,11 @@ import type { ExpectExtendMatchers } from 'bun:test';
6
6
  import { expect } from 'bun:test';
7
7
  import { describeValue, isUltimateError, renderCauseValue, stringField } from '@ultimat3/core';
8
8
  import { TestJobExpectedError, TestSchemaExpectedError } from './errors';
9
+ import {
10
+ TestNumberExpectedError,
11
+ TestOpenApiExpectedError,
12
+ TestPolicyExpectedError,
13
+ } from './matcher-receiver-errors';
9
14
  import type { MatcherResult } from './matcher-result';
10
15
  import type { UltimateMatchers } from './matcher-surface';
11
16
  import type { VisibleOptions } from './matcher-visible';
@@ -234,18 +239,19 @@ const implementations: ExpectExtendMatchers<UltimateMatchers<unknown>> = {
234
239
  return result(actual === code, () => `expected error code ${code}, received ${actual}`);
235
240
  },
236
241
 
237
- async toDenyPolicy(received: unknown, context: Readonly<Record<string, unknown>>) {
238
- const allowed = await decide(received, context);
239
- if (allowed === undefined) {
240
- return result(
241
- false,
242
- () => 'expected a policy — an object with run() (@ultimat3/policy) or evaluate()',
243
- );
242
+ // Not `async` — see `assertStandardSchema`. The receiver check throws synchronously, because a
243
+ // `pass: false` for a non-policy is a PASS under `.not`, and `.not.toDenyPolicy` held on
244
+ // `undefined`. The decision itself is the promise this returns.
245
+ toDenyPolicy(received: unknown, context: Readonly<Record<string, unknown>>) {
246
+ if (!isRunnablePolicy(received) && !isPolicy(received)) {
247
+ throw new TestPolicyExpectedError(received);
244
248
  }
245
249
  // `renderCauseValue`, never `JSON.stringify`: the context is a value a test authored, and this
246
250
  // matcher is asked about policies whose input holds a BigInt id or a back-reference. It quotes
247
251
  // what JSON can quote and names the shape of what it cannot, instead of throwing over either.
248
- return result(!allowed, () => `expected the policy to deny ${renderCauseValue(context)}`);
252
+ return decide(received, context).then((allowed) =>
253
+ result(allowed === false, () => `expected the policy to deny ${renderCauseValue(context)}`),
254
+ );
249
255
  },
250
256
 
251
257
  // Not `async` — see `assertStandardSchema`. The guard is the synchronous prologue; the wait is
@@ -261,13 +267,8 @@ const implementations: ExpectExtendMatchers<UltimateMatchers<unknown>> = {
261
267
  },
262
268
 
263
269
  toMatchOpenApi(received: unknown, committed: OpenApiLike) {
264
- if (!isOpenApiLike(received)) {
265
- return result(
266
- false,
267
- () =>
268
- 'expected an OpenAPI document — an object with operations: [{ operationId, required? }]',
269
- );
270
- }
270
+ // Thrown, never `pass: false` — which `.not` reads as a pass (`X_TEST_OPENAPI_EXPECTED`).
271
+ if (!isOpenApiLike(received)) throw new TestOpenApiExpectedError(received);
271
272
  const broke = breakingChanges(committed, received);
272
273
  return result(
273
274
  broke.length === 0,
@@ -277,11 +278,10 @@ const implementations: ExpectExtendMatchers<UltimateMatchers<unknown>> = {
277
278
  },
278
279
 
279
280
  toBeWithinBudget(received: unknown, limit: number) {
280
- if (typeof received !== 'number') {
281
- return result(
282
- false,
283
- () => `expected a number to compare against the budget, got ${typeof received}`,
284
- );
281
+ // Thrown, never `pass: false` — which `.not` reads as a pass. `NaN` is refused too: every
282
+ // comparison with it is false, so `.not.toBeWithinBudget` held for a measurement that failed.
283
+ if (typeof received !== 'number' || !Number.isFinite(received)) {
284
+ throw new TestNumberExpectedError(received);
285
285
  }
286
286
  return result(
287
287
  received <= limit,
@@ -1,147 +0,0 @@
1
- // The in-process replicator: committed row changes in, `ChangeEvent`s out, fanned into the node.
2
- //
3
- // Production decodes the write-ahead log. PGlite has no walsender and the memory driver has no log,
4
- // so a test process had no change source at all — which is what left the `subscribe` fixture with
5
- // no driver, and its five tests in `examples/dummy` asserting against a snapshot that never moved.
6
- //
7
- // WHAT IS REAL HERE, and it is everything downstream of the decoder: the matcher, the shared window,
8
- // the per-subscriber `visible` gate, the cursor, the frames. This substitutes for the WAL DECODER
9
- // and for nothing else — `@ultimat3/entity`'s `setRowObserver` reports what a repository wrote, in
10
- // this process, and the events are shaped exactly as `PgLogicalReplicationFeed` shapes them.
11
- //
12
- // WHAT IS NOT: a write another process made is invisible, because nothing here reads a log. That is
13
- // the honest bound, and it is why this is a fixture and not a `ChangeFeed` — `selectChangeFeed`
14
- // still decides what a real node reads, and this is never in that decision.
15
- //
16
- // `x dev` is the one boot that installs it outside a test, `As of 2026-09-05` (`@ultimat3/cli`'s
17
- // `dev-live-feed.ts`), and only under the EMBEDDED database: PGlite has no walsender, every role
18
- // runs in that one process, so the bound above holds by construction — and a real `DATABASE_URL`
19
- // gets the decoder instead, never both.
20
-
21
- import type { RowBulkChange, RowChange, RowObserver } from '@ultimat3/entity';
22
- import type { Row } from '@ultimat3/realtime';
23
- import type { ChangeEvent, ChangeOp, LiveQueryRegistry } from '@ultimat3/realtime/server';
24
-
25
- /** What a caller does with a change nobody could deliver. */
26
- export interface LiveReplicatorOptions {
27
- readonly registry: LiveQueryRegistry;
28
- /** Tenant column, hoisted out of the row so fanout filters without parsing it. */
29
- readonly tenantColumn?: string;
30
- readonly onError?: (error: unknown) => void;
31
- }
32
-
33
- export interface LiveReplicator {
34
- /** Resolves when every change observed so far has been fanned out. Never a sleep. */
35
- settled(): Promise<void>;
36
- /** Changes this replicator has delivered — the number a test asserts a patch count against. */
37
- readonly delivered: number;
38
- stop(): void;
39
- }
40
-
41
- const OPS: Readonly<Record<RowChange['op'], ChangeOp>> = {
42
- insert: 'insert',
43
- update: 'update',
44
- delete: 'delete',
45
- };
46
-
47
- /**
48
- * A `ChangeEvent` row is `Row` — a JSON object carrying an `id`. Every row a repository stores has
49
- * one; the cast is what says so to a compiler that only sees `Record<string, unknown>`, and a row
50
- * that genuinely has none fails downstream in `idOf`, with the entity named, exactly as a row off
51
- * the wire would.
52
- */
53
- const asRow = (value: Readonly<Record<string, unknown>> | null): Row | null =>
54
- value === null ? null : (value as unknown as Row);
55
-
56
- /**
57
- * Lexicographically comparable, which is the whole contract of an lsn — `formatLsn` in
58
- * `@ultimat3/realtime` produces the same shape from a real WAL position. A counter is enough here
59
- * because one process observes its own writes in the order it made them.
60
- */
61
- const lsnOf = (position: number): string => position.toString(16).padStart(16, '0');
62
-
63
- /**
64
- * Install the replicator for the length of one test. It takes over the process row observer and
65
- * hands back whatever was installed before, because `bun test` shares one process across files and
66
- * an unconditional clear would take an outer harness's observer with it.
67
- */
68
- export async function startLiveReplicator(options: LiveReplicatorOptions): Promise<LiveReplicator> {
69
- // Awaited BEFORE the observer exists, so installation is the last thing this function does and
70
- // no write between the call and the install can slip past unobserved.
71
- const entity = await import('@ultimat3/entity');
72
- const { registry } = options;
73
- const tenant = options.tenantColumn ?? 'orgId';
74
- let position = 0;
75
- let delivered = 0;
76
- // One promise chain, because ORDERING is the guarantee the whole pipeline is built on — the same
77
- // reason `InMemoryChangeFeed` serializes its deliveries rather than firing them concurrently.
78
- let tail: Promise<void> = Promise.resolve();
79
- let stopped = false;
80
-
81
- const enqueue = (work: () => Promise<void>): void => {
82
- tail = tail.then(work).catch((error: unknown) => {
83
- // Never rethrown into the chain: one failed fanout must not silence every change behind it,
84
- // and a rejection with nobody to hand it to ends the Bun process.
85
- options.onError?.(error);
86
- });
87
- };
88
-
89
- const observer: RowObserver = {
90
- onChange(change: RowChange): void {
91
- if (stopped) return;
92
- position += 1;
93
- const at = position;
94
- const row = change.after ?? change.before;
95
- const orgId = typeof row?.[tenant] === 'string' ? (row[tenant] as string) : null;
96
- const event: ChangeEvent = {
97
- entity: change.entity,
98
- op: OPS[change.op],
99
- before: asRow(change.before),
100
- after: asRow(change.after),
101
- lsn: lsnOf(at),
102
- txid: String(at),
103
- orgId,
104
- // Deliberately not a clock read: the preload freezes `Date.now()`, and a change's commit
105
- // time is not something any assertion in this repo reads. `at` keeps it monotonic anyway.
106
- at,
107
- };
108
- enqueue(async () => {
109
- delivered += await registry.deliver(event);
110
- });
111
- },
112
-
113
- /**
114
- * A filtered write names rows this seam never saw, so there is no event to shape. Every window
115
- * on the node is marked stale instead and re-read on the next change — `invalidate()` is the
116
- * node's own answer to "the change stream skipped something", used here for the one write that
117
- * genuinely does. Silence would be the alternative, and a subscriber told nothing happened
118
- * diverges with nobody ever asking again.
119
- */
120
- onBulk(_change: RowBulkChange): void {
121
- if (stopped) return;
122
- registry.invalidate();
123
- },
124
- };
125
-
126
- const previous = entity.setRowObserver(observer);
127
-
128
- return {
129
- get delivered() {
130
- return delivered;
131
- },
132
- settled: async () => {
133
- // Twice: a fanout can enqueue nothing, but the writes that produced these changes may still
134
- // be resolving their own promises when a test asks. Awaiting the chain, letting the
135
- // microtask queue drain, then awaiting it again covers a change observed in between.
136
- await tail;
137
- for (let turn = 0; turn < 8; turn += 1) await Promise.resolve();
138
- await tail;
139
- },
140
- stop: () => {
141
- stopped = true;
142
- // Restored, never cleared: one process runs every test file, and an outer harness's observer
143
- // must survive an inner fixture finishing.
144
- entity.setRowObserver(previous);
145
- },
146
- };
147
- }