@ultimat3/testing 7.0.0 → 9.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 +4 -1
- package/package.json +12 -12
- package/src/fixture-island.ts +32 -4
- package/src/live-node.ts +2 -2
- package/src/live-replicator.ts +2 -1
- package/src/registry-snapshot.ts +40 -5
package/CLAUDE.md
CHANGED
|
@@ -43,6 +43,8 @@ is its own entry point and not part of the barrel.
|
|
|
43
43
|
| Leaks are the file's, not the next file's | `installRegistryLeakGuard()` runs from the preload and fails the run naming the FILE that left cache tags declared or a cache tier registered after its last test (`X_TEST_REGISTRY_LEAK`). `bun test` is one process, so without it the failure lands on an innocent suite in another package. What a file's MODULE graph declares is its environment; what the file installs after that is its own to undo |
|
|
44
44
|
| The baseline is not a hook | measured on Bun 1.3.14 the order is onLoad → module eval → file `beforeAll` → describe `beforeAll` → preload `beforeEach`, so a preload hook cannot sample before the file's own `beforeAll` — a `declareTags()` there read as environment and the run went green. The load handler appends the sample to the file's source instead: after evaluation, before any hook the file registers. It is also the only signal carrying file identity, which `bun:test` hooks do not |
|
|
45
45
|
| Reported and restored are different sets | the guard also RESTORES, at the same file boundary, the registries whose module-scope declarations a neighbour's cleanup destroys — the locale config, the catalogs, the permission set and the role map (`registry-snapshot.ts`). A module evaluates once per process, so a later file's own `import` is a cache hit that declares nothing: `clearPermissions()` in one CLI test took `admin:*` from `@ultimat3/admin`'s barrel for the whole run, and a `defineCatalogs()` inside a loaded app narrowed `supported` so `Accept-Language: de-DE` answered `en` in files that never mentioned locales. Nothing restored is reported and nothing reported is restored — a repair followed by a failure over it would be two answers to one question |
|
|
46
|
+
| A catalog restore is a MERGE, never a replace, `As of 2026-08-23` | the other three registries are replaced with the snapshot; the catalogs are not. `registerCatalog` has no inverse, so the only thing a file can cost its neighbour is a `resetCatalogs()` — and that is all this repairs. Everything the live registry still holds survives, a key first registered during the file and an override of a framework base string alike, because both are one-time MODULE-scope declarations: `loadApp()` in a test body dynamically imports the app's i18n package after the file's baseline was sampled, so a replace dropped 519 keys nothing could re-add and `t()` answered `⟦brand.name⟧` for the rest of the process (#312, measured: `bun test apps/admin` in `dummy/social-media-clone`, 4 fail → 0). The override half is the same defect with no `⟦…⟧` to show it — the demo app overrides `admin.denied.body`, and reverting it rendered `@ultimat3/i18n`'s own copy. The cost, stated: a file that CLOBBERS an inherited key owns the cleanup, and the cleanup is `resetCatalogs()` in its own `afterAll`, which this repair is built around |
|
|
47
|
+
| The same is still true of permissions, and it is MEASURED, `As of 2026-08-23` | permissions, roles and the locale config are still replaced with the file's baseline, so an app's `definePermissions()` reached only by a dynamic `loadApp()` is dropped at that file's boundary exactly as the catalogs were. Reproduced: `bun test apps packages` in `dummy/social-media-clone` — one process, unsharded — leaves 6 `.contract.` cases failing on `knownPermissions()` missing `dashboard:read`, and every one of them passes when its file runs alone. The catalog fix took that run from 16 fail to 6; these are the 6. Not fixed here, and the reason is not that it is a different defect — it is the same one — but that the same union rule applied to permissions leaks every permission `packages/policy/src/permissions.test.ts` declares into every later file, and judging that needs a repo-wide `bun test` this package cannot run for itself. Its own piece of work, not a rider on this one. The `unit` step is green over it because `.contract.` is a different step and it shards |
|
|
46
48
|
| Guarded state is boot state | only the two registries whose honest invariant is "clean when the file ends" — `declareTags` and `registerTier` are boot installs. `entity()`, `job()` and `defineRoute()` register at MODULE scope, which is how an app declares itself, so a filled registry there is idiomatic and unguarded |
|
|
47
49
|
| An empty registry is a premise you state | a test whose subject is "nothing is declared" — `x db gen` with nothing to generate — calls `isolateEntityRegistry()` and restores in a `finally`. Inheriting it means the test passes until a neighbouring file imports an entity |
|
|
48
50
|
| That one helper is off the barrel | `@ultimat3/testing/registry-isolation`, its own entry point. It is the only module here that value-imports `@ultimat3/entity` — the restore is handed back synchronously, so it cannot be a dynamic import inside the call — and a static re-export from `src/index.ts` would load the entity registry into every test that imports this package for `expect` |
|
|
@@ -64,7 +66,8 @@ is its own entry point and not part of the barrel.
|
|
|
64
66
|
| `querySelector` skips `this` | descendants only, as the DOM's does. Matching the element it is called on made a host `<div>` answer `find('div')` with the container the test built rather than the markup the island rendered |
|
|
65
67
|
| A mount installs process globals | so `MountedIsland` is `Disposable` and a `mount` that THROWS restores before it rethrows. A fake `document` left installed reaches every later FILE in the run, and fails somewhere with no thread back |
|
|
66
68
|
| `fire` answers whether a handler ran | a selector matching nothing and an island that attached no handler are the same silence otherwise — the second is a bug, the first a typo. It reads Solid's delegated `$$click` property or an `addEventListener` listener; a compiled island uses one or the other |
|
|
67
|
-
| The chunk is imported from a temp FILE, `As of 2026-08-21` | `mkdtemp`ed
|
|
69
|
+
| The chunk is imported from a temp FILE, `As of 2026-08-21` | `mkdtemp`ed on the FIRST mount and named by the chunk's SHA-256, so an edited island is a different module rather than a cache hit on the same path and no test leaves a `.mjs` behind in the app it just built. It was a `data:` URL until 2026-08-21 and that read better: `bun test --coverage` panics with `range end index N out of range for slice of length 4096` on `import()` of any `data:` module past ~4 kB, and an island chunk is 12-55 kB — so every island test dumped core in the per-package CI job while the root gate stayed green. Measured on Bun 1.4.0; `fixture-island.test.ts` pins the file form as a source rule, because the failure is invisible to a `bun test` without `--coverage` |
|
|
70
|
+
| The scratch directory is lazy and removed, `As of 2026-08-22` | `mkdtempSync` ran at MODULE scope and nothing removed it, so every process importing `@ultimat3/testing` at all — this module is on the `.` barrel, so `expect` alone did it — left one directory in `/tmp` forever. Created on the first `modulePathFor` and `rmSync`ed from `process.on('exit')`: the handler has to be synchronous, and it is per PROCESS while `MountedIsland`'s `Disposable` is per mount and is never reached by a mount that threw. `fixture-island-cleanup.test.ts` asserts both halves from a CHILD process, which is the only place either is observable |
|
|
68
71
|
| Attaching a node MOVES it | `appendChild`, `insertBefore` and `replaceChild` detach the node from its old parent first, and `removeChild` clears `parentNode` — as the DOM does, and as `reconcileArrays` requires: a `<For>` re-order calls `parentNode.insertBefore(child, ref)` on a child ALREADY in that parent (`solid-js/web`'s `web.js:155`), so an attach that only pushed left the node in BOTH positions and a five-row list reconciled to ten. A removal that left `parentNode` set was the same defect read backwards — `indexOf` answers -1, so the orphan's `nextSibling` was its old parent's FIRST child instead of `null`. `textContent = ''` detaches too; it opens every island's `mount` |
|
|
69
72
|
| Globals install all-or-nothing | `installGlobals` saves DESCRIPTORS, not values — a saved value cannot tell "no such global" from "a global holding `undefined`", and the teardown deleted both — and rolls the whole install back if one assignment throws. That rollback is the half with teeth: the install runs BEFORE `mountIsland`'s own `try`, so a getter-only own global among the caller's `globals` used to leave the fake `document` installed for the rest of the process |
|
|
70
73
|
| Which command shards | `bun test` is one process on one database, and that is still what a scaffolded app's `test` script runs. `x verify` DOES shard its parallel test steps, over `ULTIMATE_TEST_WORKER` and one database per worker; `live` and `e2e` stay serial because a replication slot is cluster-scoped and `e2e` has one built `dist/`. Say which command a claim is about |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/testing",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "9.0.0",
|
|
4
4
|
"description": "Test harness: cloned template DBs per worker, frozen clock, sealed network, 6 test types",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -33,16 +33,16 @@
|
|
|
33
33
|
"test": "bun test"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@ultimat3/cache": "
|
|
37
|
-
"@ultimat3/core": "
|
|
38
|
-
"@ultimat3/db": "
|
|
39
|
-
"@ultimat3/entity": "
|
|
40
|
-
"@ultimat3/i18n": "
|
|
41
|
-
"@ultimat3/jobs": "
|
|
42
|
-
"@ultimat3/mail": "
|
|
43
|
-
"@ultimat3/policy": "
|
|
44
|
-
"@ultimat3/query": "
|
|
45
|
-
"@ultimat3/realtime": "
|
|
46
|
-
"@ultimat3/time": "
|
|
36
|
+
"@ultimat3/cache": "9.0.0",
|
|
37
|
+
"@ultimat3/core": "9.0.0",
|
|
38
|
+
"@ultimat3/db": "9.0.0",
|
|
39
|
+
"@ultimat3/entity": "9.0.0",
|
|
40
|
+
"@ultimat3/i18n": "9.0.0",
|
|
41
|
+
"@ultimat3/jobs": "9.0.0",
|
|
42
|
+
"@ultimat3/mail": "9.0.0",
|
|
43
|
+
"@ultimat3/policy": "9.0.0",
|
|
44
|
+
"@ultimat3/query": "9.0.0",
|
|
45
|
+
"@ultimat3/realtime": "9.0.0",
|
|
46
|
+
"@ultimat3/time": "9.0.0"
|
|
47
47
|
}
|
|
48
48
|
}
|
package/src/fixture-island.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// packages are tier 5, so importing it here would be the reverse of the one declared `cli → testing`
|
|
4
4
|
// edge. A structural seam keeps the direction honest and survives the bundler changing underneath.
|
|
5
5
|
|
|
6
|
-
import { mkdtempSync } from 'node:fs';
|
|
6
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { islandMountMissing, islandNotBuilt } from './errors';
|
|
@@ -76,8 +76,36 @@ function entryOf(module: unknown, file: string): IslandEntry {
|
|
|
76
76
|
return { mount: mount as IslandEntry['mount'] };
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
let moduleDir: string | undefined;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* One directory per process, outside the app under test — so no test leaves a `.mjs` behind. Two
|
|
83
|
+
* properties the module-scope `mkdtempSync` it replaces had neither of:
|
|
84
|
+
*
|
|
85
|
+
* LAZY. This module is on the `.` barrel, so importing `@ultimat3/testing` for `expect` alone
|
|
86
|
+
* created a directory — in every test process in the repo, whether or not it ever mounts anything.
|
|
87
|
+
*
|
|
88
|
+
* REMOVED. `exit` and `rmSync`, because an exit handler runs synchronously and nothing else covers
|
|
89
|
+
* every path: `mountIsland` restores and rethrows on a failed mount, so that run never reaches the
|
|
90
|
+
* `Disposable`, and the directory is per PROCESS while the disposable is per mount.
|
|
91
|
+
*/
|
|
92
|
+
function moduleDirPath(): string {
|
|
93
|
+
const existing = moduleDir;
|
|
94
|
+
if (existing !== undefined) return existing;
|
|
95
|
+
const created = mkdtempSync(join(tmpdir(), 'ultimate-island-'));
|
|
96
|
+
process.on('exit', () => {
|
|
97
|
+
rmSync(created, { recursive: true, force: true });
|
|
98
|
+
});
|
|
99
|
+
moduleDir = created;
|
|
100
|
+
return created;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The scratch root, or `undefined` when nothing has been mounted. Read by the leak test, which
|
|
105
|
+
* asserts both halves of the above from a CHILD process — the only place where "this process
|
|
106
|
+
* created no directory" and "the directory is gone afterwards" are both observable.
|
|
107
|
+
*/
|
|
108
|
+
export const islandModuleDir = (): string | undefined => moduleDir;
|
|
81
109
|
|
|
82
110
|
/**
|
|
83
111
|
* A temp file named by the chunk's own hash, NOT a `data:` URL. The URL form read better and
|
|
@@ -90,7 +118,7 @@ const MODULE_DIR = mkdtempSync(join(tmpdir(), 'ultimate-island-'));
|
|
|
90
118
|
* edited island is a different module rather than a cache hit on the same path.
|
|
91
119
|
*/
|
|
92
120
|
const modulePathFor = (code: string): string =>
|
|
93
|
-
join(
|
|
121
|
+
join(moduleDirPath(), `${Bun.SHA256.hash(code, 'hex').slice(0, 16)}.mjs`);
|
|
94
122
|
|
|
95
123
|
/**
|
|
96
124
|
* DESCRIPTORS, not values, and all-or-nothing.
|
package/src/live-node.ts
CHANGED
|
@@ -17,7 +17,7 @@ import type {
|
|
|
17
17
|
UpgradeTarget,
|
|
18
18
|
WsData,
|
|
19
19
|
WsLike,
|
|
20
|
-
} from '@ultimat3/realtime';
|
|
20
|
+
} from '@ultimat3/realtime/server';
|
|
21
21
|
import { liveNodeUnavailable, upgradeRefused } from './errors';
|
|
22
22
|
|
|
23
23
|
/** Every frame this end received, in order, already parsed. */
|
|
@@ -121,7 +121,7 @@ let sequence = 0;
|
|
|
121
121
|
export async function createLiveNode(options: LiveNodeOptions = {}): Promise<LiveNodeHandle> {
|
|
122
122
|
const core = await import('@ultimat3/core');
|
|
123
123
|
const query = await import('@ultimat3/query');
|
|
124
|
-
const realtime = await import('@ultimat3/realtime');
|
|
124
|
+
const realtime = await import('@ultimat3/realtime/server');
|
|
125
125
|
|
|
126
126
|
const buildId = options.buildId ?? 'test-build';
|
|
127
127
|
const transport = new realtime.InProcessTransport();
|
package/src/live-replicator.ts
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
// still decides what a real node reads, and this is never in that decision.
|
|
15
15
|
|
|
16
16
|
import type { RowBulkChange, RowChange, RowObserver } from '@ultimat3/entity';
|
|
17
|
-
import type {
|
|
17
|
+
import type { Row } from '@ultimat3/realtime';
|
|
18
|
+
import type { ChangeEvent, ChangeOp, LiveQueryRegistry } from '@ultimat3/realtime/server';
|
|
18
19
|
|
|
19
20
|
/** What a caller does with a change nobody could deliver. */
|
|
20
21
|
export interface LiveReplicatorOptions {
|
package/src/registry-snapshot.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
catalogFor,
|
|
9
9
|
configureLocales,
|
|
10
10
|
localeConfig,
|
|
11
|
+
mergeCatalogs,
|
|
11
12
|
registerCatalog,
|
|
12
13
|
registeredLocales,
|
|
13
14
|
resetCatalogs,
|
|
@@ -46,16 +47,50 @@ export function captureProcessRegistries(): ProcessRegistrySnapshot {
|
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
/**
|
|
49
|
-
* Idempotent
|
|
50
|
-
* about
|
|
51
|
-
*
|
|
50
|
+
* Idempotent. A REPLACE on the locale config, the permission set and the role map — a snapshot is
|
|
51
|
+
* the whole truth about those at capture time — and, for the catalogs alone, a key-level
|
|
52
|
+
* RECONCILE. See `restoreCatalogs`.
|
|
52
53
|
*/
|
|
53
54
|
export function restoreProcessRegistries(snapshot: ProcessRegistrySnapshot): void {
|
|
54
55
|
// A full `LocaleConfig`, so the merge `configureLocales` performs replaces all three fields —
|
|
55
56
|
// a partial call can never widen `supported` back.
|
|
56
57
|
configureLocales(snapshot.locales);
|
|
57
|
-
|
|
58
|
-
for (const [locale, catalog] of snapshot.catalogs) registerCatalog(locale, catalog);
|
|
58
|
+
restoreCatalogs(snapshot.catalogs);
|
|
59
59
|
restorePermissions(snapshot.permissions);
|
|
60
60
|
restoreRoles(snapshot.roles, snapshot.roleSites);
|
|
61
61
|
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Repair a clear; never undo a registration. The catalogs are the one registry here restored by
|
|
65
|
+
* MERGE rather than by replacement, and the module cache is why.
|
|
66
|
+
*
|
|
67
|
+
* `registerCatalog` has no inverse — it merges, last wins — so the only thing a file can do that
|
|
68
|
+
* costs the next file anything is `resetCatalogs()`, and that is exactly what this repairs: a key
|
|
69
|
+
* the snapshot holds and the live registry has lost comes back. Everything the live registry still
|
|
70
|
+
* holds is left alone, INCLUDING a key whose value the file changed.
|
|
71
|
+
*
|
|
72
|
+
* Undoing the change is what the first attempt at #312 did, and it is wrong twice over. A key
|
|
73
|
+
* first registered during the file cannot be re-added by anyone: `loadApp()` inside a test body
|
|
74
|
+
* dynamically imports the app's i18n package, `defineCatalogs()` there is MODULE scope — once per
|
|
75
|
+
* `bun test` process — so dropping the app's 519 keys left every later file's own `import` a cache
|
|
76
|
+
* hit that declares nothing and `t('brand.name')` answering `⟦brand.name⟧` for the rest of the run.
|
|
77
|
+
* And a key it OVERRODE is the same declaration read one layer down: the demo app's
|
|
78
|
+
* `admin.denied.body` overrides `@ultimat3/i18n`'s own base string, so reverting to the inherited
|
|
79
|
+
* value rendered the framework's `This account is missing admin:read` in place of the app's copy —
|
|
80
|
+
* a green `⟦…⟧` sweep hiding the identical defect.
|
|
81
|
+
*
|
|
82
|
+
* The cost is stated rather than hidden: a file that deliberately CLOBBERS an inherited key leaves
|
|
83
|
+
* that value for the next file. Its cleanup is the one this repair is built around — `resetCatalogs()`
|
|
84
|
+
* in the file's own `afterAll`, which drops its layer and lets the boundary put back what that clear
|
|
85
|
+
* took from everyone else.
|
|
86
|
+
*/
|
|
87
|
+
function restoreCatalogs(snapshot: readonly (readonly [Locale, Catalog])[]): void {
|
|
88
|
+
const inherited = new Map(snapshot);
|
|
89
|
+
const live = new Map(registeredLocales().map((locale) => [locale, catalogFor(locale)] as const));
|
|
90
|
+
// Cleared first so the merge order below is this function's to choose: `registerCatalog` puts
|
|
91
|
+
// its argument last, which would otherwise let the inherited value win the keys it shares.
|
|
92
|
+
resetCatalogs();
|
|
93
|
+
for (const locale of new Set([...inherited.keys(), ...live.keys()])) {
|
|
94
|
+
registerCatalog(locale, mergeCatalogs(inherited.get(locale) ?? {}, live.get(locale) ?? {}));
|
|
95
|
+
}
|
|
96
|
+
}
|