@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 +42 -0
- package/api-surface.md +3 -0
- package/package.json +6 -6
- package/src/TestApp.ts +53 -7
- package/src/preload.ts +14 -2
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.
|
|
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.
|
|
36
|
-
"@zerotal/orm": "1.
|
|
37
|
-
"@zerotal/queue": "1.
|
|
38
|
-
"@zerotal/notifications": "1.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
|
11
|
-
*
|
|
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
|
*
|