@ultimat3/testing 24.0.0 → 25.1.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 +12 -12
- package/README.md +55 -18
- package/package.json +16 -16
- package/src/auth-request.ts +1 -1
- package/src/cdp-browser-lease.ts +112 -0
- package/src/cdp-browser.ts +42 -1
- package/src/cdp-connection.ts +18 -4
- package/src/cdp-e2e-session.ts +120 -12
- package/src/cdp-errors.ts +25 -9
- package/src/cdp-launch-attempt.ts +112 -80
- package/src/cdp-launch-candidates.ts +62 -0
- package/src/cdp-launch-profile.ts +106 -0
- package/src/cdp-launch-reap.ts +68 -0
- package/src/cdp-launch-wire.ts +171 -0
- package/src/cdp-launch.ts +47 -22
- package/src/define-island-states.ts +47 -8
- package/src/determinism.ts +2 -2
- package/src/e2e-app.ts +14 -1
- package/src/e2e-errors.ts +14 -3
- package/src/e2e-locale.ts +12 -21
- package/src/e2e-page.ts +12 -26
- package/src/e2e-run.ts +21 -11
- package/src/e2e-service-worker.ts +92 -0
- package/src/e2e-spawn.ts +57 -9
- package/src/errors.ts +20 -65
- package/src/fixture-clock.ts +1 -1
- package/src/fixture-island.ts +39 -56
- package/src/fixture-jobs.ts +7 -7
- package/src/fixture-mail.ts +3 -3
- package/src/fixture-network.ts +1 -1
- package/src/fixture-statements.ts +1 -1
- package/src/fixture-subscribe.ts +3 -3
- package/src/framework-fixtures.ts +13 -13
- package/src/index.ts +17 -10
- package/src/island-dom.ts +49 -9
- package/src/island-globals.ts +73 -0
- package/src/island-scratch.ts +67 -3
- package/src/island-state-errors.ts +16 -0
- package/src/island-states.ts +23 -0
- package/src/live-node.ts +4 -4
- package/src/quiet-logs.ts +1 -1
- package/src/registry-leak-error.ts +81 -0
- package/src/registry-leak-guard.ts +4 -3
- package/src/registry-snapshot.ts +19 -1
- package/src/render-view.ts +3 -3
- package/src/sealed-network.ts +45 -4
- package/src/sealed-sockets.ts +59 -0
- package/src/template-db.ts +2 -2
- package/src/test-types.ts +6 -2
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
// `actorFor`, …). Called by the preload, which is why an app never writes
|
|
3
3
|
// `defineFixtures({ clock })` and why two apps cannot disagree about what `clock` means.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { testClock } from './fixture-clock';
|
|
6
6
|
import { DRIVER_FIXTURE_NAMES, driverFixtures } from './fixture-drivers';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
7
|
+
import { testJobs } from './fixture-jobs';
|
|
8
|
+
import { testMail } from './fixture-mail';
|
|
9
|
+
import { testNetwork } from './fixture-network';
|
|
10
|
+
import { testStatements } from './fixture-statements';
|
|
11
|
+
import { subscribeDriver } from './fixture-subscribe';
|
|
12
12
|
import { defineFixtures } from './fixtures';
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -24,7 +24,7 @@ export const FRAMEWORK_FIXTURE_NAMES = [
|
|
|
24
24
|
'runJobs',
|
|
25
25
|
'statements',
|
|
26
26
|
// Moved here from `DRIVER_FIXTURE_NAMES` on 2026-08-20: the driver it was waiting for is
|
|
27
|
-
// `
|
|
27
|
+
// `subscribeDriver()`, and the framework can build one — a whole `sync` node in this
|
|
28
28
|
// process, over the change source `@ultimat3/entity`'s `setRowObserver` gives it. The four left
|
|
29
29
|
// in that list all need something the framework genuinely cannot bundle: a browser, or a
|
|
30
30
|
// second build.
|
|
@@ -48,14 +48,14 @@ export const ALL_FIXTURE_NAMES: readonly string[] = [
|
|
|
48
48
|
export function registerFrameworkFixtures(): void {
|
|
49
49
|
defineFixtures({
|
|
50
50
|
...driverFixtures(),
|
|
51
|
-
clock:
|
|
52
|
-
mail:
|
|
53
|
-
network:
|
|
54
|
-
runJobs:
|
|
55
|
-
statements:
|
|
51
|
+
clock: testClock,
|
|
52
|
+
mail: testMail,
|
|
53
|
+
network: testNetwork,
|
|
54
|
+
runJobs: testJobs,
|
|
55
|
+
statements: testStatements,
|
|
56
56
|
// After the spread, so it REPLACES the `unavailableFixture('subscribe')` declaration above it —
|
|
57
57
|
// `defineFixtures` merges and the last registration wins, which is the same seam an app's own
|
|
58
58
|
// driver uses.
|
|
59
|
-
subscribe: async () => (await
|
|
59
|
+
subscribe: async () => (await subscribeDriver()).subscribe,
|
|
60
60
|
});
|
|
61
61
|
}
|
package/src/index.ts
CHANGED
|
@@ -37,9 +37,15 @@ export { budgetStoreConformance } from './budget-store-conformance';
|
|
|
37
37
|
export type { E2eBrowser, OpenE2eBrowserOptions } from './cdp-browser';
|
|
38
38
|
export {
|
|
39
39
|
DEFAULT_CDP_TIMEOUT_MS,
|
|
40
|
+
E2E_BROWSER_CLOSE_MS,
|
|
41
|
+
E2E_BROWSER_OPEN_MS,
|
|
42
|
+
E2E_GOTO_MS,
|
|
43
|
+
E2E_TAB_OPEN_MS,
|
|
40
44
|
openE2eBrowser,
|
|
41
45
|
openE2eBrowserIfAvailable,
|
|
42
46
|
} from './cdp-browser';
|
|
47
|
+
export type { E2eBrowserLease, RunEndHook } from './cdp-browser-lease';
|
|
48
|
+
export { closeE2eBrowsersAtRunEnd, leaseE2eBrowser } from './cdp-browser-lease';
|
|
43
49
|
export type {
|
|
44
50
|
CdpConnection,
|
|
45
51
|
CdpConnectionOptions,
|
|
@@ -71,6 +77,7 @@ export {
|
|
|
71
77
|
CONTAINER_CHROME_ARGS,
|
|
72
78
|
findChrome,
|
|
73
79
|
LAUNCH_ATTEMPTS,
|
|
80
|
+
LAUNCH_BUDGET_MS,
|
|
74
81
|
LAUNCH_TIMEOUT_MS,
|
|
75
82
|
launchChrome,
|
|
76
83
|
launchFoundChrome,
|
|
@@ -89,7 +96,6 @@ export {
|
|
|
89
96
|
captureDeterminism,
|
|
90
97
|
DEFAULT_NOW,
|
|
91
98
|
DEFAULT_SEED,
|
|
92
|
-
frozenClock,
|
|
93
99
|
frozenNow,
|
|
94
100
|
installDeterminism,
|
|
95
101
|
isDeterminismInstalled,
|
|
@@ -98,9 +104,10 @@ export {
|
|
|
98
104
|
seededRandom,
|
|
99
105
|
seededUuid,
|
|
100
106
|
setFrozenClock,
|
|
107
|
+
withFrozenClock,
|
|
101
108
|
} from './determinism';
|
|
102
109
|
export type { E2eApp, E2eAppMode, StartE2eAppOptions } from './e2e-app';
|
|
103
|
-
export { startE2eApp } from './e2e-app';
|
|
110
|
+
export { E2E_APP_START_MS, E2E_APP_STOP_MS, startE2eApp } from './e2e-app';
|
|
104
111
|
export { E2E_ROOT_ENV, e2eApp, e2eBaseUrl, e2eBrowser } from './e2e-browser-handle';
|
|
105
112
|
export type { E2eDriverOptions } from './e2e-driver';
|
|
106
113
|
export { e2eFixtures, installE2eDriver } from './e2e-driver';
|
|
@@ -157,7 +164,7 @@ export { clearPersister, persisterInstalled, usePersister } from './factory-pers
|
|
|
157
164
|
export type { EntityRegistry, FactoryRegistry } from './factory-registry';
|
|
158
165
|
export { factoriesFor } from './factory-registry';
|
|
159
166
|
export type { TestClock, TestDuration } from './fixture-clock';
|
|
160
|
-
export {
|
|
167
|
+
export { testClock } from './fixture-clock';
|
|
161
168
|
export type {
|
|
162
169
|
DriverFixtureName,
|
|
163
170
|
DriverFixtures,
|
|
@@ -190,15 +197,15 @@ export type {
|
|
|
190
197
|
RunJobsOptions,
|
|
191
198
|
StepTally,
|
|
192
199
|
} from './fixture-jobs';
|
|
193
|
-
export {
|
|
200
|
+
export { testJobs } from './fixture-jobs';
|
|
194
201
|
export type { MailRef, TestMail } from './fixture-mail';
|
|
195
|
-
export {
|
|
202
|
+
export { testMail } from './fixture-mail';
|
|
196
203
|
export type { TestNetwork } from './fixture-network';
|
|
197
|
-
export {
|
|
204
|
+
export { testNetwork } from './fixture-network';
|
|
198
205
|
export type { ObservedStatement, StatementShape, TestStatements } from './fixture-statements';
|
|
199
|
-
export {
|
|
206
|
+
export { testStatements } from './fixture-statements';
|
|
200
207
|
export type { SubscribeDriver } from './fixture-subscribe';
|
|
201
|
-
export {
|
|
208
|
+
export { subscribeDriver } from './fixture-subscribe';
|
|
202
209
|
export { fixtureTest as test } from './fixtures';
|
|
203
210
|
export {
|
|
204
211
|
ALL_FIXTURE_NAMES,
|
|
@@ -284,7 +291,7 @@ export {
|
|
|
284
291
|
export { ISOLATED_ENV, releasePluginsAfterIsolatedFile } from './isolated-plugins';
|
|
285
292
|
export { jobDriverConformance } from './job-driver-conformance';
|
|
286
293
|
export type { LiveConnection, LiveNodeHandle, LiveNodeOptions } from './live-node';
|
|
287
|
-
export {
|
|
294
|
+
export { liveNode } from './live-node';
|
|
288
295
|
// The store a mutator's `local()` half writes into under test — the Map every app hand-rolled.
|
|
289
296
|
export type { LocalRows, MemoryLocalTx } from './local-tx';
|
|
290
297
|
export { memoryLocalTx } from './local-tx';
|
|
@@ -335,11 +342,11 @@ export type { SqlRunner, TemplateDbConfig, WorkerDatabase } from './template-db'
|
|
|
335
342
|
export {
|
|
336
343
|
acquireWorkerDatabase,
|
|
337
344
|
cloneSql,
|
|
338
|
-
createTemplateSql,
|
|
339
345
|
DEFAULT_TEMPLATE,
|
|
340
346
|
databaseNameFor,
|
|
341
347
|
dropSql,
|
|
342
348
|
lockSql,
|
|
349
|
+
templateSql,
|
|
343
350
|
unlockSql,
|
|
344
351
|
urlFor,
|
|
345
352
|
workerId,
|
package/src/island-dom.ts
CHANGED
|
@@ -199,9 +199,24 @@ export class FakeClassList {
|
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
+
/** One `addEventListener` call, alive until its own `removeEventListener`. */
|
|
203
|
+
interface Registration {
|
|
204
|
+
readonly fn: (event: unknown) => void;
|
|
205
|
+
removed: boolean;
|
|
206
|
+
}
|
|
207
|
+
|
|
202
208
|
export class FakeElement extends FakeNode {
|
|
203
209
|
readonly attributes = new Map<string, string>();
|
|
204
|
-
|
|
210
|
+
/**
|
|
211
|
+
* EVERY listener per type, in the order added, deduplicated by identity — the DOM's own rule. One
|
|
212
|
+
* per type was a double that lost listeners: `@ultimat3/ui`'s Menu closes on an Escape it
|
|
213
|
+
* registers on `document`, and its focus trap's keydown, registered next, replaced it.
|
|
214
|
+
*
|
|
215
|
+
* A REGISTRATION per add, not the bare function, so a dispatch can tell "removed before its
|
|
216
|
+
* turn" (skipped, as a browser skips it) from "removed and added again" (a new registration the
|
|
217
|
+
* running dispatch never saw, so also skipped) — a `Set` of functions answers both as present.
|
|
218
|
+
*/
|
|
219
|
+
readonly #listeners = new Map<string, Map<(event: unknown) => void, Registration>>();
|
|
205
220
|
/** Plain object, so `delete el.dataset.theme` behaves as the DOM's `DOMStringMap` does. */
|
|
206
221
|
readonly dataset: Record<string, string> = {};
|
|
207
222
|
readonly classList: FakeClassList = new FakeClassList(this);
|
|
@@ -246,10 +261,34 @@ export class FakeElement extends FakeNode {
|
|
|
246
261
|
else this.attributes.delete(name);
|
|
247
262
|
}
|
|
248
263
|
addEventListener(name: string, fn: (event: unknown) => void): void {
|
|
249
|
-
this
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
264
|
+
let held = this.#listeners.get(name);
|
|
265
|
+
if (held === undefined) {
|
|
266
|
+
held = new Map();
|
|
267
|
+
this.#listeners.set(name, held);
|
|
268
|
+
}
|
|
269
|
+
if (!held.has(fn)) held.set(fn, { fn, removed: false });
|
|
270
|
+
}
|
|
271
|
+
/** By IDENTITY: a different function — even one that does the same thing — removes nothing. */
|
|
272
|
+
removeEventListener(name: string, fn: (event: unknown) => void): void {
|
|
273
|
+
const held = this.#listeners.get(name);
|
|
274
|
+
const registration = held?.get(fn);
|
|
275
|
+
if (held === undefined || registration === undefined) return;
|
|
276
|
+
registration.removed = true;
|
|
277
|
+
held.delete(fn);
|
|
278
|
+
if (held.size === 0) this.#listeners.delete(name);
|
|
279
|
+
}
|
|
280
|
+
/** Every listener of `name`, as one call — `undefined` when there is none to run. */
|
|
281
|
+
listenerFor(name: string): ((event: unknown) => void) | undefined {
|
|
282
|
+
const held = this.#listeners.get(name);
|
|
283
|
+
if (held === undefined || held.size === 0) return undefined;
|
|
284
|
+
// The registrations as of NOW, for the DOM's rule: one added during the dispatch waits for the
|
|
285
|
+
// next, and one removed before its turn does not run.
|
|
286
|
+
const snapshot = [...held.values()];
|
|
287
|
+
return (event: unknown): void => {
|
|
288
|
+
for (const registration of snapshot) {
|
|
289
|
+
if (!registration.removed) registration.fn(event);
|
|
290
|
+
}
|
|
291
|
+
};
|
|
253
292
|
}
|
|
254
293
|
override cloneNode(deep?: boolean): FakeElement {
|
|
255
294
|
const copy = new FakeElement(this.tagName);
|
|
@@ -295,7 +334,7 @@ export class FakeElement extends FakeNode {
|
|
|
295
334
|
if (options.left !== undefined) this.scrollLeft = options.left;
|
|
296
335
|
if (options.top !== undefined) this.scrollTop = options.top;
|
|
297
336
|
}
|
|
298
|
-
this.
|
|
337
|
+
this.listenerFor('scroll')?.({ currentTarget: this, target: this });
|
|
299
338
|
}
|
|
300
339
|
/**
|
|
301
340
|
* A compound of tag, `#id`, `.class`, `[attr]` and `[attr="value"]`, joined by the descendant
|
|
@@ -414,7 +453,8 @@ export function createIslandDocument(): IslandDocument {
|
|
|
414
453
|
// reaches them through the surface `MountedIsland` already exposes.
|
|
415
454
|
addEventListener: (name: string, fn: (event: unknown) => void): void =>
|
|
416
455
|
documentElement.addEventListener(name, fn),
|
|
417
|
-
removeEventListener: (name: string): void =>
|
|
456
|
+
removeEventListener: (name: string, fn: (event: unknown) => void): void =>
|
|
457
|
+
documentElement.removeEventListener(name, fn),
|
|
418
458
|
};
|
|
419
459
|
return {
|
|
420
460
|
documentElement,
|
|
@@ -434,7 +474,7 @@ export function createIslandDocument(): IslandDocument {
|
|
|
434
474
|
};
|
|
435
475
|
}
|
|
436
476
|
|
|
437
|
-
/** The delegated handler Solid parks on the node as `$$click`, or
|
|
477
|
+
/** The delegated handler Solid parks on the node as `$$click`, or EVERY listener attached with
|
|
438
478
|
* `addEventListener` — a compiled island uses one or the other and a driver must accept both. */
|
|
439
479
|
export function handlerFor(
|
|
440
480
|
element: FakeElement,
|
|
@@ -442,5 +482,5 @@ export function handlerFor(
|
|
|
442
482
|
): ((event: unknown) => void) | undefined {
|
|
443
483
|
const delegated = (element as unknown as Record<string, unknown>)[`$$${type}`];
|
|
444
484
|
if (typeof delegated === 'function') return delegated as (event: unknown) => void;
|
|
445
|
-
return element.
|
|
485
|
+
return element.listenerFor(type);
|
|
446
486
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// The process globals the island fixture installs, as LAYERS over one snapshot of the real ones.
|
|
2
|
+
// Each mount used to save what it found and restore that on dispose — right only newest-first: a
|
|
3
|
+
// worker disposing A then B had B "restore" A's fake `document`/`Element` for every later file.
|
|
4
|
+
|
|
5
|
+
/** One mount's globals, newest last. */
|
|
6
|
+
const layers: Readonly<Record<string, unknown>>[] = [];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The REAL descriptor of every key any live layer installs, taken the first time a layer installs
|
|
10
|
+
* it while no other live layer holds it — so 0→1 for each key — and put back only when the last
|
|
11
|
+
* layer goes (1→0). `undefined` is "no such global", which is restored as a delete.
|
|
12
|
+
*
|
|
13
|
+
* DESCRIPTORS, not values: a saved value cannot tell "no such global" from "a global holding
|
|
14
|
+
* `undefined`", and it cannot put an accessor back as an accessor.
|
|
15
|
+
*/
|
|
16
|
+
const real = new Map<string, PropertyDescriptor | undefined>();
|
|
17
|
+
|
|
18
|
+
const host = globalThis as unknown as Record<string, unknown>;
|
|
19
|
+
|
|
20
|
+
const putBack = (key: string, descriptor: PropertyDescriptor | undefined): void => {
|
|
21
|
+
if (descriptor === undefined) delete host[key];
|
|
22
|
+
else Object.defineProperty(host, key, descriptor);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The process as the remaining layers say it should be: each key holds the NEWEST live layer's
|
|
27
|
+
* value, and a key no live layer names is real again. Assignment for a layer's value, for
|
|
28
|
+
* `installGlobals`' reason — a global with a setter is meant to see the write.
|
|
29
|
+
*/
|
|
30
|
+
function settle(): void {
|
|
31
|
+
for (const [key, descriptor] of real) {
|
|
32
|
+
const owner = layers.findLast((layer) => Object.hasOwn(layer, key));
|
|
33
|
+
if (owner !== undefined) {
|
|
34
|
+
host[key] = owner[key];
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
putBack(key, descriptor);
|
|
38
|
+
real.delete(key);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Install `values` over the process, answering the undo. All-or-nothing: an assignment that throws
|
|
44
|
+
* — a getter-only own global among the caller's `globals` — settles the process back before it
|
|
45
|
+
* rethrows, or the fake `document` installed ahead of it would stay for the rest of the run.
|
|
46
|
+
*
|
|
47
|
+
* The undo is idempotent and ORDER-FREE: it removes this layer and settles, so whichever mount
|
|
48
|
+
* goes last hands the process the real globals, and a mount disposed while a newer one lives
|
|
49
|
+
* leaves the newer one's in place.
|
|
50
|
+
*/
|
|
51
|
+
export function installGlobals(values: Readonly<Record<string, unknown>>): () => void {
|
|
52
|
+
for (const key of Object.keys(values)) {
|
|
53
|
+
if (!real.has(key)) real.set(key, Object.getOwnPropertyDescriptor(host, key));
|
|
54
|
+
}
|
|
55
|
+
const layer = { ...values };
|
|
56
|
+
layers.push(layer);
|
|
57
|
+
let removed = false;
|
|
58
|
+
const remove = (): void => {
|
|
59
|
+
if (removed) return;
|
|
60
|
+
removed = true;
|
|
61
|
+
layers.splice(layers.indexOf(layer), 1);
|
|
62
|
+
settle();
|
|
63
|
+
};
|
|
64
|
+
try {
|
|
65
|
+
// Assignment, not `defineProperty`: `Object.assign` over the lot would report the same
|
|
66
|
+
// failure with nothing rolled back.
|
|
67
|
+
for (const [key, value] of Object.entries(values)) host[key] = value;
|
|
68
|
+
} catch (error) {
|
|
69
|
+
remove();
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
return remove;
|
|
73
|
+
}
|
package/src/island-scratch.ts
CHANGED
|
@@ -1,14 +1,71 @@
|
|
|
1
1
|
// The island fixture's scratch directories: one per mount, created when the mount needs it and
|
|
2
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`.
|
|
3
|
+
// `process.on('exit')` never fires under `bun test` (Bun 1.4.0, measured) — 5,000+ in `/tmp`. A
|
|
4
|
+
// KILLED process reaches no cleanup at all, so each directory carries its owner's pid and the next
|
|
5
|
+
// process sweeps a dead owner's.
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
// why: Bun has no directory API — a scratch DIRECTORY is made, listed, aged and removed, synchronously
|
|
8
|
+
import { mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
6
9
|
// why: Bun exposes no tmpdir(), so only node:os answers the platform temp root.
|
|
7
10
|
import { tmpdir } from 'node:os';
|
|
8
11
|
import { join } from 'node:path';
|
|
9
12
|
|
|
10
13
|
const live = new Set<string>();
|
|
11
14
|
let exitHooked = false;
|
|
15
|
+
let swept = false;
|
|
16
|
+
|
|
17
|
+
const PREFIX = 'ultimate-island-';
|
|
18
|
+
/** `ultimate-island-<pid>-<mkdtemp's six>` — this module's own spelling since 2026-10-06. */
|
|
19
|
+
const OWNED = /^ultimate-island-(\d+)-[A-Za-z0-9]{6}$/;
|
|
20
|
+
/** The spelling before it, with no owner to ask: swept once it is a day old. */
|
|
21
|
+
const UNOWNED = /^ultimate-island-[A-Za-z0-9]{6}$/;
|
|
22
|
+
const UNOWNED_AGE_MS = 24 * 60 * 60 * 1000;
|
|
23
|
+
|
|
24
|
+
/** `kill(pid, 0)` delivers nothing and answers whether the pid exists; EPERM is alive. */
|
|
25
|
+
const pidAlive = (pid: number): boolean => {
|
|
26
|
+
try {
|
|
27
|
+
process.kill(pid, 0);
|
|
28
|
+
return true;
|
|
29
|
+
} catch (error) {
|
|
30
|
+
return (error as { code?: unknown }).code === 'EPERM';
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Remove every island directory under `root` whose owning process is gone — the gate's step
|
|
36
|
+
* deadline SIGKILLs a stalled `bun test`, and nothing in a killed process runs. Answers what it
|
|
37
|
+
* removed. Best-effort and silent: a directory another sweeper removed first, or one this user
|
|
38
|
+
* cannot read, is simply not this process's to report.
|
|
39
|
+
*/
|
|
40
|
+
export function sweepDeadScratchDirs(
|
|
41
|
+
root: string,
|
|
42
|
+
alive: (pid: number) => boolean,
|
|
43
|
+
now: number,
|
|
44
|
+
): readonly string[] {
|
|
45
|
+
const removed: string[] = [];
|
|
46
|
+
let names: readonly string[];
|
|
47
|
+
try {
|
|
48
|
+
names = readdirSync(root).filter((name) => name.startsWith(PREFIX));
|
|
49
|
+
} catch {
|
|
50
|
+
return removed;
|
|
51
|
+
}
|
|
52
|
+
for (const name of names) {
|
|
53
|
+
const path = join(root, name);
|
|
54
|
+
try {
|
|
55
|
+
const owner = OWNED.exec(name)?.[1];
|
|
56
|
+
const dead =
|
|
57
|
+
owner !== undefined
|
|
58
|
+
? !alive(Number(owner))
|
|
59
|
+
: UNOWNED.test(name) && now - statSync(path).mtimeMs > UNOWNED_AGE_MS;
|
|
60
|
+
if (!dead) continue;
|
|
61
|
+
rmSync(path, { recursive: true, force: true });
|
|
62
|
+
removed.push(path);
|
|
63
|
+
} catch {
|
|
64
|
+
// Gone already, or not ours to remove.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return removed;
|
|
68
|
+
}
|
|
12
69
|
|
|
13
70
|
/**
|
|
14
71
|
* A fresh directory for one mount. Fresh, not shared: a module is cached by PATH, so two mounts
|
|
@@ -18,7 +75,14 @@ let exitHooked = false;
|
|
|
18
75
|
* under `bun test` the file boundary and the run's `afterAll` dispose every mount instead.
|
|
19
76
|
*/
|
|
20
77
|
export function createScratchDir(): string {
|
|
21
|
-
const dir = mkdtempSync(join(tmpdir(),
|
|
78
|
+
const dir = mkdtempSync(join(tmpdir(), `${PREFIX}${String(process.pid)}-`));
|
|
79
|
+
// Once per process, at its first mount: the cost is one read of the temp dir. "Now" is the new
|
|
80
|
+
// directory's own mtime — the FILESYSTEM's clock, the one the swept mtimes were written by —
|
|
81
|
+
// never `Date.now()`, which a test process has frozen at whatever instant the suite chose.
|
|
82
|
+
if (!swept) {
|
|
83
|
+
swept = true;
|
|
84
|
+
sweepDeadScratchDirs(tmpdir(), pidAlive, statSync(dir).mtimeMs);
|
|
85
|
+
}
|
|
22
86
|
live.add(dir);
|
|
23
87
|
if (!exitHooked) {
|
|
24
88
|
exitHooked = true;
|
|
@@ -135,6 +135,22 @@ export class IslandStateStubInvalidError extends UltimateError {
|
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
/**
|
|
139
|
+
* A `records` stub whose rows are not the envelope's shape — record type -> record key -> row
|
|
140
|
+
* object, `removed` a list of keys per type. The client decodes that shape behind the header and
|
|
141
|
+
* refuses anything else (`X_CLIENT_RECORD_ENVELOPE_INVALID`), so the picture would be of that
|
|
142
|
+
* refusal. Its own class beside the one above, sharing one code: a stub that cannot answer.
|
|
143
|
+
*/
|
|
144
|
+
export class IslandStateRecordsInvalidError extends UltimateError {
|
|
145
|
+
constructor(input: { readonly island: string; readonly path: string; readonly reason: string }) {
|
|
146
|
+
super({
|
|
147
|
+
code: 'X_TEST_ISLAND_STATE_STUB_INVALID',
|
|
148
|
+
cause: `${input.path} ${input.reason}, so the page store would refuse the envelope rather than adopt it`,
|
|
149
|
+
fix: `respond: { kind: 'records', data: [{ id: 'r1' }], records: { runs: { 'r1': { id: 'r1' } } } } — in ${at(input.island)}`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
138
154
|
/**
|
|
139
155
|
* A states file that imports the component it describes. The whole design rests on this file being
|
|
140
156
|
* readable from Bun with no browser and no bundle — the command must know the complete expected
|
package/src/island-states.ts
CHANGED
|
@@ -25,8 +25,31 @@ export const DEFAULT_ISLAND_VIEWPORT: IslandViewport = { width: 1280, height: 80
|
|
|
25
25
|
*/
|
|
26
26
|
export const ISLAND_SHOT_TIME_ZONE = 'UTC';
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The rows a `records` stub hands the page store: record type -> record key -> row — core's
|
|
30
|
+
* `RecordEnvelope['records']`, restated structurally because this file imports nothing.
|
|
31
|
+
*/
|
|
32
|
+
export type IslandStubRecords = Readonly<
|
|
33
|
+
Record<string, Readonly<Record<string, Readonly<Record<string, unknown>>>>>
|
|
34
|
+
>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* What a stubbed request answers. `json` is a bare body. `records` is what a query declaring an
|
|
38
|
+
* entity's `rows:` answers on the wire — the envelope `{ data, records, removed? }` behind
|
|
39
|
+
* `x-ultimate-records: 1` — so the island's page store adopts the rows exactly as it would from
|
|
40
|
+
* the server; a `json` stub of the same body is read as bare rows and adopts nothing (#683).
|
|
41
|
+
*/
|
|
28
42
|
export type IslandStubResponse =
|
|
29
43
|
| { readonly kind: 'json'; readonly status?: number; readonly body: unknown }
|
|
44
|
+
| {
|
|
45
|
+
readonly kind: 'records';
|
|
46
|
+
readonly status?: number;
|
|
47
|
+
/** The read's own answer — what the island's `await` resolves to. */
|
|
48
|
+
readonly data: unknown;
|
|
49
|
+
readonly records: IslandStubRecords;
|
|
50
|
+
/** Record keys the store drops, by record type. */
|
|
51
|
+
readonly removed?: Readonly<Record<string, readonly string[]>>;
|
|
52
|
+
}
|
|
30
53
|
| { readonly kind: 'pending' }
|
|
31
54
|
| { readonly kind: 'offline' };
|
|
32
55
|
|
package/src/live-node.ts
CHANGED
|
@@ -78,7 +78,7 @@ export interface LiveNodeOptions {
|
|
|
78
78
|
/** Pins the reconnect epoch, so a test can force a refetch by changing it. */
|
|
79
79
|
readonly epoch?: string;
|
|
80
80
|
// No `onMutate`: the socket carries no writes any more (plan 101, decision 2) — a mutation goes
|
|
81
|
-
// over HTTP as an action, and `
|
|
81
|
+
// over HTTP as an action, and `syncNode` takes no mutation hook to forward.
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
export interface LiveNodeHandle {
|
|
@@ -115,7 +115,7 @@ const ACTORS = new Map<string, Actor | null>();
|
|
|
115
115
|
|
|
116
116
|
let sequence = 0;
|
|
117
117
|
|
|
118
|
-
export async function
|
|
118
|
+
export async function liveNode(options: LiveNodeOptions = {}): Promise<LiveNodeHandle> {
|
|
119
119
|
const core = await import('@ultimat3/core');
|
|
120
120
|
const query = await import('@ultimat3/query');
|
|
121
121
|
const realtime = await import('@ultimat3/realtime/server');
|
|
@@ -126,7 +126,7 @@ export async function createLiveNode(options: LiveNodeOptions = {}): Promise<Liv
|
|
|
126
126
|
const hub = new realtime.ChannelHub({ transport, sockets });
|
|
127
127
|
const registry = new realtime.LiveQueryRegistry({ source: new realtime.RingChangeBuffer() });
|
|
128
128
|
|
|
129
|
-
const ctx = core.
|
|
129
|
+
const ctx = core.ctxOf({ role: 'sync', buildId });
|
|
130
130
|
let live = 0;
|
|
131
131
|
for (const target of query.listQueries()) {
|
|
132
132
|
if (!target.isLive) continue;
|
|
@@ -143,7 +143,7 @@ export async function createLiveNode(options: LiveNodeOptions = {}): Promise<Liv
|
|
|
143
143
|
// app that declared nothing. Said here, where the count is known.
|
|
144
144
|
if (live === 0) throw liveNodeUnavailable();
|
|
145
145
|
|
|
146
|
-
const node = realtime.
|
|
146
|
+
const node = realtime.syncNode({
|
|
147
147
|
hub,
|
|
148
148
|
registry,
|
|
149
149
|
transport,
|
package/src/quiet-logs.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// line collected nothing and failed for an environment variable. The level is therefore pinned at
|
|
12
12
|
// `info` for the one moment core loads, and what the author named decides what is PRINTED.
|
|
13
13
|
//
|
|
14
|
-
// A test that asserts on log output hands `
|
|
14
|
+
// A test that asserts on log output hands `structuredLogger({ level, writer })` its own writer, or
|
|
15
15
|
// installs its own `setLogSink` and restores the previous one. Never `process.stdout`.
|
|
16
16
|
|
|
17
17
|
import type { LogLevel } from '@ultimat3/core';
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Single responsibility: `X_TEST_REGISTRY_LEAK`'s message — which file left which cache tags and
|
|
2
|
+
// tiers registered, the isolating lines to paste, and the `bun test` re-run. The CODE is declared
|
|
3
|
+
// in `errors.ts`, the package's registry, which re-exports this class; the shape it reports is
|
|
4
|
+
// `registry-leak-guard.ts`'s. Split from `errors.ts` at the 500-line ceiling.
|
|
5
|
+
|
|
6
|
+
import { isFixShellSafe, renderCauseValue, renderFixLiteral, UltimateError } from '@ultimat3/core';
|
|
7
|
+
// Type-only, so the cycle with the guard that throws it is erased at build.
|
|
8
|
+
import type { RegistryLeak } from './registry-leak-guard';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Every value in this message is uncontrolled: the path arrives from `Bun.plugin`'s `onLoad`, and
|
|
12
|
+
* the names are whatever the app under test passed to `declareTags()` / `registerTier()`. So the
|
|
13
|
+
* sentence renders them (bounded, never throwing while describing a leak) and the command quotes
|
|
14
|
+
* them (a fix has to parse after a path with a space or a quote in it lands in the middle of it).
|
|
15
|
+
*/
|
|
16
|
+
const describeLeak = (leak: RegistryLeak): string => {
|
|
17
|
+
const left = [
|
|
18
|
+
...(leak.tags.length > 0 ? [`cache tags declared ${renderCauseValue(leak.tags)}`] : []),
|
|
19
|
+
...(leak.tiers.length > 0 ? [`cache tiers registered ${renderCauseValue(leak.tiers)}`] : []),
|
|
20
|
+
];
|
|
21
|
+
return `${renderCauseValue(leak.file)} left ${left.join(' and ')} after its last test`;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** The placeholder is the cause's own sentence: it already names every file, in order. */
|
|
25
|
+
const FILE_PLACEHOLDER = '<the file the cause names>';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Inside double quotes `$`, a backtick and `!` are still live, and `renderFixLiteral` answers double
|
|
29
|
+
* quotes — so a path carrying one becomes the quoted placeholder in the `bun test` command. Any other
|
|
30
|
+
* path stays the JSON literal: an ordinary path, or one with a quote JSON escapes, still runs.
|
|
31
|
+
*/
|
|
32
|
+
const DOUBLE_QUOTE_LIVE = /[$`!]/;
|
|
33
|
+
|
|
34
|
+
const rerunArg = (file: string): string =>
|
|
35
|
+
isFixShellSafe(file) || !DOUBLE_QUOTE_LIVE.test(file)
|
|
36
|
+
? renderFixLiteral(file, FILE_PLACEHOLDER)
|
|
37
|
+
: `'${FILE_PLACEHOLDER}'`;
|
|
38
|
+
|
|
39
|
+
/** Every leaked file as `bun test` arguments — one screen, so the fix-shell-arg guard can see it. */
|
|
40
|
+
const rerunFileArgs = (leaks: readonly RegistryLeak[]): string =>
|
|
41
|
+
leaks.map((leak) => rerunArg(leak.file)).join(' ');
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The edit, spelled as the lines to paste — the imports included, since neither call is global.
|
|
45
|
+
*
|
|
46
|
+
* Both repairs isolate; neither resets. `afterAll(resetTiers)` was the first spelling of the tier
|
|
47
|
+
* half and it is the bug wearing the fix's clothes: it drops the tiers, the revalidator and both
|
|
48
|
+
* logs a NEIGHBOUR registered, and the guard reports additions only, so the damage lands on an
|
|
49
|
+
* innocent file with nothing pointing back here. An error whose instruction causes the next defect
|
|
50
|
+
* is worse than no instruction.
|
|
51
|
+
*/
|
|
52
|
+
const repairFor = (leak: RegistryLeak): string => {
|
|
53
|
+
const imports: string[] = [];
|
|
54
|
+
const calls: string[] = [];
|
|
55
|
+
if (leak.tags.length > 0) {
|
|
56
|
+
imports.push('isolateDeclaredTags');
|
|
57
|
+
calls.push('const restoreTags = isolateDeclaredTags(); afterAll(restoreTags);');
|
|
58
|
+
}
|
|
59
|
+
if (leak.tiers.length > 0) {
|
|
60
|
+
imports.push('isolateTiers');
|
|
61
|
+
calls.push('const restoreTiers = isolateTiers(); afterAll(restoreTiers);');
|
|
62
|
+
}
|
|
63
|
+
const file = renderFixLiteral(leak.file, FILE_PLACEHOLDER);
|
|
64
|
+
return `in ${file} add: import { ${imports.join(', ')} } from '@ultimat3/cache'; ${calls.join(' ')}`;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* One error for every leaker in the run, not one per file: the run has already finished by the
|
|
69
|
+
* time the last file can be judged, and two throws would report the second as an unhandled one.
|
|
70
|
+
* The trailing command reproduces the failure on its own — the guard judges each file against its
|
|
71
|
+
* own baseline, so one leaking file fails a one-file run exactly as it failed the whole suite.
|
|
72
|
+
*/
|
|
73
|
+
export class RegistryLeakError extends UltimateError {
|
|
74
|
+
constructor(input: { readonly leaks: readonly RegistryLeak[] }) {
|
|
75
|
+
super({
|
|
76
|
+
code: 'X_TEST_REGISTRY_LEAK',
|
|
77
|
+
cause: input.leaks.map(describeLeak).join('; '),
|
|
78
|
+
fix: `${input.leaks.map(repairFor).join('; ')} — then re-run: bun test ${rerunFileArgs(input.leaks)}`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
* "Idiomatic to leave filled" says nothing about a file that calls `clear*()`/`reset*()` and takes
|
|
33
33
|
* a module-scope declaration from every file after it — a module evaluates once per process, so the
|
|
34
34
|
* next file's own `import` is a cache hit that declares nothing. That is what `registry-snapshot.ts`
|
|
35
|
-
* repairs, and it repairs
|
|
35
|
+
* repairs, and it repairs SIX registries out of the set that has one:
|
|
36
36
|
*
|
|
37
37
|
* | registry | reset export | owner | in the snapshot? |
|
|
38
38
|
* |---|---|---|---|
|
|
@@ -41,8 +41,9 @@ import {
|
|
|
41
41
|
* | routes | `clearRoutes` | `@ultimat3/render` | **no** |
|
|
42
42
|
* | jobs | `resetJobs` | `@ultimat3/jobs` | **no** |
|
|
43
43
|
* | tasks | `restoreTasks` | `@ultimat3/jobs` | yes (22.7) |
|
|
44
|
-
* |
|
|
45
|
-
* |
|
|
44
|
+
* | measurement actor | `resetMeasurementActor` | `@ultimat3/core` | yes |
|
|
45
|
+
* | actions | `resetActions` | `@ultimat3/action` | **no** |
|
|
46
|
+
* | queries | `resetQueries` | `@ultimat3/query` | **no** |
|
|
46
47
|
* | models / prompts / agents | `resetModels` / `resetPrompts` / `resetAgents` | `@ultimat3/ai` | **no** |
|
|
47
48
|
* | mails | `resetMails` | `@ultimat3/mail` | **no** |
|
|
48
49
|
* | entities | `clearRegistry` | `@ultimat3/entity` | no, by decision — `registry-isolation.ts` |
|