From f4f60ea9f2676d20edce1ef2fda27208d13e5e20 Mon Sep 17 00:00:00 2001
From: meetsu <96637888+klNuno@users.noreply.github.com>
Date: Mon, 19 Oct 2026 15:35:54 +0200
Subject: [PATCH 1/4] test(e2e): speak CDP through a WebSocket client of our
own
Bun 1.4.3's WebSocket client closes sound devtools streams with 1002 or 1011; #613's relay read the same bytes through Bun.connect without a fault. The E2E pages and the core tests' desktop host now use a small RFC 6455 text client over Bun.connect.
---
packages/core/test/browser/fixture.ts | 27 +--
tests/e2e/lib/cdp.ts | 45 ++--
tests/e2e/lib/ws-client.ts | 334 ++++++++++++++++++++++++++
tests/e2e/ws-client.test.ts | 228 ++++++++++++++++++
4 files changed, 586 insertions(+), 48 deletions(-)
create mode 100644 tests/e2e/lib/ws-client.ts
create mode 100644 tests/e2e/ws-client.test.ts
diff --git a/packages/core/test/browser/fixture.ts b/packages/core/test/browser/fixture.ts
index 03d59b68b..403b600c0 100644
--- a/packages/core/test/browser/fixture.ts
+++ b/packages/core/test/browser/fixture.ts
@@ -6,6 +6,7 @@ import type { RemoteBrowserFrame, RemoteBrowserUnchanged } from '@boite/contract
import { connect, type CoreClient } from '../../src/client.ts';
import { chromiumArgs, findChromium, waitForEndpoint } from '../../src/browser/chromium.ts';
import { echoThread, startTestCore, type TestCore } from '../harness.ts';
+import { TextSocket } from '../../../../tests/e2e/lib/ws-client.ts';
const PAGE = `
Fixture
@@ -116,7 +117,7 @@ export function createBrowserFixture() {
*/
async function fakeHost(name = 'shell', extraArgs: string[] = []) {
const dir = mkdtempSync(join(tmpdir(), 'boite-host-'));
- let browser: Bun.Subprocess | undefined, socket: WebSocket | undefined, app: CoreClient | undefined;
+ let browser: Bun.Subprocess | undefined, socket: TextSocket | undefined, app: CoreClient | undefined;
let closing: Promise | undefined;
const close = () => closing ??= (async () => {
const errors: unknown[] = [];
@@ -129,7 +130,7 @@ export function createBrowserFixture() {
// Browser.close lets Chromium's helpers release the profile before removal.
stuck = setTimeout(() => attempt(() => browser!.kill()), 10_000);
attempt(() => {
- if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ id: 2 ** 31 - 1, method: 'Browser.close', params: {} }));
+ if (socket?.open) socket.send(JSON.stringify({ id: 2 ** 31 - 1, method: 'Browser.close', params: {} }));
else browser!.kill();
});
}
@@ -148,27 +149,13 @@ export function createBrowserFixture() {
try {
const args = chromiumArgs(dir, 'linux').filter(arg => arg !== '--remote-debugging-pipe');
browser = Bun.spawn([findChromium().path!, ...args, '--remote-debugging-port=0', '--remote-debugging-address=127.0.0.1', ...extraArgs], { stdout: 'ignore', stderr: 'ignore' });
- const connectedSocket = socket = new WebSocket(await waitForEndpoint(dir, browser.exited));
- await new Promise((resolve, reject) => {
- const finish = (error?: Error) => {
- clearTimeout(timer);
- connectedSocket.removeEventListener('open', opened);
- connectedSocket.removeEventListener('error', failed);
- connectedSocket.removeEventListener('close', ended);
- if (error) reject(error); else resolve();
- };
- const opened = () => finish();
- const failed = () => finish(new Error('could not open the desktop browser DevTools socket'));
- const ended = () => finish(new Error('the desktop browser DevTools socket closed before opening'));
- const timer = setTimeout(() => finish(new Error('the desktop browser did not accept a DevTools connection within 10 seconds')), 10_000);
- connectedSocket.addEventListener('open', opened);
- connectedSocket.addEventListener('error', failed);
- connectedSocket.addEventListener('close', ended);
- });
+ // Bun's own WebSocket client closes sound devtools streams with 1002 (tests/e2e/lib/ws-client.ts).
+ const connectedSocket = socket = await TextSocket.connect(await waitForEndpoint(dir, browser.exited), 10_000)
+ .catch((error: unknown) => { throw new Error(`could not open the desktop browser DevTools socket: ${error instanceof Error ? error.message : String(error)}`, { cause: error }); });
const connectedApp = app = await connect(harness.url, harness.token, { client: { name, version: 'test' } });
const relayed: string[] = [];
connectedApp.on('browser.hostMessage', ({ profile, message }) => { relayed.push(profile); connectedSocket.send(message); });
- connectedSocket.addEventListener('message', event => void connectedApp.call('browser.hostReply', { profile: 'default', messages: [String(event.data)] }).catch(() => {}));
+ connectedSocket.onMessage(text => void connectedApp.call('browser.hostReply', { profile: 'default', messages: [text] }).catch(() => {}));
return { app: connectedApp, relayed, close };
} catch (error) {
try { await close(); }
diff --git a/tests/e2e/lib/cdp.ts b/tests/e2e/lib/cdp.ts
index f03659017..3e0c5fd34 100644
--- a/tests/e2e/lib/cdp.ts
+++ b/tests/e2e/lib/cdp.ts
@@ -5,6 +5,7 @@ import { dirname, join } from 'node:path';
import { ONBOARDING_STORAGE_KEY, ONBOARDING_VERSION } from '../../../packages/ui/src/lib/onboarding.ts';
import type { Subprocess } from 'bun';
import { E2E_DIR_PREFIX, killProcessTreeAsync, removeDirectory, watchParentDeath, type ParentDeathWatch } from './cleanup.ts';
+import { TextSocket } from './ws-client.ts';
/**
* Marks the tour seen before any script of the page runs, and only where no
@@ -156,7 +157,7 @@ async function stopBrowser(proc: Subprocess, userDataDir: string | null, watch:
}
export class BrowserPage {
- #socket: WebSocket;
+ #socket: TextSocket;
#proc: Subprocess | null;
#watch: ParentDeathWatch | null;
#userDataDir: string | null;
@@ -174,18 +175,16 @@ export class BrowserPage {
/** The browser's pid, null for a page `attach` drives. */
get pid(): number | null { return this.#proc?.pid ?? null; }
- private constructor(socket: WebSocket, proc: Subprocess | null, userDataDir: string | null, watch: ParentDeathWatch | null = null) {
+ private constructor(socket: TextSocket, proc: Subprocess | null, userDataDir: string | null, watch: ParentDeathWatch | null = null) {
this.#socket = socket;
this.#proc = proc;
this.#watch = watch;
this.#userDataDir = userDataDir;
- socket.addEventListener('message', (event: MessageEvent) => {
- this.#receive(typeof event.data === 'string' ? event.data : '');
- });
- socket.addEventListener('close', () => {
+ socket.onMessage((text) => this.#receive(text));
+ socket.onClose(({ code, reason }) => {
for (const pending of this.#pending.values()) {
clearTimeout(pending.timer);
- pending.reject(new Error('the devtools socket closed, the browser exited or was killed (bun test kills every process a file started when one of its tests or hooks times out)'));
+ pending.reject(new Error(`the devtools socket closed (${code} ${reason}), the browser exited or was killed (bun test kills every process a file started when one of its tests or hooks times out)`));
}
this.#pending.clear();
});
@@ -354,7 +353,7 @@ export class BrowserPage {
// waited out its whole timeout: after `bun test` killed the browser of a
// file whose test timed out, every later test of that file spent 20 s on a
// "Runtime.evaluate timed out" that looked like a hung renderer.
- if (this.#socket.readyState !== WebSocket.OPEN) {
+ if (!this.#socket.open) {
return Promise.reject(new Error(`the devtools socket closed before ${method}: the browser exited or was killed. bun test kills every process a file started when one of its tests or hooks times out, so look for the file's first failure`));
}
const id = this.#nextId++;
@@ -415,19 +414,19 @@ In flight: ${JSON.stringify(this.#inFlight())}`);
const deadline = Date.now() + timeoutMs;
let last = '';
while (Date.now() < deadline) {
- if (this.#closed || this.#socket.readyState !== WebSocket.OPEN) throw new Error('the browser is closed');
+ if (this.#closed || !this.#socket.open) throw new Error('the browser is closed');
try {
const remaining = Math.max(1, deadline - Date.now());
const ok = await this.evaluate(`(async () => !!(await (${expression})))()`, Math.min(CALL_TIMEOUT_MS, remaining));
if (ok) return;
last = 'it stayed false';
} catch (error) {
- if (this.#closed || this.#socket.readyState !== WebSocket.OPEN) throw error;
+ if (this.#closed || !this.#socket.open) throw error;
last = error instanceof Error ? error.message : String(error);
}
await Bun.sleep(Math.min(POLL_MS, Math.max(0, deadline - Date.now())));
}
- if (this.#closed || this.#socket.readyState !== WebSocket.OPEN) throw new Error('the browser is closed');
+ if (this.#closed || !this.#socket.open) throw new Error('the browser is closed');
// A separate, bounded grace preserves useful diagnostics after the condition consumes its budget.
const state = await this.evaluate(`({ location: location.origin + location.pathname, ready: document.readyState, title: document.title, text: document.body?.innerText.slice(0, 500) })`, WAIT_DIAGNOSTIC_TIMEOUT_MS).catch(() => 'page diagnostic timed out');
throw new Error(`waitFor timed out on ${expression}: ${last}\nPage: ${JSON.stringify(state)}\nErrors: ${JSON.stringify(this.#pageErrors)}\nIn flight: ${JSON.stringify(this.#inFlight())}\nFailed requests: ${JSON.stringify(this.#networkFailures)}`);
@@ -706,23 +705,13 @@ export async function probeDebuggingPort(port: number): Promise {
}
}
-function openSocket(target: TargetInfo): Promise {
+/** Bun's own WebSocket client closes sound devtools streams with 1002: see `ws-client.ts`. */
+async function openSocket(target: TargetInfo): Promise {
const url = target.webSocketDebuggerUrl;
if (url === undefined) throw new Error('the page target has no debugger url');
- return new Promise((resolve, reject) => {
- const socket = new WebSocket(url);
- // A socket that never opened is closed with its refusal: nothing else
- // holds it, and a late open would keep its connection for the whole run.
- const fail = (message: string) => {
- clearTimeout(timer);
- socket.close();
- reject(new Error(message));
- };
- const timer = setTimeout(() => fail('the devtools socket did not open'), CONNECT_TIMEOUT_MS);
- socket.addEventListener('open', () => {
- clearTimeout(timer);
- resolve(socket);
- }, { once: true });
- socket.addEventListener('error', () => fail('the devtools socket failed to open'), { once: true });
- });
+ try {
+ return await TextSocket.connect(url, CONNECT_TIMEOUT_MS);
+ } catch (error) {
+ throw new Error(`the devtools socket did not open: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
+ }
}
diff --git a/tests/e2e/lib/ws-client.ts b/tests/e2e/lib/ws-client.ts
new file mode 100644
index 000000000..4e0a53272
--- /dev/null
+++ b/tests/e2e/lib/ws-client.ts
@@ -0,0 +1,334 @@
+/*
+ * A WebSocket client for devtools sockets, on `Bun.connect`. Bun's own client
+ * (1.4.2 and 1.4.3) closes sound devtools streams with 1002 ("control frame is
+ * fragmented", "unsupported control frame", "unexpected opcode") or 1011
+ * ("Compression not implemented yet"), with or without permessage-deflate:
+ * #613's relay checked every frame Chrome sent on those runs and found them
+ * well-formed (#623, #635). The relay read the same bytes through
+ * `Bun.connect` without a fault, so this client parses them itself.
+ *
+ * RFC 6455 for text messages only: no extension is offered, server frames
+ * must be unmasked with their reserved bits clear, fragmented messages are
+ * joined, pings are answered and a close is echoed.
+ */
+
+import { createHash, randomBytes } from 'node:crypto';
+
+const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
+/** A devtools reply carrying a screenshot is a few MB; past this the stream is taken as corrupt. */
+const MAX_MESSAGE_BYTES = 256 << 20;
+/** How long a closing socket waits for the server's close before it hangs up. */
+const CLOSE_GRACE_MS = 1_000;
+
+export type Frame =
+ | { kind: 'text'; text: string }
+ | { kind: 'ping'; payload: Uint8Array }
+ | { kind: 'pong' }
+ | { kind: 'close'; code: number; reason: string }
+ /** The stream broke here: what came before it stands, nothing after it is read. */
+ | { kind: 'invalid'; reason: string };
+
+/** Turns the bytes a server sent, cut anywhere, into messages; a malformed stream ends in one `invalid` frame. */
+export class FrameReader {
+ /** Bytes of a frame not complete yet, kept as they came, and how many the next parse needs. */
+ #held: Uint8Array[] = [];
+ #heldBytes = 0;
+ #need = 2;
+ /** The fragments of a text message still waiting for its final frame. */
+ #parts: Uint8Array[] = [];
+ #partBytes = 0;
+ #fragmented = false;
+ #decoder = new TextDecoder('utf-8', { fatal: true });
+ #broken = false;
+
+ push(bytes: Uint8Array): Frame[] {
+ if (this.#broken) return [];
+ const frames: Frame[] = [];
+ try {
+ this.#read(bytes, frames);
+ } catch (error) {
+ if (!(error instanceof ProtocolError)) throw error;
+ this.#broken = true;
+ this.#held = []; this.#heldBytes = 0;
+ frames.push({ kind: 'invalid', reason: error.message });
+ }
+ return frames;
+ }
+
+ #read(bytes: Uint8Array, frames: Frame[]): void {
+ // The caller's buffer may be reused once this returns: what is held is a copy.
+ if (this.#heldBytes + bytes.length < this.#need) {
+ this.#held.push(bytes.slice());
+ this.#heldBytes += bytes.length;
+ return;
+ }
+ const joined = this.#held.length === 0 ? bytes : concat([...this.#held, bytes]);
+ this.#held = []; this.#heldBytes = 0;
+ let at = 0;
+ for (;;) {
+ const rest = joined.length - at;
+ this.#need = 2;
+ if (rest < 2) break;
+ const first = joined[at]!, second = joined[at + 1]!;
+ const fin = (first & 0x80) !== 0, opcode = first & 0x0f;
+ if (first & 0x70) throw new ProtocolError(`reserved bits ${(first >> 4) & 7} set on opcode ${opcode} with no extension negotiated`);
+ if (second & 0x80) throw new ProtocolError('the server masked a frame');
+ let length = second & 0x7f, header = 2;
+ if (length === 126) {
+ this.#need = 4;
+ if (rest < 4) break;
+ length = (joined[at + 2]! << 8) | joined[at + 3]!;
+ header = 4;
+ } else if (length === 127) {
+ this.#need = 10;
+ if (rest < 10) break;
+ if (joined[at + 2]! & 0x80) throw new ProtocolError('a frame length with its top bit set');
+ length = 0;
+ for (let i = 2; i < 10; i++) length = length * 256 + joined[at + i]!;
+ header = 10;
+ }
+ if (opcode >= 8 && (!fin || length > 125)) throw new ProtocolError(`a fragmented or ${length} B control frame (opcode ${opcode})`);
+ if (length > MAX_MESSAGE_BYTES) throw new ProtocolError(`a ${length} B frame`);
+ this.#need = header + length;
+ if (rest < this.#need) break;
+ const payload = joined.subarray(at + header, at + header + length);
+ at += header + length;
+ const frame = this.#frame(fin, opcode, payload);
+ if (frame !== null) frames.push(frame);
+ }
+ if (at < joined.length) { this.#held = [joined.slice(at)]; this.#heldBytes = joined.length - at; }
+ }
+
+ #frame(fin: boolean, opcode: number, payload: Uint8Array): Frame | null {
+ switch (opcode) {
+ case 0x0:
+ if (!this.#fragmented) throw new ProtocolError('a continuation frame with no message to continue');
+ return this.#data(fin, payload);
+ case 0x1:
+ if (this.#fragmented) throw new ProtocolError('a text frame inside a fragmented message');
+ return this.#data(fin, payload);
+ case 0x2:
+ throw new ProtocolError('a binary message on a devtools socket');
+ case 0x8: {
+ if (payload.length === 1) throw new ProtocolError('a close frame of 1 B');
+ const code = payload.length >= 2 ? (payload[0]! << 8) | payload[1]! : 1005;
+ return { kind: 'close', code, reason: this.#text(payload.subarray(2)) };
+ }
+ case 0x9:
+ return { kind: 'ping', payload: payload.slice() };
+ case 0xa:
+ return { kind: 'pong' };
+ default:
+ throw new ProtocolError(`opcode ${opcode}`);
+ }
+ }
+
+ #data(fin: boolean, payload: Uint8Array): Frame | null {
+ if (this.#partBytes + payload.length > MAX_MESSAGE_BYTES) throw new ProtocolError(`a message over ${MAX_MESSAGE_BYTES} B`);
+ if (!fin) {
+ this.#fragmented = true;
+ this.#parts.push(payload.slice());
+ this.#partBytes += payload.length;
+ return null;
+ }
+ const whole = this.#parts.length === 0 ? payload : concat([...this.#parts, payload]);
+ this.#parts = []; this.#partBytes = 0; this.#fragmented = false;
+ return { kind: 'text', text: this.#text(whole) };
+ }
+
+ #text(bytes: Uint8Array): string {
+ try {
+ return this.#decoder.decode(bytes);
+ } catch {
+ throw new ProtocolError('a text message that is not UTF-8');
+ }
+ }
+}
+
+class ProtocolError extends Error {}
+
+/** One client frame, masked as RFC 6455 requires of a client. */
+export function encodeFrame(opcode: number, payload: Uint8Array, mask: Uint8Array = randomBytes(4)): Uint8Array {
+ const n = payload.length;
+ const header = n < 126 ? 2 : n < 65_536 ? 4 : 10;
+ const out = new Uint8Array(header + 4 + n);
+ out[0] = 0x80 | opcode;
+ if (n < 126) out[1] = 0x80 | n;
+ else if (n < 65_536) { out[1] = 0x80 | 126; out[2] = n >> 8; out[3] = n & 0xff; }
+ else {
+ out[1] = 0x80 | 127;
+ let rest = n;
+ for (let i = 9; i >= 2; i--) { out[i] = rest % 256; rest = Math.floor(rest / 256); }
+ }
+ out.set(mask, header);
+ for (let i = 0; i < n; i++) out[header + 4 + i] = payload[i]! ^ mask[i & 3]!;
+ return out;
+}
+
+export interface CloseEvent { code: number; reason: string }
+
+/** An open devtools socket: text in and out, one close event. */
+export class TextSocket {
+ #socket: Bun.Socket;
+ #queue: Uint8Array[] = [];
+ #open = true;
+ #closed = false;
+ #onMessage: ((text: string) => void) | null = null;
+ /** Messages that came before the reader listened: those sent with the upgrade's answer. */
+ #inbox: string[] = [];
+ #onClose: Array<(event: CloseEvent) => void> = [];
+ #closeEvent: CloseEvent | null = null;
+ #graceTimer: ReturnType | undefined;
+
+ private constructor(socket: Bun.Socket) { this.#socket = socket; }
+
+ /** Whether `send` still reaches the server. */
+ get open(): boolean { return this.#open; }
+
+ /** Only one listener: a devtools socket has one reader. It gets what arrived before it first. */
+ onMessage(listener: (text: string) => void): void {
+ this.#onMessage = listener;
+ for (const text of this.#inbox.splice(0)) listener(text);
+ }
+
+ /** Called once; at once when the socket already closed. */
+ onClose(listener: (event: CloseEvent) => void): void {
+ if (this.#closeEvent !== null) listener(this.#closeEvent);
+ else this.#onClose.push(listener);
+ }
+
+ #deliver(text: string): void {
+ if (this.#onMessage === null) this.#inbox.push(text);
+ else this.#onMessage(text);
+ }
+
+ send(text: string): void {
+ if (!this.#open) return;
+ this.#write(encodeFrame(0x1, new TextEncoder().encode(text)));
+ }
+
+ /** Sends a close and hangs up once the server answers, or after `CLOSE_GRACE_MS`. */
+ close(code = 1000): void {
+ if (!this.#open) return;
+ this.#open = false;
+ this.#write(encodeFrame(0x8, new Uint8Array([code >> 8, code & 0xff])));
+ this.#graceTimer = setTimeout(() => this.#ended({ code, reason: 'the server did not answer the close' }), CLOSE_GRACE_MS);
+ }
+
+ #write(bytes: Uint8Array): void {
+ this.#queue.push(bytes);
+ if (this.#queue.length === 1) this.#flush();
+ }
+
+ #flush(): void {
+ while (this.#queue.length > 0) {
+ const chunk = this.#queue[0]!, written = this.#socket.write(chunk);
+ // Shut or failed for good (`-errno`): what waits would never leave.
+ if (written < 0) { this.#queue.length = 0; this.#ended({ code: 1006, reason: `the connection failed (${written})` }); return; }
+ if (written < chunk.length) { this.#queue[0] = chunk.subarray(written); return; }
+ this.#queue.shift();
+ }
+ }
+
+ #ended(event: CloseEvent): void {
+ if (this.#closed) return;
+ this.#closed = true;
+ this.#closeEvent = event;
+ this.#open = false;
+ clearTimeout(this.#graceTimer);
+ this.#socket.end();
+ for (const listener of this.#onClose) listener(event);
+ }
+
+ /**
+ * Connects, upgrades and resolves once the server accepted. Rejects with the
+ * server's answer when it refuses, and after `timeoutMs` when it says nothing.
+ */
+ static connect(url: string, timeoutMs: number): Promise {
+ const target = new URL(url);
+ if (target.protocol !== 'ws:') return Promise.reject(new Error(`only ws:// devtools urls are supported, got ${target.protocol}`));
+ const key = randomBytes(16).toString('base64');
+ const accept = createHash('sha1').update(key + GUID).digest('base64');
+ return new Promise((resolve, reject) => {
+ let client: TextSocket | null = null;
+ let handshake: Uint8Array = new Uint8Array(0);
+ const reader = new FrameReader();
+ let settled = false;
+ const fail = (socket: Bun.Socket | null, message: string) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ socket?.end();
+ reject(new Error(message));
+ };
+ const receive = (bytes: Uint8Array) => {
+ for (const frame of reader.push(bytes)) {
+ if (client!.#closed) return;
+ if (frame.kind === 'text') client!.#deliver(frame.text);
+ else if (frame.kind === 'ping') { if (client!.#open) client!.#write(encodeFrame(0xa, frame.payload)); }
+ else if (frame.kind === 'close') {
+ // Echo the close unless this side sent one first, then hang up.
+ if (client!.#open) {
+ client!.#open = false;
+ client!.#write(encodeFrame(0x8, frame.code === 1005 ? new Uint8Array(0) : new Uint8Array([frame.code >> 8, frame.code & 0xff])));
+ }
+ client!.#ended({ code: frame.code, reason: frame.reason });
+ } else if (frame.kind === 'invalid') {
+ client!.#write(encodeFrame(0x8, new Uint8Array([1002 >> 8, 1002 & 0xff])));
+ client!.#ended({ code: 1002, reason: frame.reason });
+ }
+ }
+ };
+ const timer = setTimeout(() => fail(null, `no WebSocket upgrade from ${target.host} within ${timeoutMs} ms`), timeoutMs);
+ Bun.connect({
+ hostname: target.hostname,
+ port: Number(target.port || 80),
+ socket: {
+ open(socket) {
+ socket.write(`GET ${target.pathname}${target.search} HTTP/1.1\r\nHost: ${target.host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\n\r\n`);
+ },
+ data(socket, bytes) {
+ if (client !== null) { receive(bytes); return; }
+ if (settled) return;
+ handshake = concat([handshake, bytes]);
+ const end = headerEnd(handshake);
+ if (end === -1) {
+ if (handshake.length > 16_384) fail(socket, 'the WebSocket upgrade answer has no end');
+ return;
+ }
+ const head = new TextDecoder().decode(handshake.subarray(0, end));
+ const status = head.split('\r\n', 1)[0] ?? '';
+ if (!/^HTTP\/1\.1 101\b/.test(status)) { fail(socket, `the server refused the WebSocket upgrade: ${status}`); return; }
+ if (/^sec-websocket-accept:\s*(\S+)/im.exec(head)?.[1] !== accept) { fail(socket, 'the server answered the upgrade with a wrong Sec-WebSocket-Accept'); return; }
+ const extensions = /^sec-websocket-extensions:\s*(.*)$/im.exec(head)?.[1]?.trim();
+ if (extensions) { fail(socket, `the server turned on extensions nobody offered: ${extensions}`); return; }
+ settled = true;
+ clearTimeout(timer);
+ client = new TextSocket(socket);
+ resolve(client);
+ if (end < handshake.length) receive(handshake.subarray(end));
+ },
+ drain() { if (client !== null) client.#flush(); },
+ end() { if (client) client.#ended({ code: 1006, reason: 'the server hung up' }); else fail(null, 'the server hung up during the WebSocket upgrade'); },
+ close() { if (client) client.#ended({ code: 1006, reason: 'the connection closed' }); else fail(null, 'the connection closed during the WebSocket upgrade'); },
+ error(_socket, error) { if (client) client.#ended({ code: 1006, reason: error.message }); else fail(null, `the WebSocket connection failed: ${error.message}`); },
+ connectError(_socket, error) { fail(null, `could not connect to ${target.host}: ${error.message}`); },
+ },
+ }).catch((error: unknown) => fail(null, `could not connect to ${target.host}: ${error instanceof Error ? error.message : String(error)}`));
+ });
+ }
+}
+
+function concat(parts: Uint8Array[]): Uint8Array {
+ let size = 0;
+ for (const part of parts) size += part.length;
+ const out = new Uint8Array(size);
+ let at = 0;
+ for (const part of parts) { out.set(part, at); at += part.length; }
+ return out;
+}
+
+function headerEnd(bytes: Uint8Array): number {
+ for (let i = 3; i < bytes.length; i++) if (bytes[i - 3] === 13 && bytes[i - 2] === 10 && bytes[i - 1] === 13 && bytes[i] === 10) return i + 1;
+ return -1;
+}
diff --git a/tests/e2e/ws-client.test.ts b/tests/e2e/ws-client.test.ts
new file mode 100644
index 000000000..3ebc2ba7e
--- /dev/null
+++ b/tests/e2e/ws-client.test.ts
@@ -0,0 +1,228 @@
+import { expect, test } from 'bun:test';
+import { createHash } from 'node:crypto';
+import { encodeFrame, FrameReader, TextSocket, type CloseEvent, type Frame } from './lib/ws-client.ts';
+
+/** A server frame: unmasked, final unless told otherwise. */
+function serverFrame(opcode: number, payload: Uint8Array | string, fin = true, rsv = 0): Uint8Array {
+ const body = typeof payload === 'string' ? new TextEncoder().encode(payload) : payload;
+ const n = body.length;
+ const head = n < 126 ? [n] : n < 65_536 ? [126, n >> 8, n & 0xff] : [127, 0, 0, 0, 0, (n >>> 24) & 0xff, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
+ const out = new Uint8Array(1 + head.length + n);
+ out[0] = (fin ? 0x80 : 0) | (rsv << 4) | opcode;
+ out.set(head, 1);
+ out.set(body, 1 + head.length);
+ return out;
+}
+
+function join(parts: Uint8Array[]): Uint8Array {
+ const out = new Uint8Array(parts.reduce((n, part) => n + part.length, 0));
+ let at = 0;
+ for (const part of parts) { out.set(part, at); at += part.length; }
+ return out;
+}
+
+/** Feeds `bytes` in slices of `cut(at)` bytes, the way TCP reads may arrive. */
+function feed(bytes: Uint8Array, cut: (at: number) => number): Frame[] {
+ const reader = new FrameReader();
+ const frames: Frame[] = [];
+ for (let at = 0; at < bytes.length;) {
+ const n = Math.max(1, Math.min(cut(at), bytes.length - at));
+ // A copy that is overwritten after the push, as Bun reuses its read buffer.
+ const slice = bytes.slice(at, at + n);
+ frames.push(...reader.push(slice));
+ slice.fill(0xee);
+ at += n;
+ }
+ return frames;
+}
+
+function seeded(seed: number): () => number {
+ return () => { seed = (seed * 1_103_515_245 + 12_345) & 0x7fffffff; return seed / 0x7fffffff; };
+}
+
+const large = 'x'.repeat(300_000);
+const stream = join([
+ serverFrame(1, '{"id":1}'),
+ serverFrame(1, 'm'.repeat(200)),
+ serverFrame(1, large),
+ // A message in three fragments with a ping between them, and a character split across two.
+ serverFrame(1, new Uint8Array([0x68, 0xc3]), false),
+ serverFrame(9, 'are you there'),
+ serverFrame(0, new Uint8Array([0xa9, 0x20]), false),
+ serverFrame(0, 'end'),
+ serverFrame(8, join([new Uint8Array([0x03, 0xe8]), new TextEncoder().encode('bye')])),
+]);
+const expected: Frame[] = [
+ { kind: 'text', text: '{"id":1}' },
+ { kind: 'text', text: 'm'.repeat(200) },
+ { kind: 'text', text: large },
+ { kind: 'ping', payload: new TextEncoder().encode('are you there') },
+ { kind: 'text', text: 'hé end' },
+ { kind: 'close', code: 1000, reason: 'bye' },
+];
+
+test('the frame reader gives the same messages whatever the reads cut', () => {
+ expect(feed(stream, () => stream.length)).toEqual(expected);
+ expect(feed(stream, () => 1)).toEqual(expected);
+ for (const seed of [1, 2, 3, 4, 5]) {
+ const random = seeded(seed);
+ expect(feed(stream, () => 1 + Math.floor(random() * (random() < 0.3 ? 12 : 9_000)))).toEqual(expected);
+ }
+}, 30_000);
+
+test('the frame reader refuses what Bun mistook for frames: reserved bits, masks, long or split control frames', () => {
+ const refusal = (bytes: Uint8Array) => {
+ const frames = new FrameReader().push(bytes);
+ const last = frames.at(-1);
+ return last?.kind === 'invalid' ? last.reason : 'accepted';
+ };
+ // The 1011 "Compression not implemented yet" close: RSV1 with no extension negotiated.
+ expect(refusal(serverFrame(1, '{}', true, 4))).toContain('reserved bits');
+ const masked = serverFrame(1, '{}'); masked[1]! |= 0x80;
+ expect(refusal(masked)).toBe('the server masked a frame');
+ expect(refusal(serverFrame(9, 'p', false))).toContain('fragmented');
+ expect(refusal(serverFrame(9, 'p'.repeat(126)))).toContain('control frame');
+ expect(refusal(serverFrame(0, 'orphan'))).toContain('continuation');
+ expect(refusal(join([serverFrame(1, 'a', false), serverFrame(1, 'b')]))).toContain('inside a fragmented message');
+ expect(refusal(serverFrame(3, 'x'))).toBe('opcode 3');
+ expect(refusal(serverFrame(1, new Uint8Array([0xc3, 0x28])))).toContain('not UTF-8');
+ // What came before the fault stands; nothing after it is read.
+ const reader = new FrameReader();
+ expect(reader.push(join([serverFrame(1, 'kept'), serverFrame(1, '{}', true, 4), serverFrame(1, 'lost')]))).toEqual([
+ { kind: 'text', text: 'kept' },
+ { kind: 'invalid', reason: 'reserved bits 4 set on opcode 1 with no extension negotiated' },
+ ]);
+ expect(reader.push(serverFrame(1, 'later'))).toEqual([]);
+});
+
+test('client frames are masked with their length in the right width', () => {
+ const mask = new Uint8Array([1, 2, 3, 4]);
+ for (const size of [0, 125, 126, 65_535, 65_536, 300_000]) {
+ const payload = new Uint8Array(size).map((_, i) => i & 0xff);
+ const frame = encodeFrame(1, payload, mask);
+ expect(frame[0]).toBe(0x81);
+ expect(frame[1]! & 0x80).toBe(0x80);
+ const header = size < 126 ? 2 : size < 65_536 ? 4 : 10;
+ expect(frame.length).toBe(header + 4 + size);
+ const unmasked = frame.subarray(header + 4).map((byte, i) => byte ^ mask[i & 3]!);
+ expect(unmasked).toEqual(payload);
+ }
+});
+
+test('a socket on a Bun server exchanges large and many messages, answers pings and reports the close', async () => {
+ const sizes = Array.from({ length: 400 }, (_, i) => (i % 150 === 0 ? 1_000_000 : (i * 7_919) % 8_000));
+ const echoed: string[] = [];
+ let pong = false;
+ let clientClose: number | undefined;
+ const server = Bun.serve({
+ hostname: '127.0.0.1', port: 0,
+ fetch: (request, self) => (self.upgrade(request) ? undefined : new Response('upgrade required', { status: 426 })),
+ websocket: {
+ perMessageDeflate: true,
+ // Everything is sent at once: past the default 16 MB Bun drops messages.
+ backpressureLimit: 64 << 20,
+ open(socket) {
+ socket.ping('hello');
+ for (const [id, size] of sizes.entries()) socket.send(JSON.stringify({ id, pad: 'é'.repeat(size) }));
+ },
+ message(socket, text) {
+ echoed.push(String(text));
+ if (echoed.length === sizes.length) socket.close(4000, 'done');
+ },
+ pong() { pong = true; },
+ close(_socket, code) { clientClose = code; },
+ },
+ });
+ try {
+ const socket = await TextSocket.connect(`ws://127.0.0.1:${server.port}/devtools/page/a`, 5_000);
+ const ids: number[] = [];
+ socket.onMessage((text) => {
+ const message = JSON.parse(text) as { id: number; pad: string };
+ expect(message.pad.length).toBe(sizes[message.id]!);
+ ids.push(message.id);
+ socket.send(JSON.stringify({ id: message.id, method: 'Runtime.evaluate' }));
+ });
+ const closed = await new Promise((resolve) => socket.onClose(resolve));
+ expect(closed).toEqual({ code: 4000, reason: 'done' });
+ expect(ids).toEqual(sizes.map((_, id) => id));
+ expect(echoed.length).toBe(sizes.length);
+ expect(pong).toBe(true);
+ expect(socket.open).toBe(false);
+ } finally { await server.stop(true); }
+ expect(clientClose).toBe(4000);
+}, 30_000);
+
+test('a socket closed by the client sends 1000 and ends once the server answers', async () => {
+ let seen: number | undefined;
+ const server = Bun.serve({
+ hostname: '127.0.0.1', port: 0,
+ fetch: (request, self) => (self.upgrade(request) ? undefined : new Response('no', { status: 426 })),
+ websocket: { message() {}, close(_socket, code) { seen = code; } },
+ });
+ try {
+ const socket = await TextSocket.connect(`ws://127.0.0.1:${server.port}/`, 5_000);
+ const closed = new Promise((resolve) => socket.onClose(resolve));
+ socket.close();
+ expect(socket.open).toBe(false);
+ expect((await closed).code).toBe(1000);
+ } finally { await server.stop(true); }
+ expect(seen).toBe(1000);
+});
+
+test('a raw server whose frames arrive cut anywhere is read whole, and a malformed one is closed with 1002', async () => {
+ const random = seeded(7);
+ const messages = Array.from({ length: 3_000 }, (_, id) => JSON.stringify({ id, pad: 'a'.repeat(random() < 0.05 ? 70_000 : Math.floor(random() * 3_000)) }));
+ const frames = join(messages.map((text) => serverFrame(1, text)));
+ const serve = (body: Uint8Array) => Bun.listen({
+ hostname: '127.0.0.1', port: 0,
+ socket: {
+ data(socket, bytes) {
+ const key = /sec-websocket-key:\s*(\S+)/i.exec(new TextDecoder().decode(bytes))?.[1];
+ if (key === undefined) return;
+ const accept = createHash('sha1').update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest('base64');
+ const all = join([new TextEncoder().encode(`HTTP/1.1 101 WebSocket Protocol Handshake\r\nUpgrade: WebSocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`), body]);
+ void (async () => {
+ for (let at = 0; at < all.length;) {
+ const part = all.subarray(at, at + 1 + Math.floor(random() * (random() < 0.3 ? 8 : 9_000)));
+ at += part.length;
+ for (let off = 0; off < part.length;) {
+ const written = socket.write(part.subarray(off));
+ if (written <= 0) await Bun.sleep(1); else off += written;
+ }
+ if (random() < 0.2) await Bun.sleep(0);
+ }
+ })();
+ },
+ },
+ });
+ const sound = serve(frames);
+ try {
+ const socket = await TextSocket.connect(`ws://127.0.0.1:${sound.port}/devtools/page/b`, 5_000);
+ const received: string[] = [];
+ await new Promise((resolve) => socket.onMessage((text) => { received.push(text); if (received.length === messages.length) resolve(); }));
+ expect(received).toEqual(messages);
+ socket.close();
+ } finally { sound.stop(true); }
+
+ const broken = serve(join([serverFrame(1, '{"id":1}'), serverFrame(1, '{"id":2}', true, 4)]));
+ try {
+ const socket = await TextSocket.connect(`ws://127.0.0.1:${broken.port}/`, 5_000);
+ const received: string[] = [];
+ socket.onMessage((text) => received.push(text));
+ const closed = await new Promise((resolve) => socket.onClose(resolve));
+ expect(closed.code).toBe(1002);
+ expect(closed.reason).toContain('reserved bits');
+ expect(received).toEqual(['{"id":1}']);
+ } finally { broken.stop(true); }
+}, 30_000);
+
+test('an upgrade the server refuses or never answers rejects with what happened', async () => {
+ const refusing = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Response('no page', { status: 404 }) });
+ try {
+ await expect(TextSocket.connect(`ws://127.0.0.1:${refusing.port}/devtools/page/gone`, 5_000)).rejects.toThrow('refused the WebSocket upgrade: HTTP/1.1 404');
+ } finally { await refusing.stop(true); }
+ const silent = Bun.listen({ hostname: '127.0.0.1', port: 0, socket: { data() {} } });
+ try {
+ await expect(TextSocket.connect(`ws://127.0.0.1:${silent.port}/`, 300)).rejects.toThrow('no WebSocket upgrade');
+ } finally { silent.stop(true); }
+});
From 52808cf325f89033bdaa06a48ccbd47bb48fab3e Mon Sep 17 00:00:00 2001
From: meetsu <96637888+klNuno@users.noreply.github.com>
Date: Mon, 19 Oct 2026 15:35:55 +0200
Subject: [PATCH 2/4] test(e2e): say what an attached shell page that never
gets its bridge was doing
A TAURI_READY timeout now reports the attached target, /json/list, the frame tree, navigation history and the shell's shell.webview.* records, and writes them to the desktop failure artifact.
---
tests/e2e/browser-native.test.ts | 4 +-
tests/e2e/browser-profiles.test.ts | 4 +-
tests/e2e/lib/browser-session.ts | 4 +-
tests/e2e/lib/cdp.ts | 14 +++--
tests/e2e/lib/shell-diagnosis.ts | 94 +++++++++++++++++++++++++++---
tests/e2e/shell-diagnosis.test.ts | 45 ++++++++++++++
tests/e2e/shell.test.ts | 45 +++++++++-----
7 files changed, 175 insertions(+), 35 deletions(-)
create mode 100644 tests/e2e/shell-diagnosis.test.ts
diff --git a/tests/e2e/browser-native.test.ts b/tests/e2e/browser-native.test.ts
index a8d50bd97..9e185b222 100644
--- a/tests/e2e/browser-native.test.ts
+++ b/tests/e2e/browser-native.test.ts
@@ -3,7 +3,7 @@ import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { test, expect } from 'bun:test';
import { BrowserPage, freePort } from './lib/cdp.ts';
-import { describeShell } from './lib/shell-diagnosis.ts';
+import { describeShell, waitForShellPage } from './lib/shell-diagnosis.ts';
import { killProcessTreeAsync, removeDirectory } from './lib/cleanup.ts';
import { connect } from '../../packages/core/src/client.ts';
import { runCli } from '../../packages/core/src/cli.ts';
@@ -32,7 +32,7 @@ test.skip('native browser fills its panel and floats inside the app without losi
const account = (await client.call('accounts.list', {})).find(a => a.providerId === 'echo')!;
const thread = await client.call('threads.create', { projectId: project.id, providerId: 'echo', accountId: account.id, title: 'Browser validation' });
page = await BrowserPage.attach(port, '', failure => describeShell(dataDir, shell.pid, failure));
- await page.waitFor("typeof window.__TAURI_INTERNALS__?.invoke === 'function'");
+ await waitForShellPage(page, { port, dataDir, pid: shell.pid });
await page.evaluate(`localStorage.setItem('boite.onboarding', JSON.stringify({version:6, at:Date.now()})); location.reload();`);
await page.waitFor("document.querySelector('[data-testid=thread-row]')");
await page.click('[data-testid=thread-row]');
diff --git a/tests/e2e/browser-profiles.test.ts b/tests/e2e/browser-profiles.test.ts
index 2c956d8f5..e257d6994 100644
--- a/tests/e2e/browser-profiles.test.ts
+++ b/tests/e2e/browser-profiles.test.ts
@@ -3,7 +3,7 @@ import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { test, expect } from 'bun:test';
import { BrowserPage, freePort } from './lib/cdp.ts';
-import { describeShell } from './lib/shell-diagnosis.ts';
+import { describeShell, waitForShellPage } from './lib/shell-diagnosis.ts';
import { killProcessTreeAsync, removeDirectory } from './lib/cleanup.ts';
import { connect } from '../../packages/core/src/client.ts';
import type { BrowserAction } from '../../packages/contracts/src/index.ts';
@@ -47,7 +47,7 @@ test.skip('browser profiles keep their own cookies across a restart until one is
}
if (!client) throw new Error('core did not start');
page = await BrowserPage.attach(port, '', failure => describeShell(dataDir, shell!.pid, failure));
- await page.waitFor("typeof window.__TAURI_INTERNALS__?.invoke === 'function'");
+ await waitForShellPage(page, { port, dataDir, pid: shell!.pid });
};
const openThread = async (threadId: string) => {
await page!.waitFor("document.querySelector('[data-testid=thread-row]')");
diff --git a/tests/e2e/lib/browser-session.ts b/tests/e2e/lib/browser-session.ts
index 3b2772f11..eda18aef5 100644
--- a/tests/e2e/lib/browser-session.ts
+++ b/tests/e2e/lib/browser-session.ts
@@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { BrowserPage, freePort } from './cdp.ts';
-import { describeShell } from './shell-diagnosis.ts';
+import { describeShell, waitForShellPage } from './shell-diagnosis.ts';
import { killProcessTreeAsync, removeDirectory } from './cleanup.ts';
import { connect } from '../../../packages/core/src/client.ts';
import type { BrowserAction } from '../../../packages/contracts/src/index.ts';
@@ -37,7 +37,7 @@ export async function startBrowserSession(executable: string, title = 'Boite ·
const account = (await client.call('accounts.list', {})).find(a => a.providerId === 'echo')!;
const thread = await client.call('threads.create', { projectId: project.id, providerId: 'echo', accountId: account.id, title });
page = await BrowserPage.attach(port, '', failure => describeShell(dataDir, shell.pid, failure));
- await page.waitFor("typeof window.__TAURI_INTERNALS__?.invoke === 'function'");
+ await waitForShellPage(page, { port, dataDir, pid: shell.pid });
await page.evaluate(`localStorage.setItem('boite.onboarding', JSON.stringify({version:6,at:Date.now()})); localStorage.setItem('boite.locale', 'fr'); localStorage.setItem('boite.experiments', ${JSON.stringify(JSON.stringify(experiments))}); location.reload();`);
await page.waitFor("document.querySelector('[data-testid=thread-row]')");
await page.click('[data-testid=thread-row]');
diff --git a/tests/e2e/lib/cdp.ts b/tests/e2e/lib/cdp.ts
index 3e0c5fd34..616a04e84 100644
--- a/tests/e2e/lib/cdp.ts
+++ b/tests/e2e/lib/cdp.ts
@@ -90,7 +90,8 @@ export function freePort(): Promise {
});
}
-interface TargetInfo {
+export interface TargetInfo {
+ id?: string;
type: string;
url: string;
webSocketDebuggerUrl?: string;
@@ -161,6 +162,7 @@ export class BrowserPage {
#proc: Subprocess | null;
#watch: ParentDeathWatch | null;
#userDataDir: string | null;
+ #target: TargetInfo;
#nextId = 1;
#pending = new Map();
#closed = false;
@@ -174,9 +176,12 @@ export class BrowserPage {
get profileDir(): string | null { return this.#userDataDir; }
/** The browser's pid, null for a page `attach` drives. */
get pid(): number | null { return this.#proc?.pid ?? null; }
+ /** The `/json/list` entry this page was opened on, as it read then. */
+ get target(): TargetInfo { return { ...this.#target }; }
- private constructor(socket: TextSocket, proc: Subprocess | null, userDataDir: string | null, watch: ParentDeathWatch | null = null) {
+ private constructor(socket: TextSocket, target: TargetInfo, proc: Subprocess | null, userDataDir: string | null, watch: ParentDeathWatch | null = null) {
this.#socket = socket;
+ this.#target = target;
this.#proc = proc;
this.#watch = watch;
this.#userDataDir = userDataDir;
@@ -272,7 +277,7 @@ export class BrowserPage {
try {
const target = await waitForPageTarget(port, '', proc, true);
const socket = await openSocket(target);
- page = new BrowserPage(socket, proc, ownsUserDataDir ? userDataDir : null, watch);
+ page = new BrowserPage(socket, target, proc, ownsUserDataDir ? userDataDir : null, watch);
open.add(page);
await page.send('Page.enable', {});
await page.send('Runtime.enable', {});
@@ -314,7 +319,8 @@ export class BrowserPage {
static async attach(port: number, urlIncludes = '', diagnose?: (failure: string) => string | Promise): Promise {
let page: BrowserPage | undefined;
try {
- page = new BrowserPage(await openSocket(await waitForPageTarget(port, urlIncludes)), null, null);
+ const target = await waitForPageTarget(port, urlIncludes);
+ page = new BrowserPage(await openSocket(target), target, null, null);
return await page.#prepareAttached();
} catch (error) {
await page?.close().catch(() => undefined);
diff --git a/tests/e2e/lib/shell-diagnosis.ts b/tests/e2e/lib/shell-diagnosis.ts
index b18cabdae..a6956b3c5 100644
--- a/tests/e2e/lib/shell-diagnosis.ts
+++ b/tests/e2e/lib/shell-diagnosis.ts
@@ -1,5 +1,9 @@
import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
+import type { BrowserPage } from './cdp.ts';
+
+/** The webview exposes its page target before the IPC bridge is injected. */
+export const TAURI_READY = "typeof window.__TAURI_INTERNALS__?.invoke === 'function'";
/** The newest records of `/logs/shell.0.ndjson` a diagnosis prints. */
const SHELL_LOG_RECORDS = 60;
@@ -8,6 +12,14 @@ const SHELL_LOG_TAIL_BYTES = 256 * 1024;
const MESSAGE_CHARS = 300;
/** How long PowerShell gets to list the shell's process tree. */
const PROCESS_TREE_TIMEOUT_MS = 10_000;
+/** The newest `shell.webview.*` records a page that never got its bridge reports, from the same tail. */
+const WEBVIEW_RECORDS = 40;
+/** How long each devtools question of that report may take: the page may be what hangs. */
+const PROBE_TIMEOUT_MS = 3_000;
+const TARGETS_CHARS = 4_000;
+
+/** A hidden shell a test drives: its debugging port, data directory and pid. */
+export interface ShellUnderTest { port: number; dataDir: string; pid: number }
/**
* What a hidden shell that never exposed its page left behind: the end of its
@@ -20,15 +32,77 @@ const PROCESS_TREE_TIMEOUT_MS = 10_000;
* uploads.
*/
export async function describeShell(dataDir: string, shellPid: number, failure = ''): Promise {
- const text = `Shell ${shellPid} log (newest ${SHELL_LOG_RECORDS} records of ${join(dataDir, 'logs', 'shell.0.ndjson')}):\n${shellLogTail(dataDir)}\n`
+ const text = await shellReport(dataDir, shellPid);
+ writeArtifact(`shell-${shellPid}-diagnosis.txt`, failure === '' ? text : `${failure}\n${text}`);
+ return text;
+}
+
+/**
+ * Waits for the shell page's IPC bridge (`TAURI_READY`). A page that never
+ * gets it fails with what the attach did not show: the target the test
+ * attached to, `/json/list` at the failure, the frame and its navigation
+ * history as devtools reports them, the shell's `shell.webview.*` records
+ * (`shell.webview.page-load` started and finished), its log tail and process
+ * tree. The text also goes to `tests/e2e/.artifacts/shell--not-ready.txt`,
+ * which the desktop job uploads as `desktop-failure-captures`.
+ */
+export async function waitForShellPage(page: BrowserPage, shell: ShellUnderTest, timeoutMs = 15_000): Promise {
+ try {
+ await page.waitFor(TAURI_READY, timeoutMs);
+ } catch (error) {
+ const failure = error instanceof Error ? error.message : String(error);
+ const report = await notReadyReport(page, shell);
+ writeArtifact(`shell-${shell.pid}-not-ready.txt`, `${failure}\n${report}`);
+ throw new Error(`${failure}\n${report}`, { cause: error });
+ }
+}
+
+async function notReadyReport(page: BrowserPage, shell: ShellUnderTest): Promise {
+ const ask = (method: string) => page.send(method, {}, PROBE_TIMEOUT_MS).then(
+ result => JSON.stringify(result),
+ (error: unknown) => `(no answer: ${error instanceof Error ? error.message.split('\n', 1)[0] : String(error)})`,
+ );
+ const [targets, frames, history, shellText] = await Promise.all([
+ fetch(`http://127.0.0.1:${shell.port}/json/list`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) })
+ .then(response => response.text())
+ .then(
+ text => text.replace(/\s+/g, ' ').slice(0, TARGETS_CHARS),
+ (error: unknown) => `(no answer: ${error instanceof Error ? error.message : String(error)})`,
+ ),
+ ask('Page.getFrameTree'),
+ ask('Page.getNavigationHistory'),
+ shellReport(shell.dataDir, shell.pid),
+ ]);
+ const records = shellRecords(shell.dataDir);
+ const webview = typeof records === 'string'
+ ? records
+ : records.filter(record => record.event.startsWith('shell.webview.')).slice(-WEBVIEW_RECORDS).map(record => record.text).join('\n') || ' (none)';
+ return [
+ `Attached target: ${JSON.stringify(page.target)}`,
+ `/json/list of port ${shell.port} now: ${targets}`,
+ `Page.getFrameTree: ${frames}`,
+ `Page.getNavigationHistory: ${history}`,
+ `Shell ${shell.pid} webview records (newest ${WEBVIEW_RECORDS} shell.webview.* of the log tail):`,
+ webview,
+ shellText,
+ ].join('\n');
+}
+
+async function shellReport(dataDir: string, shellPid: number): Promise {
+ const records = shellRecords(dataDir);
+ const tail = typeof records === 'string' ? records : records.slice(-SHELL_LOG_RECORDS).map(record => record.text).join('\n');
+ return `Shell ${shellPid} log (newest ${SHELL_LOG_RECORDS} records of ${join(dataDir, 'logs', 'shell.0.ndjson')}):\n${tail}\n`
+ `Shell ${shellPid} process tree: ${await processTree(shellPid)}`;
+}
+
+function writeArtifact(name: string, text: string): void {
const artifacts = join(import.meta.dir, '..', '.artifacts');
mkdirSync(artifacts, { recursive: true });
- writeFileSync(join(artifacts, `shell-${shellPid}-diagnosis.txt`), failure === '' ? text : `${failure}\n${text}`);
- return text;
+ writeFileSync(join(artifacts, name), text);
}
-function shellLogTail(dataDir: string): string {
+/** The records of the log's tail, each with its event and printed line; a string says why there are none. */
+function shellRecords(dataDir: string): Array<{ event: string; text: string }> | string {
const path = join(dataDir, 'logs', 'shell.0.ndjson');
if (!existsSync(path)) return ' (none: the shell never started its log)';
let lines: string[];
@@ -37,15 +111,17 @@ function shellLogTail(dataDir: string): string {
} catch (error) {
return ` (unreadable: ${error instanceof Error ? error.message : String(error)})`;
}
- return lines.slice(-SHELL_LOG_RECORDS).map((line) => {
+ return lines.map((line) => {
try {
- const record = JSON.parse(line) as { at?: number; level?: string; event?: string; message?: string; runId?: string };
+ const record = JSON.parse(line) as { at?: number; level?: string; event?: string; message?: string; runId?: string; data?: Record };
const at = typeof record.at === 'number' ? new Date(record.at).toISOString().slice(11, 23) : '?';
- return ` ${at} ${record.runId?.slice(0, 8) ?? '?'} ${record.level ?? '?'} ${record.event ?? '?'} ${(record.message ?? '').slice(0, MESSAGE_CHARS)}`;
+ // `shell.webview.page-load` says started or finished only in its data.
+ const data = record.data !== undefined && Object.keys(record.data).length > 0 ? ` ${JSON.stringify(record.data).slice(0, MESSAGE_CHARS)}` : '';
+ return { event: record.event ?? '?', text: ` ${at} ${record.runId?.slice(0, 8) ?? '?'} ${record.level ?? '?'} ${record.event ?? '?'} ${(record.message ?? '').slice(0, MESSAGE_CHARS)}${data}` };
} catch {
- return ` ${line.slice(0, MESSAGE_CHARS)}`;
+ return { event: '?', text: ` ${line.slice(0, MESSAGE_CHARS)}` };
}
- }).join('\n');
+ });
}
/** The last `SHELL_LOG_TAIL_BYTES` of a file, its first partial line dropped. */
diff --git a/tests/e2e/shell-diagnosis.test.ts b/tests/e2e/shell-diagnosis.test.ts
new file mode 100644
index 000000000..1a4b7a6c7
--- /dev/null
+++ b/tests/e2e/shell-diagnosis.test.ts
@@ -0,0 +1,45 @@
+import { expect, test } from 'bun:test';
+import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { BrowserPage, freePort } from './lib/cdp.ts';
+import { removeDirectory } from './lib/cleanup.ts';
+import { waitForShellPage } from './lib/shell-diagnosis.ts';
+
+// The Desktop Windows failure: the attached shell page stays about:blank and
+// never gets its IPC bridge. A plain browser on about:blank has no bridge
+// either, so the wait times out the same way, and its report must carry
+// what the next occurrence needs to explain itself.
+test('a shell page that never gets its IPC bridge reports its target, the target list and the page-load records', async () => {
+ const dataDir = mkdtempSync(join(tmpdir(), 'boite-e2e-shell-ready-'));
+ mkdirSync(join(dataDir, 'logs'));
+ const record = (at: number, event: string, message: string, data: Record = {}) =>
+ JSON.stringify({ id: `run:${at}`, runId: 'run00001', seq: at, at: 1_792_400_000_000 + at, level: 'info', source: 'webview', event, message, data });
+ writeFileSync(join(dataDir, 'logs', 'shell.0.ndjson'), [
+ record(1, 'shell.startup.phase', 'main-window built', { phase: 'main-window' }),
+ record(2, 'shell.webview.page-load', 'the main page started loading; browser surfaces close with it', { phase: 'started' }),
+ // The records a busy shell writes after it push the page-load out of the last 60.
+ ...Array.from({ length: 80 }, (_, i) => record(10 + i, 'shell.core.health', 'the core answered')),
+ ].join('\n') + '\n');
+ const port = await freePort();
+ let page: BrowserPage | undefined;
+ try {
+ page = await BrowserPage.launch({ url: 'about:blank', debugPort: port });
+ const shell = { port, dataDir, pid: page.pid! };
+ const failure = await waitForShellPage(page, shell, 500).then(() => null, (error: unknown) => error as Error);
+ expect(failure?.message).toStartWith("waitFor timed out on typeof window.__TAURI_INTERNALS__?.invoke === 'function'");
+ const report = failure!.message;
+ expect(report).toContain(`Attached target: {"`);
+ expect(report).toContain(`"webSocketDebuggerUrl":"ws://127.0.0.1:${port}/devtools/page/`);
+ expect(report).toMatch(new RegExp(`/json/list of port ${port} now: \\[.*"url": "about:blank"`));
+ expect(report).toMatch(/Page\.getFrameTree: \{"frameTree":\{"frame":\{"id":"[^"]+","loaderId":"[^"]+","url":"about:blank"/);
+ expect(report).toContain('Page.getNavigationHistory: {"currentIndex":');
+ expect(report).toMatch(/webview records[^\n]*\n {2}\S+ run00001 info shell\.webview\.page-load the main page started loading; browser surfaces close with it \{"phase":"started"\}/);
+ expect(report).toContain(`Shell ${shell.pid} process tree:`);
+ const artifact = readFileSync(join(import.meta.dir, '.artifacts', `shell-${shell.pid}-not-ready.txt`), 'utf8');
+ expect(artifact).toBe(report);
+ } finally {
+ await page?.close();
+ await removeDirectory(dataDir);
+ }
+}, 60_000);
diff --git a/tests/e2e/shell.test.ts b/tests/e2e/shell.test.ts
index 502421c44..f41cbf211 100644
--- a/tests/e2e/shell.test.ts
+++ b/tests/e2e/shell.test.ts
@@ -4,7 +4,7 @@ import { basename, dirname, join } from 'node:path';
import { afterAll, beforeAll, expect, test } from 'bun:test';
import { connect } from '../../packages/core/src/client.ts';
import { BrowserPage, freePort } from './lib/cdp.ts';
-import { describeShell } from './lib/shell-diagnosis.ts';
+import { describeShell, TAURI_READY, waitForShellPage, type ShellUnderTest } from './lib/shell-diagnosis.ts';
import { freshDataDir, killProcessTree, removeDirectory, startCore } from './lib/core.ts';
const TIMEOUT = 120_000;
@@ -296,15 +296,28 @@ async function waitForHealthyCore(ownDataDir: string): Promise {
}
}
-/** The webview exposes its page target before the IPC bridge is injected. */
-const TAURI_READY = "typeof window.__TAURI_INTERNALS__?.invoke === 'function'";
+/** The shell behind each page `attachShell` returned, for `shellReady`. */
+const shells = new WeakMap();
/**
* Attaches to a hidden shell's page. A shell whose page never shows up fails
* with its own log and process tree, so the run says where WebView2 stopped.
*/
-function attachShell(port: number, ownDataDir: string, pid: number, urlIncludes = ''): Promise {
- return BrowserPage.attach(port, urlIncludes, failure => describeShell(ownDataDir, pid, failure));
+async function attachShell(port: number, ownDataDir: string, pid: number, urlIncludes = ''): Promise {
+ const attached = await BrowserPage.attach(port, urlIncludes, failure => describeShell(ownDataDir, pid, failure));
+ shells.set(attached, { port, dataDir: ownDataDir, pid });
+ return attached;
+}
+
+/**
+ * Waits for the page's IPC bridge. A page that stays without it (about:blank
+ * on Windows runners, cause unknown) fails with the target list, the frame
+ * and the shell's page-load records (`waitForShellPage`).
+ */
+async function shellReady(shellPage: BrowserPage | undefined): Promise {
+ if (shellPage === undefined) return;
+ const shell = shells.get(shellPage);
+ await (shell === undefined ? shellPage.waitFor(TAURI_READY) : waitForShellPage(shellPage, shell));
}
shellTest('a resident core finishes agent work after shell exit, is adopted, and stops explicitly', async () => {
@@ -317,7 +330,7 @@ shellTest('a resident core finishes agent work after shell exit, is adopted, and
client = await connect(`http://127.0.0.1:${found.port}`, found.token);
const account = (await client.call('accounts.list', {})).find(a => a.providerId === 'echo')!;
const agent = await client.call('agents.profile.save', { value: { name: 'Background worker', domain: '', instructions: '', avatar: '', status: 'active', tools: ['messages'], selection: { providerId: 'echo', accountId: account.id, model: null, effort: null, permissionMode: 'default' } } });
- ownPage = await attachShell(port, ownDataDir, shellPid); await ownPage.waitFor(TAURI_READY);
+ ownPage = await attachShell(port, ownDataDir, shellPid); await shellReady(ownPage);
await ownPage.waitFor(`document.querySelector('.app.ready') && document.querySelector('[data-testid=titlebar] .close')`);
const started = client.next('turn.started', () => true, 10000);
const finished = client.next('turn.finished', () => true, 30000);
@@ -350,7 +363,7 @@ shellTest('a resident core finishes agent work after shell exit, is adopted, and
expect((await client.call('agents.snapshot', {})).work[0]?.status).toBe('done');
await ownPage.close(); ownPage = undefined;
const secondPort = await freePort(); shellPid = spawnHiddenShell(ownDataDir, secondPort, true);
- ownPage = await attachShell(secondPort, ownDataDir, shellPid); await ownPage.waitFor(TAURI_READY);
+ ownPage = await attachShell(secondPort, ownDataDir, shellPid); await shellReady(ownPage);
await ownPage.evaluate(`window.__TAURI_INTERNALS__.invoke('core_endpoint')`);
expect((await waitForHealthyCore(ownDataDir)).pid).toBe(found.pid);
expect(await client.call('core.shutdown', {})).toEqual({ ok: true });
@@ -374,7 +387,7 @@ shellTest('close exits by default; the persisted setting hides instead; the nati
const port = await freePort(); ownPid = spawnHiddenShell(ownDataDir, port);
ownCore = await waitForHealthyCore(ownDataDir);
ownPage = await attachShell(port, ownDataDir, ownPid);
- await ownPage.waitFor(TAURI_READY);
+ await shellReady(ownPage);
// A first launch opens centred in the work area, not at Windows' cascade spot.
const main = await ownPage.evaluate<{ dx: number; dy: number }>(`(async () => {
const invoke = window.__TAURI_INTERNALS__.invoke;
@@ -506,7 +519,7 @@ shellTest('close exits by default; the persisted setting hides instead; the nati
const thirdPort = await freePort(); ownPid = spawnHiddenShell(ownDataDir, thirdPort);
ownCore = await waitForHealthyCore(ownDataDir); ownPage = await attachShell(thirdPort, ownDataDir, ownPid);
- await ownPage.waitFor(TAURI_READY);
+ await shellReady(ownPage);
expect(await ownPage.evaluate(`window.__TAURI_INTERNALS__.invoke('close_behavior')`)).toBe(true);
await ownPage.evaluate(`window.__TAURI_INTERNALS__.invoke('close_behavior', {enabled:false})`);
await ownPage.waitFor(`document.querySelector('.app.ready') && document.querySelector('[data-testid="titlebar"] .close')`);
@@ -535,7 +548,7 @@ async function waitUntil(done: () => boolean, timeoutMs: number): Promise
* nothing (`apps/shell/src-tauri/src/lib.rs`).
*/
async function quitShell(shellPage: BrowserPage): Promise {
- await shellPage.waitFor(TAURI_READY);
+ await shellReady(shellPage);
try {
await shellPage.evaluate(
`(() => { window.__TAURI_INTERNALS__.invoke('quit_shell'); return null; })()`,
@@ -608,7 +621,7 @@ shellTest(
);
shellTest('the native updater reports its local version and refuses installation in hidden tests', async () => {
- await page?.waitFor(TAURI_READY);
+ await shellReady(page);
const status = await page?.evaluate<{ supported: boolean; currentVersion: string }>(`window.__TAURI_INTERNALS__.invoke('app_update_status')`);
expect(status?.supported).toBe(false);
expect(status?.currentVersion).toMatch(/^\d+\.\d+\.\d+/);
@@ -618,7 +631,7 @@ shellTest('the native updater reports its local version and refuses installation
shellTest('the native file opener launches the original executable and rejects paths outside the thread', async () => {
if (!page) throw new Error('the shell did not expose its page');
- await page.waitFor(TAURI_READY);
+ await shellReady(page);
const executable = join(projectDir, 'game fixture.exe');
const compiler = Bun.spawn(['rustc', '--crate-name', 'file_open', join(import.meta.dir, 'fixtures', 'file-open.rs'), '-o', executable], {
stdout: 'pipe', stderr: 'pipe', windowsHide: true,
@@ -1013,7 +1026,7 @@ async function waitForTargets(url: string, wanted: number, timeoutMs: number): P
/** `invoke` from the shell's own page, with the promise it returns awaited. */
async function invokeShell(command: string, args: Record = {}): Promise {
- await page?.waitFor(TAURI_READY);
+ await shellReady(page);
await page?.evaluate(
`window.__TAURI_INTERNALS__.invoke(${JSON.stringify(command)}, ${JSON.stringify(args)}).then(() => null)`,
);
@@ -1197,7 +1210,7 @@ shellTest(
// The list depends on the Windows build: solid always, mica from 22000,
// acrylic from 22523. Nothing off Windows. A runner on Windows Server 2022
// (20348) offers solid alone, and a stored mica then stamps nothing.
- await page?.waitFor(TAURI_READY);
+ await shellReady(page);
const supported = await page?.evaluate(
`window.__TAURI_INTERNALS__.invoke('window_material_supported')`,
);
@@ -1353,7 +1366,7 @@ shellTest(
try {
corePid = (await waitForHealthyCore(ownDataDir)).pid;
shellPage = await attachShell(debugPort, ownDataDir, shell);
- await shellPage.waitFor(TAURI_READY);
+ await shellReady(shellPage);
await shellPage.waitFor(`document.querySelector('[data-testid="titlebar"]')`);
// One tap: the hint comes and goes, nothing quits. WebView2 drops a Ctrl
@@ -1410,7 +1423,7 @@ shellTest(
// Adopted, not replaced: the endpoint the shell hands its own UI is the
// core this test started, and `core.json` still names that process. The
// page target is there before the IPC is, so this waits for the bridge.
- await shellPage.waitFor(TAURI_READY);
+ await shellReady(shellPage);
const endpoint = await shellPage.evaluate<{ url: string }>(
`window.__TAURI_INTERNALS__.invoke('core_endpoint')`,
);
From 13fa0516e4b81612cef46e5da4113edf7fb93f9a Mon Sep 17 00:00:00 2001
From: meetsu <96637888+klNuno@users.noreply.github.com>
Date: Mon, 19 Oct 2026 16:01:07 +0200
Subject: [PATCH 3/4] test(e2e): end the devtools connection when its upgrade
fails
---
tests/e2e/lib/ws-client.ts | 30 +++++++++++++++++-------------
tests/e2e/ws-client.test.ts | 6 +++++-
2 files changed, 22 insertions(+), 14 deletions(-)
diff --git a/tests/e2e/lib/ws-client.ts b/tests/e2e/lib/ws-client.ts
index 4e0a53272..c97fbce92 100644
--- a/tests/e2e/lib/ws-client.ts
+++ b/tests/e2e/lib/ws-client.ts
@@ -254,11 +254,13 @@ export class TextSocket {
let handshake: Uint8Array = new Uint8Array(0);
const reader = new FrameReader();
let settled = false;
- const fail = (socket: Bun.Socket | null, message: string) => {
+ /** The connection before it upgraded: a failed upgrade ends it, including one that connects after the timeout. */
+ let raw: Bun.Socket | null = null;
+ const fail = (message: string) => {
if (settled) return;
settled = true;
clearTimeout(timer);
- socket?.end();
+ raw?.end();
reject(new Error(message));
};
const receive = (bytes: Uint8Array) => {
@@ -279,29 +281,31 @@ export class TextSocket {
}
}
};
- const timer = setTimeout(() => fail(null, `no WebSocket upgrade from ${target.host} within ${timeoutMs} ms`), timeoutMs);
+ const timer = setTimeout(() => fail(`no WebSocket upgrade from ${target.host} within ${timeoutMs} ms`), timeoutMs);
Bun.connect({
hostname: target.hostname,
port: Number(target.port || 80),
socket: {
open(socket) {
+ raw = socket;
+ if (settled) { socket.end(); return; }
socket.write(`GET ${target.pathname}${target.search} HTTP/1.1\r\nHost: ${target.host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\n\r\n`);
},
data(socket, bytes) {
if (client !== null) { receive(bytes); return; }
- if (settled) return;
+ if (settled) { socket.end(); return; }
handshake = concat([handshake, bytes]);
const end = headerEnd(handshake);
if (end === -1) {
- if (handshake.length > 16_384) fail(socket, 'the WebSocket upgrade answer has no end');
+ if (handshake.length > 16_384) fail('the WebSocket upgrade answer has no end');
return;
}
const head = new TextDecoder().decode(handshake.subarray(0, end));
const status = head.split('\r\n', 1)[0] ?? '';
- if (!/^HTTP\/1\.1 101\b/.test(status)) { fail(socket, `the server refused the WebSocket upgrade: ${status}`); return; }
- if (/^sec-websocket-accept:\s*(\S+)/im.exec(head)?.[1] !== accept) { fail(socket, 'the server answered the upgrade with a wrong Sec-WebSocket-Accept'); return; }
+ if (!/^HTTP\/1\.1 101\b/.test(status)) { fail(`the server refused the WebSocket upgrade: ${status}`); return; }
+ if (/^sec-websocket-accept:\s*(\S+)/im.exec(head)?.[1] !== accept) { fail('the server answered the upgrade with a wrong Sec-WebSocket-Accept'); return; }
const extensions = /^sec-websocket-extensions:\s*(.*)$/im.exec(head)?.[1]?.trim();
- if (extensions) { fail(socket, `the server turned on extensions nobody offered: ${extensions}`); return; }
+ if (extensions) { fail(`the server turned on extensions nobody offered: ${extensions}`); return; }
settled = true;
clearTimeout(timer);
client = new TextSocket(socket);
@@ -309,12 +313,12 @@ export class TextSocket {
if (end < handshake.length) receive(handshake.subarray(end));
},
drain() { if (client !== null) client.#flush(); },
- end() { if (client) client.#ended({ code: 1006, reason: 'the server hung up' }); else fail(null, 'the server hung up during the WebSocket upgrade'); },
- close() { if (client) client.#ended({ code: 1006, reason: 'the connection closed' }); else fail(null, 'the connection closed during the WebSocket upgrade'); },
- error(_socket, error) { if (client) client.#ended({ code: 1006, reason: error.message }); else fail(null, `the WebSocket connection failed: ${error.message}`); },
- connectError(_socket, error) { fail(null, `could not connect to ${target.host}: ${error.message}`); },
+ end() { if (client) client.#ended({ code: 1006, reason: 'the server hung up' }); else fail('the server hung up during the WebSocket upgrade'); },
+ close() { if (client) client.#ended({ code: 1006, reason: 'the connection closed' }); else fail('the connection closed during the WebSocket upgrade'); },
+ error(_socket, error) { if (client) client.#ended({ code: 1006, reason: error.message }); else fail(`the WebSocket connection failed: ${error.message}`); },
+ connectError(_socket, error) { fail(`could not connect to ${target.host}: ${error.message}`); },
},
- }).catch((error: unknown) => fail(null, `could not connect to ${target.host}: ${error instanceof Error ? error.message : String(error)}`));
+ }).catch((error: unknown) => fail(`could not connect to ${target.host}: ${error instanceof Error ? error.message : String(error)}`));
});
}
}
diff --git a/tests/e2e/ws-client.test.ts b/tests/e2e/ws-client.test.ts
index 3ebc2ba7e..e1f3d44bd 100644
--- a/tests/e2e/ws-client.test.ts
+++ b/tests/e2e/ws-client.test.ts
@@ -221,8 +221,12 @@ test('an upgrade the server refuses or never answers rejects with what happened'
try {
await expect(TextSocket.connect(`ws://127.0.0.1:${refusing.port}/devtools/page/gone`, 5_000)).rejects.toThrow('refused the WebSocket upgrade: HTTP/1.1 404');
} finally { await refusing.stop(true); }
- const silent = Bun.listen({ hostname: '127.0.0.1', port: 0, socket: { data() {} } });
+ // A server that never answers the upgrade gets its connection closed: a late 101 would otherwise keep it for the run.
+ let hungUp = false;
+ const silent = Bun.listen({ hostname: '127.0.0.1', port: 0, socket: { data() {}, close() { hungUp = true; } } });
try {
await expect(TextSocket.connect(`ws://127.0.0.1:${silent.port}/`, 300)).rejects.toThrow('no WebSocket upgrade');
+ for (let waited = 0; !hungUp && waited < 2_000; waited += 20) await Bun.sleep(20);
+ expect(hungUp).toBe(true);
} finally { silent.stop(true); }
});
From 32cdd2643b3f8dbe91416378bdc7eb89e2cd769e Mon Sep 17 00:00:00 2001
From: meetsu <96637888+klNuno@users.noreply.github.com>
Date: Mon, 19 Oct 2026 16:30:59 +0200
Subject: [PATCH 4/4] e2e: keep the TAURI_READY failure when its diagnosis
fails
A rejected process-tree probe or an unwritable artifact replaced the wait's error. Both are now best effort: the original failure stays first and as the cause, and what failed is appended.
---
tests/e2e/lib/shell-diagnosis.ts | 37 +++++++++++++++++------
tests/e2e/shell-diagnosis.test.ts | 50 +++++++++++++++++++++++++++++--
2 files changed, 76 insertions(+), 11 deletions(-)
diff --git a/tests/e2e/lib/shell-diagnosis.ts b/tests/e2e/lib/shell-diagnosis.ts
index a6956b3c5..b541c41ab 100644
--- a/tests/e2e/lib/shell-diagnosis.ts
+++ b/tests/e2e/lib/shell-diagnosis.ts
@@ -21,6 +21,9 @@ const TARGETS_CHARS = 4_000;
/** A hidden shell a test drives: its debugging port, data directory and pid. */
export interface ShellUnderTest { port: number; dataDir: string; pid: number }
+/** What a diagnosis asks the system, which a test can make fail. */
+export interface DiagnosisProbes { processTree?: (shellPid: number) => Promise }
+
/**
* What a hidden shell that never exposed its page left behind: the end of its
* own log, which says whether `build_main_window` returned (the `main-window`
@@ -46,18 +49,28 @@ export async function describeShell(dataDir: string, shellPid: number, failure =
* tree. The text also goes to `tests/e2e/.artifacts/shell--not-ready.txt`,
* which the desktop job uploads as `desktop-failure-captures`.
*/
-export async function waitForShellPage(page: BrowserPage, shell: ShellUnderTest, timeoutMs = 15_000): Promise {
+export async function waitForShellPage(page: BrowserPage, shell: ShellUnderTest, timeoutMs = 15_000, probes: DiagnosisProbes = {}): Promise {
try {
await page.waitFor(TAURI_READY, timeoutMs);
} catch (error) {
- const failure = error instanceof Error ? error.message : String(error);
- const report = await notReadyReport(page, shell);
- writeArtifact(`shell-${shell.pid}-not-ready.txt`, `${failure}\n${report}`);
- throw new Error(`${failure}\n${report}`, { cause: error });
+ // The wait's failure is what the caller needs: a diagnosis that fails, or
+ // cannot be saved, adds a line to it and never takes its place.
+ const failure = errorText(error);
+ const report = await notReadyReport(page, shell, probes.processTree ?? processTree)
+ .catch((diagnosisError: unknown) => `The diagnosis failed: ${errorText(diagnosisError)}`);
+ const text = `${failure}\n${report}`;
+ const name = `shell-${shell.pid}-not-ready.txt`;
+ let unsaved = '';
+ try {
+ writeArtifact(name, text);
+ } catch (writeError) {
+ unsaved = `\nCould not write the artifact ${name}: ${errorText(writeError)}`;
+ }
+ throw new Error(`${text}${unsaved}`, { cause: error });
}
}
-async function notReadyReport(page: BrowserPage, shell: ShellUnderTest): Promise {
+async function notReadyReport(page: BrowserPage, shell: ShellUnderTest, tree: (shellPid: number) => Promise): Promise {
const ask = (method: string) => page.send(method, {}, PROBE_TIMEOUT_MS).then(
result => JSON.stringify(result),
(error: unknown) => `(no answer: ${error instanceof Error ? error.message.split('\n', 1)[0] : String(error)})`,
@@ -71,7 +84,7 @@ async function notReadyReport(page: BrowserPage, shell: ShellUnderTest): Promise
),
ask('Page.getFrameTree'),
ask('Page.getNavigationHistory'),
- shellReport(shell.dataDir, shell.pid),
+ shellReport(shell.dataDir, shell.pid, tree),
]);
const records = shellRecords(shell.dataDir);
const webview = typeof records === 'string'
@@ -88,11 +101,17 @@ async function notReadyReport(page: BrowserPage, shell: ShellUnderTest): Promise
].join('\n');
}
-async function shellReport(dataDir: string, shellPid: number): Promise {
+async function shellReport(dataDir: string, shellPid: number, tree = processTree): Promise {
const records = shellRecords(dataDir);
const tail = typeof records === 'string' ? records : records.slice(-SHELL_LOG_RECORDS).map(record => record.text).join('\n');
+ // A missing or blocked PowerShell makes `Bun.spawn` throw: the log tail stays.
+ const listing = await tree(shellPid).catch((error: unknown) => `(could not list it: ${errorText(error)})`);
return `Shell ${shellPid} log (newest ${SHELL_LOG_RECORDS} records of ${join(dataDir, 'logs', 'shell.0.ndjson')}):\n${tail}\n`
- + `Shell ${shellPid} process tree: ${await processTree(shellPid)}`;
+ + `Shell ${shellPid} process tree: ${listing}`;
+}
+
+function errorText(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
}
function writeArtifact(name: string, text: string): void {
diff --git a/tests/e2e/shell-diagnosis.test.ts b/tests/e2e/shell-diagnosis.test.ts
index 1a4b7a6c7..33c48d58a 100644
--- a/tests/e2e/shell-diagnosis.test.ts
+++ b/tests/e2e/shell-diagnosis.test.ts
@@ -1,10 +1,10 @@
import { expect, test } from 'bun:test';
-import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { BrowserPage, freePort } from './lib/cdp.ts';
import { removeDirectory } from './lib/cleanup.ts';
-import { waitForShellPage } from './lib/shell-diagnosis.ts';
+import { waitForShellPage, type ShellUnderTest } from './lib/shell-diagnosis.ts';
// The Desktop Windows failure: the attached shell page stays about:blank and
// never gets its IPC bridge. A plain browser on about:blank has no bridge
@@ -43,3 +43,49 @@ test('a shell page that never gets its IPC bridge reports its target, the target
await removeDirectory(dataDir);
}
}, 60_000);
+
+// The diagnosis runs after the wait failed: when one of its own steps fails,
+// the caller must still get the wait's failure, not the step's.
+const TIMED_OUT = "waitFor timed out on typeof window.__TAURI_INTERNALS__?.invoke === 'function'";
+
+async function blankShell(): Promise<{ page: BrowserPage; shell: ShellUnderTest; [Symbol.asyncDispose]: () => Promise }> {
+ const dataDir = mkdtempSync(join(tmpdir(), 'boite-e2e-shell-ready-'));
+ const port = await freePort();
+ const page = await BrowserPage.launch({ url: 'about:blank', debugPort: port });
+ return {
+ page,
+ shell: { port, dataDir, pid: page.pid! },
+ async [Symbol.asyncDispose]() {
+ await page.close();
+ await removeDirectory(dataDir);
+ },
+ };
+}
+
+test('a process-tree probe that rejects keeps the wait failure, its cause and the rest of the report', async () => {
+ await using blank = await blankShell();
+ // What `Bun.spawn` throws when PowerShell is missing from the runner.
+ const processTree = () => Promise.reject(new Error('Executable not found in $PATH: "powershell"'));
+ const failure = await waitForShellPage(blank.page, blank.shell, 500, { processTree }).then(() => null, (error: unknown) => error as Error);
+ expect(failure?.message).toStartWith(TIMED_OUT);
+ expect((failure?.cause as Error | undefined)?.message).toStartWith(TIMED_OUT);
+ expect(failure!.message).toContain('Attached target: {"');
+ expect(failure!.message).toContain(`Shell ${blank.shell.pid} process tree: (could not list it: Executable not found in $PATH: "powershell")`);
+ expect(readFileSync(join(import.meta.dir, '.artifacts', `shell-${blank.shell.pid}-not-ready.txt`), 'utf8')).toBe(failure!.message);
+}, 60_000);
+
+test('an artifact that cannot be written keeps the wait failure and says why it is missing', async () => {
+ await using blank = await blankShell();
+ // A directory where the artifact goes makes the write fail with EISDIR.
+ const artifact = join(import.meta.dir, '.artifacts', `shell-${blank.shell.pid}-not-ready.txt`);
+ mkdirSync(artifact, { recursive: true });
+ try {
+ const failure = await waitForShellPage(blank.page, blank.shell, 500).then(() => null, (error: unknown) => error as Error);
+ expect(failure?.message).toStartWith(TIMED_OUT);
+ expect((failure?.cause as Error | undefined)?.message).toStartWith(TIMED_OUT);
+ expect(failure!.message).toContain('Attached target: {"');
+ expect(failure!.message).toMatch(new RegExp(`\\nCould not write the artifact shell-${blank.shell.pid}-not-ready\\.txt: \\S+`));
+ } finally {
+ rmSync(artifact, { recursive: true, force: true });
+ }
+}, 60_000);