mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-19 19:02:29 +08:00
feat: Phase 3.5 — cookie import, QA testing, team retro (v0.3.1) (#29)
* Phase 2: Enhanced browser — dialog handling, upload, state checks, snapshots - CircularBuffer O(1) ring buffer for console/network/dialog (was O(n) array+shift) - Async buffer flush with Bun.write() (was appendFileSync) - Dialog auto-accept/dismiss with buffer + prompt text support - File upload command (upload <sel> <file...>) - Element state checks (is visible/hidden/enabled/disabled/checked/editable/focused) - Annotated screenshots with ref labels overlaid (-a flag) - Snapshot diffing against previous snapshot (-D flag) - Cursor-interactive element scan for non-ARIA clickables (-C flag) - Snapshot scoping depth limit (-d N flag) - Health check with page.evaluate + 2s timeout - Playwright error wrapping — actionable messages for AI agents - Fix useragent — context recreation preserves cookies/storage/URLs - wait --networkidle / --load / --domcontentloaded flags - console --errors filter (error + warning only) - cookie-import <json-file> with auto-fill domain from page URL - 166 integration tests (was ~63) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Phase 2: Rewrite SKILL.md as QA playbook + command reference Reorient SKILL.md files from raw command reference to QA-first playbook with 10 workflow patterns (test user flows, verify deployments, dogfood features, responsive layouts, file upload, forms, dialogs, compare pages). Compact command reference tables at the bottom. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Phase 3: /qa skill — systematic QA testing with health scores New /qa skill for systematic web app QA testing. Three modes: - full: 5-10 documented issues with screenshots and repro steps - quick: 30-second smoke test with health score - regression: compare against saved baseline Includes issue taxonomy (7 categories, 4 severity levels), structured report template, health score rubric (weighted across 7 categories), framework detection guidance (Next.js, Rails, WordPress, SPA). Also adds browse/bin/find-browse (DRY binary discovery using git rev-parse), .gstack/ to .gitignore, and updated TODO roadmap. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Bump to v0.3.0 — Phase 2 + Phase 3 changelog Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: cookie-import-browser — Chromium cookie decryption module + tests Pure logic module for reading and decrypting cookies from macOS Chromium browsers (Comet, Chrome, Arc, Brave, Edge). Supports v10 AES-128-CBC encryption with macOS Keychain access, PBKDF2 key derivation, and per-browser key caching. 18 unit tests with encrypted cookie fixtures. * feat: cookie picker web UI + route handler Two-panel dark-theme picker served from the browse server. Left panel shows source browser domains with search and import buttons. Right panel shows imported domains with trash buttons. No cookie values exposed. 6 API endpoints, importedDomains Set tracking, inline clearCookies. * feat: wire cookie-import-browser into browse server Add cookie-picker route dispatch (no auth, localhost-only), add cookie-import-browser to WRITE_COMMANDS and CHAIN_WRITE, add serverPort property to BrowserManager, add write command with two modes (picker UI vs --domain direct import), update CLI help text. * chore: /setup-browser-cookies skill + docs (Phase 3.5) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: bump version and changelog (v0.3.1) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * security: redact sensitive values from command output (PR #21) type no longer echoes text (reports character count), cookie redacts value with ****, header redacts Authorization/Cookie/X-API-Key/X-Auth-Token, storage set drops value, forms redacts password fields. Prevents secrets from persisting in LLM transcripts. 7 new tests. Credit: fredluz (PR #21) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * security: path traversal prevention for screenshot/pdf/eval (PR #26) Add validateOutputPath() for screenshot/pdf/responsive (restricts to /tmp and cwd) and validateReadPath() for eval (blocks .. sequences and absolute paths outside safe dirs). 7 new tests. Credit: Jah-yee (PR #26) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: auto-install Playwright Chromium in setup (PR #22) Setup now verifies Playwright can launch Chromium, and auto-installs it via `bunx playwright install chromium` if missing. Exits non-zero if build or Chromium launch fails. Credit: AkbarDevop (PR #22) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * security: fix path validation bypass, CORS restriction, cookie-import path check - startsWith('/tmp') matched '/tmpevil' — now requires trailing slash - CORS Access-Control-Allow-Origin changed from * to http://127.0.0.1:<port> - cookie-import now validates file paths (was missing validateReadPath) - 3 new tests for prefix collision and cookie-import path traversal Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address review informational issues + add regression tests - Add cookie-import to CHAIN_WRITE set for chain command routing - Add path validation to snapshot -a -o output path - Fix package.json version to match 0.3.1 - Use crypto.randomUUID() for temp DB paths (unpredictable filenames) - Add regression tests for chain cookie-import and snapshot path validation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add /qa, /setup-browser-cookies to README + update BROWSER.md - Add /qa and /setup-browser-cookies to skills table, install/update/uninstall blurbs - Add dedicated README sections for both new skills with usage examples - Update demo workflow to show cookie import → QA → browse flow - Update BROWSER.md: cookie import commands, new source files, test count (203) - Update skill count from 6 to 8 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: team-aware /retro v2.0 — per-person praise and growth opportunities - Identify current user via git config, orient narrative as "you" vs teammates - Add per-author metrics: commits, LOC, focus areas, commit type mix, sessions - New "Your Week" section with personal deep-dive for whoever runs the command - New "Team Breakdown" with per-person praise and growth opportunities - Track AI-assisted commits via Co-Authored-By trailers - Personal + team shipping streaks - Tone: praise like a 1:1, growth like investment advice, never compare negatively Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add Conductor parallel sessions section to README Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Architecture:
|
||||
* Bun.serve HTTP on localhost → routes commands to Playwright
|
||||
* Console/network buffers: in-memory (all entries) + disk flush every 1s
|
||||
* Console/network/dialog buffers: CircularBuffer in-memory + async disk flush
|
||||
* Chromium crash → server EXITS with clear error (CLI auto-restarts)
|
||||
* Auto-shutdown after BROWSE_IDLE_TIMEOUT (default 30 min)
|
||||
*/
|
||||
@@ -12,6 +12,7 @@ import { BrowserManager } from './browser-manager';
|
||||
import { handleReadCommand } from './read-commands';
|
||||
import { handleWriteCommand } from './write-commands';
|
||||
import { handleMetaCommand } from './meta-commands';
|
||||
import { handleCookiePickerRoute } from './cookie-picker-routes';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
@@ -32,36 +33,58 @@ function validateAuth(req: Request): boolean {
|
||||
}
|
||||
|
||||
// ─── Buffer (from buffers.ts) ────────────────────────────────────
|
||||
import { consoleBuffer, networkBuffer, addConsoleEntry, addNetworkEntry, consoleTotalAdded, networkTotalAdded, type LogEntry, type NetworkEntry } from './buffers';
|
||||
export { consoleBuffer, networkBuffer, addConsoleEntry, addNetworkEntry, type LogEntry, type NetworkEntry };
|
||||
import { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, type LogEntry, type NetworkEntry, type DialogEntry } from './buffers';
|
||||
export { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, type LogEntry, type NetworkEntry, type DialogEntry };
|
||||
|
||||
const CONSOLE_LOG_PATH = `/tmp/browse-console${INSTANCE_SUFFIX}.log`;
|
||||
const NETWORK_LOG_PATH = `/tmp/browse-network${INSTANCE_SUFFIX}.log`;
|
||||
const DIALOG_LOG_PATH = `/tmp/browse-dialog${INSTANCE_SUFFIX}.log`;
|
||||
let lastConsoleFlushed = 0;
|
||||
let lastNetworkFlushed = 0;
|
||||
let lastDialogFlushed = 0;
|
||||
let flushInProgress = false;
|
||||
|
||||
function flushBuffers() {
|
||||
// Use totalAdded cursor (not buffer.length) because the ring buffer
|
||||
// stays pinned at HIGH_WATER_MARK after wrapping.
|
||||
const newConsoleCount = consoleTotalAdded - lastConsoleFlushed;
|
||||
if (newConsoleCount > 0) {
|
||||
const count = Math.min(newConsoleCount, consoleBuffer.length);
|
||||
const newEntries = consoleBuffer.slice(-count);
|
||||
const lines = newEntries.map(e =>
|
||||
`[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}`
|
||||
).join('\n') + '\n';
|
||||
fs.appendFileSync(CONSOLE_LOG_PATH, lines);
|
||||
lastConsoleFlushed = consoleTotalAdded;
|
||||
}
|
||||
async function flushBuffers() {
|
||||
if (flushInProgress) return; // Guard against concurrent flush
|
||||
flushInProgress = true;
|
||||
|
||||
const newNetworkCount = networkTotalAdded - lastNetworkFlushed;
|
||||
if (newNetworkCount > 0) {
|
||||
const count = Math.min(newNetworkCount, networkBuffer.length);
|
||||
const newEntries = networkBuffer.slice(-count);
|
||||
const lines = newEntries.map(e =>
|
||||
`[${new Date(e.timestamp).toISOString()}] ${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)`
|
||||
).join('\n') + '\n';
|
||||
fs.appendFileSync(NETWORK_LOG_PATH, lines);
|
||||
lastNetworkFlushed = networkTotalAdded;
|
||||
try {
|
||||
// Console buffer
|
||||
const newConsoleCount = consoleBuffer.totalAdded - lastConsoleFlushed;
|
||||
if (newConsoleCount > 0) {
|
||||
const entries = consoleBuffer.last(Math.min(newConsoleCount, consoleBuffer.length));
|
||||
const lines = entries.map(e =>
|
||||
`[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}`
|
||||
).join('\n') + '\n';
|
||||
await Bun.write(CONSOLE_LOG_PATH, (await Bun.file(CONSOLE_LOG_PATH).text().catch(() => '')) + lines);
|
||||
lastConsoleFlushed = consoleBuffer.totalAdded;
|
||||
}
|
||||
|
||||
// Network buffer
|
||||
const newNetworkCount = networkBuffer.totalAdded - lastNetworkFlushed;
|
||||
if (newNetworkCount > 0) {
|
||||
const entries = networkBuffer.last(Math.min(newNetworkCount, networkBuffer.length));
|
||||
const lines = entries.map(e =>
|
||||
`[${new Date(e.timestamp).toISOString()}] ${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)`
|
||||
).join('\n') + '\n';
|
||||
await Bun.write(NETWORK_LOG_PATH, (await Bun.file(NETWORK_LOG_PATH).text().catch(() => '')) + lines);
|
||||
lastNetworkFlushed = networkBuffer.totalAdded;
|
||||
}
|
||||
|
||||
// Dialog buffer
|
||||
const newDialogCount = dialogBuffer.totalAdded - lastDialogFlushed;
|
||||
if (newDialogCount > 0) {
|
||||
const entries = dialogBuffer.last(Math.min(newDialogCount, dialogBuffer.length));
|
||||
const lines = entries.map(e =>
|
||||
`[${new Date(e.timestamp).toISOString()}] [${e.type}] "${e.message}" → ${e.action}${e.response ? ` "${e.response}"` : ''}`
|
||||
).join('\n') + '\n';
|
||||
await Bun.write(DIALOG_LOG_PATH, (await Bun.file(DIALOG_LOG_PATH).text().catch(() => '')) + lines);
|
||||
lastDialogFlushed = dialogBuffer.totalAdded;
|
||||
}
|
||||
} catch {
|
||||
// Flush failures are non-fatal — buffers are in memory
|
||||
} finally {
|
||||
flushInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,24 +105,22 @@ const idleCheckInterval = setInterval(() => {
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
// ─── Server ────────────────────────────────────────────────────
|
||||
const browserManager = new BrowserManager();
|
||||
let isShuttingDown = false;
|
||||
|
||||
// Read/write/meta command sets for routing
|
||||
const READ_COMMANDS = new Set([
|
||||
// ─── Command Sets (exported for chain command) ──────────────────
|
||||
export const READ_COMMANDS = new Set([
|
||||
'text', 'html', 'links', 'forms', 'accessibility',
|
||||
'js', 'eval', 'css', 'attrs',
|
||||
'console', 'network', 'cookies', 'storage', 'perf',
|
||||
'dialog', 'is',
|
||||
]);
|
||||
|
||||
const WRITE_COMMANDS = new Set([
|
||||
export const WRITE_COMMANDS = new Set([
|
||||
'goto', 'back', 'forward', 'reload',
|
||||
'click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait',
|
||||
'viewport', 'cookie', 'header', 'useragent',
|
||||
'viewport', 'cookie', 'cookie-import', 'cookie-import-browser', 'header', 'useragent',
|
||||
'upload', 'dialog-accept', 'dialog-dismiss',
|
||||
]);
|
||||
|
||||
const META_COMMANDS = new Set([
|
||||
export const META_COMMANDS = new Set([
|
||||
'tabs', 'tab', 'newtab', 'closetab',
|
||||
'status', 'stop', 'restart',
|
||||
'screenshot', 'pdf', 'responsive',
|
||||
@@ -107,6 +128,10 @@ const META_COMMANDS = new Set([
|
||||
'url', 'snapshot',
|
||||
]);
|
||||
|
||||
// ─── Server ────────────────────────────────────────────────────
|
||||
const browserManager = new BrowserManager();
|
||||
let isShuttingDown = false;
|
||||
|
||||
// Find port: deterministic from CONDUCTOR_PORT, or scan range
|
||||
async function findPort(): Promise<number> {
|
||||
// Deterministic port from CONDUCTOR_PORT (e.g., 55040 - 45600 = 9440)
|
||||
@@ -134,6 +159,29 @@ async function findPort(): Promise<number> {
|
||||
throw new Error(`[browse] No available port in range ${start}-${start + 9}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate Playwright errors into actionable messages for AI agents.
|
||||
*/
|
||||
function wrapError(err: any): string {
|
||||
const msg = err.message || String(err);
|
||||
// Timeout errors
|
||||
if (err.name === 'TimeoutError' || msg.includes('Timeout') || msg.includes('timeout')) {
|
||||
if (msg.includes('locator.click') || msg.includes('locator.fill') || msg.includes('locator.hover')) {
|
||||
return `Element not found or not interactable within timeout. Check your selector or run 'snapshot' for fresh refs.`;
|
||||
}
|
||||
if (msg.includes('page.goto') || msg.includes('Navigation')) {
|
||||
return `Page navigation timed out. The URL may be unreachable or the page may be loading slowly.`;
|
||||
}
|
||||
return `Operation timed out: ${msg.split('\n')[0]}`;
|
||||
}
|
||||
// Multiple elements matched
|
||||
if (msg.includes('resolved to') && msg.includes('elements')) {
|
||||
return `Selector matched multiple elements. Be more specific or use @refs from 'snapshot'.`;
|
||||
}
|
||||
// Pass through other errors
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function handleCommand(body: any): Promise<Response> {
|
||||
const { command, args = [] } = body;
|
||||
|
||||
@@ -168,7 +216,7 @@ async function handleCommand(body: any): Promise<Response> {
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
} catch (err: any) {
|
||||
return new Response(JSON.stringify({ error: err.message }), {
|
||||
return new Response(JSON.stringify({ error: wrapError(err) }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
@@ -182,7 +230,7 @@ async function shutdown() {
|
||||
console.log('[browse] Shutting down...');
|
||||
clearInterval(flushInterval);
|
||||
clearInterval(idleCheckInterval);
|
||||
flushBuffers(); // Final flush
|
||||
await flushBuffers(); // Final flush (async now)
|
||||
|
||||
await browserManager.close();
|
||||
|
||||
@@ -201,6 +249,7 @@ async function start() {
|
||||
// Clear old log files
|
||||
try { fs.unlinkSync(CONSOLE_LOG_PATH); } catch {}
|
||||
try { fs.unlinkSync(NETWORK_LOG_PATH); } catch {}
|
||||
try { fs.unlinkSync(DIALOG_LOG_PATH); } catch {}
|
||||
|
||||
const port = await findPort();
|
||||
|
||||
@@ -216,9 +265,14 @@ async function start() {
|
||||
|
||||
const url = new URL(req.url);
|
||||
|
||||
// Health check — no auth required
|
||||
// Cookie picker routes — no auth required (localhost-only)
|
||||
if (url.pathname.startsWith('/cookie-picker')) {
|
||||
return handleCookiePickerRoute(url, req, browserManager);
|
||||
}
|
||||
|
||||
// Health check — no auth required (now async)
|
||||
if (url.pathname === '/health') {
|
||||
const healthy = browserManager.isHealthy();
|
||||
const healthy = await browserManager.isHealthy();
|
||||
return new Response(JSON.stringify({
|
||||
status: healthy ? 'healthy' : 'unhealthy',
|
||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||
@@ -257,6 +311,7 @@ async function start() {
|
||||
};
|
||||
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), { mode: 0o600 });
|
||||
|
||||
browserManager.serverPort = port;
|
||||
console.log(`[browse] Server running on http://127.0.0.1:${port} (PID: ${process.pid})`);
|
||||
console.log(`[browse] State file: ${STATE_FILE}`);
|
||||
console.log(`[browse] Idle timeout: ${IDLE_TIMEOUT_MS / 1000}s`);
|
||||
|
||||
Reference in New Issue
Block a user