@zerotal/testing 1.14.3 → 1.15.1

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,48 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.15.1] — 2026-09-13
12
+
13
+ ### Fixed
14
+
15
+ - **A file that booted the app with a `setup` callback left every later file without a
16
+ database.** `createTestApp(bootstrap, setup)` opts out of _sharing_, because `setup`
17
+ registers routes and those cannot be added twice to a running server — and
18
+ `FlowBrowser.serve` always passes one. But "not shared" was implemented as "not cached",
19
+ which is a much bigger claim: `bootstrap/app.ts` is module-cached, so the `Application`
20
+ behind it is the one every later file's `bootstrap()` returns.
21
+
22
+ Being invisible to the cache meant nothing knew it was in use, so its `close()` ran the
23
+ full provider teardown. `DatabaseProvider.onStopping` clears the process-global
24
+ connection resolver, and the next file then got that same stopped `Application` back —
25
+ `Application.create()` short-circuits on a module-cached app, so the providers never
26
+ re-registered and nothing put the resolver back. Every query from then on raised
27
+ `[Zerotal ORM] No database connection. Is DatabaseProvider registered?` in files that
28
+ had done nothing wrong.
29
+
30
+ The app is now cached whichever call booted it. `_shared` still means "reached through
31
+ the sharing path", and a second `setup` caller still gets the fresh path it needs — only
32
+ the bookkeeping changed.
33
+
34
+ It read as a CI-only flake for months because the exposing order is a browser-driven
35
+ file before an ordinary one: alphabetical on Windows, arbitrary readdir order on Linux.
36
+ Reproduced locally by renaming the one ordinary file to sort last (62 pass / 49 fail),
37
+ and verified at 111 / 0 across seven runs in both orders.
38
+
39
+ ## [1.15.0] — 2026-09-04
40
+
41
+ ### Fixed
42
+
43
+ - **A test that stubbed `globalThis.fetch` also answered the test client's own requests.**
44
+ `TestApp` drives the app over a loopback server through whatever `fetch` was current, so
45
+ faking an outbound integration — the ordinary way to test one — made unrelated route tests
46
+ fail with `connection refused`, or come back holding the third-party's JSON. The failure
47
+ presents as "my test client cannot reach my app", which sends you looking at the server.
48
+
49
+ `TestApp` now holds the real `fetch`, captured at module load before any test can replace
50
+ it. An outbound stub cannot intercept an inbound request, and no passthrough is needed for
51
+ the test client.
52
+
11
53
  ## [1.11.0] — 2026-08-31
12
54
 
13
55
  ### Changed
package/api-surface.md CHANGED
@@ -3,6 +3,9 @@
3
3
  <!-- AUTO-GENERATED by scripts/api-surface.ts. Do not edit by hand.
4
4
  Run `bun run api:surface` to regenerate after an intentional API change. -->
5
5
 
6
+ > Not a guide — this is a flat, alphabetical snapshot used to detect unintended
7
+ > API changes. For how to use these, read the [documentation](https://zerotal.dev/docs).
8
+
6
9
  ## . `(./src/index.ts)`
7
10
 
8
11
  class EventFake = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/testing",
3
- "version": "1.14.3",
3
+ "version": "1.15.1",
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.14.3",
36
- "@zerotal/orm": "1.14.3",
37
- "@zerotal/queue": "1.14.3",
38
- "@zerotal/notifications": "1.14.3"
35
+ "@zerotal/core": "1.15.1",
36
+ "@zerotal/orm": "1.15.1",
37
+ "@zerotal/queue": "1.15.1",
38
+ "@zerotal/notifications": "1.15.1"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^5.8.0",
42
- "@zerotal/session": "1.14.3"
42
+ "@zerotal/session": "1.15.1"
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
@@ -5,6 +5,24 @@ import { resetTestState } from "./resetTestState.ts";
5
5
 
6
6
  type BunServer = { port: number; stop(drain?: boolean): void };
7
7
 
8
+ /**
9
+ * The real `fetch`, captured at module load — before any test has had a chance
10
+ * to replace `globalThis.fetch`.
11
+ *
12
+ * The test client talks to the app over a real loopback server, so it used to
13
+ * call whatever `fetch` was current. A test that stubs `globalThis.fetch` to
14
+ * fake an outbound integration therefore also answered the test client's own
15
+ * requests: three unrelated route tests fail with `connection refused` or come
16
+ * back holding the third-party's JSON, and the failure reads as "my test client
17
+ * cannot reach my app", which sends you looking at the server.
18
+ *
19
+ * Binding it here means an outbound stub can never intercept an inbound request.
20
+ * A test that genuinely wants to intercept the test client has `app.app` and the
21
+ * router; a test that wants to stub an integration should not have to know this
22
+ * exists at all.
23
+ */
24
+ const _fetch: typeof globalThis.fetch = globalThis.fetch.bind(globalThis);
25
+
8
26
  /**
9
27
  * Wrapper around a running Application that provides an HTTP test client.
10
28
  * Obtain an instance via createTestApp().
@@ -251,7 +269,7 @@ export class TestApp {
251
269
 
252
270
  if (this._errors) this._errors.lastError = undefined;
253
271
 
254
- let res = await fetch(full, { ...init, headers, redirect: "manual" });
272
+ let res = await _fetch(full, { ...init, headers, redirect: "manual" });
255
273
 
256
274
  if (this._followRedirects) {
257
275
  let hops = 0;
@@ -267,7 +285,7 @@ export class TestApp {
267
285
  const cookiePair = setCookie.split(";")[0] ?? "";
268
286
  nextHeaders.set("Cookie", existing ? `${existing}; ${cookiePair}` : cookiePair);
269
287
  }
270
- res = await fetch(nextUrl, { method: "GET", headers: nextHeaders, redirect: "manual" });
288
+ res = await _fetch(nextUrl, { method: "GET", headers: nextHeaders, redirect: "manual" });
271
289
  hops++;
272
290
  }
273
291
  }
@@ -431,7 +449,23 @@ export class TestApp {
431
449
  * The shared instance is torn down once, by {@link closeSharedTestApps}.
432
450
  */
433
451
  async close(): Promise<void> {
434
- 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)) {
435
469
  resetTestState();
436
470
  return;
437
471
  }
@@ -650,10 +684,22 @@ export async function createTestApp(
650
684
  await app.start(0);
651
685
  const testApp = new TestApp(app, errors);
652
686
 
653
- if (!setup) {
654
- testApp._shared = true;
655
- _sharedApps.set(app, testApp);
656
- }
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);
657
703
  return testApp;
658
704
  }
659
705
 
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
  *