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);