@zerotal/arch 1.7.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 +31 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/api-surface.md +283 -0
- package/docs/about.md +676 -0
- package/docs/admin/actions.md +183 -0
- package/docs/admin/auth.md +89 -0
- package/docs/admin/dashboard.md +155 -0
- package/docs/admin/extending-ui.md +229 -0
- package/docs/admin/extending.md +205 -0
- package/docs/admin/forms.md +155 -0
- package/docs/admin/index.md +144 -0
- package/docs/admin/operations.md +224 -0
- package/docs/admin/references.md +174 -0
- package/docs/admin/resources.md +132 -0
- package/docs/admin/structure.md +158 -0
- package/docs/admin/tables.md +185 -0
- package/docs/admin/testing.md +138 -0
- package/docs/ai.md +373 -0
- package/docs/application.md +435 -0
- package/docs/arch.md +263 -0
- package/docs/assets.md +755 -0
- package/docs/audit.md +367 -0
- package/docs/authentication.md +1000 -0
- package/docs/authorization.md +529 -0
- package/docs/broadcasting/channels.md +170 -0
- package/docs/broadcasting/client.md +108 -0
- package/docs/broadcasting/events.md +190 -0
- package/docs/broadcasting/index.md +163 -0
- package/docs/broadcasting/references.md +84 -0
- package/docs/broadcasting/testing.md +132 -0
- package/docs/cache.md +387 -0
- package/docs/carbon.md +830 -0
- package/docs/changelog.md +465 -0
- package/docs/client/auth.md +113 -0
- package/docs/client/errors.md +139 -0
- package/docs/client/files.md +118 -0
- package/docs/client/index.md +128 -0
- package/docs/client/references.md +58 -0
- package/docs/client/requests.md +131 -0
- package/docs/client/resilience.md +141 -0
- package/docs/client/testing.md +146 -0
- package/docs/commands.md +450 -0
- package/docs/components.md +2427 -0
- package/docs/config-system.md +258 -0
- package/docs/container.md +596 -0
- package/docs/context.md +579 -0
- package/docs/contributing.md +164 -0
- package/docs/controllers.md +354 -0
- package/docs/conventions.md +466 -0
- package/docs/cookies.md +101 -0
- package/docs/csrf.md +206 -0
- package/docs/database.md +476 -0
- package/docs/deployment.md +434 -0
- package/docs/devtools.md +1013 -0
- package/docs/email-verification.md +320 -0
- package/docs/encryption.md +380 -0
- package/docs/errors.md +317 -0
- package/docs/events.md +420 -0
- package/docs/flow/components.md +846 -0
- package/docs/flow/decorators.md +458 -0
- package/docs/flow/events.md +420 -0
- package/docs/flow/forms.md +551 -0
- package/docs/flow/index.md +568 -0
- package/docs/flow/layouts.md +793 -0
- package/docs/flow/lifecycle.md +336 -0
- package/docs/flow/pagination.md +402 -0
- package/docs/flow/performance.md +262 -0
- package/docs/flow/references.md +317 -0
- package/docs/flow/routing.md +550 -0
- package/docs/flow/testing.md +447 -0
- package/docs/getting-started.md +286 -0
- package/docs/health.md +269 -0
- package/docs/helpers.md +423 -0
- package/docs/i18n.md +414 -0
- package/docs/index.md +65 -0
- package/docs/inertia/build.md +95 -0
- package/docs/inertia/devtools.md +197 -0
- package/docs/inertia/index.md +267 -0
- package/docs/inertia/middleware.md +81 -0
- package/docs/inertia/props.md +507 -0
- package/docs/inertia/references.md +144 -0
- package/docs/inertia/rendering.md +189 -0
- package/docs/inertia/ssr.md +102 -0
- package/docs/inspirations.md +257 -0
- package/docs/lifecycle.md +331 -0
- package/docs/lock.md +454 -0
- package/docs/logger.md +517 -0
- package/docs/media.md +582 -0
- package/docs/middleware.md +509 -0
- package/docs/migrations.md +529 -0
- package/docs/monitor.md +377 -0
- package/docs/notifications.md +848 -0
- package/docs/orm/casts.md +365 -0
- package/docs/orm/factories.md +231 -0
- package/docs/orm/index.md +663 -0
- package/docs/orm/lifecycle.md +421 -0
- package/docs/orm/queries.md +692 -0
- package/docs/orm/relationships.md +440 -0
- package/docs/orm/serialization.md +251 -0
- package/docs/package-development.md +536 -0
- package/docs/pagination.md +340 -0
- package/docs/password-reset.md +335 -0
- package/docs/providers.md +447 -0
- package/docs/query-builder.md +465 -0
- package/docs/queue.md +549 -0
- package/docs/rate-limiting.md +253 -0
- package/docs/responses.md +336 -0
- package/docs/roles-and-2fa.md +418 -0
- package/docs/routing.md +1008 -0
- package/docs/scaffolding.md +335 -0
- package/docs/scheduler.md +533 -0
- package/docs/seeding.md +229 -0
- package/docs/session.md +451 -0
- package/docs/social.md +524 -0
- package/docs/storage.md +622 -0
- package/docs/structure.md +230 -0
- package/docs/support-policy.md +114 -0
- package/docs/telemetry.md +505 -0
- package/docs/tenancy.md +608 -0
- package/docs/testing/browser.md +153 -0
- package/docs/testing/console.md +130 -0
- package/docs/testing/database.md +238 -0
- package/docs/testing/flow-browser.md +216 -0
- package/docs/testing/http.md +441 -0
- package/docs/testing/index.md +240 -0
- package/docs/testing/mocking.md +410 -0
- package/docs/upgrade.md +192 -0
- package/docs/validator.md +549 -0
- package/docs/view.md +453 -0
- package/package.json +60 -0
- package/src/bin/mcp.ts +73 -0
- package/src/config.ts +63 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +56 -0
- package/src/install/ArchInstallCommand.ts +189 -0
- package/src/install/detect.ts +98 -0
- package/src/install/guidelines.ts +290 -0
- package/src/install/markers.ts +77 -0
- package/src/install/mcpConfig.ts +81 -0
- package/src/mcp/index.ts +27 -0
- package/src/mcp/jsonrpc.ts +133 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/stdio.ts +111 -0
- package/src/mcp/types.ts +146 -0
- package/src/probe/ArchProbeCommand.ts +48 -0
- package/src/probe/sentinel.ts +13 -0
- package/src/probe/topics.ts +291 -0
- package/src/provider/ArchProvider.ts +101 -0
- package/src/tools/_probe.ts +164 -0
- package/src/tools/apiSurface.ts +241 -0
- package/src/tools/appInfo.ts +88 -0
- package/src/tools/baselines.ts +210 -0
- package/src/tools/context.ts +37 -0
- package/src/tools/doctor.ts +93 -0
- package/src/tools/index.ts +50 -0
- package/src/tools/logs.ts +265 -0
- package/src/tools/routes.ts +133 -0
- package/src/tools/schema.ts +119 -0
- package/src/tools/searchDocs.ts +345 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: FlowBrowser
|
|
3
|
+
description: Drive a real page against a real server inside bun test, and assert on the WebSocket bridge itself.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# FlowBrowser
|
|
7
|
+
|
|
8
|
+
`FlowBrowser` runs a headless browser against your app inside `bun test`, so a test
|
|
9
|
+
can assert on the thing `FlowTest` cannot reach: the WebSocket bridge.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { FlowBrowser } from "@zerotal/testing/browser";
|
|
13
|
+
|
|
14
|
+
const browser = await FlowBrowser.serve(() => import("../bootstrap/app.ts").then((m) => m.default));
|
|
15
|
+
const page = await browser.visit("/settings");
|
|
16
|
+
|
|
17
|
+
await page.waitForConnection();
|
|
18
|
+
await page.click('[flow\\:click="save"]');
|
|
19
|
+
await page.waitForPatch();
|
|
20
|
+
|
|
21
|
+
expect(await page.text("#status")).toBe("Saved");
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Why this exists
|
|
25
|
+
|
|
26
|
+
[`FlowTest`](/docs/flow/testing) mounts a component and drives its server-side
|
|
27
|
+
lifecycle. It never opens a socket, so it renders the full markup every time and
|
|
28
|
+
every assertion passes — which is exactly the problem. The failures Flow ships have
|
|
29
|
+
one shape: **the HTML is fine and the transport is dead.** SSR renders, snapshot
|
|
30
|
+
assertions pass, the suite is green, and the app does nothing in a browser.
|
|
31
|
+
|
|
32
|
+
Those failures are invisible to every server-side test by construction. `FlowBrowser`
|
|
33
|
+
is the harness that can see them.
|
|
34
|
+
|
|
35
|
+
Reach for `FlowTest` first: it is faster, needs no browser, and covers component
|
|
36
|
+
logic. Reach for `FlowBrowser` when the thing under test only happens after
|
|
37
|
+
hydration.
|
|
38
|
+
|
|
39
|
+
## Requirements
|
|
40
|
+
|
|
41
|
+
A Chrome or Chromium install. Nothing else — no extra dependency is added to
|
|
42
|
+
`@zerotal/testing`, because [`Bun.WebView`](https://bun.sh/docs) drives the browser
|
|
43
|
+
and reads the transport through the DevTools Protocol.
|
|
44
|
+
|
|
45
|
+
The harness reaches a browser two ways:
|
|
46
|
+
|
|
47
|
+
| Mode | When |
|
|
48
|
+
| ----------- | -------------------------------------------- |
|
|
49
|
+
| **spawn** | Default. Launches its own headless instance. |
|
|
50
|
+
| **connect** | When `ZT_BROWSER_CDP_URL` is set. |
|
|
51
|
+
|
|
52
|
+
> **Windows** — Bun cannot currently spawn Chrome on Windows: it reports
|
|
53
|
+
> `Failed to spawn Chrome` even when the binary is present, because the spawn path
|
|
54
|
+
> uses `--remote-debugging-pipe`. Use connect-mode there. Start a browser once:
|
|
55
|
+
>
|
|
56
|
+
> ```bash
|
|
57
|
+
> chrome --headless=new --remote-debugging-port=9222 --user-data-dir=/tmp/zt-chrome
|
|
58
|
+
> ```
|
|
59
|
+
>
|
|
60
|
+
> then read `webSocketDebuggerUrl` from `http://127.0.0.1:9222/json/version` and set
|
|
61
|
+
> `ZT_BROWSER_CDP_URL` to it.
|
|
62
|
+
|
|
63
|
+
## Skipping, and why CI must not
|
|
64
|
+
|
|
65
|
+
`FlowBrowser.availability()` reports whether a browser can be reached, and it is
|
|
66
|
+
resolved by actually opening one — a binary that exists but cannot start is the
|
|
67
|
+
failure worth catching, and `which chrome` cannot see it.
|
|
68
|
+
|
|
69
|
+
The two environments differ deliberately. **A browser suite that skips itself in CI
|
|
70
|
+
is worse than no suite**, because it reports green for exactly the failures it exists
|
|
71
|
+
to catch. So pair the skip with a guard that runs regardless:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import { FlowBrowser, browserAvailability, browserRequired } from "@zerotal/testing/browser";
|
|
75
|
+
|
|
76
|
+
const availability = await browserAvailability();
|
|
77
|
+
|
|
78
|
+
test("a browser is reachable, so this suite is not silently skipped", () => {
|
|
79
|
+
if (!browserRequired()) return; // a developer's machine may legitimately lack one
|
|
80
|
+
expect(availability.available).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe.skipIf(!availability.available)("checkout", () => {
|
|
84
|
+
// …
|
|
85
|
+
});
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`browserRequired()` is true when `CI` is set. `browserAvailability()` resolves a
|
|
89
|
+
`BrowserAvailability` — `{ available, mode, reason }`, where `mode` is `"spawn"`,
|
|
90
|
+
`"connect"` or `"none"` and `reason` is the sentence to print when skipping.
|
|
91
|
+
|
|
92
|
+
`CDP_URL_ENV` is exported as the name of the connect-mode variable, so a test helper
|
|
93
|
+
that sets it does not have to hard-code the string.
|
|
94
|
+
|
|
95
|
+
## Serving the app
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
const browser = await FlowBrowser.serve(bootstrap, options?);
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`bootstrap` is the same callback [`createTestApp`](/docs/testing/http) takes, so the
|
|
102
|
+
app under test is configured the way the rest of your suite configures it rather than
|
|
103
|
+
through a second bootstrap that can drift. The server binds an OS-assigned port.
|
|
104
|
+
|
|
105
|
+
`options` is a `FlowBrowserOptions`:
|
|
106
|
+
|
|
107
|
+
| Option | Meaning |
|
|
108
|
+
| --------- | ------------------------------------------------------------- |
|
|
109
|
+
| `timeout` | Milliseconds every `waitFor*` allows. Default `5000`. |
|
|
110
|
+
| `setup` | Register routes before the server starts — for fixture pages. |
|
|
111
|
+
|
|
112
|
+
Call `await browser.stop()` in `afterAll`: it closes every page it opened, then stops
|
|
113
|
+
the server.
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
let browser: FlowBrowser;
|
|
117
|
+
beforeAll(async () => {
|
|
118
|
+
browser = await FlowBrowser.serve(bootstrap);
|
|
119
|
+
});
|
|
120
|
+
afterAll(async () => {
|
|
121
|
+
await browser.stop();
|
|
122
|
+
});
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Driving a page
|
|
126
|
+
|
|
127
|
+
`browser.visit(path)` returns a `BrowserPage`. Open one per test.
|
|
128
|
+
|
|
129
|
+
**Reading**
|
|
130
|
+
|
|
131
|
+
| Method | Returns |
|
|
132
|
+
| --------------------------- | ---------------------------------------- |
|
|
133
|
+
| `text(selector)` | Trimmed `textContent`, or `null` |
|
|
134
|
+
| `html(selector)` | `innerHTML`, or `null` |
|
|
135
|
+
| `count(selector)` | How many elements match |
|
|
136
|
+
| `attribute(selector, name)` | One attribute, or `null` |
|
|
137
|
+
| `connection()` | `"online"`, `"offline"`, or `null` |
|
|
138
|
+
| `url()` | Path and query, for asserting a redirect |
|
|
139
|
+
| `evaluate<T>(expression)` | Anything, as a real JS value |
|
|
140
|
+
|
|
141
|
+
`evaluate` takes an **expression**, not statements — wrap a block in an IIFE. Only one
|
|
142
|
+
evaluation runs at a time per page, so never wrap these in `Promise.all`.
|
|
143
|
+
|
|
144
|
+
**Acting**
|
|
145
|
+
|
|
146
|
+
`click(selector)`, `type(selector, text)` and `press(key)` fire real trusted events.
|
|
147
|
+
`type` clicks the field first, because the browser types into whatever holds focus.
|
|
148
|
+
|
|
149
|
+
**Waiting**
|
|
150
|
+
|
|
151
|
+
Never sleep. Every wait is on an observable signal, and every one throws on timeout
|
|
152
|
+
with the connection state in the message.
|
|
153
|
+
|
|
154
|
+
| Method | Waits for |
|
|
155
|
+
| --------------------------- | ------------------------------------------- |
|
|
156
|
+
| `waitForPatch()` | A WebSocket frame caused by the last action |
|
|
157
|
+
| `waitForConnection()` | The bridge to report `online` |
|
|
158
|
+
| `waitForCount(selector, n)` | At least `n` matches |
|
|
159
|
+
| `waitFor(expression)` | Any in-page expression to be truthy |
|
|
160
|
+
|
|
161
|
+
`waitForPatch()` is the primitive. The received-frame count is captured when the
|
|
162
|
+
action is dispatched and this waits for it to rise, so an assertion cannot race the
|
|
163
|
+
transport. A harness that raced it would produce flaky tests, and a flaky browser
|
|
164
|
+
suite gets deleted.
|
|
165
|
+
|
|
166
|
+
## Asserting on the transport
|
|
167
|
+
|
|
168
|
+
`page.transport()` reports what Chrome saw on the wire — which the page cannot lie
|
|
169
|
+
about. A client that degraded silently still shows zero upgraded sockets here.
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
expect(page.socketUpgraded()).toBe(true);
|
|
173
|
+
expect(page.transport().statuses).toContain(101);
|
|
174
|
+
expect(await page.connection()).toBe("online");
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
It returns a `TransportReport`:
|
|
178
|
+
|
|
179
|
+
| Field | Meaning |
|
|
180
|
+
| ---------- | -------------------------------------------- |
|
|
181
|
+
| `created` | Sockets the page opened |
|
|
182
|
+
| `upgraded` | Handshakes that answered `101` |
|
|
183
|
+
| `statuses` | Every handshake status that arrived |
|
|
184
|
+
| `errored` | Chrome reported a frame-level failure |
|
|
185
|
+
| `frames` | An `ObservedFrame[]` — direction and payload |
|
|
186
|
+
|
|
187
|
+
> **Assert that a `101` was seen — never that the status was not something else.**
|
|
188
|
+
> A refused upgrade does not arrive as a handshake response at all: Chrome reports a
|
|
189
|
+
> frame error instead. A test asserting `statuses` does not contain `403` passes
|
|
190
|
+
> vacuously, because that event never fires.
|
|
191
|
+
|
|
192
|
+
`frames` carries the payloads, which is how you assert on what actually crossed the
|
|
193
|
+
wire rather than on what the DOM ended up showing:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
const sent = page.transport().frames.filter((f) => f.direction === "sent");
|
|
197
|
+
expect(sent.some((f) => f.payload.includes(`"method":"save"`))).toBe(true);
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## What this cannot cover
|
|
201
|
+
|
|
202
|
+
**The origin guard.** The harness talks to `http://127.0.0.1:<port>`, which is the
|
|
203
|
+
app's own origin, so `allowedOrigins` is satisfied trivially and no test here can
|
|
204
|
+
catch a misconfigured one. That failure only appears behind a reverse proxy, where
|
|
205
|
+
the browser sends the public origin and the app compares it against the loopback
|
|
206
|
+
address it bound to.
|
|
207
|
+
|
|
208
|
+
That case has its own tool — `bun zt doctor --url=https://your-site` sends a real
|
|
209
|
+
handshake through the real proxy. See [Deployment](/docs/deployment). Do not read a
|
|
210
|
+
green browser suite as coverage of it.
|
|
211
|
+
|
|
212
|
+
## Next steps
|
|
213
|
+
|
|
214
|
+
- [Flow testing](/docs/flow/testing) — `FlowTest`, for everything that does not need a browser.
|
|
215
|
+
- [HTTP Tests](/docs/testing/http) — `createTestApp`, the bootstrap this shares.
|
|
216
|
+
- [Browser Tests](/docs/testing/browser) — Playwright, for cross-browser end-to-end work beyond Flow.
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: HTTP Tests
|
|
3
|
+
description: Boot your real application and exercise it over real HTTP requests with chainable response assertions.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# HTTP Tests
|
|
7
|
+
|
|
8
|
+
HTTP tests boot your real application on a random port and exercise it over real
|
|
9
|
+
requests. `createTestApp()` returns a `TestApp` client; every request returns a
|
|
10
|
+
chainable `TestResponse` for assertions.
|
|
11
|
+
|
|
12
|
+
## Getting Started
|
|
13
|
+
|
|
14
|
+
The testing helpers ship in `@zerotal/testing`. There is no provider to register
|
|
15
|
+
and no config file — you import the helpers directly inside your test files.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# in your project root
|
|
19
|
+
bun add -d @zerotal/testing
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
// src/tests/PostTest.ts
|
|
24
|
+
import { createTestApp, type TestApp } from "@zerotal/testing";
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Booting the app
|
|
28
|
+
|
|
29
|
+
Boot the app once per suite and close it when the suite finishes:
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
// src/tests/PostTest.ts
|
|
33
|
+
import { describe, it, beforeAll, afterAll } from "bun:test";
|
|
34
|
+
import { createTestApp, type TestApp } from "@zerotal/testing";
|
|
35
|
+
import { app } from "../bootstrap/app.ts";
|
|
36
|
+
|
|
37
|
+
let testApp: TestApp;
|
|
38
|
+
|
|
39
|
+
beforeAll(async () => {
|
|
40
|
+
testApp = await createTestApp(() => app);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterAll(() => testApp.close());
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`createTestApp()` boots the app and starts it on an OS-assigned port (port `0`):
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
function createTestApp(
|
|
50
|
+
bootstrap: () => Application | Promise<Application>,
|
|
51
|
+
setup?: () => void,
|
|
52
|
+
): Promise<TestApp>;
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The optional `setup` callback runs after `resetTestState()` but before `app.start()` —
|
|
56
|
+
use it to register routes or override bindings specific to the suite.
|
|
57
|
+
|
|
58
|
+
> **Note** — Routes registered in `setup` are compiled into the server before it
|
|
59
|
+
> starts, so they resolve correctly during the suite.
|
|
60
|
+
|
|
61
|
+
### One app per process
|
|
62
|
+
|
|
63
|
+
Bun runs a whole suite in a single process, and the database connection is
|
|
64
|
+
process-global. So when several test files each call `createTestApp()` with the same
|
|
65
|
+
bootstrap module, they share one booted app: the first call boots it, the rest get the
|
|
66
|
+
same instance back.
|
|
67
|
+
|
|
68
|
+
That means `close()` in a file's `afterAll` resets per-test state (auth, flash, captured
|
|
69
|
+
mail) but leaves the app running for the files still to come. Without this, the first
|
|
70
|
+
file's teardown closed the connection every later file depended on, and the file that
|
|
71
|
+
failed was a correct one that merely ran second:
|
|
72
|
+
|
|
73
|
+
```text
|
|
74
|
+
[Zerotal ORM] No database connection. Is DatabaseProvider registered?
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Keep calling `close()` in every file — it is what resets state between them. Passing a
|
|
78
|
+
`setup` callback opts out of sharing (routes cannot be registered twice against a
|
|
79
|
+
running server), and that app is torn down fully by its own `close()`. To tear the
|
|
80
|
+
shared app down explicitly — a global teardown, or a test asserting no timers leak —
|
|
81
|
+
call `closeSharedTestApps()`.
|
|
82
|
+
|
|
83
|
+
## Sending requests
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
// in a test
|
|
87
|
+
await testApp.get("/path");
|
|
88
|
+
await testApp.post("/path", body); // body serialized as JSON
|
|
89
|
+
await testApp.put("/path", body);
|
|
90
|
+
await testApp.patch("/path", body);
|
|
91
|
+
await testApp.delete("/path");
|
|
92
|
+
await testApp.head("/path");
|
|
93
|
+
await testApp.options("/path");
|
|
94
|
+
await testApp.request("/path", fetchInit); // raw Fetch RequestInit
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Each method returns a `TestResponse`. The `post`, `put`, and `patch` helpers set
|
|
98
|
+
`Content-Type: application/json` and JSON-encode the body for you.
|
|
99
|
+
|
|
100
|
+
### Form submits
|
|
101
|
+
|
|
102
|
+
A route meant for a browser should be tested the way a browser reaches it. A JSON
|
|
103
|
+
`post()` does not travel the same path: form submits are what trigger the
|
|
104
|
+
redirect-back-with-errors branch of validation, the CSRF check, and any middleware
|
|
105
|
+
that reads `application/x-www-form-urlencoded`.
|
|
106
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
// in a test
|
|
109
|
+
const res = await testApp.postForm("/posts", { title: "Hello", published: true });
|
|
110
|
+
|
|
111
|
+
res.assertRedirect("/posts");
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`putForm` and `patchForm` send the same body with a different method. Values are
|
|
115
|
+
stringified; `null` and `undefined` fields are omitted rather than sent as the
|
|
116
|
+
strings `"null"` and `"undefined"`.
|
|
117
|
+
|
|
118
|
+
### File uploads
|
|
119
|
+
|
|
120
|
+
`multipart()` is the only way to exercise a route that reads an uploaded file.
|
|
121
|
+
Build the attachments with `fakeFile`:
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
// in a test
|
|
125
|
+
import { fakeFile } from "@zerotal/testing";
|
|
126
|
+
|
|
127
|
+
const res = await testApp.multipart("/avatar", {
|
|
128
|
+
name: "Alice",
|
|
129
|
+
avatar: fakeFile.image("avatar.png", { width: 64, height: 64 }),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
res.assertCreated();
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The files `fakeFile` builds are real: a PNG has the PNG signature, a valid header
|
|
136
|
+
at the size you asked for, and compressed pixel data. That matters because the
|
|
137
|
+
framework never trusts what an upload claims to be — `UploadedFile.store()` and
|
|
138
|
+
`detectType()` sniff the leading bytes and name the stored file from what they
|
|
139
|
+
find. A placeholder full of zero bytes declared as `image/png` would pass a
|
|
140
|
+
`mimes` check and then be stored as `application/octet-stream`, so the test would
|
|
141
|
+
pass while the behavior it describes never happened.
|
|
142
|
+
|
|
143
|
+
| Builder | Produces |
|
|
144
|
+
| ------------------------------ | ---------------------------------------------------- |
|
|
145
|
+
| `fakeFile.image(name, opts?)` | A valid PNG at the given `width`/`height`. |
|
|
146
|
+
| `fakeFile.jpeg(name?)` | A JPEG with a real JFIF header. |
|
|
147
|
+
| `fakeFile.gif(name?)` | A GIF89a image. |
|
|
148
|
+
| `fakeFile.pdf(name?)` | A one-page PDF. |
|
|
149
|
+
| `fakeFile.create(name, c, t?)` | Exactly the contents you give it. |
|
|
150
|
+
| `fakeFile.sized(name, bytes)` | Filler of an exact size, for exercising size limits. |
|
|
151
|
+
|
|
152
|
+
For a unit test that hands an `UploadedFile` straight to the code under test
|
|
153
|
+
without a request, build one with `UploadedFile.fake('a.png', { type, size })`.
|
|
154
|
+
|
|
155
|
+
### Authentication
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
// in a test
|
|
159
|
+
|
|
160
|
+
// Forge a signed session cookie so the request is treated as authenticated
|
|
161
|
+
await testApp.actingAs(user).get("/dashboard");
|
|
162
|
+
|
|
163
|
+
// Clear auth between tests
|
|
164
|
+
testApp.actingAsGuest();
|
|
165
|
+
|
|
166
|
+
// Pre-seed arbitrary session data
|
|
167
|
+
await testApp.withSession({ locale: "fr", flash: "saved" }).get("/profile");
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`actingAs(user)` forges a signed session cookie containing `user_id`, reading
|
|
171
|
+
`session.secret` and `session.cookie` from your config. `withSession()` preserves
|
|
172
|
+
any `user_id` already set by `actingAs()`.
|
|
173
|
+
|
|
174
|
+
### Headers and redirects
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
// in a test
|
|
178
|
+
testApp.withHeaders({ "X-App-Version": "2" });
|
|
179
|
+
testApp.withCookie("theme", "dark");
|
|
180
|
+
testApp.asJson(); // Accept: application/json
|
|
181
|
+
|
|
182
|
+
// Follow Location redirects automatically (up to 10 hops)
|
|
183
|
+
const res = await testApp.followingRedirects().post("/login", { email, password });
|
|
184
|
+
res.assertOk(); // landed on the dashboard
|
|
185
|
+
|
|
186
|
+
testApp.withoutFollowingRedirects(); // restore default
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
These chain off `testApp` and apply to the request that follows.
|
|
190
|
+
|
|
191
|
+
`asJson()` is worth reaching for whenever a test asserts on a JSON body. The
|
|
192
|
+
framework negotiates error responses off the `Accept` header, so without it a
|
|
193
|
+
failed request comes back as the rendered HTML error page and the JSON assertions
|
|
194
|
+
have nothing to parse.
|
|
195
|
+
|
|
196
|
+
> **Warning** — Redirect following is **off** by default, so a `POST` that
|
|
197
|
+
> succeeds returns the `3xx` response, not the final page. Call
|
|
198
|
+
> `followingRedirects()` when you want to assert against the destination.
|
|
199
|
+
|
|
200
|
+
## When a test fails on a 500
|
|
201
|
+
|
|
202
|
+
An exception inside a route is converted to a response before the test sees it,
|
|
203
|
+
so the assertion reports `500` and the body is an error page. `withoutExceptionHandling()`
|
|
204
|
+
hands you the original instead:
|
|
205
|
+
|
|
206
|
+
```typescript
|
|
207
|
+
// in a test
|
|
208
|
+
const res = await testApp.withoutExceptionHandling().get("/checkout");
|
|
209
|
+
|
|
210
|
+
expect(res.exception()).toBeInstanceOf(PaymentDeclinedError);
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Errors are captured either way — `res.exception()` works without opting in, and a
|
|
214
|
+
failing assertion quotes the stack that caused the failure rather than making you
|
|
215
|
+
re-run the route by hand. What `withoutExceptionHandling()` adds is silence: it
|
|
216
|
+
skips the handler's reporting, so an expected failure stops writing a stack trace
|
|
217
|
+
into the test output. Call `withExceptionHandling()` to restore the default.
|
|
218
|
+
|
|
219
|
+
## TestResponse assertions
|
|
220
|
+
|
|
221
|
+
> **Note** — Every assertion is synchronous and chainable, including the body
|
|
222
|
+
> ones (`assertSee`, `assertJson`, `assertJsonPath`). The response body is read
|
|
223
|
+
> once when the `TestResponse` is built, so nothing here needs `await` — and a
|
|
224
|
+
> forgotten one can no longer turn a failure into an unhandled rejection.
|
|
225
|
+
|
|
226
|
+
Every request returns a `TestResponse`. Assertions are chainable and throw
|
|
227
|
+
descriptive errors on failure.
|
|
228
|
+
|
|
229
|
+
```typescript
|
|
230
|
+
// in a test
|
|
231
|
+
|
|
232
|
+
// Status
|
|
233
|
+
res.assertStatus(200);
|
|
234
|
+
res.assertOk(); // 200
|
|
235
|
+
res.assertCreated(); // 201
|
|
236
|
+
res.assertNoContent(); // 204
|
|
237
|
+
res.assertUnauthorized(); // 401
|
|
238
|
+
res.assertForbidden(); // 403
|
|
239
|
+
res.assertNotFound(); // 404
|
|
240
|
+
res.assertUnprocessable(); // 422
|
|
241
|
+
res.assertMovedPermanently(); // 301
|
|
242
|
+
|
|
243
|
+
// Redirects
|
|
244
|
+
res.assertRedirect("/dashboard");
|
|
245
|
+
|
|
246
|
+
// Headers
|
|
247
|
+
res.assertHeader("Content-Type");
|
|
248
|
+
res.assertHeader("Content-Type", "application/json");
|
|
249
|
+
res.assertHeaderMissing("X-Debug");
|
|
250
|
+
|
|
251
|
+
// JSON body
|
|
252
|
+
res.assertJson({ title: "Hello" });
|
|
253
|
+
res.assertJsonPath("user.name", "Alice");
|
|
254
|
+
res.assertJsonPath("data.0.id", 1);
|
|
255
|
+
res.assertJsonCount(3); // top-level array
|
|
256
|
+
res.assertJsonCount(3, "data"); // array at body.data
|
|
257
|
+
|
|
258
|
+
// HTML body
|
|
259
|
+
res.assertSee("Welcome, Alice");
|
|
260
|
+
res.assertDontSee("Error");
|
|
261
|
+
res.assertBodyContains("<h1>");
|
|
262
|
+
res.assertSeeText("Welcome, Alice"); // ignores the markup between the words
|
|
263
|
+
res.assertDontSeeText("Error");
|
|
264
|
+
|
|
265
|
+
// Validation
|
|
266
|
+
res.assertInvalid(); // failed on something
|
|
267
|
+
res.assertInvalid("email"); // failed on email
|
|
268
|
+
res.assertInvalid(["email", "password"]);
|
|
269
|
+
res.assertInvalid({ email: "required" }); // and the message contains "required"
|
|
270
|
+
res.assertValid(); // nothing failed
|
|
271
|
+
res.assertValid("email");
|
|
272
|
+
|
|
273
|
+
// Auth
|
|
274
|
+
res.assertAuthenticated();
|
|
275
|
+
res.assertAuthenticatedAs(user); // or a bare id
|
|
276
|
+
res.assertGuest();
|
|
277
|
+
|
|
278
|
+
// Cookies
|
|
279
|
+
res.assertCookie("zerotal_session");
|
|
280
|
+
res.assertCookie("theme", "dark");
|
|
281
|
+
res.assertCookieMissing("remember_me");
|
|
282
|
+
|
|
283
|
+
// Session
|
|
284
|
+
res.assertSessionHas("user_id");
|
|
285
|
+
res.assertSessionHas("status", "saved");
|
|
286
|
+
res.assertSessionMissing("errors");
|
|
287
|
+
res.assertSessionHasErrors(["email"]);
|
|
288
|
+
res.assertSessionHasNoErrors();
|
|
289
|
+
|
|
290
|
+
// Inertia
|
|
291
|
+
res.assertInertia("Posts/Index");
|
|
292
|
+
res.assertInertia("Posts/Show", { post: { id: 1 } }); // props match partially
|
|
293
|
+
res.assertInertiaProp("filters");
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
### Validation, whichever shape it arrives in
|
|
297
|
+
|
|
298
|
+
A failed validation reaches the client two different ways: an API client gets
|
|
299
|
+
`422` with an `errors` object, and a form submit gets a redirect with the errors
|
|
300
|
+
flashed to the session. `assertInvalid` reads both, so the assertion is the same
|
|
301
|
+
either way:
|
|
302
|
+
|
|
303
|
+
```typescript
|
|
304
|
+
// in a test
|
|
305
|
+
|
|
306
|
+
// API client
|
|
307
|
+
const api = await testApp.asJson().post("/posts", {});
|
|
308
|
+
api.assertUnprocessable().assertInvalid(["title", "body"]);
|
|
309
|
+
|
|
310
|
+
// Form submit
|
|
311
|
+
const form = await testApp.postForm("/posts", {});
|
|
312
|
+
form.assertRedirect("/posts/create").assertInvalid(["title", "body"]);
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
`res.validationErrors()` returns the same errors as a `{ field: string[] }` record
|
|
316
|
+
when you want to assert on them directly.
|
|
317
|
+
|
|
318
|
+
### Sessions
|
|
319
|
+
|
|
320
|
+
Session data is decoded through the application's own `session.driver`, so
|
|
321
|
+
whatever format the driver writes is the format the assertions read. That is why
|
|
322
|
+
these need a response produced by `createTestApp()` with a session driver bound —
|
|
323
|
+
a `TestResponse` built by hand has nothing to decode with, and says so rather than
|
|
324
|
+
reporting the key as absent.
|
|
325
|
+
|
|
326
|
+
`res.session()` returns the decoded record when you want to inspect it directly.
|
|
327
|
+
|
|
328
|
+
> **Note** — `assertSessionMissing` throws when the session cannot be decoded
|
|
329
|
+
> rather than passing. "I could not read the session" is not evidence that the key
|
|
330
|
+
> is absent, and an assertion that treats it as such can never fail.
|
|
331
|
+
|
|
332
|
+
Reading the body directly:
|
|
333
|
+
|
|
334
|
+
```typescript
|
|
335
|
+
// in a test
|
|
336
|
+
const data = res.json<{ id: number }>();
|
|
337
|
+
const html = res.text();
|
|
338
|
+
const code = res.status; // number
|
|
339
|
+
const ok = res.ok; // boolean
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
## Unit-testing a controller
|
|
343
|
+
|
|
344
|
+
Skip HTTP entirely for fast, focused controller tests by faking the context:
|
|
345
|
+
|
|
346
|
+
```typescript
|
|
347
|
+
// src/tests/PostControllerTest.ts
|
|
348
|
+
import { HttpContext } from "zerotal";
|
|
349
|
+
|
|
350
|
+
const ctx = HttpContext.fake("http://localhost/posts/42", { method: "GET" });
|
|
351
|
+
ctx.params = { id: "42" };
|
|
352
|
+
|
|
353
|
+
await new PostController().show({ http: ctx });
|
|
354
|
+
|
|
355
|
+
expect(ctx.response?.status).toBe(200);
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
> **Tip** — Faking the context skips booting the server, so these controller tests
|
|
359
|
+
> run far faster than a full HTTP round-trip.
|
|
360
|
+
|
|
361
|
+
## Which should I use?
|
|
362
|
+
|
|
363
|
+
- **`createTestApp()` (full HTTP)** — when you need middleware, routing, sessions,
|
|
364
|
+
or auth to run end to end. This is the realistic path and what most feature tests
|
|
365
|
+
want.
|
|
366
|
+
- **`HttpContext.fake()` (unit)** — when you want to exercise one controller method
|
|
367
|
+
in isolation without paying the cost of booting a server.
|
|
368
|
+
|
|
369
|
+
## References
|
|
370
|
+
|
|
371
|
+
### TestApp
|
|
372
|
+
|
|
373
|
+
| Member | Signature | Description |
|
|
374
|
+
| ------------------------------------ | -------------------------------------------------------- | ------------------------------------------------------------------ |
|
|
375
|
+
| `actingAs` | `actingAs(user: { id: number \| string }): this` | Forge a session cookie for `user.id`. Chainable. |
|
|
376
|
+
| `actingAsGuest` | `actingAsGuest(): this` | Clear the auth cookie. Chainable. |
|
|
377
|
+
| `withSession` | `withSession(data: Record<string, unknown>): this` | Pre-seed session data, merged with any `actingAs` data. Chainable. |
|
|
378
|
+
| `withHeaders` | `withHeaders(headers: Record<string, string>): this` | Merge headers into every request. Chainable. |
|
|
379
|
+
| `asJson` | `asJson(): this` | Send `Accept: application/json`. Chainable. |
|
|
380
|
+
| `withCookie` / `withCookies` | `(name, value): this` / `(record): this` | Attach cookies to every request. Chainable. |
|
|
381
|
+
| `withoutCookies` | `withoutCookies(): this` | Drop the attached cookies. Chainable. |
|
|
382
|
+
| `followingRedirects` | `followingRedirects(): this` | Follow up to 10 `Location` redirects. Chainable. |
|
|
383
|
+
| `withoutFollowingRedirects` | `withoutFollowingRedirects(): this` | Restore the default (no following). Chainable. |
|
|
384
|
+
| `withoutExceptionHandling` | `withoutExceptionHandling(): this` | Capture the raw exception and return a bare `500`. Chainable. |
|
|
385
|
+
| `withExceptionHandling` | `withExceptionHandling(): this` | Restore normal error rendering. Chainable. |
|
|
386
|
+
| `get` / `head` / `options` | `(url, headers?): Promise<TestResponse>` | Send a request with no body. |
|
|
387
|
+
| `post` / `put` / `patch` | `(url, body, headers?): Promise<TestResponse>` | Send a request with a JSON body. |
|
|
388
|
+
| `postForm` / `putForm` / `patchForm` | `(url, body?, headers?): Promise<TestResponse>` | Send a URL-encoded form body. |
|
|
389
|
+
| `multipart` | `(url, body?, headers?, method?): Promise<TestResponse>` | Send `multipart/form-data`, with files. |
|
|
390
|
+
| `delete` | `delete(url, headers?): Promise<TestResponse>` | Send a `DELETE` request. |
|
|
391
|
+
| `request` | `request(url, init?): Promise<TestResponse>` | Raw Fetch-style request. |
|
|
392
|
+
| `close` | `close(): Promise<void>` | Stop the server and reset framework state. |
|
|
393
|
+
| `port` | `get port(): number` | The OS-assigned port. |
|
|
394
|
+
| `baseUrl` | `get baseUrl(): string` | `http://localhost:{port}`. |
|
|
395
|
+
| `app` | `get app(): Application` | The underlying application, for resolving bindings. |
|
|
396
|
+
|
|
397
|
+
### TestResponse
|
|
398
|
+
|
|
399
|
+
| Member | Signature | Description |
|
|
400
|
+
| ----------------------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------- |
|
|
401
|
+
| `assertStatus` | `assertStatus(expected: number): this` | Assert the status code. |
|
|
402
|
+
| `assertOk` / `assertCreated` / `assertNoContent` | `(): this` | Assert `200` / `201` / `204`. |
|
|
403
|
+
| `assertSuccessful` | `(): this` | Assert any `2xx`. |
|
|
404
|
+
| `assertMovedPermanently` | `(): this` | Assert `301`. |
|
|
405
|
+
| `assertUnauthorized` / `assertForbidden` / `assertNotFound` / `assertUnprocessable` | `(): this` | Assert `401` / `403` / `404` / `422`. |
|
|
406
|
+
| `assertServerError` | `(): this` | Assert `500`. |
|
|
407
|
+
| `assertRedirect` | `assertRedirect(url: string): this` | Assert a `3xx` whose `Location` contains `url`. |
|
|
408
|
+
| `assertHeader` | `assertHeader(name, value?): this` | Assert a header is present (and contains `value`). |
|
|
409
|
+
| `assertHeaderMissing` | `assertHeaderMissing(name): this` | Assert a header is absent. |
|
|
410
|
+
| `assertJson` | `assertJson(expected): this` | Assert each key in `expected` matches the JSON body. |
|
|
411
|
+
| `assertJsonPath` | `assertJsonPath(path, expected): this` | Assert a dot-notation path in the JSON body. |
|
|
412
|
+
| `assertJsonCount` | `assertJsonCount(count, key?): this` | Assert an array length at the body or `key`. |
|
|
413
|
+
| `assertSee` / `assertBodyContains` | `(needle): this` | Assert the body contains `needle`. |
|
|
414
|
+
| `assertDontSee` | `assertDontSee(needle): this` | Assert the body does not contain `needle`. |
|
|
415
|
+
| `assertSeeText` / `assertDontSeeText` | `(needle): this` | The same, against the body with its tags stripped. |
|
|
416
|
+
| `assertInvalid` | `assertInvalid(fields?): this` | Assert validation failed, optionally on `fields`. |
|
|
417
|
+
| `assertValid` | `assertValid(fields?): this` | Assert validation did not fail. |
|
|
418
|
+
| `validationErrors` | `(): Record<string, string[]> \| null` | The errors, from the body or the session. |
|
|
419
|
+
| `assertAuthenticated` | `(): this` | Assert the session holds a `user_id`. |
|
|
420
|
+
| `assertAuthenticatedAs` | `assertAuthenticatedAs(user \| id): this` | Assert that specific user is signed in. |
|
|
421
|
+
| `assertGuest` | `(): this` | Assert nobody is signed in. |
|
|
422
|
+
| `assertCookie` | `assertCookie(name, value?): this` | Assert a `Set-Cookie` (and optional value). |
|
|
423
|
+
| `assertCookieMissing` | `assertCookieMissing(name): this` | Assert no such cookie is set. |
|
|
424
|
+
| `assertSessionHas` | `assertSessionHas(key, value?): this` | Assert the session contains `key`. |
|
|
425
|
+
| `assertSessionMissing` | `assertSessionMissing(key): this` | Assert the session lacks `key`. |
|
|
426
|
+
| `assertSessionHasErrors` / `assertSessionHasNoErrors` | `(fields?): this` | Assert flashed validation errors. |
|
|
427
|
+
| `session` | `(): Record<string, unknown> \| null` | The decoded session. |
|
|
428
|
+
| `assertInertia` | `assertInertia(component?, props?): this` | Assert the Inertia page and a partial prop match. |
|
|
429
|
+
| `assertInertiaProp` | `assertInertiaProp(key, value?): this` | Assert a single Inertia prop. |
|
|
430
|
+
| `inertia` | `(): InertiaPage \| null` | The Inertia page object, from either wire shape. |
|
|
431
|
+
| `exception` | `(): unknown` | The exception the request raised, if any. |
|
|
432
|
+
| `json` | `json<T>(): T` | Parse and return the full JSON body. |
|
|
433
|
+
| `text` | `text(): string` | Return the body as text. |
|
|
434
|
+
| `status` / `ok` / `headers` | getters | The underlying `Response` status, `ok`, and headers. |
|
|
435
|
+
|
|
436
|
+
## Next steps
|
|
437
|
+
|
|
438
|
+
- [Database](/docs/testing/database) — isolating each test's data.
|
|
439
|
+
- [Mocking](/docs/testing/mocking) — asserting mail/queue/notification side effects.
|
|
440
|
+
- [Console Tests](/docs/testing/console) — run CLI commands in-process and assert output.
|
|
441
|
+
- [Factories](/docs/orm/factories) — building the records a request acts on.
|