mirror of https://github.com/garrytan/gstack.git
fix: harden browse lifecycle and ref safety
Clean up browse daemon lifecycle, freeze refs per tab, and add regression coverage for restart, cookie, network, and snapshot edge cases.
This commit is contained in:
parent
0593ae0201
commit
28adc11c03
|
|
@ -7,8 +7,20 @@
|
||||||
* We do NOT try to self-heal — don't hide failure.
|
* We do NOT try to self-heal — don't hide failure.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { chromium, type Browser, type BrowserContext, type Page, type Locator } from 'playwright';
|
import {
|
||||||
import { addConsoleEntry, addNetworkEntry, networkBuffer, type LogEntry, type NetworkEntry } from './buffers';
|
chromium,
|
||||||
|
type Browser,
|
||||||
|
type BrowserContext,
|
||||||
|
type ElementHandle,
|
||||||
|
type Page,
|
||||||
|
type Request,
|
||||||
|
} from 'playwright';
|
||||||
|
import { addConsoleEntry, addNetworkEntry, type NetworkEntry } from './buffers';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
interface BrowserSettings {
|
||||||
|
userAgent?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export class BrowserManager {
|
export class BrowserManager {
|
||||||
private browser: Browser | null = null;
|
private browser: Browser | null = null;
|
||||||
|
|
@ -18,11 +30,19 @@ export class BrowserManager {
|
||||||
private nextTabId: number = 1;
|
private nextTabId: number = 1;
|
||||||
private extraHeaders: Record<string, string> = {};
|
private extraHeaders: Record<string, string> = {};
|
||||||
private customUserAgent: string | null = null;
|
private customUserAgent: string | null = null;
|
||||||
|
private readonly settingsFile: string | null;
|
||||||
|
|
||||||
// ─── Ref Map (snapshot → @e1, @e2, ...) ────────────────────
|
// ─── Ref Map (tab → snapshot refs → frozen element handles) ─────────────
|
||||||
private refMap: Map<string, Locator> = new Map();
|
private refMaps: Map<number, Map<string, ElementHandle<Node>>> = new Map();
|
||||||
|
// Request object identity is stable even when multiple requests share a URL.
|
||||||
|
private requestEntries: WeakMap<Request, NetworkEntry> = new WeakMap();
|
||||||
|
|
||||||
|
constructor(settingsFile?: string | null) {
|
||||||
|
this.settingsFile = settingsFile ?? process.env.BROWSE_SETTINGS_FILE ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
async launch() {
|
async launch() {
|
||||||
|
this.loadSettings();
|
||||||
this.browser = await chromium.launch({ headless: true });
|
this.browser = await chromium.launch({ headless: true });
|
||||||
|
|
||||||
// Chromium crash → exit with clear message
|
// Chromium crash → exit with clear message
|
||||||
|
|
@ -34,19 +54,29 @@ export class BrowserManager {
|
||||||
|
|
||||||
this.context = await this.browser.newContext({
|
this.context = await this.browser.newContext({
|
||||||
viewport: { width: 1280, height: 720 },
|
viewport: { width: 1280, height: 720 },
|
||||||
|
...(this.customUserAgent ? { userAgent: this.customUserAgent } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (Object.keys(this.extraHeaders).length > 0) {
|
||||||
|
await this.context.setExtraHTTPHeaders(this.extraHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
// Create first tab
|
// Create first tab
|
||||||
await this.newTab();
|
await this.newTab();
|
||||||
}
|
}
|
||||||
|
|
||||||
async close() {
|
async close() {
|
||||||
|
this.clearAllRefs();
|
||||||
if (this.browser) {
|
if (this.browser) {
|
||||||
// Remove disconnect handler to avoid exit during intentional close
|
// Remove disconnect handler to avoid exit during intentional close
|
||||||
this.browser.removeAllListeners('disconnected');
|
this.browser.removeAllListeners('disconnected');
|
||||||
await this.browser.close();
|
await this.browser.close();
|
||||||
this.browser = null;
|
this.browser = null;
|
||||||
}
|
}
|
||||||
|
this.context = null;
|
||||||
|
this.pages.clear();
|
||||||
|
this.activeTabId = 0;
|
||||||
|
this.nextTabId = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
isHealthy(): boolean {
|
isHealthy(): boolean {
|
||||||
|
|
@ -63,7 +93,7 @@ export class BrowserManager {
|
||||||
this.activeTabId = id;
|
this.activeTabId = id;
|
||||||
|
|
||||||
// Wire up console/network capture
|
// Wire up console/network capture
|
||||||
this.wirePageEvents(page);
|
this.wirePageEvents(id, page);
|
||||||
|
|
||||||
if (url) {
|
if (url) {
|
||||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||||
|
|
@ -77,6 +107,7 @@ export class BrowserManager {
|
||||||
const page = this.pages.get(tabId);
|
const page = this.pages.get(tabId);
|
||||||
if (!page) throw new Error(`Tab ${tabId} not found`);
|
if (!page) throw new Error(`Tab ${tabId} not found`);
|
||||||
|
|
||||||
|
this.clearRefs(tabId);
|
||||||
await page.close();
|
await page.close();
|
||||||
this.pages.delete(tabId);
|
this.pages.delete(tabId);
|
||||||
|
|
||||||
|
|
@ -143,34 +174,60 @@ export class BrowserManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Ref Map ──────────────────────────────────────────────
|
// ─── Ref Map ──────────────────────────────────────────────
|
||||||
setRefMap(refs: Map<string, Locator>) {
|
setRefMap(refs: Map<string, ElementHandle<Node>>, tabId: number = this.activeTabId) {
|
||||||
this.refMap = refs;
|
this.clearRefs(tabId);
|
||||||
|
if (refs.size > 0) {
|
||||||
|
this.refMaps.set(tabId, refs);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clearRefs() {
|
clearRefs(tabId: number = this.activeTabId) {
|
||||||
this.refMap.clear();
|
const refs = this.refMaps.get(tabId);
|
||||||
|
if (!refs) return;
|
||||||
|
for (const handle of refs.values()) {
|
||||||
|
void handle.dispose().catch(() => {});
|
||||||
|
}
|
||||||
|
this.refMaps.delete(tabId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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") or a CSS selector.
|
||||||
* Returns { locator } for refs or { selector } for CSS selectors.
|
* Returns { handle } for refs or { selector } for CSS selectors.
|
||||||
*/
|
*/
|
||||||
resolveRef(selector: string): { locator: Locator } | { selector: string } {
|
resolveRef(selector: string): { handle: ElementHandle<Node> } | { selector: string } {
|
||||||
if (selector.startsWith('@e')) {
|
if (selector.startsWith('@e')) {
|
||||||
const ref = selector.slice(1); // "e3"
|
const ref = selector.slice(1); // "e3"
|
||||||
const locator = this.refMap.get(ref);
|
const refMap = this.refMaps.get(this.activeTabId);
|
||||||
if (!locator) {
|
const handle = refMap?.get(ref);
|
||||||
|
if (!handle) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Ref ${selector} not found. Page may have changed — run 'snapshot' to get fresh refs.`
|
`Ref ${selector} not found. Page may have changed — run 'snapshot' to get fresh refs.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { locator };
|
return { handle };
|
||||||
}
|
}
|
||||||
return { selector };
|
return { selector };
|
||||||
}
|
}
|
||||||
|
|
||||||
getRefCount(): number {
|
getRefCount(tabId: number = this.activeTabId): number {
|
||||||
return this.refMap.size;
|
return this.refMaps.get(tabId)?.size ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
rethrowIfStaleRef(selector: string, err: unknown): never {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
const isStale =
|
||||||
|
message.includes('Element is not attached to the DOM') ||
|
||||||
|
message.includes('Execution context was destroyed') ||
|
||||||
|
message.includes('JSHandle is disposed') ||
|
||||||
|
message.includes('Target page, context or browser has been closed');
|
||||||
|
|
||||||
|
if (selector.startsWith('@e') && isStale) {
|
||||||
|
// Normalize detached-handle errors back to the same stale-ref guidance
|
||||||
|
// the old locator-based implementation returned after navigation.
|
||||||
|
this.removeRef(selector);
|
||||||
|
throw new Error(`Ref ${selector} not found. Page may have changed — run 'snapshot' to get fresh refs.`);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Viewport ──────────────────────────────────────────────
|
// ─── Viewport ──────────────────────────────────────────────
|
||||||
|
|
@ -191,17 +248,22 @@ export class BrowserManager {
|
||||||
// For simplicity, we just store it and apply on next "restart"
|
// For simplicity, we just store it and apply on next "restart"
|
||||||
setUserAgent(ua: string) {
|
setUserAgent(ua: string) {
|
||||||
this.customUserAgent = ua;
|
this.customUserAgent = ua;
|
||||||
|
this.persistSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Console/Network/Ref Wiring ────────────────────────────
|
// ─── Console/Network/Ref Wiring ────────────────────────────
|
||||||
private wirePageEvents(page: Page) {
|
private wirePageEvents(tabId: number, page: Page) {
|
||||||
// Clear ref map on navigation — refs point to stale elements after page change
|
// Clear this tab's ref map on navigation — refs point to stale elements after page change
|
||||||
page.on('framenavigated', (frame) => {
|
page.on('framenavigated', (frame) => {
|
||||||
if (frame === page.mainFrame()) {
|
if (frame === page.mainFrame()) {
|
||||||
this.clearRefs();
|
this.clearRefs(tabId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
page.on('close', () => {
|
||||||
|
this.clearRefs(tabId);
|
||||||
|
});
|
||||||
|
|
||||||
page.on('console', (msg) => {
|
page.on('console', (msg) => {
|
||||||
addConsoleEntry({
|
addConsoleEntry({
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
|
@ -211,43 +273,84 @@ export class BrowserManager {
|
||||||
});
|
});
|
||||||
|
|
||||||
page.on('request', (req) => {
|
page.on('request', (req) => {
|
||||||
addNetworkEntry({
|
const entry = {
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
method: req.method(),
|
method: req.method(),
|
||||||
url: req.url(),
|
url: req.url(),
|
||||||
});
|
};
|
||||||
|
addNetworkEntry(entry);
|
||||||
|
this.requestEntries.set(req, entry);
|
||||||
});
|
});
|
||||||
|
|
||||||
page.on('response', (res) => {
|
page.on('response', (res) => {
|
||||||
// Find matching request entry and update it
|
const entry = this.requestEntries.get(res.request());
|
||||||
const url = res.url();
|
if (entry) {
|
||||||
const status = res.status();
|
entry.status = res.status();
|
||||||
for (let i = networkBuffer.length - 1; i >= 0; i--) {
|
|
||||||
if (networkBuffer[i].url === url && !networkBuffer[i].status) {
|
|
||||||
networkBuffer[i].status = status;
|
|
||||||
networkBuffer[i].duration = Date.now() - networkBuffer[i].timestamp;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Capture response sizes via response finished
|
|
||||||
page.on('requestfinished', async (req) => {
|
page.on('requestfinished', async (req) => {
|
||||||
|
const entry = this.requestEntries.get(req);
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await req.response();
|
const timing = req.timing();
|
||||||
if (res) {
|
if (timing.responseEnd >= 0) {
|
||||||
const url = req.url();
|
entry.duration = Math.round(timing.responseEnd);
|
||||||
const body = await res.body().catch(() => null);
|
|
||||||
const size = body ? body.length : 0;
|
|
||||||
for (let i = networkBuffer.length - 1; i >= 0; i--) {
|
|
||||||
if (networkBuffer[i].url === url && !networkBuffer[i].size) {
|
|
||||||
networkBuffer[i].size = size;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {}
|
const sizes = await req.sizes().catch(() => null);
|
||||||
|
if (sizes) {
|
||||||
|
entry.size = sizes.responseBodySize;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
} finally {
|
||||||
|
this.requestEntries.delete(req);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
page.on('requestfailed', (req) => {
|
||||||
|
const entry = this.requestEntries.get(req);
|
||||||
|
if (entry) {
|
||||||
|
const timing = req.timing();
|
||||||
|
if (timing.responseEnd >= 0) {
|
||||||
|
entry.duration = Math.round(timing.responseEnd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.requestEntries.delete(req);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearAllRefs() {
|
||||||
|
for (const tabId of [...this.refMaps.keys()]) {
|
||||||
|
this.clearRefs(tabId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeRef(selector: string, tabId: number = this.activeTabId) {
|
||||||
|
if (!selector.startsWith('@e')) return;
|
||||||
|
const ref = selector.slice(1);
|
||||||
|
const refs = this.refMaps.get(tabId);
|
||||||
|
const handle = refs?.get(ref);
|
||||||
|
if (!refs || !handle) return;
|
||||||
|
void handle.dispose().catch(() => {});
|
||||||
|
refs.delete(ref);
|
||||||
|
if (refs.size === 0) {
|
||||||
|
this.refMaps.delete(tabId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadSettings() {
|
||||||
|
if (!this.settingsFile) return;
|
||||||
|
try {
|
||||||
|
const settings = JSON.parse(fs.readFileSync(this.settingsFile, 'utf-8')) as BrowserSettings;
|
||||||
|
this.customUserAgent = settings.userAgent ?? null;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private persistSettings() {
|
||||||
|
if (!this.settingsFile) return;
|
||||||
|
fs.writeFileSync(this.settingsFile, JSON.stringify({ userAgent: this.customUserAgent } satisfies BrowserSettings, null, 2), {
|
||||||
|
mode: 0o600,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,10 @@ const BROWSE_PORT = process.env.CONDUCTOR_PORT
|
||||||
: parseInt(process.env.BROWSE_PORT || '0', 10);
|
: parseInt(process.env.BROWSE_PORT || '0', 10);
|
||||||
const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : '';
|
const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : '';
|
||||||
const STATE_FILE = process.env.BROWSE_STATE_FILE || `/tmp/browse-server${INSTANCE_SUFFIX}.json`;
|
const STATE_FILE = process.env.BROWSE_STATE_FILE || `/tmp/browse-server${INSTANCE_SUFFIX}.json`;
|
||||||
|
// Serialize startup so parallel agent shells don't spawn duplicate daemons.
|
||||||
|
const LOCK_FILE = `${STATE_FILE}.lock`;
|
||||||
const MAX_START_WAIT = 8000; // 8 seconds to start
|
const MAX_START_WAIT = 8000; // 8 seconds to start
|
||||||
|
const LOCK_STALE_MS = 30_000;
|
||||||
|
|
||||||
export function resolveServerScript(
|
export function resolveServerScript(
|
||||||
env: Record<string, string | undefined> = process.env,
|
env: Record<string, string | undefined> = process.env,
|
||||||
|
|
@ -59,6 +62,11 @@ interface ServerState {
|
||||||
serverPath: string;
|
serverPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StartLock {
|
||||||
|
pid: number;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── State File ────────────────────────────────────────────────
|
// ─── State File ────────────────────────────────────────────────
|
||||||
function readState(): ServerState | null {
|
function readState(): ServerState | null {
|
||||||
try {
|
try {
|
||||||
|
|
@ -78,12 +86,74 @@ function isProcessAlive(pid: number): boolean {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Server Lifecycle ──────────────────────────────────────────
|
function readLock(): StartLock | null {
|
||||||
async function startServer(): Promise<ServerState> {
|
try {
|
||||||
// Clean up stale state file
|
return JSON.parse(fs.readFileSync(LOCK_FILE, 'utf-8')) as StartLock;
|
||||||
try { fs.unlinkSync(STATE_FILE); } catch {}
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryAcquireStartLock(): boolean {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(
|
||||||
|
LOCK_FILE,
|
||||||
|
JSON.stringify({ pid: process.pid, createdAt: Date.now() } satisfies StartLock),
|
||||||
|
{ flag: 'wx', mode: 0o600 }
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.code === 'EEXIST') return false;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearOwnedStartLock() {
|
||||||
|
const lock = readLock();
|
||||||
|
if (lock?.pid === process.pid) {
|
||||||
|
try { fs.unlinkSync(LOCK_FILE); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearStaleStartLock() {
|
||||||
|
const lock = readLock();
|
||||||
|
if (!lock) return;
|
||||||
|
if (!isProcessAlive(lock.pid) || Date.now() - lock.createdAt > LOCK_STALE_MS) {
|
||||||
|
try { fs.unlinkSync(LOCK_FILE); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchHealth(state: ServerState, timeout = 2000): Promise<{ status: string } | null> {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`http://127.0.0.1:${state.port}/health`, {
|
||||||
|
signal: AbortSignal.timeout(timeout),
|
||||||
|
});
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
return await resp.json() as { status: string };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getHealthyState(): Promise<ServerState | null> {
|
||||||
|
const state = readState();
|
||||||
|
if (!state || !isProcessAlive(state.pid)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const health = await fetchHealth(state);
|
||||||
|
if (health?.status === 'healthy') {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Server Lifecycle ──────────────────────────────────────────
|
||||||
|
async function spawnServerProcess(): Promise<ServerState> {
|
||||||
|
const existing = readState();
|
||||||
|
if (existing && !isProcessAlive(existing.pid)) {
|
||||||
|
try { fs.unlinkSync(STATE_FILE); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
// Start server as detached background process
|
|
||||||
const proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], {
|
const proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], {
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
env: { ...process.env },
|
env: { ...process.env },
|
||||||
|
|
@ -92,18 +162,18 @@ async function startServer(): Promise<ServerState> {
|
||||||
// Don't hold the CLI open
|
// Don't hold the CLI open
|
||||||
proc.unref();
|
proc.unref();
|
||||||
|
|
||||||
// Wait for state file to appear
|
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
while (Date.now() - start < MAX_START_WAIT) {
|
while (Date.now() - start < MAX_START_WAIT) {
|
||||||
const state = readState();
|
const state = readState();
|
||||||
if (state && isProcessAlive(state.pid)) {
|
if (state && isProcessAlive(state.pid)) {
|
||||||
return state;
|
const health = await fetchHealth(state, 1000);
|
||||||
|
if (health?.status === 'healthy') {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await Bun.sleep(100);
|
await Bun.sleep(100);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we get here, server didn't start in time
|
|
||||||
// Try to read stderr for error message
|
|
||||||
const stderr = proc.stderr;
|
const stderr = proc.stderr;
|
||||||
if (stderr) {
|
if (stderr) {
|
||||||
const reader = stderr.getReader();
|
const reader = stderr.getReader();
|
||||||
|
|
@ -116,31 +186,61 @@ async function startServer(): Promise<ServerState> {
|
||||||
throw new Error(`Server failed to start within ${MAX_START_WAIT / 1000}s`);
|
throw new Error(`Server failed to start within ${MAX_START_WAIT / 1000}s`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureServer(): Promise<ServerState> {
|
async function startServer(): Promise<ServerState> {
|
||||||
const state = readState();
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < MAX_START_WAIT) {
|
||||||
|
const healthy = await getHealthyState();
|
||||||
|
if (healthy) return healthy;
|
||||||
|
|
||||||
if (state && isProcessAlive(state.pid)) {
|
// Another CLI process may already be starting the daemon. Wait for it
|
||||||
// Server appears alive — do a health check
|
// unless the lock is stale, then take over.
|
||||||
try {
|
clearStaleStartLock();
|
||||||
const resp = await fetch(`http://127.0.0.1:${state.port}/health`, {
|
if (tryAcquireStartLock()) {
|
||||||
signal: AbortSignal.timeout(2000),
|
try {
|
||||||
});
|
const healthyAfterLock = await getHealthyState();
|
||||||
if (resp.ok) {
|
if (healthyAfterLock) return healthyAfterLock;
|
||||||
const health = await resp.json() as any;
|
return await spawnServerProcess();
|
||||||
if (health.status === 'healthy') {
|
} finally {
|
||||||
return state;
|
clearOwnedStartLock();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// Health check failed — server is dead or unhealthy
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await Bun.sleep(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
const healthy = await getHealthyState();
|
||||||
|
if (healthy) return healthy;
|
||||||
|
throw new Error('[browse] Timed out waiting for server startup');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureServer(): Promise<ServerState> {
|
||||||
|
const healthy = await getHealthyState();
|
||||||
|
if (healthy) {
|
||||||
|
return healthy;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Need to (re)start
|
|
||||||
console.error('[browse] Starting server...');
|
console.error('[browse] Starting server...');
|
||||||
return startServer();
|
return startServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForServerStop(pid: number, timeout = MAX_START_WAIT): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeout) {
|
||||||
|
const alive = isProcessAlive(pid);
|
||||||
|
const stateExists = fs.existsSync(STATE_FILE);
|
||||||
|
if (!alive && !stateExists) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Bun.sleep(100);
|
||||||
|
}
|
||||||
|
throw new Error('[browse] Timed out waiting for server shutdown');
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCommandOutput(text: string) {
|
||||||
|
process.stdout.write(text);
|
||||||
|
if (!text.endsWith('\n')) process.stdout.write('\n');
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Command Dispatch ──────────────────────────────────────────
|
// ─── Command Dispatch ──────────────────────────────────────────
|
||||||
async function sendCommand(state: ServerState, command: string, args: string[], retries = 0): Promise<void> {
|
async function sendCommand(state: ServerState, command: string, args: string[], retries = 0): Promise<void> {
|
||||||
const body = JSON.stringify({ command, args });
|
const body = JSON.stringify({ command, args });
|
||||||
|
|
@ -169,19 +269,38 @@ async function sendCommand(state: ServerState, command: string, args: string[],
|
||||||
const text = await resp.text();
|
const text = await resp.text();
|
||||||
|
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
process.stdout.write(text);
|
writeCommandOutput(text);
|
||||||
if (!text.endsWith('\n')) process.stdout.write('\n');
|
|
||||||
} else {
|
if (command === 'stop') {
|
||||||
// Try to parse as JSON error
|
// "stop" returns success before the server exits. Wait for the daemon
|
||||||
try {
|
// and state file to disappear so the CLI only exits green on a real stop.
|
||||||
const err = JSON.parse(text);
|
await waitForServerStop(state.pid);
|
||||||
console.error(err.error || text);
|
return;
|
||||||
if (err.hint) console.error(err.hint);
|
|
||||||
} catch {
|
|
||||||
console.error(text);
|
|
||||||
}
|
}
|
||||||
process.exit(1);
|
|
||||||
|
if (command === 'restart') {
|
||||||
|
// "restart" is "clean stop, then ensure a fresh daemon exists" — not
|
||||||
|
// "drop the socket and hope the next command recovers it."
|
||||||
|
await waitForServerStop(state.pid);
|
||||||
|
const newState = await ensureServer();
|
||||||
|
if (newState.pid === state.pid) {
|
||||||
|
throw new Error('[browse] Restart did not replace the server process');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try to parse as JSON error
|
||||||
|
try {
|
||||||
|
const err = JSON.parse(text);
|
||||||
|
console.error(err.error || text);
|
||||||
|
if (err.hint) console.error(err.hint);
|
||||||
|
} catch {
|
||||||
|
console.error(text);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.name === 'AbortError') {
|
if (err.name === 'AbortError') {
|
||||||
console.error('[browse] Command timed out after 30s');
|
console.error('[browse] Command timed out after 30s');
|
||||||
|
|
@ -220,7 +339,7 @@ Snapshot: snapshot [-i] [-c] [-d N] [-s sel]
|
||||||
Compare: diff <url1> <url2>
|
Compare: diff <url1> <url2>
|
||||||
Multi-step: chain (reads JSON from stdin)
|
Multi-step: chain (reads JSON from stdin)
|
||||||
Tabs: tabs | tab <id> | newtab [url] | closetab [id]
|
Tabs: tabs | tab <id> | newtab [url] | closetab [id]
|
||||||
Server: status | cookie <n>=<v> | header <n>:<v>
|
Server: status | cookie <n>=<v> [origin] | header <n>:<v>
|
||||||
useragent <str> | stop | restart
|
useragent <str> | stop | restart
|
||||||
|
|
||||||
Refs: After 'snapshot', use @e1, @e2... as selectors:
|
Refs: After 'snapshot', use @e1, @e2... as selectors:
|
||||||
|
|
|
||||||
|
|
@ -58,14 +58,20 @@ export async function handleMetaCommand(
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'stop': {
|
case 'stop': {
|
||||||
await shutdown();
|
// Return the HTTP response first so the CLI sees a clean stop,
|
||||||
|
// then shut down on the next tick.
|
||||||
|
setTimeout(() => {
|
||||||
|
void shutdown();
|
||||||
|
}, 0);
|
||||||
return 'Server stopped';
|
return 'Server stopped';
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'restart': {
|
case 'restart': {
|
||||||
// Signal that we want a restart — the CLI will detect exit and restart
|
// Signal that we want a restart — return success first, then exit.
|
||||||
console.log('[browse] Restart requested. Exiting for CLI to restart.');
|
// The CLI waits for shutdown and starts a fresh daemon immediately.
|
||||||
await shutdown();
|
setTimeout(() => {
|
||||||
|
void shutdown();
|
||||||
|
}, 0);
|
||||||
return 'Restarting...';
|
return 'Restarting...';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,12 @@ export async function handleReadCommand(
|
||||||
const selector = args[0];
|
const selector = args[0];
|
||||||
if (selector) {
|
if (selector) {
|
||||||
const resolved = bm.resolveRef(selector);
|
const resolved = bm.resolveRef(selector);
|
||||||
if ('locator' in resolved) {
|
if ('handle' in resolved) {
|
||||||
return await resolved.locator.innerHTML({ timeout: 5000 });
|
try {
|
||||||
|
return await resolved.handle.innerHTML();
|
||||||
|
} catch (err) {
|
||||||
|
bm.rethrowIfStaleRef(selector, err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return await page.innerHTML(resolved.selector);
|
return await page.innerHTML(resolved.selector);
|
||||||
}
|
}
|
||||||
|
|
@ -108,12 +112,16 @@ export async function handleReadCommand(
|
||||||
const [selector, property] = args;
|
const [selector, property] = args;
|
||||||
if (!selector || !property) throw new Error('Usage: browse css <selector> <property>');
|
if (!selector || !property) throw new Error('Usage: browse css <selector> <property>');
|
||||||
const resolved = bm.resolveRef(selector);
|
const resolved = bm.resolveRef(selector);
|
||||||
if ('locator' in resolved) {
|
if ('handle' in resolved) {
|
||||||
const value = await resolved.locator.evaluate(
|
try {
|
||||||
(el, prop) => getComputedStyle(el).getPropertyValue(prop),
|
const value = await resolved.handle.evaluate(
|
||||||
property
|
(el, prop) => getComputedStyle(el as Element).getPropertyValue(prop),
|
||||||
);
|
property
|
||||||
return value;
|
);
|
||||||
|
return value;
|
||||||
|
} catch (err) {
|
||||||
|
bm.rethrowIfStaleRef(selector, err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const value = await page.evaluate(
|
const value = await page.evaluate(
|
||||||
([sel, prop]) => {
|
([sel, prop]) => {
|
||||||
|
|
@ -130,15 +138,19 @@ export async function handleReadCommand(
|
||||||
const selector = args[0];
|
const selector = args[0];
|
||||||
if (!selector) throw new Error('Usage: browse attrs <selector>');
|
if (!selector) throw new Error('Usage: browse attrs <selector>');
|
||||||
const resolved = bm.resolveRef(selector);
|
const resolved = bm.resolveRef(selector);
|
||||||
if ('locator' in resolved) {
|
if ('handle' in resolved) {
|
||||||
const attrs = await resolved.locator.evaluate((el) => {
|
try {
|
||||||
const result: Record<string, string> = {};
|
const attrs = await resolved.handle.evaluate((el) => {
|
||||||
for (const attr of el.attributes) {
|
const result: Record<string, string> = {};
|
||||||
result[attr.name] = attr.value;
|
for (const attr of (el as Element).attributes) {
|
||||||
}
|
result[attr.name] = attr.value;
|
||||||
return result;
|
}
|
||||||
});
|
return result;
|
||||||
return JSON.stringify(attrs, null, 2);
|
});
|
||||||
|
return JSON.stringify(attrs, null, 2);
|
||||||
|
} catch (err) {
|
||||||
|
bm.rethrowIfStaleRef(selector, err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const attrs = await page.evaluate((sel) => {
|
const attrs = await page.evaluate((sel) => {
|
||||||
const el = document.querySelector(sel);
|
const el = document.querySelector(sel);
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,9 @@ const BROWSE_PORT = process.env.CONDUCTOR_PORT
|
||||||
: parseInt(process.env.BROWSE_PORT || '0', 10); // 0 = auto-scan
|
: parseInt(process.env.BROWSE_PORT || '0', 10); // 0 = auto-scan
|
||||||
const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : '';
|
const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : '';
|
||||||
const STATE_FILE = process.env.BROWSE_STATE_FILE || `/tmp/browse-server${INSTANCE_SUFFIX}.json`;
|
const STATE_FILE = process.env.BROWSE_STATE_FILE || `/tmp/browse-server${INSTANCE_SUFFIX}.json`;
|
||||||
|
const SETTINGS_FILE = process.env.BROWSE_SETTINGS_FILE || `${STATE_FILE}.settings.json`;
|
||||||
const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || '1800000', 10); // 30 min
|
const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || '1800000', 10); // 30 min
|
||||||
|
const SHUTDOWN_GRACE_MS = 1000;
|
||||||
|
|
||||||
function validateAuth(req: Request): boolean {
|
function validateAuth(req: Request): boolean {
|
||||||
const header = req.headers.get('authorization');
|
const header = req.headers.get('authorization');
|
||||||
|
|
@ -83,7 +85,7 @@ const idleCheckInterval = setInterval(() => {
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
// ─── Server ────────────────────────────────────────────────────
|
// ─── Server ────────────────────────────────────────────────────
|
||||||
const browserManager = new BrowserManager();
|
const browserManager = new BrowserManager(SETTINGS_FILE);
|
||||||
let isShuttingDown = false;
|
let isShuttingDown = false;
|
||||||
|
|
||||||
// Read/write/meta command sets for routing
|
// Read/write/meta command sets for routing
|
||||||
|
|
@ -184,11 +186,19 @@ async function shutdown() {
|
||||||
clearInterval(idleCheckInterval);
|
clearInterval(idleCheckInterval);
|
||||||
flushBuffers(); // Final flush
|
flushBuffers(); // Final flush
|
||||||
|
|
||||||
await browserManager.close();
|
// Remove the state file before closing Chromium so CLI stop/restart polling
|
||||||
|
// can observe shutdown progress even if browser.close() takes a moment.
|
||||||
// Clean up state file
|
|
||||||
try { fs.unlinkSync(STATE_FILE); } catch {}
|
try { fs.unlinkSync(STATE_FILE); } catch {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Graceful close is best-effort here. If Chromium hangs, still exit so
|
||||||
|
// stop/restart cannot wedge forever.
|
||||||
|
await Promise.race([
|
||||||
|
browserManager.close(),
|
||||||
|
Bun.sleep(SHUTDOWN_GRACE_MS),
|
||||||
|
]);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
/**
|
/**
|
||||||
* Snapshot command — accessibility tree with ref-based element selection
|
* Snapshot command — accessibility tree with ref-based element selection
|
||||||
*
|
*
|
||||||
* Architecture (Locator map — no DOM mutation):
|
* Architecture (frozen handle map — no DOM mutation):
|
||||||
* 1. page.locator(scope).ariaSnapshot() → YAML-like accessibility tree
|
* 1. page.locator(scope).ariaSnapshot() → YAML-like accessibility tree
|
||||||
* 2. Parse tree, assign refs @e1, @e2, ...
|
* 2. Parse tree, assign refs @e1, @e2, ...
|
||||||
* 3. Build Playwright Locator for each ref (getByRole + nth)
|
* 3. Build Playwright Locator for each ref (getByRole + nth)
|
||||||
* 4. Store Map<string, Locator> on BrowserManager
|
* 4. Resolve each locator to an ElementHandle and store it per tab
|
||||||
* 5. Return compact text output with refs prepended
|
* 5. Return compact text output with refs prepended
|
||||||
*
|
*
|
||||||
* Later: "click @e3" → look up Locator → locator.click()
|
* Later: "click @e3" → look up frozen handle → handle.click()
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Page, Locator } from 'playwright';
|
import type { ElementHandle, Locator } from 'playwright';
|
||||||
import type { BrowserManager } from './browser-manager';
|
import type { BrowserManager } from './browser-manager';
|
||||||
|
|
||||||
// Roles considered "interactive" for the -i flag
|
// Roles considered "interactive" for the -i flag
|
||||||
|
|
@ -38,6 +38,10 @@ interface ParsedNode {
|
||||||
rawLine: string;
|
rawLine: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function unescapeQuotedText(value: string): string {
|
||||||
|
return value.replace(/\\\\/g, '\\').replace(/\\"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse CLI args into SnapshotOptions
|
* Parse CLI args into SnapshotOptions
|
||||||
*/
|
*/
|
||||||
|
|
@ -83,7 +87,7 @@ export function parseSnapshotArgs(args: string[]): SnapshotOptions {
|
||||||
*/
|
*/
|
||||||
function parseLine(line: string): ParsedNode | null {
|
function parseLine(line: string): ParsedNode | null {
|
||||||
// Match: (indent)(- )(role)( "name")?( [props])?(: inline)?
|
// Match: (indent)(- )(role)( "name")?( [props])?(: inline)?
|
||||||
const match = line.match(/^(\s*)-\s+(\w+)(?:\s+"([^"]*)")?(?:\s+(\[.*?\]))?\s*(?::\s*(.*))?$/);
|
const match = line.match(/^(\s*)-\s+(\w+)(?:\s+"((?:[^"\\]|\\.)*)")?(?:\s+(\[.*?\]))?\s*(?::\s*(.*))?$/);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
// Skip metadata lines like "- /url: /a"
|
// Skip metadata lines like "- /url: /a"
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -91,7 +95,7 @@ function parseLine(line: string): ParsedNode | null {
|
||||||
return {
|
return {
|
||||||
indent: match[1].length,
|
indent: match[1].length,
|
||||||
role: match[2],
|
role: match[2],
|
||||||
name: match[3] ?? null,
|
name: match[3] ? unescapeQuotedText(match[3]) : null,
|
||||||
props: match[4] || '',
|
props: match[4] || '',
|
||||||
children: match[5]?.trim() || '',
|
children: match[5]?.trim() || '',
|
||||||
rawLine: line,
|
rawLine: line,
|
||||||
|
|
@ -126,7 +130,7 @@ export async function handleSnapshot(
|
||||||
|
|
||||||
// Parse the ariaSnapshot output
|
// Parse the ariaSnapshot output
|
||||||
const lines = ariaText.split('\n');
|
const lines = ariaText.split('\n');
|
||||||
const refMap = new Map<string, Locator>();
|
const refMap = new Map<string, ElementHandle<Node>>();
|
||||||
const output: string[] = [];
|
const output: string[] = [];
|
||||||
let refCounter = 1;
|
let refCounter = 1;
|
||||||
|
|
||||||
|
|
@ -190,7 +194,20 @@ export async function handleSnapshot(
|
||||||
locator = locator.nth(seenIndex);
|
locator = locator.nth(seenIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
refMap.set(ref, locator);
|
// Some accessibility nodes (for example structural text nodes) do not map
|
||||||
|
// cleanly back to a single DOM element. Skip those instead of stalling the
|
||||||
|
// whole snapshot.
|
||||||
|
let handle: ElementHandle<Node> | null = null;
|
||||||
|
try {
|
||||||
|
const count = await locator.count();
|
||||||
|
if (count !== 1) continue;
|
||||||
|
handle = await locator.elementHandle({ timeout: 100 });
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!handle) continue;
|
||||||
|
|
||||||
|
refMap.set(ref, handle);
|
||||||
|
|
||||||
// Format output line
|
// Format output line
|
||||||
let outputLine = `${indent}@${ref} [${node.role}]`;
|
let outputLine = `${indent}@${ref} [${node.role}]`;
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,12 @@ export async function handleWriteCommand(
|
||||||
const selector = args[0];
|
const selector = args[0];
|
||||||
if (!selector) throw new Error('Usage: browse click <selector>');
|
if (!selector) throw new Error('Usage: browse click <selector>');
|
||||||
const resolved = bm.resolveRef(selector);
|
const resolved = bm.resolveRef(selector);
|
||||||
if ('locator' in resolved) {
|
if ('handle' in resolved) {
|
||||||
await resolved.locator.click({ timeout: 5000 });
|
try {
|
||||||
|
await resolved.handle.click({ timeout: 5000 });
|
||||||
|
} catch (err) {
|
||||||
|
bm.rethrowIfStaleRef(selector, err);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await page.click(resolved.selector, { timeout: 5000 });
|
await page.click(resolved.selector, { timeout: 5000 });
|
||||||
}
|
}
|
||||||
|
|
@ -55,10 +59,14 @@ export async function handleWriteCommand(
|
||||||
case 'fill': {
|
case 'fill': {
|
||||||
const [selector, ...valueParts] = args;
|
const [selector, ...valueParts] = args;
|
||||||
const value = valueParts.join(' ');
|
const value = valueParts.join(' ');
|
||||||
if (!selector || !value) throw new Error('Usage: browse fill <selector> <value>');
|
if (!selector || args.length < 2) throw new Error('Usage: browse fill <selector> <value>');
|
||||||
const resolved = bm.resolveRef(selector);
|
const resolved = bm.resolveRef(selector);
|
||||||
if ('locator' in resolved) {
|
if ('handle' in resolved) {
|
||||||
await resolved.locator.fill(value, { timeout: 5000 });
|
try {
|
||||||
|
await resolved.handle.fill(value, { timeout: 5000 });
|
||||||
|
} catch (err) {
|
||||||
|
bm.rethrowIfStaleRef(selector, err);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await page.fill(resolved.selector, value, { timeout: 5000 });
|
await page.fill(resolved.selector, value, { timeout: 5000 });
|
||||||
}
|
}
|
||||||
|
|
@ -68,10 +76,14 @@ export async function handleWriteCommand(
|
||||||
case 'select': {
|
case 'select': {
|
||||||
const [selector, ...valueParts] = args;
|
const [selector, ...valueParts] = args;
|
||||||
const value = valueParts.join(' ');
|
const value = valueParts.join(' ');
|
||||||
if (!selector || !value) throw new Error('Usage: browse select <selector> <value>');
|
if (!selector || args.length < 2) throw new Error('Usage: browse select <selector> <value>');
|
||||||
const resolved = bm.resolveRef(selector);
|
const resolved = bm.resolveRef(selector);
|
||||||
if ('locator' in resolved) {
|
if ('handle' in resolved) {
|
||||||
await resolved.locator.selectOption(value, { timeout: 5000 });
|
try {
|
||||||
|
await resolved.handle.selectOption(value, { timeout: 5000 });
|
||||||
|
} catch (err) {
|
||||||
|
bm.rethrowIfStaleRef(selector, err);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await page.selectOption(resolved.selector, value, { timeout: 5000 });
|
await page.selectOption(resolved.selector, value, { timeout: 5000 });
|
||||||
}
|
}
|
||||||
|
|
@ -82,8 +94,12 @@ export async function handleWriteCommand(
|
||||||
const selector = args[0];
|
const selector = args[0];
|
||||||
if (!selector) throw new Error('Usage: browse hover <selector>');
|
if (!selector) throw new Error('Usage: browse hover <selector>');
|
||||||
const resolved = bm.resolveRef(selector);
|
const resolved = bm.resolveRef(selector);
|
||||||
if ('locator' in resolved) {
|
if ('handle' in resolved) {
|
||||||
await resolved.locator.hover({ timeout: 5000 });
|
try {
|
||||||
|
await resolved.handle.hover({ timeout: 5000 });
|
||||||
|
} catch (err) {
|
||||||
|
bm.rethrowIfStaleRef(selector, err);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await page.hover(resolved.selector, { timeout: 5000 });
|
await page.hover(resolved.selector, { timeout: 5000 });
|
||||||
}
|
}
|
||||||
|
|
@ -108,8 +124,12 @@ export async function handleWriteCommand(
|
||||||
const selector = args[0];
|
const selector = args[0];
|
||||||
if (selector) {
|
if (selector) {
|
||||||
const resolved = bm.resolveRef(selector);
|
const resolved = bm.resolveRef(selector);
|
||||||
if ('locator' in resolved) {
|
if ('handle' in resolved) {
|
||||||
await resolved.locator.scrollIntoViewIfNeeded({ timeout: 5000 });
|
try {
|
||||||
|
await resolved.handle.scrollIntoViewIfNeeded({ timeout: 5000 });
|
||||||
|
} catch (err) {
|
||||||
|
bm.rethrowIfStaleRef(selector, err);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await page.locator(resolved.selector).scrollIntoViewIfNeeded({ timeout: 5000 });
|
await page.locator(resolved.selector).scrollIntoViewIfNeeded({ timeout: 5000 });
|
||||||
}
|
}
|
||||||
|
|
@ -124,8 +144,12 @@ export async function handleWriteCommand(
|
||||||
if (!selector) throw new Error('Usage: browse wait <selector>');
|
if (!selector) throw new Error('Usage: browse wait <selector>');
|
||||||
const timeout = args[1] ? parseInt(args[1], 10) : 15000;
|
const timeout = args[1] ? parseInt(args[1], 10) : 15000;
|
||||||
const resolved = bm.resolveRef(selector);
|
const resolved = bm.resolveRef(selector);
|
||||||
if ('locator' in resolved) {
|
if ('handle' in resolved) {
|
||||||
await resolved.locator.waitFor({ state: 'visible', timeout });
|
try {
|
||||||
|
await resolved.handle.waitForElementState('visible', { timeout });
|
||||||
|
} catch (err) {
|
||||||
|
bm.rethrowIfStaleRef(selector, err);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await page.waitForSelector(resolved.selector, { timeout });
|
await page.waitForSelector(resolved.selector, { timeout });
|
||||||
}
|
}
|
||||||
|
|
@ -142,16 +166,28 @@ export async function handleWriteCommand(
|
||||||
|
|
||||||
case 'cookie': {
|
case 'cookie': {
|
||||||
const cookieStr = args[0];
|
const cookieStr = args[0];
|
||||||
if (!cookieStr || !cookieStr.includes('=')) throw new Error('Usage: browse cookie <name>=<value>');
|
if (!cookieStr || !cookieStr.includes('=')) throw new Error('Usage: browse cookie <name>=<value> [origin]');
|
||||||
const eq = cookieStr.indexOf('=');
|
const eq = cookieStr.indexOf('=');
|
||||||
const name = cookieStr.slice(0, eq);
|
const name = cookieStr.slice(0, eq);
|
||||||
const value = cookieStr.slice(eq + 1);
|
const value = cookieStr.slice(eq + 1);
|
||||||
const url = new URL(page.url());
|
let cookieUrl: string;
|
||||||
|
if (args[1]) {
|
||||||
|
try {
|
||||||
|
cookieUrl = new URL(args[1]).origin;
|
||||||
|
} catch {
|
||||||
|
throw new Error('Usage: browse cookie <name>=<value> [origin]');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const currentUrl = page.url();
|
||||||
|
if (currentUrl === 'about:blank') {
|
||||||
|
throw new Error('Usage: browse cookie <name>=<value> [origin]');
|
||||||
|
}
|
||||||
|
cookieUrl = new URL(currentUrl).origin;
|
||||||
|
}
|
||||||
await page.context().addCookies([{
|
await page.context().addCookies([{
|
||||||
name,
|
name,
|
||||||
value,
|
value,
|
||||||
domain: url.hostname,
|
url: cookieUrl,
|
||||||
path: '/',
|
|
||||||
}]);
|
}]);
|
||||||
return `Cookie set: ${name}=${value}`;
|
return `Cookie set: ${name}=${value}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,77 @@ afterAll(() => {
|
||||||
setTimeout(() => process.exit(0), 500);
|
setTimeout(() => process.exit(0), 500);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface CliResult {
|
||||||
|
code: number;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reservePort(): number {
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
fetch: () => new Response('ok'),
|
||||||
|
});
|
||||||
|
const { port } = server;
|
||||||
|
server.stop();
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson<T>(filePath: string): T | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, 'utf-8')) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCliCommand(args: string[], envOverrides: Record<string, string>, timeout = 20000): Promise<CliResult> {
|
||||||
|
const cliPath = path.resolve(__dirname, '../src/cli.ts');
|
||||||
|
return await new Promise<CliResult>((resolve) => {
|
||||||
|
const proc = spawn('bun', ['run', cliPath, ...args], {
|
||||||
|
timeout,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
...envOverrides,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
proc.stdout.on('data', (d) => stdout += d.toString());
|
||||||
|
proc.stderr.on('data', (d) => stderr += d.toString());
|
||||||
|
proc.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor<T>(fn: () => T | null | undefined, timeout = 5000, interval = 50): Promise<T | null> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeout) {
|
||||||
|
const value = fn();
|
||||||
|
if (value) return value;
|
||||||
|
await Bun.sleep(interval);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupCliState(stateFile: string) {
|
||||||
|
const state = readJson<{ pid?: number }>(stateFile);
|
||||||
|
if (state?.pid) {
|
||||||
|
try { process.kill(state.pid, 'SIGTERM'); } catch {}
|
||||||
|
await waitFor(() => {
|
||||||
|
try {
|
||||||
|
process.kill(state.pid!, 0);
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
try { fs.unlinkSync(stateFile); } catch {}
|
||||||
|
try { fs.unlinkSync(`${stateFile}.lock`); } catch {}
|
||||||
|
try { fs.unlinkSync(`${stateFile}.settings.json`); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Navigation ─────────────────────────────────────────────────
|
// ─── Navigation ─────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('Navigation', () => {
|
describe('Navigation', () => {
|
||||||
|
|
@ -230,6 +301,28 @@ describe('Interaction', () => {
|
||||||
const val = await handleReadCommand('js', ['document.querySelector("#name").value'], bm);
|
const val = await handleReadCommand('js', ['document.querySelector("#name").value'], bm);
|
||||||
expect(val).toBe('John Doe');
|
expect(val).toBe('John Doe');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('fill accepts empty string values', async () => {
|
||||||
|
await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm);
|
||||||
|
await handleWriteCommand('fill', ['#email', 'filled@example.com'], bm);
|
||||||
|
|
||||||
|
const result = await handleWriteCommand('fill', ['#email', ''], bm);
|
||||||
|
expect(result).toContain('Filled');
|
||||||
|
|
||||||
|
const value = await handleReadCommand('js', ['document.querySelector("#email").value'], bm);
|
||||||
|
expect(value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('select accepts empty string values', async () => {
|
||||||
|
await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm);
|
||||||
|
await handleWriteCommand('select', ['#role', 'admin'], bm);
|
||||||
|
|
||||||
|
const result = await handleWriteCommand('select', ['#role', ''], bm);
|
||||||
|
expect(result).toContain('Selected');
|
||||||
|
|
||||||
|
const value = await handleReadCommand('js', ['document.querySelector("#role").value'], bm);
|
||||||
|
expect(value).toBe('');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── SPA / Console / Network ───────────────────────────────────
|
// ─── SPA / Console / Network ───────────────────────────────────
|
||||||
|
|
@ -268,6 +361,51 @@ describe('SPA and buffers', () => {
|
||||||
const result = await handleReadCommand('network', ['--clear'], bm);
|
const result = await handleReadCommand('network', ['--clear'], bm);
|
||||||
expect(result).toContain('cleared');
|
expect(result).toContain('cleared');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('network keeps same-url requests paired with their own size and duration', async () => {
|
||||||
|
networkBuffer.length = 0;
|
||||||
|
let apiCount = 0;
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
async fetch(req) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
if (url.pathname === '/double.html') {
|
||||||
|
return new Response(`<!doctype html><body><script>
|
||||||
|
fetch('/api/data');
|
||||||
|
fetch('/api/data');
|
||||||
|
</script></body>`, {
|
||||||
|
headers: { 'Content-Type': 'text/html' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/data') {
|
||||||
|
apiCount += 1;
|
||||||
|
if (apiCount === 1) {
|
||||||
|
await Bun.sleep(15);
|
||||||
|
return new Response('small', {
|
||||||
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await Bun.sleep(200);
|
||||||
|
return new Response('X'.repeat(5000), {
|
||||||
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response('Not Found', { status: 404 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleWriteCommand('goto', [`http://127.0.0.1:${server.port}/double.html`], bm);
|
||||||
|
await Bun.sleep(700);
|
||||||
|
|
||||||
|
const entries = networkBuffer.filter((entry) => entry.url.endsWith('/api/data'));
|
||||||
|
try { server.stop(); } catch {}
|
||||||
|
|
||||||
|
expect(entries).toHaveLength(2);
|
||||||
|
expect(entries[0].size).toBe(5);
|
||||||
|
expect(entries[1].size).toBe(5000);
|
||||||
|
expect(entries[0].duration).toBeLessThan(entries[1].duration!);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Cookies / Storage ──────────────────────────────────────────
|
// ─── Cookies / Storage ──────────────────────────────────────────
|
||||||
|
|
@ -286,6 +424,27 @@ describe('Cookies and storage', () => {
|
||||||
const storage = JSON.parse(result);
|
const storage = JSON.parse(result);
|
||||||
expect(storage.localStorage.testKey).toBe('testValue');
|
expect(storage.localStorage.testKey).toBe('testValue');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('cookie supports explicit origin before first navigation', async () => {
|
||||||
|
const freshBrowser = new BrowserManager();
|
||||||
|
await freshBrowser.launch();
|
||||||
|
|
||||||
|
const result = await handleWriteCommand('cookie', ['session=abc123', baseUrl], freshBrowser);
|
||||||
|
expect(result).toContain('Cookie set');
|
||||||
|
|
||||||
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], freshBrowser);
|
||||||
|
const cookies = JSON.parse(await handleReadCommand('cookies', [], freshBrowser));
|
||||||
|
expect(cookies.some((cookie: any) => cookie.name === 'session' && cookie.value === 'abc123')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cookie on about:blank without explicit origin returns guidance error', async () => {
|
||||||
|
const freshBrowser = new BrowserManager();
|
||||||
|
await freshBrowser.launch();
|
||||||
|
|
||||||
|
await expect(handleWriteCommand('cookie', ['session=abc123'], freshBrowser)).rejects.toThrow(
|
||||||
|
'Usage: browse cookie <name>=<value> [origin]'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Performance ────────────────────────────────────────────────
|
// ─── Performance ────────────────────────────────────────────────
|
||||||
|
|
@ -486,6 +645,128 @@ describe('CLI lifecycle', () => {
|
||||||
expect(result.stdout).toContain('Status: healthy');
|
expect(result.stdout).toContain('Status: healthy');
|
||||||
expect(result.stderr).toContain('Starting server');
|
expect(result.stderr).toContain('Starting server');
|
||||||
}, 20000);
|
}, 20000);
|
||||||
|
|
||||||
|
test('stop exits cleanly and removes the state file', async () => {
|
||||||
|
const stateFile = `/tmp/browse-stop-state-${Date.now()}.json`;
|
||||||
|
const port = reservePort();
|
||||||
|
const env = {
|
||||||
|
BROWSE_STATE_FILE: stateFile,
|
||||||
|
BROWSE_PORT: String(port),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const started = await runCliCommand(['status'], env);
|
||||||
|
const startedState = readJson<{ pid: number }>(stateFile);
|
||||||
|
expect(started.code).toBe(0);
|
||||||
|
expect(startedState?.pid).toBeTruthy();
|
||||||
|
|
||||||
|
const stop = await runCliCommand(['stop'], env);
|
||||||
|
const pidGone = await waitFor(() => {
|
||||||
|
try {
|
||||||
|
process.kill(startedState!.pid, 0);
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
expect(stop.code).toBe(0);
|
||||||
|
expect(stop.stdout).toContain('Server stopped');
|
||||||
|
expect(fs.existsSync(stateFile)).toBe(false);
|
||||||
|
expect(pidGone).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await cleanupCliState(stateFile);
|
||||||
|
}
|
||||||
|
}, 20000);
|
||||||
|
|
||||||
|
test('restart exits cleanly and replaces the daemon pid', async () => {
|
||||||
|
const stateFile = `/tmp/browse-restart-state-${Date.now()}.json`;
|
||||||
|
const port = reservePort();
|
||||||
|
const env = {
|
||||||
|
BROWSE_STATE_FILE: stateFile,
|
||||||
|
BROWSE_PORT: String(port),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const started = await runCliCommand(['status'], env);
|
||||||
|
const beforeState = readJson<{ pid: number }>(stateFile);
|
||||||
|
expect(started.code).toBe(0);
|
||||||
|
expect(beforeState?.pid).toBeTruthy();
|
||||||
|
|
||||||
|
const restart = await runCliCommand(['restart'], env);
|
||||||
|
const afterState = await waitFor(() => {
|
||||||
|
const state = readJson<{ pid: number }>(stateFile);
|
||||||
|
return state && state.pid !== beforeState!.pid ? state : null;
|
||||||
|
}, 8000);
|
||||||
|
|
||||||
|
expect(restart.code).toBe(0);
|
||||||
|
expect(restart.stdout).toContain('Restarting');
|
||||||
|
expect(afterState?.pid).toBeTruthy();
|
||||||
|
expect(afterState?.pid).not.toBe(beforeState?.pid);
|
||||||
|
} finally {
|
||||||
|
await cleanupCliState(stateFile);
|
||||||
|
}
|
||||||
|
}, 25000);
|
||||||
|
|
||||||
|
test('parallel status calls start the daemon once', async () => {
|
||||||
|
const root = fs.mkdtempSync('/tmp/gstack-browse-wrapper-');
|
||||||
|
const stateFile = path.join(root, 'browse-state.json');
|
||||||
|
const startLog = path.join(root, 'server-starts.log');
|
||||||
|
const wrapperPath = path.join(root, 'server-wrapper.ts');
|
||||||
|
const realServerPath = path.resolve(__dirname, '../src/server.ts');
|
||||||
|
const port = reservePort();
|
||||||
|
|
||||||
|
fs.writeFileSync(wrapperPath, `
|
||||||
|
import * as fs from 'fs';
|
||||||
|
fs.appendFileSync(${JSON.stringify(startLog)}, 'start\\n');
|
||||||
|
await import(${JSON.stringify(realServerPath)});
|
||||||
|
`);
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
BROWSE_STATE_FILE: stateFile,
|
||||||
|
BROWSE_PORT: String(port),
|
||||||
|
BROWSE_SERVER_SCRIPT: wrapperPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [first, second] = await Promise.all([
|
||||||
|
runCliCommand(['status'], env),
|
||||||
|
runCliCommand(['status'], env),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const state = readJson<{ pid: number }>(stateFile);
|
||||||
|
const starts = fs.readFileSync(startLog, 'utf-8').trim().split('\n').filter(Boolean);
|
||||||
|
|
||||||
|
expect(first.code).toBe(0);
|
||||||
|
expect(second.code).toBe(0);
|
||||||
|
expect(state?.pid).toBeTruthy();
|
||||||
|
expect(starts).toHaveLength(1);
|
||||||
|
} finally {
|
||||||
|
await cleanupCliState(stateFile);
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}, 25000);
|
||||||
|
|
||||||
|
test('useragent applies after restart', async () => {
|
||||||
|
const stateFile = `/tmp/browse-useragent-state-${Date.now()}.json`;
|
||||||
|
const port = reservePort();
|
||||||
|
const env = {
|
||||||
|
BROWSE_STATE_FILE: stateFile,
|
||||||
|
BROWSE_PORT: String(port),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect((await runCliCommand(['status'], env)).code).toBe(0);
|
||||||
|
expect((await runCliCommand(['useragent', 'MyAgent/1.0'], env)).code).toBe(0);
|
||||||
|
expect((await runCliCommand(['restart'], env)).code).toBe(0);
|
||||||
|
|
||||||
|
const js = await runCliCommand(['js', 'navigator.userAgent'], env);
|
||||||
|
expect(js.code).toBe(0);
|
||||||
|
expect(js.stdout).toContain('MyAgent/1.0');
|
||||||
|
} finally {
|
||||||
|
await cleanupCliState(stateFile);
|
||||||
|
}
|
||||||
|
}, 25000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Buffer bounds ──────────────────────────────────────────────
|
// ─── Buffer bounds ──────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,14 @@ let bm: BrowserManager;
|
||||||
let baseUrl: string;
|
let baseUrl: string;
|
||||||
const shutdown = async () => {};
|
const shutdown = async () => {};
|
||||||
|
|
||||||
|
function extractRef(snapshot: string, predicate: (line: string) => boolean): string {
|
||||||
|
const line = snapshot.split('\n').find(predicate);
|
||||||
|
expect(line).toBeDefined();
|
||||||
|
const refMatch = line!.match(/@(e\d+)/);
|
||||||
|
expect(refMatch).toBeDefined();
|
||||||
|
return `@${refMatch![1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
testServer = startTestServer(0);
|
testServer = startTestServer(0);
|
||||||
baseUrl = testServer.url;
|
baseUrl = testServer.url;
|
||||||
|
|
@ -97,6 +105,14 @@ describe('Snapshot', () => {
|
||||||
expect(snap1).toContain('@e1');
|
expect(snap1).toContain('@e1');
|
||||||
expect(snap2).toContain('@e1');
|
expect(snap2).toContain('@e1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('snapshot preserves accessible names with escaped quotes', async () => {
|
||||||
|
const page = bm.getPage();
|
||||||
|
await page.setContent(`<!doctype html><body><button aria-label='Say "Hello"'>X</button></body>`);
|
||||||
|
const result = await handleMetaCommand('snapshot', ['-i'], bm, shutdown);
|
||||||
|
expect(result).toContain('[button]');
|
||||||
|
expect(result).toContain('Say "Hello"');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Ref-Based Interaction ──────────────────────────────────────
|
// ─── Ref-Based Interaction ──────────────────────────────────────
|
||||||
|
|
@ -175,6 +191,74 @@ describe('Ref resolution', () => {
|
||||||
// ─── Ref Invalidation ───────────────────────────────────────────
|
// ─── Ref Invalidation ───────────────────────────────────────────
|
||||||
|
|
||||||
describe('Ref invalidation', () => {
|
describe('Ref invalidation', () => {
|
||||||
|
test('ref from tab 1 cannot be used from blank tab 2', async () => {
|
||||||
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
||||||
|
const snap = await handleMetaCommand('snapshot', ['-i'], bm, shutdown);
|
||||||
|
const ref = extractRef(snap, (line) => line.includes('[link]') && line.includes('"Page 1"'));
|
||||||
|
|
||||||
|
await handleMetaCommand('newtab', [], bm, shutdown);
|
||||||
|
|
||||||
|
await expect(handleWriteCommand('click', [ref], bm)).rejects.toThrow('snapshot');
|
||||||
|
|
||||||
|
const tabs = await bm.getTabListWithTitles();
|
||||||
|
const tabOne = tabs.find((tab) => tab.id === 1);
|
||||||
|
const tabTwo = tabs.find((tab) => tab.active);
|
||||||
|
expect(tabOne?.url).toContain('/basic.html');
|
||||||
|
expect(tabTwo?.url).toBe('about:blank');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tab 1 refs still work after tab 2 navigates when switched back', async () => {
|
||||||
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
||||||
|
const snap = await handleMetaCommand('snapshot', ['-i'], bm, shutdown);
|
||||||
|
const ref = extractRef(snap, (line) => line.includes('[link]') && line.includes('"Page 1"'));
|
||||||
|
|
||||||
|
const newTabResult = await handleMetaCommand('newtab', [baseUrl + '/forms.html'], bm, shutdown);
|
||||||
|
const tabIdMatch = newTabResult.match(/Opened tab (\d+)/);
|
||||||
|
expect(tabIdMatch).toBeDefined();
|
||||||
|
|
||||||
|
await handleMetaCommand('tab', ['1'], bm, shutdown);
|
||||||
|
|
||||||
|
const result = await handleWriteCommand('click', [ref], bm);
|
||||||
|
expect(result).toContain('Clicked');
|
||||||
|
expect(bm.getCurrentUrl()).toContain('/page1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reordering same-name elements does not retarget an existing ref', async () => {
|
||||||
|
const page = bm.getPage();
|
||||||
|
await page.setContent(`<!doctype html><body>
|
||||||
|
<button id="a" onclick="window.clicked='a'">Delete</button>
|
||||||
|
<button id="b" onclick="window.clicked='b'">Delete</button>
|
||||||
|
</body>`);
|
||||||
|
const snap = await handleMetaCommand('snapshot', ['-i'], bm, shutdown);
|
||||||
|
const ref = extractRef(snap, (line) => line.includes('[button]') && line.includes('"Delete"'));
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.id = 'new';
|
||||||
|
btn.textContent = 'Delete';
|
||||||
|
btn.onclick = () => { (window as any).clicked = 'new'; };
|
||||||
|
document.body.prepend(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleWriteCommand('click', [ref], bm);
|
||||||
|
const clicked = await handleReadCommand('js', ['window.clicked'], bm);
|
||||||
|
expect(clicked).toBe('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing a referenced element returns a stale ref error', async () => {
|
||||||
|
const page = bm.getPage();
|
||||||
|
await page.setContent(`<!doctype html><body>
|
||||||
|
<button id="a" onclick="window.clicked='a'">Delete</button>
|
||||||
|
<button id="b" onclick="window.clicked='b'">Delete</button>
|
||||||
|
</body>`);
|
||||||
|
const snap = await handleMetaCommand('snapshot', ['-i'], bm, shutdown);
|
||||||
|
const ref = extractRef(snap, (line) => line.includes('[button]') && line.includes('"Delete"'));
|
||||||
|
|
||||||
|
await page.evaluate(() => document.getElementById('a')?.remove());
|
||||||
|
|
||||||
|
await expect(handleWriteCommand('click', [ref], bm)).rejects.toThrow('snapshot');
|
||||||
|
});
|
||||||
|
|
||||||
test('stale ref after goto returns clear error', async () => {
|
test('stale ref after goto returns clear error', async () => {
|
||||||
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
|
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
|
||||||
await handleMetaCommand('snapshot', ['-i'], bm, shutdown);
|
await handleMetaCommand('snapshot', ['-i'], bm, shutdown);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue