@zerotal/flow 1.3.0 → 1.5.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.
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Drive a real browser against a running Flow app.
3
+ *
4
+ * `FlowTest` calls actions directly, which is the right tool for a component's
5
+ * logic but structurally cannot fail the way production fails: it never renders
6
+ * an attribute, never dispatches a DOM event, and never opens the WebSocket. The
7
+ * bugs Flow has actually shipped all lived in that gap — a `<select>` that lost
8
+ * its binding, a click cancelled by `preventDefault`, an action the server
9
+ * refused and reported only to the browser console.
10
+ *
11
+ * `FlowBrowser` closes it. A click here is a real click, dispatched by Chrome
12
+ * into the page's own delegated listener, travelling over the real socket to the
13
+ * real dispatcher and back as a real patch. If any link in that chain is broken,
14
+ * the assertion fails — which is the whole point.
15
+ *
16
+ * ```ts
17
+ * const page = await FlowBrowser.open(`http://localhost:${port}/counter`);
18
+ * await page.click("#increment");
19
+ * await page.waitForText("#count", "1");
20
+ * expect(await page.consoleErrors()).toEqual([]);
21
+ * await page.close();
22
+ * ```
23
+ *
24
+ * Requires a Chrome or Edge install (or `CHROME_PATH`). Use {@link FlowBrowser.available}
25
+ * to skip a suite where there is no browser rather than failing it.
26
+ */
27
+ import { CdpSession, findChrome, launchBrowser, type LaunchedBrowser } from "./cdp.ts";
28
+
29
+ /** How long to wait for a condition driven by a server round-trip. */
30
+ const DEFAULT_TIMEOUT_MS = 10_000;
31
+
32
+ export interface OpenOptions {
33
+ /** Milliseconds to wait for the page to load and Flow to connect. */
34
+ timeout?: number;
35
+ /**
36
+ * Wait for Flow's WebSocket to report itself online before returning.
37
+ * Default: true. Set false for a page with no Flow component on it.
38
+ */
39
+ waitForConnection?: boolean;
40
+ }
41
+
42
+ export class FlowBrowser {
43
+ private readonly _consoleErrors: string[] = [];
44
+ private readonly _pageErrors: string[] = [];
45
+
46
+ private constructor(
47
+ private readonly _browser: LaunchedBrowser,
48
+ private readonly _session: CdpSession,
49
+ ) {}
50
+
51
+ /** Whether a browser is installed, so a suite can skip rather than fail. */
52
+ static available(): boolean {
53
+ return findChrome() !== null;
54
+ }
55
+
56
+ /** Launch a browser, load `url`, and wait for Flow to connect. */
57
+ static async open(url: string, options: OpenOptions = {}): Promise<FlowBrowser> {
58
+ const chrome = findChrome();
59
+ if (!chrome) {
60
+ throw new Error(
61
+ "No Chrome or Edge found. Install one, or set CHROME_PATH. " +
62
+ "Guard the suite with `FlowBrowser.available()` to skip instead.",
63
+ );
64
+ }
65
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
66
+ const browser = await launchBrowser(chrome);
67
+ const page = new FlowBrowser(browser, browser.session);
68
+ page._captureConsole();
69
+
70
+ const loaded = browser.session.once("Page.loadEventFired", timeout);
71
+ await browser.session.send("Page.navigate", { url });
72
+ await loaded;
73
+
74
+ if (options.waitForConnection !== false) {
75
+ // The bridge stamps the body when its socket opens. Waiting on that rather
76
+ // than on a sleep is what makes these tests deterministic: a slow boot
77
+ // waits longer, and a bridge that never connects fails with a clear reason
78
+ // instead of a mystery assertion further down.
79
+ await page.waitUntil(
80
+ `document.body.getAttribute("data-flow-connection") === "online"`,
81
+ "Flow to connect its WebSocket",
82
+ timeout,
83
+ );
84
+ }
85
+ return page;
86
+ }
87
+
88
+ /**
89
+ * Record console errors and uncaught exceptions.
90
+ *
91
+ * This is deliberate, not incidental. A Flow action the server refuses is
92
+ * reported *only* here — nothing reaches the server log and the page does not
93
+ * change — so a test that cannot see the console cannot see the failure. B15
94
+ * cost an afternoon for exactly this reason.
95
+ */
96
+ private _captureConsole(): void {
97
+ this._session.on("Runtime.consoleAPICalled", (params) => {
98
+ if (params["type"] !== "error") return;
99
+ const args = (params["args"] ?? []) as Array<{ value?: unknown; description?: string }>;
100
+ this._consoleErrors.push(args.map((a) => String(a.value ?? a.description ?? "")).join(" "));
101
+ });
102
+ this._session.on("Runtime.exceptionThrown", (params) => {
103
+ const details = params["exceptionDetails"] as
104
+ { text?: string; exception?: { description?: string } } | undefined;
105
+ this._pageErrors.push(details?.exception?.description ?? details?.text ?? "unknown error");
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Navigate this browser to another URL and wait for Flow to reconnect.
111
+ *
112
+ * Reusing one browser across a suite is worth the small amount of state
113
+ * management: launching Chrome costs about a second, and a fresh navigation
114
+ * gives each test a fresh component anyway. Recorded console output is cleared
115
+ * so one test's errors cannot be attributed to the next.
116
+ */
117
+ async goto(url: string, options: OpenOptions = {}): Promise<void> {
118
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
119
+ this._consoleErrors.length = 0;
120
+ this._pageErrors.length = 0;
121
+
122
+ const loaded = this._session.once("Page.loadEventFired", timeout);
123
+ await this._session.send("Page.navigate", { url });
124
+ await loaded;
125
+
126
+ if (options.waitForConnection !== false) {
127
+ await this.waitUntil(
128
+ `document.body.getAttribute("data-flow-connection") === "online"`,
129
+ "Flow to connect its WebSocket",
130
+ timeout,
131
+ );
132
+ }
133
+ }
134
+
135
+ /** Evaluate an expression in the page and return its value. */
136
+ async evaluate<T = unknown>(expression: string): Promise<T> {
137
+ const res = (await this._session.send("Runtime.evaluate", {
138
+ expression,
139
+ returnByValue: true,
140
+ awaitPromise: true,
141
+ })) as {
142
+ result?: { value?: T };
143
+ exceptionDetails?: { text?: string; exception?: { description?: string } };
144
+ };
145
+ if (res.exceptionDetails) {
146
+ const detail =
147
+ res.exceptionDetails.exception?.description ?? res.exceptionDetails.text ?? "unknown";
148
+ throw new Error(`Evaluation failed: ${detail}\n expression: ${expression}`);
149
+ }
150
+ return res.result?.value as T;
151
+ }
152
+
153
+ /** Poll `expression` until it is truthy. */
154
+ async waitUntil(
155
+ expression: string,
156
+ label: string,
157
+ timeoutMs = DEFAULT_TIMEOUT_MS,
158
+ ): Promise<void> {
159
+ const deadline = Date.now() + timeoutMs;
160
+ let last: unknown;
161
+ while (Date.now() < deadline) {
162
+ last = await this.evaluate(`Boolean(${expression})`);
163
+ if (last === true) return;
164
+ await Bun.sleep(40);
165
+ }
166
+ throw new Error(
167
+ `Timed out after ${timeoutMs}ms waiting for ${label}.\n` +
168
+ ` expression: ${expression}\n` +
169
+ ` console errors: ${this._consoleErrors.length ? this._consoleErrors.join(" | ") : "(none)"}`,
170
+ );
171
+ }
172
+
173
+ /** Wait for an element to exist. */
174
+ async waitForSelector(selector: string, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<void> {
175
+ await this.waitUntil(
176
+ `document.querySelector(${JSON.stringify(selector)})`,
177
+ `selector ${selector}`,
178
+ timeoutMs,
179
+ );
180
+ }
181
+
182
+ /** Wait until an element's trimmed text equals `text` — the usual post-action assertion. */
183
+ async waitForText(selector: string, text: string, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<void> {
184
+ const sel = JSON.stringify(selector);
185
+ await this.waitUntil(
186
+ `(document.querySelector(${sel})?.textContent ?? "").trim() === ${JSON.stringify(text)}`,
187
+ `${selector} to read ${JSON.stringify(text)} (currently ` +
188
+ `${JSON.stringify(await this.text(selector).catch(() => null))})`,
189
+ timeoutMs,
190
+ );
191
+ }
192
+
193
+ /**
194
+ * Click an element the way a user does.
195
+ *
196
+ * Dispatched through the element's own `click()`, so it runs the page's
197
+ * delegated `click` listener and the browser's activation behaviour — which is
198
+ * what made `flow:click` on a radio detectable at all (B4b).
199
+ */
200
+ async click(selector: string): Promise<void> {
201
+ await this.waitForSelector(selector);
202
+ await this.evaluate(`document.querySelector(${JSON.stringify(selector)}).click()`);
203
+ }
204
+
205
+ /**
206
+ * Type into a `flow:model` field.
207
+ *
208
+ * Sets the value and fires a real `input` event, because that is the event the
209
+ * bridge listens for — assigning `.value` alone changes the DOM without ever
210
+ * telling Flow, which is precisely the silent failure this harness exists to
211
+ * catch.
212
+ */
213
+ async fill(selector: string, value: string): Promise<void> {
214
+ await this.waitForSelector(selector);
215
+ const sel = JSON.stringify(selector);
216
+ await this.evaluate(
217
+ `(() => {
218
+ const el = document.querySelector(${sel});
219
+ el.value = ${JSON.stringify(value)};
220
+ el.dispatchEvent(new Event("input", { bubbles: true }));
221
+ el.dispatchEvent(new Event("change", { bubbles: true }));
222
+ })()`,
223
+ );
224
+ }
225
+
226
+ /** An element's trimmed text. */
227
+ async text(selector: string): Promise<string> {
228
+ return (
229
+ (await this.evaluate<string>(
230
+ `(document.querySelector(${JSON.stringify(selector)})?.textContent ?? "").trim()`,
231
+ )) ?? ""
232
+ );
233
+ }
234
+
235
+ /** An element's attribute value, or null. */
236
+ async attr(selector: string, name: string): Promise<string | null> {
237
+ return await this.evaluate<string | null>(
238
+ `document.querySelector(${JSON.stringify(selector)})?.getAttribute(${JSON.stringify(name)}) ?? null`,
239
+ );
240
+ }
241
+
242
+ /** An input's current value. */
243
+ async value(selector: string): Promise<string> {
244
+ return (
245
+ (await this.evaluate<string>(
246
+ `document.querySelector(${JSON.stringify(selector)})?.value ?? ""`,
247
+ )) ?? ""
248
+ );
249
+ }
250
+
251
+ /** The page's full HTML, for diagnosing a failure. */
252
+ async html(): Promise<string> {
253
+ return await this.evaluate<string>("document.documentElement.outerHTML");
254
+ }
255
+
256
+ /** Console errors recorded so far — a refused action shows up here and nowhere else. */
257
+ consoleErrors(): string[] {
258
+ return [...this._consoleErrors];
259
+ }
260
+
261
+ /** Uncaught exceptions recorded so far. */
262
+ pageErrors(): string[] {
263
+ return [...this._pageErrors];
264
+ }
265
+
266
+ /** Close the page and kill the browser. */
267
+ async close(): Promise<void> {
268
+ await this._browser.close();
269
+ }
270
+ }
@@ -0,0 +1,273 @@
1
+ /**
2
+ * A minimal Chrome DevTools Protocol client, with no dependencies.
3
+ *
4
+ * Flow's failure mode has always been silence: a button that renders correctly,
5
+ * type-checks, compiles, and does nothing when clicked. Every such bug in two
6
+ * rounds of field reports lived in the WebSocket bridge — the layer between a
7
+ * real DOM event and the server action it should dispatch — and `FlowTest`
8
+ * cannot reach it, because it calls actions directly instead of letting the
9
+ * browser do it. The suite was deep and shaped away from where the bugs were.
10
+ *
11
+ * This is the missing layer: a real browser, a real socket, a real click.
12
+ *
13
+ * It speaks CDP over Bun's built-in WebSocket and drives a headless Chrome the
14
+ * runtime already has, rather than pulling in Puppeteer or Playwright — the same
15
+ * zero-dependency posture as the telemetry tracer and the media image driver.
16
+ * The protocol surface needed is small: enable two domains, navigate, evaluate.
17
+ */
18
+ import { rmSync } from "node:fs";
19
+ import { readFile } from "node:fs/promises";
20
+ import { join } from "node:path";
21
+ import { tmpdir } from "node:os";
22
+
23
+ /** Where Chrome lives, per platform. First hit wins. */
24
+ const CHROME_PATHS: Record<string, string[]> = {
25
+ win32: [
26
+ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
27
+ "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
28
+ "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
29
+ "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
30
+ ],
31
+ darwin: [
32
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
33
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
34
+ ],
35
+ linux: ["/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser"],
36
+ };
37
+
38
+ /**
39
+ * The browser binary to drive, or `null` when none is installed.
40
+ *
41
+ * `CHROME_PATH` overrides the search, which is how CI pins a specific build.
42
+ * Returning `null` rather than throwing lets a suite skip itself on a machine
43
+ * without a browser instead of failing — a browser test that cannot run is not
44
+ * the same as one that failed.
45
+ */
46
+ export function findChrome(): string | null {
47
+ const override = Bun.env["CHROME_PATH"];
48
+ if (override) return override;
49
+ for (const candidate of CHROME_PATHS[process.platform] ?? []) {
50
+ try {
51
+ if (Bun.file(candidate).size > 0) return candidate;
52
+ } catch {
53
+ /* not here — keep looking */
54
+ }
55
+ }
56
+ return null;
57
+ }
58
+
59
+ interface CdpMessage {
60
+ id?: number;
61
+ method?: string;
62
+ params?: Record<string, unknown>;
63
+ result?: Record<string, unknown>;
64
+ error?: { message: string };
65
+ }
66
+
67
+ /**
68
+ * One CDP connection to one page target.
69
+ *
70
+ * Requests are correlated by the `id` field the protocol echoes back, so several
71
+ * can be in flight; events (no `id`) are dispatched to listeners.
72
+ */
73
+ export class CdpSession {
74
+ private _nextId = 1;
75
+ private readonly _pending = new Map<
76
+ number,
77
+ { resolve: (v: Record<string, unknown>) => void; reject: (e: Error) => void }
78
+ >();
79
+ private readonly _listeners = new Map<string, Array<(params: Record<string, unknown>) => void>>();
80
+
81
+ private constructor(private readonly _ws: WebSocket) {}
82
+
83
+ static async connect(wsUrl: string, timeoutMs = 10_000): Promise<CdpSession> {
84
+ const ws = new WebSocket(wsUrl);
85
+ const session = new CdpSession(ws);
86
+
87
+ ws.addEventListener("message", (event: MessageEvent) => {
88
+ let msg: CdpMessage;
89
+ try {
90
+ msg = JSON.parse(String(event.data)) as CdpMessage;
91
+ } catch {
92
+ return;
93
+ }
94
+ if (typeof msg.id === "number") {
95
+ const pending = session._pending.get(msg.id);
96
+ if (!pending) return;
97
+ session._pending.delete(msg.id);
98
+ if (msg.error) pending.reject(new Error(msg.error.message));
99
+ else pending.resolve(msg.result ?? {});
100
+ return;
101
+ }
102
+ if (msg.method) {
103
+ for (const fn of session._listeners.get(msg.method) ?? []) fn(msg.params ?? {});
104
+ }
105
+ });
106
+
107
+ await new Promise<void>((resolve, reject) => {
108
+ const timer = setTimeout(() => reject(new Error("CDP connect timed out")), timeoutMs);
109
+ ws.addEventListener("open", () => {
110
+ clearTimeout(timer);
111
+ resolve();
112
+ });
113
+ ws.addEventListener("error", () => {
114
+ clearTimeout(timer);
115
+ reject(new Error("CDP socket error"));
116
+ });
117
+ });
118
+
119
+ return session;
120
+ }
121
+
122
+ /** Issue a command and await its result. */
123
+ send(method: string, params: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
124
+ const id = this._nextId++;
125
+ return new Promise((resolve, reject) => {
126
+ this._pending.set(id, { resolve, reject });
127
+ this._ws.send(JSON.stringify({ id, method, params }));
128
+ });
129
+ }
130
+
131
+ /** Subscribe to a protocol event. */
132
+ on(method: string, fn: (params: Record<string, unknown>) => void): void {
133
+ const list = this._listeners.get(method) ?? [];
134
+ list.push(fn);
135
+ this._listeners.set(method, list);
136
+ }
137
+
138
+ /** Resolve once `method` fires, or reject on timeout. */
139
+ once(method: string, timeoutMs = 10_000): Promise<Record<string, unknown>> {
140
+ return new Promise((resolve, reject) => {
141
+ const timer = setTimeout(
142
+ () => reject(new Error(`timed out waiting for ${method}`)),
143
+ timeoutMs,
144
+ );
145
+ this.on(method, (params) => {
146
+ clearTimeout(timer);
147
+ resolve(params);
148
+ });
149
+ });
150
+ }
151
+
152
+ close(): void {
153
+ try {
154
+ this._ws.close();
155
+ } catch {
156
+ /* already gone */
157
+ }
158
+ }
159
+ }
160
+
161
+ /** A launched browser process plus the endpoint to talk to it. */
162
+ export interface LaunchedBrowser {
163
+ session: CdpSession;
164
+ close(): Promise<void>;
165
+ }
166
+
167
+ /** Poll `fn` until it returns a value, or throw after `timeoutMs`. */
168
+ async function poll<T>(fn: () => Promise<T | null>, timeoutMs: number, label: string): Promise<T> {
169
+ const deadline = Date.now() + timeoutMs;
170
+ while (Date.now() < deadline) {
171
+ const value = await fn();
172
+ if (value !== null) return value;
173
+ await Bun.sleep(50);
174
+ }
175
+ throw new Error(`timed out waiting for ${label}`);
176
+ }
177
+
178
+ /**
179
+ * Launch headless Chrome and attach to its first page target.
180
+ *
181
+ * Port 0 lets the OS pick, and Chrome writes the chosen port into
182
+ * `DevToolsActivePort` in the profile directory — polling that file is more
183
+ * reliable than parsing stderr, which differs between Chrome and Edge builds.
184
+ */
185
+ export async function launchBrowser(chromePath: string): Promise<LaunchedBrowser> {
186
+ const profile = join(tmpdir(), `zt-flow-cdp-${Math.random().toString(36).slice(2)}`);
187
+ const proc = Bun.spawn(
188
+ [
189
+ chromePath,
190
+ "--headless=new",
191
+ "--remote-debugging-port=0",
192
+ `--user-data-dir=${profile}`,
193
+ "--no-first-run",
194
+ "--no-default-browser-check",
195
+ "--disable-gpu",
196
+ "--disable-dev-shm-usage",
197
+ // A sandboxed renderer cannot start as root in most CI images.
198
+ "--no-sandbox",
199
+ "about:blank",
200
+ ],
201
+ { stdout: "ignore", stderr: "ignore" },
202
+ );
203
+
204
+ const cleanup = async (): Promise<void> => {
205
+ try {
206
+ proc.kill();
207
+ await proc.exited;
208
+ } catch {
209
+ /* already dead */
210
+ }
211
+
212
+ // `proc.exited` resolving does not mean Windows has released the profile
213
+ // directory: Chrome's child processes linger briefly with open handles, and
214
+ // `rm` on a locked directory throws EBUSY. That surfaced as a browser test
215
+ // failing roughly half the time, in cleanup, long after the assertions had
216
+ // passed. Retry, then give up — a leftover directory under the OS temp path
217
+ // is not worth failing a closed session over.
218
+ try {
219
+ rmSync(profile, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 });
220
+ } catch {
221
+ /* still locked — the OS reclaims it with the rest of temp */
222
+ }
223
+ };
224
+
225
+ try {
226
+ const port = await poll(
227
+ async () => {
228
+ try {
229
+ const text = await readFile(join(profile, "DevToolsActivePort"), "utf8");
230
+ const first = text.split("\n")[0]?.trim();
231
+ return first && /^\d+$/.test(first) ? first : null;
232
+ } catch {
233
+ return null;
234
+ }
235
+ },
236
+ 60_000,
237
+ "Chrome to report its debugging port",
238
+ );
239
+
240
+ // The first page target is the `about:blank` tab we asked for.
241
+ const wsUrl = await poll(
242
+ async () => {
243
+ try {
244
+ const res = await fetch(`http://127.0.0.1:${port}/json/list`);
245
+ const targets = (await res.json()) as Array<{
246
+ type: string;
247
+ webSocketDebuggerUrl?: string;
248
+ }>;
249
+ return targets.find((t) => t.type === "page")?.webSocketDebuggerUrl ?? null;
250
+ } catch {
251
+ return null;
252
+ }
253
+ },
254
+ 60_000,
255
+ "a page target",
256
+ );
257
+
258
+ const session = await CdpSession.connect(wsUrl);
259
+ await session.send("Page.enable");
260
+ await session.send("Runtime.enable");
261
+
262
+ return {
263
+ session,
264
+ close: async () => {
265
+ session.close();
266
+ await cleanup();
267
+ },
268
+ };
269
+ } catch (err) {
270
+ await cleanup();
271
+ throw err;
272
+ }
273
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `@zerotal/flow/browser` — drive a real browser against a running Flow app.
3
+ *
4
+ * The counterpart to `@zerotal/flow/testing`. `FlowTest` exercises a component's
5
+ * logic by calling its actions directly; `FlowBrowser` exercises everything
6
+ * between a user's click and that action — the rendered attribute, the delegated
7
+ * listener, the WebSocket frame, the patch that comes back. Flow's silent
8
+ * failures have all lived in that gap, so the two are complementary rather than
9
+ * alternatives: unit-test behaviour with `FlowTest`, and pin the wiring with a
10
+ * handful of `FlowBrowser` tests.
11
+ *
12
+ * Needs a Chrome or Edge install (or `CHROME_PATH`). Guard a suite with
13
+ * {@link FlowBrowser.available} so it skips, rather than fails, without one.
14
+ *
15
+ * @packageDocumentation
16
+ */
17
+ export { FlowBrowser } from "./FlowBrowser.ts";
18
+ export type { OpenOptions } from "./FlowBrowser.ts";
19
+ export { findChrome } from "./cdp.ts";
@@ -1,6 +1,6 @@
1
1
  // ── Client-side component state model ────────────────────────────────────────
2
2
  //
3
- // Mirrors Livewire v3's three-copy state model:
3
+ // A three-copy state model:
4
4
  // canonical — last confirmed server state (used for diffing)
5
5
  // ephemeral — current local state (may have unsent mutations from flow:model)
6
6
  // reactive — Alpine.reactive(ephemeral) — what DOM expressions read