@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.
Files changed (160) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +67 -0
  4. package/api-surface.md +283 -0
  5. package/docs/about.md +676 -0
  6. package/docs/admin/actions.md +183 -0
  7. package/docs/admin/auth.md +89 -0
  8. package/docs/admin/dashboard.md +155 -0
  9. package/docs/admin/extending-ui.md +229 -0
  10. package/docs/admin/extending.md +205 -0
  11. package/docs/admin/forms.md +155 -0
  12. package/docs/admin/index.md +144 -0
  13. package/docs/admin/operations.md +224 -0
  14. package/docs/admin/references.md +174 -0
  15. package/docs/admin/resources.md +132 -0
  16. package/docs/admin/structure.md +158 -0
  17. package/docs/admin/tables.md +185 -0
  18. package/docs/admin/testing.md +138 -0
  19. package/docs/ai.md +373 -0
  20. package/docs/application.md +435 -0
  21. package/docs/arch.md +263 -0
  22. package/docs/assets.md +755 -0
  23. package/docs/audit.md +367 -0
  24. package/docs/authentication.md +1000 -0
  25. package/docs/authorization.md +529 -0
  26. package/docs/broadcasting/channels.md +170 -0
  27. package/docs/broadcasting/client.md +108 -0
  28. package/docs/broadcasting/events.md +190 -0
  29. package/docs/broadcasting/index.md +163 -0
  30. package/docs/broadcasting/references.md +84 -0
  31. package/docs/broadcasting/testing.md +132 -0
  32. package/docs/cache.md +387 -0
  33. package/docs/carbon.md +830 -0
  34. package/docs/changelog.md +465 -0
  35. package/docs/client/auth.md +113 -0
  36. package/docs/client/errors.md +139 -0
  37. package/docs/client/files.md +118 -0
  38. package/docs/client/index.md +128 -0
  39. package/docs/client/references.md +58 -0
  40. package/docs/client/requests.md +131 -0
  41. package/docs/client/resilience.md +141 -0
  42. package/docs/client/testing.md +146 -0
  43. package/docs/commands.md +450 -0
  44. package/docs/components.md +2427 -0
  45. package/docs/config-system.md +258 -0
  46. package/docs/container.md +596 -0
  47. package/docs/context.md +579 -0
  48. package/docs/contributing.md +164 -0
  49. package/docs/controllers.md +354 -0
  50. package/docs/conventions.md +466 -0
  51. package/docs/cookies.md +101 -0
  52. package/docs/csrf.md +206 -0
  53. package/docs/database.md +476 -0
  54. package/docs/deployment.md +434 -0
  55. package/docs/devtools.md +1013 -0
  56. package/docs/email-verification.md +320 -0
  57. package/docs/encryption.md +380 -0
  58. package/docs/errors.md +317 -0
  59. package/docs/events.md +420 -0
  60. package/docs/flow/components.md +846 -0
  61. package/docs/flow/decorators.md +458 -0
  62. package/docs/flow/events.md +420 -0
  63. package/docs/flow/forms.md +551 -0
  64. package/docs/flow/index.md +568 -0
  65. package/docs/flow/layouts.md +793 -0
  66. package/docs/flow/lifecycle.md +336 -0
  67. package/docs/flow/pagination.md +402 -0
  68. package/docs/flow/performance.md +262 -0
  69. package/docs/flow/references.md +317 -0
  70. package/docs/flow/routing.md +550 -0
  71. package/docs/flow/testing.md +447 -0
  72. package/docs/getting-started.md +286 -0
  73. package/docs/health.md +269 -0
  74. package/docs/helpers.md +423 -0
  75. package/docs/i18n.md +414 -0
  76. package/docs/index.md +65 -0
  77. package/docs/inertia/build.md +95 -0
  78. package/docs/inertia/devtools.md +197 -0
  79. package/docs/inertia/index.md +267 -0
  80. package/docs/inertia/middleware.md +81 -0
  81. package/docs/inertia/props.md +507 -0
  82. package/docs/inertia/references.md +144 -0
  83. package/docs/inertia/rendering.md +189 -0
  84. package/docs/inertia/ssr.md +102 -0
  85. package/docs/inspirations.md +257 -0
  86. package/docs/lifecycle.md +331 -0
  87. package/docs/lock.md +454 -0
  88. package/docs/logger.md +517 -0
  89. package/docs/media.md +582 -0
  90. package/docs/middleware.md +509 -0
  91. package/docs/migrations.md +529 -0
  92. package/docs/monitor.md +377 -0
  93. package/docs/notifications.md +848 -0
  94. package/docs/orm/casts.md +365 -0
  95. package/docs/orm/factories.md +231 -0
  96. package/docs/orm/index.md +663 -0
  97. package/docs/orm/lifecycle.md +421 -0
  98. package/docs/orm/queries.md +692 -0
  99. package/docs/orm/relationships.md +440 -0
  100. package/docs/orm/serialization.md +251 -0
  101. package/docs/package-development.md +536 -0
  102. package/docs/pagination.md +340 -0
  103. package/docs/password-reset.md +335 -0
  104. package/docs/providers.md +447 -0
  105. package/docs/query-builder.md +465 -0
  106. package/docs/queue.md +549 -0
  107. package/docs/rate-limiting.md +253 -0
  108. package/docs/responses.md +336 -0
  109. package/docs/roles-and-2fa.md +418 -0
  110. package/docs/routing.md +1008 -0
  111. package/docs/scaffolding.md +335 -0
  112. package/docs/scheduler.md +533 -0
  113. package/docs/seeding.md +229 -0
  114. package/docs/session.md +451 -0
  115. package/docs/social.md +524 -0
  116. package/docs/storage.md +622 -0
  117. package/docs/structure.md +230 -0
  118. package/docs/support-policy.md +114 -0
  119. package/docs/telemetry.md +505 -0
  120. package/docs/tenancy.md +608 -0
  121. package/docs/testing/browser.md +153 -0
  122. package/docs/testing/console.md +130 -0
  123. package/docs/testing/database.md +238 -0
  124. package/docs/testing/flow-browser.md +216 -0
  125. package/docs/testing/http.md +441 -0
  126. package/docs/testing/index.md +240 -0
  127. package/docs/testing/mocking.md +410 -0
  128. package/docs/upgrade.md +192 -0
  129. package/docs/validator.md +549 -0
  130. package/docs/view.md +453 -0
  131. package/package.json +60 -0
  132. package/src/bin/mcp.ts +73 -0
  133. package/src/config.ts +63 -0
  134. package/src/errors.ts +27 -0
  135. package/src/index.ts +56 -0
  136. package/src/install/ArchInstallCommand.ts +189 -0
  137. package/src/install/detect.ts +98 -0
  138. package/src/install/guidelines.ts +290 -0
  139. package/src/install/markers.ts +77 -0
  140. package/src/install/mcpConfig.ts +81 -0
  141. package/src/mcp/index.ts +27 -0
  142. package/src/mcp/jsonrpc.ts +133 -0
  143. package/src/mcp/server.ts +307 -0
  144. package/src/mcp/stdio.ts +111 -0
  145. package/src/mcp/types.ts +146 -0
  146. package/src/probe/ArchProbeCommand.ts +48 -0
  147. package/src/probe/sentinel.ts +13 -0
  148. package/src/probe/topics.ts +291 -0
  149. package/src/provider/ArchProvider.ts +101 -0
  150. package/src/tools/_probe.ts +164 -0
  151. package/src/tools/apiSurface.ts +241 -0
  152. package/src/tools/appInfo.ts +88 -0
  153. package/src/tools/baselines.ts +210 -0
  154. package/src/tools/context.ts +37 -0
  155. package/src/tools/doctor.ts +93 -0
  156. package/src/tools/index.ts +50 -0
  157. package/src/tools/logs.ts +265 -0
  158. package/src/tools/routes.ts +133 -0
  159. package/src/tools/schema.ts +119 -0
  160. 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.