@zerotal/testing 1.0.4 → 1.3.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/CHANGELOG.md CHANGED
@@ -8,6 +8,16 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.1.0] — 2026-08-08
12
+
13
+ ### Fixed
14
+
15
+ - **Test files no longer tear the database out from under each other.** Bun runs a whole suite in one process and the ORM connection is process-global, so one file's `afterAll(() => app.close())` closed the connection every later file depended on — and the file that failed was a correct one that merely ran second, dying with "No database connection. Is DatabaseProvider registered?". `createTestApp()` now shares one app per process, keyed by the resolved `Application` (the scaffolded pattern passes a fresh arrow each time, so the callback identity is no key). `close()` on a shared app resets per-test state and leaves it running.
16
+
17
+ ### Added
18
+
19
+ - `closeSharedTestApps()` — tears the shared app down explicitly, for a global teardown or a suite asserting no timers leak. Passing a `setup` callback still opts out of sharing, since routes cannot be registered twice against a running server.
20
+
11
21
  ## [1.0.3] — 2026-08-07
12
22
 
13
23
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/testing",
3
- "version": "1.0.4",
3
+ "version": "1.3.0",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -30,14 +30,14 @@
30
30
  "typecheck": "tsc --noEmit"
31
31
  },
32
32
  "dependencies": {
33
- "@zerotal/core": "1.0.4",
34
- "@zerotal/orm": "1.0.4",
35
- "@zerotal/queue": "1.0.4",
36
- "@zerotal/notifications": "1.0.4"
33
+ "@zerotal/core": "1.3.0",
34
+ "@zerotal/orm": "1.3.0",
35
+ "@zerotal/queue": "1.3.0",
36
+ "@zerotal/notifications": "1.3.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "typescript": "^5.8.0",
40
- "@zerotal/session": "1.0.4"
40
+ "@zerotal/session": "1.3.0"
41
41
  },
42
42
  "description": "Testing utilities for Zerotal — an in-process test app, HTTP helpers, and database refresh.",
43
43
  "keywords": [
package/src/TestApp.ts CHANGED
@@ -415,8 +415,26 @@ export class TestApp {
415
415
  return this.request(url, { method, headers, body: form });
416
416
  }
417
417
 
418
- /** Stop the test server and shut the app's providers down. Call in afterAll(). */
418
+ /** @internal Set when this app is the per-process shared instance (see createTestApp). */
419
+ _shared = false;
420
+
421
+ /**
422
+ * Stop the test server and shut the app's providers down. Call in `afterAll()`.
423
+ *
424
+ * When this app is the per-process shared instance — the normal case, where each
425
+ * test file calls `createTestApp()` with the same `bootstrap/app.ts` — this only
426
+ * resets per-test state (auth, flash, captured mail) and leaves the app running for
427
+ * the files that come after. Tearing it down here would close the process-global
428
+ * database connection out from under them, and the file that broke would be an
429
+ * entirely correct one that merely ran second.
430
+ *
431
+ * The shared instance is torn down once, by {@link closeSharedTestApps}.
432
+ */
419
433
  async close(): Promise<void> {
434
+ if (this._shared) {
435
+ resetTestState();
436
+ return;
437
+ }
420
438
  // Run the full provider teardown (onStopping/onStopped), not just the HTTP
421
439
  // server: otherwise queue polling intervals, monitor timers, worker threads,
422
440
  // and DB connections from this test file keep running while the next file
@@ -548,10 +566,45 @@ function _toFile(input: TestFileInput | File | Blob): File {
548
566
  * () => { Router.get('/ping', PingController, 'handle'); },
549
567
  * );
550
568
  */
569
+ /**
570
+ * The per-process shared apps, keyed by the {@link Application} they wrap.
571
+ *
572
+ * Bun runs an entire test suite in one process, and the ORM connection is
573
+ * process-global — so a second file that boots its own app inherits a connection the
574
+ * first file's `afterAll` already closed, and dies in `migrateDatabase()` with
575
+ * "No database connection". Booting once and handing the same instance to every file
576
+ * removes the interference without asking each test file to know about it.
577
+ *
578
+ * Keyed by the Application and not by the `bootstrap` callback: the scaffolded pattern
579
+ * is `createTestApp(() => import('../bootstrap/app.ts').then((m) => m.default))`, a
580
+ * fresh arrow on every call. The module it imports is cached, so the *Application* is
581
+ * the thing that is genuinely the same across files.
582
+ */
583
+ const _sharedApps = new Map<Application, TestApp>();
584
+
551
585
  export async function createTestApp(
552
586
  bootstrap: () => Application | Promise<Application>,
553
587
  setup?: () => void,
554
588
  ): Promise<TestApp> {
589
+ // A `setup` callback registers routes, and registering them twice against an
590
+ // already-started server is not idempotent — so those callers always get a fresh
591
+ // app and own its teardown, exactly as before.
592
+ //
593
+ // Only probe once an app exists: on the first call `bootstrap()` has real side
594
+ // effects (Application.create + provider registration) and must run after the
595
+ // reset below, not before it.
596
+ if (!setup && _sharedApps.size > 0) {
597
+ const booted = await bootstrap();
598
+ const existing = _sharedApps.get(booted);
599
+ if (existing) {
600
+ // Re-adopt: an earlier file's close() reset the app scope even though the app
601
+ // itself is still running, so facades need pointing back at it.
602
+ booted.adoptAsCurrent();
603
+ resetTestState();
604
+ return existing;
605
+ }
606
+ }
607
+
555
608
  resetTestState();
556
609
  const app = await bootstrap();
557
610
  // A module-cached `bootstrap/app.ts` returns its top-level app on re-import
@@ -569,5 +622,27 @@ export async function createTestApp(
569
622
  errors.inner = app._swapExceptionHandler(errors);
570
623
 
571
624
  await app.start(0);
572
- return new TestApp(app, errors);
625
+ const testApp = new TestApp(app, errors);
626
+
627
+ if (!setup) {
628
+ testApp._shared = true;
629
+ _sharedApps.set(app, testApp);
630
+ }
631
+ return testApp;
632
+ }
633
+
634
+ /**
635
+ * Tear down every shared app created by {@link createTestApp}.
636
+ *
637
+ * Only needed when something must be released before the process exits — a global
638
+ * teardown file, or a suite that asserts no timers are left running. Individual test
639
+ * files should keep calling `app.close()`; on a shared app that resets per-test state
640
+ * and leaves the app up for the files still to run.
641
+ */
642
+ export async function closeSharedTestApps(): Promise<void> {
643
+ for (const testApp of _sharedApps.values()) {
644
+ testApp._shared = false;
645
+ await testApp.close();
646
+ }
647
+ _sharedApps.clear();
573
648
  }
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { createTestApp, TestApp } from "./TestApp.ts";
1
+ export { createTestApp, closeSharedTestApps, TestApp } from "./TestApp.ts";
2
2
  export type { TestFileInput, TestFormValue } from "./TestApp.ts";
3
3
  export { TestResponse } from "./TestResponse.ts";
4
4
  export type { SessionDecoder, TestResponseContext, InertiaPage } from "./TestResponse.ts";
@@ -0,0 +1,17 @@
1
+ /**
2
+ * A minimal bootstrap module shared by the two regression files beside it.
3
+ *
4
+ * The point is that this module is *cached*: both files import it, so both get the
5
+ * same Application instance — which is what `createTestApp` keys its per-process
6
+ * sharing on.
7
+ */
8
+ import { Application, Router } from "@zerotal/core";
9
+ import { DatabaseProvider } from "@zerotal/orm";
10
+
11
+ const app = Application.create({ env: "test" })
12
+ .register([DatabaseProvider])
13
+ .useConfig({ database: { driver: "sqlite", url: ":memory:" } });
14
+
15
+ Router.get("/ping", () => new Response("pong"));
16
+
17
+ export default app;