@zerotal/testing 1.8.1 → 1.10.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,40 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.10.0] — 2026-08-30
12
+
13
+ ### Added
14
+
15
+ - **`res.assertInertiaRedirect(url)`** — the assertion that pins what actually breaks.
16
+ A redirect with the right status and the right `Location` and no `X-Inertia: true`
17
+ is ignored by the Inertia client: the request succeeds, the row is written, and the
18
+ form sits there with its fields still filled in. `assertRedirect` checks the two
19
+ headers that were never wrong, so an app can write three tests for this and have
20
+ them pass whether or not its own workaround middleware is installed — which is how
21
+ a workaround becomes permanent. This checks the redirect status (303 by default,
22
+ because that is what a form submit must get), the `Location`, and the marker.
23
+
24
+ - **`@zerotal/testing/preload` warns when the runtime is below the project's
25
+ `engines.bun`.** `startZerotal()` refuses on the same condition, which covers every
26
+ `zt` command — but not `bun test` typed straight into a shell, and that is the case
27
+ worth catching: the shell's Bun and the project's can differ, and the difference
28
+ arrives as a handful of `Intl` assertions going red with nothing naming a binary.
29
+ A parent-process check cannot see this; only an assertion from inside the process
30
+ the tests run in can, which is what a preload is.
31
+
32
+ A warning rather than a refusal, because a preload that throws takes down the whole
33
+ run and a suite that is merely _suspect_ should still produce its results.
34
+
35
+ ## [1.9.0] — 2026-08-29
36
+
37
+ ### Documented
38
+
39
+ - **Every promised export is documented.** The `docs-coverage` gate reads `maturity: stable` as a
40
+ promise about a package's exports, and measures how much of that promise is written down. It
41
+ was 798 gaps across the suite; it is now zero. This package's share is covered on its own
42
+ pages — types named, options shapes described, and the decisions behind them recorded where
43
+ somebody looking for them will find them.
44
+
11
45
  ## [1.5.0] — 2026-08-15
12
46
 
13
47
  ### Added
package/api-surface.md CHANGED
@@ -187,6 +187,7 @@ class TestResponse = {
187
187
  assertHeaderMissing: (name: string) => TestResponse
188
188
  assertInertia: (component?: string, props?: Record<string, unknown>) => TestResponse
189
189
  assertInertiaProp: (key: string, value?: unknown) => TestResponse
190
+ assertInertiaRedirect: (url: string, status?: number) => TestResponse
190
191
  assertInvalid: (fields?: string | string[] | Record<string, string>) => TestResponse
191
192
  assertJson: (expected: Record<string, unknown>) => TestResponse
192
193
  assertJsonCount: (count: number, key?: string) => TestResponse
@@ -261,16 +262,16 @@ interface InertiaPage = {
261
262
  }
262
263
 
263
264
  interface MigrateDatabaseOptions = {
264
- connection?: SQLInstance
265
- path?: string
266
- table?: string
265
+ connection?: SQLInstance | undefined
266
+ path?: string | undefined
267
+ table?: string | undefined
267
268
  }
268
269
 
269
270
  interface RefreshDatabaseOptions = {
270
- connection?: SQLInstance
271
- migrate?: string | boolean
272
- setup?: (db: SQLInstance) => void | Promise<void>
273
- teardown?: (db: SQLInstance) => void | Promise<void>
271
+ connection?: SQLInstance | undefined
272
+ migrate?: string | boolean | undefined
273
+ setup?: ((db: SQLInstance) => void | Promise<void>) | undefined
274
+ teardown?: ((db: SQLInstance) => void | Promise<void>) | undefined
274
275
  }
275
276
 
276
277
  interface TestFileInput = {
@@ -342,8 +343,8 @@ interface BrowserAvailability = {
342
343
  }
343
344
 
344
345
  interface FlowBrowserOptions = {
345
- setup?: () => void
346
- timeout?: number
346
+ setup?: (() => void) | undefined
347
+ timeout?: number | undefined
347
348
  }
348
349
 
349
350
  interface ObservedFrame = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/testing",
3
- "version": "1.8.1",
3
+ "version": "1.10.0",
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.8.1",
36
- "@zerotal/orm": "1.8.1",
37
- "@zerotal/queue": "1.8.1",
38
- "@zerotal/notifications": "1.8.1"
35
+ "@zerotal/core": "1.10.0",
36
+ "@zerotal/orm": "1.10.0",
37
+ "@zerotal/queue": "1.10.0",
38
+ "@zerotal/notifications": "1.10.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^5.8.0",
42
- "@zerotal/session": "1.8.1"
42
+ "@zerotal/session": "1.10.0"
43
43
  },
44
44
  "description": "Testing utilities for Zerotal — an in-process test app, HTTP helpers, and database refresh.",
45
45
  "keywords": [
@@ -195,6 +195,61 @@ export class TestResponse {
195
195
  return this;
196
196
  }
197
197
 
198
+ /**
199
+ * Assert the response is a redirect a browser running Inertia will actually follow.
200
+ *
201
+ * Three things have to be true together, and `assertRedirect` checks one of them.
202
+ * A redirect that is correct on status and `Location` but unmarked is the failure
203
+ * this exists for: the request succeeds, the row is written, and the form sits
204
+ * there with the fields still filled in — because the Inertia client did not
205
+ * recognise the response as its own and never navigated. Nothing about that looks
206
+ * like an error from either end, which is why it is worth an assertion of its own.
207
+ *
208
+ * - **A redirect status.** After a POST/PUT/DELETE it must be `303`, not `302`:
209
+ * only See Other makes the browser follow with `GET` instead of repeating the
210
+ * method against the target.
211
+ * - **`Location`**, as `assertRedirect` checks it.
212
+ * - **`X-Inertia: true`**, which is the part a status-and-location assertion
213
+ * cannot see and the part that was missing.
214
+ *
215
+ * Send the request with the `X-Inertia` header for this to mean anything — a
216
+ * redirect to a browser that is not running Inertia is just a redirect.
217
+ *
218
+ * @param url - Expected `Location` (matched as a substring, like {@link assertRedirect}).
219
+ * @param status - Expected status. Defaults to `303`, which is what a form submit gets.
220
+ *
221
+ * @example
222
+ * ```ts
223
+ * const res = await app.post("/orders", data, { headers: { "X-Inertia": "true" } });
224
+ * res.assertInertiaRedirect("/orders/1");
225
+ * ```
226
+ */
227
+ assertInertiaRedirect(url: string, status = 303): this {
228
+ this.assertRedirect(url);
229
+ if (this._res.status !== status) {
230
+ throw new Error(
231
+ this._decorate(
232
+ `Expected an Inertia redirect with HTTP ${status} but got ${this._res.status}. ` +
233
+ (status === 303 && this._res.status === 302
234
+ ? "A 302 after a non-GET makes the browser repeat the method against the target."
235
+ : ""),
236
+ { headers: true },
237
+ ),
238
+ );
239
+ }
240
+ if (this._res.headers.get("X-Inertia") !== "true") {
241
+ throw new Error(
242
+ this._decorate(
243
+ `Expected the redirect to carry X-Inertia: true, but it did not. The Inertia ` +
244
+ `client ignores a redirect it does not recognise as its own — the request ` +
245
+ `succeeds and the page never moves.`,
246
+ { headers: true },
247
+ ),
248
+ );
249
+ }
250
+ return this;
251
+ }
252
+
198
253
  // ── Header assertions ─────────────────────────────────────────────────
199
254
 
200
255
  /**
@@ -39,7 +39,7 @@ export interface TransportReport {
39
39
  /** Options for {@link FlowBrowser.serve}. */
40
40
  export interface FlowBrowserOptions {
41
41
  /** Milliseconds any `waitFor*` will wait before giving up. */
42
- timeout?: number;
42
+ timeout?: number | undefined;
43
43
  /**
44
44
  * Register routes, before the server starts.
45
45
  *
@@ -48,7 +48,7 @@ export interface FlowBrowserOptions {
48
48
  * needs a fixture page — one deliberately built to reproduce a bug — registers
49
49
  * it here instead of adding it to the application under test.
50
50
  */
51
- setup?: () => void;
51
+ setup?: (() => void) | undefined;
52
52
  }
53
53
 
54
54
  const DEFAULT_TIMEOUT = 5_000;
@@ -13,11 +13,11 @@ export interface MigrateDatabaseOptions {
13
13
  * Connection to migrate. Defaults to the active model connection, which is
14
14
  * what `createTestApp()` and `refreshDatabase({ connection })` install.
15
15
  */
16
- connection?: SQLInstance;
16
+ connection?: SQLInstance | undefined;
17
17
  /** Directory holding the migration files. Defaults to `database/migrations`. */
18
- path?: string;
18
+ path?: string | undefined;
19
19
  /** Tracking-table name. Defaults to `migrations`. */
20
- table?: string;
20
+ table?: string | undefined;
21
21
  }
22
22
 
23
23
  /**
package/src/preload.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * @zerotal/testing/preload — auto-wires the DB connection for every test worker.
2
+ * @zerotal/testing/preload — auto-wires the DB connection for every test worker,
3
+ * and checks the runtime the tests are actually running on.
3
4
  *
4
5
  * Loaded via `bun test --preload @zerotal/testing/preload` when you run
5
6
  * `bun zt test`. Reads ZT_DB_URL from the environment (set by
@@ -8,10 +9,30 @@
8
9
  *
9
10
  * Each Bun test worker runs its own copy of this module, so connections are
10
11
  * isolated per file — no cross-file leakage.
12
+ *
13
+ * ## Why the runtime check is here and not only in `zt test`
14
+ *
15
+ * `startZerotal()` refuses to run below the project's `engines.bun`, which covers
16
+ * every `zt` command. It does not cover `bun test` typed straight into a shell,
17
+ * and that is the case worth catching: the shell's `bun` and the project's can
18
+ * differ, and the difference shows up as a handful of `Intl` assertions going red
19
+ * with nothing in the failure naming a binary. A parent-process check cannot see
20
+ * this — only an assertion from inside the process the tests run in can, which is
21
+ * what a preload is.
11
22
  */
12
23
 
13
24
  import { SQL } from "bun";
14
25
  import { _setBaseModelConnection, _setBaseModelDialect, _setDbConnection } from "@zerotal/orm";
26
+ import { runtimeBelowFloor, runtimeBelowFloorMessage } from "@zerotal/core/runtime";
27
+
28
+ // A warning rather than a refusal: a preload that throws takes down the whole run,
29
+ // and a suite that is merely *suspect* should still produce its results — the point
30
+ // is that the version is named at the top of the output instead of being the last
31
+ // thing anyone thinks to check.
32
+ const _floor = runtimeBelowFloor();
33
+ if (_floor) {
34
+ console.warn(`\n⚠ ${runtimeBelowFloorMessage(_floor)}\n`);
35
+ }
15
36
 
16
37
  const url = Bun.env["ZT_DB_URL"];
17
38
 
@@ -18,16 +18,16 @@ function _hooks(): TestHooks {
18
18
  }
19
19
 
20
20
  export interface RefreshDatabaseOptions {
21
- connection?: SQLInstance;
21
+ connection?: SQLInstance | undefined;
22
22
  /**
23
23
  * Build the schema by running the project's migrations before the suite.
24
24
  * `true` uses `database/migrations`; pass a string for a different directory.
25
25
  *
26
26
  * Prefer this over hand-written DDL in `setup` — see {@link migrateDatabase}.
27
27
  */
28
- migrate?: boolean | string;
29
- setup?: (db: SQLInstance) => void | Promise<void>;
30
- teardown?: (db: SQLInstance) => void | Promise<void>;
28
+ migrate?: boolean | string | undefined;
29
+ setup?: ((db: SQLInstance) => void | Promise<void>) | undefined;
30
+ teardown?: ((db: SQLInstance) => void | Promise<void>) | undefined;
31
31
  }
32
32
 
33
33
  export function refreshDatabase(options: RefreshDatabaseOptions = {}): void {