@zerotal/testing 1.15.0 → 1.15.2

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/CHANGELOG.md CHANGED
@@ -8,6 +8,43 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.15.2] — 2026-10-23
12
+
13
+ ### Fixed
14
+
15
+ - **`assertDatabaseHas`, `assertDatabaseMissing` and `assertDatabaseCount` run on Bun
16
+ 1.4.4.** They counted rows through a hand-built template object whose `raw` was
17
+ enumerable, which Bun 1.4.4 refuses. They use the ORM's `_templateStrings()` now; see
18
+ `@zerotal/orm`.
19
+
20
+ ## [1.15.1] — 2026-09-13
21
+
22
+ ### Fixed
23
+
24
+ - **A file that booted the app with a `setup` callback left every later file without a
25
+ database.** `createTestApp(bootstrap, setup)` opts out of _sharing_, because `setup`
26
+ registers routes and those cannot be added twice to a running server — and
27
+ `FlowBrowser.serve` always passes one. But "not shared" was implemented as "not cached",
28
+ which is a much bigger claim: `bootstrap/app.ts` is module-cached, so the `Application`
29
+ behind it is the one every later file's `bootstrap()` returns.
30
+
31
+ Being invisible to the cache meant nothing knew it was in use, so its `close()` ran the
32
+ full provider teardown. `DatabaseProvider.onStopping` clears the process-global
33
+ connection resolver, and the next file then got that same stopped `Application` back —
34
+ `Application.create()` short-circuits on a module-cached app, so the providers never
35
+ re-registered and nothing put the resolver back. Every query from then on raised
36
+ `[Zerotal ORM] No database connection. Is DatabaseProvider registered?` in files that
37
+ had done nothing wrong.
38
+
39
+ The app is now cached whichever call booted it. `_shared` still means "reached through
40
+ the sharing path", and a second `setup` caller still gets the fresh path it needs — only
41
+ the bookkeeping changed.
42
+
43
+ It read as a CI-only flake for months because the exposing order is a browser-driven
44
+ file before an ordinary one: alphabetical on Windows, arbitrary readdir order on Linux.
45
+ Reproduced locally by renaming the one ordinary file to sort last (62 pass / 49 fail),
46
+ and verified at 111 / 0 across seven runs in both orders.
47
+
11
48
  ## [1.15.0] — 2026-09-04
12
49
 
13
50
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/testing",
3
- "version": "1.15.0",
3
+ "version": "1.15.2",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -32,14 +32,14 @@
32
32
  "typecheck": "tsc --noEmit"
33
33
  },
34
34
  "dependencies": {
35
- "@zerotal/core": "1.15.0",
36
- "@zerotal/orm": "1.15.0",
37
- "@zerotal/queue": "1.15.0",
38
- "@zerotal/notifications": "1.15.0"
35
+ "@zerotal/core": "1.15.2",
36
+ "@zerotal/orm": "1.15.2",
37
+ "@zerotal/queue": "1.15.2",
38
+ "@zerotal/notifications": "1.15.2"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^5.8.0",
42
- "@zerotal/session": "1.15.0"
42
+ "@zerotal/session": "1.15.2"
43
43
  },
44
44
  "description": "Testing utilities for Zerotal — an in-process test app, HTTP helpers, and database refresh.",
45
45
  "keywords": [
package/src/TestApp.ts CHANGED
@@ -449,7 +449,23 @@ export class TestApp {
449
449
  * The shared instance is torn down once, by {@link closeSharedTestApps}.
450
450
  */
451
451
  async close(): Promise<void> {
452
- if (this._shared) {
452
+ // `_shared` says this wrapper came from the sharing path. It is not the same
453
+ // question as whether the *Application* is shared — a `bootstrap/app.ts` is
454
+ // module-cached, so a `createTestApp(bootstrap, setup)` call (which opts out of
455
+ // sharing, because `setup` registers routes and those cannot be added twice to
456
+ // a running server) is handed the very same `Application` the shared wrapper
457
+ // holds. `FlowBrowser.serve` always passes a `setup`.
458
+ //
459
+ // Tearing down here would then stop an Application somebody else is still
460
+ // using, closing the process-global database connection out from under every
461
+ // later file: they fail on their first query with "No database connection. Is
462
+ // DatabaseProvider registered?", in files that are entirely correct and merely
463
+ // ran second. The ordering that exposes it — a browser-driven file before an
464
+ // ordinary one — is alphabetical on Windows and arbitrary on Linux, so it read
465
+ // as a CI-only flake for months.
466
+ //
467
+ // So the question to ask is about the Application, not the wrapper.
468
+ if (this._shared || _sharedApps.has(this._app)) {
453
469
  resetTestState();
454
470
  return;
455
471
  }
@@ -668,10 +684,22 @@ export async function createTestApp(
668
684
  await app.start(0);
669
685
  const testApp = new TestApp(app, errors);
670
686
 
671
- if (!setup) {
672
- testApp._shared = true;
673
- _sharedApps.set(app, testApp);
674
- }
687
+ // Cache it either way. `_shared` still means "reached through the sharing path",
688
+ // and only a `setup`-free call is handed one back — a second `setup` caller has
689
+ // routes to register and needs the fresh path. But the *Application* is
690
+ // module-cached and every later file will ask for it, so it has to be findable
691
+ // regardless of which call happened to boot it first.
692
+ //
693
+ // Without this, an app booted by `FlowBrowser.serve` (which always passes a
694
+ // `setup`) was invisible here, so nothing knew it was in use: its `close()` ran
695
+ // the full teardown, `DatabaseProvider.onStopping` cleared the process-global
696
+ // connection resolver, and the next file's `bootstrap()` returned that same dead
697
+ // Application — `Application.create()` short-circuits on a module-cached app, so
698
+ // the providers never re-registered and nothing ever put the resolver back. Every
699
+ // query after that raised "No database connection. Is DatabaseProvider
700
+ // registered?" in a file that had done nothing wrong.
701
+ testApp._shared = !setup;
702
+ _sharedApps.set(app, testApp);
675
703
  return testApp;
676
704
  }
677
705
 
package/src/assertions.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { _getModelConnection } from "@zerotal/orm";
1
+ import { _getModelConnection, _templateStrings } from "@zerotal/orm";
2
2
 
3
3
  async function countRows(table: string, where: Record<string, unknown>): Promise<number> {
4
4
  const conn = _getModelConnection() as unknown as (
@@ -19,7 +19,7 @@ async function countRows(table: string, where: Record<string, unknown>): Promise
19
19
  });
20
20
  parts.push(sql);
21
21
 
22
- const tpl = Object.assign([...parts], { raw: [...parts] }) as unknown as TemplateStringsArray;
22
+ const tpl = _templateStrings(parts);
23
23
  const rows = await conn(tpl, ...vals);
24
24
  return Number(rows[0]?.cnt ?? 0);
25
25
  }
package/src/preload.ts CHANGED
@@ -7,8 +7,20 @@
7
7
  * TestCommand) and calls _setBaseModelConnection + _setDbConnection so that
8
8
  * withDatabase() and DB.table() work without any manual beforeAll setup.
9
9
  *
10
- * Each Bun test worker runs its own copy of this module, so connections are
11
- * isolated per file — no cross-file leakage.
10
+ * Each Bun test *worker* evaluates this module once — not once per file. Every
11
+ * file that worker runs therefore shares the connection wired here, and shares
12
+ * every other piece of module-level state in the process with it.
13
+ *
14
+ * This used to read "isolated per file — no cross-file leakage", which is the
15
+ * assumption that let five separate cross-file leaks hide: a `document` stub, a
16
+ * runtime-mode variable, an ORM dialect, and two whole booted applications, each
17
+ * set by one file and read by another. Verified rather than assumed — a hook
18
+ * registered here runs once at the end of a run, not once per file.
19
+ *
20
+ * So a test that mutates process-global state has to put it back, and the reason
21
+ * it is easy to skip is that the damage lands somewhere else: the file that
22
+ * breaks is the one that ran next, and which file that is depends on directory
23
+ * order — alphabetical on Windows, arbitrary on Linux.
12
24
  *
13
25
  * ## Why the runtime check is here and not only in `zt test`
14
26
  *