@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,447 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Testing Flow Components
|
|
3
|
+
description: Drive a component in-process with FlowTest, and pin the wiring in a real browser with FlowBrowser.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Testing
|
|
7
|
+
|
|
8
|
+
`FlowTest` runs the full Flow server-side pipeline in-process — no running server, no WebSocket connection, no browser. It's the fastest way to write component tests and covers the complete request cycle: mount, hydrate, action, render, dehydrate.
|
|
9
|
+
|
|
10
|
+
Import it from `@zerotal/flow/testing`.
|
|
11
|
+
|
|
12
|
+
## Mounting a component
|
|
13
|
+
|
|
14
|
+
`FlowTest.mount()` drives the **initial GET** cycle: `onBoot → onMount → onRendering → render → onRendered → onDehydrate`. It returns a test handle you can use for assertions and further interactions:
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
import { FlowTest } from "@zerotal/flow/testing";
|
|
18
|
+
import { CounterPage } from "#app/flow/CounterPage.tsx";
|
|
19
|
+
|
|
20
|
+
const t = await FlowTest.mount(CounterPage);
|
|
21
|
+
|
|
22
|
+
// Inspect initial state
|
|
23
|
+
expect(t.page().count).toBe(0);
|
|
24
|
+
t.assertSee("Count: 0");
|
|
25
|
+
t.assertDontSee("Count: 1");
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Seeding initial state
|
|
29
|
+
|
|
30
|
+
Pass initial props to seed state before `onMount()` runs. Props are applied to the component instance before the lifecycle starts:
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
const t = await FlowTest.mount(PostsPage, { page: 2, search: "TypeScript" });
|
|
34
|
+
|
|
35
|
+
expect(t.page().search).toBe("TypeScript");
|
|
36
|
+
expect(t.page().page).toBe(2);
|
|
37
|
+
t.assertSee("TypeScript");
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
This is equivalent to the component being navigated to with `?page=2&search=TypeScript` in the URL.
|
|
41
|
+
|
|
42
|
+
## Calling actions
|
|
43
|
+
|
|
44
|
+
`t.call(method, ...args)` drives a **WebSocket action frame**: `onBoot → onHydrate → [onUpdating/onUpdated] → action → onUpdate → onRendering → render → onRendered → onDehydrate`.
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
await t.call("increment");
|
|
48
|
+
expect(t.page().count).toBe(1);
|
|
49
|
+
t.assertSee("Count: 1");
|
|
50
|
+
|
|
51
|
+
// Call with arguments
|
|
52
|
+
await t.call("setCount", 42);
|
|
53
|
+
expect(t.page().count).toBe(42);
|
|
54
|
+
|
|
55
|
+
// Call an async action that hits the database
|
|
56
|
+
await t.call("save");
|
|
57
|
+
t.assertRedirectedTo("/posts");
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The full lifecycle fires on every `call()` — hooks like `onHydrate`, `onUpdate`, `onRendering`, and `onDehydrate` run exactly as they would in production.
|
|
61
|
+
|
|
62
|
+
### When an action throws
|
|
63
|
+
|
|
64
|
+
A `ValidationError` is an expected outcome: the error bag is populated, the page re-renders, and you assert on it with `assertHasErrors`.
|
|
65
|
+
|
|
66
|
+
Any other error is passed to `onError()` — as in production — and then **rethrown**, so the test fails with the real stack. This matters because the alternative is invisible: an action that throws produces no server error, no browser error, and an unchanged page, so a swallowed exception looks exactly like an action that ran and did nothing.
|
|
67
|
+
|
|
68
|
+
When the error path is what you're testing, opt in with `tolerateErrors()`:
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
const t = (await FlowTest.mount(CheckoutPage)).tolerateErrors();
|
|
72
|
+
await t.call("submit");
|
|
73
|
+
|
|
74
|
+
t.assertErrored(/payment gateway/); // or t.lastError()
|
|
75
|
+
t.assertFlashed("error", "Payment failed");
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Updating state
|
|
79
|
+
|
|
80
|
+
Three methods change a property between calls:
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
// set() — direct assignment, then re-renders; no hooks fire
|
|
84
|
+
await t.set("draft", "Hello world");
|
|
85
|
+
await t.set("page", 3);
|
|
86
|
+
|
|
87
|
+
// update() — simulates a client input; fires onUpdating and onUpdated hooks
|
|
88
|
+
await t.update("username", "alice");
|
|
89
|
+
await t.update("email", "alice@example.com");
|
|
90
|
+
|
|
91
|
+
// seed() — assignment WITHOUT re-rendering, for batching
|
|
92
|
+
await t.seed("step", 3);
|
|
93
|
+
await t.seed("mode", "advanced");
|
|
94
|
+
await t.render(); // one render for both
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`set()` re-renders, so `html()` and every assertion that reads it describe the state you just set. Reach for `seed()` only when you're assigning several properties and want to pay for a single render — and remember that nothing reads the new values until the next `render()`, `update()`, or `call()`.
|
|
98
|
+
|
|
99
|
+
Use `set()` to put the component in a specific state for a test scenario. Use `update()` when you're testing that `onUpdating`/`onUpdated` hooks run correctly:
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
// Test that onUpdatedUsername normalises to lowercase
|
|
103
|
+
const t = await FlowTest.mount(ProfilePage);
|
|
104
|
+
await t.update("username", "ALICE");
|
|
105
|
+
|
|
106
|
+
expect(t.page().username).toBe("alice"); // hook lowercased it
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## HTML assertions
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
t.assertSee("Published post"); // rendered HTML contains this string
|
|
113
|
+
t.assertDontSee("Error"); // rendered HTML does NOT contain this string
|
|
114
|
+
t.assertSee("<h1>Dashboard</h1>"); // can match HTML tags too
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Both methods check `t.html()` — the raw rendered HTML string for this component. The check is a simple substring match, not a DOM query.
|
|
118
|
+
|
|
119
|
+
## Validation assertions
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
await t.call("register"); // trigger a validation action
|
|
123
|
+
|
|
124
|
+
t.assertHasErrors("email"); // field has at least one error
|
|
125
|
+
t.assertHasErrors("email", "required"); // error message contains "required"
|
|
126
|
+
t.assertHasErrors("password", "min"); // error message contains "min"
|
|
127
|
+
t.assertNoErrors(); // no validation errors at all
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`assertHasErrors(field, msg?)` checks the error bag returned from the last action. `msg` is a substring match on the first error message for that field.
|
|
131
|
+
|
|
132
|
+
## Redirect assertions
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
await t.call("login");
|
|
136
|
+
|
|
137
|
+
t.assertRedirectedTo("/dashboard"); // last action redirected to this URL
|
|
138
|
+
t.assertNotRedirected(); // last action did NOT redirect
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Flash assertions
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
await t.call("save");
|
|
145
|
+
|
|
146
|
+
t.assertFlashed("success", "Saved."); // level + message substring
|
|
147
|
+
t.assertFlashed("error"); // just check the level
|
|
148
|
+
t.assertFlashed(undefined, "Something went"); // just check the message substring
|
|
149
|
+
t.assertFlashed(); // any flash was emitted
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Event assertions
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
await t.call("createPost");
|
|
156
|
+
|
|
157
|
+
t.assertDispatched("post-created"); // event was dispatched
|
|
158
|
+
t.assertDispatched("post-created", { id: 1 }); // event was dispatched with this payload
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Accessors
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
t.page(); // the Component instance — inspect properties and call methods directly
|
|
165
|
+
t.html(); // the rendered HTML string from the last render
|
|
166
|
+
t.errors(); // current error bag: Record<string, string[]>
|
|
167
|
+
t.effects(); // effects from the last action: { flashes, redirects, events, downloads }
|
|
168
|
+
t.snapshot(); // the serialised snapshot blob
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`t.page()` gives you the live component instance, so you can read any property:
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
const page = t.page();
|
|
175
|
+
expect(page.posts.length).toBe(10);
|
|
176
|
+
expect(page.user?.email).toBe("alice@example.com");
|
|
177
|
+
expect(page.totalRevenue).toBe(450.0);
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Testing with a database
|
|
181
|
+
|
|
182
|
+
`FlowTest` does not set up or tear down a database — use your test suite's standard database helpers. With Bun, wrap tests in a transaction that rolls back after each test for full isolation:
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
// tests/flow/PostsPage.test.ts
|
|
186
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
187
|
+
import { FlowTest } from "@zerotal/flow/testing";
|
|
188
|
+
import { PostsPage } from "#app/flow/PostsPage.tsx";
|
|
189
|
+
import { DB } from "@zerotal/orm";
|
|
190
|
+
import { Post, User } from "#app/models/index.ts";
|
|
191
|
+
|
|
192
|
+
describe("PostsPage", () => {
|
|
193
|
+
let tx: Awaited<ReturnType<typeof DB.beginTransaction>>;
|
|
194
|
+
|
|
195
|
+
beforeEach(async () => {
|
|
196
|
+
tx = await DB.beginTransaction();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
afterEach(async () => {
|
|
200
|
+
await tx.rollback();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("shows published posts", async () => {
|
|
204
|
+
const user = await User.create({ name: "Alice", email: "alice@example.com" });
|
|
205
|
+
await Post.create({ title: "Hello world", status: "published", userId: user.id });
|
|
206
|
+
await Post.create({ title: "Draft post", status: "draft", userId: user.id });
|
|
207
|
+
|
|
208
|
+
const t = await FlowTest.mount(PostsPage);
|
|
209
|
+
|
|
210
|
+
t.assertSee("Hello world");
|
|
211
|
+
t.assertDontSee("Draft post");
|
|
212
|
+
expect(t.page().posts.length).toBe(1);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## Full test suite example
|
|
218
|
+
|
|
219
|
+
A complete example covering the common scenarios for a login page:
|
|
220
|
+
|
|
221
|
+
```typescript
|
|
222
|
+
// tests/flow/LoginPage.test.ts
|
|
223
|
+
import { describe, test, expect } from "bun:test";
|
|
224
|
+
import { FlowTest } from "@zerotal/flow/testing";
|
|
225
|
+
import { LoginPage } from "#app/flow/LoginPage.tsx";
|
|
226
|
+
import { User } from "#app/models/User.ts";
|
|
227
|
+
|
|
228
|
+
describe("LoginPage", () => {
|
|
229
|
+
test("redirects to dashboard on valid credentials", async () => {
|
|
230
|
+
await User.create({ email: "alice@example.com", password: "secret123" });
|
|
231
|
+
|
|
232
|
+
const t = await FlowTest.mount(LoginPage);
|
|
233
|
+
await t.set("email", "alice@example.com");
|
|
234
|
+
await t.set("password", "secret123");
|
|
235
|
+
await t.call("login");
|
|
236
|
+
|
|
237
|
+
t.assertRedirectedTo("/dashboard");
|
|
238
|
+
t.assertNoErrors();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("shows error on invalid credentials", async () => {
|
|
242
|
+
const t = await FlowTest.mount(LoginPage);
|
|
243
|
+
await t.set("email", "alice@example.com");
|
|
244
|
+
await t.set("password", "wrongpassword");
|
|
245
|
+
await t.call("login");
|
|
246
|
+
|
|
247
|
+
t.assertNotRedirected();
|
|
248
|
+
t.assertHasErrors("email", "credentials");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("validates required fields", async () => {
|
|
252
|
+
const t = await FlowTest.mount(LoginPage);
|
|
253
|
+
await t.call("login"); // no fields set
|
|
254
|
+
|
|
255
|
+
t.assertHasErrors("email", "required");
|
|
256
|
+
t.assertHasErrors("password", "required");
|
|
257
|
+
t.assertNotRedirected();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("normalises email to lowercase via onUpdated hook", async () => {
|
|
261
|
+
const t = await FlowTest.mount(LoginPage);
|
|
262
|
+
await t.update("email", "ALICE@EXAMPLE.COM");
|
|
263
|
+
|
|
264
|
+
expect(t.page().email).toBe("alice@example.com");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("flashes error after too many attempts", async () => {
|
|
268
|
+
const t = await FlowTest.mount(LoginPage);
|
|
269
|
+
await t.set("email", "alice@example.com");
|
|
270
|
+
await t.set("password", "wrong");
|
|
271
|
+
|
|
272
|
+
for (let i = 0; i < 5; i++) {
|
|
273
|
+
await t.call("login");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
t.assertFlashed("error", "Too many");
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
## Testing events
|
|
282
|
+
|
|
283
|
+
To test that a component dispatches events and that `@on` listeners respond, mount each component separately and verify the dispatch effect:
|
|
284
|
+
|
|
285
|
+
```typescript
|
|
286
|
+
test("dispatches post-created when saved", async () => {
|
|
287
|
+
const editor = await FlowTest.mount(PostEditorPage);
|
|
288
|
+
await t.set("title", "My post");
|
|
289
|
+
await t.set("body", "Content here");
|
|
290
|
+
await t.call("save");
|
|
291
|
+
|
|
292
|
+
editor.assertDispatched("post-created", { title: "My post" });
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("post list responds to post-created event", async () => {
|
|
296
|
+
await Post.create({ title: "Existing post", status: "published" });
|
|
297
|
+
|
|
298
|
+
const list = await FlowTest.mount(PostListPage);
|
|
299
|
+
expect(list.page().posts.length).toBe(1);
|
|
300
|
+
|
|
301
|
+
// Simulate the event arriving (same as calling the @on listener):
|
|
302
|
+
await list.call("handlePostCreated", { id: 99, title: "New post" });
|
|
303
|
+
|
|
304
|
+
// The listener called this.refresh() — posts re-loaded:
|
|
305
|
+
expect(list.page().posts.length).toBe(2);
|
|
306
|
+
});
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
## Testing redirects and navigation
|
|
310
|
+
|
|
311
|
+
```typescript
|
|
312
|
+
test("redirects to the created post after save", async () => {
|
|
313
|
+
const t = await FlowTest.mount(NewPostPage);
|
|
314
|
+
await t.set("title", "Hello");
|
|
315
|
+
await t.set("body", "World");
|
|
316
|
+
await t.call("save");
|
|
317
|
+
|
|
318
|
+
// The URL contains the new post's ID — match with a regex
|
|
319
|
+
const effects = t.effects();
|
|
320
|
+
expect(effects.redirect).toMatch(/^\/posts\/\d+$/);
|
|
321
|
+
|
|
322
|
+
// Or use the assertion helper for an exact URL
|
|
323
|
+
// t.assertRedirectedTo("/posts/1");
|
|
324
|
+
});
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
## FlowTest assertions
|
|
328
|
+
|
|
329
|
+
| Method | Asserts |
|
|
330
|
+
| ------------------------------------ | --------------------------------------------------------------- |
|
|
331
|
+
| `t.assertSee(text)` | Rendered HTML contains `text` |
|
|
332
|
+
| `t.assertDontSee(text)` | Rendered HTML does NOT contain `text` |
|
|
333
|
+
| `t.assertHasErrors(field)` | Validation error exists for `field` |
|
|
334
|
+
| `t.assertHasErrors(field, msg)` | Error for `field` contains `msg` as a substring |
|
|
335
|
+
| `t.assertNoErrors()` | No validation errors in the error bag |
|
|
336
|
+
| `t.assertRedirectedTo(url)` | Last action redirected to this exact URL |
|
|
337
|
+
| `t.assertNotRedirected()` | Last action did not produce a redirect |
|
|
338
|
+
| `t.assertFlashed(level?, msg?)` | Flash was emitted; both args are optional substrings |
|
|
339
|
+
| `t.assertDispatched(event)` | Cross-component event was dispatched |
|
|
340
|
+
| `t.assertDispatched(event, payload)` | Event was dispatched with matching payload |
|
|
341
|
+
| `t.page()` | Returns the component instance |
|
|
342
|
+
| `t.html()` | Returns the rendered HTML string |
|
|
343
|
+
| `t.errors()` | Returns the error bag: `Record<string, string[]>` |
|
|
344
|
+
| `t.effects()` | Returns drained effects (flashes, redirects, events, downloads) |
|
|
345
|
+
| `t.snapshot()` | Returns the serialised snapshot blob |
|
|
346
|
+
|
|
347
|
+
## Testing in a real browser
|
|
348
|
+
|
|
349
|
+
`FlowTest` never opens a socket. That is what makes it fast, and it is also what it
|
|
350
|
+
cannot check: whether the attribute the client needs was rendered, whether the click
|
|
351
|
+
listener fired, whether the frame reached the dispatcher, whether the patch came back.
|
|
352
|
+
A page can pass every `FlowTest` assertion and still do nothing when a person clicks it.
|
|
353
|
+
|
|
354
|
+
`FlowBrowser`, from `@zerotal/flow/browser`, closes that gap. It drives headless Chrome
|
|
355
|
+
over the DevTools Protocol — no Puppeteer or Playwright dependency — so the click is a
|
|
356
|
+
real click and the round-trip is a real round-trip.
|
|
357
|
+
|
|
358
|
+
```ts
|
|
359
|
+
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
360
|
+
import { Application, Router } from "zerotal";
|
|
361
|
+
import { FlowProvider } from "@zerotal/flow";
|
|
362
|
+
import { FlowBrowser } from "@zerotal/flow/browser";
|
|
363
|
+
import { CounterPage } from "../app/flow/pages/Counter.tsx";
|
|
364
|
+
|
|
365
|
+
let app: Application;
|
|
366
|
+
let url: string;
|
|
367
|
+
|
|
368
|
+
beforeAll(async () => {
|
|
369
|
+
app = Application.create({ env: "web", providers: [FlowProvider] });
|
|
370
|
+
await app.boot(); // Router.flow is a macro FlowProvider installs, so route after boot
|
|
371
|
+
Router.flow("/counter", CounterPage);
|
|
372
|
+
await app.start(0);
|
|
373
|
+
url = `http://localhost:${(app as any)._static.port}/counter`;
|
|
374
|
+
}, 30_000);
|
|
375
|
+
|
|
376
|
+
// close(), not stop(): stop() ends with process.exit(0) and would kill the test run.
|
|
377
|
+
afterAll(async () => await app.close(), 30_000);
|
|
378
|
+
|
|
379
|
+
it("increments through the socket", async () => {
|
|
380
|
+
const page = await FlowBrowser.open(url);
|
|
381
|
+
try {
|
|
382
|
+
await page.click("#increment");
|
|
383
|
+
await page.waitForText("#count", "1");
|
|
384
|
+
expect(page.consoleErrors()).toEqual([]);
|
|
385
|
+
} finally {
|
|
386
|
+
await page.close();
|
|
387
|
+
}
|
|
388
|
+
}, 30_000);
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
A few things are deliberate and worth copying:
|
|
392
|
+
|
|
393
|
+
- **Assert `consoleErrors()` is empty.** An action the server refuses is reported
|
|
394
|
+
_only_ to the browser console — nothing reaches the server log and the page does not
|
|
395
|
+
change. Without this assertion that failure is invisible to the test too.
|
|
396
|
+
- **Use `waitForText` / `waitUntil` rather than a sleep.** Every wait is a poll with a
|
|
397
|
+
timeout, so a slow round-trip waits longer and a broken one fails naming what it was
|
|
398
|
+
waiting for and what the console said.
|
|
399
|
+
- **Give browser tests an explicit timeout.** Bun's default is 5 s, which a browser
|
|
400
|
+
launch plus a page load can exceed.
|
|
401
|
+
- **Guard the suite with `FlowBrowser.available()`** so it skips where no browser is
|
|
402
|
+
installed instead of failing:
|
|
403
|
+
|
|
404
|
+
```ts
|
|
405
|
+
const describeBrowser = FlowBrowser.available() ? describe : describe.skip;
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
Set `CHROME_PATH` to pin a specific binary (this is how CI selects one).
|
|
409
|
+
|
|
410
|
+
### What to test where
|
|
411
|
+
|
|
412
|
+
| Question | Tool |
|
|
413
|
+
| ---------------------------------------------- | ------------- |
|
|
414
|
+
| Does the action compute the right state? | `FlowTest` |
|
|
415
|
+
| Does validation reject this payload? | `FlowTest` |
|
|
416
|
+
| Does the button actually reach that action? | `FlowBrowser` |
|
|
417
|
+
| Does the typed value arrive at the server? | `FlowBrowser` |
|
|
418
|
+
| Does the page still work after several clicks? | `FlowBrowser` |
|
|
419
|
+
|
|
420
|
+
Keep the browser tests few — a handful pinning the wiring of each critical flow — and
|
|
421
|
+
write the rest with `FlowTest`. They cost about a second each.
|
|
422
|
+
|
|
423
|
+
### API
|
|
424
|
+
|
|
425
|
+
| Call | What it does |
|
|
426
|
+
| ---------------------------------- | ----------------------------------------------------- |
|
|
427
|
+
| `FlowBrowser.available()` | Whether a browser is installed, for skipping |
|
|
428
|
+
| `FlowBrowser.open(url, opts?)` | Launch, load, and wait for Flow to connect |
|
|
429
|
+
| `page.goto(url, opts?)` | Navigate again, clearing recorded console output |
|
|
430
|
+
| `page.click(sel)` | Real click, through the page's own delegated listener |
|
|
431
|
+
| `page.fill(sel, value)` | Set a value and fire `input`/`change`, as a user does |
|
|
432
|
+
| `page.text(sel)` | An element's trimmed text |
|
|
433
|
+
| `page.value(sel)` | An input's current value |
|
|
434
|
+
| `page.attr(sel, name)` | An attribute, for asserting a binding was rendered |
|
|
435
|
+
| `page.html()` | The whole document, for diagnosing a failure |
|
|
436
|
+
| `page.waitForText(sel, text, ms?)` | Poll until the text matches |
|
|
437
|
+
| `page.waitForSelector(sel, ms?)` | Poll until the element exists |
|
|
438
|
+
| `page.waitUntil(expr, label, ms?)` | Poll until a JS expression is truthy |
|
|
439
|
+
| `page.evaluate(expr)` | Evaluate an expression in the page |
|
|
440
|
+
| `page.consoleErrors()` | Console errors recorded so far |
|
|
441
|
+
| `page.pageErrors()` | Uncaught exceptions recorded so far |
|
|
442
|
+
| `page.close()` | Close the page and kill the browser |
|
|
443
|
+
|
|
444
|
+
## Next steps
|
|
445
|
+
|
|
446
|
+
- [Flow overview](/docs/flow) — the guide's front page and the rest of the sections.
|
|
447
|
+
- [Reference](/docs/flow/references) — every decorator, prop, and directive in one table.
|