@zerotal/testing 1.9.0 → 1.11.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,50 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.11.0] — 2026-08-31
12
+
13
+ ### Changed
14
+
15
+ - **`assertRedirect` compares the path exactly**, where it used to use `includes()`.
16
+ The loose form made the assertion mean less than it looks like it means:
17
+ `assertRedirect("/login")` was satisfied by `/login-as-someone-else` and by
18
+ `/admin?next=/login` — the two cases a test about a login redirect exists to rule
19
+ out. An absolute `Location` still matches a relative expectation, and naming a query
20
+ string compares that too. `assertRedirectContains()` is the old behaviour, for the
21
+ cases that want it (a signed URL with an unpredictable token).
22
+
23
+ ### Documented
24
+
25
+ - **How to authenticate a test when identity is not a row.** `withSession()` already
26
+ did it, and an app with no users table — an IMAP login _is_ the identity — reached
27
+ past it to the session driver instead, guessing `driver.write()`. Reaching for the
28
+ driver is the wrong layer and does not work; the doc now says so and shows the form
29
+ that does.
30
+
31
+ ## [1.10.0] — 2026-08-30
32
+
33
+ ### Added
34
+
35
+ - **`res.assertInertiaRedirect(url)`** — the assertion that pins what actually breaks.
36
+ A redirect with the right status and the right `Location` and no `X-Inertia: true`
37
+ is ignored by the Inertia client: the request succeeds, the row is written, and the
38
+ form sits there with its fields still filled in. `assertRedirect` checks the two
39
+ headers that were never wrong, so an app can write three tests for this and have
40
+ them pass whether or not its own workaround middleware is installed — which is how
41
+ a workaround becomes permanent. This checks the redirect status (303 by default,
42
+ because that is what a form submit must get), the `Location`, and the marker.
43
+
44
+ - **`@zerotal/testing/preload` warns when the runtime is below the project's
45
+ `engines.bun`.** `startZerotal()` refuses on the same condition, which covers every
46
+ `zt` command — but not `bun test` typed straight into a shell, and that is the case
47
+ worth catching: the shell's Bun and the project's can differ, and the difference
48
+ arrives as a handful of `Intl` assertions going red with nothing naming a binary.
49
+ A parent-process check cannot see this; only an assertion from inside the process
50
+ the tests run in can, which is what a preload is.
51
+
52
+ A warning rather than a refusal, because a preload that throws takes down the whole
53
+ run and a suite that is merely _suspect_ should still produce its results.
54
+
11
55
  ## [1.9.0] — 2026-08-29
12
56
 
13
57
  ### Documented
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
@@ -196,6 +197,7 @@ class TestResponse = {
196
197
  assertNotFound: () => TestResponse
197
198
  assertOk: () => TestResponse
198
199
  assertRedirect: (url: string) => TestResponse
200
+ assertRedirectContains: (fragment: string) => TestResponse
199
201
  assertSee: (needle: string) => TestResponse
200
202
  assertSeeText: (needle: string) => TestResponse
201
203
  assertServerError: () => TestResponse
@@ -261,16 +263,16 @@ interface InertiaPage = {
261
263
  }
262
264
 
263
265
  interface MigrateDatabaseOptions = {
264
- connection?: SQLInstance
265
- path?: string
266
- table?: string
266
+ connection?: SQLInstance | undefined
267
+ path?: string | undefined
268
+ table?: string | undefined
267
269
  }
268
270
 
269
271
  interface RefreshDatabaseOptions = {
270
- connection?: SQLInstance
271
- migrate?: string | boolean
272
- setup?: (db: SQLInstance) => void | Promise<void>
273
- teardown?: (db: SQLInstance) => void | Promise<void>
272
+ connection?: SQLInstance | undefined
273
+ migrate?: string | boolean | undefined
274
+ setup?: ((db: SQLInstance) => void | Promise<void>) | undefined
275
+ teardown?: ((db: SQLInstance) => void | Promise<void>) | undefined
274
276
  }
275
277
 
276
278
  interface TestFileInput = {
@@ -342,8 +344,8 @@ interface BrowserAvailability = {
342
344
  }
343
345
 
344
346
  interface FlowBrowserOptions = {
345
- setup?: () => void
346
- timeout?: number
347
+ setup?: (() => void) | undefined
348
+ timeout?: number | undefined
347
349
  }
348
350
 
349
351
  interface ObservedFrame = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/testing",
3
- "version": "1.9.0",
3
+ "version": "1.11.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.9.0",
36
- "@zerotal/orm": "1.9.0",
37
- "@zerotal/queue": "1.9.0",
38
- "@zerotal/notifications": "1.9.0"
35
+ "@zerotal/core": "1.11.0",
36
+ "@zerotal/orm": "1.11.0",
37
+ "@zerotal/queue": "1.11.0",
38
+ "@zerotal/notifications": "1.11.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^5.8.0",
42
- "@zerotal/session": "1.9.0"
42
+ "@zerotal/session": "1.11.0"
43
43
  },
44
44
  "description": "Testing utilities for Zerotal — an in-process test app, HTTP helpers, and database refresh.",
45
45
  "keywords": [
@@ -179,17 +179,124 @@ export class TestResponse {
179
179
  return this;
180
180
  }
181
181
 
182
- /** Assert the response is a redirect to `url`. */
182
+ /**
183
+ * Assert the response is a redirect to `url`.
184
+ *
185
+ * The comparison is on the `Location` header's **path**, exactly. A substring
186
+ * match — which this used to do — makes the assertion mean less than it looks
187
+ * like it means: `assertRedirect("/login")` was satisfied by
188
+ * `/login-as-someone-else` and by `/admin?next=/login`, so a redirect to the
189
+ * wrong place passed a test written to catch exactly that.
190
+ *
191
+ * A `url` carrying a query string or a fragment is compared whole, so you can
192
+ * still pin one when it matters. For anything looser, use
193
+ * {@link assertRedirectContains}.
194
+ *
195
+ * @param url - Expected `Location`, or just its path.
196
+ */
183
197
  assertRedirect(url: string): this {
198
+ const location = this._assertIsRedirect();
199
+
200
+ // Compare paths unless the expectation names a query or a fragment — an
201
+ // absolute `Location` and a relative expectation are the same redirect.
202
+ const wantsMore = url.includes("?") || url.includes("#");
203
+ const actual = wantsMore ? _withoutOrigin(location) : _pathOf(location);
204
+ const expected = wantsMore ? _withoutOrigin(url) : _pathOf(url);
205
+
206
+ if (actual !== expected) {
207
+ throw new Error(
208
+ this._decorate(
209
+ `Expected redirect to "${url}" but Location was "${location}".` +
210
+ (location.includes(url)
211
+ ? `\n (It contains the expected value but is not equal to it. ` +
212
+ `Use assertRedirectContains() if that is what you meant.)`
213
+ : ""),
214
+ ),
215
+ );
216
+ }
217
+ return this;
218
+ }
219
+
220
+ /**
221
+ * Assert the response is a redirect whose `Location` *contains* `fragment`.
222
+ *
223
+ * The old behaviour of {@link assertRedirect}, kept for the cases where it is
224
+ * genuinely what you want — a signed URL with an unpredictable token, say.
225
+ *
226
+ * @param fragment - Substring the `Location` must contain.
227
+ */
228
+ assertRedirectContains(fragment: string): this {
229
+ const location = this._assertIsRedirect();
230
+ if (!location.includes(fragment)) {
231
+ throw new Error(
232
+ this._decorate(
233
+ `Expected redirect containing "${fragment}" but Location was "${location}".`,
234
+ ),
235
+ );
236
+ }
237
+ return this;
238
+ }
239
+
240
+ /** The `Location` of a response that is a redirect, or a failure saying it is not. */
241
+ private _assertIsRedirect(): string {
184
242
  if (this._res.status < 300 || this._res.status > 399) {
185
243
  throw new Error(
186
244
  this._decorate(`Expected a redirect but got HTTP ${this._res.status}.`, { body: true }),
187
245
  );
188
246
  }
189
- const location = this._res.headers.get("Location") ?? "";
190
- if (!location.includes(url)) {
247
+ return this._res.headers.get("Location") ?? "";
248
+ }
249
+
250
+ /**
251
+ * Assert the response is a redirect a browser running Inertia will actually follow.
252
+ *
253
+ * Three things have to be true together, and `assertRedirect` checks one of them.
254
+ * A redirect that is correct on status and `Location` but unmarked is the failure
255
+ * this exists for: the request succeeds, the row is written, and the form sits
256
+ * there with the fields still filled in — because the Inertia client did not
257
+ * recognise the response as its own and never navigated. Nothing about that looks
258
+ * like an error from either end, which is why it is worth an assertion of its own.
259
+ *
260
+ * - **A redirect status.** After a POST/PUT/DELETE it must be `303`, not `302`:
261
+ * only See Other makes the browser follow with `GET` instead of repeating the
262
+ * method against the target.
263
+ * - **`Location`**, as `assertRedirect` checks it.
264
+ * - **`X-Inertia: true`**, which is the part a status-and-location assertion
265
+ * cannot see and the part that was missing.
266
+ *
267
+ * Send the request with the `X-Inertia` header for this to mean anything — a
268
+ * redirect to a browser that is not running Inertia is just a redirect.
269
+ *
270
+ * @param url - Expected `Location` (matched as a substring, like {@link assertRedirect}).
271
+ * @param status - Expected status. Defaults to `303`, which is what a form submit gets.
272
+ *
273
+ * @example
274
+ * ```ts
275
+ * const res = await app.post("/orders", data, { headers: { "X-Inertia": "true" } });
276
+ * res.assertInertiaRedirect("/orders/1");
277
+ * ```
278
+ */
279
+ assertInertiaRedirect(url: string, status = 303): this {
280
+ this.assertRedirect(url);
281
+ if (this._res.status !== status) {
282
+ throw new Error(
283
+ this._decorate(
284
+ `Expected an Inertia redirect with HTTP ${status} but got ${this._res.status}. ` +
285
+ (status === 303 && this._res.status === 302
286
+ ? "A 302 after a non-GET makes the browser repeat the method against the target."
287
+ : ""),
288
+ { headers: true },
289
+ ),
290
+ );
291
+ }
292
+ if (this._res.headers.get("X-Inertia") !== "true") {
191
293
  throw new Error(
192
- this._decorate(`Expected redirect to "${url}" but Location was "${location}".`),
294
+ this._decorate(
295
+ `Expected the redirect to carry X-Inertia: true, but it did not. The Inertia ` +
296
+ `client ignores a redirect it does not recognise as its own — the request ` +
297
+ `succeeds and the page never moves.`,
298
+ { headers: true },
299
+ ),
193
300
  );
194
301
  }
195
302
  return this;
@@ -951,3 +1058,27 @@ function _indent(text: string): string {
951
1058
  .map((line) => ` ${line}`)
952
1059
  .join("\n");
953
1060
  }
1061
+
1062
+ /**
1063
+ * A URL's path, so an absolute `Location` and a relative expectation compare equal.
1064
+ *
1065
+ * A redirect to `https://app.test/dashboard` and an expectation of `/dashboard` are
1066
+ * the same redirect, and a test should not have to know which form the handler used.
1067
+ */
1068
+ function _pathOf(url: string): string {
1069
+ try {
1070
+ return new URL(url, "http://localhost").pathname;
1071
+ } catch {
1072
+ return url;
1073
+ }
1074
+ }
1075
+
1076
+ /** A URL without its origin — path, query and fragment — for an exact comparison. */
1077
+ function _withoutOrigin(url: string): string {
1078
+ try {
1079
+ const parsed = new URL(url, "http://localhost");
1080
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
1081
+ } catch {
1082
+ return url;
1083
+ }
1084
+ }
@@ -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 {