@zerotal/testing 1.9.0 → 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 +24 -0
- package/api-surface.md +10 -9
- package/package.json +6 -6
- package/src/TestResponse.ts +55 -0
- package/src/browser/FlowBrowser.ts +2 -2
- package/src/migrateDatabase.ts +3 -3
- package/src/preload.ts +22 -1
- package/src/refreshDatabase.ts +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,30 @@ 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
|
+
|
|
11
35
|
## [1.9.0] — 2026-08-29
|
|
12
36
|
|
|
13
37
|
### 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
|
|
@@ -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.
|
|
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.
|
|
36
|
-
"@zerotal/orm": "1.
|
|
37
|
-
"@zerotal/queue": "1.
|
|
38
|
-
"@zerotal/notifications": "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.
|
|
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": [
|
package/src/TestResponse.ts
CHANGED
|
@@ -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;
|
package/src/migrateDatabase.ts
CHANGED
|
@@ -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
|
|
package/src/refreshDatabase.ts
CHANGED
|
@@ -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 {
|