refactor: reorganize codebase — move browse CLI to browse/ directory

Restructure project layout: src/ → browse/src/, test/ → browse/test/. Add snapshot testing. Update docs, package.json, and skills integration. Add setup script and TODO tracking.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-11 19:27:43 -07:00
parent bfa6102679
commit e9fbb664f8
29 changed files with 888 additions and 130 deletions
+253
View File
@@ -0,0 +1,253 @@
/**
* Browser lifecycle manager
*
* Chromium crash handling:
* browser.on('disconnected') → log error → process.exit(1)
* CLI detects dead server → auto-restarts on next command
* We do NOT try to self-heal — don't hide failure.
*/
import { chromium, type Browser, type BrowserContext, type Page, type Locator } from 'playwright';
import { addConsoleEntry, addNetworkEntry, networkBuffer, type LogEntry, type NetworkEntry } from './buffers';
export class BrowserManager {
private browser: Browser | null = null;
private context: BrowserContext | null = null;
private pages: Map<number, Page> = new Map();
private activeTabId: number = 0;
private nextTabId: number = 1;
private extraHeaders: Record<string, string> = {};
private customUserAgent: string | null = null;
// ─── Ref Map (snapshot → @e1, @e2, ...) ────────────────────
private refMap: Map<string, Locator> = new Map();
async launch() {
this.browser = await chromium.launch({ headless: true });
// Chromium crash → exit with clear message
this.browser.on('disconnected', () => {
console.error('[browse] FATAL: Chromium process crashed or was killed. Server exiting.');
console.error('[browse] Console/network logs flushed to /tmp/browse-*.log');
process.exit(1);
});
this.context = await this.browser.newContext({
viewport: { width: 1280, height: 720 },
});
// Create first tab
await this.newTab();
}
async close() {
if (this.browser) {
// Remove disconnect handler to avoid exit during intentional close
this.browser.removeAllListeners('disconnected');
await this.browser.close();
this.browser = null;
}
}
isHealthy(): boolean {
return this.browser !== null && this.browser.isConnected();
}
// ─── Tab Management ────────────────────────────────────────
async newTab(url?: string): Promise<number> {
if (!this.context) throw new Error('Browser not launched');
const page = await this.context.newPage();
const id = this.nextTabId++;
this.pages.set(id, page);
this.activeTabId = id;
// Wire up console/network capture
this.wirePageEvents(page);
if (url) {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
}
return id;
}
async closeTab(id?: number): Promise<void> {
const tabId = id ?? this.activeTabId;
const page = this.pages.get(tabId);
if (!page) throw new Error(`Tab ${tabId} not found`);
await page.close();
this.pages.delete(tabId);
// Switch to another tab if we closed the active one
if (tabId === this.activeTabId) {
const remaining = [...this.pages.keys()];
if (remaining.length > 0) {
this.activeTabId = remaining[remaining.length - 1];
} else {
// No tabs left — create a new blank one
await this.newTab();
}
}
}
switchTab(id: number): void {
if (!this.pages.has(id)) throw new Error(`Tab ${id} not found`);
this.activeTabId = id;
}
getTabCount(): number {
return this.pages.size;
}
getTabList(): Array<{ id: number; url: string; title: string; active: boolean }> {
const tabs: Array<{ id: number; url: string; title: string; active: boolean }> = [];
for (const [id, page] of this.pages) {
tabs.push({
id,
url: page.url(),
title: '', // title requires await, populated by caller
active: id === this.activeTabId,
});
}
return tabs;
}
async getTabListWithTitles(): Promise<Array<{ id: number; url: string; title: string; active: boolean }>> {
const tabs: Array<{ id: number; url: string; title: string; active: boolean }> = [];
for (const [id, page] of this.pages) {
tabs.push({
id,
url: page.url(),
title: await page.title().catch(() => ''),
active: id === this.activeTabId,
});
}
return tabs;
}
// ─── Page Access ───────────────────────────────────────────
getPage(): Page {
const page = this.pages.get(this.activeTabId);
if (!page) throw new Error('No active page. Use "browse goto <url>" first.');
return page;
}
getCurrentUrl(): string {
try {
return this.getPage().url();
} catch {
return 'about:blank';
}
}
// ─── Ref Map ──────────────────────────────────────────────
setRefMap(refs: Map<string, Locator>) {
this.refMap = refs;
}
clearRefs() {
this.refMap.clear();
}
/**
* Resolve a selector that may be a @ref (e.g., "@e3") or a CSS selector.
* Returns { locator } for refs or { selector } for CSS selectors.
*/
resolveRef(selector: string): { locator: Locator } | { selector: string } {
if (selector.startsWith('@e')) {
const ref = selector.slice(1); // "e3"
const locator = this.refMap.get(ref);
if (!locator) {
throw new Error(
`Ref ${selector} not found. Page may have changed — run 'snapshot' to get fresh refs.`
);
}
return { locator };
}
return { selector };
}
getRefCount(): number {
return this.refMap.size;
}
// ─── Viewport ──────────────────────────────────────────────
async setViewport(width: number, height: number) {
await this.getPage().setViewportSize({ width, height });
}
// ─── Extra Headers ─────────────────────────────────────────
async setExtraHeader(name: string, value: string) {
this.extraHeaders[name] = value;
if (this.context) {
await this.context.setExtraHTTPHeaders(this.extraHeaders);
}
}
// ─── User Agent ────────────────────────────────────────────
// Note: user agent changes require a new context in Playwright
// For simplicity, we just store it and apply on next "restart"
setUserAgent(ua: string) {
this.customUserAgent = ua;
}
// ─── Console/Network/Ref Wiring ────────────────────────────
private wirePageEvents(page: Page) {
// Clear ref map on navigation — refs point to stale elements after page change
page.on('framenavigated', (frame) => {
if (frame === page.mainFrame()) {
this.clearRefs();
}
});
page.on('console', (msg) => {
addConsoleEntry({
timestamp: Date.now(),
level: msg.type(),
text: msg.text(),
});
});
page.on('request', (req) => {
addNetworkEntry({
timestamp: Date.now(),
method: req.method(),
url: req.url(),
});
});
page.on('response', (res) => {
// Find matching request entry and update it
const url = res.url();
const status = res.status();
for (let i = networkBuffer.length - 1; i >= 0; i--) {
if (networkBuffer[i].url === url && !networkBuffer[i].status) {
networkBuffer[i].status = status;
networkBuffer[i].duration = Date.now() - networkBuffer[i].timestamp;
break;
}
}
});
// Capture response sizes via response finished
page.on('requestfinished', async (req) => {
try {
const res = await req.response();
if (res) {
const url = req.url();
const body = await res.body().catch(() => null);
const size = body ? body.length : 0;
for (let i = networkBuffer.length - 1; i >= 0; i--) {
if (networkBuffer[i].url === url && !networkBuffer[i].size) {
networkBuffer[i].size = size;
break;
}
}
}
} catch {}
});
}
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Shared buffers and types — extracted to break circular dependency
* between server.ts and browser-manager.ts
*/
export interface LogEntry {
timestamp: number;
level: string;
text: string;
}
export interface NetworkEntry {
timestamp: number;
method: string;
url: string;
status?: number;
duration?: number;
size?: number;
}
export const consoleBuffer: LogEntry[] = [];
export const networkBuffer: NetworkEntry[] = [];
const HIGH_WATER_MARK = 50_000;
export function addConsoleEntry(entry: LogEntry) {
consoleBuffer.push(entry);
if (consoleBuffer.length === HIGH_WATER_MARK) {
console.warn(`[browse] Console buffer reached ${HIGH_WATER_MARK} entries`);
}
}
export function addNetworkEntry(entry: NetworkEntry) {
networkBuffer.push(entry);
if (networkBuffer.length === HIGH_WATER_MARK) {
console.warn(`[browse] Network buffer reached ${HIGH_WATER_MARK} entries`);
}
}
+220
View File
@@ -0,0 +1,220 @@
/**
* gstack CLI — thin wrapper that talks to the persistent server
*
* Flow:
* 1. Read /tmp/browse-server.json for port + token
* 2. If missing or stale PID → start server in background
* 3. Health check
* 4. Send command via HTTP POST
* 5. Print response to stdout (or stderr for errors)
*/
import * as fs from 'fs';
import * as path from 'path';
const PORT_OFFSET = 45600;
const BROWSE_PORT = process.env.CONDUCTOR_PORT
? parseInt(process.env.CONDUCTOR_PORT, 10) - PORT_OFFSET
: parseInt(process.env.BROWSE_PORT || '0', 10);
const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : '';
const STATE_FILE = process.env.BROWSE_STATE_FILE || `/tmp/browse-server${INSTANCE_SUFFIX}.json`;
// When compiled, import.meta.dir is virtual. Use env var or well-known path.
const SERVER_SCRIPT = process.env.BROWSE_SERVER_SCRIPT
|| (import.meta.dir.startsWith('/') && !import.meta.dir.includes('$bunfs')
? path.resolve(import.meta.dir, 'server.ts')
: path.resolve(process.env.HOME || '/tmp', '.claude/skills/gstack/browse/src/server.ts'));
const MAX_START_WAIT = 8000; // 8 seconds to start
interface ServerState {
pid: number;
port: number;
token: string;
startedAt: string;
serverPath: string;
}
// ─── State File ────────────────────────────────────────────────
function readState(): ServerState | null {
try {
const data = fs.readFileSync(STATE_FILE, 'utf-8');
return JSON.parse(data);
} catch {
return null;
}
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
// ─── Server Lifecycle ──────────────────────────────────────────
async function startServer(): Promise<ServerState> {
// Clean up stale state file
try { fs.unlinkSync(STATE_FILE); } catch {}
// Start server as detached background process
const proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env },
});
// Don't hold the CLI open
proc.unref();
// Wait for state file to appear
const start = Date.now();
while (Date.now() - start < MAX_START_WAIT) {
const state = readState();
if (state && isProcessAlive(state.pid)) {
return state;
}
await Bun.sleep(100);
}
// If we get here, server didn't start in time
// Try to read stderr for error message
const stderr = proc.stderr;
if (stderr) {
const reader = stderr.getReader();
const { value } = await reader.read();
if (value) {
const errText = new TextDecoder().decode(value);
throw new Error(`Server failed to start:\n${errText}`);
}
}
throw new Error(`Server failed to start within ${MAX_START_WAIT / 1000}s`);
}
async function ensureServer(): Promise<ServerState> {
const state = readState();
if (state && isProcessAlive(state.pid)) {
// Server appears alive — do a health check
try {
const resp = await fetch(`http://127.0.0.1:${state.port}/health`, {
signal: AbortSignal.timeout(2000),
});
if (resp.ok) {
const health = await resp.json() as any;
if (health.status === 'healthy') {
return state;
}
}
} catch {
// Health check failed — server is dead or unhealthy
}
}
// Need to (re)start
console.error('[browse] Starting server...');
return startServer();
}
// ─── Command Dispatch ──────────────────────────────────────────
async function sendCommand(state: ServerState, command: string, args: string[]): Promise<void> {
const body = JSON.stringify({ command, args });
try {
const resp = await fetch(`http://127.0.0.1:${state.port}/command`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${state.token}`,
},
body,
signal: AbortSignal.timeout(30000),
});
if (resp.status === 401) {
// Token mismatch — server may have restarted
console.error('[browse] Auth failed — server may have restarted. Retrying...');
const newState = readState();
if (newState && newState.token !== state.token) {
return sendCommand(newState, command, args);
}
throw new Error('Authentication failed');
}
const text = await resp.text();
if (resp.ok) {
process.stdout.write(text);
if (!text.endsWith('\n')) process.stdout.write('\n');
} else {
// Try to parse as JSON error
try {
const err = JSON.parse(text);
console.error(err.error || text);
if (err.hint) console.error(err.hint);
} catch {
console.error(text);
}
process.exit(1);
}
} catch (err: any) {
if (err.name === 'AbortError') {
console.error('[browse] Command timed out after 30s');
process.exit(1);
}
// Connection error — server may have crashed
if (err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' || err.message?.includes('fetch failed')) {
console.error('[browse] Server connection lost. Restarting...');
const newState = await startServer();
return sendCommand(newState, command, args);
}
throw err;
}
}
// ─── Main ──────────────────────────────────────────────────────
async function main() {
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
console.log(`gstack browse — Fast headless browser for AI coding agents
Usage: browse <command> [args...]
Navigation: goto <url> | back | forward | reload | url
Content: text | html [sel] | links | forms | accessibility
Interaction: click <sel> | fill <sel> <val> | select <sel> <val>
hover <sel> | type <text> | press <key>
scroll [sel] | wait <sel> | viewport <WxH>
Inspection: js <expr> | eval <file> | css <sel> <prop> | attrs <sel>
console [--clear] | network [--clear]
cookies | storage [set <k> <v>] | perf
Visual: screenshot [path] | pdf [path] | responsive [prefix]
Snapshot: snapshot [-i] [-c] [-d N] [-s sel]
Compare: diff <url1> <url2>
Multi-step: chain (reads JSON from stdin)
Tabs: tabs | tab <id> | newtab [url] | closetab [id]
Server: status | cookie <n>=<v> | header <n>:<v>
useragent <str> | stop | restart
Refs: After 'snapshot', use @e1, @e2... as selectors:
click @e3 | fill @e4 "value" | hover @e1`);
process.exit(0);
}
const command = args[0];
const commandArgs = args.slice(1);
// Special case: chain reads from stdin
if (command === 'chain' && commandArgs.length === 0) {
const stdin = await Bun.stdin.text();
commandArgs.push(stdin.trim());
}
const state = await ensureServer();
await sendCommand(state, command, commandArgs);
}
main().catch((err) => {
console.error(`[browse] ${err.message}`);
process.exit(1);
});
+204
View File
@@ -0,0 +1,204 @@
/**
* Meta commands — tabs, server control, screenshots, chain, diff, snapshot
*/
import type { BrowserManager } from './browser-manager';
import { handleSnapshot } from './snapshot';
import * as Diff from 'diff';
import * as fs from 'fs';
export async function handleMetaCommand(
command: string,
args: string[],
bm: BrowserManager,
shutdown: () => Promise<void> | void
): Promise<string> {
switch (command) {
// ─── Tabs ──────────────────────────────────────────
case 'tabs': {
const tabs = await bm.getTabListWithTitles();
return tabs.map(t =>
`${t.active ? '→ ' : ' '}[${t.id}] ${t.title || '(untitled)'}${t.url}`
).join('\n');
}
case 'tab': {
const id = parseInt(args[0], 10);
if (isNaN(id)) throw new Error('Usage: browse tab <id>');
bm.switchTab(id);
return `Switched to tab ${id}`;
}
case 'newtab': {
const url = args[0];
const id = await bm.newTab(url);
return `Opened tab ${id}${url ? `${url}` : ''}`;
}
case 'closetab': {
const id = args[0] ? parseInt(args[0], 10) : undefined;
await bm.closeTab(id);
return `Closed tab${id ? ` ${id}` : ''}`;
}
// ─── Server Control ────────────────────────────────
case 'status': {
const page = bm.getPage();
const tabs = bm.getTabCount();
return [
`Status: healthy`,
`URL: ${page.url()}`,
`Tabs: ${tabs}`,
`PID: ${process.pid}`,
].join('\n');
}
case 'url': {
return bm.getCurrentUrl();
}
case 'stop': {
await shutdown();
return 'Server stopped';
}
case 'restart': {
// Signal that we want a restart — the CLI will detect exit and restart
console.log('[browse] Restart requested. Exiting for CLI to restart.');
await shutdown();
return 'Restarting...';
}
// ─── Visual ────────────────────────────────────────
case 'screenshot': {
const page = bm.getPage();
const screenshotPath = args[0] || '/tmp/browse-screenshot.png';
await page.screenshot({ path: screenshotPath, fullPage: true });
return `Screenshot saved: ${screenshotPath}`;
}
case 'pdf': {
const page = bm.getPage();
const pdfPath = args[0] || '/tmp/browse-page.pdf';
await page.pdf({ path: pdfPath, format: 'A4' });
return `PDF saved: ${pdfPath}`;
}
case 'responsive': {
const page = bm.getPage();
const prefix = args[0] || '/tmp/browse-responsive';
const viewports = [
{ name: 'mobile', width: 375, height: 812 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'desktop', width: 1280, height: 720 },
];
const originalViewport = page.viewportSize();
const results: string[] = [];
for (const vp of viewports) {
await page.setViewportSize({ width: vp.width, height: vp.height });
const path = `${prefix}-${vp.name}.png`;
await page.screenshot({ path, fullPage: true });
results.push(`${vp.name} (${vp.width}x${vp.height}): ${path}`);
}
// Restore original viewport
if (originalViewport) {
await page.setViewportSize(originalViewport);
}
return results.join('\n');
}
// ─── Chain ─────────────────────────────────────────
case 'chain': {
// Read JSON array from args[0] (if provided) or expect it was passed as body
const jsonStr = args[0];
if (!jsonStr) throw new Error('Usage: echo \'[["goto","url"],["text"]]\' | browse chain');
let commands: string[][];
try {
commands = JSON.parse(jsonStr);
} catch {
throw new Error('Invalid JSON. Expected: [["command", "arg1", "arg2"], ...]');
}
if (!Array.isArray(commands)) throw new Error('Expected JSON array of commands');
const results: string[] = [];
// Import handlers dynamically to avoid circular deps
const { handleReadCommand } = await import('./read-commands');
const { handleWriteCommand } = await import('./write-commands');
for (const cmd of commands) {
const [name, ...cmdArgs] = cmd;
try {
// Try each command type
let result: string;
try {
result = await handleWriteCommand(name, cmdArgs, bm);
} catch {
try {
result = await handleReadCommand(name, cmdArgs, bm);
} catch {
result = await handleMetaCommand(name, cmdArgs, bm, shutdown);
}
}
results.push(`[${name}] ${result}`);
} catch (err: any) {
results.push(`[${name}] ERROR: ${err.message}`);
}
}
return results.join('\n\n');
}
// ─── Diff ──────────────────────────────────────────
case 'diff': {
const [url1, url2] = args;
if (!url1 || !url2) throw new Error('Usage: browse diff <url1> <url2>');
// Get text from URL1
const page = bm.getPage();
await page.goto(url1, { waitUntil: 'domcontentloaded', timeout: 15000 });
const text1 = await page.evaluate(() => {
const body = document.body;
if (!body) return '';
const clone = body.cloneNode(true) as HTMLElement;
clone.querySelectorAll('script, style, noscript, svg').forEach(el => el.remove());
return clone.innerText.split('\n').map(l => l.trim()).filter(l => l).join('\n');
});
// Get text from URL2
await page.goto(url2, { waitUntil: 'domcontentloaded', timeout: 15000 });
const text2 = await page.evaluate(() => {
const body = document.body;
if (!body) return '';
const clone = body.cloneNode(true) as HTMLElement;
clone.querySelectorAll('script, style, noscript, svg').forEach(el => el.remove());
return clone.innerText.split('\n').map(l => l.trim()).filter(l => l).join('\n');
});
const changes = Diff.diffLines(text1, text2);
const output: string[] = [`--- ${url1}`, `+++ ${url2}`, ''];
for (const part of changes) {
const prefix = part.added ? '+' : part.removed ? '-' : ' ';
const lines = part.value.split('\n').filter(l => l.length > 0);
for (const line of lines) {
output.push(`${prefix} ${line}`);
}
}
return output.join('\n');
}
// ─── Snapshot ─────────────────────────────────────
case 'snapshot': {
return await handleSnapshot(args, bm);
}
default:
throw new Error(`Unknown meta command: ${command}`);
}
}
+221
View File
@@ -0,0 +1,221 @@
/**
* Read commands — extract data from pages without side effects
*
* text, html, links, forms, accessibility, js, eval, css, attrs,
* console, network, cookies, storage, perf
*/
import type { BrowserManager } from './browser-manager';
import { consoleBuffer, networkBuffer } from './buffers';
import * as fs from 'fs';
export async function handleReadCommand(
command: string,
args: string[],
bm: BrowserManager
): Promise<string> {
const page = bm.getPage();
switch (command) {
case 'text': {
return await page.evaluate(() => {
const body = document.body;
if (!body) return '';
const clone = body.cloneNode(true) as HTMLElement;
clone.querySelectorAll('script, style, noscript, svg').forEach(el => el.remove());
return clone.innerText
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
.join('\n');
});
}
case 'html': {
const selector = args[0];
if (selector) {
const resolved = bm.resolveRef(selector);
if ('locator' in resolved) {
return await resolved.locator.innerHTML({ timeout: 5000 });
}
return await page.innerHTML(resolved.selector);
}
return await page.content();
}
case 'links': {
const links = await page.evaluate(() =>
[...document.querySelectorAll('a[href]')].map(a => ({
text: a.textContent?.trim().slice(0, 120) || '',
href: (a as HTMLAnchorElement).href,
})).filter(l => l.text && l.href)
);
return links.map(l => `${l.text}${l.href}`).join('\n');
}
case 'forms': {
const forms = await page.evaluate(() => {
return [...document.querySelectorAll('form')].map((form, i) => {
const fields = [...form.querySelectorAll('input, select, textarea')].map(el => {
const input = el as HTMLInputElement;
return {
tag: el.tagName.toLowerCase(),
type: input.type || undefined,
name: input.name || undefined,
id: input.id || undefined,
placeholder: input.placeholder || undefined,
required: input.required || undefined,
value: input.value || undefined,
options: el.tagName === 'SELECT'
? [...(el as HTMLSelectElement).options].map(o => ({ value: o.value, text: o.text }))
: undefined,
};
});
return {
index: i,
action: form.action || undefined,
method: form.method || 'get',
id: form.id || undefined,
fields,
};
});
});
return JSON.stringify(forms, null, 2);
}
case 'accessibility': {
const snapshot = await page.locator("body").ariaSnapshot();
return snapshot;
}
case 'js': {
const expr = args[0];
if (!expr) throw new Error('Usage: browse js <expression>');
const result = await page.evaluate(expr);
return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
}
case 'eval': {
const filePath = args[0];
if (!filePath) throw new Error('Usage: browse eval <js-file>');
if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
const code = fs.readFileSync(filePath, 'utf-8');
const result = await page.evaluate(code);
return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
}
case 'css': {
const [selector, property] = args;
if (!selector || !property) throw new Error('Usage: browse css <selector> <property>');
const resolved = bm.resolveRef(selector);
if ('locator' in resolved) {
const value = await resolved.locator.evaluate(
(el, prop) => getComputedStyle(el).getPropertyValue(prop),
property
);
return value;
}
const value = await page.evaluate(
([sel, prop]) => {
const el = document.querySelector(sel);
if (!el) return `Element not found: ${sel}`;
return getComputedStyle(el).getPropertyValue(prop);
},
[resolved.selector, property]
);
return value;
}
case 'attrs': {
const selector = args[0];
if (!selector) throw new Error('Usage: browse attrs <selector>');
const resolved = bm.resolveRef(selector);
if ('locator' in resolved) {
const attrs = await resolved.locator.evaluate((el) => {
const result: Record<string, string> = {};
for (const attr of el.attributes) {
result[attr.name] = attr.value;
}
return result;
});
return JSON.stringify(attrs, null, 2);
}
const attrs = await page.evaluate((sel) => {
const el = document.querySelector(sel);
if (!el) return `Element not found: ${sel}`;
const result: Record<string, string> = {};
for (const attr of el.attributes) {
result[attr.name] = attr.value;
}
return result;
}, resolved.selector);
return typeof attrs === 'string' ? attrs : JSON.stringify(attrs, null, 2);
}
case 'console': {
if (args[0] === '--clear') {
consoleBuffer.length = 0;
return 'Console buffer cleared.';
}
if (consoleBuffer.length === 0) return '(no console messages)';
return consoleBuffer.map(e =>
`[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}`
).join('\n');
}
case 'network': {
if (args[0] === '--clear') {
networkBuffer.length = 0;
return 'Network buffer cleared.';
}
if (networkBuffer.length === 0) return '(no network requests)';
return networkBuffer.map(e =>
`${e.method} ${e.url}${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)`
).join('\n');
}
case 'cookies': {
const cookies = await page.context().cookies();
return JSON.stringify(cookies, null, 2);
}
case 'storage': {
if (args[0] === 'set' && args[1]) {
const key = args[1];
const value = args[2] || '';
await page.evaluate(([k, v]) => localStorage.setItem(k, v), [key, value]);
return `Set localStorage["${key}"] = "${value}"`;
}
const storage = await page.evaluate(() => ({
localStorage: { ...localStorage },
sessionStorage: { ...sessionStorage },
}));
return JSON.stringify(storage, null, 2);
}
case 'perf': {
const timings = await page.evaluate(() => {
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
if (!nav) return 'No navigation timing data available.';
return {
dns: Math.round(nav.domainLookupEnd - nav.domainLookupStart),
tcp: Math.round(nav.connectEnd - nav.connectStart),
ssl: Math.round(nav.secureConnectionStart > 0 ? nav.connectEnd - nav.secureConnectionStart : 0),
ttfb: Math.round(nav.responseStart - nav.requestStart),
download: Math.round(nav.responseEnd - nav.responseStart),
domParse: Math.round(nav.domInteractive - nav.responseEnd),
domReady: Math.round(nav.domContentLoadedEventEnd - nav.startTime),
load: Math.round(nav.loadEventEnd - nav.startTime),
total: Math.round(nav.loadEventEnd - nav.startTime),
};
});
if (typeof timings === 'string') return timings;
return Object.entries(timings)
.map(([k, v]) => `${k.padEnd(12)} ${v}ms`)
.join('\n');
}
default:
throw new Error(`Unknown read command: ${command}`);
}
}
+264
View File
@@ -0,0 +1,264 @@
/**
* gstack browse server — persistent Chromium daemon
*
* Architecture:
* Bun.serve HTTP on localhost → routes commands to Playwright
* Console/network buffers: in-memory (all entries) + disk flush every 1s
* Chromium crash → server EXITS with clear error (CLI auto-restarts)
* Auto-shutdown after BROWSE_IDLE_TIMEOUT (default 30 min)
*/
import { BrowserManager } from './browser-manager';
import { handleReadCommand } from './read-commands';
import { handleWriteCommand } from './write-commands';
import { handleMetaCommand } from './meta-commands';
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
// ─── Auth (inline) ─────────────────────────────────────────────
const AUTH_TOKEN = crypto.randomUUID();
const PORT_OFFSET = 45600;
const BROWSE_PORT = process.env.CONDUCTOR_PORT
? parseInt(process.env.CONDUCTOR_PORT, 10) - PORT_OFFSET
: parseInt(process.env.BROWSE_PORT || '0', 10); // 0 = auto-scan
const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : '';
const STATE_FILE = process.env.BROWSE_STATE_FILE || `/tmp/browse-server${INSTANCE_SUFFIX}.json`;
const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || '1800000', 10); // 30 min
function validateAuth(req: Request): boolean {
const header = req.headers.get('authorization');
return header === `Bearer ${AUTH_TOKEN}`;
}
// ─── Buffer (from buffers.ts) ────────────────────────────────────
import { consoleBuffer, networkBuffer, addConsoleEntry, addNetworkEntry, type LogEntry, type NetworkEntry } from './buffers';
export { consoleBuffer, networkBuffer, addConsoleEntry, addNetworkEntry, type LogEntry, type NetworkEntry };
const CONSOLE_LOG_PATH = `/tmp/browse-console${INSTANCE_SUFFIX}.log`;
const NETWORK_LOG_PATH = `/tmp/browse-network${INSTANCE_SUFFIX}.log`;
let lastConsoleFlushed = 0;
let lastNetworkFlushed = 0;
function flushBuffers() {
// Flush new console entries to disk
if (consoleBuffer.length > lastConsoleFlushed) {
const newEntries = consoleBuffer.slice(lastConsoleFlushed);
const lines = newEntries.map(e =>
`[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}`
).join('\n') + '\n';
fs.appendFileSync(CONSOLE_LOG_PATH, lines);
lastConsoleFlushed = consoleBuffer.length;
}
// Flush new network entries to disk
if (networkBuffer.length > lastNetworkFlushed) {
const newEntries = networkBuffer.slice(lastNetworkFlushed);
const lines = newEntries.map(e =>
`[${new Date(e.timestamp).toISOString()}] ${e.method} ${e.url}${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)`
).join('\n') + '\n';
fs.appendFileSync(NETWORK_LOG_PATH, lines);
lastNetworkFlushed = networkBuffer.length;
}
}
// Flush every 1 second
const flushInterval = setInterval(flushBuffers, 1000);
// ─── Idle Timer ────────────────────────────────────────────────
let lastActivity = Date.now();
function resetIdleTimer() {
lastActivity = Date.now();
}
const idleCheckInterval = setInterval(() => {
if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) {
console.log(`[browse] Idle for ${IDLE_TIMEOUT_MS / 1000}s, shutting down`);
shutdown();
}
}, 60_000);
// ─── Server ────────────────────────────────────────────────────
const browserManager = new BrowserManager();
let isShuttingDown = false;
// Read/write/meta command sets for routing
const READ_COMMANDS = new Set([
'text', 'html', 'links', 'forms', 'accessibility',
'js', 'eval', 'css', 'attrs',
'console', 'network', 'cookies', 'storage', 'perf',
]);
const WRITE_COMMANDS = new Set([
'goto', 'back', 'forward', 'reload',
'click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait',
'viewport', 'cookie', 'header', 'useragent',
]);
const META_COMMANDS = new Set([
'tabs', 'tab', 'newtab', 'closetab',
'status', 'stop', 'restart',
'screenshot', 'pdf', 'responsive',
'chain', 'diff',
'url', 'snapshot',
]);
// Find port: deterministic from CONDUCTOR_PORT, or scan range
async function findPort(): Promise<number> {
// Deterministic port from CONDUCTOR_PORT (e.g., 55040 - 45600 = 9440)
if (BROWSE_PORT) {
try {
const testServer = Bun.serve({ port: BROWSE_PORT, fetch: () => new Response('ok') });
testServer.stop();
return BROWSE_PORT;
} catch {
throw new Error(`[browse] Port ${BROWSE_PORT} (from CONDUCTOR_PORT ${process.env.CONDUCTOR_PORT}) is in use`);
}
}
// Fallback: scan range
const start = parseInt(process.env.BROWSE_PORT_START || '9400', 10);
for (let port = start; port < start + 10; port++) {
try {
const testServer = Bun.serve({ port, fetch: () => new Response('ok') });
testServer.stop();
return port;
} catch {
continue;
}
}
throw new Error(`[browse] No available port in range ${start}-${start + 9}`);
}
async function handleCommand(body: any): Promise<Response> {
const { command, args = [] } = body;
if (!command) {
return new Response(JSON.stringify({ error: 'Missing "command" field' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
try {
let result: string;
if (READ_COMMANDS.has(command)) {
result = await handleReadCommand(command, args, browserManager);
} else if (WRITE_COMMANDS.has(command)) {
result = await handleWriteCommand(command, args, browserManager);
} else if (META_COMMANDS.has(command)) {
result = await handleMetaCommand(command, args, browserManager, shutdown);
} else {
return new Response(JSON.stringify({
error: `Unknown command: ${command}`,
hint: `Available commands: ${[...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS].sort().join(', ')}`,
}), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(result, {
status: 200,
headers: { 'Content-Type': 'text/plain' },
});
} catch (err: any) {
return new Response(JSON.stringify({ error: err.message }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}
async function shutdown() {
if (isShuttingDown) return;
isShuttingDown = true;
console.log('[browse] Shutting down...');
clearInterval(flushInterval);
clearInterval(idleCheckInterval);
flushBuffers(); // Final flush
await browserManager.close();
// Clean up state file
try { fs.unlinkSync(STATE_FILE); } catch {}
process.exit(0);
}
// Handle signals
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
// ─── Start ─────────────────────────────────────────────────────
async function start() {
// Clear old log files
try { fs.unlinkSync(CONSOLE_LOG_PATH); } catch {}
try { fs.unlinkSync(NETWORK_LOG_PATH); } catch {}
const port = await findPort();
// Launch browser
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);
// Health check — no auth required
if (url.pathname === '/health') {
const healthy = browserManager.isHealthy();
return new Response(JSON.stringify({
status: healthy ? 'healthy' : 'unhealthy',
uptime: Math.floor((Date.now() - startTime) / 1000),
tabs: browserManager.getTabCount(),
currentUrl: browserManager.getCurrentUrl(),
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
// All other endpoints require auth
if (!validateAuth(req)) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.pathname === '/command' && req.method === 'POST') {
const body = await req.json();
return handleCommand(body);
}
return new Response('Not found', { status: 404 });
},
});
// Write state file
const state = {
pid: process.pid,
port,
token: AUTH_TOKEN,
startedAt: new Date().toISOString(),
serverPath: path.resolve(import.meta.dir, 'server.ts'),
};
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), { mode: 0o600 });
console.log(`[browse] Server running on http://127.0.0.1:${port} (PID: ${process.pid})`);
console.log(`[browse] State file: ${STATE_FILE}`);
console.log(`[browse] Idle timeout: ${IDLE_TIMEOUT_MS / 1000}s`);
}
start().catch((err) => {
console.error(`[browse] Failed to start: ${err.message}`);
process.exit(1);
});
+212
View File
@@ -0,0 +1,212 @@
/**
* Snapshot command — accessibility tree with ref-based element selection
*
* Architecture (Locator map — no DOM mutation):
* 1. page.locator(scope).ariaSnapshot() → YAML-like accessibility tree
* 2. Parse tree, assign refs @e1, @e2, ...
* 3. Build Playwright Locator for each ref (getByRole + nth)
* 4. Store Map<string, Locator> on BrowserManager
* 5. Return compact text output with refs prepended
*
* Later: "click @e3" → look up Locator → locator.click()
*/
import type { Page, Locator } from 'playwright';
import type { BrowserManager } from './browser-manager';
// Roles considered "interactive" for the -i flag
const INTERACTIVE_ROLES = new Set([
'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox',
'listbox', 'menuitem', 'menuitemcheckbox', 'menuitemradio',
'option', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab',
'treeitem',
]);
interface SnapshotOptions {
interactive?: boolean; // -i: only interactive elements
compact?: boolean; // -c: remove empty structural elements
depth?: number; // -d N: limit tree depth
selector?: string; // -s SEL: scope to CSS selector
}
interface ParsedNode {
indent: number;
role: string;
name: string | null;
props: string; // e.g., "[level=1]"
children: string; // inline text content after ":"
rawLine: string;
}
/**
* Parse CLI args into SnapshotOptions
*/
export function parseSnapshotArgs(args: string[]): SnapshotOptions {
const opts: SnapshotOptions = {};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '-i':
case '--interactive':
opts.interactive = true;
break;
case '-c':
case '--compact':
opts.compact = true;
break;
case '-d':
case '--depth':
opts.depth = parseInt(args[++i], 10);
if (isNaN(opts.depth!)) throw new Error('Usage: snapshot -d <number>');
break;
case '-s':
case '--selector':
opts.selector = args[++i];
if (!opts.selector) throw new Error('Usage: snapshot -s <selector>');
break;
default:
throw new Error(`Unknown snapshot flag: ${args[i]}`);
}
}
return opts;
}
/**
* Parse one line of ariaSnapshot output.
*
* Format examples:
* - heading "Test" [level=1]
* - link "Link A":
* - /url: /a
* - textbox "Name"
* - paragraph: Some text
* - combobox "Role":
*/
function parseLine(line: string): ParsedNode | null {
// Match: (indent)(- )(role)( "name")?( [props])?(: inline)?
const match = line.match(/^(\s*)-\s+(\w+)(?:\s+"([^"]*)")?(?:\s+(\[.*?\]))?\s*(?::\s*(.*))?$/);
if (!match) {
// Skip metadata lines like "- /url: /a"
return null;
}
return {
indent: match[1].length,
role: match[2],
name: match[3] ?? null,
props: match[4] || '',
children: match[5]?.trim() || '',
rawLine: line,
};
}
/**
* Take an accessibility snapshot and build the ref map.
*/
export async function handleSnapshot(
args: string[],
bm: BrowserManager
): Promise<string> {
const opts = parseSnapshotArgs(args);
const page = bm.getPage();
// Get accessibility tree via ariaSnapshot
let rootLocator: Locator;
if (opts.selector) {
rootLocator = page.locator(opts.selector);
const count = await rootLocator.count();
if (count === 0) throw new Error(`Selector not found: ${opts.selector}`);
} else {
rootLocator = page.locator('body');
}
const ariaText = await rootLocator.ariaSnapshot();
if (!ariaText || ariaText.trim().length === 0) {
bm.setRefMap(new Map());
return '(no accessible elements found)';
}
// Parse the ariaSnapshot output
const lines = ariaText.split('\n');
const refMap = new Map<string, Locator>();
const output: string[] = [];
let refCounter = 1;
// Track role+name occurrences for nth() disambiguation
const roleNameCounts = new Map<string, number>();
const roleNameSeen = new Map<string, number>();
// First pass: count role+name pairs for disambiguation
for (const line of lines) {
const node = parseLine(line);
if (!node) continue;
const key = `${node.role}:${node.name || ''}`;
roleNameCounts.set(key, (roleNameCounts.get(key) || 0) + 1);
}
// Second pass: assign refs and build locators
for (const line of lines) {
const node = parseLine(line);
if (!node) continue;
const depth = Math.floor(node.indent / 2);
const isInteractive = INTERACTIVE_ROLES.has(node.role);
// Depth filter
if (opts.depth !== undefined && depth > opts.depth) continue;
// Interactive filter: skip non-interactive but still count for locator indices
if (opts.interactive && !isInteractive) {
// Still track for nth() counts
const key = `${node.role}:${node.name || ''}`;
roleNameSeen.set(key, (roleNameSeen.get(key) || 0) + 1);
continue;
}
// Compact filter: skip elements with no name and no inline content that aren't interactive
if (opts.compact && !isInteractive && !node.name && !node.children) continue;
// Assign ref
const ref = `e${refCounter++}`;
const indent = ' '.repeat(depth);
// Build Playwright locator
const key = `${node.role}:${node.name || ''}`;
const seenIndex = roleNameSeen.get(key) || 0;
roleNameSeen.set(key, seenIndex + 1);
const totalCount = roleNameCounts.get(key) || 1;
let locator: Locator;
if (opts.selector) {
locator = page.locator(opts.selector).getByRole(node.role as any, {
name: node.name || undefined,
});
} else {
locator = page.getByRole(node.role as any, {
name: node.name || undefined,
});
}
// Disambiguate with nth() if multiple elements share role+name
if (totalCount > 1) {
locator = locator.nth(seenIndex);
}
refMap.set(ref, locator);
// Format output line
let outputLine = `${indent}@${ref} [${node.role}]`;
if (node.name) outputLine += ` "${node.name}"`;
if (node.props) outputLine += ` ${node.props}`;
if (node.children) outputLine += `: ${node.children}`;
output.push(outputLine);
}
// Store ref map on BrowserManager
bm.setRefMap(refMap);
if (output.length === 0) {
return '(no interactive elements found)';
}
return output.join('\n');
}
+179
View File
@@ -0,0 +1,179 @@
/**
* Write commands — navigate and interact with pages (side effects)
*
* goto, back, forward, reload, click, fill, select, hover, type,
* press, scroll, wait, viewport, cookie, header, useragent
*/
import type { BrowserManager } from './browser-manager';
export async function handleWriteCommand(
command: string,
args: string[],
bm: BrowserManager
): Promise<string> {
const page = bm.getPage();
switch (command) {
case 'goto': {
const url = args[0];
if (!url) throw new Error('Usage: browse goto <url>');
const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
const status = response?.status() || 'unknown';
return `Navigated to ${url} (${status})`;
}
case 'back': {
await page.goBack({ waitUntil: 'domcontentloaded', timeout: 15000 });
return `Back → ${page.url()}`;
}
case 'forward': {
await page.goForward({ waitUntil: 'domcontentloaded', timeout: 15000 });
return `Forward → ${page.url()}`;
}
case 'reload': {
await page.reload({ waitUntil: 'domcontentloaded', timeout: 15000 });
return `Reloaded ${page.url()}`;
}
case 'click': {
const selector = args[0];
if (!selector) throw new Error('Usage: browse click <selector>');
const resolved = bm.resolveRef(selector);
if ('locator' in resolved) {
await resolved.locator.click({ timeout: 5000 });
} else {
await page.click(resolved.selector, { timeout: 5000 });
}
// Wait briefly for any navigation/DOM update
await page.waitForLoadState('domcontentloaded').catch(() => {});
return `Clicked ${selector} → now at ${page.url()}`;
}
case 'fill': {
const [selector, ...valueParts] = args;
const value = valueParts.join(' ');
if (!selector || !value) throw new Error('Usage: browse fill <selector> <value>');
const resolved = bm.resolveRef(selector);
if ('locator' in resolved) {
await resolved.locator.fill(value, { timeout: 5000 });
} else {
await page.fill(resolved.selector, value, { timeout: 5000 });
}
return `Filled ${selector}`;
}
case 'select': {
const [selector, ...valueParts] = args;
const value = valueParts.join(' ');
if (!selector || !value) throw new Error('Usage: browse select <selector> <value>');
const resolved = bm.resolveRef(selector);
if ('locator' in resolved) {
await resolved.locator.selectOption(value, { timeout: 5000 });
} else {
await page.selectOption(resolved.selector, value, { timeout: 5000 });
}
return `Selected "${value}" in ${selector}`;
}
case 'hover': {
const selector = args[0];
if (!selector) throw new Error('Usage: browse hover <selector>');
const resolved = bm.resolveRef(selector);
if ('locator' in resolved) {
await resolved.locator.hover({ timeout: 5000 });
} else {
await page.hover(resolved.selector, { timeout: 5000 });
}
return `Hovered ${selector}`;
}
case 'type': {
const text = args.join(' ');
if (!text) throw new Error('Usage: browse type <text>');
await page.keyboard.type(text);
return `Typed "${text}"`;
}
case 'press': {
const key = args[0];
if (!key) throw new Error('Usage: browse press <key> (e.g., Enter, Tab, Escape)');
await page.keyboard.press(key);
return `Pressed ${key}`;
}
case 'scroll': {
const selector = args[0];
if (selector) {
const resolved = bm.resolveRef(selector);
if ('locator' in resolved) {
await resolved.locator.scrollIntoViewIfNeeded({ timeout: 5000 });
} else {
await page.locator(resolved.selector).scrollIntoViewIfNeeded({ timeout: 5000 });
}
return `Scrolled ${selector} into view`;
}
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
return 'Scrolled to bottom';
}
case 'wait': {
const selector = args[0];
if (!selector) throw new Error('Usage: browse wait <selector>');
const timeout = args[1] ? parseInt(args[1], 10) : 15000;
const resolved = bm.resolveRef(selector);
if ('locator' in resolved) {
await resolved.locator.waitFor({ state: 'visible', timeout });
} else {
await page.waitForSelector(resolved.selector, { timeout });
}
return `Element ${selector} appeared`;
}
case 'viewport': {
const size = args[0];
if (!size || !size.includes('x')) throw new Error('Usage: browse viewport <WxH> (e.g., 375x812)');
const [w, h] = size.split('x').map(Number);
await bm.setViewport(w, h);
return `Viewport set to ${w}x${h}`;
}
case 'cookie': {
const cookieStr = args[0];
if (!cookieStr || !cookieStr.includes('=')) throw new Error('Usage: browse cookie <name>=<value>');
const eq = cookieStr.indexOf('=');
const name = cookieStr.slice(0, eq);
const value = cookieStr.slice(eq + 1);
const url = new URL(page.url());
await page.context().addCookies([{
name,
value,
domain: url.hostname,
path: '/',
}]);
return `Cookie set: ${name}=${value}`;
}
case 'header': {
const headerStr = args[0];
if (!headerStr || !headerStr.includes(':')) throw new Error('Usage: browse header <name>:<value>');
const sep = headerStr.indexOf(':');
const name = headerStr.slice(0, sep).trim();
const value = headerStr.slice(sep + 1).trim();
await bm.setExtraHeader(name, value);
return `Header set: ${name}: ${value}`;
}
case 'useragent': {
const ua = args.join(' ');
if (!ua) throw new Error('Usage: browse useragent <string>');
bm.setUserAgent(ua);
return `User agent set (applies on next restart): ${ua}`;
}
default:
throw new Error(`Unknown write command: ${command}`);
}
}
+409
View File
@@ -0,0 +1,409 @@
/**
* Integration tests for all browse commands
*
* Tests run against a local test server serving fixture HTML files.
* A real browse server is started and commands are sent via the CLI HTTP interface.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { startTestServer } from './test-server';
import { BrowserManager } from '../src/browser-manager';
import { handleReadCommand } from '../src/read-commands';
import { handleWriteCommand } from '../src/write-commands';
import { handleMetaCommand } from '../src/meta-commands';
import { consoleBuffer, networkBuffer } from '../src/buffers';
import * as fs from 'fs';
let testServer: ReturnType<typeof startTestServer>;
let bm: BrowserManager;
let baseUrl: string;
beforeAll(async () => {
testServer = startTestServer(0);
baseUrl = testServer.url;
bm = new BrowserManager();
await bm.launch();
});
afterAll(() => {
// Force kill browser instead of graceful close (avoids hang)
try { testServer.server.stop(); } catch {}
// bm.close() can hang — just let process exit handle it
setTimeout(() => process.exit(0), 500);
});
// ─── Navigation ─────────────────────────────────────────────────
describe('Navigation', () => {
test('goto navigates to URL', async () => {
const result = await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
expect(result).toContain('Navigated to');
expect(result).toContain('200');
});
test('url returns current URL', async () => {
const result = await handleMetaCommand('url', [], bm, async () => {});
expect(result).toContain('/basic.html');
});
test('back goes back', async () => {
await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm);
const result = await handleWriteCommand('back', [], bm);
expect(result).toContain('Back');
});
test('forward goes forward', async () => {
const result = await handleWriteCommand('forward', [], bm);
expect(result).toContain('Forward');
});
test('reload reloads page', async () => {
const result = await handleWriteCommand('reload', [], bm);
expect(result).toContain('Reloaded');
});
});
// ─── Content Extraction ─────────────────────────────────────────
describe('Content extraction', () => {
beforeAll(async () => {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
});
test('text returns cleaned page text', async () => {
const result = await handleReadCommand('text', [], bm);
expect(result).toContain('Hello World');
expect(result).toContain('Item one');
expect(result).not.toContain('<h1>');
});
test('html returns full page HTML', async () => {
const result = await handleReadCommand('html', [], bm);
expect(result).toContain('<!DOCTYPE html>');
expect(result).toContain('<h1 id="title">Hello World</h1>');
});
test('html with selector returns element innerHTML', async () => {
const result = await handleReadCommand('html', ['#content'], bm);
expect(result).toContain('Some body text here.');
expect(result).toContain('<li>Item one</li>');
});
test('links returns all links', async () => {
const result = await handleReadCommand('links', [], bm);
expect(result).toContain('Page 1');
expect(result).toContain('Page 2');
expect(result).toContain('External');
expect(result).toContain('→');
});
test('forms discovers form fields', async () => {
await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm);
const result = await handleReadCommand('forms', [], bm);
const forms = JSON.parse(result);
expect(forms.length).toBe(2);
expect(forms[0].id).toBe('login-form');
expect(forms[0].method).toBe('post');
expect(forms[0].fields.length).toBeGreaterThanOrEqual(2);
expect(forms[1].id).toBe('profile-form');
// Check field discovery
const emailField = forms[0].fields.find((f: any) => f.name === 'email');
expect(emailField).toBeDefined();
expect(emailField.type).toBe('email');
expect(emailField.required).toBe(true);
});
test('accessibility returns ARIA tree', async () => {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
const result = await handleReadCommand('accessibility', [], bm);
expect(result).toContain('Hello World');
});
});
// ─── JavaScript / CSS / Attrs ───────────────────────────────────
describe('Inspection', () => {
beforeAll(async () => {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
});
test('js evaluates expression', async () => {
const result = await handleReadCommand('js', ['document.title'], bm);
expect(result).toBe('Test Page - Basic');
});
test('js returns objects as JSON', async () => {
const result = await handleReadCommand('js', ['({a: 1, b: 2})'], bm);
const obj = JSON.parse(result);
expect(obj.a).toBe(1);
expect(obj.b).toBe(2);
});
test('css returns computed property', async () => {
const result = await handleReadCommand('css', ['h1', 'color'], bm);
// Navy color
expect(result).toContain('0, 0, 128');
});
test('css returns font-family', async () => {
const result = await handleReadCommand('css', ['body', 'font-family'], bm);
expect(result).toContain('Helvetica');
});
test('attrs returns element attributes', async () => {
const result = await handleReadCommand('attrs', ['#content'], bm);
const attrs = JSON.parse(result);
expect(attrs.id).toBe('content');
expect(attrs['data-testid']).toBe('main-content');
expect(attrs['data-version']).toBe('1.0');
});
});
// ─── Interaction ────────────────────────────────────────────────
describe('Interaction', () => {
test('fill + click works on form', async () => {
await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm);
let result = await handleWriteCommand('fill', ['#email', 'test@example.com'], bm);
expect(result).toContain('Filled');
result = await handleWriteCommand('fill', ['#password', 'secret123'], bm);
expect(result).toContain('Filled');
// Verify values were set
const emailVal = await handleReadCommand('js', ['document.querySelector("#email").value'], bm);
expect(emailVal).toBe('test@example.com');
result = await handleWriteCommand('click', ['#login-btn'], bm);
expect(result).toContain('Clicked');
});
test('select works on dropdown', async () => {
await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm);
const result = await handleWriteCommand('select', ['#role', 'admin'], bm);
expect(result).toContain('Selected');
const val = await handleReadCommand('js', ['document.querySelector("#role").value'], bm);
expect(val).toBe('admin');
});
test('hover works', async () => {
const result = await handleWriteCommand('hover', ['h1'], bm);
expect(result).toContain('Hovered');
});
test('wait finds existing element', async () => {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
const result = await handleWriteCommand('wait', ['#title'], bm);
expect(result).toContain('appeared');
});
test('scroll works', async () => {
const result = await handleWriteCommand('scroll', ['footer'], bm);
expect(result).toContain('Scrolled');
});
test('viewport changes size', async () => {
const result = await handleWriteCommand('viewport', ['375x812'], bm);
expect(result).toContain('Viewport set');
const size = await handleReadCommand('js', ['`${window.innerWidth}x${window.innerHeight}`'], bm);
expect(size).toBe('375x812');
// Reset
await handleWriteCommand('viewport', ['1280x720'], bm);
});
test('type and press work', async () => {
await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm);
await handleWriteCommand('click', ['#name'], bm);
const result = await handleWriteCommand('type', ['John Doe'], bm);
expect(result).toContain('Typed');
const val = await handleReadCommand('js', ['document.querySelector("#name").value'], bm);
expect(val).toBe('John Doe');
});
});
// ─── SPA / Console / Network ───────────────────────────────────
describe('SPA and buffers', () => {
test('wait handles delayed rendering', async () => {
await handleWriteCommand('goto', [baseUrl + '/spa.html'], bm);
const result = await handleWriteCommand('wait', ['.loaded'], bm);
expect(result).toContain('appeared');
const text = await handleReadCommand('text', [], bm);
expect(text).toContain('SPA Content Loaded');
});
test('console captures messages', async () => {
const result = await handleReadCommand('console', [], bm);
expect(result).toContain('[SPA] Starting render');
expect(result).toContain('[SPA] Render complete');
});
test('console --clear clears buffer', async () => {
const result = await handleReadCommand('console', ['--clear'], bm);
expect(result).toContain('cleared');
const after = await handleReadCommand('console', [], bm);
expect(after).toContain('no console messages');
});
test('network captures requests', async () => {
const result = await handleReadCommand('network', [], bm);
expect(result).toContain('GET');
expect(result).toContain('/spa.html');
});
test('network --clear clears buffer', async () => {
const result = await handleReadCommand('network', ['--clear'], bm);
expect(result).toContain('cleared');
});
});
// ─── Cookies / Storage ──────────────────────────────────────────
describe('Cookies and storage', () => {
test('cookies returns array', async () => {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
const result = await handleReadCommand('cookies', [], bm);
// Test server doesn't set cookies, so empty array
expect(result).toBe('[]');
});
test('storage set and get works', async () => {
await handleReadCommand('storage', ['set', 'testKey', 'testValue'], bm);
const result = await handleReadCommand('storage', [], bm);
const storage = JSON.parse(result);
expect(storage.localStorage.testKey).toBe('testValue');
});
});
// ─── Performance ────────────────────────────────────────────────
describe('Performance', () => {
test('perf returns timing data', async () => {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
const result = await handleReadCommand('perf', [], bm);
expect(result).toContain('dns');
expect(result).toContain('ttfb');
expect(result).toContain('load');
expect(result).toContain('ms');
});
});
// ─── Visual ─────────────────────────────────────────────────────
describe('Visual', () => {
test('screenshot saves file', async () => {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
const screenshotPath = '/tmp/browse-test-screenshot.png';
const result = await handleMetaCommand('screenshot', [screenshotPath], bm, async () => {});
expect(result).toContain('Screenshot saved');
expect(fs.existsSync(screenshotPath)).toBe(true);
const stat = fs.statSync(screenshotPath);
expect(stat.size).toBeGreaterThan(1000);
fs.unlinkSync(screenshotPath);
});
test('responsive saves 3 screenshots', async () => {
await handleWriteCommand('goto', [baseUrl + '/responsive.html'], bm);
const prefix = '/tmp/browse-test-resp';
const result = await handleMetaCommand('responsive', [prefix], bm, async () => {});
expect(result).toContain('mobile');
expect(result).toContain('tablet');
expect(result).toContain('desktop');
expect(fs.existsSync(`${prefix}-mobile.png`)).toBe(true);
expect(fs.existsSync(`${prefix}-tablet.png`)).toBe(true);
expect(fs.existsSync(`${prefix}-desktop.png`)).toBe(true);
// Cleanup
fs.unlinkSync(`${prefix}-mobile.png`);
fs.unlinkSync(`${prefix}-tablet.png`);
fs.unlinkSync(`${prefix}-desktop.png`);
});
});
// ─── Tabs ───────────────────────────────────────────────────────
describe('Tabs', () => {
test('tabs lists all tabs', async () => {
const result = await handleMetaCommand('tabs', [], bm, async () => {});
expect(result).toContain('[');
expect(result).toContain(']');
});
test('newtab opens new tab', async () => {
const result = await handleMetaCommand('newtab', [baseUrl + '/forms.html'], bm, async () => {});
expect(result).toContain('Opened tab');
const tabCount = bm.getTabCount();
expect(tabCount).toBeGreaterThanOrEqual(2);
});
test('tab switches to specific tab', async () => {
const result = await handleMetaCommand('tab', ['1'], bm, async () => {});
expect(result).toContain('Switched to tab 1');
});
test('closetab closes a tab', async () => {
const before = bm.getTabCount();
// Close the last opened tab
const tabs = await bm.getTabListWithTitles();
const lastTab = tabs[tabs.length - 1];
const result = await handleMetaCommand('closetab', [String(lastTab.id)], bm, async () => {});
expect(result).toContain('Closed tab');
expect(bm.getTabCount()).toBe(before - 1);
});
});
// ─── Diff ───────────────────────────────────────────────────────
describe('Diff', () => {
test('diff shows differences between pages', async () => {
const result = await handleMetaCommand(
'diff',
[baseUrl + '/basic.html', baseUrl + '/forms.html'],
bm,
async () => {}
);
expect(result).toContain('---');
expect(result).toContain('+++');
// basic.html has "Hello World", forms.html has "Form Test Page"
expect(result).toContain('Hello World');
expect(result).toContain('Form Test Page');
});
});
// ─── Chain ──────────────────────────────────────────────────────
describe('Chain', () => {
test('chain executes sequence of commands', async () => {
const commands = JSON.stringify([
['goto', baseUrl + '/basic.html'],
['js', 'document.title'],
['css', 'h1', 'color'],
]);
const result = await handleMetaCommand('chain', [commands], bm, async () => {});
expect(result).toContain('[goto]');
expect(result).toContain('Test Page - Basic');
expect(result).toContain('[css]');
});
});
// ─── Status ─────────────────────────────────────────────────────
describe('Status', () => {
test('status reports health', async () => {
const result = await handleMetaCommand('status', [], bm, async () => {});
expect(result).toContain('Status: healthy');
expect(result).toContain('Tabs:');
});
});
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Test Page - Basic</title>
<style>
body { font-family: "Helvetica Neue", sans-serif; color: #333; margin: 20px; }
h1 { color: navy; font-size: 24px; }
.highlight { background: yellow; padding: 4px; }
.hidden { display: none; }
nav a { margin-right: 10px; color: blue; }
</style>
</head>
<body>
<nav>
<a href="/page1">Page 1</a>
<a href="/page2">Page 2</a>
<a href="https://external.com/link">External</a>
</nav>
<h1 id="title">Hello World</h1>
<p class="highlight">This is a highlighted paragraph.</p>
<p class="hidden">This should be hidden.</p>
<div id="content" data-testid="main-content" data-version="1.0">
<p>Some body text here.</p>
<ul>
<li>Item one</li>
<li>Item two</li>
<li>Item three</li>
</ul>
</div>
<footer>Footer text</footer>
</body>
</html>
+55
View File
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Test Page - Forms</title>
<style>
body { font-family: sans-serif; padding: 20px; }
form { margin-bottom: 20px; padding: 10px; border: 1px solid #ccc; }
label { display: block; margin: 5px 0; }
input, select, textarea { margin-bottom: 10px; padding: 5px; }
#result { color: green; display: none; }
</style>
</head>
<body>
<h1>Form Test Page</h1>
<form id="login-form" action="/login" method="post">
<label for="email">Email:</label>
<input type="email" id="email" name="email" placeholder="your@email.com" required>
<label for="password">Password:</label>
<input type="password" id="password" name="password" required>
<button type="submit" id="login-btn">Log In</button>
</form>
<form id="profile-form" action="/profile" method="post">
<label for="name">Name:</label>
<input type="text" id="name" name="name" placeholder="Your name">
<label for="bio">Bio:</label>
<textarea id="bio" name="bio" placeholder="Tell us about yourself"></textarea>
<label for="role">Role:</label>
<select id="role" name="role">
<option value="">Choose...</option>
<option value="admin">Admin</option>
<option value="user">User</option>
<option value="guest">Guest</option>
</select>
<label>
<input type="checkbox" id="newsletter" name="newsletter"> Subscribe to newsletter
</label>
<button type="submit" id="profile-btn">Save Profile</button>
</form>
<div id="result">Form submitted!</div>
<script>
document.querySelectorAll('form').forEach(form => {
form.addEventListener('submit', (e) => {
e.preventDefault();
document.getElementById('result').style.display = 'block';
console.log('[Form] Submitted:', form.id);
});
});
</script>
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Test Page - Responsive</title>
<style>
body { font-family: sans-serif; margin: 0; padding: 20px; }
.container { max-width: 1200px; margin: 0 auto; }
.grid { display: grid; gap: 16px; }
.card { padding: 16px; border: 1px solid #ddd; border-radius: 8px; }
/* Mobile: single column */
.grid { grid-template-columns: 1fr; }
/* Tablet: two columns */
@media (min-width: 768px) {
.grid { grid-template-columns: 1fr 1fr; }
.mobile-only { display: none; }
}
/* Desktop: three columns */
@media (min-width: 1024px) {
.grid { grid-template-columns: 1fr 1fr 1fr; }
}
.mobile-only { color: red; }
.desktop-indicator { display: none; }
@media (min-width: 1024px) {
.desktop-indicator { display: block; color: green; }
}
</style>
</head>
<body>
<div class="container">
<h1>Responsive Layout Test</h1>
<p class="mobile-only">You are on mobile</p>
<p class="desktop-indicator">You are on desktop</p>
<div class="grid">
<div class="card">Card 1</div>
<div class="card">Card 2</div>
<div class="card">Card 3</div>
<div class="card">Card 4</div>
<div class="card">Card 5</div>
<div class="card">Card 6</div>
</div>
</div>
</body>
</html>
+55
View File
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Snapshot Test Page</title>
<style>
body { font-family: sans-serif; padding: 20px; }
form { margin: 10px 0; }
input, select, button { margin: 5px; padding: 5px; }
#main { border: 1px solid #ccc; padding: 10px; }
.empty-div { }
.hidden { display: none; }
</style>
</head>
<body>
<h1>Snapshot Test</h1>
<h2>Subheading</h2>
<nav>
<a href="/page1">Internal Link</a>
<a href="https://external.com">External Link</a>
</nav>
<div id="main">
<h3>Form Section</h3>
<form id="test-form">
<input type="text" id="username" placeholder="Username" aria-label="Username">
<input type="email" id="email" placeholder="Email" aria-label="Email">
<input type="password" id="pass" placeholder="Password" aria-label="Password">
<label><input type="checkbox" id="agree"> I agree</label>
<select id="role" aria-label="Role">
<option value="">Choose...</option>
<option value="admin">Admin</option>
<option value="user">User</option>
</select>
<button type="submit" id="submit-btn">Submit</button>
<button type="button" id="cancel-btn">Cancel</button>
</form>
</div>
<div class="empty-div">
<div class="empty-div">
<button id="nested-btn">Nested Button</button>
</div>
</div>
<p>Some paragraph text that is not interactive.</p>
<script>
document.getElementById('test-form').addEventListener('submit', (e) => {
e.preventDefault();
});
</script>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Test Page - SPA</title>
<style>
body { font-family: sans-serif; }
#app { padding: 20px; }
.loaded { color: green; }
</style>
</head>
<body>
<div id="app">Loading...</div>
<script>
console.log('[SPA] Starting render');
console.warn('[SPA] This is a warning');
console.error('[SPA] This is an error');
setTimeout(() => {
document.getElementById('app').innerHTML = '<h1 class="loaded">SPA Content Loaded</h1><p>Rendered by JavaScript</p>';
console.log('[SPA] Render complete');
}, 500);
</script>
</body>
</html>
+201
View File
@@ -0,0 +1,201 @@
/**
* Snapshot command tests
*
* Tests: accessibility tree snapshots, ref-based element selection,
* ref invalidation on navigation, and ref resolution in commands.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { startTestServer } from './test-server';
import { BrowserManager } from '../src/browser-manager';
import { handleReadCommand } from '../src/read-commands';
import { handleWriteCommand } from '../src/write-commands';
import { handleMetaCommand } from '../src/meta-commands';
let testServer: ReturnType<typeof startTestServer>;
let bm: BrowserManager;
let baseUrl: string;
const shutdown = async () => {};
beforeAll(async () => {
testServer = startTestServer(0);
baseUrl = testServer.url;
bm = new BrowserManager();
await bm.launch();
});
afterAll(() => {
try { testServer.server.stop(); } catch {}
setTimeout(() => process.exit(0), 500);
});
// ─── Snapshot Output ────────────────────────────────────────────
describe('Snapshot', () => {
test('snapshot returns accessibility tree with refs', async () => {
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
const result = await handleMetaCommand('snapshot', [], bm, shutdown);
expect(result).toContain('@e');
expect(result).toContain('[heading]');
expect(result).toContain('"Snapshot Test"');
expect(result).toContain('[button]');
expect(result).toContain('[link]');
});
test('snapshot -i returns only interactive elements', async () => {
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
const result = await handleMetaCommand('snapshot', ['-i'], bm, shutdown);
expect(result).toContain('[button]');
expect(result).toContain('[link]');
expect(result).toContain('[textbox]');
// Should NOT contain non-interactive roles like heading or paragraph
expect(result).not.toContain('[heading]');
});
test('snapshot -c returns compact output', async () => {
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
const full = await handleMetaCommand('snapshot', [], bm, shutdown);
const compact = await handleMetaCommand('snapshot', ['-c'], bm, shutdown);
// Compact should have fewer lines (empty structural elements removed)
const fullLines = full.split('\n').length;
const compactLines = compact.split('\n').length;
expect(compactLines).toBeLessThanOrEqual(fullLines);
});
test('snapshot -d 2 limits depth', async () => {
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
const shallow = await handleMetaCommand('snapshot', ['-d', '2'], bm, shutdown);
const deep = await handleMetaCommand('snapshot', [], bm, shutdown);
// Shallow should have fewer or equal lines
expect(shallow.split('\n').length).toBeLessThanOrEqual(deep.split('\n').length);
});
test('snapshot -s "#main" scopes to selector', async () => {
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
const scoped = await handleMetaCommand('snapshot', ['-s', '#main'], bm, shutdown);
// Should contain elements inside #main
expect(scoped).toContain('[button]');
expect(scoped).toContain('"Submit"');
// Should NOT contain elements outside #main (like nav links)
expect(scoped).not.toContain('"Internal Link"');
});
test('snapshot on page with no interactive elements', async () => {
// Navigate to about:blank which has minimal content
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
const result = await handleMetaCommand('snapshot', ['-i'], bm, shutdown);
// basic.html has links, so this should find those
expect(result).toContain('[link]');
});
test('second snapshot generates fresh refs', async () => {
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
const snap1 = await handleMetaCommand('snapshot', [], bm, shutdown);
const snap2 = await handleMetaCommand('snapshot', [], bm, shutdown);
// Both should have @e1 (refs restart from 1)
expect(snap1).toContain('@e1');
expect(snap2).toContain('@e1');
});
});
// ─── Ref-Based Interaction ──────────────────────────────────────
describe('Ref resolution', () => {
test('click @ref works after snapshot', async () => {
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
const snap = await handleMetaCommand('snapshot', ['-i'], bm, shutdown);
// Find a button ref
const buttonLine = snap.split('\n').find(l => l.includes('[button]') && l.includes('"Submit"'));
expect(buttonLine).toBeDefined();
const refMatch = buttonLine!.match(/@(e\d+)/);
expect(refMatch).toBeDefined();
const ref = `@${refMatch![1]}`;
const result = await handleWriteCommand('click', [ref], bm);
expect(result).toContain('Clicked');
});
test('fill @ref works after snapshot', async () => {
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
const snap = await handleMetaCommand('snapshot', ['-i'], bm, shutdown);
// Find a textbox ref (Username)
const textboxLine = snap.split('\n').find(l => l.includes('[textbox]') && l.includes('"Username"'));
expect(textboxLine).toBeDefined();
const refMatch = textboxLine!.match(/@(e\d+)/);
expect(refMatch).toBeDefined();
const ref = `@${refMatch![1]}`;
const result = await handleWriteCommand('fill', [ref, 'testuser'], bm);
expect(result).toContain('Filled');
});
test('hover @ref works after snapshot', async () => {
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
const snap = await handleMetaCommand('snapshot', ['-i'], bm, shutdown);
const linkLine = snap.split('\n').find(l => l.includes('[link]'));
expect(linkLine).toBeDefined();
const refMatch = linkLine!.match(/@(e\d+)/);
const ref = `@${refMatch![1]}`;
const result = await handleWriteCommand('hover', [ref], bm);
expect(result).toContain('Hovered');
});
test('html @ref returns innerHTML', async () => {
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
const snap = await handleMetaCommand('snapshot', [], bm, shutdown);
// Find a heading ref
const headingLine = snap.split('\n').find(l => l.includes('[heading]') && l.includes('"Snapshot Test"'));
expect(headingLine).toBeDefined();
const refMatch = headingLine!.match(/@(e\d+)/);
const ref = `@${refMatch![1]}`;
const result = await handleReadCommand('html', [ref], bm);
expect(result).toContain('Snapshot Test');
});
test('css @ref returns computed CSS', async () => {
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
const snap = await handleMetaCommand('snapshot', [], bm, shutdown);
const headingLine = snap.split('\n').find(l => l.includes('[heading]') && l.includes('"Snapshot Test"'));
const refMatch = headingLine!.match(/@(e\d+)/);
const ref = `@${refMatch![1]}`;
const result = await handleReadCommand('css', [ref, 'font-family'], bm);
expect(result).toBeTruthy();
});
test('attrs @ref returns element attributes', async () => {
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
const snap = await handleMetaCommand('snapshot', ['-i'], bm, shutdown);
const textboxLine = snap.split('\n').find(l => l.includes('[textbox]') && l.includes('"Username"'));
const refMatch = textboxLine!.match(/@(e\d+)/);
const ref = `@${refMatch![1]}`;
const result = await handleReadCommand('attrs', [ref], bm);
expect(result).toContain('id');
});
});
// ─── Ref Invalidation ───────────────────────────────────────────
describe('Ref invalidation', () => {
test('stale ref after goto returns clear error', async () => {
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
await handleMetaCommand('snapshot', ['-i'], bm, shutdown);
// Navigate away — should invalidate refs
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
// Try to use old ref
try {
await handleWriteCommand('click', ['@e1'], bm);
expect(true).toBe(false); // Should not reach here
} catch (err: any) {
expect(err.message).toContain('not found');
expect(err.message).toContain('snapshot');
}
});
test('refs cleared on page navigation', async () => {
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
await handleMetaCommand('snapshot', ['-i'], bm, shutdown);
expect(bm.getRefCount()).toBeGreaterThan(0);
// Navigate
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
expect(bm.getRefCount()).toBe(0);
});
});
+47
View File
@@ -0,0 +1,47 @@
/**
* Tiny Bun.serve for test fixtures
* Serves HTML files from test/fixtures/ on a random available port
*/
import * as path from 'path';
import * as fs from 'fs';
const FIXTURES_DIR = path.resolve(import.meta.dir, 'fixtures');
export function startTestServer(port: number = 0): { server: ReturnType<typeof Bun.serve>; url: string } {
const server = Bun.serve({
port,
hostname: '127.0.0.1',
fetch(req) {
const url = new URL(req.url);
let filePath = url.pathname === '/' ? '/basic.html' : url.pathname;
// Remove leading slash
filePath = filePath.replace(/^\//, '');
const fullPath = path.join(FIXTURES_DIR, filePath);
if (!fs.existsSync(fullPath)) {
return new Response('Not Found', { status: 404 });
}
const content = fs.readFileSync(fullPath, 'utf-8');
const ext = path.extname(fullPath);
const contentType = ext === '.html' ? 'text/html' : 'text/plain';
return new Response(content, {
headers: { 'Content-Type': contentType },
});
},
});
const url = `http://127.0.0.1:${server.port}`;
return { server, url };
}
// If run directly, start and print URL
if (import.meta.main) {
const { server, url } = startTestServer(9450);
console.log(`Test server running at ${url}`);
console.log(`Fixtures: ${FIXTURES_DIR}`);
console.log('Press Ctrl+C to stop');
}