@zerotal/testing 1.4.0 → 1.5.1

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 CHANGED
@@ -8,6 +8,51 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.5.0] — 2026-08-15
12
+
13
+ ### Added
14
+
15
+ - **`FlowBrowser` — drive a real page against a real server, inside `bun test`.**
16
+ `FlowTest` mounts a component and runs its server-side lifecycle; it never opens a
17
+ socket, so it renders the full markup every time and every assertion passes. That is
18
+ the problem: every silent failure Flow has shipped has one shape — the HTML is fine
19
+ and the transport is dead — and no server-side test can see it by construction.
20
+
21
+ `FlowBrowser.serve(bootstrap)` boots the app through `createTestApp` on an
22
+ OS-assigned port, `visit(path)` opens a headless page, and the page can be read
23
+ (`text`, `count`, `attribute`, `connection`), driven (`click`, `type`, `press`) and
24
+ waited on. Lives behind `@zerotal/testing/browser` so the vast majority of tests
25
+ never pay to find out whether a browser is present.
26
+
27
+ **`waitForPatch()` is the primitive, and it is not a sleep.** The harness reads the
28
+ WebSocket through the DevTools Protocol, so the received-frame count is captured when
29
+ an action is dispatched and the wait is for it to rise. A harness whose assertions
30
+ race the transport produces flaky tests, and a flaky browser suite gets deleted.
31
+
32
+ `transport()` reports what the browser saw on the wire — sockets created, handshakes
33
+ that answered `101`, every frame's payload — which the page cannot lie about. Assert
34
+ that a `101` was **seen**, never that a status was not `403`: a refused upgrade does
35
+ not arrive as a handshake response at all, so the negative form passes vacuously.
36
+
37
+ **No new dependency.** `Bun.WebView` covers driving the page and reading the
38
+ transport, so the argument for a browser-automation library is not made.
39
+
40
+ Two limits, documented rather than discovered: the harness talks to the app's own
41
+ origin, so it **cannot** catch a misconfigured `allowedOrigins` (`bun zt doctor --url`
42
+ is the tool for that); and Bun cannot spawn Chrome on Windows today, so connect-mode
43
+ via `ZT_BROWSER_CDP_URL` is a first-class path rather than a fallback.
44
+
45
+ ### Fixed
46
+
47
+ - **`createTestApp()` no longer un-adopts the app it just adopted.** The shared-app
48
+ path called `adoptAsCurrent()` and then `resetTestState()`, which calls
49
+ `Application._resetInstance()` — precisely what the adoption existed to undo. The
50
+ second test file in a process was handed an app whose scope had been torn down, and
51
+ the first facade it touched threw `E_FACADE_BEFORE_BOOT`. What made it expensive is
52
+ that each file passed _in isolation_, so the failure attached to whichever file
53
+ happened to sort second and read as a bug in that file. Reset, then adopt — the same
54
+ order the fresh-app path already used.
55
+
11
56
  ## [1.1.0] — 2026-08-08
12
57
 
13
58
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/testing",
3
- "version": "1.4.0",
3
+ "version": "1.5.1",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -9,7 +9,8 @@
9
9
  "types": "./src/index.ts",
10
10
  "exports": {
11
11
  ".": "./src/index.ts",
12
- "./preload": "./src/preload.ts"
12
+ "./preload": "./src/preload.ts",
13
+ "./browser": "./src/browser.ts"
13
14
  },
14
15
  "files": [
15
16
  "CHANGELOG.md",
@@ -30,14 +31,14 @@
30
31
  "typecheck": "tsc --noEmit"
31
32
  },
32
33
  "dependencies": {
33
- "@zerotal/core": "1.4.0",
34
- "@zerotal/orm": "1.4.0",
35
- "@zerotal/queue": "1.4.0",
36
- "@zerotal/notifications": "1.4.0"
34
+ "@zerotal/core": "1.5.1",
35
+ "@zerotal/orm": "1.5.1",
36
+ "@zerotal/queue": "1.5.1",
37
+ "@zerotal/notifications": "1.5.1"
37
38
  },
38
39
  "devDependencies": {
39
40
  "typescript": "^5.8.0",
40
- "@zerotal/session": "1.4.0"
41
+ "@zerotal/session": "1.5.1"
41
42
  },
42
43
  "description": "Testing utilities for Zerotal — an in-process test app, HTTP helpers, and database refresh.",
43
44
  "keywords": [
package/src/TestApp.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Application, type ExceptionHandler } from "@zerotal/core";
1
+ import { Application } from "@zerotal/core";
2
2
  import { TestResponse, type SessionDecoder } from "./TestResponse.ts";
3
3
  import { TestExceptionHandler } from "./TestExceptionHandler.ts";
4
4
  import { resetTestState } from "./resetTestState.ts";
@@ -590,19 +590,30 @@ export async function createTestApp(
590
590
  // already-started server is not idempotent — so those callers always get a fresh
591
591
  // app and own its teardown, exactly as before.
592
592
  //
593
- // Only probe once an app exists: on the first call `bootstrap()` has real side
594
- // effects (Application.create + provider registration) and must run after the
595
- // reset below, not before it.
593
+ // Reset BEFORE probing, not after. `bootstrap()` may be evaluating its module for
594
+ // the first time, and the scaffolded bootstrap builds its Application at module
595
+ // scope so probing first calls `Application.create()` while the previous file's
596
+ // app is still installed, and it throws "An application already exists in this
597
+ // process" before sharing is ever considered. Whether an app is still installed
598
+ // depends on which file happened to run before this one, which is why this passed
599
+ // on one machine and failed on the CI runner with the same code.
600
+ //
601
+ // Resetting first is safe on both paths: `resetTestState()` calls
602
+ // `Application._resetInstance()`, and every path below re-adopts — the shared one
603
+ // here, the fresh one after its own reset. Adopting before the reset is what is
604
+ // unsafe: it hands back an app whose scope has just been torn down, and the first
605
+ // facade to touch it throws E_FACADE_BEFORE_BOOT.
596
606
  if (!setup && _sharedApps.size > 0) {
607
+ resetTestState();
597
608
  const booted = await bootstrap();
598
609
  const existing = _sharedApps.get(booted);
599
610
  if (existing) {
600
- // Re-adopt: an earlier file's close() reset the app scope even though the app
601
- // itself is still running, so facades need pointing back at it.
602
611
  booted.adoptAsCurrent();
603
- resetTestState();
604
612
  return existing;
605
613
  }
614
+ // Not a shared app after all. Fall through — the module is cached now, so the
615
+ // bootstrap below returns this same instance rather than creating a second one,
616
+ // and the `adoptAsCurrent()` there reinstalls the scope this reset tore down.
606
617
  }
607
618
 
608
619
  resetTestState();
@@ -0,0 +1,406 @@
1
+ import { createTestApp, type TestApp } from "../TestApp.ts";
2
+ import { backendOption, browserAvailability } from "./chrome.ts";
3
+ import type { Application } from "@zerotal/core";
4
+
5
+ /**
6
+ * Read a CDP event's params.
7
+ *
8
+ * `Bun.WebView` delivers each event as an `Event` carrying the CDP params on
9
+ * `data`, but neither `addEventListener` overload types that usefully here:
10
+ * the generic one resolves `MessageEvent` to its *constructor* once lib.dom is
11
+ * in scope, and the plain one wants a bare `EventListener`. A predicate keeps
12
+ * the listener assignable and states the assumption in one place instead of
13
+ * casting at every call site.
14
+ */
15
+ function cdpParams<T>(event: Event): event is Event & { readonly data: T } {
16
+ return "data" in event;
17
+ }
18
+
19
+ /** A WebSocket frame the page sent or received, as CDP reported it. */
20
+ export interface ObservedFrame {
21
+ direction: "sent" | "received";
22
+ payload: string;
23
+ }
24
+
25
+ /** What the transport did, from outside the page. */
26
+ export interface TransportReport {
27
+ /** Sockets the page opened. */
28
+ created: number;
29
+ /** Handshakes that answered `101`. Zero means the socket never opened. */
30
+ upgraded: number;
31
+ /** Status of each handshake response that arrived. */
32
+ statuses: number[];
33
+ /** True when Chrome reported a frame-level failure. */
34
+ errored: boolean;
35
+ frames: ObservedFrame[];
36
+ }
37
+
38
+ /** Options for {@link FlowBrowser.serve}. */
39
+ export interface FlowBrowserOptions {
40
+ /** Milliseconds any `waitFor*` will wait before giving up. */
41
+ timeout?: number;
42
+ /**
43
+ * Register routes, before the server starts.
44
+ *
45
+ * Passed straight to `createTestApp`, which runs it after the state reset and
46
+ * before `start()` so the routes are compiled into the server. A suite that
47
+ * needs a fixture page — one deliberately built to reproduce a bug — registers
48
+ * it here instead of adding it to the application under test.
49
+ */
50
+ setup?: () => void;
51
+ }
52
+
53
+ const DEFAULT_TIMEOUT = 5_000;
54
+ /** Gap between polls of an in-page condition. Not a sleep the assertions depend on. */
55
+ const POLL_MS = 25;
56
+
57
+ /**
58
+ * A real page, driven against a real server.
59
+ *
60
+ * Obtained from {@link FlowBrowser.visit}. Every method that acts on the page
61
+ * records the transport frame count first, so {@link waitForPatch} can wait for
62
+ * a frame that arrived *after* the action rather than one already in flight.
63
+ */
64
+ export class BrowserPage {
65
+ private readonly _view: Bun.WebView;
66
+ private readonly _report: TransportReport;
67
+ private readonly _timeout: number;
68
+ private _framesAtAction = 0;
69
+ private _closed = false;
70
+
71
+ /** @internal Constructed by {@link FlowBrowser.visit}. */
72
+ constructor(view: Bun.WebView, report: TransportReport, timeout: number) {
73
+ this._view = view;
74
+ this._report = report;
75
+ this._timeout = timeout;
76
+ }
77
+
78
+ // ── Reading the page ────────────────────────────────────────────────────────
79
+
80
+ /**
81
+ * Evaluate an expression in the page.
82
+ *
83
+ * `Bun.WebView` allows only one evaluation in flight per view, so every read
84
+ * here is serial by construction — never wrap these in `Promise.all`.
85
+ */
86
+ async evaluate<T = unknown>(expression: string): Promise<T> {
87
+ this._assertOpen();
88
+ return this._view.evaluate<T>(expression);
89
+ }
90
+
91
+ /** `textContent` of the first match, trimmed. `null` when nothing matches. */
92
+ async text(selector: string): Promise<string | null> {
93
+ return this.evaluate<string | null>(
94
+ `(() => { const el = document.querySelector(${quote(selector)});
95
+ return el ? el.textContent.trim() : null; })()`,
96
+ );
97
+ }
98
+
99
+ /** `innerHTML` of the first match. `null` when nothing matches. */
100
+ async html(selector: string): Promise<string | null> {
101
+ return this.evaluate<string | null>(
102
+ `(() => { const el = document.querySelector(${quote(selector)});
103
+ return el ? el.innerHTML : null; })()`,
104
+ );
105
+ }
106
+
107
+ /** How many elements match. The assertion the keyless-child bug needed. */
108
+ async count(selector: string): Promise<number> {
109
+ return this.evaluate<number>(`document.querySelectorAll(${quote(selector)}).length`);
110
+ }
111
+
112
+ /** An attribute of the first match, or `null`. */
113
+ async attribute(selector: string, name: string): Promise<string | null> {
114
+ return this.evaluate<string | null>(
115
+ `(() => { const el = document.querySelector(${quote(selector)});
116
+ return el ? el.getAttribute(${quote(name)}) : null; })()`,
117
+ );
118
+ }
119
+
120
+ /**
121
+ * The connection state the bridge stamped on `<body>`.
122
+ *
123
+ * This is the single most valuable thing the harness can read: it is the
124
+ * difference between "the app is broken" and "the app is fine" in every
125
+ * failure this harness exists to catch. `"online"`, `"offline"`, or `null`
126
+ * before the bridge has run.
127
+ */
128
+ async connection(): Promise<string | null> {
129
+ return this.evaluate<string | null>("document.body.dataset.flowConnection ?? null");
130
+ }
131
+
132
+ /** The page's current URL — for asserting a redirect actually happened. */
133
+ async url(): Promise<string> {
134
+ return this.evaluate<string>("location.pathname + location.search");
135
+ }
136
+
137
+ // ── Acting on the page ──────────────────────────────────────────────────────
138
+
139
+ /** Click the first element matching `selector`, as a real trusted click. */
140
+ async click(selector: string): Promise<this> {
141
+ this._assertOpen();
142
+ this._markAction();
143
+ await this._view.click(selector);
144
+ return this;
145
+ }
146
+
147
+ /**
148
+ * Click the match to focus it, then type into it.
149
+ *
150
+ * `WebView.type()` types into whatever holds focus, so the click is what
151
+ * chooses the field — the same two steps a person performs.
152
+ */
153
+ async type(selector: string, text: string): Promise<this> {
154
+ this._assertOpen();
155
+ this._markAction();
156
+ await this._view.click(selector);
157
+ await this._view.type(text);
158
+ return this;
159
+ }
160
+
161
+ /** Press a key, with the page's current focus. */
162
+ async press(key: string): Promise<this> {
163
+ this._assertOpen();
164
+ this._markAction();
165
+ await this._view.press(key);
166
+ return this;
167
+ }
168
+
169
+ // ── Waiting ─────────────────────────────────────────────────────────────────
170
+
171
+ /**
172
+ * Wait until the page has received a WebSocket frame caused by the last action.
173
+ *
174
+ * This is the primitive, and it is deliberately not a sleep: the frame count is
175
+ * captured when the action is dispatched, and this waits for it to rise. A
176
+ * harness whose assertions race the transport produces flaky tests, and a flaky
177
+ * browser suite gets deleted.
178
+ *
179
+ * Throws on timeout rather than returning false, so a test that meant to observe
180
+ * a patch fails where the patch did not arrive instead of three assertions later.
181
+ */
182
+ async waitForPatch(timeout = this._timeout): Promise<this> {
183
+ const before = this._framesAtAction;
184
+ const deadline = Date.now() + timeout;
185
+ while (Date.now() < deadline) {
186
+ if (this._receivedCount() > before) return this;
187
+ await Bun.sleep(POLL_MS);
188
+ }
189
+ throw new Error(
190
+ `[FlowBrowser] No patch arrived within ${timeout}ms. ` +
191
+ `Connection is "${await this.connection()}"; ` +
192
+ `${this._report.upgraded} socket(s) upgraded, ` +
193
+ `${this._receivedCount()} frame(s) received in total.`,
194
+ );
195
+ }
196
+
197
+ /** Wait until an expression in the page is truthy. */
198
+ async waitFor(expression: string, timeout = this._timeout): Promise<this> {
199
+ const deadline = Date.now() + timeout;
200
+ let last: unknown;
201
+ while (Date.now() < deadline) {
202
+ last = await this.evaluate(expression);
203
+ if (last) return this;
204
+ await Bun.sleep(POLL_MS);
205
+ }
206
+ throw new Error(
207
+ `[FlowBrowser] Timed out after ${timeout}ms waiting for: ${expression}\n` +
208
+ ` last value: ${JSON.stringify(last)}; connection is "${await this.connection()}".`,
209
+ );
210
+ }
211
+
212
+ /** Wait until the bridge reports `online`. */
213
+ async waitForConnection(timeout = this._timeout): Promise<this> {
214
+ return this.waitFor(`document.body.dataset.flowConnection === "online"`, timeout);
215
+ }
216
+
217
+ /** Wait until `selector` matches at least `n` elements. */
218
+ async waitForCount(selector: string, n: number, timeout = this._timeout): Promise<this> {
219
+ return this.waitFor(
220
+ `document.querySelectorAll(${quote(selector)}).length >= ${String(n)}`,
221
+ timeout,
222
+ );
223
+ }
224
+
225
+ // ── The transport, from outside the page ────────────────────────────────────
226
+
227
+ /**
228
+ * What Chrome saw on the wire.
229
+ *
230
+ * The page cannot lie about this: a client that silently degraded and called
231
+ * itself fine still shows zero upgraded sockets here.
232
+ */
233
+ transport(): TransportReport {
234
+ return {
235
+ ...this._report,
236
+ statuses: [...this._report.statuses],
237
+ frames: [...this._report.frames],
238
+ };
239
+ }
240
+
241
+ /**
242
+ * Whether a WebSocket handshake answered `101`.
243
+ *
244
+ * Asserted as "a 101 was observed", never as "the status was not 403": a
245
+ * refused upgrade does not arrive as a handshake response at all — Chrome
246
+ * reports a frame error instead — so a test asserting on the status of that
247
+ * event would pass vacuously.
248
+ */
249
+ socketUpgraded(): boolean {
250
+ return this._report.upgraded > 0;
251
+ }
252
+
253
+ /** Close the page. Its server is not affected. */
254
+ close(): void {
255
+ if (this._closed) return;
256
+ this._closed = true;
257
+ this._view.close();
258
+ }
259
+
260
+ private _receivedCount(): number {
261
+ return this._report.frames.filter((f) => f.direction === "received").length;
262
+ }
263
+
264
+ private _markAction(): void {
265
+ this._framesAtAction = this._receivedCount();
266
+ }
267
+
268
+ private _assertOpen(): void {
269
+ if (this._closed) throw new Error("[FlowBrowser] This page is closed.");
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Drives a real browser against a real server, so the WebSocket bridge has a
275
+ * regression net.
276
+ *
277
+ * Every silent failure Flow has shipped shares one shape — the HTML is fine and
278
+ * the transport is dead — and `FlowTest` cannot exercise the bridge at all: SSR
279
+ * renders, snapshot assertions pass, the suite is green, and the app does
280
+ * nothing. This is the harness that can see that.
281
+ *
282
+ * @example
283
+ * ```ts
284
+ * const browser = await FlowBrowser.serve(bootstrap);
285
+ * const page = await browser.visit("/settings");
286
+ * await page.waitForConnection();
287
+ * await page.click('[flow\\:click="save"]');
288
+ * await page.waitForPatch();
289
+ * expect(await page.text("#status")).toBe("Saved");
290
+ * ```
291
+ */
292
+ export class FlowBrowser {
293
+ private readonly _app: TestApp;
294
+ private readonly _timeout: number;
295
+ private readonly _pages: BrowserPage[] = [];
296
+
297
+ private constructor(app: TestApp, timeout: number) {
298
+ this._app = app;
299
+ this._timeout = timeout;
300
+ }
301
+
302
+ /** Whether a browser can be reached, and how. Cached per process. */
303
+ static availability = browserAvailability;
304
+
305
+ /**
306
+ * Boot the app on an OS-assigned port and return a harness.
307
+ *
308
+ * Reuses `createTestApp()`, so the app under test is configured exactly the way
309
+ * the rest of the suite configures it rather than through a second bootstrap
310
+ * that can drift.
311
+ */
312
+ static async serve(
313
+ bootstrap: () => Application | Promise<Application>,
314
+ options: FlowBrowserOptions = {},
315
+ ): Promise<FlowBrowser> {
316
+ const availability = await browserAvailability();
317
+ if (!availability.available) throw new Error(`[FlowBrowser] ${availability.reason}`);
318
+
319
+ // Boot inside the bootstrap callback, so `setup` can use route macros.
320
+ //
321
+ // `createTestApp` runs `setup` between `bootstrap()` and `start()`, and a
322
+ // provider registers its macros in `onRegister()` — which runs during
323
+ // `boot()`, inside `start()`. So a `setup` that calls `Router.flow(...)`
324
+ // would find it undefined. `boot()` is idempotent and `start()` skips it
325
+ // when already booted, so pulling it forward changes only the ordering.
326
+ const app = await createTestApp(async () => {
327
+ const application = await bootstrap();
328
+ application.adoptAsCurrent();
329
+ await application.boot();
330
+ return application;
331
+ }, options.setup);
332
+ return new FlowBrowser(app, options.timeout ?? DEFAULT_TIMEOUT);
333
+ }
334
+
335
+ /** The server's base URL, e.g. `http://localhost:53211`. */
336
+ get url(): string {
337
+ return this._app.baseUrl;
338
+ }
339
+
340
+ /** The port the server bound. */
341
+ get port(): number {
342
+ return this._app.port;
343
+ }
344
+
345
+ /**
346
+ * Open `path` in a fresh page and wait for it to load.
347
+ *
348
+ * One page per test, torn down by {@link stop} — shared browser state across
349
+ * tests is the other way a browser suite becomes untrustworthy.
350
+ */
351
+ async visit(path: string): Promise<BrowserPage> {
352
+ const view = new Bun.WebView({ backend: backendOption() });
353
+ const report: TransportReport = {
354
+ created: 0,
355
+ upgraded: 0,
356
+ statuses: [],
357
+ errored: false,
358
+ frames: [],
359
+ };
360
+
361
+ // Network tracking has to be enabled before the page opens its socket, and
362
+ // CDP needs one navigation before it has a session — so land on a blank page
363
+ // first, subscribe, and only then navigate to the app.
364
+ await view.navigate("about:blank");
365
+ await view.cdp("Network.enable");
366
+
367
+ view.addEventListener("Network.webSocketCreated", () => {
368
+ report.created++;
369
+ });
370
+ view.addEventListener("Network.webSocketHandshakeResponseReceived", (event: Event) => {
371
+ if (!cdpParams<{ response: { status: number } }>(event)) return;
372
+ const status = event.data.response.status;
373
+ report.statuses.push(status);
374
+ if (status === 101) report.upgraded++;
375
+ });
376
+ view.addEventListener("Network.webSocketFrameReceived", (event: Event) => {
377
+ if (!cdpParams<{ response: { payloadData: string } }>(event)) return;
378
+ report.frames.push({ direction: "received", payload: event.data.response.payloadData });
379
+ });
380
+ view.addEventListener("Network.webSocketFrameSent", (event: Event) => {
381
+ if (!cdpParams<{ response: { payloadData: string } }>(event)) return;
382
+ report.frames.push({ direction: "sent", payload: event.data.response.payloadData });
383
+ });
384
+ view.addEventListener("Network.webSocketFrameError", () => {
385
+ report.errored = true;
386
+ });
387
+
388
+ await view.navigate(new URL(path, this._app.baseUrl).href);
389
+
390
+ const page = new BrowserPage(view, report, this._timeout);
391
+ this._pages.push(page);
392
+ return page;
393
+ }
394
+
395
+ /** Close every page this harness opened, then stop the server. */
396
+ async stop(): Promise<void> {
397
+ for (const page of this._pages) page.close();
398
+ this._pages.length = 0;
399
+ await this._app.close();
400
+ }
401
+ }
402
+
403
+ /** JSON-quote a value for embedding in an evaluated expression. */
404
+ function quote(value: string): string {
405
+ return JSON.stringify(value);
406
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Getting a headless browser, and saying clearly why you could not.
3
+ *
4
+ * `Bun.WebView` reaches Chrome two ways, and both are first-class here:
5
+ *
6
+ * - **spawn** — `backend: "chrome"`, which launches its own headless instance
7
+ * over `--remote-debugging-pipe`. Nothing to set up.
8
+ * - **connect** — `backend: { type: "chrome", url }`, against a browser already
9
+ * running with `--remote-debugging-port`.
10
+ *
11
+ * Connect is not a fallback for tidiness. On Windows, Bun 1.3.14 cannot spawn
12
+ * Chrome at all — it throws `Failed to spawn Chrome` even with `BUN_CHROME_PATH`
13
+ * set and the binary verified present — so connect is the only path a Windows
14
+ * developer has. Set `ZT_BROWSER_CDP_URL` and the harness uses it.
15
+ */
16
+
17
+ /** How the harness reached a browser, or why it did not. */
18
+ export interface BrowserAvailability {
19
+ available: boolean;
20
+ /** `spawn` launched its own; `connect` attached to a running one. */
21
+ mode: "spawn" | "connect" | "none";
22
+ /** Human-facing explanation — printed when a suite skips. */
23
+ reason: string;
24
+ }
25
+
26
+ /** Env var carrying a CDP WebSocket URL, for connect-mode. */
27
+ export const CDP_URL_ENV = "ZT_BROWSER_CDP_URL";
28
+
29
+ let _cached: BrowserAvailability | null = null;
30
+
31
+ function connectUrl(): string | undefined {
32
+ const url = Bun.env[CDP_URL_ENV];
33
+ return url && url.length > 0 ? url : undefined;
34
+ }
35
+
36
+ /** The backend descriptor for a new view, honouring connect-mode when configured. */
37
+ export function backendOption(): "chrome" | { type: "chrome"; url: string } {
38
+ const url = connectUrl();
39
+ return url ? { type: "chrome", url } : "chrome";
40
+ }
41
+
42
+ /**
43
+ * Whether a browser can be reached, cached for the process.
44
+ *
45
+ * Probes by actually opening a view and navigating — a Chrome binary that exists
46
+ * but cannot start is the failure mode worth catching, and it is invisible to a
47
+ * `which chrome`.
48
+ */
49
+ export async function browserAvailability(): Promise<BrowserAvailability> {
50
+ if (_cached) return _cached;
51
+
52
+ const url = connectUrl();
53
+ try {
54
+ const view = new Bun.WebView({ backend: backendOption() });
55
+ try {
56
+ await view.navigate("about:blank");
57
+ await view.evaluate("1");
58
+ } finally {
59
+ view.close();
60
+ }
61
+ _cached = {
62
+ available: true,
63
+ mode: url ? "connect" : "spawn",
64
+ reason: url ? `connected to ${url}` : "spawned a headless Chrome",
65
+ };
66
+ } catch (error) {
67
+ const message = error instanceof Error ? error.message : String(error);
68
+ _cached = {
69
+ available: false,
70
+ mode: "none",
71
+ reason:
72
+ `no headless browser: ${message}\n` +
73
+ ` Install Chrome or Chromium, or start one with --remote-debugging-port ` +
74
+ `and set ${CDP_URL_ENV} to its webSocketDebuggerUrl ` +
75
+ `(read it from http://127.0.0.1:<port>/json/version).`,
76
+ };
77
+ }
78
+ return _cached;
79
+ }
80
+
81
+ /**
82
+ * True when a skipped browser suite is not acceptable.
83
+ *
84
+ * A browser suite that goes quietly green in CI is precisely the failure this
85
+ * harness exists to prevent, so CI treats "no browser" as a failure while a
86
+ * developer's machine treats it as a skip.
87
+ */
88
+ export function browserRequired(): boolean {
89
+ return Bun.env["CI"] !== undefined && Bun.env["CI"] !== "" && Bun.env["CI"] !== "false";
90
+ }
package/src/browser.ts ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * `@zerotal/testing/browser` — drive a real page against a real server.
3
+ *
4
+ * Separate from the main entry point on purpose: this one reaches for a headless
5
+ * browser, and the overwhelming majority of tests neither need one nor should pay
6
+ * to find out whether it is there.
7
+ *
8
+ * `FlowTest` mounts a component and drives its server-side lifecycle. It cannot
9
+ * open the WebSocket bridge, which is where every silent failure Flow has shipped
10
+ * has lived: SSR renders, snapshot assertions pass, the suite is green, and the
11
+ * app does nothing in a browser. `FlowBrowser` is the harness that sees that.
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * import { FlowBrowser } from "@zerotal/testing/browser";
16
+ *
17
+ * const availability = await FlowBrowser.availability();
18
+ *
19
+ * describe.skipIf(!availability.available)("settings", () => {
20
+ * let browser: FlowBrowser;
21
+ * beforeAll(async () => { browser = await FlowBrowser.serve(bootstrap); });
22
+ * afterAll(async () => { await browser.stop(); });
23
+ *
24
+ * it("saves", async () => {
25
+ * const page = await browser.visit("/settings");
26
+ * await page.waitForConnection();
27
+ * await page.click('[flow\\:click="save"]');
28
+ * await page.waitForPatch();
29
+ * expect(await page.text("#status")).toBe("Saved");
30
+ * });
31
+ * });
32
+ * ```
33
+ *
34
+ * @packageDocumentation
35
+ */
36
+
37
+ export { FlowBrowser, BrowserPage } from "./browser/FlowBrowser.ts";
38
+ export type { FlowBrowserOptions, ObservedFrame, TransportReport } from "./browser/FlowBrowser.ts";
39
+ export { browserAvailability, browserRequired, CDP_URL_ENV } from "./browser/chrome.ts";
40
+ export type { BrowserAvailability } from "./browser/chrome.ts";