Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 7 additions & 20 deletions packages/core/test/browser/fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<!doctype html><title>Fixture</title>
<style>body{margin:0;font:16px sans-serif} button{position:absolute;left:100px;top:100px;width:200px;height:80px}</style>
Expand Down Expand Up @@ -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<void> | undefined;
const close = () => closing ??= (async () => {
const errors: unknown[] = [];
Expand All @@ -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();
});
}
Expand All @@ -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<void>((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(); }
Expand Down
4 changes: 2 additions & 2 deletions tests/e2e/browser-native.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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]');
Expand Down
4 changes: 2 additions & 2 deletions tests/e2e/browser-profiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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]')");
Expand Down
4 changes: 2 additions & 2 deletions tests/e2e/lib/browser-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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]');
Expand Down
57 changes: 26 additions & 31 deletions tests/e2e/lib/cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -89,7 +90,8 @@ export function freePort(): Promise<number> {
});
}

interface TargetInfo {
export interface TargetInfo {
id?: string;
type: string;
url: string;
webSocketDebuggerUrl?: string;
Expand Down Expand Up @@ -156,10 +158,11 @@ 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;
#target: TargetInfo;
#nextId = 1;
#pending = new Map<number, Pending>();
#closed = false;
Expand All @@ -173,19 +176,20 @@ 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: WebSocket, 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;
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();
});
Expand Down Expand Up @@ -273,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', {});
Expand Down Expand Up @@ -315,7 +319,8 @@ export class BrowserPage {
static async attach(port: number, urlIncludes = '', diagnose?: (failure: string) => string | Promise<string>): Promise<BrowserPage> {
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);
Expand Down Expand Up @@ -354,7 +359,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++;
Expand Down Expand Up @@ -415,19 +420,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<boolean>(`(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)}`);
Expand Down Expand Up @@ -706,23 +711,13 @@ export async function probeDebuggingPort(port: number): Promise<string> {
}
}

function openSocket(target: TargetInfo): Promise<WebSocket> {
/** Bun's own WebSocket client closes sound devtools streams with 1002: see `ws-client.ts`. */
async function openSocket(target: TargetInfo): Promise<TextSocket> {
const url = target.webSocketDebuggerUrl;
if (url === undefined) throw new Error('the page target has no debugger url');
return new Promise<WebSocket>((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 });
}
}
Loading
Loading