@ultimat3/testing 22.15.0 → 24.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 +37 -44
- package/README.md +297 -13
- package/package.json +16 -14
- package/src/auth-request.ts +54 -0
- package/src/budget-store-conformance.ts +81 -0
- package/src/cdp-browser.ts +5 -10
- package/src/cdp-connection.ts +23 -6
- package/src/cdp-e2e-session.ts +10 -1
- package/src/cdp-errors.ts +113 -8
- package/src/cdp-launch-attempt.ts +192 -0
- package/src/cdp-launch.ts +49 -122
- package/src/cdp-offline-script.ts +33 -3
- package/src/cdp-wire-watch.ts +113 -0
- package/src/determinism.ts +53 -4
- package/src/e2e-app.ts +17 -1
- package/src/e2e-driver.ts +11 -2
- package/src/e2e-run.ts +7 -2
- package/src/e2e-spawn.ts +4 -8
- package/src/errors.ts +16 -1
- package/src/factories.ts +4 -6
- package/src/failure-context.ts +42 -0
- package/src/file-boundary.ts +12 -7
- package/src/fixture-island.ts +18 -45
- package/src/fixture-jobs.ts +117 -28
- package/src/fixtures.ts +9 -4
- package/src/free-port.ts +46 -0
- package/src/frozen-scheduler.ts +49 -0
- package/src/harness.ts +2 -1
- package/src/index.ts +41 -1
- package/src/island-dom.ts +8 -4
- package/src/island-html-entities.ts +27 -0
- package/src/island-scratch.ts +41 -0
- package/src/island-state-errors.ts +19 -0
- package/src/island-state-mount.ts +129 -0
- package/src/job-driver-checks.ts +177 -0
- package/src/job-driver-conformance.ts +21 -0
- package/src/live-node.ts +2 -0
- package/src/local-tx.ts +87 -0
- package/src/matcher-row.ts +93 -0
- package/src/matcher-surface.ts +7 -0
- package/src/matchers.ts +6 -0
- package/src/per-test-reset.ts +43 -0
- package/src/preload.ts +14 -7
- package/src/quiet-logs.ts +56 -0
- package/src/registry-leak-guard.ts +3 -1
- package/src/registry-snapshot.ts +22 -1
- package/src/render-view.ts +118 -0
- package/src/sealed-network.ts +5 -1
- package/src/template-db.ts +6 -3
- package/src/test-seal-key.ts +51 -0
- package/src/test-types.ts +53 -30
- package/src/e2e-dom-fixture.ts +0 -122
package/src/index.ts
CHANGED
|
@@ -23,6 +23,12 @@ 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 two arguments `hooks.authenticate` is handed, for a test of an app's own authenticator.
|
|
27
|
+
// `@ultimat3/http` is imported inside the function.
|
|
28
|
+
export type { AuthRequestInit } from './auth-request';
|
|
29
|
+
export { authRequest } from './auth-request';
|
|
30
|
+
export type { BudgetStoreLike } from './budget-store-conformance';
|
|
31
|
+
export { budgetStoreConformance } from './budget-store-conformance';
|
|
26
32
|
// The browser-backed e2e driver and the raw-CDP browser under it — moved here from
|
|
27
33
|
// `@ultimat3/cli` in 22.0.0. `installE2eDriver` is the ONE entry point an app's test preload calls;
|
|
28
34
|
// `openE2eBrowserIfAvailable()` answers `undefined` on a machine with no Chrome, so a
|
|
@@ -46,6 +52,12 @@ export type { CdpE2eTabOptions, E2eTab } from './cdp-e2e-page';
|
|
|
46
52
|
export { cdpE2eTab } from './cdp-e2e-page';
|
|
47
53
|
export type { CdpE2eSessionOptions, E2eSession } from './cdp-e2e-session';
|
|
48
54
|
export { cdpE2eSession } from './cdp-e2e-session';
|
|
55
|
+
export type {
|
|
56
|
+
CdpLaunchAttempt,
|
|
57
|
+
CdpTargetGone,
|
|
58
|
+
CdpTimeoutObservation,
|
|
59
|
+
CdpTimeoutReading,
|
|
60
|
+
} from './cdp-errors';
|
|
49
61
|
export {
|
|
50
62
|
CdpBrowserMissingError,
|
|
51
63
|
CdpCallFailedError,
|
|
@@ -58,6 +70,8 @@ export {
|
|
|
58
70
|
CHROME_PATH_ENV,
|
|
59
71
|
CONTAINER_CHROME_ARGS,
|
|
60
72
|
findChrome,
|
|
73
|
+
LAUNCH_ATTEMPTS,
|
|
74
|
+
LAUNCH_TIMEOUT_MS,
|
|
61
75
|
launchChrome,
|
|
62
76
|
launchFoundChrome,
|
|
63
77
|
} from './cdp-launch';
|
|
@@ -117,6 +131,7 @@ export {
|
|
|
117
131
|
} from './e2e-selection';
|
|
118
132
|
export type { TestingErrorCode } from './errors';
|
|
119
133
|
export {
|
|
134
|
+
AppNotBootedError,
|
|
120
135
|
FixtureUnavailableError,
|
|
121
136
|
NetworkOfflineError,
|
|
122
137
|
NetworkSealedError,
|
|
@@ -167,7 +182,14 @@ export type {
|
|
|
167
182
|
SharedChunkLike,
|
|
168
183
|
} from './fixture-island';
|
|
169
184
|
export { mountIsland } from './fixture-island';
|
|
170
|
-
export type {
|
|
185
|
+
export type {
|
|
186
|
+
JobDrainAs,
|
|
187
|
+
JobEnqueueAs,
|
|
188
|
+
JobRunTrace,
|
|
189
|
+
RunJobs,
|
|
190
|
+
RunJobsOptions,
|
|
191
|
+
StepTally,
|
|
192
|
+
} from './fixture-jobs';
|
|
171
193
|
export { createRunJobs } from './fixture-jobs';
|
|
172
194
|
export type { MailRef, TestMail } from './fixture-mail';
|
|
173
195
|
export { createTestMail } from './fixture-mail';
|
|
@@ -201,6 +223,14 @@ export {
|
|
|
201
223
|
// The file an error tells the reader to edit — the island's own name with `.states.ts` where
|
|
202
224
|
// `.tsx` was. Exported so the command that takes the pictures names the same file the refusal does.
|
|
203
225
|
export { islandStatesFile } from './island-state-errors';
|
|
226
|
+
// One declared state, mounted: `describeIslandState` owns a block's mount and teardown,
|
|
227
|
+
// `mountIslandState` is the one mount a single test asks for with `using`.
|
|
228
|
+
export type { IslandStateMountOptions, IslandStateSuiteOptions } from './island-state-mount';
|
|
229
|
+
export {
|
|
230
|
+
describeIslandState,
|
|
231
|
+
ISLAND_MOUNT_TIMEOUT_MS,
|
|
232
|
+
mountIslandState,
|
|
233
|
+
} from './island-state-mount';
|
|
204
234
|
export type {
|
|
205
235
|
IslandRouteStub,
|
|
206
236
|
IslandState,
|
|
@@ -252,8 +282,12 @@ export {
|
|
|
252
282
|
normalizeIslandName,
|
|
253
283
|
} from './island-states-resolve';
|
|
254
284
|
export { ISOLATED_ENV, releasePluginsAfterIsolatedFile } from './isolated-plugins';
|
|
285
|
+
export { jobDriverConformance } from './job-driver-conformance';
|
|
255
286
|
export type { LiveConnection, LiveNodeHandle, LiveNodeOptions } from './live-node';
|
|
256
287
|
export { createLiveNode } from './live-node';
|
|
288
|
+
// The store a mutator's `local()` half writes into under test — the Map every app hand-rolled.
|
|
289
|
+
export type { LocalRows, MemoryLocalTx } from './local-tx';
|
|
290
|
+
export { memoryLocalTx } from './local-tx';
|
|
257
291
|
/**
|
|
258
292
|
* The budget `toBeVisible(options?)` takes. Exported because it is in a public matcher's signature;
|
|
259
293
|
* `retryUntil` and `RetryBudget` deliberately are NOT — nothing outside this package calls them,
|
|
@@ -272,6 +306,10 @@ export type { RegistryLeak, RegistrySample } from './registry-leak-guard';
|
|
|
272
306
|
export { installRegistryLeakGuard, leakBetween, sampleRegistries } from './registry-leak-guard';
|
|
273
307
|
export type { ProcessRegistrySnapshot } from './registry-snapshot';
|
|
274
308
|
export { captureProcessRegistries, restoreProcessRegistries } from './registry-snapshot';
|
|
309
|
+
// What the server renders, for a unit test. `@ultimat3/render` is imported inside the two
|
|
310
|
+
// functions, so a tier-0 test importing this barrel for `expect` still loads no renderer.
|
|
311
|
+
export type { RenderedRoute, RenderedView, RouteModule, RouteRequest } from './render-view';
|
|
312
|
+
export { renderRoute, renderView } from './render-view';
|
|
275
313
|
export type { DatabaseSnapshot } from './reusable-database';
|
|
276
314
|
export { restoreDatabase, reusableDatabase, snapshotDatabase } from './reusable-database';
|
|
277
315
|
export type { MockRoute, NetworkSnapshot, NetworkState } from './sealed-network';
|
|
@@ -308,12 +346,14 @@ export {
|
|
|
308
346
|
} from './template-db';
|
|
309
347
|
export type {
|
|
310
348
|
E2eBody,
|
|
349
|
+
E2eDriver,
|
|
311
350
|
E2eFixtures,
|
|
312
351
|
EvalCase,
|
|
313
352
|
EvalOptions,
|
|
314
353
|
LocatorLike,
|
|
315
354
|
OpenApiLike,
|
|
316
355
|
PageLike,
|
|
356
|
+
TestOptions,
|
|
317
357
|
TestType,
|
|
318
358
|
} from './test-types';
|
|
319
359
|
export {
|
package/src/island-dom.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// Two neighbours at the 500-line ceiling: `island-selector.ts` is the selector grammar `find`
|
|
7
7
|
// and `all` read, and `island-observers.ts` is the `ResizeObserver` an island measures with.
|
|
8
8
|
|
|
9
|
+
import { decodeEntities } from './island-html-entities';
|
|
9
10
|
import type { FakeResizeObserver } from './island-observers';
|
|
10
11
|
import { createResizeObservers, rectOf } from './island-observers';
|
|
11
12
|
import { matchesSelector, parseSelector } from './island-selector';
|
|
@@ -350,8 +351,10 @@ const VOID_TAGS = new Set([
|
|
|
350
351
|
'track',
|
|
351
352
|
'wbr',
|
|
352
353
|
]);
|
|
354
|
+
// Group 6 is a comment: Solid compiles `<!>` as the marker its `nextSibling` walk counts, so it must
|
|
355
|
+
// be a node — read as text it merged into its neighbours and the walk landed one node short.
|
|
353
356
|
const TOKEN =
|
|
354
|
-
/<(\/?)([a-zA-Z][\w-]*)((?:\s+[^\s=/>]+(?:=(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*(\/?)>|([^<]+)/g;
|
|
357
|
+
/<(\/?)([a-zA-Z][\w-]*)((?:\s+[^\s=/>]+(?:=(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*(\/?)>|([^<]+)|(<!(?:--[\s\S]*?--|[^>]*)>)/g;
|
|
355
358
|
const ATTRIBUTE = /([^\s=/>]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
|
|
356
359
|
|
|
357
360
|
/** Only what `babel-preset-solid` emits into a template: tags, static attributes and text. */
|
|
@@ -360,8 +363,8 @@ export function parseHtml(html: string): FakeNode {
|
|
|
360
363
|
const open: FakeNode[] = [root];
|
|
361
364
|
for (const match of html.matchAll(TOKEN)) {
|
|
362
365
|
const parent = open[open.length - 1] as FakeNode;
|
|
363
|
-
if (match[5] !== undefined) {
|
|
364
|
-
parent.appendChild(new FakeText(match[5]));
|
|
366
|
+
if (match[5] !== undefined || match[6] !== undefined) {
|
|
367
|
+
parent.appendChild(new FakeText(decodeEntities(match[5] ?? '')));
|
|
365
368
|
continue;
|
|
366
369
|
}
|
|
367
370
|
if (match[1] === '/') {
|
|
@@ -370,7 +373,8 @@ export function parseHtml(html: string): FakeNode {
|
|
|
370
373
|
}
|
|
371
374
|
const element = new FakeElement(match[2] as string);
|
|
372
375
|
for (const attr of (match[3] ?? '').matchAll(ATTRIBUTE)) {
|
|
373
|
-
if (attr[1] !== undefined)
|
|
376
|
+
if (attr[1] !== undefined)
|
|
377
|
+
element.setAttribute(attr[1], decodeEntities(attr[2] ?? attr[3] ?? attr[4] ?? ''));
|
|
374
378
|
}
|
|
375
379
|
parent.appendChild(element);
|
|
376
380
|
if (match[4] !== '/' && !VOID_TAGS.has(element.tagName)) open.push(element);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Character references in a compiled island template, decoded once — what a browser's parser does
|
|
2
|
+
// before an island reads `textContent` or `getAttribute`.
|
|
3
|
+
|
|
4
|
+
const NAMED = Object.freeze<Record<string, string>>({
|
|
5
|
+
amp: '&',
|
|
6
|
+
lt: '<',
|
|
7
|
+
gt: '>',
|
|
8
|
+
quot: '"',
|
|
9
|
+
apos: "'",
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const REFERENCE = /&(?:#(\d+)|#[xX]([0-9a-fA-F]+)|([a-zA-Z]+));/g;
|
|
13
|
+
const MAX_CODE_POINT = 0x10ffff;
|
|
14
|
+
const REPLACEMENT = '\uFFFD';
|
|
15
|
+
|
|
16
|
+
/** The five named entities Solid's escaper writes, and numeric references; an unknown name as written. */
|
|
17
|
+
export function decodeEntities(text: string): string {
|
|
18
|
+
if (!text.includes('&')) return text;
|
|
19
|
+
return text.replace(REFERENCE, (whole, decimal?: string, hex?: string, name?: string) => {
|
|
20
|
+
if (name !== undefined) return Object.hasOwn(NAMED, name) ? (NAMED[name] as string) : whole;
|
|
21
|
+
const point = decimal !== undefined ? Number(decimal) : Number.parseInt(hex as string, 16);
|
|
22
|
+
// What the HTML parser does with a reference no character answers to: U+FFFD, never `\0` or a
|
|
23
|
+
// lone surrogate.
|
|
24
|
+
const invalid = point === 0 || point > MAX_CODE_POINT || (point >= 0xd800 && point <= 0xdfff);
|
|
25
|
+
return invalid ? REPLACEMENT : String.fromCodePoint(point);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// The island fixture's scratch directories: one per mount, created when the mount needs it and
|
|
2
|
+
// removed when the mount ends. A per-PROCESS directory left one behind per `bun test` run, because
|
|
3
|
+
// `process.on('exit')` never fires under `bun test` (Bun 1.4.0, measured) — 5,000+ in `/tmp`.
|
|
4
|
+
|
|
5
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
6
|
+
// why: Bun exposes no tmpdir(), so only node:os answers the platform temp root.
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
const live = new Set<string>();
|
|
11
|
+
let exitHooked = false;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A fresh directory for one mount. Fresh, not shared: a module is cached by PATH, so two mounts
|
|
15
|
+
* of byte-identical chunks under one directory imported as ONE instance and shared its state.
|
|
16
|
+
*
|
|
17
|
+
* The `exit` handler is the backstop for a process that is not `bun test` (`bun run`, `x shot`);
|
|
18
|
+
* under `bun test` the file boundary and the run's `afterAll` dispose every mount instead.
|
|
19
|
+
*/
|
|
20
|
+
export function createScratchDir(): string {
|
|
21
|
+
const dir = mkdtempSync(join(tmpdir(), 'ultimate-island-'));
|
|
22
|
+
live.add(dir);
|
|
23
|
+
if (!exitHooked) {
|
|
24
|
+
exitHooked = true;
|
|
25
|
+
process.on('exit', removeAllScratchDirs);
|
|
26
|
+
}
|
|
27
|
+
return dir;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Remove one mount's directory. Idempotent: dispose and a failed mount may both reach it. */
|
|
31
|
+
export function removeScratchDir(dir: string): void {
|
|
32
|
+
live.delete(dir);
|
|
33
|
+
rmSync(dir, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function removeAllScratchDirs(): void {
|
|
37
|
+
for (const dir of [...live]) removeScratchDir(dir);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Every scratch directory whose mount has not ended — read by the leak test. */
|
|
41
|
+
export const islandScratchDirs = (): readonly string[] => [...live];
|
|
@@ -234,3 +234,22 @@ export class IslandStatesAmbiguousError extends UltimateError {
|
|
|
234
234
|
});
|
|
235
235
|
}
|
|
236
236
|
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* A state id the manifest does not declare — a test naming `idel`, or a state renamed in the
|
|
240
|
+
* manifest and not in the test beside it. Listing the declared ids is the whole value, for the
|
|
241
|
+
* reason `IslandStatesUnknownError` lists names: a typo and a rename are one symptom.
|
|
242
|
+
*/
|
|
243
|
+
export class IslandStateUnknownError extends UltimateError {
|
|
244
|
+
constructor(input: {
|
|
245
|
+
readonly island: string;
|
|
246
|
+
readonly id: string;
|
|
247
|
+
readonly known: readonly string[];
|
|
248
|
+
}) {
|
|
249
|
+
super({
|
|
250
|
+
code: 'X_TEST_ISLAND_STATE_UNKNOWN',
|
|
251
|
+
cause: `${renderCauseValue(input.island)} declares no state ${renderCauseValue(input.id)}; declared: ${input.known.join(', ')}`,
|
|
252
|
+
fix: `name one of them instead: ${input.known[0] ?? ''} — or declare the state in ${at(input.island)}`,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// One declared island STATE, mounted: the lookup every island test hand-rolled, and the suite
|
|
2
|
+
// form that owns the `beforeAll` / `afterAll` a mount needs because it installs a process-global
|
|
3
|
+
// `document`. The props a test asserts on are the props `x shot --island` photographs, so the
|
|
4
|
+
// picture and the test cannot be of two different components.
|
|
5
|
+
|
|
6
|
+
import { afterAll, beforeAll, describe } from 'bun:test';
|
|
7
|
+
import { assert } from '@ultimat3/core';
|
|
8
|
+
import type {
|
|
9
|
+
IslandBuilder,
|
|
10
|
+
IslandBundleLike,
|
|
11
|
+
MountedIsland,
|
|
12
|
+
MountIslandOptions,
|
|
13
|
+
} from './fixture-island';
|
|
14
|
+
import { mountIsland } from './fixture-island';
|
|
15
|
+
import { IslandStateUnknownError } from './island-state-errors';
|
|
16
|
+
import type { IslandStatesManifest } from './island-states';
|
|
17
|
+
import type { TestOptions } from './test-types';
|
|
18
|
+
import { testName } from './test-types';
|
|
19
|
+
|
|
20
|
+
/** What a state's mount takes: `mountIsland`'s options, minus what the manifest already says. */
|
|
21
|
+
export type IslandStateMountOptions = Omit<MountIslandOptions, 'file' | 'props'>;
|
|
22
|
+
|
|
23
|
+
/** The suite form's: the mount's own, plus the deadline of the `beforeAll` that runs it. */
|
|
24
|
+
export interface IslandStateSuiteOptions extends IslandStateMountOptions, TestOptions {}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The deadline a block's mount gets when it names none. The first mount in a file pays the island
|
|
28
|
+
* build — a Babel pass and a browser bundle, seconds not milliseconds — and Bun's 5 s default is
|
|
29
|
+
* what a cold CI runner misses.
|
|
30
|
+
*/
|
|
31
|
+
export const ISLAND_MOUNT_TIMEOUT_MS = 60_000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Bundles already built in this process, per builder, by `<root>\0<island>`. A file with three
|
|
35
|
+
* states used to build its island three times; the source cannot change between two mounts of one
|
|
36
|
+
* test run, so the second is a lookup. A build that REJECTS is forgotten, so the next mount
|
|
37
|
+
* reports its own failure rather than a cached one.
|
|
38
|
+
*/
|
|
39
|
+
const bundles = new WeakMap<IslandBuilder, Map<string, Promise<IslandBundleLike>>>();
|
|
40
|
+
|
|
41
|
+
const buildOnce =
|
|
42
|
+
(build: IslandBuilder): IslandBuilder =>
|
|
43
|
+
(root, options) => {
|
|
44
|
+
const built = bundles.get(build) ?? new Map<string, Promise<IslandBundleLike>>();
|
|
45
|
+
bundles.set(build, built);
|
|
46
|
+
const key = `${root}\0${options?.only ?? ''}`;
|
|
47
|
+
const known = built.get(key);
|
|
48
|
+
if (known !== undefined) return known;
|
|
49
|
+
const pending = build(root, options);
|
|
50
|
+
built.set(key, pending);
|
|
51
|
+
pending.catch(() => built.delete(key));
|
|
52
|
+
return pending;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Mount one state the manifest declares. `using island = await mountIslandState(states, 'idle',
|
|
57
|
+
* { build: buildIslands, root })` inside one test — `using` owns the teardown. A file asserting
|
|
58
|
+
* several things about one state uses `describeIslandState`, which owns it for the block.
|
|
59
|
+
*/
|
|
60
|
+
export async function mountIslandState(
|
|
61
|
+
manifest: IslandStatesManifest,
|
|
62
|
+
id: string,
|
|
63
|
+
options: IslandStateMountOptions,
|
|
64
|
+
): Promise<MountedIsland> {
|
|
65
|
+
const state = manifest.states.find((one) => one.id === id);
|
|
66
|
+
if (state === undefined) {
|
|
67
|
+
throw new IslandStateUnknownError({
|
|
68
|
+
island: manifest.island,
|
|
69
|
+
id,
|
|
70
|
+
known: manifest.states.map((one) => one.id),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return mountIsland({
|
|
74
|
+
...options,
|
|
75
|
+
build: buildOnce(options.build),
|
|
76
|
+
file: manifest.island,
|
|
77
|
+
props: state.props,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* One block per declared state: mounted before its first test, disposed after its last, with the
|
|
83
|
+
* island built once per file. `body` is handed an ACCESSOR, for `describeApp`'s reason — the
|
|
84
|
+
* block is declared at module scope and the mount does not exist until `beforeAll` has run:
|
|
85
|
+
*
|
|
86
|
+
* ```ts
|
|
87
|
+
* describeIslandState(counterStates, 'idle', { build: buildIslands, root }, (island) => {
|
|
88
|
+
* test('a click reaches the DOM', () => {
|
|
89
|
+
* expect(island().fire('button', 'click')).toBe(true);
|
|
90
|
+
* });
|
|
91
|
+
* });
|
|
92
|
+
* ```
|
|
93
|
+
*
|
|
94
|
+
* Calls `describe`, so it goes at declaration scope. The block is named for the `unit` step — an
|
|
95
|
+
* island test is `<name>.island.test.ts`, which that step owns.
|
|
96
|
+
*/
|
|
97
|
+
export function describeIslandState(
|
|
98
|
+
manifest: IslandStatesManifest,
|
|
99
|
+
id: string,
|
|
100
|
+
options: IslandStateSuiteOptions,
|
|
101
|
+
body: (island: () => MountedIsland) => void,
|
|
102
|
+
): void {
|
|
103
|
+
const { timeoutMs, ...mount } = options;
|
|
104
|
+
describe(testName('unit', `the ${manifest.name} island, ${id}`), () => {
|
|
105
|
+
let mounted: MountedIsland | undefined;
|
|
106
|
+
let over = false;
|
|
107
|
+
beforeAll(async () => {
|
|
108
|
+
const island = await mountIslandState(manifest, id, mount);
|
|
109
|
+
// A mount that outlived its deadline lands after `afterAll` has already run: disposed here,
|
|
110
|
+
// or its fake `document` is every later block's.
|
|
111
|
+
if (over) island[Symbol.dispose]();
|
|
112
|
+
else mounted = island;
|
|
113
|
+
}, timeoutMs ?? ISLAND_MOUNT_TIMEOUT_MS);
|
|
114
|
+
// Bun runs `afterAll` whether or not the mount resolved, so this reads what is there.
|
|
115
|
+
afterAll(() => {
|
|
116
|
+
over = true;
|
|
117
|
+
mounted?.[Symbol.dispose]();
|
|
118
|
+
mounted = undefined;
|
|
119
|
+
});
|
|
120
|
+
body(() => {
|
|
121
|
+
assert(
|
|
122
|
+
mounted !== undefined,
|
|
123
|
+
`the ${manifest.name} island's "${id}" state is not mounted — its mount failed, or the accessor was called outside this block's tests`,
|
|
124
|
+
'call the accessor inside a test of this describeIslandState block; a failed mount is reported above this line, by its own error',
|
|
125
|
+
);
|
|
126
|
+
return mounted;
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// What every `JobDriver` must do, as checks over a driver: claim, ack, nack, the claim fence, lease
|
|
2
|
+
// expiry and the burial of a row whose lease lapsed on its final attempt. A custom `claim` that
|
|
3
|
+
// ignored `onExhausted` / `dropExhausted` compiled and re-claimed a poisoned row forever.
|
|
4
|
+
// Type-only imports: the barrel never loads `@ultimat3/jobs`.
|
|
5
|
+
|
|
6
|
+
import { expect } from 'bun:test';
|
|
7
|
+
import type { ClaimOptions, JobDriver, JobRecord, QueueStats } from '@ultimat3/jobs';
|
|
8
|
+
|
|
9
|
+
export interface JobDriverCheck {
|
|
10
|
+
readonly name: string;
|
|
11
|
+
run(driver: JobDriver): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let minted = 0;
|
|
15
|
+
|
|
16
|
+
/** A queue and a job name of this check's own, so checks sharing one store never see each other. */
|
|
17
|
+
const scope = (): { readonly queue: string; readonly name: string } => {
|
|
18
|
+
minted += 1;
|
|
19
|
+
const tag = `${process.pid.toString(36)}-${minted.toString(36)}`;
|
|
20
|
+
return { queue: `conformance-${tag}`, name: `conformance.job-${tag}` };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const enqueue = async (
|
|
24
|
+
driver: JobDriver,
|
|
25
|
+
at: { readonly queue: string; readonly name: string },
|
|
26
|
+
maxAttempts: number,
|
|
27
|
+
key = 'k',
|
|
28
|
+
): Promise<string> =>
|
|
29
|
+
(
|
|
30
|
+
await driver.enqueue({
|
|
31
|
+
name: at.name,
|
|
32
|
+
queue: at.queue,
|
|
33
|
+
input: {},
|
|
34
|
+
idempotencyKey: key,
|
|
35
|
+
maxAttempts,
|
|
36
|
+
})
|
|
37
|
+
).id;
|
|
38
|
+
|
|
39
|
+
const claim = (
|
|
40
|
+
driver: JobDriver,
|
|
41
|
+
queue: string,
|
|
42
|
+
workerId: string,
|
|
43
|
+
extra: Partial<ClaimOptions> = {},
|
|
44
|
+
) => driver.claim({ queues: [queue], limit: 10, visibilityTimeoutMs: 30_000, workerId, ...extra });
|
|
45
|
+
|
|
46
|
+
const statsOf = async (driver: JobDriver, queue: string): Promise<QueueStats | undefined> =>
|
|
47
|
+
(await driver.stats()).find((each) => each.queue === queue);
|
|
48
|
+
|
|
49
|
+
interface Lapsed {
|
|
50
|
+
readonly id: string;
|
|
51
|
+
readonly queue: string;
|
|
52
|
+
readonly reported: readonly (readonly JobRecord[])[];
|
|
53
|
+
/** How many rows the second claim handed out. */
|
|
54
|
+
readonly handed: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Two claims, the first with a lease that has already lapsed (0 ms): the second re-claims the row
|
|
59
|
+
* or, on its final attempt, buries it. `drop` names the job in `dropExhausted`.
|
|
60
|
+
*/
|
|
61
|
+
async function lapse(driver: JobDriver, maxAttempts: number, drop = false): Promise<Lapsed> {
|
|
62
|
+
const at = scope();
|
|
63
|
+
const id = await enqueue(driver, at, maxAttempts);
|
|
64
|
+
expect(await claim(driver, at.queue, 'w1', { visibilityTimeoutMs: 0 })).toHaveLength(1);
|
|
65
|
+
const reported: JobRecord[][] = [];
|
|
66
|
+
const second = await claim(driver, at.queue, 'w2', {
|
|
67
|
+
...(drop ? { dropExhausted: [at.name] } : {}),
|
|
68
|
+
onExhausted: (dead) => reported.push([...dead]),
|
|
69
|
+
});
|
|
70
|
+
return { id, queue: at.queue, reported, handed: second.length };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const buried = (lapsed: Lapsed): readonly (readonly [string, string])[][] =>
|
|
74
|
+
lapsed.reported.map((rows) => rows.map((row) => [row.id, row.state] as const));
|
|
75
|
+
|
|
76
|
+
export const JOB_DRIVER_CHECKS: readonly JobDriverCheck[] = [
|
|
77
|
+
{
|
|
78
|
+
name: 'a row is claimed once, and an ack by its claim settles it',
|
|
79
|
+
async run(driver) {
|
|
80
|
+
const at = scope();
|
|
81
|
+
const id = await enqueue(driver, at, 3);
|
|
82
|
+
const [first, ...rest] = await claim(driver, at.queue, 'w1');
|
|
83
|
+
expect(rest).toEqual([]);
|
|
84
|
+
expect(first).toMatchObject({ id, state: 'running', claimedBy: 'w1' });
|
|
85
|
+
expect(await claim(driver, at.queue, 'w2')).toEqual([]);
|
|
86
|
+
expect(await driver.ack(id, { workerId: 'w1', claim: first?.claim ?? -1 })).toBe(true);
|
|
87
|
+
expect(await claim(driver, at.queue, 'w2')).toEqual([]);
|
|
88
|
+
expect((await statsOf(driver, at.queue))?.running ?? 0).toBe(0);
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: 'a live idempotency key dedupes onto the row that holds it',
|
|
93
|
+
async run(driver) {
|
|
94
|
+
const at = scope();
|
|
95
|
+
const id = await enqueue(driver, at, 3, 'same');
|
|
96
|
+
const again = await driver.enqueue({
|
|
97
|
+
name: at.name,
|
|
98
|
+
queue: at.queue,
|
|
99
|
+
input: {},
|
|
100
|
+
idempotencyKey: 'same',
|
|
101
|
+
maxAttempts: 3,
|
|
102
|
+
});
|
|
103
|
+
expect(again).toMatchObject({ id, deduped: true });
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: 'an empty queue list is refused, never read as every queue or as default',
|
|
108
|
+
async run(driver) {
|
|
109
|
+
const refused = await Promise.resolve()
|
|
110
|
+
.then(() => driver.claim({ queues: [], limit: 1, visibilityTimeoutMs: 1, workerId: 'w' }))
|
|
111
|
+
.then(
|
|
112
|
+
() => undefined,
|
|
113
|
+
(error: unknown) => error,
|
|
114
|
+
);
|
|
115
|
+
expect(refused).toMatchObject({ code: 'X_JOB_CLAIM_QUEUES_EMPTY' });
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: 'a nack hands the row back, and the claim it ended can no longer settle it',
|
|
120
|
+
async run(driver) {
|
|
121
|
+
const at = scope();
|
|
122
|
+
const id = await enqueue(driver, at, 3);
|
|
123
|
+
const [first] = await claim(driver, at.queue, 'w1');
|
|
124
|
+
const one = { workerId: 'w1', claim: first?.claim ?? -1 };
|
|
125
|
+
expect(await driver.nack(id, { ...one, delayMs: 0 })).toBe(true);
|
|
126
|
+
const [second] = await claim(driver, at.queue, 'w1');
|
|
127
|
+
expect(second?.id).toBe(id);
|
|
128
|
+
expect(second?.claim).toBeGreaterThan(one.claim);
|
|
129
|
+
expect(await driver.ack(id, one)).toBe(false);
|
|
130
|
+
expect(await driver.ack(id, { workerId: 'w1', claim: second?.claim ?? -1 })).toBe(true);
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: 'a dead-lettered row is counted dead and never claimed again',
|
|
135
|
+
async run(driver) {
|
|
136
|
+
const at = scope();
|
|
137
|
+
const id = await enqueue(driver, at, 3);
|
|
138
|
+
const [first] = await claim(driver, at.queue, 'w1');
|
|
139
|
+
const by = { workerId: 'w1', claim: first?.claim ?? -1, delayMs: 0, deadLetter: true };
|
|
140
|
+
expect(await driver.nack(id, by)).toBe(true);
|
|
141
|
+
expect(await claim(driver, at.queue, 'w1')).toEqual([]);
|
|
142
|
+
expect(await statsOf(driver, at.queue)).toMatchObject({ dead: 1 });
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: 'a lapsed lease is re-claimed, and the worker that lost it can neither renew nor settle',
|
|
147
|
+
async run(driver) {
|
|
148
|
+
const { id, handed } = await lapse(driver, 3);
|
|
149
|
+
expect(handed).toBe(1);
|
|
150
|
+
const renew = (workerId: string) =>
|
|
151
|
+
driver.heartbeat(id, { visibilityTimeoutMs: 30_000, workerId });
|
|
152
|
+
expect(await renew('w1')).toBe(false);
|
|
153
|
+
expect(await renew('w2')).toBe(true);
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: 'a lease lapsed on the final attempt is buried dead, reported once, never handed out',
|
|
158
|
+
async run(driver) {
|
|
159
|
+
const lapsed = await lapse(driver, 1);
|
|
160
|
+
expect(lapsed.handed).toBe(0);
|
|
161
|
+
expect(buried(lapsed)).toEqual([[[lapsed.id, 'dead']]]);
|
|
162
|
+
let again = 0;
|
|
163
|
+
const third = await claim(driver, lapsed.queue, 'w3', { onExhausted: () => (again += 1) });
|
|
164
|
+
expect([third.length, again]).toEqual([0, 0]);
|
|
165
|
+
expect(await statsOf(driver, lapsed.queue)).toMatchObject({ dead: 1, running: 0 });
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: 'dropExhausted buries the named job failed, not dead',
|
|
170
|
+
async run(driver) {
|
|
171
|
+
const lapsed = await lapse(driver, 1, true);
|
|
172
|
+
expect(lapsed.handed).toBe(0);
|
|
173
|
+
expect(buried(lapsed)).toEqual([[[lapsed.id, 'failed']]]);
|
|
174
|
+
expect(await statsOf(driver, lapsed.queue)).toMatchObject({ failed: 1, dead: 0 });
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
];
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// The `JobDriver` contract as shared examples: `behavesLike(jobDriverConformance, () => driver)`
|
|
2
|
+
// runs every check in `job-driver-checks.ts` against a driver of your own, one test per check.
|
|
3
|
+
// The subject is called once per test — hand back a fresh driver, or one store with no rows a
|
|
4
|
+
// check could mistake for its own (each check claims only from a queue it named).
|
|
5
|
+
|
|
6
|
+
import { test } from 'bun:test';
|
|
7
|
+
import type { JobDriver } from '@ultimat3/jobs';
|
|
8
|
+
import { JOB_DRIVER_CHECKS } from './job-driver-checks';
|
|
9
|
+
import type { SharedExamples } from './shared-examples';
|
|
10
|
+
import { sharedExamples } from './shared-examples';
|
|
11
|
+
|
|
12
|
+
export const jobDriverConformance: SharedExamples<JobDriver | Promise<JobDriver>> = sharedExamples(
|
|
13
|
+
'a job driver',
|
|
14
|
+
(subject) => {
|
|
15
|
+
for (const check of JOB_DRIVER_CHECKS) {
|
|
16
|
+
test(check.name, async () => {
|
|
17
|
+
await check.run(await subject());
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
);
|
package/src/live-node.ts
CHANGED
|
@@ -178,6 +178,8 @@ export async function createLiveNode(options: LiveNodeOptions = {}): Promise<Liv
|
|
|
178
178
|
data = upgradeOptions.data;
|
|
179
179
|
return true;
|
|
180
180
|
},
|
|
181
|
+
// No socket behind this harness, so no peer address: a health path answers the verdict.
|
|
182
|
+
requestIP: () => null,
|
|
181
183
|
};
|
|
182
184
|
// `/_x/sync` is `SyncNodeOptions.path`'s default and `handleUpgrade` answers 404 to
|
|
183
185
|
// anything else. The `c` parameter is this connection's actor key — see `ACTORS` above.
|
package/src/local-tx.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// A mutator's `local()` half, run in a unit test: a `LocalTx` over rows held in memory, with the
|
|
2
|
+
// semantics of the page's record store. The twin under test is the app's; this is only the store
|
|
3
|
+
// it writes into, so a test asserts on rows and holds no cast and no hand-rolled table.
|
|
4
|
+
|
|
5
|
+
import type { LocalTable, LocalTx } from '@ultimat3/action';
|
|
6
|
+
|
|
7
|
+
/** `{ <table>: { <key>: <row> } }` — what the store holds, as a test seeds it and reads it back. */
|
|
8
|
+
export type LocalRows<TRow extends object = object> = Readonly<
|
|
9
|
+
Record<string, Readonly<Record<string, TRow>>>
|
|
10
|
+
>;
|
|
11
|
+
|
|
12
|
+
export interface MemoryLocalTx {
|
|
13
|
+
/** What `mutator.local(tx, input)` is handed. `tx.<table>` and `tx.table('<name>')` are one table. */
|
|
14
|
+
readonly tx: LocalTx;
|
|
15
|
+
/** What a table holds now, by key — after however many applications. `{}` for one never seen. */
|
|
16
|
+
rows<TRow extends object = object>(table: string): Readonly<Record<string, TRow>>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** `undefined` in a patch means "leave it alone", exactly as the record store reads it. */
|
|
20
|
+
const definedOf = (columns: object): Record<string, unknown> =>
|
|
21
|
+
Object.fromEntries(Object.entries(columns).filter(([, value]) => value !== undefined));
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A client store for one test. Keyed, exactly like the real one: the browser holds no entity
|
|
25
|
+
* schema, so a row is addressed by the key its server twin answers under, never by a column.
|
|
26
|
+
*
|
|
27
|
+
* | Call | Does |
|
|
28
|
+
* |---|---|
|
|
29
|
+
* | `insert(key, row)` | stores the row under the key |
|
|
30
|
+
* | `upsert(key, row)` | merges over what is held; an `undefined` field leaves the column alone |
|
|
31
|
+
* | `update(key, patch)` | changed fields, or a function of the held row. A NO-OP for a key the table does not hold — a twin must not invent a row |
|
|
32
|
+
* | `delete(key)` | removes it |
|
|
33
|
+
*/
|
|
34
|
+
export function memoryLocalTx(seed: LocalRows = {}): MemoryLocalTx {
|
|
35
|
+
const tables = new Map<string, Map<string, object>>();
|
|
36
|
+
const held = (name: string): Map<string, object> => {
|
|
37
|
+
const existing = tables.get(name);
|
|
38
|
+
if (existing !== undefined) return existing;
|
|
39
|
+
const created = new Map<string, object>();
|
|
40
|
+
tables.set(name, created);
|
|
41
|
+
return created;
|
|
42
|
+
};
|
|
43
|
+
// Frozen on the way in, as the record store freezes: a twin that mutated a row in place would
|
|
44
|
+
// pass here and corrupt the synced truth it was handed in a browser.
|
|
45
|
+
const stored = (row: object): object => Object.freeze({ ...row });
|
|
46
|
+
for (const [name, rows] of Object.entries(seed)) {
|
|
47
|
+
for (const [key, row] of Object.entries(rows)) held(name).set(key, stored(row));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The one widening in this file: the store holds rows of whatever shape a test seeded, and a
|
|
51
|
+
// table is read back as the shape its caller names — the same door `tx.table<TRow>()` is.
|
|
52
|
+
const table = <TRow extends object>(name: string): LocalTable<TRow> => {
|
|
53
|
+
const rows = held(name) as Map<string, TRow>;
|
|
54
|
+
return {
|
|
55
|
+
get: (key) => rows.get(key),
|
|
56
|
+
all: () => [...rows.values()],
|
|
57
|
+
insert: (key, row) => {
|
|
58
|
+
rows.set(key, stored(row) as TRow);
|
|
59
|
+
},
|
|
60
|
+
upsert: (key, row) => {
|
|
61
|
+
rows.set(key, stored({ ...rows.get(key), ...definedOf(row) }) as TRow);
|
|
62
|
+
},
|
|
63
|
+
update: (key, patch) => {
|
|
64
|
+
const current = rows.get(key);
|
|
65
|
+
if (current === undefined) return;
|
|
66
|
+
const changed = typeof patch === 'function' ? patch(current) : patch;
|
|
67
|
+
rows.set(key, stored({ ...current, ...definedOf(changed) }) as TRow);
|
|
68
|
+
},
|
|
69
|
+
delete: (key) => {
|
|
70
|
+
rows.delete(key);
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const tx = new Proxy({} as LocalTx, {
|
|
76
|
+
get: (_target, property) => {
|
|
77
|
+
if (typeof property !== 'string') return undefined;
|
|
78
|
+
return property === 'table' ? table : table(property);
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
tx,
|
|
84
|
+
rows: <TRow extends object = object>(name: string): Readonly<Record<string, TRow>> =>
|
|
85
|
+
Object.fromEntries(tables.get(name) ?? []) as Record<string, TRow>,
|
|
86
|
+
};
|
|
87
|
+
}
|