mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-07 14:06:42 +02:00
Merge remote-tracking branch 'origin/main' into garrytan/recover-voice-fix
This commit is contained in:
@@ -480,6 +480,9 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `chain` | Run commands from JSON stdin. Format: [["cmd","arg1",...],...] |
|
||||
| `frame <sel|@ref|--name n|--url pattern|main>` | Switch to iframe context (or main to return) |
|
||||
| `inbox [--clear]` | List messages from sidebar scout inbox |
|
||||
| `watch [stop]` | Passive observation — periodic snapshots while user browses |
|
||||
|
||||
### Tabs
|
||||
| Command | Description |
|
||||
@@ -492,8 +495,12 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
|
||||
### Server
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `connect` | Launch headed Chromium with Chrome extension |
|
||||
| `disconnect` | Disconnect headed browser, return to headless mode |
|
||||
| `focus [@ref]` | Bring headed browser window to foreground (macOS) |
|
||||
| `handoff [message]` | Open visible Chrome at current page for user takeover |
|
||||
| `restart` | Restart server |
|
||||
| `resume` | Re-snapshot after user takeover, return control to AI |
|
||||
| `state save|load <name>` | Save/load browser state (cookies + URLs) |
|
||||
| `status` | Health check |
|
||||
| `stop` | Shutdown server |
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Activity streaming — real-time feed of browse commands for the Chrome extension Side Panel
|
||||
*
|
||||
* Architecture:
|
||||
* handleCommand() ──► emitActivity(command_start)
|
||||
* ──► emitActivity(command_end)
|
||||
* wirePageEvents() ──► emitActivity(navigation)
|
||||
*
|
||||
* GET /activity/stream?after=ID ──► SSE via ReadableStream
|
||||
* GET /activity/history?limit=N ──► REST fallback
|
||||
*
|
||||
* Privacy: filterArgs() redacts passwords, auth tokens, and sensitive query params.
|
||||
* Backpressure: subscribers notified via queueMicrotask (never blocks command path).
|
||||
* Gap detection: client sends ?after=ID, server detects if ring buffer overflowed.
|
||||
*/
|
||||
|
||||
import { CircularBuffer } from './buffers';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────
|
||||
|
||||
export interface ActivityEntry {
|
||||
id: number;
|
||||
timestamp: number;
|
||||
type: 'command_start' | 'command_end' | 'navigation' | 'error';
|
||||
command?: string;
|
||||
args?: string[];
|
||||
url?: string;
|
||||
duration?: number;
|
||||
status?: 'ok' | 'error';
|
||||
error?: string;
|
||||
result?: string;
|
||||
tabs?: number;
|
||||
mode?: string;
|
||||
}
|
||||
|
||||
// ─── Buffer & Subscribers ───────────────────────────────────────
|
||||
|
||||
const BUFFER_CAPACITY = 1000;
|
||||
const activityBuffer = new CircularBuffer<ActivityEntry>(BUFFER_CAPACITY);
|
||||
let nextId = 1;
|
||||
|
||||
type ActivitySubscriber = (entry: ActivityEntry) => void;
|
||||
const subscribers = new Set<ActivitySubscriber>();
|
||||
|
||||
// ─── Privacy Filtering ─────────────────────────────────────────
|
||||
|
||||
const SENSITIVE_COMMANDS = new Set(['fill', 'type', 'cookie', 'header']);
|
||||
const SENSITIVE_PARAM_PATTERN = /\b(password|token|secret|key|auth|bearer|api[_-]?key)\b/i;
|
||||
|
||||
/**
|
||||
* Redact sensitive data from command args before streaming.
|
||||
*/
|
||||
export function filterArgs(command: string, args: string[]): string[] {
|
||||
if (!args || args.length === 0) return args;
|
||||
|
||||
// fill: redact the value (last arg) for password-type fields
|
||||
if (command === 'fill' && args.length >= 2) {
|
||||
const selector = args[0];
|
||||
// If the selector suggests a password field, redact the value
|
||||
if (/password|passwd|secret|token/i.test(selector)) {
|
||||
return [selector, '[REDACTED]'];
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
// header: redact Authorization and other sensitive headers
|
||||
if (command === 'header' && args.length >= 1) {
|
||||
const headerLine = args[0];
|
||||
if (/^(authorization|x-api-key|cookie|set-cookie)/i.test(headerLine)) {
|
||||
const colonIdx = headerLine.indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
return [headerLine.substring(0, colonIdx + 1) + '[REDACTED]'];
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
// cookie: redact cookie values
|
||||
if (command === 'cookie' && args.length >= 1) {
|
||||
const cookieStr = args[0];
|
||||
const eqIdx = cookieStr.indexOf('=');
|
||||
if (eqIdx > 0) {
|
||||
return [cookieStr.substring(0, eqIdx + 1) + '[REDACTED]'];
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
// type: always redact (could be a password field)
|
||||
if (command === 'type') {
|
||||
return ['[REDACTED]'];
|
||||
}
|
||||
|
||||
// URL args: redact sensitive query params
|
||||
return args.map(arg => {
|
||||
if (arg.startsWith('http://') || arg.startsWith('https://')) {
|
||||
try {
|
||||
const url = new URL(arg);
|
||||
let redacted = false;
|
||||
for (const key of url.searchParams.keys()) {
|
||||
if (SENSITIVE_PARAM_PATTERN.test(key)) {
|
||||
url.searchParams.set(key, '[REDACTED]');
|
||||
redacted = true;
|
||||
}
|
||||
}
|
||||
return redacted ? url.toString() : arg;
|
||||
} catch {
|
||||
return arg;
|
||||
}
|
||||
}
|
||||
return arg;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate result text for streaming (max 200 chars).
|
||||
*/
|
||||
function truncateResult(result: string | undefined): string | undefined {
|
||||
if (!result) return undefined;
|
||||
if (result.length <= 200) return result;
|
||||
return result.substring(0, 200) + '...';
|
||||
}
|
||||
|
||||
// ─── Public API ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Emit an activity event. Backpressure-safe: subscribers notified asynchronously.
|
||||
*/
|
||||
export function emitActivity(entry: Omit<ActivityEntry, 'id' | 'timestamp'>): ActivityEntry {
|
||||
const full: ActivityEntry = {
|
||||
...entry,
|
||||
id: nextId++,
|
||||
timestamp: Date.now(),
|
||||
args: entry.args ? filterArgs(entry.command || '', entry.args) : undefined,
|
||||
result: truncateResult(entry.result),
|
||||
};
|
||||
activityBuffer.push(full);
|
||||
|
||||
// Notify subscribers asynchronously — never block the command path
|
||||
for (const notify of subscribers) {
|
||||
queueMicrotask(() => {
|
||||
try { notify(full); } catch { /* subscriber error — don't crash */ }
|
||||
});
|
||||
}
|
||||
|
||||
return full;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to live activity events. Returns unsubscribe function.
|
||||
*/
|
||||
export function subscribe(fn: ActivitySubscriber): () => void {
|
||||
subscribers.add(fn);
|
||||
return () => subscribers.delete(fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent activity entries after the given cursor ID.
|
||||
* Returns entries and gap info if the buffer has overflowed.
|
||||
*/
|
||||
export function getActivityAfter(afterId: number): {
|
||||
entries: ActivityEntry[];
|
||||
gap: boolean;
|
||||
gapFrom?: number;
|
||||
availableFrom?: number;
|
||||
totalAdded: number;
|
||||
} {
|
||||
const total = activityBuffer.totalAdded;
|
||||
const allEntries = activityBuffer.toArray();
|
||||
|
||||
if (afterId === 0) {
|
||||
return { entries: allEntries, gap: false, totalAdded: total };
|
||||
}
|
||||
|
||||
// Check for gap: if afterId is too old and has been evicted
|
||||
const oldestId = allEntries.length > 0 ? allEntries[0].id : nextId;
|
||||
if (afterId < oldestId) {
|
||||
return {
|
||||
entries: allEntries,
|
||||
gap: true,
|
||||
gapFrom: afterId + 1,
|
||||
availableFrom: oldestId,
|
||||
totalAdded: total,
|
||||
};
|
||||
}
|
||||
|
||||
// Filter to entries after the cursor
|
||||
const filtered = allEntries.filter(e => e.id > afterId);
|
||||
return { entries: filtered, gap: false, totalAdded: total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the N most recent activity entries.
|
||||
*/
|
||||
export function getActivityHistory(limit: number = 50): {
|
||||
entries: ActivityEntry[];
|
||||
totalAdded: number;
|
||||
} {
|
||||
const allEntries = activityBuffer.toArray();
|
||||
const sliced = limit < allEntries.length ? allEntries.slice(-limit) : allEntries;
|
||||
return { entries: sliced, totalAdded: activityBuffer.totalAdded };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscriber count (for debugging/health).
|
||||
*/
|
||||
export function getSubscriberCount(): number {
|
||||
return subscribers.size;
|
||||
}
|
||||
+302
-38
@@ -61,6 +61,88 @@ export class BrowserManager {
|
||||
private isHeaded: boolean = false;
|
||||
private consecutiveFailures: number = 0;
|
||||
|
||||
// ─── Watch Mode ─────────────────────────────────────────
|
||||
private watching = false;
|
||||
public watchInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private watchSnapshots: string[] = [];
|
||||
private watchStartTime: number = 0;
|
||||
|
||||
// ─── Headed State ────────────────────────────────────────
|
||||
private connectionMode: 'launched' | 'headed' = 'launched';
|
||||
private intentionalDisconnect = false;
|
||||
|
||||
getConnectionMode(): 'launched' | 'headed' { return this.connectionMode; }
|
||||
|
||||
// ─── Watch Mode Methods ─────────────────────────────────
|
||||
isWatching(): boolean { return this.watching; }
|
||||
|
||||
startWatch(): void {
|
||||
this.watching = true;
|
||||
this.watchSnapshots = [];
|
||||
this.watchStartTime = Date.now();
|
||||
}
|
||||
|
||||
stopWatch(): { snapshots: string[]; duration: number } {
|
||||
this.watching = false;
|
||||
if (this.watchInterval) {
|
||||
clearInterval(this.watchInterval);
|
||||
this.watchInterval = null;
|
||||
}
|
||||
const snapshots = this.watchSnapshots;
|
||||
const duration = Date.now() - this.watchStartTime;
|
||||
this.watchSnapshots = [];
|
||||
this.watchStartTime = 0;
|
||||
return { snapshots, duration };
|
||||
}
|
||||
|
||||
addWatchSnapshot(snapshot: string): void {
|
||||
this.watchSnapshots.push(snapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the gstack Chrome extension directory.
|
||||
* Checks: repo root /extension, global install, dev install.
|
||||
*/
|
||||
private findExtensionPath(): string | null {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const candidates = [
|
||||
// Relative to this source file (dev mode: browse/src/ -> ../../extension)
|
||||
path.resolve(__dirname, '..', '..', 'extension'),
|
||||
// Global gstack install
|
||||
path.join(process.env.HOME || '', '.claude', 'skills', 'gstack', 'extension'),
|
||||
// Git repo root (detected via BROWSE_STATE_FILE location)
|
||||
(() => {
|
||||
const stateFile = process.env.BROWSE_STATE_FILE || '';
|
||||
if (stateFile) {
|
||||
const repoRoot = path.resolve(path.dirname(stateFile), '..');
|
||||
return path.join(repoRoot, '.claude', 'skills', 'gstack', 'extension');
|
||||
}
|
||||
return '';
|
||||
})(),
|
||||
].filter(Boolean);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
if (fs.existsSync(path.join(candidate, 'manifest.json'))) {
|
||||
return candidate;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ref map for external consumers (e.g., /refs endpoint).
|
||||
*/
|
||||
getRefMap(): Array<{ ref: string; role: string; name: string }> {
|
||||
const refs: Array<{ ref: string; role: string; name: string }> = [];
|
||||
for (const [ref, entry] of this.refMap) {
|
||||
refs.push({ ref, role: entry.role, name: entry.name });
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
async launch() {
|
||||
// ─── Extension Support ────────────────────────────────────
|
||||
// BROWSE_EXTENSIONS_DIR points to an unpacked Chrome extension directory.
|
||||
@@ -119,15 +201,140 @@ export class BrowserManager {
|
||||
await this.newTab();
|
||||
}
|
||||
|
||||
async close() {
|
||||
// ─── Headed Mode ─────────────────────────────────────────────
|
||||
/**
|
||||
* Launch Playwright's bundled Chromium in headed mode with the gstack
|
||||
* Chrome extension auto-loaded. Uses launchPersistentContext() which
|
||||
* is required for extension loading (launch() + newContext() can't
|
||||
* load extensions).
|
||||
*
|
||||
* The browser launches headed with a visible window — the user sees
|
||||
* every action Claude takes in real time.
|
||||
*/
|
||||
async launchHeaded(): Promise<void> {
|
||||
// Clear old state before repopulating
|
||||
this.pages.clear();
|
||||
this.refMap.clear();
|
||||
this.nextTabId = 1;
|
||||
|
||||
// Find the gstack extension directory for auto-loading
|
||||
const extensionPath = this.findExtensionPath();
|
||||
const launchArgs = ['--hide-crash-restore-bubble'];
|
||||
if (extensionPath) {
|
||||
launchArgs.push(`--disable-extensions-except=${extensionPath}`);
|
||||
launchArgs.push(`--load-extension=${extensionPath}`);
|
||||
}
|
||||
|
||||
// Launch headed Chromium via Playwright's persistent context.
|
||||
// Extensions REQUIRE launchPersistentContext (not launch + newContext).
|
||||
// Real Chrome (executablePath/channel) silently blocks --load-extension,
|
||||
// so we use Playwright's bundled Chromium which reliably loads extensions.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const userDataDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
|
||||
fs.mkdirSync(userDataDir, { recursive: true });
|
||||
|
||||
this.context = await chromium.launchPersistentContext(userDataDir, {
|
||||
headless: false,
|
||||
args: launchArgs,
|
||||
viewport: null, // Use browser's default viewport (real window size)
|
||||
// Playwright adds flags that block extension loading
|
||||
ignoreDefaultArgs: [
|
||||
'--disable-extensions',
|
||||
'--disable-component-extensions-with-background-pages',
|
||||
],
|
||||
});
|
||||
this.browser = this.context.browser();
|
||||
this.connectionMode = 'headed';
|
||||
this.intentionalDisconnect = false;
|
||||
|
||||
// Inject visual indicator — subtle top-edge amber gradient
|
||||
// Extension's content script handles the floating pill
|
||||
const indicatorScript = () => {
|
||||
const injectIndicator = () => {
|
||||
if (document.getElementById('gstack-ctrl')) return;
|
||||
|
||||
const topLine = document.createElement('div');
|
||||
topLine.id = 'gstack-ctrl';
|
||||
topLine.style.cssText = `
|
||||
position: fixed; top: 0; left: 0; right: 0; height: 2px;
|
||||
background: linear-gradient(90deg, #F59E0B, #FBBF24, #F59E0B);
|
||||
background-size: 200% 100%;
|
||||
animation: gstack-shimmer 3s linear infinite;
|
||||
pointer-events: none; z-index: 2147483647;
|
||||
opacity: 0.8;
|
||||
`;
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes gstack-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#gstack-ctrl { animation: none !important; }
|
||||
}
|
||||
`;
|
||||
|
||||
document.documentElement.appendChild(style);
|
||||
document.documentElement.appendChild(topLine);
|
||||
};
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', injectIndicator);
|
||||
} else {
|
||||
injectIndicator();
|
||||
}
|
||||
};
|
||||
await this.context.addInitScript(indicatorScript);
|
||||
|
||||
// Persistent context opens a default page — adopt it instead of creating a new one
|
||||
const existingPages = this.context.pages();
|
||||
if (existingPages.length > 0) {
|
||||
const page = existingPages[0];
|
||||
const id = this.nextTabId++;
|
||||
this.pages.set(id, page);
|
||||
this.activeTabId = id;
|
||||
this.wirePageEvents(page);
|
||||
// Inject indicator on restored page (addInitScript only fires on new navigations)
|
||||
try { await page.evaluate(indicatorScript); } catch {}
|
||||
} else {
|
||||
await this.newTab();
|
||||
}
|
||||
|
||||
// Browser disconnect handler — exit code 2 distinguishes from crashes (1)
|
||||
if (this.browser) {
|
||||
// Remove disconnect handler to avoid exit during intentional close
|
||||
this.browser.removeAllListeners('disconnected');
|
||||
// Timeout: headed browser.close() can hang on macOS
|
||||
await Promise.race([
|
||||
this.browser.close(),
|
||||
new Promise(resolve => setTimeout(resolve, 5000)),
|
||||
]).catch(() => {});
|
||||
this.browser.on('disconnected', () => {
|
||||
if (this.intentionalDisconnect) return;
|
||||
console.error('[browse] Real browser disconnected (user closed or crashed).');
|
||||
console.error('[browse] Run `$B connect` to reconnect.');
|
||||
process.exit(2);
|
||||
});
|
||||
}
|
||||
|
||||
// Headed mode defaults
|
||||
this.dialogAutoAccept = false; // Don't dismiss user's real dialogs
|
||||
this.isHeaded = true;
|
||||
this.consecutiveFailures = 0;
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (this.browser || (this.connectionMode === 'headed' && this.context)) {
|
||||
if (this.connectionMode === 'headed') {
|
||||
// Headed/persistent context mode: close the context (which closes the browser)
|
||||
this.intentionalDisconnect = true;
|
||||
if (this.browser) this.browser.removeAllListeners('disconnected');
|
||||
await Promise.race([
|
||||
this.context ? this.context.close() : Promise.resolve(),
|
||||
new Promise(resolve => setTimeout(resolve, 5000)),
|
||||
]).catch(() => {});
|
||||
} else {
|
||||
// Launched mode: close the browser we spawned
|
||||
this.browser.removeAllListeners('disconnected');
|
||||
await Promise.race([
|
||||
this.browser.close(),
|
||||
new Promise(resolve => setTimeout(resolve, 5000)),
|
||||
]).catch(() => {});
|
||||
}
|
||||
this.browser = null;
|
||||
}
|
||||
}
|
||||
@@ -195,6 +402,7 @@ export class BrowserManager {
|
||||
switchTab(id: number): void {
|
||||
if (!this.pages.has(id)) throw new Error(`Tab ${id} not found`);
|
||||
this.activeTabId = id;
|
||||
this.activeFrame = null; // Frame context is per-tab
|
||||
}
|
||||
|
||||
getTabCount(): number {
|
||||
@@ -324,6 +532,42 @@ export class BrowserManager {
|
||||
return this.customUserAgent;
|
||||
}
|
||||
|
||||
// ─── Lifecycle helpers ───────────────────────────────
|
||||
/**
|
||||
* Close all open pages and clear the pages map.
|
||||
* Used by state load to replace the current session.
|
||||
*/
|
||||
async closeAllPages(): Promise<void> {
|
||||
for (const page of this.pages.values()) {
|
||||
await page.close().catch(() => {});
|
||||
}
|
||||
this.pages.clear();
|
||||
this.clearRefs();
|
||||
}
|
||||
|
||||
// ─── Frame context ─────────────────────────────────
|
||||
private activeFrame: import('playwright').Frame | null = null;
|
||||
|
||||
setFrame(frame: import('playwright').Frame | null): void {
|
||||
this.activeFrame = frame;
|
||||
}
|
||||
|
||||
getFrame(): import('playwright').Frame | null {
|
||||
return this.activeFrame;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the active frame if set, otherwise the current page.
|
||||
* Use this for operations that work on both Page and Frame (locator, evaluate, etc.).
|
||||
*/
|
||||
getActiveFrameOrPage(): import('playwright').Page | import('playwright').Frame {
|
||||
// Auto-recover from detached frames (iframe removed/navigated)
|
||||
if (this.activeFrame?.isDetached()) {
|
||||
this.activeFrame = null;
|
||||
}
|
||||
return this.activeFrame ?? this.getPage();
|
||||
}
|
||||
|
||||
// ─── State Save/Restore (shared by recreateContext + handoff) ─
|
||||
/**
|
||||
* Capture browser state: cookies, localStorage, sessionStorage, URLs, active tab.
|
||||
@@ -416,6 +660,9 @@ export class BrowserManager {
|
||||
* Falls back to a clean slate on any failure.
|
||||
*/
|
||||
async recreateContext(): Promise<string | null> {
|
||||
if (this.connectionMode === 'headed') {
|
||||
throw new Error('Cannot recreate context in headed mode. Use disconnect first.');
|
||||
}
|
||||
if (!this.browser || !this.context) {
|
||||
throw new Error('Browser not launched');
|
||||
}
|
||||
@@ -482,7 +729,7 @@ export class BrowserManager {
|
||||
* If step 2 fails → return error, headless browser untouched
|
||||
*/
|
||||
async handoff(message: string): Promise<string> {
|
||||
if (this.isHeaded) {
|
||||
if (this.connectionMode === 'headed' || this.isHeaded) {
|
||||
return `HANDOFF: Already in headed mode at ${this.getCurrentUrl()}`;
|
||||
}
|
||||
if (!this.browser || !this.context) {
|
||||
@@ -493,53 +740,68 @@ export class BrowserManager {
|
||||
const state = await this.saveState();
|
||||
const currentUrl = this.getCurrentUrl();
|
||||
|
||||
// 2. Launch new headed browser (try-catch — if this fails, headless stays running)
|
||||
let newBrowser: Browser;
|
||||
// 2. Launch new headed browser with extension (same as launchHeaded)
|
||||
// Uses launchPersistentContext so the extension auto-loads.
|
||||
let newContext: BrowserContext;
|
||||
try {
|
||||
newBrowser = await chromium.launch({
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const extensionPath = this.findExtensionPath();
|
||||
const launchArgs = ['--hide-crash-restore-bubble'];
|
||||
if (extensionPath) {
|
||||
launchArgs.push(`--disable-extensions-except=${extensionPath}`);
|
||||
launchArgs.push(`--load-extension=${extensionPath}`);
|
||||
console.log(`[browse] Handoff: loading extension from ${extensionPath}`);
|
||||
} else {
|
||||
console.log('[browse] Handoff: extension not found — headed mode without side panel');
|
||||
}
|
||||
|
||||
const userDataDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
|
||||
fs.mkdirSync(userDataDir, { recursive: true });
|
||||
|
||||
newContext = await chromium.launchPersistentContext(userDataDir, {
|
||||
headless: false,
|
||||
args: launchArgs,
|
||||
viewport: null,
|
||||
ignoreDefaultArgs: [
|
||||
'--disable-extensions',
|
||||
'--disable-component-extensions-with-background-pages',
|
||||
],
|
||||
timeout: 15000,
|
||||
chromiumSandbox: process.platform !== 'win32',
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return `ERROR: Cannot open headed browser — ${msg}. Headless browser still running.`;
|
||||
}
|
||||
|
||||
// 3. Create context and restore state into new headed browser
|
||||
// 3. Restore state into new headed browser
|
||||
try {
|
||||
const contextOptions: BrowserContextOptions = {
|
||||
viewport: { width: 1280, height: 720 },
|
||||
};
|
||||
if (this.customUserAgent) {
|
||||
contextOptions.userAgent = this.customUserAgent;
|
||||
}
|
||||
const newContext = await newBrowser.newContext(contextOptions);
|
||||
// Swap to new browser/context before restoreState (it uses this.context)
|
||||
const oldBrowser = this.browser;
|
||||
|
||||
this.context = newContext;
|
||||
this.browser = newContext.browser();
|
||||
this.pages.clear();
|
||||
this.connectionMode = 'headed';
|
||||
|
||||
if (Object.keys(this.extraHeaders).length > 0) {
|
||||
await newContext.setExtraHTTPHeaders(this.extraHeaders);
|
||||
}
|
||||
|
||||
// Swap to new browser/context before restoreState (it uses this.context)
|
||||
const oldBrowser = this.browser;
|
||||
const oldContext = this.context;
|
||||
|
||||
this.browser = newBrowser;
|
||||
this.context = newContext;
|
||||
this.pages.clear();
|
||||
|
||||
// Register crash handler on new browser
|
||||
this.browser.on('disconnected', () => {
|
||||
console.error('[browse] FATAL: Chromium process crashed or was killed. Server exiting.');
|
||||
console.error('[browse] Console/network logs flushed to .gstack/browse-*.log');
|
||||
process.exit(1);
|
||||
});
|
||||
if (this.browser) {
|
||||
this.browser.on('disconnected', () => {
|
||||
if (this.intentionalDisconnect) return;
|
||||
console.error('[browse] FATAL: Chromium process crashed or was killed. Server exiting.');
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
await this.restoreState(state);
|
||||
this.isHeaded = true;
|
||||
this.dialogAutoAccept = false; // User controls dialogs in headed mode
|
||||
|
||||
// 4. Close old headless browser (fire-and-forget — close() can hang
|
||||
// when another Playwright instance is active, so we don't await it)
|
||||
// 4. Close old headless browser (fire-and-forget)
|
||||
oldBrowser.removeAllListeners('disconnected');
|
||||
oldBrowser.close().catch(() => {});
|
||||
|
||||
@@ -549,8 +811,8 @@ export class BrowserManager {
|
||||
`STATUS: Waiting for user. Run 'resume' when done.`,
|
||||
].join('\n');
|
||||
} catch (err: unknown) {
|
||||
// Restore failed — close the new browser, keep old one
|
||||
await newBrowser.close().catch(() => {});
|
||||
// Restore failed — close the new context, keep old state
|
||||
await newContext.close().catch(() => {});
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return `ERROR: Handoff failed during state restore — ${msg}. Headless browser still running.`;
|
||||
}
|
||||
@@ -564,6 +826,7 @@ export class BrowserManager {
|
||||
resume(): void {
|
||||
this.clearRefs();
|
||||
this.resetFailures();
|
||||
this.activeFrame = null;
|
||||
}
|
||||
|
||||
getIsHeaded(): boolean {
|
||||
@@ -593,6 +856,7 @@ export class BrowserManager {
|
||||
page.on('framenavigated', (frame) => {
|
||||
if (frame === page.mainFrame()) {
|
||||
this.clearRefs();
|
||||
this.activeFrame = null; // Navigation invalidates frame context
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+150
-2
@@ -90,6 +90,7 @@ interface ServerState {
|
||||
startedAt: string;
|
||||
serverPath: string;
|
||||
binaryVersion?: string;
|
||||
mode?: 'launched' | 'headed';
|
||||
}
|
||||
|
||||
// ─── State File ────────────────────────────────────────────────
|
||||
@@ -217,7 +218,7 @@ function cleanupLegacyState(): void {
|
||||
}
|
||||
|
||||
// ─── Server Lifecycle ──────────────────────────────────────────
|
||||
async function startServer(): Promise<ServerState> {
|
||||
async function startServer(extraEnv?: Record<string, string>): Promise<ServerState> {
|
||||
ensureStateDir(config);
|
||||
|
||||
// Clean up stale state file and error log
|
||||
@@ -241,7 +242,7 @@ async function startServer(): Promise<ServerState> {
|
||||
// macOS/Linux: Bun.spawn + unref works correctly
|
||||
proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile },
|
||||
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, ...extraEnv },
|
||||
});
|
||||
proc.unref();
|
||||
}
|
||||
@@ -328,6 +329,15 @@ async function ensureServer(): Promise<ServerState> {
|
||||
return state;
|
||||
}
|
||||
|
||||
// Guard: never silently replace a headed server with a headless one.
|
||||
// Headed mode means a user-visible Chrome window is (or was) controlled.
|
||||
// Silently replacing it would be confusing — tell the user to reconnect.
|
||||
if (state && state.mode === 'headed' && isProcessAlive(state.pid)) {
|
||||
console.error(`[browse] Headed server running (PID ${state.pid}) but not responding.`);
|
||||
console.error(`[browse] Run '$B connect' to restart.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Ensure state directory exists before lock acquisition (lock file lives there)
|
||||
ensureStateDir(config);
|
||||
|
||||
@@ -471,6 +481,144 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
||||
const command = args[0];
|
||||
const commandArgs = args.slice(1);
|
||||
|
||||
// ─── Headed Connect (pre-server command) ────────────────────
|
||||
// connect must be handled BEFORE ensureServer() because it needs
|
||||
// to restart the server in headed mode with the Chrome extension.
|
||||
if (command === 'connect') {
|
||||
// Check if already in headed mode and healthy
|
||||
const existingState = readState();
|
||||
if (existingState && existingState.mode === 'headed' && isProcessAlive(existingState.pid)) {
|
||||
try {
|
||||
const resp = await fetch(`http://127.0.0.1:${existingState.port}/health`, {
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
if (resp.ok) {
|
||||
console.log('Already connected in headed mode.');
|
||||
process.exit(0);
|
||||
}
|
||||
} catch {
|
||||
// Headed server alive but not responding — kill and restart
|
||||
}
|
||||
}
|
||||
|
||||
// Kill ANY existing server (SIGTERM → wait 2s → SIGKILL)
|
||||
if (existingState && isProcessAlive(existingState.pid)) {
|
||||
try { process.kill(existingState.pid, 'SIGTERM'); } catch {}
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
if (isProcessAlive(existingState.pid)) {
|
||||
try { process.kill(existingState.pid, 'SIGKILL'); } catch {}
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up Chromium profile locks (can persist after crashes)
|
||||
const profileDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
|
||||
for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
|
||||
try { fs.unlinkSync(path.join(profileDir, lockFile)); } catch {}
|
||||
}
|
||||
|
||||
// Delete stale state file
|
||||
try { fs.unlinkSync(config.stateFile); } catch {}
|
||||
|
||||
console.log('Launching headed Chromium with extension + sidebar agent...');
|
||||
try {
|
||||
// Start server in headed mode with extension auto-loaded
|
||||
// Use a well-known port so the Chrome extension auto-connects
|
||||
const serverEnv: Record<string, string> = {
|
||||
BROWSE_HEADED: '1',
|
||||
BROWSE_PORT: '34567',
|
||||
BROWSE_SIDEBAR_CHAT: '1',
|
||||
};
|
||||
const newState = await startServer(serverEnv);
|
||||
|
||||
// Print connected status
|
||||
const resp = await fetch(`http://127.0.0.1:${newState.port}/command`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${newState.token}`,
|
||||
},
|
||||
body: JSON.stringify({ command: 'status', args: [] }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
const status = await resp.text();
|
||||
console.log(`Connected to real Chrome\n${status}`);
|
||||
|
||||
// Auto-start sidebar agent
|
||||
const agentScript = path.resolve(__dirname, 'sidebar-agent.ts');
|
||||
try {
|
||||
// Clear old agent queue
|
||||
const agentQueue = path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-agent-queue.jsonl');
|
||||
try { fs.writeFileSync(agentQueue, ''); } catch {}
|
||||
|
||||
const agentProc = Bun.spawn(['bun', 'run', agentScript], {
|
||||
cwd: config.projectDir,
|
||||
env: {
|
||||
...process.env,
|
||||
BROWSE_BIN: path.resolve(__dirname, '..', 'dist', 'browse'),
|
||||
BROWSE_STATE_FILE: config.stateFile,
|
||||
BROWSE_SERVER_PORT: String(newState.port),
|
||||
},
|
||||
stdio: ['ignore', 'ignore', 'ignore'],
|
||||
});
|
||||
agentProc.unref();
|
||||
console.log(`[browse] Sidebar agent started (PID: ${agentProc.pid})`);
|
||||
} catch (err: any) {
|
||||
console.error(`[browse] Sidebar agent failed to start: ${err.message}`);
|
||||
console.error(`[browse] Run manually: bun run ${agentScript}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`[browse] Connect failed: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ─── Headed Disconnect (pre-server command) ─────────────────
|
||||
// disconnect must be handled BEFORE ensureServer() because the headed
|
||||
// guard blocks all commands when the server is unresponsive.
|
||||
if (command === 'disconnect') {
|
||||
const existingState = readState();
|
||||
if (!existingState || existingState.mode !== 'headed') {
|
||||
console.log('Not in headed mode — nothing to disconnect.');
|
||||
process.exit(0);
|
||||
}
|
||||
// Try graceful shutdown via server
|
||||
try {
|
||||
const resp = await fetch(`http://127.0.0.1:${existingState.port}/command`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${existingState.token}`,
|
||||
},
|
||||
body: JSON.stringify({ command: 'disconnect', args: [] }),
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
if (resp.ok) {
|
||||
console.log('Disconnected from real browser.');
|
||||
process.exit(0);
|
||||
}
|
||||
} catch {
|
||||
// Server not responding — force cleanup
|
||||
}
|
||||
// Force kill + cleanup
|
||||
if (isProcessAlive(existingState.pid)) {
|
||||
try { process.kill(existingState.pid, 'SIGTERM'); } catch {}
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
if (isProcessAlive(existingState.pid)) {
|
||||
try { process.kill(existingState.pid, 'SIGKILL'); } catch {}
|
||||
}
|
||||
}
|
||||
// Clean profile locks and state file
|
||||
const profileDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
|
||||
for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
|
||||
try { fs.unlinkSync(path.join(profileDir, lockFile)); } catch {}
|
||||
}
|
||||
try { fs.unlinkSync(config.stateFile); } catch {}
|
||||
console.log('Disconnected (server was unresponsive — force cleaned).');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Special case: chain reads from stdin
|
||||
if (command === 'chain' && commandArgs.length === 0) {
|
||||
const stdin = await Bun.stdin.text();
|
||||
|
||||
@@ -31,6 +31,11 @@ export const META_COMMANDS = new Set([
|
||||
'chain', 'diff',
|
||||
'url', 'snapshot',
|
||||
'handoff', 'resume',
|
||||
'connect', 'disconnect', 'focus',
|
||||
'inbox',
|
||||
'watch',
|
||||
'state',
|
||||
'frame',
|
||||
]);
|
||||
|
||||
export const ALL_COMMANDS = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
|
||||
@@ -98,6 +103,18 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
|
||||
// Handoff
|
||||
'handoff': { category: 'Server', description: 'Open visible Chrome at current page for user takeover', usage: 'handoff [message]' },
|
||||
'resume': { category: 'Server', description: 'Re-snapshot after user takeover, return control to AI', usage: 'resume' },
|
||||
// Headed mode
|
||||
'connect': { category: 'Server', description: 'Launch headed Chromium with Chrome extension', usage: 'connect' },
|
||||
'disconnect': { category: 'Server', description: 'Disconnect headed browser, return to headless mode' },
|
||||
'focus': { category: 'Server', description: 'Bring headed browser window to foreground (macOS)', usage: 'focus [@ref]' },
|
||||
// Inbox
|
||||
'inbox': { category: 'Meta', description: 'List messages from sidebar scout inbox', usage: 'inbox [--clear]' },
|
||||
// Watch
|
||||
'watch': { category: 'Meta', description: 'Passive observation — periodic snapshots while user browses', usage: 'watch [stop]' },
|
||||
// State
|
||||
'state': { category: 'Server', description: 'Save/load browser state (cookies + URLs)', usage: 'state save|load <name>' },
|
||||
// Frame
|
||||
'frame': { category: 'Meta', description: 'Switch to iframe context (or main to return)', usage: 'frame <sel|@ref|--name n|--url pattern|main>' },
|
||||
};
|
||||
|
||||
// Load-time validation: descriptions must cover exactly the command sets
|
||||
|
||||
+276
-8
@@ -11,6 +11,8 @@ import * as Diff from 'diff';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { TEMP_DIR, isPathWithin } from './platform';
|
||||
import { resolveConfig } from './config';
|
||||
import type { Frame } from 'playwright';
|
||||
|
||||
// Security: Path validation to prevent path traversal attacks
|
||||
const SAFE_DIRECTORIES = [TEMP_DIR, process.cwd()];
|
||||
@@ -23,6 +25,25 @@ export function validateOutputPath(filePath: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Tokenize a pipe segment respecting double-quoted strings. */
|
||||
function tokenizePipeSegment(segment: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = '';
|
||||
let inQuote = false;
|
||||
for (let i = 0; i < segment.length; i++) {
|
||||
const ch = segment[i];
|
||||
if (ch === '"') {
|
||||
inQuote = !inQuote;
|
||||
} else if (ch === ' ' && !inQuote) {
|
||||
if (current) { tokens.push(current); current = ''; }
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
if (current) tokens.push(current);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export async function handleMetaCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
@@ -61,8 +82,10 @@ export async function handleMetaCommand(
|
||||
case 'status': {
|
||||
const page = bm.getPage();
|
||||
const tabs = bm.getTabCount();
|
||||
const mode = bm.getConnectionMode();
|
||||
return [
|
||||
`Status: healthy`,
|
||||
`Mode: ${mode}`,
|
||||
`URL: ${page.url()}`,
|
||||
`Tabs: ${tabs}`,
|
||||
`PID: ${process.pid}`,
|
||||
@@ -185,35 +208,54 @@ export async function handleMetaCommand(
|
||||
case 'chain': {
|
||||
// Read JSON array from args[0] (if provided) or expect it was passed as body
|
||||
const jsonStr = args[0];
|
||||
if (!jsonStr) throw new Error('Usage: echo \'[["goto","url"],["text"]]\' | browse chain');
|
||||
if (!jsonStr) throw new Error(
|
||||
'Usage: echo \'[["goto","url"],["text"]]\' | browse chain\n' +
|
||||
' or: browse chain \'goto url | click @e5 | snapshot -ic\''
|
||||
);
|
||||
|
||||
let commands: string[][];
|
||||
try {
|
||||
commands = JSON.parse(jsonStr);
|
||||
if (!Array.isArray(commands)) throw new Error('not array');
|
||||
} catch {
|
||||
throw new Error('Invalid JSON. Expected: [["command", "arg1", "arg2"], ...]');
|
||||
// Fallback: pipe-delimited format "goto url | click @e5 | snapshot -ic"
|
||||
commands = jsonStr.split(' | ')
|
||||
.filter(seg => seg.trim().length > 0)
|
||||
.map(seg => tokenizePipeSegment(seg.trim()));
|
||||
}
|
||||
|
||||
if (!Array.isArray(commands)) throw new Error('Expected JSON array of commands');
|
||||
|
||||
const results: string[] = [];
|
||||
const { handleReadCommand } = await import('./read-commands');
|
||||
const { handleWriteCommand } = await import('./write-commands');
|
||||
|
||||
let lastWasWrite = false;
|
||||
for (const cmd of commands) {
|
||||
const [name, ...cmdArgs] = cmd;
|
||||
try {
|
||||
let result: string;
|
||||
if (WRITE_COMMANDS.has(name)) result = await handleWriteCommand(name, cmdArgs, bm);
|
||||
else if (READ_COMMANDS.has(name)) result = await handleReadCommand(name, cmdArgs, bm);
|
||||
else if (META_COMMANDS.has(name)) result = await handleMetaCommand(name, cmdArgs, bm, shutdown);
|
||||
else throw new Error(`Unknown command: ${name}`);
|
||||
if (WRITE_COMMANDS.has(name)) {
|
||||
result = await handleWriteCommand(name, cmdArgs, bm);
|
||||
lastWasWrite = true;
|
||||
} else if (READ_COMMANDS.has(name)) {
|
||||
result = await handleReadCommand(name, cmdArgs, bm);
|
||||
lastWasWrite = false;
|
||||
} else if (META_COMMANDS.has(name)) {
|
||||
result = await handleMetaCommand(name, cmdArgs, bm, shutdown);
|
||||
lastWasWrite = false;
|
||||
} else {
|
||||
throw new Error(`Unknown command: ${name}`);
|
||||
}
|
||||
results.push(`[${name}] ${result}`);
|
||||
} catch (err: any) {
|
||||
results.push(`[${name}] ERROR: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for network to settle after write commands before returning
|
||||
if (lastWasWrite) {
|
||||
await bm.getPage().waitForLoadState('networkidle', { timeout: 2000 }).catch(() => {});
|
||||
}
|
||||
|
||||
return results.join('\n\n');
|
||||
}
|
||||
|
||||
@@ -263,6 +305,232 @@ export async function handleMetaCommand(
|
||||
return `RESUMED\n${snapshot}`;
|
||||
}
|
||||
|
||||
// ─── Headed Mode ──────────────────────────────────────
|
||||
case 'connect': {
|
||||
// connect is handled as a pre-server command in cli.ts
|
||||
// If we get here, server is already running — tell the user
|
||||
if (bm.getConnectionMode() === 'headed') {
|
||||
return 'Already in headed mode with extension.';
|
||||
}
|
||||
return 'The connect command must be run from the CLI (not sent to a running server). Run: $B connect';
|
||||
}
|
||||
|
||||
case 'disconnect': {
|
||||
if (bm.getConnectionMode() !== 'headed') {
|
||||
return 'Not in headed mode — nothing to disconnect.';
|
||||
}
|
||||
// Signal that we want a restart in headless mode
|
||||
console.log('[browse] Disconnecting headed browser. Restarting in headless mode.');
|
||||
await shutdown();
|
||||
return 'Disconnected. Server will restart in headless mode on next command.';
|
||||
}
|
||||
|
||||
case 'focus': {
|
||||
if (bm.getConnectionMode() !== 'headed') {
|
||||
return 'focus requires headed mode. Run `$B connect` first.';
|
||||
}
|
||||
try {
|
||||
const { execSync } = await import('child_process');
|
||||
// Try common Chromium-based browser app names to bring to foreground
|
||||
const appNames = ['Comet', 'Google Chrome', 'Arc', 'Brave Browser', 'Microsoft Edge'];
|
||||
let activated = false;
|
||||
for (const appName of appNames) {
|
||||
try {
|
||||
execSync(`osascript -e 'tell application "${appName}" to activate'`, { stdio: 'pipe', timeout: 3000 });
|
||||
activated = true;
|
||||
break;
|
||||
} catch {
|
||||
// Try next browser
|
||||
}
|
||||
}
|
||||
|
||||
if (!activated) {
|
||||
return 'Could not bring browser to foreground. macOS only.';
|
||||
}
|
||||
|
||||
// If a ref was passed, scroll it into view
|
||||
if (args.length > 0 && args[0].startsWith('@')) {
|
||||
try {
|
||||
const resolved = await bm.resolveRef(args[0]);
|
||||
if ('locator' in resolved) {
|
||||
await resolved.locator.scrollIntoViewIfNeeded({ timeout: 5000 });
|
||||
return `Browser activated. Scrolled ${args[0]} into view.`;
|
||||
}
|
||||
} catch {
|
||||
// Ref not found — still activated the browser
|
||||
}
|
||||
}
|
||||
|
||||
return 'Browser window activated.';
|
||||
} catch (err: any) {
|
||||
return `focus failed: ${err.message}. macOS only.`;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Watch ──────────────────────────────────────────
|
||||
case 'watch': {
|
||||
if (args[0] === 'stop') {
|
||||
if (!bm.isWatching()) return 'Not currently watching.';
|
||||
const result = bm.stopWatch();
|
||||
const durationSec = Math.round(result.duration / 1000);
|
||||
return [
|
||||
`WATCH STOPPED (${durationSec}s, ${result.snapshots.length} snapshots)`,
|
||||
'',
|
||||
'Last snapshot:',
|
||||
result.snapshots.length > 0 ? result.snapshots[result.snapshots.length - 1] : '(none)',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
if (bm.isWatching()) return 'Already watching. Run `$B watch stop` to stop.';
|
||||
if (bm.getConnectionMode() !== 'headed') {
|
||||
return 'watch requires headed mode. Run `$B connect` first.';
|
||||
}
|
||||
|
||||
bm.startWatch();
|
||||
return 'WATCHING — observing user browsing. Periodic snapshots every 5s.\nRun `$B watch stop` to stop and get summary.';
|
||||
}
|
||||
|
||||
// ─── Inbox ──────────────────────────────────────────
|
||||
case 'inbox': {
|
||||
const { execSync } = await import('child_process');
|
||||
let gitRoot: string;
|
||||
try {
|
||||
gitRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
||||
} catch {
|
||||
return 'Not in a git repository — cannot locate inbox.';
|
||||
}
|
||||
|
||||
const inboxDir = path.join(gitRoot, '.context', 'sidebar-inbox');
|
||||
if (!fs.existsSync(inboxDir)) return 'Inbox empty.';
|
||||
|
||||
const files = fs.readdirSync(inboxDir)
|
||||
.filter(f => f.endsWith('.json') && !f.startsWith('.'))
|
||||
.sort()
|
||||
.reverse(); // newest first
|
||||
|
||||
if (files.length === 0) return 'Inbox empty.';
|
||||
|
||||
const messages: { timestamp: string; url: string; userMessage: string }[] = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(path.join(inboxDir, file), 'utf-8'));
|
||||
messages.push({
|
||||
timestamp: data.timestamp || '',
|
||||
url: data.page?.url || 'unknown',
|
||||
userMessage: data.userMessage || '',
|
||||
});
|
||||
} catch {
|
||||
// Skip malformed files
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.length === 0) return 'Inbox empty.';
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`SIDEBAR INBOX (${messages.length} message${messages.length === 1 ? '' : 's'})`);
|
||||
lines.push('────────────────────────────────');
|
||||
|
||||
for (const msg of messages) {
|
||||
const ts = msg.timestamp ? `[${msg.timestamp}]` : '[unknown]';
|
||||
lines.push(`${ts} ${msg.url}`);
|
||||
lines.push(` "${msg.userMessage}"`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push('────────────────────────────────');
|
||||
|
||||
// Handle --clear flag
|
||||
if (args.includes('--clear')) {
|
||||
for (const file of files) {
|
||||
try { fs.unlinkSync(path.join(inboxDir, file)); } catch {}
|
||||
}
|
||||
lines.push(`Cleared ${files.length} message${files.length === 1 ? '' : 's'}.`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ─── State ────────────────────────────────────────
|
||||
case 'state': {
|
||||
const [action, name] = args;
|
||||
if (!action || !name) throw new Error('Usage: state save|load <name>');
|
||||
|
||||
// Sanitize name: alphanumeric + hyphens + underscores only
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
||||
throw new Error('State name must be alphanumeric (a-z, 0-9, _, -)');
|
||||
}
|
||||
|
||||
const config = resolveConfig();
|
||||
const stateDir = path.join(config.stateDir, 'browse-states');
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
const statePath = path.join(stateDir, `${name}.json`);
|
||||
|
||||
if (action === 'save') {
|
||||
const state = await bm.saveState();
|
||||
// V1: cookies + URLs only (not localStorage — breaks on load-before-navigate)
|
||||
const saveData = {
|
||||
version: 1,
|
||||
cookies: state.cookies,
|
||||
pages: state.pages.map(p => ({ url: p.url, isActive: p.isActive })),
|
||||
};
|
||||
fs.writeFileSync(statePath, JSON.stringify(saveData, null, 2), { mode: 0o600 });
|
||||
return `State saved: ${statePath} (${state.cookies.length} cookies, ${state.pages.length} pages — treat as sensitive)`;
|
||||
}
|
||||
|
||||
if (action === 'load') {
|
||||
if (!fs.existsSync(statePath)) throw new Error(`State not found: ${statePath}`);
|
||||
const data = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
|
||||
if (!Array.isArray(data.cookies) || !Array.isArray(data.pages)) {
|
||||
throw new Error('Invalid state file: expected cookies and pages arrays');
|
||||
}
|
||||
// Close existing pages, then restore (replace, not merge)
|
||||
bm.setFrame(null);
|
||||
await bm.closeAllPages();
|
||||
await bm.restoreState({
|
||||
cookies: data.cookies,
|
||||
pages: data.pages.map((p: any) => ({ ...p, storage: null })),
|
||||
});
|
||||
return `State loaded: ${data.cookies.length} cookies, ${data.pages.length} pages`;
|
||||
}
|
||||
|
||||
throw new Error('Usage: state save|load <name>');
|
||||
}
|
||||
|
||||
// ─── Frame ───────────────────────────────────────
|
||||
case 'frame': {
|
||||
const target = args[0];
|
||||
if (!target) throw new Error('Usage: frame <selector|@ref|--name name|--url pattern|main>');
|
||||
|
||||
if (target === 'main') {
|
||||
bm.setFrame(null);
|
||||
bm.clearRefs();
|
||||
return 'Switched to main frame';
|
||||
}
|
||||
|
||||
const page = bm.getPage();
|
||||
let frame: Frame | null = null;
|
||||
|
||||
if (target === '--name') {
|
||||
if (!args[1]) throw new Error('Usage: frame --name <name>');
|
||||
frame = page.frame({ name: args[1] });
|
||||
} else if (target === '--url') {
|
||||
if (!args[1]) throw new Error('Usage: frame --url <pattern>');
|
||||
frame = page.frame({ url: new RegExp(args[1]) });
|
||||
} else {
|
||||
// CSS selector or @ref for the iframe element
|
||||
const resolved = await bm.resolveRef(target);
|
||||
const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector);
|
||||
const elementHandle = await locator.elementHandle({ timeout: 5000 });
|
||||
frame = await elementHandle?.contentFrame() ?? null;
|
||||
await elementHandle?.dispose();
|
||||
}
|
||||
|
||||
if (!frame) throw new Error(`Frame not found: ${target}`);
|
||||
bm.setFrame(frame);
|
||||
bm.clearRefs();
|
||||
return `Switched to frame: ${frame.url()}`;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown meta command: ${command}`);
|
||||
}
|
||||
|
||||
+23
-15
@@ -7,7 +7,7 @@
|
||||
|
||||
import type { BrowserManager } from './browser-manager';
|
||||
import { consoleBuffer, networkBuffer, dialogBuffer } from './buffers';
|
||||
import type { Page } from 'playwright';
|
||||
import type { Page, Frame } from 'playwright';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { TEMP_DIR, isPathWithin } from './platform';
|
||||
@@ -57,7 +57,7 @@ export function validateReadPath(filePath: string): void {
|
||||
* 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> {
|
||||
export async function getCleanText(page: Page | Frame): Promise<string> {
|
||||
return await page.evaluate(() => {
|
||||
const body = document.body;
|
||||
if (!body) return '';
|
||||
@@ -77,10 +77,12 @@ export async function handleReadCommand(
|
||||
bm: BrowserManager
|
||||
): Promise<string> {
|
||||
const page = bm.getPage();
|
||||
// Frame-aware target for content extraction
|
||||
const target = bm.getActiveFrameOrPage();
|
||||
|
||||
switch (command) {
|
||||
case 'text': {
|
||||
return await getCleanText(page);
|
||||
return await getCleanText(target);
|
||||
}
|
||||
|
||||
case 'html': {
|
||||
@@ -90,13 +92,19 @@ export async function handleReadCommand(
|
||||
if ('locator' in resolved) {
|
||||
return await resolved.locator.innerHTML({ timeout: 5000 });
|
||||
}
|
||||
return await page.innerHTML(resolved.selector);
|
||||
return await target.locator(resolved.selector).innerHTML({ timeout: 5000 });
|
||||
}
|
||||
return await page.content();
|
||||
// page.content() is page-only; use evaluate for frame compat
|
||||
const doctype = await target.evaluate(() => {
|
||||
const dt = document.doctype;
|
||||
return dt ? `<!DOCTYPE ${dt.name}>` : '';
|
||||
});
|
||||
const html = await target.evaluate(() => document.documentElement.outerHTML);
|
||||
return doctype ? `${doctype}\n${html}` : html;
|
||||
}
|
||||
|
||||
case 'links': {
|
||||
const links = await page.evaluate(() =>
|
||||
const links = await target.evaluate(() =>
|
||||
[...document.querySelectorAll('a[href]')].map(a => ({
|
||||
text: a.textContent?.trim().slice(0, 120) || '',
|
||||
href: (a as HTMLAnchorElement).href,
|
||||
@@ -106,7 +114,7 @@ export async function handleReadCommand(
|
||||
}
|
||||
|
||||
case 'forms': {
|
||||
const forms = await page.evaluate(() => {
|
||||
const forms = await target.evaluate(() => {
|
||||
return [...document.querySelectorAll('form')].map((form, i) => {
|
||||
const fields = [...form.querySelectorAll('input, select, textarea')].map(el => {
|
||||
const input = el as HTMLInputElement;
|
||||
@@ -136,7 +144,7 @@ export async function handleReadCommand(
|
||||
}
|
||||
|
||||
case 'accessibility': {
|
||||
const snapshot = await page.locator("body").ariaSnapshot();
|
||||
const snapshot = await target.locator("body").ariaSnapshot();
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
@@ -144,7 +152,7 @@ export async function handleReadCommand(
|
||||
const expr = args[0];
|
||||
if (!expr) throw new Error('Usage: browse js <expression>');
|
||||
const wrapped = wrapForEvaluate(expr);
|
||||
const result = await page.evaluate(wrapped);
|
||||
const result = await target.evaluate(wrapped);
|
||||
return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
|
||||
}
|
||||
|
||||
@@ -155,7 +163,7 @@ export async function handleReadCommand(
|
||||
if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
|
||||
const code = fs.readFileSync(filePath, 'utf-8');
|
||||
const wrapped = wrapForEvaluate(code);
|
||||
const result = await page.evaluate(wrapped);
|
||||
const result = await target.evaluate(wrapped);
|
||||
return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
|
||||
}
|
||||
|
||||
@@ -170,7 +178,7 @@ export async function handleReadCommand(
|
||||
);
|
||||
return value;
|
||||
}
|
||||
const value = await page.evaluate(
|
||||
const value = await target.evaluate(
|
||||
([sel, prop]) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return `Element not found: ${sel}`;
|
||||
@@ -195,7 +203,7 @@ export async function handleReadCommand(
|
||||
});
|
||||
return JSON.stringify(attrs, null, 2);
|
||||
}
|
||||
const attrs = await page.evaluate((sel) => {
|
||||
const attrs = await target.evaluate((sel: string) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return `Element not found: ${sel}`;
|
||||
const result: Record<string, string> = {};
|
||||
@@ -253,7 +261,7 @@ export async function handleReadCommand(
|
||||
if ('locator' in resolved) {
|
||||
locator = resolved.locator;
|
||||
} else {
|
||||
locator = page.locator(resolved.selector);
|
||||
locator = target.locator(resolved.selector);
|
||||
}
|
||||
|
||||
switch (property) {
|
||||
@@ -283,10 +291,10 @@ export async function handleReadCommand(
|
||||
if (args[0] === 'set' && args[1]) {
|
||||
const key = args[1];
|
||||
const value = args[2] || '';
|
||||
await page.evaluate(([k, v]) => localStorage.setItem(k, v), [key, value]);
|
||||
await target.evaluate(([k, v]: string[]) => localStorage.setItem(k, v), [key, value]);
|
||||
return `Set localStorage["${key}"]`;
|
||||
}
|
||||
const storage = await page.evaluate(() => ({
|
||||
const storage = await target.evaluate(() => ({
|
||||
localStorage: { ...localStorage },
|
||||
sessionStorage: { ...sessionStorage },
|
||||
}));
|
||||
|
||||
+758
-8
@@ -19,8 +19,11 @@ import { handleWriteCommand } from './write-commands';
|
||||
import { handleMetaCommand } from './meta-commands';
|
||||
import { handleCookiePickerRoute } from './cookie-picker-routes';
|
||||
import { COMMAND_DESCRIPTIONS } from './commands';
|
||||
import { SNAPSHOT_FLAGS } from './snapshot';
|
||||
import { handleSnapshot, SNAPSHOT_FLAGS } from './snapshot';
|
||||
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
|
||||
import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity';
|
||||
// Bun.spawn used instead of child_process.spawn (compiled bun binaries
|
||||
// fail posix_spawn on all executables including /bin/bash)
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
@@ -33,6 +36,7 @@ ensureStateDir(config);
|
||||
const AUTH_TOKEN = crypto.randomUUID();
|
||||
const BROWSE_PORT = parseInt(process.env.BROWSE_PORT || '0', 10);
|
||||
const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || '1800000', 10); // 30 min
|
||||
// Sidebar chat is always enabled in headed mode (ungated in v0.12.0)
|
||||
|
||||
function validateAuth(req: Request): boolean {
|
||||
const header = req.headers.get('authorization');
|
||||
@@ -87,6 +91,377 @@ export { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetwork
|
||||
const CONSOLE_LOG_PATH = config.consoleLog;
|
||||
const NETWORK_LOG_PATH = config.networkLog;
|
||||
const DIALOG_LOG_PATH = config.dialogLog;
|
||||
|
||||
// ─── Sidebar Agent (integrated — no separate process) ─────────────
|
||||
|
||||
interface ChatEntry {
|
||||
id: number;
|
||||
ts: string;
|
||||
role: 'user' | 'assistant' | 'agent';
|
||||
message?: string;
|
||||
type?: string;
|
||||
tool?: string;
|
||||
input?: string;
|
||||
text?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SidebarSession {
|
||||
id: string;
|
||||
name: string;
|
||||
claudeSessionId: string | null;
|
||||
worktreePath: string | null;
|
||||
createdAt: string;
|
||||
lastActiveAt: string;
|
||||
}
|
||||
|
||||
const SESSIONS_DIR = path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-sessions');
|
||||
const AGENT_TIMEOUT_MS = 300_000; // 5 minutes — multi-page tasks need time
|
||||
const MAX_QUEUE = 5;
|
||||
|
||||
let sidebarSession: SidebarSession | null = null;
|
||||
let agentProcess: ChildProcess | null = null;
|
||||
let agentStatus: 'idle' | 'processing' | 'hung' = 'idle';
|
||||
let agentStartTime: number | null = null;
|
||||
let messageQueue: Array<{message: string, ts: string}> = [];
|
||||
let currentMessage: string | null = null;
|
||||
let chatBuffer: ChatEntry[] = [];
|
||||
let chatNextId = 0;
|
||||
|
||||
// Find the browse binary for the claude subprocess system prompt
|
||||
function findBrowseBin(): string {
|
||||
const candidates = [
|
||||
path.resolve(__dirname, '..', 'dist', 'browse'),
|
||||
path.resolve(__dirname, '..', '..', '.claude', 'skills', 'gstack', 'browse', 'dist', 'browse'),
|
||||
path.join(process.env.HOME || '', '.claude', 'skills', 'gstack', 'browse', 'dist', 'browse'),
|
||||
];
|
||||
for (const c of candidates) {
|
||||
try { if (fs.existsSync(c)) return c; } catch {}
|
||||
}
|
||||
return 'browse'; // fallback to PATH
|
||||
}
|
||||
|
||||
const BROWSE_BIN = findBrowseBin();
|
||||
|
||||
function findClaudeBin(): string | null {
|
||||
const home = process.env.HOME || '';
|
||||
const candidates = [
|
||||
// Conductor app bundled binary (not a symlink — works reliably)
|
||||
path.join(home, 'Library', 'Application Support', 'com.conductor.app', 'bin', 'claude'),
|
||||
// Direct versioned binary (not a symlink)
|
||||
...(() => {
|
||||
try {
|
||||
const versionsDir = path.join(home, '.local', 'share', 'claude', 'versions');
|
||||
const entries = fs.readdirSync(versionsDir).filter(e => /^\d/.test(e)).sort().reverse();
|
||||
return entries.map(e => path.join(versionsDir, e));
|
||||
} catch { return []; }
|
||||
})(),
|
||||
// Standard install (symlink — resolve it)
|
||||
path.join(home, '.local', 'bin', 'claude'),
|
||||
'/usr/local/bin/claude',
|
||||
'/opt/homebrew/bin/claude',
|
||||
];
|
||||
// Also check if 'claude' is in current PATH
|
||||
try {
|
||||
const proc = Bun.spawnSync(['which', 'claude'], { stdout: 'pipe', stderr: 'pipe', timeout: 2000 });
|
||||
if (proc.exitCode === 0) {
|
||||
const p = proc.stdout.toString().trim();
|
||||
if (p) candidates.unshift(p);
|
||||
}
|
||||
} catch {}
|
||||
for (const c of candidates) {
|
||||
try {
|
||||
if (!fs.existsSync(c)) continue;
|
||||
// Resolve symlinks — posix_spawn can fail on symlinks in compiled bun binaries
|
||||
return fs.realpathSync(c);
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function shortenPath(str: string): string {
|
||||
return str
|
||||
.replace(new RegExp(BROWSE_BIN.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '$B')
|
||||
.replace(/\/Users\/[^/]+/g, '~')
|
||||
.replace(/\/conductor\/workspaces\/[^/]+\/[^/]+/g, '')
|
||||
.replace(/\.claude\/skills\/gstack\//g, '')
|
||||
.replace(/browse\/dist\/browse/g, '$B');
|
||||
}
|
||||
|
||||
function summarizeToolInput(tool: string, input: any): string {
|
||||
if (!input) return '';
|
||||
if (tool === 'Bash' && input.command) {
|
||||
let cmd = shortenPath(input.command);
|
||||
return cmd.length > 80 ? cmd.slice(0, 80) + '…' : cmd;
|
||||
}
|
||||
if (tool === 'Read' && input.file_path) return shortenPath(input.file_path);
|
||||
if (tool === 'Edit' && input.file_path) return shortenPath(input.file_path);
|
||||
if (tool === 'Write' && input.file_path) return shortenPath(input.file_path);
|
||||
if (tool === 'Grep' && input.pattern) return `/${input.pattern}/`;
|
||||
if (tool === 'Glob' && input.pattern) return input.pattern;
|
||||
try { return shortenPath(JSON.stringify(input)).slice(0, 60); } catch { return ''; }
|
||||
}
|
||||
|
||||
function addChatEntry(entry: Omit<ChatEntry, 'id'>): ChatEntry {
|
||||
const full: ChatEntry = { ...entry, id: chatNextId++ };
|
||||
chatBuffer.push(full);
|
||||
// Persist to disk (best-effort)
|
||||
if (sidebarSession) {
|
||||
const chatFile = path.join(SESSIONS_DIR, sidebarSession.id, 'chat.jsonl');
|
||||
try { fs.appendFileSync(chatFile, JSON.stringify(full) + '\n'); } catch {}
|
||||
}
|
||||
return full;
|
||||
}
|
||||
|
||||
function loadSession(): SidebarSession | null {
|
||||
try {
|
||||
const activeFile = path.join(SESSIONS_DIR, 'active.json');
|
||||
const activeData = JSON.parse(fs.readFileSync(activeFile, 'utf-8'));
|
||||
const sessionFile = path.join(SESSIONS_DIR, activeData.id, 'session.json');
|
||||
const session = JSON.parse(fs.readFileSync(sessionFile, 'utf-8')) as SidebarSession;
|
||||
// Load chat history
|
||||
const chatFile = path.join(SESSIONS_DIR, session.id, 'chat.jsonl');
|
||||
try {
|
||||
const lines = fs.readFileSync(chatFile, 'utf-8').split('\n').filter(Boolean);
|
||||
chatBuffer = lines.map(line => { try { return JSON.parse(line); } catch { return null; } }).filter(Boolean);
|
||||
chatNextId = chatBuffer.length > 0 ? Math.max(...chatBuffer.map(e => e.id)) + 1 : 0;
|
||||
} catch {}
|
||||
return session;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a git worktree for session isolation.
|
||||
* Falls back to null (use main cwd) if:
|
||||
* - not in a git repo
|
||||
* - git worktree add fails (submodules, LFS, permissions)
|
||||
* - worktree dir already exists (collision from prior crash)
|
||||
*/
|
||||
function createWorktree(sessionId: string): string | null {
|
||||
try {
|
||||
// Check if we're in a git repo
|
||||
const gitCheck = Bun.spawnSync(['git', 'rev-parse', '--show-toplevel'], {
|
||||
stdout: 'pipe', stderr: 'pipe', timeout: 3000,
|
||||
});
|
||||
if (gitCheck.exitCode !== 0) return null;
|
||||
const repoRoot = gitCheck.stdout.toString().trim();
|
||||
|
||||
const worktreeDir = path.join(process.env.HOME || '/tmp', '.gstack', 'worktrees', sessionId.slice(0, 8));
|
||||
|
||||
// Clean up if dir exists from prior crash
|
||||
if (fs.existsSync(worktreeDir)) {
|
||||
Bun.spawnSync(['git', 'worktree', 'remove', '--force', worktreeDir], {
|
||||
cwd: repoRoot, stdout: 'pipe', stderr: 'pipe', timeout: 5000,
|
||||
});
|
||||
try { fs.rmSync(worktreeDir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
|
||||
// Get current branch/commit
|
||||
const headCheck = Bun.spawnSync(['git', 'rev-parse', 'HEAD'], {
|
||||
cwd: repoRoot, stdout: 'pipe', stderr: 'pipe', timeout: 3000,
|
||||
});
|
||||
if (headCheck.exitCode !== 0) return null;
|
||||
const head = headCheck.stdout.toString().trim();
|
||||
|
||||
// Create worktree (detached HEAD — no branch conflicts)
|
||||
const result = Bun.spawnSync(['git', 'worktree', 'add', '--detach', worktreeDir, head], {
|
||||
cwd: repoRoot, stdout: 'pipe', stderr: 'pipe', timeout: 10000,
|
||||
});
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
console.log(`[browse] Worktree creation failed: ${result.stderr.toString().trim()}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`[browse] Created worktree: ${worktreeDir}`);
|
||||
return worktreeDir;
|
||||
} catch (err: any) {
|
||||
console.log(`[browse] Worktree creation error: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function removeWorktree(worktreePath: string | null): void {
|
||||
if (!worktreePath) return;
|
||||
try {
|
||||
const gitCheck = Bun.spawnSync(['git', 'rev-parse', '--show-toplevel'], {
|
||||
stdout: 'pipe', stderr: 'pipe', timeout: 3000,
|
||||
});
|
||||
if (gitCheck.exitCode === 0) {
|
||||
Bun.spawnSync(['git', 'worktree', 'remove', '--force', worktreePath], {
|
||||
cwd: gitCheck.stdout.toString().trim(), stdout: 'pipe', stderr: 'pipe', timeout: 5000,
|
||||
});
|
||||
}
|
||||
// Cleanup dir if git worktree remove didn't
|
||||
try { fs.rmSync(worktreePath, { recursive: true, force: true }); } catch {}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function createSession(): SidebarSession {
|
||||
const id = crypto.randomUUID();
|
||||
const worktreePath = createWorktree(id);
|
||||
const session: SidebarSession = {
|
||||
id,
|
||||
name: 'Chrome sidebar',
|
||||
claudeSessionId: null,
|
||||
worktreePath,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastActiveAt: new Date().toISOString(),
|
||||
};
|
||||
const sessionDir = path.join(SESSIONS_DIR, id);
|
||||
fs.mkdirSync(sessionDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(sessionDir, 'session.json'), JSON.stringify(session, null, 2));
|
||||
fs.writeFileSync(path.join(sessionDir, 'chat.jsonl'), '');
|
||||
fs.writeFileSync(path.join(SESSIONS_DIR, 'active.json'), JSON.stringify({ id }));
|
||||
chatBuffer = [];
|
||||
chatNextId = 0;
|
||||
return session;
|
||||
}
|
||||
|
||||
function saveSession(): void {
|
||||
if (!sidebarSession) return;
|
||||
sidebarSession.lastActiveAt = new Date().toISOString();
|
||||
const sessionFile = path.join(SESSIONS_DIR, sidebarSession.id, 'session.json');
|
||||
try { fs.writeFileSync(sessionFile, JSON.stringify(sidebarSession, null, 2)); } catch {}
|
||||
}
|
||||
|
||||
function listSessions(): Array<SidebarSession & { chatLines: number }> {
|
||||
try {
|
||||
const dirs = fs.readdirSync(SESSIONS_DIR).filter(d => d !== 'active.json');
|
||||
return dirs.map(d => {
|
||||
try {
|
||||
const session = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, d, 'session.json'), 'utf-8'));
|
||||
let chatLines = 0;
|
||||
try { chatLines = fs.readFileSync(path.join(SESSIONS_DIR, d, 'chat.jsonl'), 'utf-8').split('\n').filter(Boolean).length; } catch {}
|
||||
return { ...session, chatLines };
|
||||
} catch { return null; }
|
||||
}).filter(Boolean);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function processAgentEvent(event: any): void {
|
||||
if (event.type === 'system' && event.session_id && sidebarSession && !sidebarSession.claudeSessionId) {
|
||||
// Capture session_id from first claude init event for --resume
|
||||
sidebarSession.claudeSessionId = event.session_id;
|
||||
saveSession();
|
||||
}
|
||||
|
||||
if (event.type === 'assistant' && event.message?.content) {
|
||||
for (const block of event.message.content) {
|
||||
if (block.type === 'tool_use') {
|
||||
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'tool_use', tool: block.name, input: summarizeToolInput(block.name, block.input) });
|
||||
} else if (block.type === 'text' && block.text) {
|
||||
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'text', text: block.text });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
|
||||
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'tool_use', tool: event.content_block.name, input: summarizeToolInput(event.content_block.name, event.content_block.input) });
|
||||
}
|
||||
|
||||
if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta' && event.delta.text) {
|
||||
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'text_delta', text: event.delta.text });
|
||||
}
|
||||
|
||||
if (event.type === 'result') {
|
||||
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'result', text: event.text || event.result || '' });
|
||||
}
|
||||
}
|
||||
|
||||
function spawnClaude(userMessage: string): void {
|
||||
agentStatus = 'processing';
|
||||
agentStartTime = Date.now();
|
||||
currentMessage = userMessage;
|
||||
|
||||
const pageUrl = browserManager.getCurrentUrl() || 'about:blank';
|
||||
const B = BROWSE_BIN;
|
||||
const systemPrompt = [
|
||||
'You are a browser assistant running in a Chrome sidebar.',
|
||||
`Current page: ${pageUrl}`,
|
||||
`Browse binary: ${B}`,
|
||||
'',
|
||||
'Commands (run via bash):',
|
||||
` ${B} goto <url> ${B} click <@ref> ${B} fill <@ref> <text>`,
|
||||
` ${B} snapshot -i ${B} text ${B} screenshot`,
|
||||
` ${B} back ${B} forward ${B} reload`,
|
||||
'',
|
||||
'Rules: run snapshot -i before clicking. Keep responses SHORT.',
|
||||
].join('\n');
|
||||
|
||||
const prompt = `${systemPrompt}\n\nUser: ${userMessage}`;
|
||||
const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose',
|
||||
'--allowedTools', 'Bash,Read,Glob,Grep'];
|
||||
if (sidebarSession?.claudeSessionId) {
|
||||
args.push('--resume', sidebarSession.claudeSessionId);
|
||||
}
|
||||
|
||||
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_start' });
|
||||
|
||||
// Compiled bun binaries CANNOT spawn external processes (posix_spawn
|
||||
// fails with ENOENT on everything, including /bin/bash). Instead,
|
||||
// write the command to a queue file that the sidebar-agent process
|
||||
// (running as non-compiled bun) picks up and spawns claude.
|
||||
const gstackDir = path.join(process.env.HOME || '/tmp', '.gstack');
|
||||
const agentQueue = path.join(gstackDir, 'sidebar-agent-queue.jsonl');
|
||||
const entry = JSON.stringify({
|
||||
ts: new Date().toISOString(),
|
||||
message: userMessage,
|
||||
prompt,
|
||||
args,
|
||||
stateFile: config.stateFile,
|
||||
cwd: (sidebarSession as any)?.worktreePath || process.cwd(),
|
||||
sessionId: sidebarSession?.claudeSessionId || null,
|
||||
});
|
||||
try {
|
||||
fs.mkdirSync(gstackDir, { recursive: true });
|
||||
fs.appendFileSync(agentQueue, entry + '\n');
|
||||
} catch (err: any) {
|
||||
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_error', error: `Failed to queue: ${err.message}` });
|
||||
agentStatus = 'idle';
|
||||
agentStartTime = null;
|
||||
currentMessage = null;
|
||||
return;
|
||||
}
|
||||
// The sidebar-agent.ts process polls this file and spawns claude.
|
||||
// It POST events back via /sidebar-event which processAgentEvent handles.
|
||||
// Agent status transitions happen when we receive agent_done/agent_error events.
|
||||
}
|
||||
|
||||
function killAgent(): void {
|
||||
if (agentProcess) {
|
||||
try { agentProcess.kill('SIGTERM'); } catch {}
|
||||
setTimeout(() => { try { agentProcess?.kill('SIGKILL'); } catch {} }, 3000);
|
||||
}
|
||||
agentProcess = null;
|
||||
agentStartTime = null;
|
||||
currentMessage = null;
|
||||
agentStatus = 'idle';
|
||||
}
|
||||
|
||||
// Agent health check — detect hung processes
|
||||
let agentHealthInterval: ReturnType<typeof setInterval> | null = null;
|
||||
function startAgentHealthCheck(): void {
|
||||
agentHealthInterval = setInterval(() => {
|
||||
if (agentStatus === 'processing' && agentStartTime && Date.now() - agentStartTime > AGENT_TIMEOUT_MS) {
|
||||
agentStatus = 'hung';
|
||||
console.log(`[browse] Sidebar agent hung (>${AGENT_TIMEOUT_MS / 1000}s)`);
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
// Initialize session on startup
|
||||
function initSidebarSession(): void {
|
||||
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
||||
sidebarSession = loadSession();
|
||||
if (!sidebarSession) {
|
||||
sidebarSession = createSession();
|
||||
}
|
||||
console.log(`[browse] Sidebar session: ${sidebarSession.id} (${chatBuffer.length} chat entries loaded)`);
|
||||
startAgentHealthCheck();
|
||||
}
|
||||
let lastConsoleFlushed = 0;
|
||||
let lastNetworkFlushed = 0;
|
||||
let lastDialogFlushed = 0;
|
||||
@@ -224,6 +599,27 @@ async function handleCommand(body: any): Promise<Response> {
|
||||
});
|
||||
}
|
||||
|
||||
// Block mutation commands while watching (read-only observation mode)
|
||||
if (browserManager.isWatching() && WRITE_COMMANDS.has(command)) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Cannot run mutation commands while watching. Run `$B watch stop` first.',
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Activity: emit command_start
|
||||
const startTime = Date.now();
|
||||
emitActivity({
|
||||
type: 'command_start',
|
||||
command,
|
||||
args,
|
||||
url: browserManager.getCurrentUrl(),
|
||||
tabs: browserManager.getTabCount(),
|
||||
mode: browserManager.getConnectionMode(),
|
||||
});
|
||||
|
||||
try {
|
||||
let result: string;
|
||||
|
||||
@@ -233,6 +629,22 @@ async function handleCommand(body: any): Promise<Response> {
|
||||
result = await handleWriteCommand(command, args, browserManager);
|
||||
} else if (META_COMMANDS.has(command)) {
|
||||
result = await handleMetaCommand(command, args, browserManager, shutdown);
|
||||
// Start periodic snapshot interval when watch mode begins
|
||||
if (command === 'watch' && args[0] !== 'stop' && browserManager.isWatching()) {
|
||||
const watchInterval = setInterval(async () => {
|
||||
if (!browserManager.isWatching()) {
|
||||
clearInterval(watchInterval);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const snapshot = await handleSnapshot(['-i'], browserManager);
|
||||
browserManager.addWatchSnapshot(snapshot);
|
||||
} catch {
|
||||
// Page may be navigating — skip this snapshot
|
||||
}
|
||||
}, 5000);
|
||||
browserManager.watchInterval = watchInterval;
|
||||
}
|
||||
} else if (command === 'help') {
|
||||
const helpText = generateHelpText();
|
||||
return new Response(helpText, {
|
||||
@@ -249,12 +661,38 @@ async function handleCommand(body: any): Promise<Response> {
|
||||
});
|
||||
}
|
||||
|
||||
// Activity: emit command_end (success)
|
||||
emitActivity({
|
||||
type: 'command_end',
|
||||
command,
|
||||
args,
|
||||
url: browserManager.getCurrentUrl(),
|
||||
duration: Date.now() - startTime,
|
||||
status: 'ok',
|
||||
result: result,
|
||||
tabs: browserManager.getTabCount(),
|
||||
mode: browserManager.getConnectionMode(),
|
||||
});
|
||||
|
||||
browserManager.resetFailures();
|
||||
return new Response(result, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
} catch (err: any) {
|
||||
// Activity: emit command_end (error)
|
||||
emitActivity({
|
||||
type: 'command_end',
|
||||
command,
|
||||
args,
|
||||
url: browserManager.getCurrentUrl(),
|
||||
duration: Date.now() - startTime,
|
||||
status: 'error',
|
||||
error: err.message,
|
||||
tabs: browserManager.getTabCount(),
|
||||
mode: browserManager.getConnectionMode(),
|
||||
});
|
||||
|
||||
browserManager.incrementFailures();
|
||||
let errorMsg = wrapError(err);
|
||||
const hint = browserManager.getFailureHint();
|
||||
@@ -271,12 +709,25 @@ async function shutdown() {
|
||||
isShuttingDown = true;
|
||||
|
||||
console.log('[browse] Shutting down...');
|
||||
// Stop watch mode if active
|
||||
if (browserManager.isWatching()) browserManager.stopWatch();
|
||||
killAgent();
|
||||
messageQueue = [];
|
||||
saveSession(); // Persist chat history before exit
|
||||
if (sidebarSession?.worktreePath) removeWorktree(sidebarSession.worktreePath);
|
||||
if (agentHealthInterval) clearInterval(agentHealthInterval);
|
||||
clearInterval(flushInterval);
|
||||
clearInterval(idleCheckInterval);
|
||||
await flushBuffers(); // Final flush (async now)
|
||||
|
||||
await browserManager.close();
|
||||
|
||||
// Clean up Chromium profile locks (prevent SingletonLock on next launch)
|
||||
const profileDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
|
||||
for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
|
||||
try { fs.unlinkSync(path.join(profileDir, lockFile)); } catch {}
|
||||
}
|
||||
|
||||
// Clean up state file
|
||||
try { fs.unlinkSync(config.stateFile); } catch {}
|
||||
|
||||
@@ -294,6 +745,32 @@ if (process.platform === 'win32') {
|
||||
});
|
||||
}
|
||||
|
||||
// Emergency cleanup for crashes (OOM, uncaught exceptions, browser disconnect)
|
||||
function emergencyCleanup() {
|
||||
if (isShuttingDown) return;
|
||||
isShuttingDown = true;
|
||||
// Kill agent subprocess if running
|
||||
try { killAgent(); } catch {}
|
||||
// Save session state so chat history persists across crashes
|
||||
try { saveSession(); } catch {}
|
||||
// Clean Chromium profile locks
|
||||
const profileDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
|
||||
for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
|
||||
try { fs.unlinkSync(path.join(profileDir, lockFile)); } catch {}
|
||||
}
|
||||
try { fs.unlinkSync(config.stateFile); } catch {}
|
||||
}
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('[browse] FATAL uncaught exception:', err.message);
|
||||
emergencyCleanup();
|
||||
process.exit(1);
|
||||
});
|
||||
process.on('unhandledRejection', (err: any) => {
|
||||
console.error('[browse] FATAL unhandled rejection:', err?.message || err);
|
||||
emergencyCleanup();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// ─── Start ─────────────────────────────────────────────────────
|
||||
async function start() {
|
||||
// Clear old log files
|
||||
@@ -303,16 +780,20 @@ async function start() {
|
||||
|
||||
const port = await findPort();
|
||||
|
||||
// Launch browser
|
||||
await browserManager.launch();
|
||||
// Launch browser (headless or headed with extension)
|
||||
const headed = process.env.BROWSE_HEADED === '1';
|
||||
if (headed) {
|
||||
await browserManager.launchHeaded();
|
||||
console.log(`[browse] Launched headed Chromium with extension`);
|
||||
} else {
|
||||
await browserManager.launch();
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
hostname: '127.0.0.1',
|
||||
fetch: async (req) => {
|
||||
resetIdleTimer();
|
||||
|
||||
const url = new URL(req.url);
|
||||
|
||||
// Cookie picker routes — no auth required (localhost-only)
|
||||
@@ -320,21 +801,285 @@ async function start() {
|
||||
return handleCookiePickerRoute(url, req, browserManager);
|
||||
}
|
||||
|
||||
// Health check — no auth required (now async)
|
||||
// Health check — no auth required, does NOT reset idle timer
|
||||
if (url.pathname === '/health') {
|
||||
const healthy = await browserManager.isHealthy();
|
||||
return new Response(JSON.stringify({
|
||||
status: healthy ? 'healthy' : 'unhealthy',
|
||||
mode: browserManager.getConnectionMode(),
|
||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||
tabs: browserManager.getTabCount(),
|
||||
currentUrl: browserManager.getCurrentUrl(),
|
||||
token: AUTH_TOKEN, // Extension uses this for Bearer auth
|
||||
chatEnabled: true,
|
||||
agent: {
|
||||
status: agentStatus,
|
||||
runningFor: agentStartTime ? Date.now() - agentStartTime : null,
|
||||
currentMessage,
|
||||
queueLength: messageQueue.length,
|
||||
},
|
||||
session: sidebarSession ? { id: sidebarSession.id, name: sidebarSession.name } : null,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// All other endpoints require auth
|
||||
// Refs endpoint — no auth required (localhost-only), does NOT reset idle timer
|
||||
if (url.pathname === '/refs') {
|
||||
const refs = browserManager.getRefMap();
|
||||
return new Response(JSON.stringify({
|
||||
refs,
|
||||
url: browserManager.getCurrentUrl(),
|
||||
mode: browserManager.getConnectionMode(),
|
||||
}), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Activity stream — SSE, no auth (localhost-only), does NOT reset idle timer
|
||||
if (url.pathname === '/activity/stream') {
|
||||
const afterId = parseInt(url.searchParams.get('after') || '0', 10);
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
// 1. Gap detection + replay
|
||||
const { entries, gap, gapFrom, availableFrom } = getActivityAfter(afterId);
|
||||
if (gap) {
|
||||
controller.enqueue(encoder.encode(`event: gap\ndata: ${JSON.stringify({ gapFrom, availableFrom })}\n\n`));
|
||||
}
|
||||
for (const entry of entries) {
|
||||
controller.enqueue(encoder.encode(`event: activity\ndata: ${JSON.stringify(entry)}\n\n`));
|
||||
}
|
||||
|
||||
// 2. Subscribe for live events
|
||||
const unsubscribe = subscribe((entry) => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`event: activity\ndata: ${JSON.stringify(entry)}\n\n`));
|
||||
} catch {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Heartbeat every 15s
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`: heartbeat\n\n`));
|
||||
} catch {
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
// 4. Cleanup on disconnect
|
||||
req.signal.addEventListener('abort', () => {
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
try { controller.close(); } catch {}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Activity history — REST, no auth (localhost-only), does NOT reset idle timer
|
||||
if (url.pathname === '/activity/history') {
|
||||
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
|
||||
const { entries, totalAdded } = getActivityHistory(limit);
|
||||
return new Response(JSON.stringify({ entries, totalAdded, subscribers: getSubscriberCount() }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Sidebar endpoints (auth required — token from /health) ────
|
||||
|
||||
// Sidebar routes are always available in headed mode (ungated in v0.12.0)
|
||||
|
||||
// Sidebar chat history — read from in-memory buffer
|
||||
if (url.pathname === '/sidebar-chat') {
|
||||
if (!validateAuth(req)) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
const afterId = parseInt(url.searchParams.get('after') || '0', 10);
|
||||
const entries = chatBuffer.filter(e => e.id >= afterId);
|
||||
return new Response(JSON.stringify({ entries, total: chatNextId }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
|
||||
});
|
||||
}
|
||||
|
||||
// Sidebar → server: user message → queue or process immediately
|
||||
if (url.pathname === '/sidebar-command' && req.method === 'POST') {
|
||||
if (!validateAuth(req)) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
const body = await req.json();
|
||||
const msg = body.message?.trim();
|
||||
if (!msg) {
|
||||
return new Response(JSON.stringify({ error: 'Empty message' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
const ts = new Date().toISOString();
|
||||
addChatEntry({ ts, role: 'user', message: msg });
|
||||
if (sidebarSession) { sidebarSession.lastActiveAt = ts; saveSession(); }
|
||||
|
||||
if (agentStatus === 'idle') {
|
||||
spawnClaude(msg);
|
||||
return new Response(JSON.stringify({ ok: true, processing: true }), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} else if (messageQueue.length < MAX_QUEUE) {
|
||||
messageQueue.push({ message: msg, ts });
|
||||
return new Response(JSON.stringify({ ok: true, queued: true, position: messageQueue.length }), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} else {
|
||||
return new Response(JSON.stringify({ error: 'Queue full (max 5)' }), {
|
||||
status: 429, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Clear sidebar chat
|
||||
if (url.pathname === '/sidebar-chat/clear' && req.method === 'POST') {
|
||||
if (!validateAuth(req)) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
chatBuffer = [];
|
||||
chatNextId = 0;
|
||||
if (sidebarSession) {
|
||||
try { fs.writeFileSync(path.join(SESSIONS_DIR, sidebarSession.id, 'chat.jsonl'), ''); } catch {}
|
||||
}
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
// Kill hung agent
|
||||
if (url.pathname === '/sidebar-agent/kill' && req.method === 'POST') {
|
||||
if (!validateAuth(req)) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
killAgent();
|
||||
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_error', error: 'Killed by user' });
|
||||
// Process next in queue
|
||||
if (messageQueue.length > 0) {
|
||||
const next = messageQueue.shift()!;
|
||||
spawnClaude(next.message);
|
||||
}
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
// Stop agent (user-initiated) — queued messages remain for dismissal
|
||||
if (url.pathname === '/sidebar-agent/stop' && req.method === 'POST') {
|
||||
if (!validateAuth(req)) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
killAgent();
|
||||
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_error', error: 'Stopped by user' });
|
||||
return new Response(JSON.stringify({ ok: true, queuedMessages: messageQueue.length }), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Dismiss a queued message by index
|
||||
if (url.pathname === '/sidebar-queue/dismiss' && req.method === 'POST') {
|
||||
if (!validateAuth(req)) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
const body = await req.json();
|
||||
const idx = body.index;
|
||||
if (typeof idx === 'number' && idx >= 0 && idx < messageQueue.length) {
|
||||
messageQueue.splice(idx, 1);
|
||||
}
|
||||
return new Response(JSON.stringify({ ok: true, queueLength: messageQueue.length }), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Session info
|
||||
if (url.pathname === '/sidebar-session') {
|
||||
if (!validateAuth(req)) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
return new Response(JSON.stringify({
|
||||
session: sidebarSession,
|
||||
agent: { status: agentStatus, runningFor: agentStartTime ? Date.now() - agentStartTime : null, currentMessage, queueLength: messageQueue.length, queue: messageQueue },
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
// Create new session
|
||||
if (url.pathname === '/sidebar-session/new' && req.method === 'POST') {
|
||||
if (!validateAuth(req)) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
killAgent();
|
||||
messageQueue = [];
|
||||
// Clean up old session's worktree before creating new one
|
||||
if (sidebarSession?.worktreePath) removeWorktree(sidebarSession.worktreePath);
|
||||
sidebarSession = createSession();
|
||||
return new Response(JSON.stringify({ ok: true, session: sidebarSession }), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// List all sessions
|
||||
if (url.pathname === '/sidebar-session/list') {
|
||||
if (!validateAuth(req)) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
return new Response(JSON.stringify({ sessions: listSessions(), activeId: sidebarSession?.id }), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Agent event relay — sidebar-agent.ts POSTs events here
|
||||
if (url.pathname === '/sidebar-agent/event' && req.method === 'POST') {
|
||||
if (!validateAuth(req)) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
const body = await req.json();
|
||||
processAgentEvent(body);
|
||||
// Handle agent lifecycle events
|
||||
if (body.type === 'agent_done' || body.type === 'agent_error') {
|
||||
agentProcess = null;
|
||||
agentStartTime = null;
|
||||
currentMessage = null;
|
||||
if (body.type === 'agent_done') {
|
||||
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_done' });
|
||||
}
|
||||
// Process next queued message
|
||||
if (messageQueue.length > 0) {
|
||||
const next = messageQueue.shift()!;
|
||||
spawnClaude(next.message);
|
||||
} else {
|
||||
agentStatus = 'idle';
|
||||
}
|
||||
}
|
||||
// Capture claude session ID for --resume
|
||||
if (body.claudeSessionId && sidebarSession && !sidebarSession.claudeSessionId) {
|
||||
sidebarSession.claudeSessionId = body.claudeSessionId;
|
||||
saveSession();
|
||||
}
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
// ─── Auth-required endpoints ──────────────────────────────────
|
||||
|
||||
if (!validateAuth(req)) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
@@ -343,6 +1088,7 @@ async function start() {
|
||||
}
|
||||
|
||||
if (url.pathname === '/command' && req.method === 'POST') {
|
||||
resetIdleTimer(); // Only commands reset idle timer
|
||||
const body = await req.json();
|
||||
return handleCommand(body);
|
||||
}
|
||||
@@ -352,13 +1098,14 @@ async function start() {
|
||||
});
|
||||
|
||||
// Write state file (atomic: write .tmp then rename)
|
||||
const state = {
|
||||
const state: Record<string, unknown> = {
|
||||
pid: process.pid,
|
||||
port,
|
||||
token: AUTH_TOKEN,
|
||||
startedAt: new Date().toISOString(),
|
||||
serverPath: path.resolve(import.meta.dir, 'server.ts'),
|
||||
binaryVersion: readVersionHash() || undefined,
|
||||
mode: browserManager.getConnectionMode(),
|
||||
};
|
||||
const tmpFile = config.stateFile + '.tmp';
|
||||
fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { mode: 0o600 });
|
||||
@@ -368,6 +1115,9 @@ async function start() {
|
||||
console.log(`[browse] Server running on http://127.0.0.1:${port} (PID: ${process.pid})`);
|
||||
console.log(`[browse] State file: ${config.stateFile}`);
|
||||
console.log(`[browse] Idle timeout: ${IDLE_TIMEOUT_MS / 1000}s`);
|
||||
|
||||
// Initialize sidebar session (load existing or create new)
|
||||
initSidebarSession();
|
||||
}
|
||||
|
||||
start().catch((err) => {
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Sidebar Agent — polls agent-queue from server, spawns claude -p for each
|
||||
* message, streams live events back to the server via /sidebar-agent/event.
|
||||
*
|
||||
* This runs as a NON-COMPILED bun process because compiled bun binaries
|
||||
* cannot posix_spawn external executables. The server writes to the queue
|
||||
* file, this process reads it and spawns claude.
|
||||
*
|
||||
* Usage: BROWSE_BIN=/path/to/browse bun run browse/src/sidebar-agent.ts
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const QUEUE = path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-agent-queue.jsonl');
|
||||
const SERVER_PORT = parseInt(process.env.BROWSE_SERVER_PORT || '34567', 10);
|
||||
const SERVER_URL = `http://127.0.0.1:${SERVER_PORT}`;
|
||||
const POLL_MS = 500; // Fast polling — server already did the user-facing response
|
||||
const B = process.env.BROWSE_BIN || path.resolve(__dirname, '../../.claude/skills/gstack/browse/dist/browse');
|
||||
|
||||
let lastLine = 0;
|
||||
let authToken: string | null = null;
|
||||
let isProcessing = false;
|
||||
|
||||
// ─── File drop relay ──────────────────────────────────────────
|
||||
|
||||
function getGitRoot(): string | null {
|
||||
try {
|
||||
const { execSync } = require('child_process');
|
||||
return execSync('git rev-parse --show-toplevel', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeToInbox(message: string, pageUrl?: string, sessionId?: string): void {
|
||||
const gitRoot = getGitRoot();
|
||||
if (!gitRoot) {
|
||||
console.error('[sidebar-agent] Cannot write to inbox — not in a git repo');
|
||||
return;
|
||||
}
|
||||
|
||||
const inboxDir = path.join(gitRoot, '.context', 'sidebar-inbox');
|
||||
fs.mkdirSync(inboxDir, { recursive: true });
|
||||
|
||||
const now = new Date();
|
||||
const timestamp = now.toISOString().replace(/:/g, '-');
|
||||
const filename = `${timestamp}-observation.json`;
|
||||
const tmpFile = path.join(inboxDir, `.${filename}.tmp`);
|
||||
const finalFile = path.join(inboxDir, filename);
|
||||
|
||||
const inboxMessage = {
|
||||
type: 'observation',
|
||||
timestamp: now.toISOString(),
|
||||
page: { url: pageUrl || 'unknown', title: '' },
|
||||
userMessage: message,
|
||||
sidebarSessionId: sessionId || 'unknown',
|
||||
};
|
||||
|
||||
fs.writeFileSync(tmpFile, JSON.stringify(inboxMessage, null, 2));
|
||||
fs.renameSync(tmpFile, finalFile);
|
||||
console.log(`[sidebar-agent] Wrote inbox message: ${filename}`);
|
||||
}
|
||||
|
||||
// ─── Auth ────────────────────────────────────────────────────────
|
||||
|
||||
async function refreshToken(): Promise<string | null> {
|
||||
try {
|
||||
const resp = await fetch(`${SERVER_URL}/health`, { signal: AbortSignal.timeout(3000) });
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json() as any;
|
||||
authToken = data.token || null;
|
||||
return authToken;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Event relay to server ──────────────────────────────────────
|
||||
|
||||
async function sendEvent(event: Record<string, any>): Promise<void> {
|
||||
if (!authToken) await refreshToken();
|
||||
if (!authToken) return;
|
||||
|
||||
try {
|
||||
await fetch(`${SERVER_URL}/sidebar-agent/event`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify(event),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[sidebar-agent] Failed to send event:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Claude subprocess ──────────────────────────────────────────
|
||||
|
||||
function shorten(str: string): string {
|
||||
return str
|
||||
.replace(new RegExp(B.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '$B')
|
||||
.replace(/\/Users\/[^/]+/g, '~')
|
||||
.replace(/\/conductor\/workspaces\/[^/]+\/[^/]+/g, '')
|
||||
.replace(/\.claude\/skills\/gstack\//g, '')
|
||||
.replace(/browse\/dist\/browse/g, '$B');
|
||||
}
|
||||
|
||||
function summarizeToolInput(tool: string, input: any): string {
|
||||
if (!input) return '';
|
||||
if (tool === 'Bash' && input.command) {
|
||||
let cmd = shorten(input.command);
|
||||
return cmd.length > 80 ? cmd.slice(0, 80) + '…' : cmd;
|
||||
}
|
||||
if (tool === 'Read' && input.file_path) return shorten(input.file_path);
|
||||
if (tool === 'Edit' && input.file_path) return shorten(input.file_path);
|
||||
if (tool === 'Write' && input.file_path) return shorten(input.file_path);
|
||||
if (tool === 'Grep' && input.pattern) return `/${input.pattern}/`;
|
||||
if (tool === 'Glob' && input.pattern) return input.pattern;
|
||||
try { return shorten(JSON.stringify(input)).slice(0, 60); } catch { return ''; }
|
||||
}
|
||||
|
||||
async function handleStreamEvent(event: any): Promise<void> {
|
||||
if (event.type === 'system' && event.session_id) {
|
||||
// Relay claude session ID for --resume support
|
||||
await sendEvent({ type: 'system', claudeSessionId: event.session_id });
|
||||
}
|
||||
|
||||
if (event.type === 'assistant' && event.message?.content) {
|
||||
for (const block of event.message.content) {
|
||||
if (block.type === 'tool_use') {
|
||||
await sendEvent({ type: 'tool_use', tool: block.name, input: summarizeToolInput(block.name, block.input) });
|
||||
} else if (block.type === 'text' && block.text) {
|
||||
await sendEvent({ type: 'text', text: block.text });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
|
||||
await sendEvent({ type: 'tool_use', tool: event.content_block.name, input: summarizeToolInput(event.content_block.name, event.content_block.input) });
|
||||
}
|
||||
|
||||
if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta' && event.delta.text) {
|
||||
await sendEvent({ type: 'text_delta', text: event.delta.text });
|
||||
}
|
||||
|
||||
if (event.type === 'result') {
|
||||
await sendEvent({ type: 'result', text: event.result || '' });
|
||||
}
|
||||
}
|
||||
|
||||
async function askClaude(queueEntry: any): Promise<void> {
|
||||
const { prompt, args, stateFile, cwd } = queueEntry;
|
||||
|
||||
isProcessing = true;
|
||||
await sendEvent({ type: 'agent_start' });
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// Build args fresh — don't trust --resume from queue (session may be stale)
|
||||
let claudeArgs = ['-p', prompt, '--output-format', 'stream-json', '--verbose',
|
||||
'--allowedTools', 'Bash,Read,Glob,Grep'];
|
||||
|
||||
// Validate cwd exists — queue may reference a stale worktree
|
||||
let effectiveCwd = cwd || process.cwd();
|
||||
try { fs.accessSync(effectiveCwd); } catch { effectiveCwd = process.cwd(); }
|
||||
|
||||
const proc = spawn('claude', claudeArgs, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
cwd: effectiveCwd,
|
||||
env: { ...process.env, BROWSE_STATE_FILE: stateFile || '' },
|
||||
});
|
||||
|
||||
proc.stdin.end();
|
||||
|
||||
let buffer = '';
|
||||
|
||||
proc.stdout.on('data', (data: Buffer) => {
|
||||
buffer += data.toString();
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try { handleStreamEvent(JSON.parse(line)); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
proc.stderr.on('data', () => {}); // Claude logs to stderr, ignore
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (buffer.trim()) {
|
||||
try { handleStreamEvent(JSON.parse(buffer)); } catch {}
|
||||
}
|
||||
sendEvent({ type: 'agent_done' }).then(() => {
|
||||
isProcessing = false;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
sendEvent({ type: 'agent_error', error: err.message }).then(() => {
|
||||
isProcessing = false;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Timeout after 300 seconds (5 min — multi-page tasks need time)
|
||||
setTimeout(() => {
|
||||
try { proc.kill(); } catch {}
|
||||
sendEvent({ type: 'agent_error', error: 'Timed out after 300s' }).then(() => {
|
||||
isProcessing = false;
|
||||
resolve();
|
||||
});
|
||||
}, 300000);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Poll loop ───────────────────────────────────────────────────
|
||||
|
||||
function countLines(): number {
|
||||
try {
|
||||
return fs.readFileSync(QUEUE, 'utf-8').split('\n').filter(Boolean).length;
|
||||
} catch { return 0; }
|
||||
}
|
||||
|
||||
function readLine(n: number): string | null {
|
||||
try {
|
||||
const lines = fs.readFileSync(QUEUE, 'utf-8').split('\n').filter(Boolean);
|
||||
return lines[n - 1] || null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
if (isProcessing) return; // One at a time — server handles queuing
|
||||
|
||||
const current = countLines();
|
||||
if (current <= lastLine) return;
|
||||
|
||||
while (lastLine < current && !isProcessing) {
|
||||
lastLine++;
|
||||
const line = readLine(lastLine);
|
||||
if (!line) continue;
|
||||
|
||||
let entry: any;
|
||||
try { entry = JSON.parse(line); } catch { continue; }
|
||||
if (!entry.message && !entry.prompt) continue;
|
||||
|
||||
console.log(`[sidebar-agent] Processing: "${entry.message}"`);
|
||||
// Write to inbox so workspace agent can pick it up
|
||||
writeToInbox(entry.message || entry.prompt, entry.pageUrl, entry.sessionId);
|
||||
try {
|
||||
await askClaude(entry);
|
||||
} catch (err) {
|
||||
console.error(`[sidebar-agent] Error:`, err);
|
||||
await sendEvent({ type: 'agent_error', error: String(err) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Main ────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
const dir = path.dirname(QUEUE);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
if (!fs.existsSync(QUEUE)) fs.writeFileSync(QUEUE, '');
|
||||
|
||||
lastLine = countLines();
|
||||
await refreshToken();
|
||||
|
||||
console.log(`[sidebar-agent] Started. Watching ${QUEUE} from line ${lastLine}`);
|
||||
console.log(`[sidebar-agent] Server: ${SERVER_URL}`);
|
||||
console.log(`[sidebar-agent] Browse binary: ${B}`);
|
||||
|
||||
setInterval(poll, POLL_MS);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
+16
-7
@@ -17,7 +17,7 @@
|
||||
* Later: "click @e3" → look up Locator → locator.click()
|
||||
*/
|
||||
|
||||
import type { Page, Locator } from 'playwright';
|
||||
import type { Page, Frame, Locator } from 'playwright';
|
||||
import type { BrowserManager, RefEntry } from './browser-manager';
|
||||
import * as Diff from 'diff';
|
||||
import { TEMP_DIR, isPathWithin } from './platform';
|
||||
@@ -136,15 +136,18 @@ export async function handleSnapshot(
|
||||
): Promise<string> {
|
||||
const opts = parseSnapshotArgs(args);
|
||||
const page = bm.getPage();
|
||||
// Frame-aware target for accessibility tree
|
||||
const target = bm.getActiveFrameOrPage();
|
||||
const inFrame = bm.getFrame() !== null;
|
||||
|
||||
// Get accessibility tree via ariaSnapshot
|
||||
let rootLocator: Locator;
|
||||
if (opts.selector) {
|
||||
rootLocator = page.locator(opts.selector);
|
||||
rootLocator = target.locator(opts.selector);
|
||||
const count = await rootLocator.count();
|
||||
if (count === 0) throw new Error(`Selector not found: ${opts.selector}`);
|
||||
} else {
|
||||
rootLocator = page.locator('body');
|
||||
rootLocator = target.locator('body');
|
||||
}
|
||||
|
||||
const ariaText = await rootLocator.ariaSnapshot();
|
||||
@@ -205,11 +208,11 @@ export async function handleSnapshot(
|
||||
|
||||
let locator: Locator;
|
||||
if (opts.selector) {
|
||||
locator = page.locator(opts.selector).getByRole(node.role as any, {
|
||||
locator = target.locator(opts.selector).getByRole(node.role as any, {
|
||||
name: node.name || undefined,
|
||||
});
|
||||
} else {
|
||||
locator = page.getByRole(node.role as any, {
|
||||
locator = target.getByRole(node.role as any, {
|
||||
name: node.name || undefined,
|
||||
});
|
||||
}
|
||||
@@ -233,7 +236,7 @@ export async function handleSnapshot(
|
||||
// ─── Cursor-interactive scan (-C) ─────────────────────────
|
||||
if (opts.cursorInteractive) {
|
||||
try {
|
||||
const cursorElements = await page.evaluate(() => {
|
||||
const cursorElements = await target.evaluate(() => {
|
||||
const STANDARD_INTERACTIVE = new Set([
|
||||
'A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'SUMMARY', 'DETAILS',
|
||||
]);
|
||||
@@ -287,7 +290,7 @@ export async function handleSnapshot(
|
||||
let cRefCounter = 1;
|
||||
for (const elem of cursorElements) {
|
||||
const ref = `c${cRefCounter++}`;
|
||||
const locator = page.locator(elem.selector);
|
||||
const locator = target.locator(elem.selector);
|
||||
refMap.set(ref, { locator, role: 'cursor-interactive', name: elem.text });
|
||||
output.push(`@${ref} [${elem.reason}] "${elem.text}"`);
|
||||
}
|
||||
@@ -394,5 +397,11 @@ export async function handleSnapshot(
|
||||
// Store for future diffs
|
||||
bm.setLastSnapshot(snapshotText);
|
||||
|
||||
// Add frame context header when operating inside an iframe
|
||||
if (inFrame) {
|
||||
const frameUrl = bm.getFrame()?.url() ?? 'unknown';
|
||||
output.unshift(`[Context: iframe src="${frameUrl}"]`);
|
||||
}
|
||||
|
||||
return output.join('\n');
|
||||
}
|
||||
|
||||
@@ -18,9 +18,13 @@ export async function handleWriteCommand(
|
||||
bm: BrowserManager
|
||||
): Promise<string> {
|
||||
const page = bm.getPage();
|
||||
// Frame-aware target for locator-based operations (click, fill, etc.)
|
||||
const target = bm.getActiveFrameOrPage();
|
||||
const inFrame = bm.getFrame() !== null;
|
||||
|
||||
switch (command) {
|
||||
case 'goto': {
|
||||
if (inFrame) throw new Error('Cannot use goto inside a frame. Run \'frame main\' first.');
|
||||
const url = args[0];
|
||||
if (!url) throw new Error('Usage: browse goto <url>');
|
||||
await validateNavigationUrl(url);
|
||||
@@ -30,16 +34,19 @@ export async function handleWriteCommand(
|
||||
}
|
||||
|
||||
case 'back': {
|
||||
if (inFrame) throw new Error('Cannot use back inside a frame. Run \'frame main\' first.');
|
||||
await page.goBack({ waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
return `Back → ${page.url()}`;
|
||||
}
|
||||
|
||||
case 'forward': {
|
||||
if (inFrame) throw new Error('Cannot use forward inside a frame. Run \'frame main\' first.');
|
||||
await page.goForward({ waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
return `Forward → ${page.url()}`;
|
||||
}
|
||||
|
||||
case 'reload': {
|
||||
if (inFrame) throw new Error('Cannot use reload inside a frame. Run \'frame main\' first.');
|
||||
await page.reload({ waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
return `Reloaded ${page.url()}`;
|
||||
}
|
||||
@@ -73,15 +80,14 @@ export async function handleWriteCommand(
|
||||
if ('locator' in resolved) {
|
||||
await resolved.locator.click({ timeout: 5000 });
|
||||
} else {
|
||||
await page.click(resolved.selector, { timeout: 5000 });
|
||||
await target.locator(resolved.selector).click({ timeout: 5000 });
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Enhanced error guidance: clicking <option> elements always fails (not visible / timeout)
|
||||
const isOption = 'locator' in resolved
|
||||
? await resolved.locator.evaluate(el => el.tagName === 'OPTION').catch(() => false)
|
||||
: await page.evaluate(
|
||||
(sel: string) => document.querySelector(sel)?.tagName === 'OPTION',
|
||||
(resolved as { selector: string }).selector
|
||||
: await target.locator(resolved.selector).evaluate(
|
||||
el => el.tagName === 'OPTION'
|
||||
).catch(() => false);
|
||||
if (isOption) {
|
||||
throw new Error(
|
||||
@@ -90,8 +96,8 @@ export async function handleWriteCommand(
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// Wait briefly for any navigation/DOM update
|
||||
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
||||
// Wait for network to settle (catches XHR/fetch triggered by clicks)
|
||||
await page.waitForLoadState('networkidle', { timeout: 2000 }).catch(() => {});
|
||||
return `Clicked ${selector} → now at ${page.url()}`;
|
||||
}
|
||||
|
||||
@@ -103,8 +109,10 @@ export async function handleWriteCommand(
|
||||
if ('locator' in resolved) {
|
||||
await resolved.locator.fill(value, { timeout: 5000 });
|
||||
} else {
|
||||
await page.fill(resolved.selector, value, { timeout: 5000 });
|
||||
await target.locator(resolved.selector).fill(value, { timeout: 5000 });
|
||||
}
|
||||
// Wait for network to settle (form validation XHRs)
|
||||
await page.waitForLoadState('networkidle', { timeout: 2000 }).catch(() => {});
|
||||
return `Filled ${selector}`;
|
||||
}
|
||||
|
||||
@@ -116,8 +124,10 @@ export async function handleWriteCommand(
|
||||
if ('locator' in resolved) {
|
||||
await resolved.locator.selectOption(value, { timeout: 5000 });
|
||||
} else {
|
||||
await page.selectOption(resolved.selector, value, { timeout: 5000 });
|
||||
await target.locator(resolved.selector).selectOption(value, { timeout: 5000 });
|
||||
}
|
||||
// Wait for network to settle (dropdown-triggered requests)
|
||||
await page.waitForLoadState('networkidle', { timeout: 2000 }).catch(() => {});
|
||||
return `Selected "${value}" in ${selector}`;
|
||||
}
|
||||
|
||||
@@ -128,7 +138,7 @@ export async function handleWriteCommand(
|
||||
if ('locator' in resolved) {
|
||||
await resolved.locator.hover({ timeout: 5000 });
|
||||
} else {
|
||||
await page.hover(resolved.selector, { timeout: 5000 });
|
||||
await target.locator(resolved.selector).hover({ timeout: 5000 });
|
||||
}
|
||||
return `Hovered ${selector}`;
|
||||
}
|
||||
@@ -154,11 +164,11 @@ export async function handleWriteCommand(
|
||||
if ('locator' in resolved) {
|
||||
await resolved.locator.scrollIntoViewIfNeeded({ timeout: 5000 });
|
||||
} else {
|
||||
await page.locator(resolved.selector).scrollIntoViewIfNeeded({ timeout: 5000 });
|
||||
await target.locator(resolved.selector).scrollIntoViewIfNeeded({ timeout: 5000 });
|
||||
}
|
||||
return `Scrolled ${selector} into view`;
|
||||
}
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||
await target.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||
return 'Scrolled to bottom';
|
||||
}
|
||||
|
||||
@@ -183,7 +193,7 @@ export async function handleWriteCommand(
|
||||
if ('locator' in resolved) {
|
||||
await resolved.locator.waitFor({ state: 'visible', timeout });
|
||||
} else {
|
||||
await page.waitForSelector(resolved.selector, { timeout });
|
||||
await target.locator(resolved.selector).waitFor({ state: 'visible', timeout });
|
||||
}
|
||||
return `Element ${selector} appeared`;
|
||||
}
|
||||
@@ -248,7 +258,7 @@ export async function handleWriteCommand(
|
||||
if ('locator' in resolved) {
|
||||
await resolved.locator.setInputFiles(filePaths);
|
||||
} else {
|
||||
await page.locator(resolved.selector).setInputFiles(filePaths);
|
||||
await target.locator(resolved.selector).setInputFiles(filePaths);
|
||||
}
|
||||
|
||||
const fileInfo = filePaths.map(fp => {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { filterArgs, emitActivity, getActivityAfter, getActivityHistory, subscribe } from '../src/activity';
|
||||
|
||||
describe('filterArgs — privacy filtering', () => {
|
||||
it('redacts fill value for password fields', () => {
|
||||
expect(filterArgs('fill', ['#password', 'mysecret123'])).toEqual(['#password', '[REDACTED]']);
|
||||
expect(filterArgs('fill', ['input[type=passwd]', 'abc'])).toEqual(['input[type=passwd]', '[REDACTED]']);
|
||||
});
|
||||
|
||||
it('preserves fill value for non-password fields', () => {
|
||||
expect(filterArgs('fill', ['#email', 'user@test.com'])).toEqual(['#email', 'user@test.com']);
|
||||
});
|
||||
|
||||
it('redacts type command args', () => {
|
||||
expect(filterArgs('type', ['my password'])).toEqual(['[REDACTED]']);
|
||||
});
|
||||
|
||||
it('redacts Authorization header', () => {
|
||||
expect(filterArgs('header', ['Authorization:Bearer abc123'])).toEqual(['Authorization:[REDACTED]']);
|
||||
});
|
||||
|
||||
it('preserves non-sensitive headers', () => {
|
||||
expect(filterArgs('header', ['Content-Type:application/json'])).toEqual(['Content-Type:application/json']);
|
||||
});
|
||||
|
||||
it('redacts cookie values', () => {
|
||||
expect(filterArgs('cookie', ['session_id=abc123'])).toEqual(['session_id=[REDACTED]']);
|
||||
});
|
||||
|
||||
it('redacts sensitive URL query params', () => {
|
||||
const result = filterArgs('goto', ['https://example.com?api_key=secret&page=1']);
|
||||
expect(result[0]).toContain('api_key=%5BREDACTED%5D');
|
||||
expect(result[0]).toContain('page=1');
|
||||
});
|
||||
|
||||
it('preserves non-sensitive URL query params', () => {
|
||||
const result = filterArgs('goto', ['https://example.com?page=1&sort=name']);
|
||||
expect(result[0]).toBe('https://example.com?page=1&sort=name');
|
||||
});
|
||||
|
||||
it('handles empty args', () => {
|
||||
expect(filterArgs('click', [])).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles non-URL non-sensitive args', () => {
|
||||
expect(filterArgs('click', ['@e3'])).toEqual(['@e3']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitActivity', () => {
|
||||
it('emits with auto-incremented id', () => {
|
||||
const e1 = emitActivity({ type: 'command_start', command: 'goto', args: ['https://example.com'] });
|
||||
const e2 = emitActivity({ type: 'command_end', command: 'goto', status: 'ok', duration: 100 });
|
||||
expect(e2.id).toBe(e1.id + 1);
|
||||
});
|
||||
|
||||
it('truncates long results', () => {
|
||||
const longResult = 'x'.repeat(500);
|
||||
const entry = emitActivity({ type: 'command_end', command: 'text', result: longResult });
|
||||
expect(entry.result!.length).toBeLessThanOrEqual(203); // 200 + "..."
|
||||
});
|
||||
|
||||
it('applies privacy filtering', () => {
|
||||
const entry = emitActivity({ type: 'command_start', command: 'type', args: ['my secret password'] });
|
||||
expect(entry.args).toEqual(['[REDACTED]']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActivityAfter', () => {
|
||||
it('returns entries after cursor', () => {
|
||||
const e1 = emitActivity({ type: 'command_start', command: 'test1' });
|
||||
const e2 = emitActivity({ type: 'command_start', command: 'test2' });
|
||||
const result = getActivityAfter(e1.id);
|
||||
expect(result.entries.some(e => e.id === e2.id)).toBe(true);
|
||||
expect(result.gap).toBe(false);
|
||||
});
|
||||
|
||||
it('returns all entries when cursor is 0', () => {
|
||||
emitActivity({ type: 'command_start', command: 'test3' });
|
||||
const result = getActivityAfter(0);
|
||||
expect(result.entries.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActivityHistory', () => {
|
||||
it('returns limited entries', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
emitActivity({ type: 'command_start', command: `history-test-${i}` });
|
||||
}
|
||||
const result = getActivityHistory(3);
|
||||
expect(result.entries.length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscribe', () => {
|
||||
it('receives new events', async () => {
|
||||
const received: any[] = [];
|
||||
const unsub = subscribe((entry) => received.push(entry));
|
||||
|
||||
emitActivity({ type: 'command_start', command: 'sub-test' });
|
||||
|
||||
// queueMicrotask is async — wait a tick
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(received.length).toBeGreaterThanOrEqual(1);
|
||||
expect(received[received.length - 1].command).toBe('sub-test');
|
||||
unsub();
|
||||
});
|
||||
|
||||
it('stops receiving after unsubscribe', async () => {
|
||||
const received: any[] = [];
|
||||
const unsub = subscribe((entry) => received.push(entry));
|
||||
unsub();
|
||||
|
||||
emitActivity({ type: 'command_start', command: 'should-not-see' });
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(received.filter(e => e.command === 'should-not-see').length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
|
||||
// ─── BrowserManager basic unit tests ─────────────────────────────
|
||||
|
||||
describe('BrowserManager defaults', () => {
|
||||
it('getConnectionMode defaults to launched', async () => {
|
||||
const { BrowserManager } = await import('../src/browser-manager');
|
||||
const bm = new BrowserManager();
|
||||
expect(bm.getConnectionMode()).toBe('launched');
|
||||
});
|
||||
|
||||
it('getRefMap returns empty array initially', async () => {
|
||||
const { BrowserManager } = await import('../src/browser-manager');
|
||||
const bm = new BrowserManager();
|
||||
expect(bm.getRefMap()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1323,13 +1323,12 @@ describe('Errors', () => {
|
||||
}
|
||||
});
|
||||
|
||||
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 invalid JSON falls back to pipe format', async () => {
|
||||
// Non-JSON input is now treated as pipe-delimited format
|
||||
// 'not json' → [["not", "json"]] → "not" is unknown command → error in result
|
||||
const result = await handleMetaCommand('chain', ['not json'], bm, async () => {});
|
||||
expect(result).toContain('ERROR');
|
||||
expect(result).toContain('Unknown command: not');
|
||||
});
|
||||
|
||||
test('chain with no arg throws', async () => {
|
||||
@@ -1834,3 +1833,232 @@ describe('Chain with cookie-import', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Network Idle Detection ─────────────────────────────────────
|
||||
|
||||
describe('Network idle', () => {
|
||||
test('click on fetch button waits for XHR to complete', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/network-idle.html'], bm);
|
||||
// Click the button that triggers a fetch → networkidle waits for it
|
||||
await handleWriteCommand('click', ['#fetch-btn'], bm);
|
||||
// The DOM should be updated by the time click returns
|
||||
const result = await handleReadCommand('js', ['document.getElementById("result").textContent'], bm);
|
||||
expect(result).toContain('Data loaded');
|
||||
});
|
||||
|
||||
test('click on static button has no latency penalty', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/network-idle.html'], bm);
|
||||
const start = Date.now();
|
||||
await handleWriteCommand('click', ['#static-btn'], bm);
|
||||
const elapsed = Date.now() - start;
|
||||
// Static click should complete well under 2s (the networkidle timeout)
|
||||
// networkidle resolves immediately when no requests are in flight
|
||||
expect(elapsed).toBeLessThan(1500);
|
||||
const result = await handleReadCommand('js', ['document.getElementById("static-result").textContent'], bm);
|
||||
expect(result).toBe('Static action done');
|
||||
});
|
||||
|
||||
test('fill triggers networkidle wait', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm);
|
||||
// fill should complete without error (networkidle resolves immediately on static page)
|
||||
const result = await handleWriteCommand('fill', ['#email', 'idle@test.com'], bm);
|
||||
expect(result).toContain('Filled');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Chain Pipe Format ──────────────────────────────────────────
|
||||
|
||||
describe('Chain pipe format', () => {
|
||||
test('pipe-delimited commands work', async () => {
|
||||
const result = await handleMetaCommand(
|
||||
'chain',
|
||||
[`goto ${baseUrl}/basic.html | js document.title`],
|
||||
bm,
|
||||
async () => {}
|
||||
);
|
||||
expect(result).toContain('[goto]');
|
||||
expect(result).toContain('[js]');
|
||||
expect(result).toContain('Test Page - Basic');
|
||||
});
|
||||
|
||||
test('pipe format with quoted args', async () => {
|
||||
const result = await handleMetaCommand(
|
||||
'chain',
|
||||
[`goto ${baseUrl}/forms.html | fill #email "pipe@test.com"`],
|
||||
bm,
|
||||
async () => {}
|
||||
);
|
||||
expect(result).toContain('[fill]');
|
||||
expect(result).toContain('Filled');
|
||||
// Verify the fill actually worked
|
||||
const val = await handleReadCommand('js', ['document.querySelector("#email").value'], bm);
|
||||
expect(val).toBe('pipe@test.com');
|
||||
});
|
||||
|
||||
test('JSON format still works', async () => {
|
||||
const commands = JSON.stringify([
|
||||
['goto', baseUrl + '/basic.html'],
|
||||
['js', 'document.title'],
|
||||
]);
|
||||
const result = await handleMetaCommand('chain', [commands], bm, async () => {});
|
||||
expect(result).toContain('[goto]');
|
||||
expect(result).toContain('Test Page - Basic');
|
||||
});
|
||||
|
||||
test('pipe format with unknown command includes error', async () => {
|
||||
const result = await handleMetaCommand(
|
||||
'chain',
|
||||
['bogus command'],
|
||||
bm,
|
||||
async () => {}
|
||||
);
|
||||
expect(result).toContain('ERROR');
|
||||
expect(result).toContain('Unknown command: bogus');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── State Persistence ──────────────────────────────────────────
|
||||
|
||||
describe('State persistence', () => {
|
||||
test('state save and load round-trip', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
||||
// Set a cookie so we can verify it persists
|
||||
await handleWriteCommand('cookie', ['state_test=hello'], bm);
|
||||
|
||||
// Save state
|
||||
const saveResult = await handleMetaCommand('state', ['save', 'test-roundtrip'], bm, async () => {});
|
||||
expect(saveResult).toContain('State saved');
|
||||
expect(saveResult).toContain('treat as sensitive');
|
||||
|
||||
// Navigate away
|
||||
await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm);
|
||||
|
||||
// Load state — should restore to basic.html with cookie
|
||||
const loadResult = await handleMetaCommand('state', ['load', 'test-roundtrip'], bm, async () => {});
|
||||
expect(loadResult).toContain('State loaded');
|
||||
|
||||
// Verify we're back on basic.html
|
||||
const url = await handleReadCommand('js', ['location.pathname'], bm);
|
||||
expect(url).toContain('basic.html');
|
||||
|
||||
// Clean up
|
||||
try {
|
||||
const { resolveConfig } = await import('../src/config');
|
||||
const config = resolveConfig();
|
||||
fs.unlinkSync(`${config.stateDir}/browse-states/test-roundtrip.json`);
|
||||
} catch {}
|
||||
});
|
||||
|
||||
test('state save rejects invalid names', async () => {
|
||||
try {
|
||||
await handleMetaCommand('state', ['save', '../../evil'], bm, async () => {});
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('alphanumeric');
|
||||
}
|
||||
});
|
||||
|
||||
test('state save accepts valid names', async () => {
|
||||
const result = await handleMetaCommand('state', ['save', 'my-state_1'], bm, async () => {});
|
||||
expect(result).toContain('State saved');
|
||||
// Clean up
|
||||
try {
|
||||
const { resolveConfig } = await import('../src/config');
|
||||
const config = resolveConfig();
|
||||
fs.unlinkSync(`${config.stateDir}/browse-states/my-state_1.json`);
|
||||
} catch {}
|
||||
});
|
||||
|
||||
test('state load rejects missing state', async () => {
|
||||
try {
|
||||
await handleMetaCommand('state', ['load', 'nonexistent-state-xyz'], bm, async () => {});
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('State not found');
|
||||
}
|
||||
});
|
||||
|
||||
test('state requires action and name', async () => {
|
||||
try {
|
||||
await handleMetaCommand('state', [], bm, async () => {});
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Usage');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Frame (Iframe Support) ─────────────────────────────────────
|
||||
|
||||
describe('Frame', () => {
|
||||
test('frame switch to iframe and back', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/iframe.html'], bm);
|
||||
|
||||
// Verify we're on the main page
|
||||
const mainTitle = await handleReadCommand('js', ['document.getElementById("main-title").textContent'], bm);
|
||||
expect(mainTitle).toBe('Main Page');
|
||||
|
||||
// Switch to iframe by CSS selector
|
||||
const switchResult = await handleMetaCommand('frame', ['#test-frame'], bm, async () => {});
|
||||
expect(switchResult).toContain('Switched to frame');
|
||||
|
||||
// Verify we can read iframe content
|
||||
const frameTitle = await handleReadCommand('js', ['document.getElementById("frame-title").textContent'], bm);
|
||||
expect(frameTitle).toBe('Inside Frame');
|
||||
|
||||
// Switch back to main
|
||||
const mainResult = await handleMetaCommand('frame', ['main'], bm, async () => {});
|
||||
expect(mainResult).toBe('Switched to main frame');
|
||||
|
||||
// Verify we're back on the main page
|
||||
const mainTitleAgain = await handleReadCommand('js', ['document.getElementById("main-title").textContent'], bm);
|
||||
expect(mainTitleAgain).toBe('Main Page');
|
||||
});
|
||||
|
||||
test('snapshot shows frame context header', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/iframe.html'], bm);
|
||||
await handleMetaCommand('frame', ['#test-frame'], bm, async () => {});
|
||||
|
||||
const snap = await handleMetaCommand('snapshot', ['-i'], bm, async () => {});
|
||||
expect(snap).toContain('[Context: iframe');
|
||||
|
||||
// Clean up — return to main
|
||||
await handleMetaCommand('frame', ['main'], bm, async () => {});
|
||||
});
|
||||
|
||||
test('goto throws error when in frame context', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/iframe.html'], bm);
|
||||
await handleMetaCommand('frame', ['#test-frame'], bm, async () => {});
|
||||
|
||||
try {
|
||||
await handleWriteCommand('goto', ['https://example.com'], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Cannot use goto inside a frame');
|
||||
}
|
||||
|
||||
await handleMetaCommand('frame', ['main'], bm, async () => {});
|
||||
});
|
||||
|
||||
test('frame requires argument', async () => {
|
||||
try {
|
||||
await handleMetaCommand('frame', [], bm, async () => {});
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Usage');
|
||||
}
|
||||
});
|
||||
|
||||
test('fill works inside iframe', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/iframe.html'], bm);
|
||||
await handleMetaCommand('frame', ['#test-frame'], bm, async () => {});
|
||||
|
||||
const result = await handleWriteCommand('fill', ['#frame-input', 'hello from frame'], bm);
|
||||
expect(result).toContain('Filled');
|
||||
|
||||
const value = await handleReadCommand('js', ['document.getElementById("frame-input").value'], bm);
|
||||
expect(value).toBe('hello from frame');
|
||||
|
||||
await handleMetaCommand('frame', ['main'], bm, async () => {});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Tests for the inbox meta-command handler (file drop relay).
|
||||
*
|
||||
* Tests the inbox display, --clear flag, and edge cases by creating
|
||||
* temp directories with test JSON files and calling handleMetaCommand.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { handleMetaCommand } from '../src/meta-commands';
|
||||
import { BrowserManager } from '../src/browser-manager';
|
||||
|
||||
let tmpDir: string;
|
||||
let bm: BrowserManager;
|
||||
|
||||
// We need a BrowserManager instance for handleMetaCommand, but inbox
|
||||
// doesn't use it. We also need to mock git rev-parse to point to our
|
||||
// temp directory. We'll test the inbox logic directly by manipulating
|
||||
// the filesystem and using child_process.execSync override.
|
||||
|
||||
// ─── Direct filesystem tests (bypassing handleMetaCommand) ──────
|
||||
// The inbox handler in meta-commands.ts calls `git rev-parse --show-toplevel`
|
||||
// to find the inbox directory. Since we can't easily mock that in unit tests,
|
||||
// we test the inbox parsing logic directly.
|
||||
|
||||
interface InboxMessage {
|
||||
timestamp: string;
|
||||
url: string;
|
||||
userMessage: string;
|
||||
}
|
||||
|
||||
/** Replicate the inbox file reading logic from meta-commands.ts */
|
||||
function readInbox(inboxDir: string): InboxMessage[] {
|
||||
if (!fs.existsSync(inboxDir)) return [];
|
||||
|
||||
const files = fs.readdirSync(inboxDir)
|
||||
.filter(f => f.endsWith('.json') && !f.startsWith('.'))
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
if (files.length === 0) return [];
|
||||
|
||||
const messages: InboxMessage[] = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(path.join(inboxDir, file), 'utf-8'));
|
||||
messages.push({
|
||||
timestamp: data.timestamp || '',
|
||||
url: data.page?.url || 'unknown',
|
||||
userMessage: data.userMessage || '',
|
||||
});
|
||||
} catch {
|
||||
// Skip malformed files
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
/** Replicate the inbox formatting logic from meta-commands.ts */
|
||||
function formatInbox(messages: InboxMessage[]): string {
|
||||
if (messages.length === 0) return 'Inbox empty.';
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`SIDEBAR INBOX (${messages.length} message${messages.length === 1 ? '' : 's'})`);
|
||||
lines.push('────────────────────────────────');
|
||||
|
||||
for (const msg of messages) {
|
||||
const ts = msg.timestamp ? `[${msg.timestamp}]` : '[unknown]';
|
||||
lines.push(`${ts} ${msg.url}`);
|
||||
lines.push(` "${msg.userMessage}"`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push('────────────────────────────────');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** Replicate the --clear logic from meta-commands.ts */
|
||||
function clearInbox(inboxDir: string): number {
|
||||
const files = fs.readdirSync(inboxDir)
|
||||
.filter(f => f.endsWith('.json') && !f.startsWith('.'));
|
||||
for (const file of files) {
|
||||
try { fs.unlinkSync(path.join(inboxDir, file)); } catch {}
|
||||
}
|
||||
return files.length;
|
||||
}
|
||||
|
||||
function writeTestInboxFile(
|
||||
inboxDir: string,
|
||||
message: string,
|
||||
pageUrl: string,
|
||||
timestamp: string,
|
||||
): string {
|
||||
fs.mkdirSync(inboxDir, { recursive: true });
|
||||
const filename = `${timestamp.replace(/:/g, '-')}-observation.json`;
|
||||
const filePath = path.join(inboxDir, filename);
|
||||
fs.writeFileSync(filePath, JSON.stringify({
|
||||
type: 'observation',
|
||||
timestamp,
|
||||
page: { url: pageUrl, title: '' },
|
||||
userMessage: message,
|
||||
sidebarSessionId: 'test-session',
|
||||
}, null, 2));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'file-drop-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─── Empty Inbox ─────────────────────────────────────────────────
|
||||
|
||||
describe('inbox — empty states', () => {
|
||||
test('no .context/sidebar-inbox directory returns empty', () => {
|
||||
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
|
||||
const messages = readInbox(inboxDir);
|
||||
expect(messages.length).toBe(0);
|
||||
expect(formatInbox(messages)).toBe('Inbox empty.');
|
||||
});
|
||||
|
||||
test('empty inbox directory returns empty', () => {
|
||||
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
|
||||
fs.mkdirSync(inboxDir, { recursive: true });
|
||||
const messages = readInbox(inboxDir);
|
||||
expect(messages.length).toBe(0);
|
||||
expect(formatInbox(messages)).toBe('Inbox empty.');
|
||||
});
|
||||
|
||||
test('directory with only dotfiles returns empty', () => {
|
||||
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
|
||||
fs.mkdirSync(inboxDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(inboxDir, '.tmp-file.json'), '{}');
|
||||
const messages = readInbox(inboxDir);
|
||||
expect(messages.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Valid Messages ──────────────────────────────────────────────
|
||||
|
||||
describe('inbox — valid messages', () => {
|
||||
test('displays formatted output with timestamps and URLs', () => {
|
||||
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
|
||||
writeTestInboxFile(inboxDir, 'This button is broken', 'https://example.com/page', '2024-06-15T10:30:00.000Z');
|
||||
writeTestInboxFile(inboxDir, 'Login form fails', 'https://example.com/login', '2024-06-15T10:31:00.000Z');
|
||||
|
||||
const messages = readInbox(inboxDir);
|
||||
expect(messages.length).toBe(2);
|
||||
|
||||
const output = formatInbox(messages);
|
||||
expect(output).toContain('SIDEBAR INBOX (2 messages)');
|
||||
expect(output).toContain('https://example.com/page');
|
||||
expect(output).toContain('https://example.com/login');
|
||||
expect(output).toContain('"This button is broken"');
|
||||
expect(output).toContain('"Login form fails"');
|
||||
expect(output).toContain('[2024-06-15T10:30:00.000Z]');
|
||||
expect(output).toContain('[2024-06-15T10:31:00.000Z]');
|
||||
});
|
||||
|
||||
test('single message uses singular form', () => {
|
||||
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
|
||||
writeTestInboxFile(inboxDir, 'Just one', 'https://example.com', '2024-06-15T10:30:00.000Z');
|
||||
|
||||
const messages = readInbox(inboxDir);
|
||||
const output = formatInbox(messages);
|
||||
expect(output).toContain('1 message)');
|
||||
expect(output).not.toContain('messages)');
|
||||
});
|
||||
|
||||
test('messages sorted newest first', () => {
|
||||
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
|
||||
writeTestInboxFile(inboxDir, 'older', 'https://example.com', '2024-06-15T10:00:00.000Z');
|
||||
writeTestInboxFile(inboxDir, 'newer', 'https://example.com', '2024-06-15T11:00:00.000Z');
|
||||
|
||||
const messages = readInbox(inboxDir);
|
||||
// Filenames sort lexicographically, reversed = newest first
|
||||
expect(messages[0].userMessage).toBe('newer');
|
||||
expect(messages[1].userMessage).toBe('older');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Malformed Files ─────────────────────────────────────────────
|
||||
|
||||
describe('inbox — malformed files', () => {
|
||||
test('malformed JSON files are skipped gracefully', () => {
|
||||
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
|
||||
fs.mkdirSync(inboxDir, { recursive: true });
|
||||
|
||||
// Write a valid message
|
||||
writeTestInboxFile(inboxDir, 'valid message', 'https://example.com', '2024-06-15T10:30:00.000Z');
|
||||
|
||||
// Write a malformed JSON file
|
||||
fs.writeFileSync(
|
||||
path.join(inboxDir, '2024-06-15T10-35-00.000Z-observation.json'),
|
||||
'this is not valid json {{{',
|
||||
);
|
||||
|
||||
const messages = readInbox(inboxDir);
|
||||
expect(messages.length).toBe(1);
|
||||
expect(messages[0].userMessage).toBe('valid message');
|
||||
});
|
||||
|
||||
test('JSON file missing fields uses defaults', () => {
|
||||
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
|
||||
fs.mkdirSync(inboxDir, { recursive: true });
|
||||
|
||||
// Write a JSON file with missing fields
|
||||
fs.writeFileSync(
|
||||
path.join(inboxDir, '2024-06-15T10-30-00.000Z-observation.json'),
|
||||
JSON.stringify({ type: 'observation' }),
|
||||
);
|
||||
|
||||
const messages = readInbox(inboxDir);
|
||||
expect(messages.length).toBe(1);
|
||||
expect(messages[0].timestamp).toBe('');
|
||||
expect(messages[0].url).toBe('unknown');
|
||||
expect(messages[0].userMessage).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Clear Flag ──────────────────────────────────────────────────
|
||||
|
||||
describe('inbox — --clear flag', () => {
|
||||
test('files deleted after clear', () => {
|
||||
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
|
||||
writeTestInboxFile(inboxDir, 'message 1', 'https://example.com', '2024-06-15T10:30:00.000Z');
|
||||
writeTestInboxFile(inboxDir, 'message 2', 'https://example.com', '2024-06-15T10:31:00.000Z');
|
||||
|
||||
// Verify files exist
|
||||
const filesBefore = fs.readdirSync(inboxDir).filter(f => f.endsWith('.json') && !f.startsWith('.'));
|
||||
expect(filesBefore.length).toBe(2);
|
||||
|
||||
// Clear
|
||||
const cleared = clearInbox(inboxDir);
|
||||
expect(cleared).toBe(2);
|
||||
|
||||
// Verify files deleted
|
||||
const filesAfter = fs.readdirSync(inboxDir).filter(f => f.endsWith('.json') && !f.startsWith('.'));
|
||||
expect(filesAfter.length).toBe(0);
|
||||
});
|
||||
|
||||
test('clear on empty directory does nothing', () => {
|
||||
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
|
||||
fs.mkdirSync(inboxDir, { recursive: true });
|
||||
|
||||
const cleared = clearInbox(inboxDir);
|
||||
expect(cleared).toBe(0);
|
||||
});
|
||||
|
||||
test('clear preserves dotfiles', () => {
|
||||
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
|
||||
fs.mkdirSync(inboxDir, { recursive: true });
|
||||
|
||||
// Write a dotfile and a regular file
|
||||
fs.writeFileSync(path.join(inboxDir, '.keep'), '');
|
||||
writeTestInboxFile(inboxDir, 'to be cleared', 'https://example.com', '2024-06-15T10:30:00.000Z');
|
||||
|
||||
clearInbox(inboxDir);
|
||||
|
||||
// Dotfile should remain
|
||||
expect(fs.existsSync(path.join(inboxDir, '.keep'))).toBe(true);
|
||||
// Regular file should be gone
|
||||
const jsonFiles = fs.readdirSync(inboxDir).filter(f => f.endsWith('.json') && !f.startsWith('.'));
|
||||
expect(jsonFiles.length).toBe(0);
|
||||
});
|
||||
});
|
||||
Vendored
+30
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Test Page - Iframe</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; padding: 20px; }
|
||||
iframe { border: 1px solid #ccc; width: 400px; height: 200px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 id="main-title">Main Page</h1>
|
||||
<iframe id="test-frame" name="testframe" srcdoc='
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1 id="frame-title">Inside Frame</h1>
|
||||
<button id="frame-btn">Frame Button</button>
|
||||
<input id="frame-input" type="text" placeholder="Type here">
|
||||
<div id="frame-result"></div>
|
||||
<script>
|
||||
document.getElementById("frame-btn").addEventListener("click", () => {
|
||||
document.getElementById("frame-result").textContent = "Frame button clicked";
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
'></iframe>
|
||||
</body>
|
||||
</html>
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Test Page - Network Idle</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; padding: 20px; }
|
||||
#result { margin-top: 10px; color: green; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button id="fetch-btn">Load Data</button>
|
||||
<div id="result"></div>
|
||||
<button id="static-btn">Static Action</button>
|
||||
<div id="static-result"></div>
|
||||
<script>
|
||||
document.getElementById('fetch-btn').addEventListener('click', async () => {
|
||||
// Simulate an XHR that takes 200ms
|
||||
const res = await fetch('/echo');
|
||||
const data = await res.json();
|
||||
document.getElementById('result').textContent = 'Data loaded: ' + Object.keys(data).length + ' headers';
|
||||
});
|
||||
|
||||
document.getElementById('static-btn').addEventListener('click', () => {
|
||||
// No network activity — purely client-side
|
||||
document.getElementById('static-result').textContent = 'Static action done';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Tests for sidebar agent queue parsing and inbox writing.
|
||||
*
|
||||
* sidebar-agent.ts functions are not exported (it's an entry-point script),
|
||||
* so we test the same logic inline: JSONL parsing, writeToInbox filesystem
|
||||
* behavior, and edge cases.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
// ─── Helpers: replicate sidebar-agent logic for unit testing ──────
|
||||
|
||||
/** Parse a single JSONL line — same logic as sidebar-agent poll() */
|
||||
function parseQueueLine(line: string): any | null {
|
||||
if (!line.trim()) return null;
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (!entry.message && !entry.prompt) return null;
|
||||
return entry;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read all valid entries from a JSONL string — same as countLines + readLine loop */
|
||||
function parseQueueFile(content: string): any[] {
|
||||
const entries: any[] = [];
|
||||
const lines = content.split('\n').filter(Boolean);
|
||||
for (const line of lines) {
|
||||
const entry = parseQueueLine(line);
|
||||
if (entry) entries.push(entry);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Write to inbox — extracted logic from sidebar-agent.ts writeToInbox() */
|
||||
function writeToInbox(
|
||||
gitRoot: string,
|
||||
message: string,
|
||||
pageUrl?: string,
|
||||
sessionId?: string,
|
||||
): string | null {
|
||||
if (!gitRoot) return null;
|
||||
|
||||
const inboxDir = path.join(gitRoot, '.context', 'sidebar-inbox');
|
||||
fs.mkdirSync(inboxDir, { recursive: true });
|
||||
|
||||
const now = new Date();
|
||||
const timestamp = now.toISOString().replace(/:/g, '-');
|
||||
const filename = `${timestamp}-observation.json`;
|
||||
const tmpFile = path.join(inboxDir, `.${filename}.tmp`);
|
||||
const finalFile = path.join(inboxDir, filename);
|
||||
|
||||
const inboxMessage = {
|
||||
type: 'observation',
|
||||
timestamp: now.toISOString(),
|
||||
page: { url: pageUrl || 'unknown', title: '' },
|
||||
userMessage: message,
|
||||
sidebarSessionId: sessionId || 'unknown',
|
||||
};
|
||||
|
||||
fs.writeFileSync(tmpFile, JSON.stringify(inboxMessage, null, 2));
|
||||
fs.renameSync(tmpFile, finalFile);
|
||||
return finalFile;
|
||||
}
|
||||
|
||||
// ─── Test setup ──────────────────────────────────────────────────
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sidebar-agent-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─── Queue File Parsing ─────────────────────────────────────────
|
||||
|
||||
describe('queue file parsing', () => {
|
||||
test('valid JSONL line parsed correctly', () => {
|
||||
const line = JSON.stringify({ message: 'hello', prompt: 'check this', pageUrl: 'https://example.com' });
|
||||
const entry = parseQueueLine(line);
|
||||
expect(entry).not.toBeNull();
|
||||
expect(entry.message).toBe('hello');
|
||||
expect(entry.prompt).toBe('check this');
|
||||
expect(entry.pageUrl).toBe('https://example.com');
|
||||
});
|
||||
|
||||
test('malformed JSON line skipped without crash', () => {
|
||||
const entry = parseQueueLine('this is not json {{{');
|
||||
expect(entry).toBeNull();
|
||||
});
|
||||
|
||||
test('valid JSON without message or prompt is skipped', () => {
|
||||
const line = JSON.stringify({ foo: 'bar' });
|
||||
const entry = parseQueueLine(line);
|
||||
expect(entry).toBeNull();
|
||||
});
|
||||
|
||||
test('empty file returns no entries', () => {
|
||||
const entries = parseQueueFile('');
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
|
||||
test('file with blank lines returns no entries', () => {
|
||||
const entries = parseQueueFile('\n\n\n');
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
|
||||
test('mixed valid and invalid lines', () => {
|
||||
const content = [
|
||||
JSON.stringify({ message: 'first' }),
|
||||
'not json',
|
||||
JSON.stringify({ unrelated: true }),
|
||||
JSON.stringify({ message: 'second', prompt: 'do stuff' }),
|
||||
].join('\n');
|
||||
|
||||
const entries = parseQueueFile(content);
|
||||
expect(entries.length).toBe(2);
|
||||
expect(entries[0].message).toBe('first');
|
||||
expect(entries[1].message).toBe('second');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── writeToInbox ────────────────────────────────────────────────
|
||||
|
||||
describe('writeToInbox', () => {
|
||||
test('creates .context/sidebar-inbox/ directory', () => {
|
||||
writeToInbox(tmpDir, 'test message');
|
||||
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
|
||||
expect(fs.existsSync(inboxDir)).toBe(true);
|
||||
expect(fs.statSync(inboxDir).isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
test('writes valid JSON file', () => {
|
||||
const filePath = writeToInbox(tmpDir, 'test message', 'https://example.com', 'session-123');
|
||||
expect(filePath).not.toBeNull();
|
||||
expect(fs.existsSync(filePath!)).toBe(true);
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(filePath!, 'utf-8'));
|
||||
expect(data.type).toBe('observation');
|
||||
expect(data.userMessage).toBe('test message');
|
||||
expect(data.page.url).toBe('https://example.com');
|
||||
expect(data.sidebarSessionId).toBe('session-123');
|
||||
expect(data.timestamp).toBeTruthy();
|
||||
});
|
||||
|
||||
test('atomic write — final file exists, no .tmp left', () => {
|
||||
const filePath = writeToInbox(tmpDir, 'atomic test');
|
||||
expect(filePath).not.toBeNull();
|
||||
expect(fs.existsSync(filePath!)).toBe(true);
|
||||
|
||||
// Check no .tmp files remain in the inbox directory
|
||||
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
|
||||
const files = fs.readdirSync(inboxDir);
|
||||
const tmpFiles = files.filter(f => f.endsWith('.tmp'));
|
||||
expect(tmpFiles.length).toBe(0);
|
||||
|
||||
// Final file should end with -observation.json
|
||||
const jsonFiles = files.filter(f => f.endsWith('-observation.json') && !f.startsWith('.'));
|
||||
expect(jsonFiles.length).toBe(1);
|
||||
});
|
||||
|
||||
test('handles missing git root gracefully', () => {
|
||||
const result = writeToInbox('', 'test');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('defaults pageUrl to unknown when not provided', () => {
|
||||
const filePath = writeToInbox(tmpDir, 'no url provided');
|
||||
expect(filePath).not.toBeNull();
|
||||
const data = JSON.parse(fs.readFileSync(filePath!, 'utf-8'));
|
||||
expect(data.page.url).toBe('unknown');
|
||||
});
|
||||
|
||||
test('defaults sessionId to unknown when not provided', () => {
|
||||
const filePath = writeToInbox(tmpDir, 'no session');
|
||||
expect(filePath).not.toBeNull();
|
||||
const data = JSON.parse(fs.readFileSync(filePath!, 'utf-8'));
|
||||
expect(data.sidebarSessionId).toBe('unknown');
|
||||
});
|
||||
|
||||
test('multiple writes create separate files', () => {
|
||||
writeToInbox(tmpDir, 'message 1');
|
||||
// Tiny delay to ensure different timestamps
|
||||
const t = Date.now();
|
||||
while (Date.now() === t) {} // spin until next ms
|
||||
writeToInbox(tmpDir, 'message 2');
|
||||
|
||||
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
|
||||
const files = fs.readdirSync(inboxDir).filter(f => f.endsWith('.json') && !f.startsWith('.'));
|
||||
expect(files.length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Tests for watch mode state machine in BrowserManager.
|
||||
*
|
||||
* Pure unit tests — no browser needed. Just instantiate BrowserManager
|
||||
* and test the watch state methods (startWatch, stopWatch, addWatchSnapshot,
|
||||
* isWatching).
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { BrowserManager } from '../src/browser-manager';
|
||||
|
||||
describe('watch mode — state machine', () => {
|
||||
test('isWatching returns false by default', () => {
|
||||
const bm = new BrowserManager();
|
||||
expect(bm.isWatching()).toBe(false);
|
||||
});
|
||||
|
||||
test('startWatch sets isWatching to true', () => {
|
||||
const bm = new BrowserManager();
|
||||
bm.startWatch();
|
||||
expect(bm.isWatching()).toBe(true);
|
||||
});
|
||||
|
||||
test('stopWatch clears isWatching and returns snapshots', () => {
|
||||
const bm = new BrowserManager();
|
||||
bm.startWatch();
|
||||
bm.addWatchSnapshot('snapshot-1');
|
||||
bm.addWatchSnapshot('snapshot-2');
|
||||
|
||||
const result = bm.stopWatch();
|
||||
expect(bm.isWatching()).toBe(false);
|
||||
expect(result.snapshots).toEqual(['snapshot-1', 'snapshot-2']);
|
||||
expect(result.snapshots.length).toBe(2);
|
||||
});
|
||||
|
||||
test('stopWatch returns correct duration (approximately)', async () => {
|
||||
const bm = new BrowserManager();
|
||||
bm.startWatch();
|
||||
|
||||
// Wait ~50ms to get a measurable duration
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
const result = bm.stopWatch();
|
||||
// Duration should be at least 40ms (allowing for timer imprecision)
|
||||
expect(result.duration).toBeGreaterThanOrEqual(40);
|
||||
// And less than 5 seconds (sanity check)
|
||||
expect(result.duration).toBeLessThan(5000);
|
||||
});
|
||||
|
||||
test('addWatchSnapshot stores snapshots', () => {
|
||||
const bm = new BrowserManager();
|
||||
bm.startWatch();
|
||||
|
||||
bm.addWatchSnapshot('page A content');
|
||||
bm.addWatchSnapshot('page B content');
|
||||
bm.addWatchSnapshot('page C content');
|
||||
|
||||
const result = bm.stopWatch();
|
||||
expect(result.snapshots.length).toBe(3);
|
||||
expect(result.snapshots[0]).toBe('page A content');
|
||||
expect(result.snapshots[1]).toBe('page B content');
|
||||
expect(result.snapshots[2]).toBe('page C content');
|
||||
});
|
||||
|
||||
test('stopWatch resets snapshots for next cycle', () => {
|
||||
const bm = new BrowserManager();
|
||||
|
||||
// First cycle
|
||||
bm.startWatch();
|
||||
bm.addWatchSnapshot('first-cycle-snapshot');
|
||||
const result1 = bm.stopWatch();
|
||||
expect(result1.snapshots.length).toBe(1);
|
||||
|
||||
// Second cycle — should start fresh
|
||||
bm.startWatch();
|
||||
const result2 = bm.stopWatch();
|
||||
expect(result2.snapshots.length).toBe(0);
|
||||
});
|
||||
|
||||
test('multiple start/stop cycles work correctly', () => {
|
||||
const bm = new BrowserManager();
|
||||
|
||||
// Cycle 1
|
||||
bm.startWatch();
|
||||
expect(bm.isWatching()).toBe(true);
|
||||
bm.addWatchSnapshot('snap-1');
|
||||
const r1 = bm.stopWatch();
|
||||
expect(bm.isWatching()).toBe(false);
|
||||
expect(r1.snapshots).toEqual(['snap-1']);
|
||||
|
||||
// Cycle 2
|
||||
bm.startWatch();
|
||||
expect(bm.isWatching()).toBe(true);
|
||||
bm.addWatchSnapshot('snap-2a');
|
||||
bm.addWatchSnapshot('snap-2b');
|
||||
const r2 = bm.stopWatch();
|
||||
expect(bm.isWatching()).toBe(false);
|
||||
expect(r2.snapshots).toEqual(['snap-2a', 'snap-2b']);
|
||||
|
||||
// Cycle 3 — no snapshots added
|
||||
bm.startWatch();
|
||||
expect(bm.isWatching()).toBe(true);
|
||||
const r3 = bm.stopWatch();
|
||||
expect(bm.isWatching()).toBe(false);
|
||||
expect(r3.snapshots).toEqual([]);
|
||||
});
|
||||
|
||||
test('stopWatch clears watchInterval if set', () => {
|
||||
const bm = new BrowserManager();
|
||||
bm.startWatch();
|
||||
|
||||
// Simulate an interval being set (as the server does)
|
||||
bm.watchInterval = setInterval(() => {}, 100000);
|
||||
expect(bm.watchInterval).not.toBeNull();
|
||||
|
||||
bm.stopWatch();
|
||||
expect(bm.watchInterval).toBeNull();
|
||||
});
|
||||
|
||||
test('stopWatch without startWatch returns empty results', () => {
|
||||
const bm = new BrowserManager();
|
||||
|
||||
// Calling stopWatch without startWatch should not throw
|
||||
const result = bm.stopWatch();
|
||||
expect(result.snapshots).toEqual([]);
|
||||
expect(result.duration).toBeLessThanOrEqual(Date.now()); // duration = now - 0
|
||||
expect(bm.isWatching()).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user