mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 11:45:20 +02:00
Phase 2: Enhanced browser — dialog handling, upload, state checks, snapshots
- CircularBuffer O(1) ring buffer for console/network/dialog (was O(n) array+shift) - Async buffer flush with Bun.write() (was appendFileSync) - Dialog auto-accept/dismiss with buffer + prompt text support - File upload command (upload <sel> <file...>) - Element state checks (is visible/hidden/enabled/disabled/checked/editable/focused) - Annotated screenshots with ref labels overlaid (-a flag) - Snapshot diffing against previous snapshot (-D flag) - Cursor-interactive element scan for non-ARIA clickables (-C flag) - Snapshot scoping depth limit (-d N flag) - Health check with page.evaluate + 2s timeout - Playwright error wrapping — actionable messages for AI agents - Fix useragent — context recreation preserves cookies/storage/URLs - wait --networkidle / --load / --domcontentloaded flags - console --errors filter (error + warning only) - cookie-import <json-file> with auto-fill domain from page URL - 166 integration tests (was ~63) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+230
-33
@@ -5,10 +5,18 @@
|
||||
* 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.
|
||||
*
|
||||
* Dialog handling:
|
||||
* page.on('dialog') → auto-accept by default → store in dialog buffer
|
||||
* Prevents browser lockup from alert/confirm/prompt
|
||||
*
|
||||
* Context recreation (useragent):
|
||||
* recreateContext() saves cookies/storage/URLs, creates new context,
|
||||
* restores state. Falls back to clean slate on any failure.
|
||||
*/
|
||||
|
||||
import { chromium, type Browser, type BrowserContext, type Page, type Locator } from 'playwright';
|
||||
import { addConsoleEntry, addNetworkEntry, networkBuffer, type LogEntry, type NetworkEntry } from './buffers';
|
||||
import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers';
|
||||
|
||||
export class BrowserManager {
|
||||
private browser: Browser | null = null;
|
||||
@@ -19,9 +27,17 @@ export class BrowserManager {
|
||||
private extraHeaders: Record<string, string> = {};
|
||||
private customUserAgent: string | null = null;
|
||||
|
||||
// ─── Ref Map (snapshot → @e1, @e2, ...) ────────────────────
|
||||
// ─── Ref Map (snapshot → @e1, @e2, @c1, @c2, ...) ────────
|
||||
private refMap: Map<string, Locator> = new Map();
|
||||
|
||||
// ─── Snapshot Diffing ─────────────────────────────────────
|
||||
// NOT cleared on navigation — it's a text baseline for diffing
|
||||
private lastSnapshot: string | null = null;
|
||||
|
||||
// ─── Dialog Handling ──────────────────────────────────────
|
||||
private dialogAutoAccept: boolean = true;
|
||||
private dialogPromptText: string | null = null;
|
||||
|
||||
async launch() {
|
||||
this.browser = await chromium.launch({ headless: true });
|
||||
|
||||
@@ -32,9 +48,17 @@ export class BrowserManager {
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
this.context = await this.browser.newContext({
|
||||
const contextOptions: any = {
|
||||
viewport: { width: 1280, height: 720 },
|
||||
});
|
||||
};
|
||||
if (this.customUserAgent) {
|
||||
contextOptions.userAgent = this.customUserAgent;
|
||||
}
|
||||
this.context = await this.browser.newContext(contextOptions);
|
||||
|
||||
if (Object.keys(this.extraHeaders).length > 0) {
|
||||
await this.context.setExtraHTTPHeaders(this.extraHeaders);
|
||||
}
|
||||
|
||||
// Create first tab
|
||||
await this.newTab();
|
||||
@@ -49,8 +73,20 @@ export class BrowserManager {
|
||||
}
|
||||
}
|
||||
|
||||
isHealthy(): boolean {
|
||||
return this.browser !== null && this.browser.isConnected();
|
||||
/** Health check — verifies Chromium is connected AND responsive */
|
||||
async isHealthy(): Promise<boolean> {
|
||||
if (!this.browser || !this.browser.isConnected()) return false;
|
||||
try {
|
||||
const page = this.pages.get(this.activeTabId);
|
||||
if (!page) return true; // connected but no pages — still healthy
|
||||
await Promise.race([
|
||||
page.evaluate('1'),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 2000)),
|
||||
]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tab Management ────────────────────────────────────────
|
||||
@@ -62,7 +98,7 @@ export class BrowserManager {
|
||||
this.pages.set(id, page);
|
||||
this.activeTabId = id;
|
||||
|
||||
// Wire up console/network capture
|
||||
// Wire up console/network/dialog capture
|
||||
this.wirePageEvents(page);
|
||||
|
||||
if (url) {
|
||||
@@ -101,19 +137,6 @@ export class BrowserManager {
|
||||
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) {
|
||||
@@ -152,12 +175,12 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a selector that may be a @ref (e.g., "@e3") or a CSS selector.
|
||||
* Resolve a selector that may be a @ref (e.g., "@e3", "@c1") 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"
|
||||
if (selector.startsWith('@e') || selector.startsWith('@c')) {
|
||||
const ref = selector.slice(1); // "e3" or "c1"
|
||||
const locator = this.refMap.get(ref);
|
||||
if (!locator) {
|
||||
throw new Error(
|
||||
@@ -173,6 +196,32 @@ export class BrowserManager {
|
||||
return this.refMap.size;
|
||||
}
|
||||
|
||||
// ─── Snapshot Diffing ─────────────────────────────────────
|
||||
setLastSnapshot(text: string | null) {
|
||||
this.lastSnapshot = text;
|
||||
}
|
||||
|
||||
getLastSnapshot(): string | null {
|
||||
return this.lastSnapshot;
|
||||
}
|
||||
|
||||
// ─── Dialog Control ───────────────────────────────────────
|
||||
setDialogAutoAccept(accept: boolean) {
|
||||
this.dialogAutoAccept = accept;
|
||||
}
|
||||
|
||||
getDialogAutoAccept(): boolean {
|
||||
return this.dialogAutoAccept;
|
||||
}
|
||||
|
||||
setDialogPromptText(text: string | null) {
|
||||
this.dialogPromptText = text;
|
||||
}
|
||||
|
||||
getDialogPromptText(): string | null {
|
||||
return this.dialogPromptText;
|
||||
}
|
||||
|
||||
// ─── Viewport ──────────────────────────────────────────────
|
||||
async setViewport(width: number, height: number) {
|
||||
await this.getPage().setViewportSize({ width, height });
|
||||
@@ -187,21 +236,169 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
// ─── 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 ────────────────────────────
|
||||
getUserAgent(): string | null {
|
||||
return this.customUserAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recreate the browser context to apply user agent changes.
|
||||
* Saves and restores cookies, localStorage, sessionStorage, and open pages.
|
||||
* Falls back to a clean slate on any failure.
|
||||
*/
|
||||
async recreateContext(): Promise<string | null> {
|
||||
if (!this.browser || !this.context) {
|
||||
throw new Error('Browser not launched');
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Save state from current context
|
||||
const savedCookies = await this.context.cookies();
|
||||
const savedPages: Array<{ url: string; isActive: boolean; storage: any }> = [];
|
||||
|
||||
for (const [id, page] of this.pages) {
|
||||
const url = page.url();
|
||||
let storage = null;
|
||||
try {
|
||||
storage = await page.evaluate(() => ({
|
||||
localStorage: { ...localStorage },
|
||||
sessionStorage: { ...sessionStorage },
|
||||
}));
|
||||
} catch {}
|
||||
savedPages.push({
|
||||
url: url === 'about:blank' ? '' : url,
|
||||
isActive: id === this.activeTabId,
|
||||
storage,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Close old pages and context
|
||||
for (const page of this.pages.values()) {
|
||||
await page.close().catch(() => {});
|
||||
}
|
||||
this.pages.clear();
|
||||
await this.context.close().catch(() => {});
|
||||
|
||||
// 3. Create new context with updated settings
|
||||
const contextOptions: any = {
|
||||
viewport: { width: 1280, height: 720 },
|
||||
};
|
||||
if (this.customUserAgent) {
|
||||
contextOptions.userAgent = this.customUserAgent;
|
||||
}
|
||||
this.context = await this.browser.newContext(contextOptions);
|
||||
|
||||
if (Object.keys(this.extraHeaders).length > 0) {
|
||||
await this.context.setExtraHTTPHeaders(this.extraHeaders);
|
||||
}
|
||||
|
||||
// 4. Restore cookies
|
||||
if (savedCookies.length > 0) {
|
||||
await this.context.addCookies(savedCookies);
|
||||
}
|
||||
|
||||
// 5. Re-create pages
|
||||
let activeId: number | null = null;
|
||||
for (const saved of savedPages) {
|
||||
const page = await this.context.newPage();
|
||||
const id = this.nextTabId++;
|
||||
this.pages.set(id, page);
|
||||
this.wirePageEvents(page);
|
||||
|
||||
if (saved.url) {
|
||||
await page.goto(saved.url, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {});
|
||||
}
|
||||
|
||||
// 6. Restore storage
|
||||
if (saved.storage) {
|
||||
try {
|
||||
await page.evaluate((s: any) => {
|
||||
if (s.localStorage) {
|
||||
for (const [k, v] of Object.entries(s.localStorage)) {
|
||||
localStorage.setItem(k, v as string);
|
||||
}
|
||||
}
|
||||
if (s.sessionStorage) {
|
||||
for (const [k, v] of Object.entries(s.sessionStorage)) {
|
||||
sessionStorage.setItem(k, v as string);
|
||||
}
|
||||
}
|
||||
}, saved.storage);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (saved.isActive) activeId = id;
|
||||
}
|
||||
|
||||
// If no pages were saved, create a blank one
|
||||
if (this.pages.size === 0) {
|
||||
await this.newTab();
|
||||
} else {
|
||||
this.activeTabId = activeId ?? [...this.pages.keys()][0];
|
||||
}
|
||||
|
||||
// Clear refs — pages are new, locators are stale
|
||||
this.clearRefs();
|
||||
|
||||
return null; // success
|
||||
} catch (err: any) {
|
||||
// Fallback: create a clean context + blank tab
|
||||
try {
|
||||
this.pages.clear();
|
||||
if (this.context) await this.context.close().catch(() => {});
|
||||
|
||||
const contextOptions: any = {
|
||||
viewport: { width: 1280, height: 720 },
|
||||
};
|
||||
if (this.customUserAgent) {
|
||||
contextOptions.userAgent = this.customUserAgent;
|
||||
}
|
||||
this.context = await this.browser!.newContext(contextOptions);
|
||||
await this.newTab();
|
||||
this.clearRefs();
|
||||
} catch {
|
||||
// If even the fallback fails, we're in trouble — but browser is still alive
|
||||
}
|
||||
return `Context recreation failed: ${err.message}. Browser reset to blank tab.`;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Console/Network/Dialog/Ref Wiring ────────────────────
|
||||
private wirePageEvents(page: Page) {
|
||||
// Clear ref map on navigation — refs point to stale elements after page change
|
||||
// (lastSnapshot is NOT cleared — it's a text baseline for diffing)
|
||||
page.on('framenavigated', (frame) => {
|
||||
if (frame === page.mainFrame()) {
|
||||
this.clearRefs();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Dialog auto-handling (prevents browser lockup) ─────
|
||||
page.on('dialog', async (dialog) => {
|
||||
const entry: DialogEntry = {
|
||||
timestamp: Date.now(),
|
||||
type: dialog.type(),
|
||||
message: dialog.message(),
|
||||
defaultValue: dialog.defaultValue() || undefined,
|
||||
action: this.dialogAutoAccept ? 'accepted' : 'dismissed',
|
||||
response: this.dialogAutoAccept ? (this.dialogPromptText ?? undefined) : undefined,
|
||||
};
|
||||
addDialogEntry(entry);
|
||||
|
||||
try {
|
||||
if (this.dialogAutoAccept) {
|
||||
await dialog.accept(this.dialogPromptText ?? undefined);
|
||||
} else {
|
||||
await dialog.dismiss();
|
||||
}
|
||||
} catch {
|
||||
// Dialog may have been dismissed by navigation — ignore
|
||||
}
|
||||
});
|
||||
|
||||
page.on('console', (msg) => {
|
||||
addConsoleEntry({
|
||||
timestamp: Date.now(),
|
||||
@@ -219,13 +416,13 @@ export class BrowserManager {
|
||||
});
|
||||
|
||||
page.on('response', (res) => {
|
||||
// Find matching request entry and update it
|
||||
// Find matching request entry and update it (backward scan)
|
||||
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;
|
||||
const entry = networkBuffer.get(i);
|
||||
if (entry && entry.url === url && !entry.status) {
|
||||
networkBuffer.set(i, { ...entry, status, duration: Date.now() - entry.timestamp });
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -240,8 +437,9 @@ export class BrowserManager {
|
||||
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;
|
||||
const entry = networkBuffer.get(i);
|
||||
if (entry && entry.url === url && !entry.size) {
|
||||
networkBuffer.set(i, { ...entry, size });
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -250,4 +448,3 @@ export class BrowserManager {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+107
-14
@@ -1,8 +1,95 @@
|
||||
/**
|
||||
* Shared buffers and types — extracted to break circular dependency
|
||||
* between server.ts and browser-manager.ts
|
||||
*
|
||||
* CircularBuffer<T>: O(1) insert ring buffer with fixed capacity.
|
||||
*
|
||||
* ┌───┬───┬───┬───┬───┬───┐
|
||||
* │ 3 │ 4 │ 5 │ │ 1 │ 2 │ capacity=6, head=4, size=5
|
||||
* └───┴───┴───┴───┴─▲─┴───┘
|
||||
* │
|
||||
* head (oldest entry)
|
||||
*
|
||||
* push() writes at (head+size) % capacity, O(1)
|
||||
* toArray() returns entries in insertion order, O(n)
|
||||
* totalAdded keeps incrementing past capacity (flush cursor)
|
||||
*/
|
||||
|
||||
// ─── CircularBuffer ─────────────────────────────────────────
|
||||
|
||||
export class CircularBuffer<T> {
|
||||
private buffer: (T | undefined)[];
|
||||
private head: number = 0;
|
||||
private _size: number = 0;
|
||||
private _totalAdded: number = 0;
|
||||
readonly capacity: number;
|
||||
|
||||
constructor(capacity: number) {
|
||||
this.capacity = capacity;
|
||||
this.buffer = new Array(capacity);
|
||||
}
|
||||
|
||||
push(entry: T): void {
|
||||
const index = (this.head + this._size) % this.capacity;
|
||||
this.buffer[index] = entry;
|
||||
if (this._size < this.capacity) {
|
||||
this._size++;
|
||||
} else {
|
||||
// Buffer full — advance head (overwrites oldest)
|
||||
this.head = (this.head + 1) % this.capacity;
|
||||
}
|
||||
this._totalAdded++;
|
||||
}
|
||||
|
||||
/** Return entries in insertion order (oldest first) */
|
||||
toArray(): T[] {
|
||||
const result: T[] = [];
|
||||
for (let i = 0; i < this._size; i++) {
|
||||
result.push(this.buffer[(this.head + i) % this.capacity] as T);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Return the last N entries (most recent first → reversed to oldest first) */
|
||||
last(n: number): T[] {
|
||||
const count = Math.min(n, this._size);
|
||||
const result: T[] = [];
|
||||
const start = (this.head + this._size - count) % this.capacity;
|
||||
for (let i = 0; i < count; i++) {
|
||||
result.push(this.buffer[(start + i) % this.capacity] as T);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return this._size;
|
||||
}
|
||||
|
||||
get totalAdded(): number {
|
||||
return this._totalAdded;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.head = 0;
|
||||
this._size = 0;
|
||||
// Don't reset totalAdded — flush cursor depends on it
|
||||
}
|
||||
|
||||
/** Get entry by index (0 = oldest) — used by network response matching */
|
||||
get(index: number): T | undefined {
|
||||
if (index < 0 || index >= this._size) return undefined;
|
||||
return this.buffer[(this.head + index) % this.capacity];
|
||||
}
|
||||
|
||||
/** Set entry by index (0 = oldest) — used by network response matching */
|
||||
set(index: number, entry: T): void {
|
||||
if (index < 0 || index >= this._size) return;
|
||||
this.buffer[(this.head + index) % this.capacity] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Entry Types ────────────────────────────────────────────
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: number;
|
||||
level: string;
|
||||
@@ -18,27 +105,33 @@ export interface NetworkEntry {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export const consoleBuffer: LogEntry[] = [];
|
||||
export const networkBuffer: NetworkEntry[] = [];
|
||||
export interface DialogEntry {
|
||||
timestamp: number;
|
||||
type: string; // 'alert' | 'confirm' | 'prompt' | 'beforeunload'
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
action: string; // 'accepted' | 'dismissed'
|
||||
response?: string; // text provided for prompt
|
||||
}
|
||||
|
||||
// ─── Buffer Instances ───────────────────────────────────────
|
||||
|
||||
const HIGH_WATER_MARK = 50_000;
|
||||
|
||||
// Total entries ever added — used by server.ts flush logic as a cursor
|
||||
// that keeps advancing even after the ring buffer wraps.
|
||||
export let consoleTotalAdded = 0;
|
||||
export let networkTotalAdded = 0;
|
||||
export const consoleBuffer = new CircularBuffer<LogEntry>(HIGH_WATER_MARK);
|
||||
export const networkBuffer = new CircularBuffer<NetworkEntry>(HIGH_WATER_MARK);
|
||||
export const dialogBuffer = new CircularBuffer<DialogEntry>(HIGH_WATER_MARK);
|
||||
|
||||
// ─── Convenience add functions ──────────────────────────────
|
||||
|
||||
export function addConsoleEntry(entry: LogEntry) {
|
||||
if (consoleBuffer.length >= HIGH_WATER_MARK) {
|
||||
consoleBuffer.shift();
|
||||
}
|
||||
consoleBuffer.push(entry);
|
||||
consoleTotalAdded++;
|
||||
}
|
||||
|
||||
export function addNetworkEntry(entry: NetworkEntry) {
|
||||
if (networkBuffer.length >= HIGH_WATER_MARK) {
|
||||
networkBuffer.shift();
|
||||
}
|
||||
networkBuffer.push(entry);
|
||||
networkTotalAdded++;
|
||||
}
|
||||
|
||||
export function addDialogEntry(entry: DialogEntry) {
|
||||
dialogBuffer.push(entry);
|
||||
}
|
||||
|
||||
+12
-4
@@ -185,20 +185,28 @@ 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>
|
||||
scroll [sel] | wait <sel|--networkidle|--load> | viewport <WxH>
|
||||
upload <sel> <file1> [file2...]
|
||||
cookie-import <json-file>
|
||||
Inspection: js <expr> | eval <file> | css <sel> <prop> | attrs <sel>
|
||||
console [--clear] | network [--clear]
|
||||
console [--clear|--errors] | network [--clear] | dialog [--clear]
|
||||
cookies | storage [set <k> <v>] | perf
|
||||
is <prop> <sel> (visible|hidden|enabled|disabled|checked|editable|focused)
|
||||
Visual: screenshot [path] | pdf [path] | responsive [prefix]
|
||||
Snapshot: snapshot [-i] [-c] [-d N] [-s sel]
|
||||
Snapshot: snapshot [-i] [-c] [-d N] [-s sel] [-D] [-a] [-o path] [-C]
|
||||
-D/--diff: diff against previous snapshot
|
||||
-a/--annotate: annotated screenshot with ref labels
|
||||
-C/--cursor-interactive: find non-ARIA clickable elements
|
||||
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
|
||||
Dialogs: dialog-accept [text] | dialog-dismiss
|
||||
|
||||
Refs: After 'snapshot', use @e1, @e2... as selectors:
|
||||
click @e3 | fill @e4 "value" | hover @e1`);
|
||||
click @e3 | fill @e4 "value" | hover @e1
|
||||
@c refs from -C: click @c1`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
|
||||
+28
-22
@@ -4,9 +4,31 @@
|
||||
|
||||
import type { BrowserManager } from './browser-manager';
|
||||
import { handleSnapshot } from './snapshot';
|
||||
import { getCleanText } from './read-commands';
|
||||
import * as Diff from 'diff';
|
||||
import * as fs from 'fs';
|
||||
|
||||
// Command sets for chain routing (mirrors server.ts — kept local to avoid circular import)
|
||||
const CHAIN_READ = new Set([
|
||||
'text', 'html', 'links', 'forms', 'accessibility',
|
||||
'js', 'eval', 'css', 'attrs',
|
||||
'console', 'network', 'cookies', 'storage', 'perf',
|
||||
'dialog', 'is',
|
||||
]);
|
||||
const CHAIN_WRITE = new Set([
|
||||
'goto', 'back', 'forward', 'reload',
|
||||
'click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait',
|
||||
'viewport', 'cookie', 'header', 'useragent',
|
||||
'upload', 'dialog-accept', 'dialog-dismiss',
|
||||
]);
|
||||
const CHAIN_META = new Set([
|
||||
'tabs', 'tab', 'newtab', 'closetab',
|
||||
'status', 'stop', 'restart',
|
||||
'screenshot', 'pdf', 'responsive',
|
||||
'chain', 'diff',
|
||||
'url', 'snapshot',
|
||||
]);
|
||||
|
||||
export async function handleMetaCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
@@ -129,16 +151,14 @@ export async function handleMetaCommand(
|
||||
const { handleReadCommand } = await import('./read-commands');
|
||||
const { handleWriteCommand } = await import('./write-commands');
|
||||
|
||||
const WRITE_SET = new Set(['goto','back','forward','reload','click','fill','select','hover','type','press','scroll','wait','viewport','cookie','header','useragent']);
|
||||
const READ_SET = new Set(['text','html','links','forms','accessibility','js','eval','css','attrs','console','network','cookies','storage','perf']);
|
||||
|
||||
for (const cmd of commands) {
|
||||
const [name, ...cmdArgs] = cmd;
|
||||
try {
|
||||
let result: string;
|
||||
if (WRITE_SET.has(name)) result = await handleWriteCommand(name, cmdArgs, bm);
|
||||
else if (READ_SET.has(name)) result = await handleReadCommand(name, cmdArgs, bm);
|
||||
else result = await handleMetaCommand(name, cmdArgs, bm, shutdown);
|
||||
if (CHAIN_WRITE.has(name)) result = await handleWriteCommand(name, cmdArgs, bm);
|
||||
else if (CHAIN_READ.has(name)) result = await handleReadCommand(name, cmdArgs, bm);
|
||||
else if (CHAIN_META.has(name)) result = await handleMetaCommand(name, cmdArgs, bm, shutdown);
|
||||
else throw new Error(`Unknown command: ${name}`);
|
||||
results.push(`[${name}] ${result}`);
|
||||
} catch (err: any) {
|
||||
results.push(`[${name}] ERROR: ${err.message}`);
|
||||
@@ -153,26 +173,12 @@ export async function handleMetaCommand(
|
||||
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');
|
||||
});
|
||||
const text1 = await getCleanText(page);
|
||||
|
||||
// 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 text2 = await getCleanText(page);
|
||||
|
||||
const changes = Diff.diffLines(text1, text2);
|
||||
const output: string[] = [`--- ${url1}`, `+++ ${url2}`, ''];
|
||||
|
||||
+71
-17
@@ -6,9 +6,28 @@
|
||||
*/
|
||||
|
||||
import type { BrowserManager } from './browser-manager';
|
||||
import { consoleBuffer, networkBuffer } from './buffers';
|
||||
import { consoleBuffer, networkBuffer, dialogBuffer } from './buffers';
|
||||
import type { Page } from 'playwright';
|
||||
import * as fs from 'fs';
|
||||
|
||||
/**
|
||||
* Extract clean text from a page (strips script/style/noscript/svg).
|
||||
* Exported for DRY reuse in meta-commands (diff).
|
||||
*/
|
||||
export async function getCleanText(page: Page): Promise<string> {
|
||||
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');
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleReadCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
@@ -18,17 +37,7 @@ export async function handleReadCommand(
|
||||
|
||||
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');
|
||||
});
|
||||
return await getCleanText(page);
|
||||
}
|
||||
|
||||
case 'html': {
|
||||
@@ -154,26 +163,71 @@ export async function handleReadCommand(
|
||||
|
||||
case 'console': {
|
||||
if (args[0] === '--clear') {
|
||||
consoleBuffer.length = 0;
|
||||
consoleBuffer.clear();
|
||||
return 'Console buffer cleared.';
|
||||
}
|
||||
if (consoleBuffer.length === 0) return '(no console messages)';
|
||||
return consoleBuffer.map(e =>
|
||||
const entries = args[0] === '--errors'
|
||||
? consoleBuffer.toArray().filter(e => e.level === 'error' || e.level === 'warning')
|
||||
: consoleBuffer.toArray();
|
||||
if (entries.length === 0) return args[0] === '--errors' ? '(no console errors)' : '(no console messages)';
|
||||
return entries.map(e =>
|
||||
`[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}`
|
||||
).join('\n');
|
||||
}
|
||||
|
||||
case 'network': {
|
||||
if (args[0] === '--clear') {
|
||||
networkBuffer.length = 0;
|
||||
networkBuffer.clear();
|
||||
return 'Network buffer cleared.';
|
||||
}
|
||||
if (networkBuffer.length === 0) return '(no network requests)';
|
||||
return networkBuffer.map(e =>
|
||||
return networkBuffer.toArray().map(e =>
|
||||
`${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)`
|
||||
).join('\n');
|
||||
}
|
||||
|
||||
case 'dialog': {
|
||||
if (args[0] === '--clear') {
|
||||
dialogBuffer.clear();
|
||||
return 'Dialog buffer cleared.';
|
||||
}
|
||||
if (dialogBuffer.length === 0) return '(no dialogs captured)';
|
||||
return dialogBuffer.toArray().map(e =>
|
||||
`[${new Date(e.timestamp).toISOString()}] [${e.type}] "${e.message}" → ${e.action}${e.response ? ` "${e.response}"` : ''}`
|
||||
).join('\n');
|
||||
}
|
||||
|
||||
case 'is': {
|
||||
const property = args[0];
|
||||
const selector = args[1];
|
||||
if (!property || !selector) throw new Error('Usage: browse is <property> <selector>\nProperties: visible, hidden, enabled, disabled, checked, editable, focused');
|
||||
|
||||
const resolved = bm.resolveRef(selector);
|
||||
let locator;
|
||||
if ('locator' in resolved) {
|
||||
locator = resolved.locator;
|
||||
} else {
|
||||
locator = page.locator(resolved.selector);
|
||||
}
|
||||
|
||||
switch (property) {
|
||||
case 'visible': return String(await locator.isVisible());
|
||||
case 'hidden': return String(await locator.isHidden());
|
||||
case 'enabled': return String(await locator.isEnabled());
|
||||
case 'disabled': return String(await locator.isDisabled());
|
||||
case 'checked': return String(await locator.isChecked());
|
||||
case 'editable': return String(await locator.isEditable());
|
||||
case 'focused': {
|
||||
const isFocused = await locator.evaluate(
|
||||
(el) => el === document.activeElement
|
||||
);
|
||||
return String(isFocused);
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown property: ${property}. Use: visible, hidden, enabled, disabled, checked, editable, focused`);
|
||||
}
|
||||
}
|
||||
|
||||
case 'cookies': {
|
||||
const cookies = await page.context().cookies();
|
||||
return JSON.stringify(cookies, null, 2);
|
||||
|
||||
+86
-38
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Architecture:
|
||||
* Bun.serve HTTP on localhost → routes commands to Playwright
|
||||
* Console/network buffers: in-memory (all entries) + disk flush every 1s
|
||||
* Console/network/dialog buffers: CircularBuffer in-memory + async disk flush
|
||||
* Chromium crash → server EXITS with clear error (CLI auto-restarts)
|
||||
* Auto-shutdown after BROWSE_IDLE_TIMEOUT (default 30 min)
|
||||
*/
|
||||
@@ -32,36 +32,58 @@ function validateAuth(req: Request): boolean {
|
||||
}
|
||||
|
||||
// ─── Buffer (from buffers.ts) ────────────────────────────────────
|
||||
import { consoleBuffer, networkBuffer, addConsoleEntry, addNetworkEntry, consoleTotalAdded, networkTotalAdded, type LogEntry, type NetworkEntry } from './buffers';
|
||||
export { consoleBuffer, networkBuffer, addConsoleEntry, addNetworkEntry, type LogEntry, type NetworkEntry };
|
||||
import { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, type LogEntry, type NetworkEntry, type DialogEntry } from './buffers';
|
||||
export { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, type LogEntry, type NetworkEntry, type DialogEntry };
|
||||
|
||||
const CONSOLE_LOG_PATH = `/tmp/browse-console${INSTANCE_SUFFIX}.log`;
|
||||
const NETWORK_LOG_PATH = `/tmp/browse-network${INSTANCE_SUFFIX}.log`;
|
||||
const DIALOG_LOG_PATH = `/tmp/browse-dialog${INSTANCE_SUFFIX}.log`;
|
||||
let lastConsoleFlushed = 0;
|
||||
let lastNetworkFlushed = 0;
|
||||
let lastDialogFlushed = 0;
|
||||
let flushInProgress = false;
|
||||
|
||||
function flushBuffers() {
|
||||
// Use totalAdded cursor (not buffer.length) because the ring buffer
|
||||
// stays pinned at HIGH_WATER_MARK after wrapping.
|
||||
const newConsoleCount = consoleTotalAdded - lastConsoleFlushed;
|
||||
if (newConsoleCount > 0) {
|
||||
const count = Math.min(newConsoleCount, consoleBuffer.length);
|
||||
const newEntries = consoleBuffer.slice(-count);
|
||||
const lines = newEntries.map(e =>
|
||||
`[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}`
|
||||
).join('\n') + '\n';
|
||||
fs.appendFileSync(CONSOLE_LOG_PATH, lines);
|
||||
lastConsoleFlushed = consoleTotalAdded;
|
||||
}
|
||||
async function flushBuffers() {
|
||||
if (flushInProgress) return; // Guard against concurrent flush
|
||||
flushInProgress = true;
|
||||
|
||||
const newNetworkCount = networkTotalAdded - lastNetworkFlushed;
|
||||
if (newNetworkCount > 0) {
|
||||
const count = Math.min(newNetworkCount, networkBuffer.length);
|
||||
const newEntries = networkBuffer.slice(-count);
|
||||
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 = networkTotalAdded;
|
||||
try {
|
||||
// Console buffer
|
||||
const newConsoleCount = consoleBuffer.totalAdded - lastConsoleFlushed;
|
||||
if (newConsoleCount > 0) {
|
||||
const entries = consoleBuffer.last(Math.min(newConsoleCount, consoleBuffer.length));
|
||||
const lines = entries.map(e =>
|
||||
`[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}`
|
||||
).join('\n') + '\n';
|
||||
await Bun.write(CONSOLE_LOG_PATH, (await Bun.file(CONSOLE_LOG_PATH).text().catch(() => '')) + lines);
|
||||
lastConsoleFlushed = consoleBuffer.totalAdded;
|
||||
}
|
||||
|
||||
// Network buffer
|
||||
const newNetworkCount = networkBuffer.totalAdded - lastNetworkFlushed;
|
||||
if (newNetworkCount > 0) {
|
||||
const entries = networkBuffer.last(Math.min(newNetworkCount, networkBuffer.length));
|
||||
const lines = entries.map(e =>
|
||||
`[${new Date(e.timestamp).toISOString()}] ${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)`
|
||||
).join('\n') + '\n';
|
||||
await Bun.write(NETWORK_LOG_PATH, (await Bun.file(NETWORK_LOG_PATH).text().catch(() => '')) + lines);
|
||||
lastNetworkFlushed = networkBuffer.totalAdded;
|
||||
}
|
||||
|
||||
// Dialog buffer
|
||||
const newDialogCount = dialogBuffer.totalAdded - lastDialogFlushed;
|
||||
if (newDialogCount > 0) {
|
||||
const entries = dialogBuffer.last(Math.min(newDialogCount, dialogBuffer.length));
|
||||
const lines = entries.map(e =>
|
||||
`[${new Date(e.timestamp).toISOString()}] [${e.type}] "${e.message}" → ${e.action}${e.response ? ` "${e.response}"` : ''}`
|
||||
).join('\n') + '\n';
|
||||
await Bun.write(DIALOG_LOG_PATH, (await Bun.file(DIALOG_LOG_PATH).text().catch(() => '')) + lines);
|
||||
lastDialogFlushed = dialogBuffer.totalAdded;
|
||||
}
|
||||
} catch {
|
||||
// Flush failures are non-fatal — buffers are in memory
|
||||
} finally {
|
||||
flushInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,24 +104,22 @@ const idleCheckInterval = setInterval(() => {
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
// ─── Server ────────────────────────────────────────────────────
|
||||
const browserManager = new BrowserManager();
|
||||
let isShuttingDown = false;
|
||||
|
||||
// Read/write/meta command sets for routing
|
||||
const READ_COMMANDS = new Set([
|
||||
// ─── Command Sets (exported for chain command) ──────────────────
|
||||
export const READ_COMMANDS = new Set([
|
||||
'text', 'html', 'links', 'forms', 'accessibility',
|
||||
'js', 'eval', 'css', 'attrs',
|
||||
'console', 'network', 'cookies', 'storage', 'perf',
|
||||
'dialog', 'is',
|
||||
]);
|
||||
|
||||
const WRITE_COMMANDS = new Set([
|
||||
export const WRITE_COMMANDS = new Set([
|
||||
'goto', 'back', 'forward', 'reload',
|
||||
'click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait',
|
||||
'viewport', 'cookie', 'header', 'useragent',
|
||||
'viewport', 'cookie', 'cookie-import', 'header', 'useragent',
|
||||
'upload', 'dialog-accept', 'dialog-dismiss',
|
||||
]);
|
||||
|
||||
const META_COMMANDS = new Set([
|
||||
export const META_COMMANDS = new Set([
|
||||
'tabs', 'tab', 'newtab', 'closetab',
|
||||
'status', 'stop', 'restart',
|
||||
'screenshot', 'pdf', 'responsive',
|
||||
@@ -107,6 +127,10 @@ const META_COMMANDS = new Set([
|
||||
'url', 'snapshot',
|
||||
]);
|
||||
|
||||
// ─── Server ────────────────────────────────────────────────────
|
||||
const browserManager = new BrowserManager();
|
||||
let isShuttingDown = false;
|
||||
|
||||
// Find port: deterministic from CONDUCTOR_PORT, or scan range
|
||||
async function findPort(): Promise<number> {
|
||||
// Deterministic port from CONDUCTOR_PORT (e.g., 55040 - 45600 = 9440)
|
||||
@@ -134,6 +158,29 @@ async function findPort(): Promise<number> {
|
||||
throw new Error(`[browse] No available port in range ${start}-${start + 9}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate Playwright errors into actionable messages for AI agents.
|
||||
*/
|
||||
function wrapError(err: any): string {
|
||||
const msg = err.message || String(err);
|
||||
// Timeout errors
|
||||
if (err.name === 'TimeoutError' || msg.includes('Timeout') || msg.includes('timeout')) {
|
||||
if (msg.includes('locator.click') || msg.includes('locator.fill') || msg.includes('locator.hover')) {
|
||||
return `Element not found or not interactable within timeout. Check your selector or run 'snapshot' for fresh refs.`;
|
||||
}
|
||||
if (msg.includes('page.goto') || msg.includes('Navigation')) {
|
||||
return `Page navigation timed out. The URL may be unreachable or the page may be loading slowly.`;
|
||||
}
|
||||
return `Operation timed out: ${msg.split('\n')[0]}`;
|
||||
}
|
||||
// Multiple elements matched
|
||||
if (msg.includes('resolved to') && msg.includes('elements')) {
|
||||
return `Selector matched multiple elements. Be more specific or use @refs from 'snapshot'.`;
|
||||
}
|
||||
// Pass through other errors
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function handleCommand(body: any): Promise<Response> {
|
||||
const { command, args = [] } = body;
|
||||
|
||||
@@ -168,7 +215,7 @@ async function handleCommand(body: any): Promise<Response> {
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
} catch (err: any) {
|
||||
return new Response(JSON.stringify({ error: err.message }), {
|
||||
return new Response(JSON.stringify({ error: wrapError(err) }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
@@ -182,7 +229,7 @@ async function shutdown() {
|
||||
console.log('[browse] Shutting down...');
|
||||
clearInterval(flushInterval);
|
||||
clearInterval(idleCheckInterval);
|
||||
flushBuffers(); // Final flush
|
||||
await flushBuffers(); // Final flush (async now)
|
||||
|
||||
await browserManager.close();
|
||||
|
||||
@@ -201,6 +248,7 @@ async function start() {
|
||||
// Clear old log files
|
||||
try { fs.unlinkSync(CONSOLE_LOG_PATH); } catch {}
|
||||
try { fs.unlinkSync(NETWORK_LOG_PATH); } catch {}
|
||||
try { fs.unlinkSync(DIALOG_LOG_PATH); } catch {}
|
||||
|
||||
const port = await findPort();
|
||||
|
||||
@@ -216,9 +264,9 @@ async function start() {
|
||||
|
||||
const url = new URL(req.url);
|
||||
|
||||
// Health check — no auth required
|
||||
// Health check — no auth required (now async)
|
||||
if (url.pathname === '/health') {
|
||||
const healthy = browserManager.isHealthy();
|
||||
const healthy = await browserManager.isHealthy();
|
||||
return new Response(JSON.stringify({
|
||||
status: healthy ? 'healthy' : 'unhealthy',
|
||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||
|
||||
+183
-4
@@ -8,11 +8,18 @@
|
||||
* 4. Store Map<string, Locator> on BrowserManager
|
||||
* 5. Return compact text output with refs prepended
|
||||
*
|
||||
* Extended features:
|
||||
* --diff / -D: Compare against last snapshot, return unified diff
|
||||
* --annotate / -a: Screenshot with overlay boxes at each @ref
|
||||
* --output / -o: Output path for annotated screenshot
|
||||
* -C / --cursor-interactive: Scan for cursor:pointer/onclick/tabindex elements
|
||||
*
|
||||
* Later: "click @e3" → look up Locator → locator.click()
|
||||
*/
|
||||
|
||||
import type { Page, Locator } from 'playwright';
|
||||
import type { BrowserManager } from './browser-manager';
|
||||
import * as Diff from 'diff';
|
||||
|
||||
// Roles considered "interactive" for the -i flag
|
||||
const INTERACTIVE_ROLES = new Set([
|
||||
@@ -23,10 +30,14 @@ const INTERACTIVE_ROLES = new Set([
|
||||
]);
|
||||
|
||||
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
|
||||
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
|
||||
diff?: boolean; // -D / --diff: diff against last snapshot
|
||||
annotate?: boolean; // -a / --annotate: annotated screenshot
|
||||
outputPath?: string; // -o / --output: path for annotated screenshot
|
||||
cursorInteractive?: boolean; // -C / --cursor-interactive: scan cursor:pointer etc.
|
||||
}
|
||||
|
||||
interface ParsedNode {
|
||||
@@ -63,6 +74,23 @@ export function parseSnapshotArgs(args: string[]): SnapshotOptions {
|
||||
opts.selector = args[++i];
|
||||
if (!opts.selector) throw new Error('Usage: snapshot -s <selector>');
|
||||
break;
|
||||
case '-D':
|
||||
case '--diff':
|
||||
opts.diff = true;
|
||||
break;
|
||||
case '-a':
|
||||
case '--annotate':
|
||||
opts.annotate = true;
|
||||
break;
|
||||
case '-o':
|
||||
case '--output':
|
||||
opts.outputPath = args[++i];
|
||||
if (!opts.outputPath) throw new Error('Usage: snapshot -o <path>');
|
||||
break;
|
||||
case '-C':
|
||||
case '--cursor-interactive':
|
||||
opts.cursorInteractive = true;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown snapshot flag: ${args[i]}`);
|
||||
}
|
||||
@@ -201,6 +229,74 @@ export async function handleSnapshot(
|
||||
output.push(outputLine);
|
||||
}
|
||||
|
||||
// ─── Cursor-interactive scan (-C) ─────────────────────────
|
||||
if (opts.cursorInteractive) {
|
||||
try {
|
||||
const cursorElements = await page.evaluate(() => {
|
||||
const STANDARD_INTERACTIVE = new Set([
|
||||
'A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'SUMMARY', 'DETAILS',
|
||||
]);
|
||||
|
||||
const results: Array<{ selector: string; text: string; reason: string }> = [];
|
||||
const allElements = document.querySelectorAll('*');
|
||||
|
||||
for (const el of allElements) {
|
||||
// Skip standard interactive elements (already in ARIA tree)
|
||||
if (STANDARD_INTERACTIVE.has(el.tagName)) continue;
|
||||
// Skip hidden elements
|
||||
if (!(el as HTMLElement).offsetParent && el.tagName !== 'BODY') continue;
|
||||
|
||||
const style = getComputedStyle(el);
|
||||
const hasCursorPointer = style.cursor === 'pointer';
|
||||
const hasOnclick = el.hasAttribute('onclick');
|
||||
const hasTabindex = el.hasAttribute('tabindex') && parseInt(el.getAttribute('tabindex')!, 10) >= 0;
|
||||
const hasRole = el.hasAttribute('role');
|
||||
|
||||
if (!hasCursorPointer && !hasOnclick && !hasTabindex) continue;
|
||||
// Skip if it has an ARIA role (likely already captured)
|
||||
if (hasRole) continue;
|
||||
|
||||
// Build deterministic nth-child CSS path
|
||||
const parts: string[] = [];
|
||||
let current: Element | null = el;
|
||||
while (current && current !== document.documentElement) {
|
||||
const parent = current.parentElement;
|
||||
if (!parent) break;
|
||||
const siblings = [...parent.children];
|
||||
const index = siblings.indexOf(current) + 1;
|
||||
parts.unshift(`${current.tagName.toLowerCase()}:nth-child(${index})`);
|
||||
current = parent;
|
||||
}
|
||||
const selector = parts.join(' > ');
|
||||
|
||||
const text = (el as HTMLElement).innerText?.trim().slice(0, 80) || el.tagName.toLowerCase();
|
||||
const reasons: string[] = [];
|
||||
if (hasCursorPointer) reasons.push('cursor:pointer');
|
||||
if (hasOnclick) reasons.push('onclick');
|
||||
if (hasTabindex) reasons.push(`tabindex=${el.getAttribute('tabindex')}`);
|
||||
|
||||
results.push({ selector, text, reason: reasons.join(', ') });
|
||||
}
|
||||
return results;
|
||||
});
|
||||
|
||||
if (cursorElements.length > 0) {
|
||||
output.push('');
|
||||
output.push('── cursor-interactive (not in ARIA tree) ──');
|
||||
let cRefCounter = 1;
|
||||
for (const elem of cursorElements) {
|
||||
const ref = `c${cRefCounter++}`;
|
||||
const locator = page.locator(elem.selector);
|
||||
refMap.set(ref, locator);
|
||||
output.push(`@${ref} [${elem.reason}] "${elem.text}"`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
output.push('');
|
||||
output.push('(cursor scan failed — CSP restriction)');
|
||||
}
|
||||
}
|
||||
|
||||
// Store ref map on BrowserManager
|
||||
bm.setRefMap(refMap);
|
||||
|
||||
@@ -208,5 +304,88 @@ export async function handleSnapshot(
|
||||
return '(no interactive elements found)';
|
||||
}
|
||||
|
||||
const snapshotText = output.join('\n');
|
||||
|
||||
// ─── Annotated screenshot (-a) ────────────────────────────
|
||||
if (opts.annotate) {
|
||||
const screenshotPath = opts.outputPath || '/tmp/browse-annotated.png';
|
||||
try {
|
||||
// Inject overlay divs at each ref's bounding box
|
||||
const boxes: Array<{ ref: string; box: { x: number; y: number; width: number; height: number } }> = [];
|
||||
for (const [ref, locator] of refMap) {
|
||||
try {
|
||||
const box = await locator.boundingBox({ timeout: 1000 });
|
||||
if (box) {
|
||||
boxes.push({ ref: `@${ref}`, box });
|
||||
}
|
||||
} catch {
|
||||
// Element may be offscreen or hidden — skip
|
||||
}
|
||||
}
|
||||
|
||||
await page.evaluate((boxes) => {
|
||||
for (const { ref, box } of boxes) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = '__browse_annotation__';
|
||||
overlay.style.cssText = `
|
||||
position: absolute; top: ${box.y}px; left: ${box.x}px;
|
||||
width: ${box.width}px; height: ${box.height}px;
|
||||
border: 2px solid red; background: rgba(255,0,0,0.1);
|
||||
pointer-events: none; z-index: 99999;
|
||||
font-size: 10px; color: red; font-weight: bold;
|
||||
`;
|
||||
const label = document.createElement('span');
|
||||
label.textContent = ref;
|
||||
label.style.cssText = 'position: absolute; top: -14px; left: 0; background: red; color: white; padding: 0 3px; font-size: 10px;';
|
||||
overlay.appendChild(label);
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
}, boxes);
|
||||
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
|
||||
// Always remove overlays
|
||||
await page.evaluate(() => {
|
||||
document.querySelectorAll('.__browse_annotation__').forEach(el => el.remove());
|
||||
});
|
||||
|
||||
output.push('');
|
||||
output.push(`[annotated screenshot: ${screenshotPath}]`);
|
||||
} catch {
|
||||
// Remove overlays even on screenshot failure
|
||||
try {
|
||||
await page.evaluate(() => {
|
||||
document.querySelectorAll('.__browse_annotation__').forEach(el => el.remove());
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Diff mode (-D) ───────────────────────────────────────
|
||||
if (opts.diff) {
|
||||
const lastSnapshot = bm.getLastSnapshot();
|
||||
if (!lastSnapshot) {
|
||||
bm.setLastSnapshot(snapshotText);
|
||||
return snapshotText + '\n\n(no previous snapshot to diff against — this snapshot stored as baseline)';
|
||||
}
|
||||
|
||||
const changes = Diff.diffLines(lastSnapshot, snapshotText);
|
||||
const diffOutput: string[] = ['--- previous snapshot', '+++ current snapshot', ''];
|
||||
|
||||
for (const part of changes) {
|
||||
const prefix = part.added ? '+' : part.removed ? '-' : ' ';
|
||||
const diffLines = part.value.split('\n').filter(l => l.length > 0);
|
||||
for (const line of diffLines) {
|
||||
diffOutput.push(`${prefix} ${line}`);
|
||||
}
|
||||
}
|
||||
|
||||
bm.setLastSnapshot(snapshotText);
|
||||
return diffOutput.join('\n');
|
||||
}
|
||||
|
||||
// Store for future diffs
|
||||
bm.setLastSnapshot(snapshotText);
|
||||
|
||||
return output.join('\n');
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
*/
|
||||
|
||||
import type { BrowserManager } from './browser-manager';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export async function handleWriteCommand(
|
||||
command: string,
|
||||
@@ -121,7 +123,20 @@ export async function handleWriteCommand(
|
||||
|
||||
case 'wait': {
|
||||
const selector = args[0];
|
||||
if (!selector) throw new Error('Usage: browse wait <selector>');
|
||||
if (!selector) throw new Error('Usage: browse wait <selector|--networkidle|--load|--domcontentloaded>');
|
||||
if (selector === '--networkidle') {
|
||||
const timeout = args[1] ? parseInt(args[1], 10) : 15000;
|
||||
await page.waitForLoadState('networkidle', { timeout });
|
||||
return 'Network idle';
|
||||
}
|
||||
if (selector === '--load') {
|
||||
await page.waitForLoadState('load');
|
||||
return 'Page loaded';
|
||||
}
|
||||
if (selector === '--domcontentloaded') {
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
return 'DOM content loaded';
|
||||
}
|
||||
const timeout = args[1] ? parseInt(args[1], 10) : 15000;
|
||||
const resolved = bm.resolveRef(selector);
|
||||
if ('locator' in resolved) {
|
||||
@@ -170,7 +185,72 @@ export async function handleWriteCommand(
|
||||
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}`;
|
||||
const error = await bm.recreateContext();
|
||||
if (error) {
|
||||
return `User agent set to "${ua}" but: ${error}`;
|
||||
}
|
||||
return `User agent set: ${ua}`;
|
||||
}
|
||||
|
||||
case 'upload': {
|
||||
const [selector, ...filePaths] = args;
|
||||
if (!selector || filePaths.length === 0) throw new Error('Usage: browse upload <selector> <file1> [file2...]');
|
||||
|
||||
// Validate all files exist before upload
|
||||
for (const fp of filePaths) {
|
||||
if (!fs.existsSync(fp)) throw new Error(`File not found: ${fp}`);
|
||||
}
|
||||
|
||||
const resolved = bm.resolveRef(selector);
|
||||
if ('locator' in resolved) {
|
||||
await resolved.locator.setInputFiles(filePaths);
|
||||
} else {
|
||||
await page.locator(resolved.selector).setInputFiles(filePaths);
|
||||
}
|
||||
|
||||
const fileInfo = filePaths.map(fp => {
|
||||
const stat = fs.statSync(fp);
|
||||
return `${path.basename(fp)} (${stat.size}B)`;
|
||||
}).join(', ');
|
||||
return `Uploaded: ${fileInfo}`;
|
||||
}
|
||||
|
||||
case 'dialog-accept': {
|
||||
const text = args.length > 0 ? args.join(' ') : null;
|
||||
bm.setDialogAutoAccept(true);
|
||||
bm.setDialogPromptText(text);
|
||||
return text
|
||||
? `Dialogs will be accepted with text: "${text}"`
|
||||
: 'Dialogs will be accepted';
|
||||
}
|
||||
|
||||
case 'dialog-dismiss': {
|
||||
bm.setDialogAutoAccept(false);
|
||||
bm.setDialogPromptText(null);
|
||||
return 'Dialogs will be dismissed';
|
||||
}
|
||||
|
||||
case 'cookie-import': {
|
||||
const filePath = args[0];
|
||||
if (!filePath) throw new Error('Usage: browse cookie-import <json-file>');
|
||||
if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
|
||||
const raw = fs.readFileSync(filePath, 'utf-8');
|
||||
let cookies: any[];
|
||||
try { cookies = JSON.parse(raw); } catch { throw new Error(`Invalid JSON in ${filePath}`); }
|
||||
if (!Array.isArray(cookies)) throw new Error('Cookie file must contain a JSON array');
|
||||
|
||||
// Auto-fill domain from current page URL when missing (consistent with cookie command)
|
||||
const pageUrl = new URL(page.url());
|
||||
const defaultDomain = pageUrl.hostname;
|
||||
|
||||
for (const c of cookies) {
|
||||
if (!c.name || c.value === undefined) throw new Error('Each cookie must have "name" and "value" fields');
|
||||
if (!c.domain) c.domain = defaultDomain;
|
||||
if (!c.path) c.path = '/';
|
||||
}
|
||||
|
||||
await page.context().addCookies(cookies);
|
||||
return `Loaded ${cookies.length} cookies from ${filePath}`;
|
||||
}
|
||||
|
||||
default:
|
||||
|
||||
+900
-23
@@ -11,7 +11,7 @@ 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, addConsoleEntry, addNetworkEntry, consoleTotalAdded, networkTotalAdded } from '../src/buffers';
|
||||
import { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, CircularBuffer } from '../src/buffers';
|
||||
import * as fs from 'fs';
|
||||
import { spawn } from 'child_process';
|
||||
import * as path from 'path';
|
||||
@@ -424,26 +424,27 @@ describe('Status', () => {
|
||||
|
||||
describe('CLI retry guard', () => {
|
||||
test('sendCommand aborts after repeated connection failures', async () => {
|
||||
// Write a fake state file pointing to a port that refuses connections
|
||||
const stateFile = '/tmp/browse-server.json';
|
||||
const origState = fs.existsSync(stateFile) ? fs.readFileSync(stateFile, 'utf-8') : null;
|
||||
|
||||
// Use an isolated state file to avoid conflicts with running servers
|
||||
const stateFile = '/tmp/browse-server-test-retry.json';
|
||||
fs.writeFileSync(stateFile, JSON.stringify({ port: 1, token: 'fake', pid: 999999 }));
|
||||
|
||||
const cliPath = path.resolve(__dirname, '../src/cli.ts');
|
||||
const result = await new Promise<{ code: number; stderr: string }>((resolve) => {
|
||||
const proc = spawn('bun', ['run', cliPath, 'status'], {
|
||||
timeout: 15000,
|
||||
env: { ...process.env },
|
||||
env: {
|
||||
...process.env,
|
||||
BROWSE_STATE_FILE: stateFile,
|
||||
BROWSE_PORT: '1', // Force port 1 (will fail)
|
||||
},
|
||||
});
|
||||
let stderr = '';
|
||||
proc.stderr.on('data', (d) => stderr += d.toString());
|
||||
proc.on('close', (code) => resolve({ code: code ?? 1, stderr }));
|
||||
});
|
||||
|
||||
// Restore original state file
|
||||
if (origState) fs.writeFileSync(stateFile, origState);
|
||||
else if (fs.existsSync(stateFile)) fs.unlinkSync(stateFile);
|
||||
// Clean up
|
||||
try { fs.unlinkSync(stateFile); } catch {}
|
||||
|
||||
// Should fail, not loop forever
|
||||
expect(result.code).not.toBe(0);
|
||||
@@ -454,37 +455,913 @@ describe('CLI retry guard', () => {
|
||||
|
||||
describe('Buffer bounds', () => {
|
||||
test('console buffer caps at 50000 entries', () => {
|
||||
consoleBuffer.length = 0;
|
||||
consoleBuffer.clear();
|
||||
for (let i = 0; i < 50_010; i++) {
|
||||
addConsoleEntry({ timestamp: i, level: 'log', text: `msg-${i}` });
|
||||
}
|
||||
expect(consoleBuffer.length).toBe(50_000);
|
||||
expect(consoleBuffer[0].text).toBe('msg-10');
|
||||
expect(consoleBuffer[consoleBuffer.length - 1].text).toBe('msg-50009');
|
||||
consoleBuffer.length = 0;
|
||||
const entries = consoleBuffer.toArray();
|
||||
expect(entries[0].text).toBe('msg-10');
|
||||
expect(entries[entries.length - 1].text).toBe('msg-50009');
|
||||
consoleBuffer.clear();
|
||||
});
|
||||
|
||||
test('network buffer caps at 50000 entries', () => {
|
||||
networkBuffer.length = 0;
|
||||
networkBuffer.clear();
|
||||
for (let i = 0; i < 50_010; i++) {
|
||||
addNetworkEntry({ timestamp: i, method: 'GET', url: `http://x/${i}` });
|
||||
}
|
||||
expect(networkBuffer.length).toBe(50_000);
|
||||
expect(networkBuffer[0].url).toBe('http://x/10');
|
||||
expect(networkBuffer[networkBuffer.length - 1].url).toBe('http://x/50009');
|
||||
networkBuffer.length = 0;
|
||||
const entries = networkBuffer.toArray();
|
||||
expect(entries[0].url).toBe('http://x/10');
|
||||
expect(entries[entries.length - 1].url).toBe('http://x/50009');
|
||||
networkBuffer.clear();
|
||||
});
|
||||
|
||||
test('totalAdded counters keep incrementing past buffer cap', () => {
|
||||
const startConsole = consoleTotalAdded;
|
||||
const startNetwork = networkTotalAdded;
|
||||
const startConsole = consoleBuffer.totalAdded;
|
||||
const startNetwork = networkBuffer.totalAdded;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
addConsoleEntry({ timestamp: i, level: 'log', text: `t-${i}` });
|
||||
addNetworkEntry({ timestamp: i, method: 'GET', url: `http://t/${i}` });
|
||||
}
|
||||
expect(consoleTotalAdded).toBe(startConsole + 100);
|
||||
expect(networkTotalAdded).toBe(startNetwork + 100);
|
||||
consoleBuffer.length = 0;
|
||||
networkBuffer.length = 0;
|
||||
expect(consoleBuffer.totalAdded).toBe(startConsole + 100);
|
||||
expect(networkBuffer.totalAdded).toBe(startNetwork + 100);
|
||||
consoleBuffer.clear();
|
||||
networkBuffer.clear();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CircularBuffer Unit Tests ─────────────────────────────────
|
||||
|
||||
describe('CircularBuffer', () => {
|
||||
test('push and toArray return items in insertion order', () => {
|
||||
const buf = new CircularBuffer<number>(5);
|
||||
buf.push(1); buf.push(2); buf.push(3);
|
||||
expect(buf.toArray()).toEqual([1, 2, 3]);
|
||||
expect(buf.length).toBe(3);
|
||||
});
|
||||
|
||||
test('overwrites oldest when full', () => {
|
||||
const buf = new CircularBuffer<number>(3);
|
||||
buf.push(1); buf.push(2); buf.push(3); buf.push(4);
|
||||
expect(buf.toArray()).toEqual([2, 3, 4]);
|
||||
expect(buf.length).toBe(3);
|
||||
});
|
||||
|
||||
test('totalAdded increments past capacity', () => {
|
||||
const buf = new CircularBuffer<number>(2);
|
||||
buf.push(1); buf.push(2); buf.push(3); buf.push(4); buf.push(5);
|
||||
expect(buf.totalAdded).toBe(5);
|
||||
expect(buf.length).toBe(2);
|
||||
expect(buf.toArray()).toEqual([4, 5]);
|
||||
});
|
||||
|
||||
test('last(n) returns most recent entries', () => {
|
||||
const buf = new CircularBuffer<number>(5);
|
||||
for (let i = 1; i <= 5; i++) buf.push(i);
|
||||
expect(buf.last(3)).toEqual([3, 4, 5]);
|
||||
expect(buf.last(10)).toEqual([1, 2, 3, 4, 5]); // clamped
|
||||
expect(buf.last(1)).toEqual([5]);
|
||||
});
|
||||
|
||||
test('get and set work by index', () => {
|
||||
const buf = new CircularBuffer<string>(3);
|
||||
buf.push('a'); buf.push('b'); buf.push('c');
|
||||
expect(buf.get(0)).toBe('a');
|
||||
expect(buf.get(2)).toBe('c');
|
||||
buf.set(1, 'B');
|
||||
expect(buf.get(1)).toBe('B');
|
||||
expect(buf.get(-1)).toBeUndefined();
|
||||
expect(buf.get(5)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('clear resets size but not totalAdded', () => {
|
||||
const buf = new CircularBuffer<number>(5);
|
||||
buf.push(1); buf.push(2); buf.push(3);
|
||||
buf.clear();
|
||||
expect(buf.length).toBe(0);
|
||||
expect(buf.totalAdded).toBe(3);
|
||||
expect(buf.toArray()).toEqual([]);
|
||||
});
|
||||
|
||||
test('works with capacity=1', () => {
|
||||
const buf = new CircularBuffer<number>(1);
|
||||
buf.push(10);
|
||||
expect(buf.toArray()).toEqual([10]);
|
||||
buf.push(20);
|
||||
expect(buf.toArray()).toEqual([20]);
|
||||
expect(buf.totalAdded).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Dialog Handling ─────────────────────────────────────────
|
||||
|
||||
describe('Dialog handling', () => {
|
||||
test('alert does not hang — auto-accepted', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/dialog.html'], bm);
|
||||
await handleWriteCommand('click', ['#alert-btn'], bm);
|
||||
// If we get here, dialog was handled (no hang)
|
||||
const result = await handleReadCommand('dialog', [], bm);
|
||||
expect(result).toContain('alert');
|
||||
expect(result).toContain('Hello from alert');
|
||||
expect(result).toContain('accepted');
|
||||
});
|
||||
|
||||
test('confirm is auto-accepted by default', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/dialog.html'], bm);
|
||||
await handleWriteCommand('click', ['#confirm-btn'], bm);
|
||||
// Wait for DOM update
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
const result = await handleReadCommand('js', ['document.querySelector("#confirm-result").textContent'], bm);
|
||||
expect(result).toBe('confirmed');
|
||||
});
|
||||
|
||||
test('dialog-dismiss changes behavior', async () => {
|
||||
const setResult = await handleWriteCommand('dialog-dismiss', [], bm);
|
||||
expect(setResult).toContain('dismissed');
|
||||
|
||||
await handleWriteCommand('goto', [baseUrl + '/dialog.html'], bm);
|
||||
await handleWriteCommand('click', ['#confirm-btn'], bm);
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
const result = await handleReadCommand('js', ['document.querySelector("#confirm-result").textContent'], bm);
|
||||
expect(result).toBe('cancelled');
|
||||
|
||||
// Reset to accept
|
||||
await handleWriteCommand('dialog-accept', [], bm);
|
||||
});
|
||||
|
||||
test('dialog-accept with text provides prompt response', async () => {
|
||||
const setResult = await handleWriteCommand('dialog-accept', ['TestUser'], bm);
|
||||
expect(setResult).toContain('TestUser');
|
||||
|
||||
await handleWriteCommand('goto', [baseUrl + '/dialog.html'], bm);
|
||||
await handleWriteCommand('click', ['#prompt-btn'], bm);
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
const result = await handleReadCommand('js', ['document.querySelector("#prompt-result").textContent'], bm);
|
||||
expect(result).toBe('TestUser');
|
||||
|
||||
// Reset
|
||||
await handleWriteCommand('dialog-accept', [], bm);
|
||||
});
|
||||
|
||||
test('dialog --clear clears buffer', async () => {
|
||||
const cleared = await handleReadCommand('dialog', ['--clear'], bm);
|
||||
expect(cleared).toContain('cleared');
|
||||
const after = await handleReadCommand('dialog', [], bm);
|
||||
expect(after).toContain('no dialogs');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Element State Checks (is) ─────────────────────────────────
|
||||
|
||||
describe('Element state checks', () => {
|
||||
beforeAll(async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/states.html'], bm);
|
||||
});
|
||||
|
||||
test('is visible returns true for visible element', async () => {
|
||||
const result = await handleReadCommand('is', ['visible', '#visible-div'], bm);
|
||||
expect(result).toBe('true');
|
||||
});
|
||||
|
||||
test('is hidden returns true for hidden element', async () => {
|
||||
const result = await handleReadCommand('is', ['hidden', '#hidden-div'], bm);
|
||||
expect(result).toBe('true');
|
||||
});
|
||||
|
||||
test('is visible returns false for hidden element', async () => {
|
||||
const result = await handleReadCommand('is', ['visible', '#hidden-div'], bm);
|
||||
expect(result).toBe('false');
|
||||
});
|
||||
|
||||
test('is enabled returns true for enabled input', async () => {
|
||||
const result = await handleReadCommand('is', ['enabled', '#enabled-input'], bm);
|
||||
expect(result).toBe('true');
|
||||
});
|
||||
|
||||
test('is disabled returns true for disabled input', async () => {
|
||||
const result = await handleReadCommand('is', ['disabled', '#disabled-input'], bm);
|
||||
expect(result).toBe('true');
|
||||
});
|
||||
|
||||
test('is checked returns true for checked checkbox', async () => {
|
||||
const result = await handleReadCommand('is', ['checked', '#checked-box'], bm);
|
||||
expect(result).toBe('true');
|
||||
});
|
||||
|
||||
test('is checked returns false for unchecked checkbox', async () => {
|
||||
const result = await handleReadCommand('is', ['checked', '#unchecked-box'], bm);
|
||||
expect(result).toBe('false');
|
||||
});
|
||||
|
||||
test('is editable returns true for normal input', async () => {
|
||||
const result = await handleReadCommand('is', ['editable', '#enabled-input'], bm);
|
||||
expect(result).toBe('true');
|
||||
});
|
||||
|
||||
test('is editable returns false for readonly input', async () => {
|
||||
const result = await handleReadCommand('is', ['editable', '#readonly-input'], bm);
|
||||
expect(result).toBe('false');
|
||||
});
|
||||
|
||||
test('is focused after click', async () => {
|
||||
await handleWriteCommand('click', ['#enabled-input'], bm);
|
||||
const result = await handleReadCommand('is', ['focused', '#enabled-input'], bm);
|
||||
expect(result).toBe('true');
|
||||
});
|
||||
|
||||
test('is with @ref works', async () => {
|
||||
await handleMetaCommand('snapshot', ['-i'], bm, async () => {});
|
||||
// Find a ref for the enabled input
|
||||
const snap = await handleMetaCommand('snapshot', ['-i'], bm, async () => {});
|
||||
const textboxLine = snap.split('\n').find(l => l.includes('[textbox]'));
|
||||
if (textboxLine) {
|
||||
const refMatch = textboxLine.match(/@(e\d+)/);
|
||||
if (refMatch) {
|
||||
const ref = `@${refMatch[1]}`;
|
||||
const result = await handleReadCommand('is', ['visible', ref], bm);
|
||||
expect(result).toBe('true');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('is with unknown property throws', async () => {
|
||||
try {
|
||||
await handleReadCommand('is', ['bogus', '#enabled-input'], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Unknown property');
|
||||
}
|
||||
});
|
||||
|
||||
test('is with missing args throws', async () => {
|
||||
try {
|
||||
await handleReadCommand('is', ['visible'], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Usage');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── File Upload ─────────────────────────────────────────────────
|
||||
|
||||
describe('File upload', () => {
|
||||
test('upload single file', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/upload.html'], bm);
|
||||
// Create a temp file to upload
|
||||
const tempFile = '/tmp/browse-test-upload.txt';
|
||||
fs.writeFileSync(tempFile, 'test content');
|
||||
const result = await handleWriteCommand('upload', ['#file-input', tempFile], bm);
|
||||
expect(result).toContain('Uploaded');
|
||||
expect(result).toContain('browse-test-upload.txt');
|
||||
|
||||
// Verify upload handler fired
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
const text = await handleReadCommand('js', ['document.querySelector("#upload-result").textContent'], bm);
|
||||
expect(text).toContain('browse-test-upload.txt');
|
||||
fs.unlinkSync(tempFile);
|
||||
});
|
||||
|
||||
test('upload with @ref works', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/upload.html'], bm);
|
||||
const tempFile = '/tmp/browse-test-upload2.txt';
|
||||
fs.writeFileSync(tempFile, 'ref upload test');
|
||||
const snap = await handleMetaCommand('snapshot', ['-i'], bm, async () => {});
|
||||
// Find the file input ref (it won't appear as "file input" in aria — use CSS selector instead)
|
||||
const result = await handleWriteCommand('upload', ['#file-input', tempFile], bm);
|
||||
expect(result).toContain('Uploaded');
|
||||
fs.unlinkSync(tempFile);
|
||||
});
|
||||
|
||||
test('upload nonexistent file throws', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/upload.html'], bm);
|
||||
try {
|
||||
await handleWriteCommand('upload', ['#file-input', '/tmp/nonexistent-file-12345.txt'], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('File not found');
|
||||
}
|
||||
});
|
||||
|
||||
test('upload missing args throws', async () => {
|
||||
try {
|
||||
await handleWriteCommand('upload', ['#file-input'], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Usage');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Eval command ───────────────────────────────────────────────
|
||||
|
||||
describe('Eval', () => {
|
||||
test('eval runs JS file', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
||||
const tempFile = '/tmp/browse-test-eval.js';
|
||||
fs.writeFileSync(tempFile, 'document.title + " — evaluated"');
|
||||
const result = await handleReadCommand('eval', [tempFile], bm);
|
||||
expect(result).toBe('Test Page - Basic — evaluated');
|
||||
fs.unlinkSync(tempFile);
|
||||
});
|
||||
|
||||
test('eval returns object as JSON', async () => {
|
||||
const tempFile = '/tmp/browse-test-eval-obj.js';
|
||||
fs.writeFileSync(tempFile, '({title: document.title, keys: Object.keys(document.body.dataset)})');
|
||||
const result = await handleReadCommand('eval', [tempFile], bm);
|
||||
const obj = JSON.parse(result);
|
||||
expect(obj.title).toBe('Test Page - Basic');
|
||||
expect(Array.isArray(obj.keys)).toBe(true);
|
||||
fs.unlinkSync(tempFile);
|
||||
});
|
||||
|
||||
test('eval file not found throws', async () => {
|
||||
try {
|
||||
await handleReadCommand('eval', ['/tmp/nonexistent-eval.js'], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('File not found');
|
||||
}
|
||||
});
|
||||
|
||||
test('eval no arg throws', async () => {
|
||||
try {
|
||||
await handleReadCommand('eval', [], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Usage');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Press command ──────────────────────────────────────────────
|
||||
|
||||
describe('Press', () => {
|
||||
test('press Tab moves focus', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm);
|
||||
await handleWriteCommand('click', ['#email'], bm);
|
||||
const result = await handleWriteCommand('press', ['Tab'], bm);
|
||||
expect(result).toContain('Pressed Tab');
|
||||
});
|
||||
|
||||
test('press no arg throws', async () => {
|
||||
try {
|
||||
await handleWriteCommand('press', [], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Usage');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cookie command ─────────────────────────────────────────────
|
||||
|
||||
describe('Cookie command', () => {
|
||||
test('cookie sets value', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
||||
const result = await handleWriteCommand('cookie', ['testcookie=testvalue'], bm);
|
||||
expect(result).toContain('Cookie set');
|
||||
|
||||
const cookies = await handleReadCommand('cookies', [], bm);
|
||||
expect(cookies).toContain('testcookie');
|
||||
expect(cookies).toContain('testvalue');
|
||||
});
|
||||
|
||||
test('cookie no arg throws', async () => {
|
||||
try {
|
||||
await handleWriteCommand('cookie', [], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Usage');
|
||||
}
|
||||
});
|
||||
|
||||
test('cookie no = throws', async () => {
|
||||
try {
|
||||
await handleWriteCommand('cookie', ['invalid'], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Usage');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Header command ─────────────────────────────────────────────
|
||||
|
||||
describe('Header command', () => {
|
||||
test('header sets value and is sent', async () => {
|
||||
const result = await handleWriteCommand('header', ['X-Test:test-value'], bm);
|
||||
expect(result).toContain('Header set');
|
||||
|
||||
await handleWriteCommand('goto', [baseUrl + '/echo'], bm);
|
||||
const echoText = await handleReadCommand('text', [], bm);
|
||||
expect(echoText).toContain('x-test');
|
||||
expect(echoText).toContain('test-value');
|
||||
});
|
||||
|
||||
test('header no arg throws', async () => {
|
||||
try {
|
||||
await handleWriteCommand('header', [], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Usage');
|
||||
}
|
||||
});
|
||||
|
||||
test('header no colon throws', async () => {
|
||||
try {
|
||||
await handleWriteCommand('header', ['invalid'], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Usage');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── PDF command ────────────────────────────────────────────────
|
||||
|
||||
describe('PDF', () => {
|
||||
test('pdf saves file with size', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
||||
const pdfPath = '/tmp/browse-test.pdf';
|
||||
const result = await handleMetaCommand('pdf', [pdfPath], bm, async () => {});
|
||||
expect(result).toContain('PDF saved');
|
||||
expect(fs.existsSync(pdfPath)).toBe(true);
|
||||
const stat = fs.statSync(pdfPath);
|
||||
expect(stat.size).toBeGreaterThan(100);
|
||||
fs.unlinkSync(pdfPath);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Empty page edge cases ──────────────────────────────────────
|
||||
|
||||
describe('Empty page', () => {
|
||||
test('text returns empty on empty page', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/empty.html'], bm);
|
||||
const result = await handleReadCommand('text', [], bm);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
test('links returns empty on empty page', async () => {
|
||||
const result = await handleReadCommand('links', [], bm);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
test('forms returns empty array on empty page', async () => {
|
||||
const result = await handleReadCommand('forms', [], bm);
|
||||
expect(JSON.parse(result)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error paths ────────────────────────────────────────────────
|
||||
|
||||
describe('Errors', () => {
|
||||
// Write command errors
|
||||
test('goto with no arg throws', async () => {
|
||||
try {
|
||||
await handleWriteCommand('goto', [], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Usage');
|
||||
}
|
||||
});
|
||||
|
||||
test('click with no arg throws', async () => {
|
||||
try {
|
||||
await handleWriteCommand('click', [], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Usage');
|
||||
}
|
||||
});
|
||||
|
||||
test('fill with no value throws', async () => {
|
||||
try {
|
||||
await handleWriteCommand('fill', ['#input'], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Usage');
|
||||
}
|
||||
});
|
||||
|
||||
test('select with no value throws', async () => {
|
||||
try {
|
||||
await handleWriteCommand('select', ['#sel'], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Usage');
|
||||
}
|
||||
});
|
||||
|
||||
test('hover with no arg throws', async () => {
|
||||
try {
|
||||
await handleWriteCommand('hover', [], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Usage');
|
||||
}
|
||||
});
|
||||
|
||||
test('type with no arg throws', async () => {
|
||||
try {
|
||||
await handleWriteCommand('type', [], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Usage');
|
||||
}
|
||||
});
|
||||
|
||||
test('wait with no arg throws', async () => {
|
||||
try {
|
||||
await handleWriteCommand('wait', [], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Usage');
|
||||
}
|
||||
});
|
||||
|
||||
test('viewport with bad format throws', async () => {
|
||||
try {
|
||||
await handleWriteCommand('viewport', ['badformat'], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Usage');
|
||||
}
|
||||
});
|
||||
|
||||
test('useragent with no arg throws', async () => {
|
||||
try {
|
||||
await handleWriteCommand('useragent', [], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Usage');
|
||||
}
|
||||
});
|
||||
|
||||
// Read command errors
|
||||
test('js with no expression throws', async () => {
|
||||
try {
|
||||
await handleReadCommand('js', [], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Usage');
|
||||
}
|
||||
});
|
||||
|
||||
test('css with missing property throws', async () => {
|
||||
try {
|
||||
await handleReadCommand('css', ['h1'], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Usage');
|
||||
}
|
||||
});
|
||||
|
||||
test('attrs with no selector throws', async () => {
|
||||
try {
|
||||
await handleReadCommand('attrs', [], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Usage');
|
||||
}
|
||||
});
|
||||
|
||||
// Meta command errors
|
||||
test('tab with non-numeric id throws', async () => {
|
||||
try {
|
||||
await handleMetaCommand('tab', ['abc'], bm, async () => {});
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Usage');
|
||||
}
|
||||
});
|
||||
|
||||
test('diff with missing urls throws', async () => {
|
||||
try {
|
||||
await handleMetaCommand('diff', [baseUrl + '/basic.html'], bm, async () => {});
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Usage');
|
||||
}
|
||||
});
|
||||
|
||||
test('chain with invalid JSON throws', async () => {
|
||||
try {
|
||||
await handleMetaCommand('chain', ['not json'], bm, async () => {});
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Invalid JSON');
|
||||
}
|
||||
});
|
||||
|
||||
test('chain with no arg throws', async () => {
|
||||
try {
|
||||
await handleMetaCommand('chain', [], bm, async () => {});
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Usage');
|
||||
}
|
||||
});
|
||||
|
||||
test('unknown read command throws', async () => {
|
||||
try {
|
||||
await handleReadCommand('bogus' as any, [], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Unknown');
|
||||
}
|
||||
});
|
||||
|
||||
test('unknown write command throws', async () => {
|
||||
try {
|
||||
await handleWriteCommand('bogus' as any, [], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Unknown');
|
||||
}
|
||||
});
|
||||
|
||||
test('unknown meta command throws', async () => {
|
||||
try {
|
||||
await handleMetaCommand('bogus' as any, [], bm, async () => {});
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Unknown');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Workflow: Navigation + Snapshot + Interaction ───────────────
|
||||
|
||||
describe('Workflows', () => {
|
||||
test('navigation → snapshot → click @ref → verify URL', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
|
||||
const snap = await handleMetaCommand('snapshot', ['-i'], bm, async () => {});
|
||||
// Find a link ref
|
||||
const linkLine = snap.split('\n').find(l => l.includes('[link]'));
|
||||
expect(linkLine).toBeDefined();
|
||||
const refMatch = linkLine!.match(/@(e\d+)/);
|
||||
expect(refMatch).toBeDefined();
|
||||
// Click the link
|
||||
await handleWriteCommand('click', [`@${refMatch![1]}`], bm);
|
||||
// URL should have changed
|
||||
const url = await handleMetaCommand('url', [], bm, async () => {});
|
||||
expect(url).toBeTruthy();
|
||||
});
|
||||
|
||||
test('form: goto → snapshot → fill @ref → click @ref', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
|
||||
const snap = await handleMetaCommand('snapshot', ['-i'], bm, async () => {});
|
||||
// Find textbox and button
|
||||
const textboxLine = snap.split('\n').find(l => l.includes('[textbox]'));
|
||||
const buttonLine = snap.split('\n').find(l => l.includes('[button]') && l.includes('"Submit"'));
|
||||
if (textboxLine && buttonLine) {
|
||||
const textRef = textboxLine.match(/@(e\d+)/)![1];
|
||||
const btnRef = buttonLine.match(/@(e\d+)/)![1];
|
||||
await handleWriteCommand('fill', [`@${textRef}`, 'testuser'], bm);
|
||||
await handleWriteCommand('click', [`@${btnRef}`], bm);
|
||||
}
|
||||
});
|
||||
|
||||
test('tabs: newtab → goto → switch → verify isolation', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
||||
const tabsBefore = bm.getTabCount();
|
||||
await handleMetaCommand('newtab', [baseUrl + '/forms.html'], bm, async () => {});
|
||||
expect(bm.getTabCount()).toBe(tabsBefore + 1);
|
||||
|
||||
const url = await handleMetaCommand('url', [], bm, async () => {});
|
||||
expect(url).toContain('/forms.html');
|
||||
|
||||
// Switch back to previous tab
|
||||
const tabs = await bm.getTabListWithTitles();
|
||||
const prevTab = tabs.find(t => t.url.includes('/basic.html'));
|
||||
if (prevTab) {
|
||||
bm.switchTab(prevTab.id);
|
||||
const url2 = await handleMetaCommand('url', [], bm, async () => {});
|
||||
expect(url2).toContain('/basic.html');
|
||||
}
|
||||
|
||||
// Clean up extra tab
|
||||
const allTabs = await bm.getTabListWithTitles();
|
||||
const formTab = allTabs.find(t => t.url.includes('/forms.html'));
|
||||
if (formTab) await bm.closeTab(formTab.id);
|
||||
});
|
||||
|
||||
test('cookies: set → read → reload → verify persistence', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
||||
await handleWriteCommand('cookie', ['workflow-test=persisted'], bm);
|
||||
await handleWriteCommand('reload', [], bm);
|
||||
const cookies = await handleReadCommand('cookies', [], bm);
|
||||
expect(cookies).toContain('workflow-test');
|
||||
expect(cookies).toContain('persisted');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Wait load states ──────────────────────────────────────────
|
||||
|
||||
describe('Wait load states', () => {
|
||||
test('wait --networkidle succeeds after page load', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
||||
const result = await handleWriteCommand('wait', ['--networkidle'], bm);
|
||||
expect(result).toBe('Network idle');
|
||||
});
|
||||
|
||||
test('wait --load succeeds', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
||||
const result = await handleWriteCommand('wait', ['--load'], bm);
|
||||
expect(result).toBe('Page loaded');
|
||||
});
|
||||
|
||||
test('wait --domcontentloaded succeeds', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
||||
const result = await handleWriteCommand('wait', ['--domcontentloaded'], bm);
|
||||
expect(result).toBe('DOM content loaded');
|
||||
});
|
||||
|
||||
test('wait --networkidle with custom timeout', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
||||
const result = await handleWriteCommand('wait', ['--networkidle', '5000'], bm);
|
||||
expect(result).toBe('Network idle');
|
||||
});
|
||||
|
||||
test('wait with selector still works', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
||||
const result = await handleWriteCommand('wait', ['#title'], bm);
|
||||
expect(result).toContain('appeared');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Console --errors ──────────────────────────────────────────
|
||||
|
||||
describe('Console --errors', () => {
|
||||
test('console --errors filters to error and warning only', async () => {
|
||||
// Clear existing entries
|
||||
await handleReadCommand('console', ['--clear'], bm);
|
||||
|
||||
// Add mixed entries
|
||||
addConsoleEntry({ timestamp: Date.now(), level: 'log', text: 'info message' });
|
||||
addConsoleEntry({ timestamp: Date.now(), level: 'warning', text: 'warn message' });
|
||||
addConsoleEntry({ timestamp: Date.now(), level: 'error', text: 'error message' });
|
||||
|
||||
const result = await handleReadCommand('console', ['--errors'], bm);
|
||||
expect(result).toContain('warn message');
|
||||
expect(result).toContain('error message');
|
||||
expect(result).not.toContain('info message');
|
||||
|
||||
// Cleanup
|
||||
consoleBuffer.clear();
|
||||
});
|
||||
|
||||
test('console --errors returns empty message when no errors', async () => {
|
||||
consoleBuffer.clear();
|
||||
addConsoleEntry({ timestamp: Date.now(), level: 'log', text: 'just a log' });
|
||||
|
||||
const result = await handleReadCommand('console', ['--errors'], bm);
|
||||
expect(result).toBe('(no console errors)');
|
||||
|
||||
consoleBuffer.clear();
|
||||
});
|
||||
|
||||
test('console --errors on empty buffer', async () => {
|
||||
consoleBuffer.clear();
|
||||
const result = await handleReadCommand('console', ['--errors'], bm);
|
||||
expect(result).toBe('(no console errors)');
|
||||
});
|
||||
|
||||
test('console without flag still returns all messages', async () => {
|
||||
consoleBuffer.clear();
|
||||
addConsoleEntry({ timestamp: Date.now(), level: 'log', text: 'all messages test' });
|
||||
|
||||
const result = await handleReadCommand('console', [], bm);
|
||||
expect(result).toContain('all messages test');
|
||||
|
||||
consoleBuffer.clear();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cookie Import ─────────────────────────────────────────────
|
||||
|
||||
describe('Cookie import', () => {
|
||||
test('cookie-import loads valid JSON cookies', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
||||
const tempFile = '/tmp/browse-test-cookies.json';
|
||||
const cookies = [
|
||||
{ name: 'test-cookie', value: 'test-value' },
|
||||
{ name: 'another', value: '123' },
|
||||
];
|
||||
fs.writeFileSync(tempFile, JSON.stringify(cookies));
|
||||
|
||||
const result = await handleWriteCommand('cookie-import', [tempFile], bm);
|
||||
expect(result).toBe('Loaded 2 cookies from /tmp/browse-test-cookies.json');
|
||||
|
||||
// Verify cookies were set
|
||||
const cookieList = await handleReadCommand('cookies', [], bm);
|
||||
expect(cookieList).toContain('test-cookie');
|
||||
expect(cookieList).toContain('test-value');
|
||||
expect(cookieList).toContain('another');
|
||||
|
||||
fs.unlinkSync(tempFile);
|
||||
});
|
||||
|
||||
test('cookie-import auto-fills domain from page URL', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
||||
const tempFile = '/tmp/browse-test-cookies-nodomain.json';
|
||||
// Cookies without domain — should auto-fill from page URL
|
||||
const cookies = [{ name: 'autofill-test', value: 'works' }];
|
||||
fs.writeFileSync(tempFile, JSON.stringify(cookies));
|
||||
|
||||
const result = await handleWriteCommand('cookie-import', [tempFile], bm);
|
||||
expect(result).toContain('Loaded 1');
|
||||
|
||||
const cookieList = await handleReadCommand('cookies', [], bm);
|
||||
expect(cookieList).toContain('autofill-test');
|
||||
|
||||
fs.unlinkSync(tempFile);
|
||||
});
|
||||
|
||||
test('cookie-import preserves explicit domain', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
||||
const tempFile = '/tmp/browse-test-cookies-domain.json';
|
||||
const cookies = [{ name: 'explicit', value: 'domain', domain: 'example.com', path: '/foo' }];
|
||||
fs.writeFileSync(tempFile, JSON.stringify(cookies));
|
||||
|
||||
const result = await handleWriteCommand('cookie-import', [tempFile], bm);
|
||||
expect(result).toContain('Loaded 1');
|
||||
|
||||
fs.unlinkSync(tempFile);
|
||||
});
|
||||
|
||||
test('cookie-import with empty array succeeds', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
||||
const tempFile = '/tmp/browse-test-cookies-empty.json';
|
||||
fs.writeFileSync(tempFile, '[]');
|
||||
|
||||
const result = await handleWriteCommand('cookie-import', [tempFile], bm);
|
||||
expect(result).toBe('Loaded 0 cookies from /tmp/browse-test-cookies-empty.json');
|
||||
|
||||
fs.unlinkSync(tempFile);
|
||||
});
|
||||
|
||||
test('cookie-import throws on file not found', async () => {
|
||||
try {
|
||||
await handleWriteCommand('cookie-import', ['/tmp/nonexistent-cookies.json'], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('File not found');
|
||||
}
|
||||
});
|
||||
|
||||
test('cookie-import throws on invalid JSON', async () => {
|
||||
const tempFile = '/tmp/browse-test-cookies-bad.json';
|
||||
fs.writeFileSync(tempFile, 'not json {{{');
|
||||
|
||||
try {
|
||||
await handleWriteCommand('cookie-import', [tempFile], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Invalid JSON');
|
||||
}
|
||||
|
||||
fs.unlinkSync(tempFile);
|
||||
});
|
||||
|
||||
test('cookie-import throws on non-array JSON', async () => {
|
||||
const tempFile = '/tmp/browse-test-cookies-obj.json';
|
||||
fs.writeFileSync(tempFile, '{"name": "not-an-array"}');
|
||||
|
||||
try {
|
||||
await handleWriteCommand('cookie-import', [tempFile], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('JSON array');
|
||||
}
|
||||
|
||||
fs.unlinkSync(tempFile);
|
||||
});
|
||||
|
||||
test('cookie-import throws on cookie missing name', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
||||
const tempFile = '/tmp/browse-test-cookies-noname.json';
|
||||
fs.writeFileSync(tempFile, JSON.stringify([{ value: 'no-name' }]));
|
||||
|
||||
try {
|
||||
await handleWriteCommand('cookie-import', [tempFile], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('name');
|
||||
}
|
||||
|
||||
fs.unlinkSync(tempFile);
|
||||
});
|
||||
|
||||
test('cookie-import no arg throws', async () => {
|
||||
try {
|
||||
await handleWriteCommand('cookie-import', [], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Usage');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Test Page - Cursor Interactive</title>
|
||||
<style>
|
||||
.clickable-div { cursor: pointer; padding: 10px; border: 1px solid #ccc; }
|
||||
.hover-card { cursor: pointer; padding: 20px; background: #f0f0f0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Cursor Interactive Test</h1>
|
||||
<!-- These are NOT standard interactive elements but have cursor:pointer -->
|
||||
<div class="clickable-div" id="click-div" onclick="this.textContent = 'clicked!'">Click me (div)</div>
|
||||
<span class="hover-card" id="hover-span">Hover card (span)</span>
|
||||
<div tabindex="0" id="focusable-div">Focusable div</div>
|
||||
<div onclick="alert('hi')" id="onclick-div">Onclick div</div>
|
||||
<!-- Standard interactive element (should NOT appear in -C output) -->
|
||||
<button id="normal-btn">Normal Button</button>
|
||||
<a href="/test">Normal Link</a>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Test Page - Dialog</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Dialog Test</h1>
|
||||
<button id="alert-btn" onclick="alert('Hello from alert')">Alert</button>
|
||||
<button id="confirm-btn" onclick="document.getElementById('confirm-result').textContent = confirm('Are you sure?') ? 'confirmed' : 'cancelled'">Confirm</button>
|
||||
<button id="prompt-btn" onclick="document.getElementById('prompt-result').textContent = prompt('Enter name:', 'default') || 'null'">Prompt</button>
|
||||
<p id="confirm-result"></p>
|
||||
<p id="prompt-result"></p>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
<!DOCTYPE html>
|
||||
<html><body></body></html>
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Test Page - Element States</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Element States Test</h1>
|
||||
<input type="text" id="enabled-input" value="enabled" />
|
||||
<input type="text" id="disabled-input" value="disabled" disabled />
|
||||
<input type="checkbox" id="checked-box" checked />
|
||||
<input type="checkbox" id="unchecked-box" />
|
||||
<div id="visible-div">Visible</div>
|
||||
<div id="hidden-div" style="display: none;">Hidden</div>
|
||||
<input type="text" id="readonly-input" readonly value="readonly" />
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Test Page - Upload</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Upload Test</h1>
|
||||
<input type="file" id="file-input" />
|
||||
<input type="file" id="multi-input" multiple />
|
||||
<p id="upload-result"></p>
|
||||
<script>
|
||||
document.getElementById('file-input').addEventListener('change', function(e) {
|
||||
const files = e.target.files;
|
||||
const names = Array.from(files).map(f => f.name).join(', ');
|
||||
document.getElementById('upload-result').textContent = 'Uploaded: ' + names;
|
||||
});
|
||||
document.getElementById('multi-input').addEventListener('change', function(e) {
|
||||
const files = e.target.files;
|
||||
const names = Array.from(files).map(f => f.name).join(', ');
|
||||
document.getElementById('upload-result').textContent = 'Multi: ' + names;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -11,6 +11,7 @@ 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 * as fs from 'fs';
|
||||
|
||||
let testServer: ReturnType<typeof startTestServer>;
|
||||
let bm: BrowserManager;
|
||||
@@ -199,3 +200,219 @@ describe('Ref invalidation', () => {
|
||||
expect(bm.getRefCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Snapshot Diffing ──────────────────────────────────────────
|
||||
|
||||
describe('Snapshot diff', () => {
|
||||
test('first snapshot -D stores baseline', async () => {
|
||||
// Clear any previous snapshot
|
||||
bm.setLastSnapshot(null);
|
||||
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
|
||||
const result = await handleMetaCommand('snapshot', ['-D'], bm, shutdown);
|
||||
expect(result).toContain('no previous snapshot');
|
||||
expect(result).toContain('baseline');
|
||||
});
|
||||
|
||||
test('snapshot -D shows diff after change', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
|
||||
// Take first snapshot
|
||||
await handleMetaCommand('snapshot', [], bm, shutdown);
|
||||
// Modify DOM
|
||||
await handleReadCommand('js', ['document.querySelector("h1").textContent = "Changed Title"'], bm);
|
||||
// Take diff
|
||||
const diff = await handleMetaCommand('snapshot', ['-D'], bm, shutdown);
|
||||
expect(diff).toContain('---');
|
||||
expect(diff).toContain('+++');
|
||||
expect(diff).toContain('previous snapshot');
|
||||
expect(diff).toContain('current snapshot');
|
||||
});
|
||||
|
||||
test('snapshot -D with identical page shows no changes', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
||||
await handleMetaCommand('snapshot', [], bm, shutdown);
|
||||
const diff = await handleMetaCommand('snapshot', ['-D'], bm, shutdown);
|
||||
// All lines should be unchanged (prefixed with space)
|
||||
const lines = diff.split('\n').filter(l => l.startsWith('+') || l.startsWith('-'));
|
||||
// Header lines start with --- and +++ so filter those
|
||||
const contentChanges = lines.filter(l => !l.startsWith('---') && !l.startsWith('+++'));
|
||||
expect(contentChanges.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Annotated Screenshots ─────────────────────────────────────
|
||||
|
||||
describe('Annotated screenshots', () => {
|
||||
test('snapshot -a creates annotated screenshot', async () => {
|
||||
const screenshotPath = '/tmp/browse-test-annotated.png';
|
||||
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
|
||||
const result = await handleMetaCommand('snapshot', ['-a', '-o', screenshotPath], bm, shutdown);
|
||||
expect(result).toContain('annotated screenshot');
|
||||
expect(result).toContain(screenshotPath);
|
||||
expect(fs.existsSync(screenshotPath)).toBe(true);
|
||||
const stat = fs.statSync(screenshotPath);
|
||||
expect(stat.size).toBeGreaterThan(1000);
|
||||
fs.unlinkSync(screenshotPath);
|
||||
});
|
||||
|
||||
test('snapshot -a uses default path', async () => {
|
||||
const defaultPath = '/tmp/browse-annotated.png';
|
||||
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
|
||||
const result = await handleMetaCommand('snapshot', ['-a'], bm, shutdown);
|
||||
expect(result).toContain('annotated screenshot');
|
||||
expect(fs.existsSync(defaultPath)).toBe(true);
|
||||
fs.unlinkSync(defaultPath);
|
||||
});
|
||||
|
||||
test('snapshot -a -i only annotates interactive', async () => {
|
||||
const screenshotPath = '/tmp/browse-test-annotated-i.png';
|
||||
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
|
||||
const result = await handleMetaCommand('snapshot', ['-i', '-a', '-o', screenshotPath], bm, shutdown);
|
||||
expect(result).toContain('[button]');
|
||||
expect(result).toContain('[link]');
|
||||
expect(result).toContain('annotated screenshot');
|
||||
if (fs.existsSync(screenshotPath)) fs.unlinkSync(screenshotPath);
|
||||
});
|
||||
|
||||
test('annotation overlays are cleaned up', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
|
||||
await handleMetaCommand('snapshot', ['-a'], bm, shutdown);
|
||||
// Check that overlays are removed
|
||||
const overlays = await handleReadCommand('js', ['document.querySelectorAll(".__browse_annotation__").length'], bm);
|
||||
expect(overlays).toBe('0');
|
||||
// Clean up default file
|
||||
try { fs.unlinkSync('/tmp/browse-annotated.png'); } catch {}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cursor-Interactive ────────────────────────────────────────
|
||||
|
||||
describe('Cursor-interactive', () => {
|
||||
test('snapshot -C finds cursor:pointer elements', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/cursor-interactive.html'], bm);
|
||||
const result = await handleMetaCommand('snapshot', ['-C'], bm, shutdown);
|
||||
expect(result).toContain('cursor-interactive');
|
||||
expect(result).toContain('@c');
|
||||
expect(result).toContain('cursor:pointer');
|
||||
});
|
||||
|
||||
test('snapshot -C includes onclick elements', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/cursor-interactive.html'], bm);
|
||||
const result = await handleMetaCommand('snapshot', ['-C'], bm, shutdown);
|
||||
expect(result).toContain('onclick');
|
||||
});
|
||||
|
||||
test('snapshot -C includes tabindex elements', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/cursor-interactive.html'], bm);
|
||||
const result = await handleMetaCommand('snapshot', ['-C'], bm, shutdown);
|
||||
expect(result).toContain('tabindex');
|
||||
});
|
||||
|
||||
test('@c ref is clickable', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/cursor-interactive.html'], bm);
|
||||
const snap = await handleMetaCommand('snapshot', ['-C'], bm, shutdown);
|
||||
// Find a @c ref
|
||||
const cLine = snap.split('\n').find(l => l.includes('@c'));
|
||||
if (cLine) {
|
||||
const refMatch = cLine.match(/@(c\d+)/);
|
||||
if (refMatch) {
|
||||
const result = await handleWriteCommand('click', [`@${refMatch[1]}`], bm);
|
||||
expect(result).toContain('Clicked');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('snapshot -C on page with no cursor elements', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/empty.html'], bm);
|
||||
const result = await handleMetaCommand('snapshot', ['-C'], bm, shutdown);
|
||||
// Should not contain cursor-interactive section
|
||||
expect(result).not.toContain('cursor-interactive');
|
||||
});
|
||||
|
||||
test('snapshot -i -C combines both modes', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/cursor-interactive.html'], bm);
|
||||
const result = await handleMetaCommand('snapshot', ['-i', '-C'], bm, shutdown);
|
||||
// Should have interactive elements (button, link)
|
||||
expect(result).toContain('[button]');
|
||||
expect(result).toContain('[link]');
|
||||
// And cursor-interactive section
|
||||
expect(result).toContain('cursor-interactive');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Snapshot Error Paths ───────────────────────────────────────
|
||||
|
||||
describe('Snapshot errors', () => {
|
||||
test('unknown flag throws', async () => {
|
||||
try {
|
||||
await handleMetaCommand('snapshot', ['--bogus'], bm, shutdown);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Unknown snapshot flag');
|
||||
}
|
||||
});
|
||||
|
||||
test('-d without number throws', async () => {
|
||||
try {
|
||||
await handleMetaCommand('snapshot', ['-d'], bm, shutdown);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Usage');
|
||||
}
|
||||
});
|
||||
|
||||
test('-s without selector throws', async () => {
|
||||
try {
|
||||
await handleMetaCommand('snapshot', ['-s'], bm, shutdown);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Usage');
|
||||
}
|
||||
});
|
||||
|
||||
test('-s with nonexistent selector throws', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
||||
try {
|
||||
await handleMetaCommand('snapshot', ['-s', '#nonexistent-element-12345'], bm, shutdown);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Selector not found');
|
||||
}
|
||||
});
|
||||
|
||||
test('-o without path throws', async () => {
|
||||
try {
|
||||
await handleMetaCommand('snapshot', ['-o'], bm, shutdown);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Usage');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Combined Flags ─────────────────────────────────────────────
|
||||
|
||||
describe('Snapshot combined flags', () => {
|
||||
test('-i -c -d 2 combines all filters', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
|
||||
const result = await handleMetaCommand('snapshot', ['-i', '-c', '-d', '2'], bm, shutdown);
|
||||
// Should be filtered to interactive, compact, shallow
|
||||
expect(result).toContain('[button]');
|
||||
expect(result).toContain('[link]');
|
||||
// Should NOT contain deep nested non-interactive elements
|
||||
expect(result).not.toContain('[heading]');
|
||||
});
|
||||
|
||||
test('closetab last tab auto-creates new', async () => {
|
||||
// Get down to 1 tab
|
||||
const tabs = await bm.getTabListWithTitles();
|
||||
for (let i = 1; i < tabs.length; i++) {
|
||||
await bm.closeTab(tabs[i].id);
|
||||
}
|
||||
expect(bm.getTabCount()).toBe(1);
|
||||
// Close the last tab
|
||||
const lastTab = (await bm.getTabListWithTitles())[0];
|
||||
await bm.closeTab(lastTab.id);
|
||||
// Should have auto-created a new tab
|
||||
expect(bm.getTabCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,16 @@ export function startTestServer(port: number = 0): { server: ReturnType<typeof B
|
||||
hostname: '127.0.0.1',
|
||||
fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
// Echo endpoint — returns request headers as JSON
|
||||
if (url.pathname === '/echo') {
|
||||
const headers: Record<string, string> = {};
|
||||
req.headers.forEach((value, key) => { headers[key] = value; });
|
||||
return new Response(JSON.stringify(headers, null, 2), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
let filePath = url.pathname === '/' ? '/basic.html' : url.pathname;
|
||||
|
||||
// Remove leading slash
|
||||
|
||||
Reference in New Issue
Block a user