@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 +44 -0
- package/api-surface.md +11 -9
- package/package.json +6 -6
- package/src/TestResponse.ts +135 -4
- 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,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.
|
|
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.
|
|
36
|
-
"@zerotal/orm": "1.
|
|
37
|
-
"@zerotal/queue": "1.
|
|
38
|
-
"@zerotal/notifications": "1.
|
|
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.
|
|
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": [
|
package/src/TestResponse.ts
CHANGED
|
@@ -179,17 +179,124 @@ export class TestResponse {
|
|
|
179
179
|
return this;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
/**
|
|
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
|
-
|
|
190
|
-
|
|
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(
|
|
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;
|
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 {
|