@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 +45 -0
- package/package.json +8 -7
- package/src/TestApp.ts +18 -7
- package/src/browser/FlowBrowser.ts +406 -0
- package/src/browser/chrome.ts +90 -0
- package/src/browser.ts +40 -0
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.
|
|
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.
|
|
34
|
-
"@zerotal/orm": "1.
|
|
35
|
-
"@zerotal/queue": "1.
|
|
36
|
-
"@zerotal/notifications": "1.
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
594
|
-
//
|
|
595
|
-
//
|
|
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";
|