mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-01 19:25:10 +02:00
f7b95329c1
* 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>
222 lines
7.5 KiB
TypeScript
222 lines
7.5 KiB
TypeScript
/**
|
|
* Meta commands — tabs, server control, screenshots, chain, diff, snapshot
|
|
*/
|
|
|
|
import type { BrowserManager } from './browser-manager';
|
|
import { handleSnapshot } from './snapshot';
|
|
import { getCleanText } from './read-commands';
|
|
import * as Diff from 'diff';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
// Security: Path validation to prevent path traversal attacks
|
|
const SAFE_DIRECTORIES = ['/tmp', process.cwd()];
|
|
|
|
function validateOutputPath(filePath: string): void {
|
|
const resolved = path.resolve(filePath);
|
|
const isSafe = SAFE_DIRECTORIES.some(dir => resolved === dir || resolved.startsWith(dir + '/'));
|
|
if (!isSafe) {
|
|
throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
|
|
}
|
|
}
|
|
|
|
// Command sets for chain routing (mirrors server.ts — kept local to avoid circular import)
|
|
const CHAIN_READ = new Set([
|
|
'text', 'html', 'links', 'forms', 'accessibility',
|
|
'js', 'eval', 'css', 'attrs',
|
|
'console', 'network', 'cookies', 'storage', 'perf',
|
|
'dialog', 'is',
|
|
]);
|
|
const CHAIN_WRITE = new Set([
|
|
'goto', 'back', 'forward', 'reload',
|
|
'click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait',
|
|
'viewport', 'cookie', 'cookie-import', 'header', 'useragent',
|
|
'upload', 'dialog-accept', 'dialog-dismiss',
|
|
'cookie-import-browser',
|
|
]);
|
|
const CHAIN_META = new Set([
|
|
'tabs', 'tab', 'newtab', 'closetab',
|
|
'status', 'stop', 'restart',
|
|
'screenshot', 'pdf', 'responsive',
|
|
'chain', 'diff',
|
|
'url', 'snapshot',
|
|
]);
|
|
|
|
export async function handleMetaCommand(
|
|
command: string,
|
|
args: string[],
|
|
bm: BrowserManager,
|
|
shutdown: () => Promise<void> | void
|
|
): Promise<string> {
|
|
switch (command) {
|
|
// ─── Tabs ──────────────────────────────────────────
|
|
case 'tabs': {
|
|
const tabs = await bm.getTabListWithTitles();
|
|
return tabs.map(t =>
|
|
`${t.active ? '→ ' : ' '}[${t.id}] ${t.title || '(untitled)'} — ${t.url}`
|
|
).join('\n');
|
|
}
|
|
|
|
case 'tab': {
|
|
const id = parseInt(args[0], 10);
|
|
if (isNaN(id)) throw new Error('Usage: browse tab <id>');
|
|
bm.switchTab(id);
|
|
return `Switched to tab ${id}`;
|
|
}
|
|
|
|
case 'newtab': {
|
|
const url = args[0];
|
|
const id = await bm.newTab(url);
|
|
return `Opened tab ${id}${url ? ` → ${url}` : ''}`;
|
|
}
|
|
|
|
case 'closetab': {
|
|
const id = args[0] ? parseInt(args[0], 10) : undefined;
|
|
await bm.closeTab(id);
|
|
return `Closed tab${id ? ` ${id}` : ''}`;
|
|
}
|
|
|
|
// ─── Server Control ────────────────────────────────
|
|
case 'status': {
|
|
const page = bm.getPage();
|
|
const tabs = bm.getTabCount();
|
|
return [
|
|
`Status: healthy`,
|
|
`URL: ${page.url()}`,
|
|
`Tabs: ${tabs}`,
|
|
`PID: ${process.pid}`,
|
|
].join('\n');
|
|
}
|
|
|
|
case 'url': {
|
|
return bm.getCurrentUrl();
|
|
}
|
|
|
|
case 'stop': {
|
|
await shutdown();
|
|
return 'Server stopped';
|
|
}
|
|
|
|
case 'restart': {
|
|
// Signal that we want a restart — the CLI will detect exit and restart
|
|
console.log('[browse] Restart requested. Exiting for CLI to restart.');
|
|
await shutdown();
|
|
return 'Restarting...';
|
|
}
|
|
|
|
// ─── Visual ────────────────────────────────────────
|
|
case 'screenshot': {
|
|
const page = bm.getPage();
|
|
const screenshotPath = args[0] || '/tmp/browse-screenshot.png';
|
|
validateOutputPath(screenshotPath);
|
|
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
return `Screenshot saved: ${screenshotPath}`;
|
|
}
|
|
|
|
case 'pdf': {
|
|
const page = bm.getPage();
|
|
const pdfPath = args[0] || '/tmp/browse-page.pdf';
|
|
validateOutputPath(pdfPath);
|
|
await page.pdf({ path: pdfPath, format: 'A4' });
|
|
return `PDF saved: ${pdfPath}`;
|
|
}
|
|
|
|
case 'responsive': {
|
|
const page = bm.getPage();
|
|
const prefix = args[0] || '/tmp/browse-responsive';
|
|
validateOutputPath(prefix);
|
|
const viewports = [
|
|
{ name: 'mobile', width: 375, height: 812 },
|
|
{ name: 'tablet', width: 768, height: 1024 },
|
|
{ name: 'desktop', width: 1280, height: 720 },
|
|
];
|
|
const originalViewport = page.viewportSize();
|
|
const results: string[] = [];
|
|
|
|
for (const vp of viewports) {
|
|
await page.setViewportSize({ width: vp.width, height: vp.height });
|
|
const path = `${prefix}-${vp.name}.png`;
|
|
await page.screenshot({ path, fullPage: true });
|
|
results.push(`${vp.name} (${vp.width}x${vp.height}): ${path}`);
|
|
}
|
|
|
|
// Restore original viewport
|
|
if (originalViewport) {
|
|
await page.setViewportSize(originalViewport);
|
|
}
|
|
|
|
return results.join('\n');
|
|
}
|
|
|
|
// ─── Chain ─────────────────────────────────────────
|
|
case 'chain': {
|
|
// Read JSON array from args[0] (if provided) or expect it was passed as body
|
|
const jsonStr = args[0];
|
|
if (!jsonStr) throw new Error('Usage: echo \'[["goto","url"],["text"]]\' | browse chain');
|
|
|
|
let commands: string[][];
|
|
try {
|
|
commands = JSON.parse(jsonStr);
|
|
} catch {
|
|
throw new Error('Invalid JSON. Expected: [["command", "arg1", "arg2"], ...]');
|
|
}
|
|
|
|
if (!Array.isArray(commands)) throw new Error('Expected JSON array of commands');
|
|
|
|
const results: string[] = [];
|
|
const { handleReadCommand } = await import('./read-commands');
|
|
const { handleWriteCommand } = await import('./write-commands');
|
|
|
|
for (const cmd of commands) {
|
|
const [name, ...cmdArgs] = cmd;
|
|
try {
|
|
let result: string;
|
|
if (CHAIN_WRITE.has(name)) result = await handleWriteCommand(name, cmdArgs, bm);
|
|
else if (CHAIN_READ.has(name)) result = await handleReadCommand(name, cmdArgs, bm);
|
|
else if (CHAIN_META.has(name)) result = await handleMetaCommand(name, cmdArgs, bm, shutdown);
|
|
else throw new Error(`Unknown command: ${name}`);
|
|
results.push(`[${name}] ${result}`);
|
|
} catch (err: any) {
|
|
results.push(`[${name}] ERROR: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
return results.join('\n\n');
|
|
}
|
|
|
|
// ─── Diff ──────────────────────────────────────────
|
|
case 'diff': {
|
|
const [url1, url2] = args;
|
|
if (!url1 || !url2) throw new Error('Usage: browse diff <url1> <url2>');
|
|
|
|
const page = bm.getPage();
|
|
await page.goto(url1, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
const text1 = await getCleanText(page);
|
|
|
|
await page.goto(url2, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
const text2 = await getCleanText(page);
|
|
|
|
const changes = Diff.diffLines(text1, text2);
|
|
const output: string[] = [`--- ${url1}`, `+++ ${url2}`, ''];
|
|
|
|
for (const part of changes) {
|
|
const prefix = part.added ? '+' : part.removed ? '-' : ' ';
|
|
const lines = part.value.split('\n').filter(l => l.length > 0);
|
|
for (const line of lines) {
|
|
output.push(`${prefix} ${line}`);
|
|
}
|
|
}
|
|
|
|
return output.join('\n');
|
|
}
|
|
|
|
// ─── Snapshot ─────────────────────────────────────
|
|
case 'snapshot': {
|
|
return await handleSnapshot(args, bm);
|
|
}
|
|
|
|
default:
|
|
throw new Error(`Unknown meta command: ${command}`);
|
|
}
|
|
}
|