@ultimat3/testing 8.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 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` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/testing",
3
- "version": "8.0.0",
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": "8.0.0",
37
- "@ultimat3/core": "8.0.0",
38
- "@ultimat3/db": "8.0.0",
39
- "@ultimat3/entity": "8.0.0",
40
- "@ultimat3/i18n": "8.0.0",
41
- "@ultimat3/jobs": "8.0.0",
42
- "@ultimat3/mail": "8.0.0",
43
- "@ultimat3/policy": "8.0.0",
44
- "@ultimat3/query": "8.0.0",
45
- "@ultimat3/realtime": "8.0.0",
46
- "@ultimat3/time": "8.0.0"
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
  }
@@ -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, and a REPLACE on every registry rather than a merge: a snapshot is the whole truth
50
- * about the process at capture time, so anything declared since must go as surely as anything
51
- * cleared since must come back.
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
- resetCatalogs();
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
+ }