mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-05 13:15:24 +02:00
feat: CDP connect — control real Chrome/Comet via Playwright
Add `connectCDP()` to BrowserManager: connects to a running browser via Chrome DevTools Protocol. All existing browse commands work unchanged through Playwright's abstraction layer. - chrome-launcher.ts: browser discovery, CDP probe, auto-relaunch with rollback - browser-manager.ts: connectCDP(), mode guards (close/closeTab/recreateContext/handoff), auto-reconnect on browser restart, getRefMap() for extension API - server.ts: CDP branch in start(), /health gains mode field, /refs endpoint, idle timer only resets on /command (not passive endpoints) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -61,6 +61,26 @@ export class BrowserManager {
|
||||
private isHeaded: boolean = false;
|
||||
private consecutiveFailures: number = 0;
|
||||
|
||||
// ─── CDP State ────────────────────────────────────────────
|
||||
private connectionMode: 'launched' | 'cdp' = 'launched';
|
||||
private preExistingTabIds: Set<number> = new Set();
|
||||
private cdpPort: number = 0;
|
||||
private intentionalDisconnect = false;
|
||||
private reconnecting = false;
|
||||
|
||||
getConnectionMode(): 'launched' | 'cdp' { return this.connectionMode; }
|
||||
|
||||
/**
|
||||
* Get the ref map for external consumers (e.g., /refs endpoint).
|
||||
*/
|
||||
getRefMap(): Array<{ ref: string; role: string; name: string }> {
|
||||
const refs: Array<{ ref: string; role: string; name: string }> = [];
|
||||
for (const [ref, entry] of this.refMap) {
|
||||
refs.push({ ref, role: entry.role, name: entry.name });
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
async launch() {
|
||||
this.browser = await chromium.launch({ headless: true });
|
||||
|
||||
@@ -87,15 +107,110 @@ export class BrowserManager {
|
||||
await this.newTab();
|
||||
}
|
||||
|
||||
// ─── CDP Connect ────────────────────────────────────────────
|
||||
/**
|
||||
* Connect to a running browser via Chrome DevTools Protocol.
|
||||
* All existing commands work unchanged through Playwright's abstraction.
|
||||
*
|
||||
* CDP flow:
|
||||
* connectOverCDP(wsUrl) → Browser → contexts()[0] → discover pages
|
||||
* Disconnect handler → attemptReconnect() (not process.exit)
|
||||
* close() → browser.disconnect() (not browser.close())
|
||||
*/
|
||||
async connectCDP(wsUrl: string, port: number): Promise<void> {
|
||||
// Clear old state before repopulating (safe for reconnect)
|
||||
this.pages.clear();
|
||||
this.preExistingTabIds.clear();
|
||||
this.refMap.clear();
|
||||
this.nextTabId = 1;
|
||||
|
||||
this.browser = await chromium.connectOverCDP(wsUrl);
|
||||
this.connectionMode = 'cdp';
|
||||
this.cdpPort = port;
|
||||
this.intentionalDisconnect = false;
|
||||
|
||||
// Use the user's existing default context (has their cookies, sessions)
|
||||
const contexts = this.browser.contexts();
|
||||
if (contexts.length === 0) {
|
||||
throw new Error('No browser context found. Chrome may have no windows open.');
|
||||
}
|
||||
this.context = contexts[0];
|
||||
|
||||
// Discover existing tabs
|
||||
for (const page of this.context.pages()) {
|
||||
const id = this.nextTabId++;
|
||||
this.pages.set(id, page);
|
||||
this.preExistingTabIds.add(id);
|
||||
this.wirePageEvents(page);
|
||||
}
|
||||
this.activeTabId = [...this.pages.keys()].pop() || 0;
|
||||
|
||||
// Listen for new tabs created by the user
|
||||
this.context.on('page', (page: Page) => {
|
||||
const id = this.nextTabId++;
|
||||
this.pages.set(id, page);
|
||||
this.wirePageEvents(page);
|
||||
this.activeTabId = id;
|
||||
});
|
||||
|
||||
// CDP disconnect ≠ crash — reconnect unless intentional
|
||||
this.browser.on('disconnected', () => {
|
||||
if (this.intentionalDisconnect) return;
|
||||
console.log('[browse] Real browser disconnected — reconnecting...');
|
||||
this.attemptReconnect();
|
||||
});
|
||||
|
||||
// CDP-specific defaults
|
||||
this.dialogAutoAccept = false; // Don't dismiss user's real dialogs
|
||||
this.isHeaded = true;
|
||||
this.consecutiveFailures = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-reconnect after unexpected CDP disconnect (e.g., browser restart).
|
||||
* Non-blocking recursive setTimeout — never overlaps or blocks commands.
|
||||
*/
|
||||
private async attemptReconnect(remaining = 60): Promise<void> {
|
||||
if (remaining <= 0 || this.reconnecting || this.intentionalDisconnect) {
|
||||
if (remaining <= 0) {
|
||||
console.log('[browse] CDP reconnect failed after 5 minutes. Run `$B connect` to reconnect.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnecting = true;
|
||||
try {
|
||||
const { isCdpAvailable } = await import('./chrome-launcher');
|
||||
const result = await isCdpAvailable(this.cdpPort);
|
||||
if (result.available && result.wsUrl) {
|
||||
await this.connectCDP(result.wsUrl, this.cdpPort);
|
||||
console.log('[browse] Reconnected to real browser');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Probe failed — try again
|
||||
} finally {
|
||||
this.reconnecting = false;
|
||||
}
|
||||
|
||||
setTimeout(() => this.attemptReconnect(remaining - 1), 5000);
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (this.browser) {
|
||||
// Remove disconnect handler to avoid exit during intentional close
|
||||
this.browser.removeAllListeners('disconnected');
|
||||
// Timeout: headed browser.close() can hang on macOS
|
||||
await Promise.race([
|
||||
this.browser.close(),
|
||||
new Promise(resolve => setTimeout(resolve, 5000)),
|
||||
]).catch(() => {});
|
||||
if (this.connectionMode === 'cdp') {
|
||||
// CDP mode: disconnect (don't kill user's browser)
|
||||
this.intentionalDisconnect = true;
|
||||
this.browser.removeAllListeners('disconnected');
|
||||
await this.browser.disconnect().catch(() => {});
|
||||
} else {
|
||||
// Launched mode: close the browser we spawned
|
||||
this.browser.removeAllListeners('disconnected');
|
||||
await Promise.race([
|
||||
this.browser.close(),
|
||||
new Promise(resolve => setTimeout(resolve, 5000)),
|
||||
]).catch(() => {});
|
||||
}
|
||||
this.browser = null;
|
||||
}
|
||||
}
|
||||
@@ -145,6 +260,11 @@ export class BrowserManager {
|
||||
const page = this.pages.get(tabId);
|
||||
if (!page) throw new Error(`Tab ${tabId} not found`);
|
||||
|
||||
// CDP mode: block closing pre-existing user tabs
|
||||
if (this.connectionMode === 'cdp' && this.preExistingTabIds.has(tabId)) {
|
||||
throw new Error("Cannot close user's pre-existing tab in real-browser mode. Only tabs created by gstack can be closed.");
|
||||
}
|
||||
|
||||
await page.close();
|
||||
this.pages.delete(tabId);
|
||||
|
||||
@@ -384,6 +504,9 @@ export class BrowserManager {
|
||||
* Falls back to a clean slate on any failure.
|
||||
*/
|
||||
async recreateContext(): Promise<string | null> {
|
||||
if (this.connectionMode === 'cdp') {
|
||||
throw new Error('Cannot recreate context in real-browser mode. The browser context belongs to the user.');
|
||||
}
|
||||
if (!this.browser || !this.context) {
|
||||
throw new Error('Browser not launched');
|
||||
}
|
||||
@@ -450,6 +573,9 @@ export class BrowserManager {
|
||||
* If step 2 fails → return error, headless browser untouched
|
||||
*/
|
||||
async handoff(message: string): Promise<string> {
|
||||
if (this.connectionMode === 'cdp') {
|
||||
return 'Already controlling real browser via CDP. No handoff needed.';
|
||||
}
|
||||
if (this.isHeaded) {
|
||||
return `HANDOFF: Already in headed mode at ${this.getCurrentUrl()}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Chrome/Comet browser discovery + CDP connection
|
||||
*
|
||||
* Discovery flow (macOS only):
|
||||
* 1. Probe localhost:9222 for existing CDP endpoint
|
||||
* 2. If occupied by non-Chrome, try 9223-9225
|
||||
* 3. If no CDP: find browser binary, quit gracefully, relaunch with --remote-debugging-port
|
||||
* 4. On attach failure: rollback — relaunch browser WITHOUT debug flag
|
||||
*
|
||||
* Reuses the browser registry pattern from cookie-import-browser.ts
|
||||
*/
|
||||
|
||||
import { execSync, spawn } from 'child_process';
|
||||
|
||||
// ─── Browser Binary Registry (macOS) ───────────────────────────
|
||||
|
||||
export interface BrowserBinary {
|
||||
name: string;
|
||||
binary: string;
|
||||
appName: string; // for osascript 'tell application "X"'
|
||||
aliases: string[];
|
||||
}
|
||||
|
||||
export const BROWSER_BINARIES: BrowserBinary[] = [
|
||||
{ name: 'Comet', binary: '/Applications/Comet.app/Contents/MacOS/Comet', appName: 'Comet', aliases: ['comet', 'perplexity'] },
|
||||
{ name: 'Chrome', binary: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', appName: 'Google Chrome', aliases: ['chrome', 'google-chrome'] },
|
||||
{ name: 'Arc', binary: '/Applications/Arc.app/Contents/MacOS/Arc', appName: 'Arc', aliases: ['arc'] },
|
||||
{ name: 'Brave', binary: '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser', appName: 'Brave Browser', aliases: ['brave'] },
|
||||
{ name: 'Edge', binary: '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', appName: 'Microsoft Edge', aliases: ['edge'] },
|
||||
];
|
||||
|
||||
// ─── CDP Probe ─────────────────────────────────────────────────
|
||||
|
||||
export interface CdpProbeResult {
|
||||
available: boolean;
|
||||
wsUrl?: string;
|
||||
browser?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a CDP endpoint is available at the given port.
|
||||
* Returns the WebSocket debugger URL if found.
|
||||
*/
|
||||
export async function isCdpAvailable(port: number): Promise<CdpProbeResult> {
|
||||
try {
|
||||
const resp = await fetch(`http://127.0.0.1:${port}/json/version`, {
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
if (!resp.ok) return { available: false };
|
||||
const data = await resp.json() as Record<string, string>;
|
||||
const wsUrl = data.webSocketDebuggerUrl;
|
||||
if (!wsUrl) return { available: false };
|
||||
return { available: true, wsUrl, browser: data.Browser };
|
||||
} catch {
|
||||
return { available: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the WebSocket debugger URL from a CDP port.
|
||||
* Throws if not available.
|
||||
*/
|
||||
export async function getCdpWebSocketUrl(port: number): Promise<string> {
|
||||
const result = await isCdpAvailable(port);
|
||||
if (!result.available || !result.wsUrl) {
|
||||
throw new Error(`No CDP endpoint at port ${port}`);
|
||||
}
|
||||
return result.wsUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try ports 9222-9225 to find an available CDP endpoint.
|
||||
*/
|
||||
export async function findCdpPort(): Promise<{ port: number; wsUrl: string; browser?: string } | null> {
|
||||
for (const port of [9222, 9223, 9224, 9225]) {
|
||||
const result = await isCdpAvailable(port);
|
||||
if (result.available && result.wsUrl) {
|
||||
return { port, wsUrl: result.wsUrl, browser: result.browser };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Browser Binary Discovery ──────────────────────────────────
|
||||
|
||||
import * as fs from 'fs';
|
||||
|
||||
/**
|
||||
* Find the binary path for a browser by name or alias.
|
||||
*/
|
||||
export function findBrowserBinary(nameOrAlias: string): BrowserBinary | null {
|
||||
const needle = nameOrAlias.toLowerCase();
|
||||
return BROWSER_BINARIES.find(b =>
|
||||
b.aliases.includes(needle) || b.name.toLowerCase() === needle
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find installed browsers (binary exists on disk).
|
||||
*/
|
||||
export function findInstalledBrowsers(): BrowserBinary[] {
|
||||
return BROWSER_BINARIES.filter(b => {
|
||||
try { return fs.existsSync(b.binary); } catch { return false; }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a browser is currently running (macOS: pgrep).
|
||||
*/
|
||||
export function isBrowserRunning(browser: BrowserBinary): boolean {
|
||||
try {
|
||||
// Use the app name to find the process
|
||||
execSync(`pgrep -f "${browser.appName}"`, { stdio: 'pipe' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Browser Launch with CDP ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* Quit a browser gracefully via osascript and relaunch with --remote-debugging-port.
|
||||
* Returns the CDP WebSocket URL on success.
|
||||
*
|
||||
* If the user's browser is running, this will:
|
||||
* 1. Quit it gracefully (tabs restored on relaunch)
|
||||
* 2. Wait 2s for clean shutdown
|
||||
* 3. Relaunch with --remote-debugging-port
|
||||
* 4. Poll for CDP availability (up to 15s)
|
||||
*
|
||||
* On failure: attempt to relaunch WITHOUT debug flag (rollback).
|
||||
*/
|
||||
export async function launchWithCdp(
|
||||
browser: BrowserBinary,
|
||||
port: number = 9222,
|
||||
): Promise<{ wsUrl: string; port: number }> {
|
||||
const wasRunning = isBrowserRunning(browser);
|
||||
|
||||
if (wasRunning) {
|
||||
// Quit gracefully via osascript
|
||||
try {
|
||||
execSync(`osascript -e 'tell application "${browser.appName}" to quit'`, {
|
||||
stdio: 'pipe',
|
||||
timeout: 10000,
|
||||
});
|
||||
} catch {
|
||||
throw new Error(`Failed to quit ${browser.name}. Close it manually and try again.`);
|
||||
}
|
||||
// Wait for clean shutdown
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
// Relaunch with CDP flag
|
||||
const child = spawn(browser.binary, [
|
||||
`--remote-debugging-port=${port}`,
|
||||
'--restore-last-session',
|
||||
], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
child.unref();
|
||||
|
||||
// Poll for CDP availability (up to 15s)
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < 15000) {
|
||||
const result = await isCdpAvailable(port);
|
||||
if (result.available && result.wsUrl) {
|
||||
return { wsUrl: result.wsUrl, port };
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
// Rollback: relaunch without debug flag so user gets their browser back
|
||||
if (wasRunning) {
|
||||
try {
|
||||
const rollback = spawn(browser.binary, ['--restore-last-session'], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
rollback.unref();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`CDP endpoint not available after 15s. ${browser.name} may not support --remote-debugging-port, ` +
|
||||
`or port ${port} is blocked. Browser has been relaunched without debug flag.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full discovery algorithm:
|
||||
* 1. Check for existing CDP on ports 9222-9225
|
||||
* 2. Find an installed browser (priority order)
|
||||
* 3. Launch/relaunch with CDP
|
||||
*
|
||||
* @param preferredBrowser - Optional browser name (e.g., 'chrome', 'comet')
|
||||
* @param port - CDP port (default 9222)
|
||||
*/
|
||||
export async function discoverAndConnect(
|
||||
preferredBrowser?: string,
|
||||
port: number = 9222,
|
||||
): Promise<{ wsUrl: string; port: number; browser: string }> {
|
||||
// Step 1: Check for existing CDP
|
||||
const existing = await findCdpPort();
|
||||
if (existing) {
|
||||
return {
|
||||
wsUrl: existing.wsUrl,
|
||||
port: existing.port,
|
||||
browser: existing.browser || 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: Find browser binary
|
||||
let browser: BrowserBinary | null = null;
|
||||
|
||||
if (preferredBrowser) {
|
||||
browser = findBrowserBinary(preferredBrowser);
|
||||
if (!browser) {
|
||||
const installed = findInstalledBrowsers();
|
||||
const names = installed.map(b => b.name.toLowerCase()).join(', ');
|
||||
throw new Error(
|
||||
`Browser '${preferredBrowser}' not found. Installed: ${names || 'none'}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Auto-detect: first installed browser in priority order
|
||||
const installed = findInstalledBrowsers();
|
||||
if (installed.length === 0) {
|
||||
throw new Error('No supported browser found. Install Chrome, Comet, Arc, Brave, or Edge.');
|
||||
}
|
||||
browser = installed[0];
|
||||
}
|
||||
|
||||
// Step 3: Launch with CDP
|
||||
const result = await launchWithCdp(browser, port);
|
||||
return { ...result, browser: browser.name };
|
||||
}
|
||||
+136
-6
@@ -21,6 +21,7 @@ import { handleCookiePickerRoute } from './cookie-picker-routes';
|
||||
import { COMMAND_DESCRIPTIONS } from './commands';
|
||||
import { SNAPSHOT_FLAGS } from './snapshot';
|
||||
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
|
||||
import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
@@ -224,6 +225,17 @@ async function handleCommand(body: any): Promise<Response> {
|
||||
});
|
||||
}
|
||||
|
||||
// Activity: emit command_start
|
||||
const startTime = Date.now();
|
||||
emitActivity({
|
||||
type: 'command_start',
|
||||
command,
|
||||
args,
|
||||
url: browserManager.getCurrentUrl(),
|
||||
tabs: browserManager.getTabCount(),
|
||||
mode: browserManager.getConnectionMode(),
|
||||
});
|
||||
|
||||
try {
|
||||
let result: string;
|
||||
|
||||
@@ -249,12 +261,38 @@ async function handleCommand(body: any): Promise<Response> {
|
||||
});
|
||||
}
|
||||
|
||||
// Activity: emit command_end (success)
|
||||
emitActivity({
|
||||
type: 'command_end',
|
||||
command,
|
||||
args,
|
||||
url: browserManager.getCurrentUrl(),
|
||||
duration: Date.now() - startTime,
|
||||
status: 'ok',
|
||||
result: result,
|
||||
tabs: browserManager.getTabCount(),
|
||||
mode: browserManager.getConnectionMode(),
|
||||
});
|
||||
|
||||
browserManager.resetFailures();
|
||||
return new Response(result, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
} catch (err: any) {
|
||||
// Activity: emit command_end (error)
|
||||
emitActivity({
|
||||
type: 'command_end',
|
||||
command,
|
||||
args,
|
||||
url: browserManager.getCurrentUrl(),
|
||||
duration: Date.now() - startTime,
|
||||
status: 'error',
|
||||
error: err.message,
|
||||
tabs: browserManager.getTabCount(),
|
||||
mode: browserManager.getConnectionMode(),
|
||||
});
|
||||
|
||||
browserManager.incrementFailures();
|
||||
let errorMsg = wrapError(err);
|
||||
const hint = browserManager.getFailureHint();
|
||||
@@ -296,16 +334,21 @@ async function start() {
|
||||
|
||||
const port = await findPort();
|
||||
|
||||
// Launch browser
|
||||
await browserManager.launch();
|
||||
// Launch browser (or connect to existing via CDP)
|
||||
const cdpUrl = process.env.BROWSE_CDP_URL;
|
||||
const cdpPort = parseInt(process.env.BROWSE_CDP_PORT || '0', 10);
|
||||
if (cdpUrl) {
|
||||
await browserManager.connectCDP(cdpUrl, cdpPort);
|
||||
console.log(`[browse] Connected to real browser via CDP (port ${cdpPort})`);
|
||||
} else {
|
||||
await browserManager.launch();
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
hostname: '127.0.0.1',
|
||||
fetch: async (req) => {
|
||||
resetIdleTimer();
|
||||
|
||||
const url = new URL(req.url);
|
||||
|
||||
// Cookie picker routes — no auth required (localhost-only)
|
||||
@@ -313,11 +356,12 @@ async function start() {
|
||||
return handleCookiePickerRoute(url, req, browserManager);
|
||||
}
|
||||
|
||||
// Health check — no auth required (now async)
|
||||
// Health check — no auth required, does NOT reset idle timer
|
||||
if (url.pathname === '/health') {
|
||||
const healthy = await browserManager.isHealthy();
|
||||
return new Response(JSON.stringify({
|
||||
status: healthy ? 'healthy' : 'unhealthy',
|
||||
mode: browserManager.getConnectionMode(),
|
||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||
tabs: browserManager.getTabCount(),
|
||||
currentUrl: browserManager.getCurrentUrl(),
|
||||
@@ -327,6 +371,89 @@ async function start() {
|
||||
});
|
||||
}
|
||||
|
||||
// Refs endpoint — no auth required (localhost-only), does NOT reset idle timer
|
||||
if (url.pathname === '/refs') {
|
||||
const refs = browserManager.getRefMap();
|
||||
return new Response(JSON.stringify({
|
||||
refs,
|
||||
url: browserManager.getCurrentUrl(),
|
||||
mode: browserManager.getConnectionMode(),
|
||||
}), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Activity stream — SSE, no auth (localhost-only), does NOT reset idle timer
|
||||
if (url.pathname === '/activity/stream') {
|
||||
const afterId = parseInt(url.searchParams.get('after') || '0', 10);
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
// 1. Gap detection + replay
|
||||
const { entries, gap, gapFrom, availableFrom } = getActivityAfter(afterId);
|
||||
if (gap) {
|
||||
controller.enqueue(encoder.encode(`event: gap\ndata: ${JSON.stringify({ gapFrom, availableFrom })}\n\n`));
|
||||
}
|
||||
for (const entry of entries) {
|
||||
controller.enqueue(encoder.encode(`event: activity\ndata: ${JSON.stringify(entry)}\n\n`));
|
||||
}
|
||||
|
||||
// 2. Subscribe for live events
|
||||
const unsubscribe = subscribe((entry) => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`event: activity\ndata: ${JSON.stringify(entry)}\n\n`));
|
||||
} catch {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Heartbeat every 15s
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`: heartbeat\n\n`));
|
||||
} catch {
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
// 4. Cleanup on disconnect
|
||||
req.signal.addEventListener('abort', () => {
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
try { controller.close(); } catch {}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Activity history — REST, no auth (localhost-only), does NOT reset idle timer
|
||||
if (url.pathname === '/activity/history') {
|
||||
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
|
||||
const { entries, totalAdded } = getActivityHistory(limit);
|
||||
return new Response(JSON.stringify({ entries, totalAdded, subscribers: getSubscriberCount() }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// All other endpoints require auth
|
||||
if (!validateAuth(req)) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
@@ -336,6 +463,7 @@ async function start() {
|
||||
}
|
||||
|
||||
if (url.pathname === '/command' && req.method === 'POST') {
|
||||
resetIdleTimer(); // Only commands reset idle timer
|
||||
const body = await req.json();
|
||||
return handleCommand(body);
|
||||
}
|
||||
@@ -345,13 +473,15 @@ async function start() {
|
||||
});
|
||||
|
||||
// Write state file (atomic: write .tmp then rename)
|
||||
const state = {
|
||||
const state: Record<string, unknown> = {
|
||||
pid: process.pid,
|
||||
port,
|
||||
token: AUTH_TOKEN,
|
||||
startedAt: new Date().toISOString(),
|
||||
serverPath: path.resolve(import.meta.dir, 'server.ts'),
|
||||
binaryVersion: readVersionHash() || undefined,
|
||||
mode: browserManager.getConnectionMode(),
|
||||
...(cdpPort ? { cdpPort } : {}),
|
||||
};
|
||||
const tmpFile = config.stateFile + '.tmp';
|
||||
fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { mode: 0o600 });
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, mock, beforeEach } from 'bun:test';
|
||||
import {
|
||||
findBrowserBinary,
|
||||
findInstalledBrowsers,
|
||||
isCdpAvailable,
|
||||
getCdpWebSocketUrl,
|
||||
findCdpPort,
|
||||
BROWSER_BINARIES,
|
||||
} from '../src/chrome-launcher';
|
||||
|
||||
// ─── chrome-launcher unit tests ─────────────────────────────────
|
||||
|
||||
describe('findBrowserBinary', () => {
|
||||
it('finds Chrome by alias', () => {
|
||||
const result = findBrowserBinary('chrome');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.name).toBe('Chrome');
|
||||
});
|
||||
|
||||
it('finds Chrome by name (case-insensitive)', () => {
|
||||
const result = findBrowserBinary('Chrome');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.name).toBe('Chrome');
|
||||
});
|
||||
|
||||
it('finds Comet by alias', () => {
|
||||
const result = findBrowserBinary('comet');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.name).toBe('Comet');
|
||||
});
|
||||
|
||||
it('finds Comet by perplexity alias', () => {
|
||||
const result = findBrowserBinary('perplexity');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.name).toBe('Comet');
|
||||
});
|
||||
|
||||
it('returns null for unknown browser', () => {
|
||||
expect(findBrowserBinary('netscape')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty string', () => {
|
||||
expect(findBrowserBinary('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('BROWSER_BINARIES', () => {
|
||||
it('has correct priority order (Comet first)', () => {
|
||||
expect(BROWSER_BINARIES[0].name).toBe('Comet');
|
||||
expect(BROWSER_BINARIES[1].name).toBe('Chrome');
|
||||
});
|
||||
|
||||
it('all entries have required fields', () => {
|
||||
for (const browser of BROWSER_BINARIES) {
|
||||
expect(browser.name).toBeTruthy();
|
||||
expect(browser.binary).toContain('/Applications/');
|
||||
expect(browser.appName).toBeTruthy();
|
||||
expect(browser.aliases.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCdpAvailable', () => {
|
||||
it('returns false for port with no listener', async () => {
|
||||
// Port 19999 should not have anything listening
|
||||
const result = await isCdpAvailable(19999);
|
||||
expect(result.available).toBe(false);
|
||||
expect(result.wsUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns false for invalid port', async () => {
|
||||
const result = await isCdpAvailable(0);
|
||||
expect(result.available).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCdpWebSocketUrl', () => {
|
||||
it('throws for unavailable port', async () => {
|
||||
await expect(getCdpWebSocketUrl(19999)).rejects.toThrow('No CDP endpoint');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findCdpPort', () => {
|
||||
it('returns null when no CDP ports are available', async () => {
|
||||
// This test passes in CI where no Chrome is running with debug port
|
||||
// In local dev with debug port open, it would find one
|
||||
const result = await findCdpPort();
|
||||
// Either null (no CDP) or valid result — both are correct
|
||||
if (result !== null) {
|
||||
expect(result.port).toBeGreaterThan(0);
|
||||
expect(result.wsUrl).toContain('ws://');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── BrowserManager CDP mode guards ─────────────────────────────
|
||||
|
||||
describe('BrowserManager CDP mode', () => {
|
||||
// These tests verify the mode guard logic without actually connecting
|
||||
// to a real browser. We test the public interface.
|
||||
|
||||
it('getConnectionMode defaults to launched', async () => {
|
||||
const { BrowserManager } = await import('../src/browser-manager');
|
||||
const bm = new BrowserManager();
|
||||
expect(bm.getConnectionMode()).toBe('launched');
|
||||
});
|
||||
|
||||
it('getRefMap returns empty array initially', async () => {
|
||||
const { BrowserManager } = await import('../src/browser-manager');
|
||||
const bm = new BrowserManager();
|
||||
expect(bm.getRefMap()).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user