Files
wehub-resource-sync 9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

380 lines
12 KiB
TypeScript

import { afterEach, describe, expect, it } from 'vitest';
import { createServer, request, type IncomingMessage, type ServerResponse } from 'node:http';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseJsonOutput, runCli } from './helpers.js';
// Match the running CLI's package version so BrowserBridge does not classify
// this fake daemon as stale (PR #1399 auto-restarts daemons whose
// daemonVersion does not match PKG_VERSION; the fake daemon does not implement
// /shutdown, so a mismatch makes every test exit with code 1).
const PKG_VERSION: string = (() => {
const here = path.dirname(fileURLToPath(import.meta.url));
// tests/e2e -> repo root: ../..
const pkgPath = path.resolve(here, '..', '..', 'package.json');
try {
return JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version;
} catch {
return '0.0.0';
}
})();
type FakeTab = {
page: string;
url: string;
title: string;
active: boolean;
};
type FakeDaemon = {
close: () => Promise<void>;
maxInFlightExec: () => number;
};
const DAEMON_PORT = 19825;
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function shutdownExistingDaemon(): Promise<void> {
await new Promise<void>((resolve) => {
let settled = false;
const finish = () => {
if (!settled) {
settled = true;
resolve();
}
};
const req = request({
host: '127.0.0.1',
port: DAEMON_PORT,
path: '/shutdown',
method: 'POST',
headers: { 'X-OpenCLI': '1' },
timeout: 500,
}, (res) => {
res.resume();
res.on('end', finish);
res.on('close', finish);
});
req.on('timeout', () => req.destroy());
req.on('error', finish);
req.on('close', finish);
req.end();
});
await sleep(100);
}
async function readBody(req: IncomingMessage): Promise<string> {
return await new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
req.on('error', reject);
});
}
function json(res: ServerResponse, status: number, payload: unknown): void {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payload));
}
async function startFakeDaemon(): Promise<FakeDaemon> {
await shutdownExistingDaemon();
const tabs = new Map<string, FakeTab>([
['tab-1', { page: 'tab-1', url: 'https://one.example/', title: 'tab-one', active: true }],
['tab-2', { page: 'tab-2', url: 'https://two.example/', title: 'tab-two', active: false }],
]);
let nextId = 3;
let inFlightExec = 0;
let maxInFlightExec = 0;
const server = createServer(async (req, res) => {
const pathname = req.url?.split('?')[0] ?? '/';
if (req.method === 'GET' && pathname === '/status') {
json(res, 200, {
ok: true,
pid: process.pid,
uptime: 1,
daemonVersion: PKG_VERSION,
extensionConnected: true,
extensionVersion: 'test',
pending: 0,
memoryMB: 1,
port: DAEMON_PORT,
});
return;
}
if (req.method !== 'POST' || pathname !== '/command') {
json(res, 404, { ok: false, error: 'Not found' });
return;
}
const body = JSON.parse(await readBody(req)) as {
id: string;
action: string;
op?: string;
page?: string;
index?: number;
url?: string;
code?: string;
};
const listTabs = () => [...tabs.values()].map((tab, index) => ({ index, ...tab }));
const tabByIndex = (index?: number) => index === undefined ? undefined : listTabs()[index];
switch (body.action) {
case 'tabs': {
switch (body.op) {
case 'list':
json(res, 200, { id: body.id, ok: true, data: listTabs() });
return;
case 'new': {
const page = `tab-${nextId++}`;
const url = body.url ?? 'about:blank';
tabs.set(page, {
page,
url,
title: page,
active: true,
});
json(res, 200, { id: body.id, ok: true, page, data: { url } });
return;
}
case 'close': {
const targetPage = typeof body.page === 'string' ? body.page : tabByIndex(body.index)?.page;
if (!targetPage || !tabs.has(targetPage)) {
json(res, 200, { id: body.id, ok: false, error: 'Tab not found' });
return;
}
tabs.delete(targetPage);
json(res, 200, { id: body.id, ok: true, data: { closed: targetPage } });
return;
}
case 'select': {
const targetPage = typeof body.page === 'string' ? body.page : tabByIndex(body.index)?.page;
if (!targetPage || !tabs.has(targetPage)) {
json(res, 200, { id: body.id, ok: false, error: 'Tab not found' });
return;
}
json(res, 200, { id: body.id, ok: true, page: targetPage, data: { selected: true } });
return;
}
default:
json(res, 200, { id: body.id, ok: false, error: `Unknown tabs op: ${body.op}` });
return;
}
}
case 'navigate': {
const targetPage = typeof body.page === 'string' && tabs.has(body.page) ? body.page : 'tab-1';
const target = tabs.get(targetPage)!;
const url = body.url ?? target.url;
target.url = url;
target.title = url;
json(res, 200, {
id: body.id,
ok: true,
page: targetPage,
data: { title: target.title, url: target.url, timedOut: false },
});
return;
}
case 'exec': {
const targetPage = typeof body.page === 'string' ? body.page : 'tab-1';
const target = tabs.get(targetPage);
if (!target) {
json(res, 200, { id: body.id, ok: false, error: `Unknown page: ${targetPage}` });
return;
}
inFlightExec++;
maxInFlightExec = Math.max(maxInFlightExec, inFlightExec);
try {
if ((body.code ?? '').includes('__delay')) {
await new Promise(resolve => setTimeout(resolve, 200));
}
json(res, 200, {
id: body.id,
ok: true,
page: targetPage,
data: {
page: targetPage,
title: target.title,
url: target.url,
},
});
} finally {
inFlightExec--;
}
return;
}
default:
json(res, 200, { id: body.id, ok: false, error: `Unknown action: ${body.action}` });
}
});
let lastBindError: unknown;
for (let attempt = 0; attempt < 10; attempt++) {
try {
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(DAEMON_PORT, '127.0.0.1', () => {
server.off('error', reject);
resolve();
});
});
lastBindError = undefined;
break;
} catch (err: any) {
lastBindError = err;
if (err?.code !== 'EADDRINUSE') {
break;
}
await shutdownExistingDaemon();
await sleep(100);
}
}
if (lastBindError) {
throw lastBindError;
}
const address = server.address();
if (!address || typeof address !== 'object') {
throw new Error(`Failed to bind fake daemon port ${DAEMON_PORT}`);
}
return {
close: async () => {
await new Promise<void>((resolve, reject) => {
server.close((err) => err ? reject(err) : resolve());
});
},
maxInFlightExec: () => maxInFlightExec,
};
}
describe('browser tab CLI e2e', () => {
const daemons: FakeDaemon[] = [];
const cacheDirs: string[] = [];
const browserArgs = (session: string, ...args: string[]) => ['browser', session, ...args];
afterEach(async () => {
while (daemons.length > 0) {
await daemons.pop()!.close();
}
while (cacheDirs.length > 0) {
fs.rmSync(cacheDirs.pop()!, { recursive: true, force: true });
}
});
it('lists, creates, and closes tabs through the built CLI', async () => {
const daemon = await startFakeDaemon();
daemons.push(daemon);
const session = 'tabs-basic';
const listed = await runCli(browserArgs(session, 'tab', 'list'));
expect(listed.code).toBe(0);
const listData = parseJsonOutput(listed.stdout);
expect(listData).toEqual(expect.arrayContaining([
expect.objectContaining({ page: 'tab-1', title: 'tab-one' }),
expect.objectContaining({ page: 'tab-2', title: 'tab-two' }),
]));
const created = await runCli(browserArgs(session, 'tab', 'new', 'https://three.example/'));
expect(created.code).toBe(0);
const createdData = parseJsonOutput(created.stdout);
expect(createdData).toEqual(expect.objectContaining({
page: 'tab-3',
url: 'https://three.example/',
}));
const closed = await runCli(browserArgs(session, 'tab', 'close', 'tab-3'));
expect(closed.code).toBe(0);
const closedData = parseJsonOutput(closed.stdout);
expect(closedData).toEqual({ closed: 'tab-3' });
const relisted = await runCli(browserArgs(session, 'tab', 'list'));
expect(relisted.code).toBe(0);
const relistedData = parseJsonOutput(relisted.stdout);
expect(relistedData).toHaveLength(2);
expect(relistedData.some((tab: { page: string }) => tab.page === 'tab-3')).toBe(false);
}, 30_000);
it('routes concurrent browser commands to their requested tabs', async () => {
const daemon = await startFakeDaemon();
daemons.push(daemon);
const session = 'tabs-concurrent';
const [left, right] = await Promise.all([
runCli(browserArgs(session, 'eval', '--tab', 'tab-1', 'window.__delay = "left"'), { timeout: 30_000 }),
runCli(browserArgs(session, 'eval', '--tab', 'tab-2', 'window.__delay = "right"'), { timeout: 30_000 }),
]);
expect(left.code).toBe(0);
expect(right.code).toBe(0);
const leftData = parseJsonOutput(left.stdout);
const rightData = parseJsonOutput(right.stdout);
expect(leftData).toEqual(expect.objectContaining({ page: 'tab-1', title: 'tab-one' }));
expect(rightData).toEqual(expect.objectContaining({ page: 'tab-2', title: 'tab-two' }));
expect(daemon.maxInFlightExec()).toBe(2);
}, 30_000);
it('keeps untargeted browser commands on the default tab after creating a new tab', async () => {
const daemon = await startFakeDaemon();
daemons.push(daemon);
const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-browser-tabs-'));
cacheDirs.push(cacheDir);
const env = {
OPENCLI_CACHE_DIR: cacheDir,
};
const session = 'tabs-default-new';
const created = await runCli(browserArgs(session, 'tab', 'new', 'https://three.example/'), { env });
expect(created.code).toBe(0);
expect(parseJsonOutput(created.stdout)).toEqual(expect.objectContaining({ page: 'tab-3' }));
const untargeted = await runCli(browserArgs(session, 'eval', 'document.title'), { env });
expect(untargeted.code).toBe(0);
expect(parseJsonOutput(untargeted.stdout)).toEqual(expect.objectContaining({ page: 'tab-1', title: 'tab-one' }));
}, 30_000);
it('uses an explicitly selected tab as the default target for later untargeted commands', async () => {
const daemon = await startFakeDaemon();
daemons.push(daemon);
const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-browser-tabs-'));
cacheDirs.push(cacheDir);
const env = {
OPENCLI_CACHE_DIR: cacheDir,
};
const session = 'tabs-selected-default';
const selected = await runCli(browserArgs(session, 'tab', 'select', 'tab-2'), { env });
expect(selected.code).toBe(0);
expect(parseJsonOutput(selected.stdout)).toEqual({ selected: 'tab-2' });
const untargeted = await runCli(browserArgs(session, 'eval', 'document.title'), { env });
expect(untargeted.code).toBe(0);
expect(parseJsonOutput(untargeted.stdout)).toEqual(expect.objectContaining({ page: 'tab-2', title: 'tab-two' }));
const closed = await runCli(browserArgs(session, 'tab', 'close', 'tab-2'), { env });
expect(closed.code).toBe(0);
expect(parseJsonOutput(closed.stdout)).toEqual({ closed: 'tab-2' });
const fallback = await runCli(browserArgs(session, 'eval', 'document.title'), { env });
expect(fallback.code).toBe(0);
expect(parseJsonOutput(fallback.stdout)).toEqual(expect.objectContaining({ page: 'tab-1', title: 'tab-one' }));
}, 30_000);
});