@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/CLAUDE.md +123 -85
- package/README.md +17 -1
- package/package.json +15 -13
- package/src/cdp-browser.ts +94 -0
- package/src/cdp-connection.ts +260 -0
- package/src/cdp-e2e-page.ts +180 -0
- package/src/cdp-e2e-session.ts +199 -0
- package/src/cdp-errors.ts +58 -0
- package/src/cdp-launch.ts +192 -0
- package/src/cdp-offline-script.ts +76 -0
- package/src/cdp-pipe.ts +77 -0
- package/src/e2e-app.ts +106 -0
- package/src/e2e-browser-handle.ts +55 -0
- package/src/e2e-dom-fixture.ts +122 -0
- package/src/e2e-driver.ts +114 -0
- package/src/e2e-error-codes.ts +42 -0
- package/src/e2e-errors.ts +126 -0
- package/src/e2e-evaluate.ts +157 -0
- package/src/e2e-locator.ts +86 -0
- package/src/e2e-page.ts +153 -0
- package/src/e2e-preload.ts +22 -0
- package/src/e2e-probe.ts +23 -0
- package/src/e2e-run.ts +87 -0
- package/src/e2e-selection.ts +192 -0
- package/src/e2e-spawn.ts +195 -0
- package/src/errors.ts +6 -0
- package/src/fixture-subscribe.ts +2 -2
- package/src/index.ts +68 -2
- package/src/island-dom.ts +18 -1
- package/src/island-observers.ts +3 -0
- package/src/live-node.ts +2 -5
- package/src/matcher-receiver-errors.ts +39 -0
- package/src/matchers.ts +20 -20
- package/src/live-replicator.ts +0 -147
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
|
package/src/fixture-subscribe.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/island-observers.ts
CHANGED
|
@@ -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
|
|
81
|
-
//
|
|
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
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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,
|
package/src/live-replicator.ts
DELETED
|
@@ -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
|
-
}
|