diff --git a/.gitignore b/.gitignore index 2d3e850..ef0aeaf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ browse/dist/ +.gstack/ /tmp/ *.log bun.lock diff --git a/BROWSER.md b/BROWSER.md index 570f1dd..0f52b41 100644 --- a/BROWSER.md +++ b/BROWSER.md @@ -8,15 +8,17 @@ This document covers the command reference and internals of gstack's headless br |----------|----------|----------| | Navigate | `goto`, `back`, `forward`, `reload`, `url` | Get to a page | | Read | `text`, `html`, `links`, `forms`, `accessibility` | Extract content | -| Snapshot | `snapshot [-i] [-c] [-d N] [-s sel]` | Get refs for interaction | -| Interact | `click`, `fill`, `select`, `hover`, `type`, `press`, `scroll`, `wait`, `viewport` | Use the page | -| Inspect | `js`, `eval`, `css`, `attrs`, `console`, `network`, `cookies`, `storage`, `perf` | Debug and verify | +| Snapshot | `snapshot [-i] [-c] [-d N] [-s sel] [-D] [-a] [-o] [-C]` | Get refs, diff, annotate | +| Interact | `click`, `fill`, `select`, `hover`, `type`, `press`, `scroll`, `wait`, `viewport`, `upload` | Use the page | +| Inspect | `js`, `eval`, `css`, `attrs`, `is`, `console`, `network`, `dialog`, `cookies`, `storage`, `perf` | Debug and verify | | Visual | `screenshot`, `pdf`, `responsive` | See what Claude sees | | Compare | `diff ` | Spot differences between environments | +| Dialogs | `dialog-accept [text]`, `dialog-dismiss` | Control alert/confirm/prompt handling | | Tabs | `tabs`, `tab`, `newtab`, `closetab` | Multi-page workflows | +| Cookies | `cookie-import`, `cookie-import-browser` | Import cookies from file or real browser | | Multi-step | `chain` (JSON from stdin) | Batch commands in one call | -All selector arguments accept CSS selectors or `@ref` after `snapshot`. 40+ commands total. +All selector arguments accept CSS selectors, `@e` refs after `snapshot`, or `@c` refs after `snapshot -C`. 50+ commands total plus cookie import. ## How it works @@ -60,11 +62,14 @@ browse/ │ ├── cli.ts # Thin client — reads state file, sends HTTP, prints response │ ├── server.ts # Bun.serve HTTP server — routes commands to Playwright │ ├── browser-manager.ts # Chromium lifecycle — launch, tabs, ref map, crash handling -│ ├── snapshot.ts # Accessibility tree → @ref assignment → Locator map -│ ├── read-commands.ts # Non-mutating commands (text, html, links, js, css, etc.) -│ ├── write-commands.ts # Mutating commands (click, fill, select, navigate, etc.) -│ ├── meta-commands.ts # Server management (status, stop, restart) -│ └── buffers.ts # Console + network log capture (in-memory + disk flush) +│ ├── snapshot.ts # Accessibility tree → @ref assignment → Locator map + diff/annotate/-C +│ ├── read-commands.ts # Non-mutating commands (text, html, links, js, css, is, dialog, etc.) +│ ├── write-commands.ts # Mutating commands (click, fill, select, upload, dialog-accept, etc.) +│ ├── meta-commands.ts # Server management, chain, diff, snapshot routing +│ ├── cookie-import-browser.ts # Decrypt + import cookies from real Chromium browsers +│ ├── cookie-picker-routes.ts # HTTP routes for interactive cookie picker UI +│ ├── cookie-picker-ui.ts # Self-contained HTML/CSS/JS for cookie picker +│ └── buffers.ts # CircularBuffer + console/network/dialog capture ├── test/ # Integration tests + HTML fixtures └── dist/ └── browse # Compiled binary (~58MB, Bun --compile) @@ -82,18 +87,28 @@ The browser's key innovation is ref-based element selection, built on Playwright No DOM mutation. No injected scripts. Just Playwright's native accessibility API. +**Extended snapshot features:** +- `--diff` (`-D`): Stores each snapshot as a baseline. On the next `-D` call, returns a unified diff showing what changed. Use this to verify that an action (click, fill, etc.) actually worked. +- `--annotate` (`-a`): Injects temporary overlay divs at each ref's bounding box, takes a screenshot with ref labels visible, then removes the overlays. Use `-o ` to control the output path. +- `--cursor-interactive` (`-C`): Scans for non-ARIA interactive elements (divs with `cursor:pointer`, `onclick`, `tabindex>=0`) using `page.evaluate`. Assigns `@c1`, `@c2`... refs with deterministic `nth-child` CSS selectors. These are elements the ARIA tree misses but users can still click. + ### Authentication Each server session generates a random UUID as a bearer token. The token is written to the state file (`/tmp/browse-server.json`) with chmod 600. Every HTTP request must include `Authorization: Bearer `. This prevents other processes on the machine from controlling the browser. -### Console and network capture +### Console, network, and dialog capture -The server hooks into Playwright's `page.on('console')` and `page.on('response')` events. All entries are kept in memory and flushed to disk every second: +The server hooks into Playwright's `page.on('console')`, `page.on('response')`, and `page.on('dialog')` events. All entries are kept in O(1) circular buffers (50,000 capacity each) and flushed to disk asynchronously via `Bun.write()`: - Console: `/tmp/browse-console.log` - Network: `/tmp/browse-network.log` +- Dialog: `/tmp/browse-dialog.log` + +The `console`, `network`, and `dialog` commands read from the in-memory buffers, not disk. + +### Dialog handling -The `console` and `network` commands read from the in-memory buffers, not disk. +Dialogs (alert, confirm, prompt) are auto-accepted by default to prevent browser lockup. The `dialog-accept` and `dialog-dismiss` commands control this behavior. For prompts, `dialog-accept ` provides the response text. All dialogs are logged to the dialog buffer with type, message, and action taken. ### Multi-workspace support @@ -180,11 +195,12 @@ The compiled binary (`bun run build`) is only needed for distribution. It produc ```bash bun test # run all tests -bun test browse/test/commands # run command integration tests only -bun test browse/test/snapshot # run snapshot tests only +bun test browse/test/commands # run command integration tests only +bun test browse/test/snapshot # run snapshot tests only +bun test browse/test/cookie-import-browser # run cookie import unit tests only ``` -Tests spin up a local HTTP server (`browse/test/test-server.ts`) serving HTML fixtures from `browse/test/fixtures/`, then exercise the CLI commands against those pages. Tests take ~3 seconds. +Tests spin up a local HTTP server (`browse/test/test-server.ts`) serving HTML fixtures from `browse/test/fixtures/`, then exercise the CLI commands against those pages. 203 tests across 3 files, ~15 seconds total. ### Source map @@ -193,11 +209,14 @@ Tests spin up a local HTTP server (`browse/test/test-server.ts`) serving HTML fi | `browse/src/cli.ts` | Entry point. Reads `/tmp/browse-server.json`, sends HTTP to the server, prints response. | | `browse/src/server.ts` | Bun HTTP server. Routes commands to the right handler. Manages idle timeout. | | `browse/src/browser-manager.ts` | Chromium lifecycle — launch, tab management, ref map, crash detection. | -| `browse/src/snapshot.ts` | Parses Playwright's accessibility tree, assigns `@ref` labels, builds Locator map. | -| `browse/src/read-commands.ts` | Non-mutating commands: `text`, `html`, `links`, `js`, `css`, `forms`, etc. | -| `browse/src/write-commands.ts` | Mutating commands: `goto`, `click`, `fill`, `select`, `scroll`, etc. | -| `browse/src/meta-commands.ts` | Server management: `status`, `stop`, `restart`. | -| `browse/src/buffers.ts` | In-memory + disk capture for console and network logs. | +| `browse/src/snapshot.ts` | Parses accessibility tree, assigns `@e`/`@c` refs, builds Locator map. Handles `--diff`, `--annotate`, `-C`. | +| `browse/src/read-commands.ts` | Non-mutating commands: `text`, `html`, `links`, `js`, `css`, `is`, `dialog`, `forms`, etc. Exports `getCleanText()`. | +| `browse/src/write-commands.ts` | Mutating commands: `goto`, `click`, `fill`, `upload`, `dialog-accept`, `useragent` (with context recreation), etc. | +| `browse/src/meta-commands.ts` | Server management, chain routing, diff (DRY via `getCleanText`), snapshot delegation. | +| `browse/src/cookie-import-browser.ts` | Decrypt Chromium cookies via macOS Keychain + PBKDF2/AES-128-CBC. Auto-detects installed browsers. | +| `browse/src/cookie-picker-routes.ts` | HTTP routes for `/cookie-picker/*` — browser list, domain search, import, remove. | +| `browse/src/cookie-picker-ui.ts` | Self-contained HTML generator for the interactive cookie picker (dark theme, no frameworks). | +| `browse/src/buffers.ts` | `CircularBuffer` (O(1) ring buffer) + console/network/dialog capture with async disk flush. | ### Deploying to the active skill diff --git a/CHANGELOG.md b/CHANGELOG.md index 677849f..49a7caa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,48 @@ # Changelog +## 0.3.1 — 2026-03-12 + +### Phase 3.5: Browser cookie import + +- `cookie-import-browser` command — decrypt and import cookies from real Chromium browsers (Comet, Chrome, Arc, Brave, Edge) +- Interactive cookie picker web UI served from the browse server (dark theme, two-panel layout, domain search, import/remove) +- Direct CLI import with `--domain` flag for non-interactive use +- `/setup-browser-cookies` skill for Claude Code integration +- macOS Keychain access with async 10s timeout (no event loop blocking) +- Per-browser AES key caching (one Keychain prompt per browser per session) +- DB lock fallback: copies locked cookie DB to /tmp for safe reads +- 18 unit tests with encrypted cookie fixtures + +## 0.3.0 — 2026-03-12 + +### Phase 3: /qa skill — systematic QA testing + +- New `/qa` skill with 6-phase workflow (Initialize, Authenticate, Orient, Explore, Document, Wrap up) +- Three modes: full (systematic, 5-10 issues), quick (30-second smoke test), regression (compare against baseline) +- Issue taxonomy: 7 categories, 4 severity levels, per-page exploration checklist +- Structured report template with health score (0-100, weighted across 7 categories) +- Framework detection guidance for Next.js, Rails, WordPress, and SPAs +- `browse/bin/find-browse` — DRY binary discovery using `git rev-parse --show-toplevel` + +### Phase 2: Enhanced browser + +- Dialog handling: auto-accept/dismiss, dialog buffer, prompt text support +- File upload: `upload [file2...]` +- Element state checks: `is visible|hidden|enabled|disabled|checked|editable|focused ` +- Annotated screenshots with ref labels overlaid (`snapshot -a`) +- Snapshot diffing against previous snapshot (`snapshot -D`) +- Cursor-interactive element scan for non-ARIA clickables (`snapshot -C`) +- `wait --networkidle` / `--load` / `--domcontentloaded` flags +- `console --errors` filter (error + warning only) +- `cookie-import ` with auto-fill domain from page URL +- CircularBuffer O(1) ring buffer for console/network/dialog buffers +- Async buffer flush with Bun.write() +- Health check with page.evaluate + 2s timeout +- Playwright error wrapping — actionable messages for AI agents +- Context recreation preserves cookies/storage/URLs (useragent fix) +- SKILL.md rewritten as QA-oriented playbook with 10 workflow patterns +- 166 integration tests (was ~63) + ## 0.0.2 — 2026-03-12 - Fix project-local `/browse` installs — compiled binary now resolves `server.ts` from its own directory instead of assuming a global install exists diff --git a/README.md b/README.md index f1d6329..6bb7d5c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **gstack turns Claude Code from one generic assistant into a team of specialists you can summon on demand.** -Six opinionated workflow skills for [Claude Code](https://docs.anthropic.com/en/docs/claude-code). Plan review, code review, one-command shipping, browser automation, and engineering retrospectives — all as slash commands. +Eight opinionated workflow skills for [Claude Code](https://docs.anthropic.com/en/docs/claude-code). Plan review, code review, one-command shipping, browser automation, QA testing, and engineering retrospectives — all as slash commands. ### Without gstack @@ -22,7 +22,9 @@ Six opinionated workflow skills for [Claude Code](https://docs.anthropic.com/en/ | `/review` | Paranoid staff engineer | Find the bugs that pass CI but blow up in production. Not a style nitpick pass. | | `/ship` | Release engineer | Sync main, run tests, push, open PR. For a ready branch, not for deciding what to build. | | `/browse` | QA engineer | Give the agent eyes. It logs in, clicks through your app, takes screenshots, catches breakage. Full QA pass in 60 seconds. | -| `/retro` | Engineering manager | Analyze commit history, work patterns, and shipping velocity for the week. | +| `/qa` | QA lead | Systematic QA testing with structured reports, health scores, screenshots, and regression tracking. Three modes: full, quick, regression. | +| `/setup-browser-cookies` | Session manager | Import cookies from your real browser (Comet, Chrome, Arc, Brave, Edge) into the headless session. Test authenticated pages without logging in manually. | +| `/retro` | Engineering manager | Team-aware retro: your deep-dive + per-person praise and growth opportunities for every contributor. | ## Demo: one feature, five modes @@ -61,11 +63,20 @@ You: /ship Claude: [Syncs main, runs tests, pushes branch, opens PR — 6 tool calls, done] -You: /browse staging.myapp.com — test the listing flow +You: /setup-browser-cookies staging.myapp.com + +Claude: Imported 8 cookies for staging.myapp.com from Chrome. + +You: /qa https://staging.myapp.com --quick + +Claude: [Smoke test: homepage + 5 pages, 30 seconds] + Health Score: 91/100. No critical issues. 1 medium: mobile nav overlap. + +You: /browse staging.myapp.com/listings/new — test the upload flow specifically Claude: [22 tool calls — navigates routes, fills the upload form, verifies enrichment renders, checks console for errors, screenshots each step] - All pages load correctly. Listing flow works end to end on staging. + Listing flow works end to end on staging. ``` ## Who this is for @@ -74,6 +85,16 @@ You already use Claude Code heavily and want consistent, high-rigor workflows in This is not a prompt pack for beginners. It is an operating system for people who ship. +## How to fly: 10 sessions at once + +gstack is powerful with one Claude Code session. It is transformative with ten. + +[Conductor](https://conductor.build) runs multiple Claude Code sessions in parallel — each in its own isolated workspace. That means you can have one session running `/qa` on staging, another doing `/review` on a PR, a third implementing a feature, and seven more working on other branches. All at the same time. + +gstack is Conductor-aware out of the box. Each workspace gets its own isolated browser instance (separate Chromium process, cookies, tabs, and logs) so `/browse` and `/qa` sessions never collide. No configuration needed — it just works. + +This is the setup I use. One person, ten parallel agents, each with the right cognitive mode for its task. That is not incremental improvement. That is a different way of building software. + ## Install **Requirements:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Git](https://git-scm.com/), [Bun](https://bun.sh/) v1.0+. `/browse` compiles a native binary — works on macOS and Linux (x64 and arm64). @@ -82,18 +103,18 @@ This is not a prompt pack for beginners. It is an operating system for people wh Open Claude Code and paste this. Claude will do the rest. -> Install gstack: run `git clone https://github.com/garrytan/gstack.git ~/.claude/skills/gstack && cd ~/.claude/skills/gstack && ./setup` then add a "gstack" section to CLAUDE.md that says to use the /browse skill from gstack for all web browsing, never use mcp\_\_claude-in-chrome\_\_\* tools, and lists the available skills: /plan-ceo-review, /plan-eng-review, /review, /ship, /browse, /retro. Then ask the user if they also want to add gstack to the current project so teammates get it. +> Install gstack: run `git clone https://github.com/garrytan/gstack.git ~/.claude/skills/gstack && cd ~/.claude/skills/gstack && ./setup` then add a "gstack" section to CLAUDE.md that says to use the /browse skill from gstack for all web browsing, never use mcp\_\_claude-in-chrome\_\_\* tools, and lists the available skills: /plan-ceo-review, /plan-eng-review, /review, /ship, /browse, /qa, /setup-browser-cookies, /retro. Then ask the user if they also want to add gstack to the current project so teammates get it. ### Step 2: Add to your repo so teammates get it (optional) -> Add gstack to this project: run `cp -Rf ~/.claude/skills/gstack .claude/skills/gstack && rm -rf .claude/skills/gstack/.git && cd .claude/skills/gstack && ./setup` then add a "gstack" section to this project's CLAUDE.md that says to use the /browse skill from gstack for all web browsing, never use mcp\_\_claude-in-chrome\_\_\* tools, lists the available skills: /plan-ceo-review, /plan-eng-review, /review, /ship, /browse, /retro, and tells Claude that if gstack skills aren't working, run `cd .claude/skills/gstack && ./setup` to build the binary and register skills. +> Add gstack to this project: run `cp -Rf ~/.claude/skills/gstack .claude/skills/gstack && rm -rf .claude/skills/gstack/.git && cd .claude/skills/gstack && ./setup` then add a "gstack" section to this project's CLAUDE.md that says to use the /browse skill from gstack for all web browsing, never use mcp\_\_claude-in-chrome\_\_\* tools, lists the available skills: /plan-ceo-review, /plan-eng-review, /review, /ship, /browse, /qa, /setup-browser-cookies, /retro, and tells Claude that if gstack skills aren't working, run `cd .claude/skills/gstack && ./setup` to build the binary and register skills. Real files get committed to your repo (not a submodule), so `git clone` just works. The binary and node\_modules are gitignored — teammates just need to run `cd .claude/skills/gstack && ./setup` once to build (or `/browse` handles it automatically on first use). ### What gets installed - Skill files (Markdown prompts) in `~/.claude/skills/gstack/` (or `.claude/skills/gstack/` for project installs) -- Symlinks at `~/.claude/skills/browse`, `~/.claude/skills/review`, etc. pointing into the gstack directory +- Symlinks at `~/.claude/skills/browse`, `~/.claude/skills/qa`, `~/.claude/skills/review`, etc. pointing into the gstack directory - Browser binary at `browse/dist/browse` (~58MB, gitignored) - `node_modules/` (gitignored) - `/retro` saves JSON snapshots to `.context/retros/` in your project for trend tracking @@ -378,22 +399,104 @@ For the full command reference, technical internals, and architecture details, s --- +## `/qa` + +This is my **QA lead mode**. + +`/browse` gives the agent eyes. `/qa` gives it a testing methodology. + +Where `/browse` is a single command — go here, click this, screenshot that — `/qa` is a full systematic test pass. It explores every reachable page, fills forms, clicks buttons, checks console errors, tests responsive layouts, and produces a structured report with a health score, screenshots as evidence, and ranked issues with repro steps. + +Three modes: + +- **Full** (default) — systematic exploration of the entire app. 5-15 minutes depending on app size. Documents 5-10 well-evidenced issues. +- **Quick** (`--quick`) — 30-second smoke test. Homepage + top 5 nav targets. Loads? Console errors? Broken links? +- **Regression** (`--regression baseline.json`) — run full mode, then diff against a previous baseline. Which issues are fixed? Which are new? What's the score delta? + +``` +You: /qa https://staging.myapp.com + +Claude: [Explores 12 pages, fills 3 forms, tests 2 flows] + + QA Report: staging.myapp.com — Health Score: 72/100 + + Top 3 Issues: + 1. CRITICAL: Checkout form submits with empty required fields + 2. HIGH: Mobile nav menu doesn't close after selecting an item + 3. MEDIUM: Dashboard chart overlaps sidebar below 1024px + + [Full report with screenshots saved to .gstack/qa-reports/] +``` + +Reports and screenshots accumulate in `.gstack/qa-reports/` so you can track quality over time and compare runs. + +**Testing authenticated pages:** Use `/setup-browser-cookies` first to import your real browser sessions, then `/qa` can test pages behind login. + +--- + +## `/setup-browser-cookies` + +This is my **session manager mode**. + +Before `/qa` or `/browse` can test authenticated pages, they need cookies. Instead of manually logging in through the headless browser every time, `/setup-browser-cookies` imports your real sessions directly from your daily browser. + +It auto-detects installed Chromium browsers (Comet, Chrome, Arc, Brave, Edge), decrypts cookies via the macOS Keychain, and loads them into the Playwright session. An interactive picker UI lets you choose exactly which domains to import — no cookie values are ever displayed. + +``` +You: /setup-browser-cookies + +Claude: Cookie picker opened — select the domains you want to import + in your browser, then tell me when you're done. + + [You pick github.com, myapp.com in the browser UI] + +You: done + +Claude: Imported 2 domains (47 cookies). Session is ready. +``` + +Or skip the UI entirely: + +``` +You: /setup-browser-cookies github.com + +Claude: Imported 12 cookies for github.com from Comet. +``` + +First import per browser triggers a macOS Keychain prompt — click "Allow" or "Always Allow." + +--- + ## `/retro` This is my **engineering manager mode**. At the end of the week I want to know what actually happened. Not vibes — data. `/retro` analyzes commit history, work patterns, and shipping velocity and writes a candid retrospective. -It computes metrics like commits, LOC, test ratio, PR sizes, and fix ratio. It detects coding sessions from commit timestamps, finds hotspot files, tracks shipping streaks, and identifies the biggest ship of the week. +It is team-aware. It identifies who is running the command, gives you the deepest treatment on your own work, then breaks down every contributor with specific praise and growth opportunities — the kind of feedback you would actually give in a 1:1. It computes metrics like commits, LOC, test ratio, PR sizes, and fix ratio. It detects coding sessions from commit timestamps, finds hotspot files, tracks shipping streaks, and identifies the biggest ship of the week. ``` You: /retro -Claude: Week of Mar 1: 47 commits, 3.2k LOC, 38% tests, 12 PRs, peak: 10pm | Streak: 47d +Claude: Week of Mar 1: 47 commits (3 contributors), 3.2k LOC, 38% tests, 12 PRs, peak: 10pm | Streak: 47d + + ## Your Week + 32 commits, +2.4k LOC, 41% tests. Peak hours: 9-11pm. + Biggest ship: cookie import system (browser decryption + picker UI). + What you did well: shipped a complete feature with encryption, UI, and + 18 unit tests in one focused push... + + ## Team Breakdown + + ### Alice + 12 commits focused on app/services/. Every PR under 200 LOC — disciplined. + Opportunity: test ratio at 12% — worth investing before payment gets more complex. + + ### Bob + 3 commits — fixed the N+1 query on dashboard. Small but high-impact. + Opportunity: only 1 active day this week — check if blocked on anything. - [Full retro with summary table, time patterns, session analysis, - commit type breakdown, hotspots, focus score, top 3 wins, - 3 things to improve, 3 habits for next week] + [Top 3 team wins, 3 things to improve, 3 habits for next week] ``` It saves a JSON snapshot to `.context/retros/` so the next run can show trends. Run `/retro compare` to see this week vs last week side by side. @@ -409,7 +512,7 @@ Run `cd ~/.claude/skills/gstack && ./setup` (or `cd .claude/skills/gstack && ./s Run `cd ~/.claude/skills/gstack && bun install && bun run build`. This compiles the browser binary. Requires Bun v1.0+. **Project copy is stale?** -Re-copy from global: `for s in browse plan-ceo-review plan-eng-review review ship retro; do rm -f .claude/skills/$s; done && rm -rf .claude/skills/gstack && cp -Rf ~/.claude/skills/gstack .claude/skills/gstack && rm -rf .claude/skills/gstack/.git && cd .claude/skills/gstack && ./setup` +Re-copy from global: `for s in browse plan-ceo-review plan-eng-review review ship retro qa setup-browser-cookies; do rm -f .claude/skills/$s; done && rm -rf .claude/skills/gstack && cp -Rf ~/.claude/skills/gstack .claude/skills/gstack && rm -rf .claude/skills/gstack/.git && cd .claude/skills/gstack && ./setup` **`bun` not installed?** Install it: `curl -fsSL https://bun.sh/install | bash` @@ -418,7 +521,7 @@ Install it: `curl -fsSL https://bun.sh/install | bash` Paste this into Claude Code: -> Update gstack: run `cd ~/.claude/skills/gstack && git fetch origin && git reset --hard origin/main && ./setup`. If this project also has gstack at .claude/skills/gstack, update it too: run `for s in browse plan-ceo-review plan-eng-review review ship retro; do rm -f .claude/skills/$s; done && rm -rf .claude/skills/gstack && cp -Rf ~/.claude/skills/gstack .claude/skills/gstack && rm -rf .claude/skills/gstack/.git && cd .claude/skills/gstack && ./setup` +> Update gstack: run `cd ~/.claude/skills/gstack && git fetch origin && git reset --hard origin/main && ./setup`. If this project also has gstack at .claude/skills/gstack, update it too: run `for s in browse plan-ceo-review plan-eng-review review ship retro qa setup-browser-cookies; do rm -f .claude/skills/$s; done && rm -rf .claude/skills/gstack && cp -Rf ~/.claude/skills/gstack .claude/skills/gstack && rm -rf .claude/skills/gstack/.git && cd .claude/skills/gstack && ./setup` The `setup` script rebuilds the browser binary and re-symlinks skills. It takes a few seconds. @@ -426,7 +529,7 @@ The `setup` script rebuilds the browser binary and re-symlinks skills. It takes Paste this into Claude Code: -> Uninstall gstack: remove the skill symlinks by running `for s in browse plan-ceo-review plan-eng-review review ship retro; do rm -f ~/.claude/skills/$s; done` then run `rm -rf ~/.claude/skills/gstack` and remove the gstack section from CLAUDE.md. If this project also has gstack at .claude/skills/gstack, remove it by running `for s in browse plan-ceo-review plan-eng-review review ship retro; do rm -f .claude/skills/$s; done && rm -rf .claude/skills/gstack` and remove the gstack section from the project CLAUDE.md too. +> Uninstall gstack: remove the skill symlinks by running `for s in browse plan-ceo-review plan-eng-review review ship retro qa setup-browser-cookies; do rm -f ~/.claude/skills/$s; done` then run `rm -rf ~/.claude/skills/gstack` and remove the gstack section from CLAUDE.md. If this project also has gstack at .claude/skills/gstack, remove it by running `for s in browse plan-ceo-review plan-eng-review review ship retro qa setup-browser-cookies; do rm -f .claude/skills/$s; done && rm -rf .claude/skills/gstack` and remove the gstack section from the project CLAUDE.md too. ## Development diff --git a/SKILL.md b/SKILL.md index 08ad3e9..d657a20 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,254 +1,340 @@ --- name: gstack -version: 1.0.0 +version: 1.1.0 description: | - Fast web browsing for Claude Code via persistent headless Chromium daemon. Navigate to any URL, - read page content, click elements, fill forms, run JavaScript, take screenshots, - inspect CSS/DOM, capture console/network logs, and more. ~100ms per command after - first call. Use when you need to check a website, verify a deployment, read docs, - or interact with any web page. No MCP, no Chrome extension — just fast CLI. + Fast headless browser for QA testing and site dogfooding. Navigate any URL, interact with + elements, verify page state, diff before/after actions, take annotated screenshots, check + responsive layouts, test forms and uploads, handle dialogs, and assert element states. + ~100ms per command. Use when you need to test a feature, verify a deployment, dogfood a + user flow, or file a bug with evidence. allowed-tools: - Bash - Read --- -# gstack: Persistent Browser for Claude Code +# gstack browse: QA Testing & Dogfooding -Persistent headless Chromium daemon. First call auto-starts the server (~3s). -Every subsequent call: ~100-200ms. Auto-shuts down after 30 min idle. +Persistent headless Chromium. First call auto-starts (~3s), then ~100-200ms per command. +Auto-shuts down after 30 min idle. State persists between calls (cookies, tabs, sessions). ## SETUP (run this check BEFORE any browse command) -Before using any browse command, find the skill and check if the binary exists: - ```bash -# Check project-level first, then user-level -if test -x .claude/skills/gstack/browse/dist/browse; then - echo "READY_PROJECT" -elif test -x ~/.claude/skills/gstack/browse/dist/browse; then - echo "READY_USER" +B=$(browse/bin/find-browse 2>/dev/null || ~/.claude/skills/gstack/browse/bin/find-browse 2>/dev/null) +if [ -n "$B" ]; then + echo "READY: $B" else echo "NEEDS_SETUP" fi ``` -Set `B` to whichever path is READY and use it for all commands. Prefer project-level if both exist. - If `NEEDS_SETUP`: -1. Tell the user: "gstack browse needs a one-time build (~10 seconds). OK to proceed?" Then STOP and wait for their response. -2. If they approve, determine the skill directory (project-level `.claude/skills/gstack` or user-level `~/.claude/skills/gstack`) and run: -```bash -cd && ./setup -``` -3. If `bun` is not installed, tell the user to install it: `curl -fsSL https://bun.sh/install | bash` -4. Verify the `.gitignore` in the skill directory contains `browse/dist/` and `node_modules/`. If either line is missing, add it. - -Once setup is done, it never needs to run again (the compiled binary persists). +1. Tell the user: "gstack browse needs a one-time build (~10 seconds). OK to proceed?" Then STOP and wait. +2. Run: `cd && ./setup` +3. If `bun` is not installed: `curl -fsSL https://bun.sh/install | bash` ## IMPORTANT -- Use the compiled binary via Bash: `.claude/skills/gstack/browse/dist/browse` (project) or `~/.claude/skills/gstack/browse/dist/browse` (user). +- Use the compiled binary via Bash: `$B ` - NEVER use `mcp__claude-in-chrome__*` tools. They are slow and unreliable. -- The browser persists between calls — cookies, tabs, and state carry over. -- The server auto-starts on first command. No setup needed. +- Browser persists between calls — cookies, login sessions, and tabs carry over. +- Dialogs (alert/confirm/prompt) are auto-accepted by default — no browser lockup. -## Quick Reference +## QA Workflows + +### Test a user flow (login, signup, checkout, etc.) ```bash B=~/.claude/skills/gstack/browse/dist/browse -# Navigate to a page -$B goto https://example.com +# 1. Go to the page +$B goto https://app.example.com/login -# Read cleaned page text -$B text +# 2. See what's interactive +$B snapshot -i -# Take a screenshot (then Read the image) -$B screenshot /tmp/page.png +# 3. Fill the form using refs +$B fill @e3 "test@example.com" +$B fill @e4 "password123" +$B click @e5 -# Snapshot: accessibility tree with refs -$B snapshot -i +# 4. Verify it worked +$B snapshot -D # diff shows what changed after clicking +$B is visible ".dashboard" # assert the dashboard appeared +$B screenshot /tmp/after-login.png +``` -# Click by ref (after snapshot) -$B click @e3 +### Verify a deployment / check prod -# Fill by ref -$B fill @e4 "test@test.com" +```bash +$B goto https://yourapp.com +$B text # read the page — does it load? +$B console # any JS errors? +$B network # any failed requests? +$B js "document.title" # correct title? +$B is visible ".hero-section" # key elements present? +$B screenshot /tmp/prod-check.png +``` -# Run JavaScript -$B js "document.title" +### Dogfood a feature end-to-end -# Get all links -$B links +```bash +# Navigate to the feature +$B goto https://app.example.com/new-feature -# Click by CSS selector -$B click "button.submit" +# Take annotated screenshot — shows every interactive element with labels +$B snapshot -i -a -o /tmp/feature-annotated.png -# Fill a form by CSS selector -$B fill "#email" "test@test.com" -$B fill "#password" "abc123" -$B click "button[type=submit]" +# Find ALL clickable things (including divs with cursor:pointer) +$B snapshot -C -# Get HTML of an element -$B html "main" +# Walk through the flow +$B snapshot -i # baseline +$B click @e3 # interact +$B snapshot -D # what changed? (unified diff) -# Get computed CSS -$B css "body" "font-family" +# Check element states +$B is visible ".success-toast" +$B is enabled "#next-step-btn" +$B is checked "#agree-checkbox" -# Get element attributes -$B attrs "nav" +# Check console for errors after interactions +$B console +``` -# Wait for element to appear -$B wait ".loaded" +### Test responsive layouts -# Accessibility tree -$B accessibility +```bash +# Quick: 3 screenshots at mobile/tablet/desktop +$B goto https://yourapp.com +$B responsive /tmp/layout + +# Manual: specific viewport +$B viewport 375x812 # iPhone +$B screenshot /tmp/mobile.png +$B viewport 1440x900 # Desktop +$B screenshot /tmp/desktop.png +``` -# Set viewport -$B viewport 375x812 +### Test file upload -# Set cookies / headers -$B cookie "session=abc123" -$B header "Authorization:Bearer token123" +```bash +$B goto https://app.example.com/upload +$B snapshot -i +$B upload @e3 /path/to/test-file.pdf +$B is visible ".upload-success" +$B screenshot /tmp/upload-result.png ``` -## Command Reference +### Test forms with validation -### Navigation +```bash +$B goto https://app.example.com/form +$B snapshot -i + +# Submit empty — check validation errors appear +$B click @e10 # submit button +$B snapshot -D # diff shows error messages appeared +$B is visible ".error-message" + +# Fill and resubmit +$B fill @e3 "valid input" +$B click @e10 +$B snapshot -D # diff shows errors gone, success state ``` -browse goto Navigate current tab -browse back Go back -browse forward Go forward -browse reload Reload page -browse url Print current URL + +### Test dialogs (delete confirmations, prompts) + +```bash +# Set up dialog handling BEFORE triggering +$B dialog-accept # will auto-accept next alert/confirm +$B click "#delete-button" # triggers confirmation dialog +$B dialog # see what dialog appeared +$B snapshot -D # verify the item was deleted + +# For prompts that need input +$B dialog-accept "my answer" # accept with text +$B click "#rename-button" # triggers prompt ``` -### Content extraction +### Test authenticated pages (import real browser cookies) + +```bash +# Import cookies from your real browser (opens interactive picker) +$B cookie-import-browser + +# Or import a specific domain directly +$B cookie-import-browser comet --domain .github.com + +# Now test authenticated pages +$B goto https://github.com/settings/profile +$B snapshot -i +$B screenshot /tmp/github-profile.png ``` -browse text Cleaned page text (no scripts/styles) -browse html [selector] innerHTML of element, or full page HTML -browse links All links as "text → href" -browse forms All forms + fields as JSON -browse accessibility Accessibility tree snapshot (ARIA) + +### Compare two pages / environments + +```bash +$B diff https://staging.app.com https://prod.app.com ``` -### Snapshot (ref-based element selection) +### Multi-step chain (efficient for long flows) + +```bash +echo '[ + ["goto","https://app.example.com"], + ["snapshot","-i"], + ["fill","@e3","test@test.com"], + ["fill","@e4","password"], + ["click","@e5"], + ["snapshot","-D"], + ["screenshot","/tmp/result.png"] +]' | $B chain ``` -browse snapshot Full accessibility tree with @refs -browse snapshot -i Interactive elements only (buttons, links, inputs) -browse snapshot -c Compact (no empty structural elements) -browse snapshot -d Limit depth to N levels -browse snapshot -s Scope to CSS selector + +## Quick Assertion Patterns + +```bash +# Element exists and is visible +$B is visible ".modal" + +# Button is enabled/disabled +$B is enabled "#submit-btn" +$B is disabled "#submit-btn" + +# Checkbox state +$B is checked "#agree" + +# Input is editable +$B is editable "#name-field" + +# Element has focus +$B is focused "#search-input" + +# Page contains text +$B js "document.body.textContent.includes('Success')" + +# Element count +$B js "document.querySelectorAll('.list-item').length" + +# Specific attribute value +$B attrs "#logo" # returns all attributes as JSON + +# CSS property +$B css ".button" "background-color" ``` -After snapshot, use @refs as selectors in any command: +## Snapshot System + +The snapshot is your primary tool for understanding and interacting with pages. + +```bash +$B snapshot -i # Interactive elements only (buttons, links, inputs) with @e refs +$B snapshot -c # Compact (no empty structural elements) +$B snapshot -d 3 # Limit depth to 3 levels +$B snapshot -s "main" # Scope to CSS selector +$B snapshot -D # Diff against previous snapshot (what changed?) +$B snapshot -a # Annotated screenshot with ref labels +$B snapshot -o /tmp/x.png # Output path for annotated screenshot +$B snapshot -C # Cursor-interactive elements (@c refs — divs with pointer, onclick) ``` -browse click @e3 Click the element assigned ref @e3 -browse fill @e4 "value" Fill the input assigned ref @e4 -browse hover @e1 Hover the element assigned ref @e1 -browse html @e2 Get innerHTML of ref @e2 -browse css @e5 "color" Get computed CSS of ref @e5 -browse attrs @e6 Get attributes of ref @e6 + +Combine flags: `$B snapshot -i -a -C -o /tmp/annotated.png` + +After snapshot, use @refs everywhere: +```bash +$B click @e3 $B fill @e4 "value" $B hover @e1 +$B html @e2 $B css @e5 "color" $B attrs @e6 +$B click @c1 # cursor-interactive ref (from -C) ``` Refs are invalidated on navigation — run `snapshot` again after `goto`. +## Command Reference + +### Navigation +| Command | Description | +|---------|-------------| +| `goto ` | Navigate to URL | +| `back` / `forward` | History navigation | +| `reload` | Reload page | +| `url` | Print current URL | + +### Reading +| Command | Description | +|---------|-------------| +| `text` | Cleaned page text | +| `html [selector]` | innerHTML | +| `links` | All links as "text -> href" | +| `forms` | Forms + fields as JSON | +| `accessibility` | Full ARIA tree | + ### Interaction -``` -browse click Click element (CSS selector or @ref) -browse fill Fill input field -browse select Select dropdown value -browse hover Hover over element -browse type Type into focused element -browse press Press key (Enter, Tab, Escape, etc.) -browse scroll [selector] Scroll element into view, or page bottom -browse wait Wait for element to appear (max 10s) -browse viewport Set viewport size (e.g. 375x812) -``` +| Command | Description | +|---------|-------------| +| `click ` | Click element | +| `fill ` | Fill input | +| `select ` | Select dropdown | +| `hover ` | Hover element | +| `type ` | Type into focused element | +| `press ` | Press key (Enter, Tab, Escape) | +| `scroll [sel]` | Scroll element into view | +| `wait ` | Wait for element (max 10s) | +| `wait --networkidle` | Wait for network to be idle | +| `wait --load` | Wait for page load event | +| `upload ` | Upload file(s) | +| `cookie-import ` | Import cookies from JSON file | +| `cookie-import-browser [browser] [--domain ]` | Import cookies from real browser (opens picker UI, or direct import with --domain) | +| `dialog-accept [text]` | Auto-accept dialogs | +| `dialog-dismiss` | Auto-dismiss dialogs | +| `viewport ` | Set viewport size | ### Inspection -``` -browse js Run JS, print result -browse eval Run JS file against page -browse css Get computed CSS property -browse attrs Get element attributes as JSON -browse console Dump captured console messages -browse console --clear Clear console buffer -browse network Dump captured network requests -browse network --clear Clear network buffer -browse cookies Dump all cookies as JSON -browse storage localStorage + sessionStorage as JSON -browse storage set Set localStorage value -browse perf Page load performance timings -``` +| Command | Description | +|---------|-------------| +| `js ` | Run JavaScript | +| `eval ` | Run JS file | +| `css ` | Computed CSS | +| `attrs ` | Element attributes | +| `is ` | State check (visible/hidden/enabled/disabled/checked/editable/focused) | +| `console [--clear\|--errors]` | Console messages (--errors filters to error/warning) | +| `network [--clear]` | Network requests | +| `dialog [--clear]` | Dialog messages | +| `cookies` | All cookies | +| `storage` | localStorage + sessionStorage | +| `perf` | Page load timings | ### Visual -``` -browse screenshot [path] Screenshot (default: /tmp/browse-screenshot.png) -browse pdf [path] Save as PDF -browse responsive [prefix] Screenshots at mobile/tablet/desktop -``` - -### Compare -``` -browse diff Text diff between two pages -``` - -### Multi-step (chain) -``` -echo '[["goto","https://example.com"],["snapshot","-i"],["click","@e1"],["screenshot","/tmp/result.png"]]' | browse chain -``` +| Command | Description | +|---------|-------------| +| `screenshot [path]` | Screenshot | +| `pdf [path]` | Save as PDF | +| `responsive [prefix]` | Mobile/tablet/desktop screenshots | +| `diff ` | Text diff between pages | ### Tabs -``` -browse tabs List tabs (id, url, title) -browse tab Switch to tab -browse newtab [url] Open new tab -browse closetab [id] Close tab -``` - -### Server management -``` -browse status Server health, uptime, tab count -browse stop Shutdown server -browse restart Kill + restart server -``` - -## Speed Rules - -1. **Navigate once, query many times.** `goto` loads the page; then `text`, `js`, `css`, `screenshot` all run against the loaded page instantly. -2. **Use `snapshot -i` for interaction.** Get refs for all interactive elements, then click/fill by ref. No need to guess CSS selectors. -3. **Use `js` for precision.** `js "document.querySelector('.price').textContent"` is faster than parsing full page text. -4. **Use `links` to survey.** Faster than `text` when you just need navigation structure. -5. **Use `chain` for multi-step flows.** Avoids CLI overhead per step. -6. **Use `responsive` for layout checks.** One command = 3 viewport screenshots. - -## When to Use What - -| Task | Commands | -|------|----------| -| Read a page | `goto ` then `text` | -| Interact with elements | `snapshot -i` then `click @e3` | -| Check if element exists | `js "!!document.querySelector('.thing')"` | -| Extract specific data | `js "document.querySelector('.price').textContent"` | -| Visual check | `screenshot /tmp/x.png` then Read the image | -| Fill and submit form | `snapshot -i` → `fill @e4 "val"` → `click @e5` → `screenshot` | -| Check CSS | `css "selector" "property"` or `css @e3 "property"` | -| Inspect DOM | `html "selector"` or `attrs @e3` | -| Debug console errors | `console` | -| Check network requests | `network` | -| Check local dev | `goto http://127.0.0.1:3000` | -| Compare two pages | `diff ` | -| Mobile layout check | `responsive /tmp/prefix` | -| Multi-step flow | `echo '[...]' \| browse chain` | - -## Architecture - -- Persistent Chromium daemon on localhost (port 9400-9410) -- Bearer token auth per session -- State file: `/tmp/browse-server.json` -- Console log: `/tmp/browse-console.log` -- Network log: `/tmp/browse-network.log` -- Auto-shutdown after 30 min idle -- Chromium crash → server exits → auto-restarts on next command +| Command | Description | +|---------|-------------| +| `tabs` | List tabs | +| `tab ` | Switch tab | +| `newtab [url]` | Open tab | +| `closetab [id]` | Close tab | + +### Server +| Command | Description | +|---------|-------------| +| `status` | Health check | +| `stop` | Shutdown | +| `restart` | Restart | + +## Tips + +1. **Navigate once, query many times.** `goto` loads the page; then `text`, `js`, `screenshot` all hit the loaded page instantly. +2. **Use `snapshot -i` first.** See all interactive elements, then click/fill by ref. No CSS selector guessing. +3. **Use `snapshot -D` to verify.** Baseline → action → diff. See exactly what changed. +4. **Use `is` for assertions.** `is visible .modal` is faster and more reliable than parsing page text. +5. **Use `snapshot -a` for evidence.** Annotated screenshots are great for bug reports. +6. **Use `snapshot -C` for tricky UIs.** Finds clickable divs that the accessibility tree misses. +7. **Check `console` after actions.** Catch JS errors that don't surface visually. +8. **Use `chain` for long flows.** Single command, no per-step CLI overhead. diff --git a/TODO.md b/TODO.md index f08cff2..dc09311 100644 --- a/TODO.md +++ b/TODO.md @@ -7,24 +7,53 @@ - [x] Snapshot command with ref-based element selection - [x] Snapshot tests -## Phase 2: Enhanced Browser - - [ ] Annotated screenshots (--annotate flag, numbered labels on elements mapped to refs) - - [ ] Snapshot diffing (compare before/after accessibility trees, verify actions worked) - - [ ] Dialog handling (dialog accept/dismiss — prevents browser lockup) - - [ ] File upload (upload ) - - [ ] Cursor-interactive elements (-C flag, detect divs with cursor:pointer/onclick/tabindex) - - [ ] Element state checks (is visible/enabled/checked ) +## Phase 2: Enhanced Browser (v0.2.0) ✅ + - [x] Annotated screenshots (--annotate flag, ref labels overlaid on screenshot) + - [x] Snapshot diffing (--diff flag, unified diff against previous snapshot) + - [x] Dialog handling (auto-accept/dismiss, dialog buffer, prevents browser lockup) + - [x] File upload (upload ) + - [x] Cursor-interactive elements (-C flag, cursor:pointer/onclick/tabindex scan) + - [x] Element state checks (is visible/hidden/enabled/disabled/checked/editable/focused) + - [x] CircularBuffer — O(1) ring buffer for console/network/dialog (was O(n) array+shift) + - [x] Async buffer flush with Bun.write() (was appendFileSync) + - [x] Health check with page.evaluate('1') + 2s timeout + - [x] Playwright error wrapping — actionable messages for AI agents + - [x] Fix useragent — context recreation preserves cookies/storage/URLs + - [x] DRY: getCleanText exported, command sets in chain updated + - [x] 148 integration tests (was ~63) -## Phase 3: QA Testing Agent (dogfood skill) - - [ ] SKILL.md — 6-phase workflow: Initialize → Authenticate → Orient → Explore → Document → Wrap up - - [ ] Issue taxonomy reference (7 categories: visual, functional, UX, content, performance, console, accessibility) - - [ ] Severity classification (critical/high/medium/low) - - [ ] Exploration checklist per page - - [ ] Report template (structured markdown with per-issue evidence) - - [ ] Repro-first philosophy: every issue gets evidence before moving on - - [ ] Two evidence tiers: interactive bugs (video + step-by-step screenshots), static bugs (single annotated screenshot) - - [ ] Video recording (record start/stop for WebM capture via Playwright) - - [ ] Key guidance: 5-10 well-documented issues per session, depth over breadth, write incrementally +## Phase 3: QA Testing Agent (v0.3.0) + - [x] `/qa` SKILL.md — 6-phase workflow: Initialize → Authenticate → Orient → Explore → Document → Wrap up + - [x] Issue taxonomy reference (7 categories: visual, functional, UX, content, performance, console, accessibility) + - [x] Severity classification (critical/high/medium/low) + - [x] Exploration checklist per page + - [x] Report template (structured markdown with per-issue evidence) + - [x] Repro-first philosophy: every issue gets evidence before moving on + - [x] Two evidence tiers: interactive bugs (multi-step screenshots), static bugs (single annotated screenshot) + - [x] Key guidance: 5-10 well-documented issues per session, depth over breadth, write incrementally + - [x] Three modes: full (systematic), quick (30-second smoke test), regression (compare against baseline) + - [x] Framework detection guidance (Next.js, Rails, WordPress, SPA) + - [x] Health score rubric (7 categories, weighted average) + - [x] `wait --networkidle` / `wait --load` / `wait --domcontentloaded` + - [x] `console --errors` (filter to error/warning only) + - [x] `cookie-import ` (bulk cookie import with auto-fill domain) + - [x] `browse/bin/find-browse` (DRY binary discovery across skills) + - [ ] Video recording (deferred to Phase 5 — recreateContext destroys page state) + +## Phase 3.5: Browser Cookie Import (v0.3.x) + - [x] `cookie-import-browser` command (Chromium cookie DB decryption) + - [x] Cookie picker web UI (served from browse server) + - [x] `/setup-browser-cookies` skill + - [x] Unit tests with encrypted cookie fixtures (18 tests) + - [x] Browser registry (Comet, Chrome, Arc, Brave, Edge) + +## Phase 3.6: Visual PR Annotations + S3 Upload + - [ ] `/setup-gstack-upload` skill (configure S3 bucket for image hosting) + - [ ] `browse/bin/gstack-upload` helper (upload file to S3, return public URL) + - [ ] `/ship` Step 7.5: visual verification with screenshots in PR body + - [ ] `/review` Step 4.5: visual review with annotated screenshots in PR + - [ ] WebM → GIF conversion (ffmpeg) for video evidence in PRs + - [ ] README documentation for visual PR annotations ## Phase 4: Skill + Browser Integration - [ ] ship + browse: post-deploy verification @@ -48,9 +77,11 @@ - Pass/fail with evidence ## Phase 5: State & Sessions + - [ ] v20 encryption format support (AES-256-GCM) — future Chromium versions may change from v10 - [ ] Sessions (isolated browser instances with separate cookies/storage/history) - [ ] State persistence (save/load cookies + localStorage to JSON files) - [ ] Auth vault (encrypted credential storage, referenced by name, LLM never sees passwords) + - [ ] Video recording (record start/stop — needs sessions for clean context lifecycle) - [ ] retro + browse: deployment health tracking - Screenshot production state - Check perf metrics (page load times) @@ -67,6 +98,12 @@ - [ ] Streaming (WebSocket live preview for pair browsing) - [ ] CDP mode (connect to already-running Chrome/Electron apps) +## Future Ideas + - [ ] Linux/Windows cookie decryption (GNOME Keyring / kwallet / DPAPI) + - [ ] Trend tracking across QA runs — compare baseline.json over time, detect regressions (P2, S) + - [ ] CI/CD integration — `/qa` as GitHub Action step, fail PR if health score drops (P2, M) + - [ ] Accessibility audit mode — `--a11y` flag for focused accessibility testing (P3, S) + ## Ideas & Notes - Browser is the nervous system — every skill should be able to see, interact with, and verify the web - Skills are the product; the browser enables them diff --git a/VERSION b/VERSION index 4e379d2..9e11b32 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.2 +0.3.1 diff --git a/browse/SKILL.md b/browse/SKILL.md index b752aec..99c979c 100644 --- a/browse/SKILL.md +++ b/browse/SKILL.md @@ -1,254 +1,128 @@ --- name: browse -version: 1.0.0 +version: 1.1.0 description: | - Fast web browsing for Claude Code via persistent headless Chromium daemon. Navigate to any URL, - read page content, click elements, fill forms, run JavaScript, take screenshots, - inspect CSS/DOM, capture console/network logs, and more. ~100ms per command after - first call. Use when you need to check a website, verify a deployment, read docs, - or interact with any web page. No MCP, no Chrome extension — just fast CLI. + Fast headless browser for QA testing and site dogfooding. Navigate any URL, interact with + elements, verify page state, diff before/after actions, take annotated screenshots, check + responsive layouts, test forms and uploads, handle dialogs, and assert element states. + ~100ms per command. Use when you need to test a feature, verify a deployment, dogfood a + user flow, or file a bug with evidence. allowed-tools: - Bash - Read --- -# gstack: Persistent Browser for Claude Code +# browse: QA Testing & Dogfooding -Persistent headless Chromium daemon. First call auto-starts the server (~3s). -Every subsequent call: ~100-200ms. Auto-shuts down after 30 min idle. +Persistent headless Chromium. First call auto-starts (~3s), then ~100ms per command. +State persists between calls (cookies, tabs, login sessions). -## SETUP (run this check BEFORE any browse command) - -Before using any browse command, find the skill and check if the binary exists: +## Core QA Patterns +### 1. Verify a page loads correctly ```bash -# Check project-level first, then user-level -if test -x .claude/skills/gstack/browse/dist/browse; then - echo "READY_PROJECT" -elif test -x ~/.claude/skills/gstack/browse/dist/browse; then - echo "READY_USER" -else - echo "NEEDS_SETUP" -fi +$B goto https://yourapp.com +$B text # content loads? +$B console # JS errors? +$B network # failed requests? +$B is visible ".main-content" # key elements present? ``` -Set `B` to whichever path is READY and use it for all commands. Prefer project-level if both exist. - -If `NEEDS_SETUP`: -1. Tell the user: "gstack browse needs a one-time build (~10 seconds). OK to proceed?" Then STOP and wait for their response. -2. If they approve, determine the skill directory (project-level `.claude/skills/gstack` or user-level `~/.claude/skills/gstack`) and run: +### 2. Test a user flow ```bash -cd && ./setup +$B goto https://app.com/login +$B snapshot -i # see all interactive elements +$B fill @e3 "user@test.com" +$B fill @e4 "password" +$B click @e5 # submit +$B snapshot -D # diff: what changed after submit? +$B is visible ".dashboard" # success state present? ``` -3. If `bun` is not installed, tell the user to install it: `curl -fsSL https://bun.sh/install | bash` -4. Verify the `.gitignore` in the skill directory contains `browse/dist/` and `node_modules/`. If either line is missing, add it. - -Once setup is done, it never needs to run again (the compiled binary persists). - -## IMPORTANT - -- Use the compiled binary via Bash: `.claude/skills/gstack/browse/dist/browse` (project) or `~/.claude/skills/gstack/browse/dist/browse` (user). -- NEVER use `mcp__claude-in-chrome__*` tools. They are slow and unreliable. -- The browser persists between calls — cookies, tabs, and state carry over. -- The server auto-starts on first command. No setup needed. - -## Quick Reference +### 3. Verify an action worked ```bash -B=~/.claude/skills/gstack/browse/dist/browse - -# Navigate to a page -$B goto https://example.com - -# Read cleaned page text -$B text - -# Take a screenshot (then Read the image) -$B screenshot /tmp/page.png - -# Snapshot: accessibility tree with refs -$B snapshot -i - -# Click by ref (after snapshot) -$B click @e3 - -# Fill by ref -$B fill @e4 "test@test.com" - -# Run JavaScript -$B js "document.title" - -# Get all links -$B links - -# Click by CSS selector -$B click "button.submit" - -# Fill a form by CSS selector -$B fill "#email" "test@test.com" -$B fill "#password" "abc123" -$B click "button[type=submit]" - -# Get HTML of an element -$B html "main" - -# Get computed CSS -$B css "body" "font-family" - -# Get element attributes -$B attrs "nav" - -# Wait for element to appear -$B wait ".loaded" - -# Accessibility tree -$B accessibility - -# Set viewport -$B viewport 375x812 - -# Set cookies / headers -$B cookie "session=abc123" -$B header "Authorization:Bearer token123" +$B snapshot # baseline +$B click @e3 # do something +$B snapshot -D # unified diff shows exactly what changed ``` -## Command Reference - -### Navigation -``` -browse goto Navigate current tab -browse back Go back -browse forward Go forward -browse reload Reload page -browse url Print current URL +### 4. Visual evidence for bug reports +```bash +$B snapshot -i -a -o /tmp/annotated.png # labeled screenshot +$B screenshot /tmp/bug.png # plain screenshot +$B console # error log ``` -### Content extraction -``` -browse text Cleaned page text (no scripts/styles) -browse html [selector] innerHTML of element, or full page HTML -browse links All links as "text → href" -browse forms All forms + fields as JSON -browse accessibility Accessibility tree snapshot (ARIA) +### 5. Find all clickable elements (including non-ARIA) +```bash +$B snapshot -C # finds divs with cursor:pointer, onclick, tabindex +$B click @c1 # interact with them ``` -### Snapshot (ref-based element selection) -``` -browse snapshot Full accessibility tree with @refs -browse snapshot -i Interactive elements only (buttons, links, inputs) -browse snapshot -c Compact (no empty structural elements) -browse snapshot -d Limit depth to N levels -browse snapshot -s Scope to CSS selector +### 6. Assert element states +```bash +$B is visible ".modal" +$B is enabled "#submit-btn" +$B is disabled "#submit-btn" +$B is checked "#agree-checkbox" +$B is editable "#name-field" +$B is focused "#search-input" +$B js "document.body.textContent.includes('Success')" ``` -After snapshot, use @refs as selectors in any command: -``` -browse click @e3 Click the element assigned ref @e3 -browse fill @e4 "value" Fill the input assigned ref @e4 -browse hover @e1 Hover the element assigned ref @e1 -browse html @e2 Get innerHTML of ref @e2 -browse css @e5 "color" Get computed CSS of ref @e5 -browse attrs @e6 Get attributes of ref @e6 +### 7. Test responsive layouts +```bash +$B responsive /tmp/layout # mobile + tablet + desktop screenshots +$B viewport 375x812 # or set specific viewport +$B screenshot /tmp/mobile.png ``` -Refs are invalidated on navigation — run `snapshot` again after `goto`. - -### Interaction -``` -browse click Click element (CSS selector or @ref) -browse fill Fill input field -browse select Select dropdown value -browse hover Hover over element -browse type Type into focused element -browse press Press key (Enter, Tab, Escape, etc.) -browse scroll [selector] Scroll element into view, or page bottom -browse wait Wait for element to appear (max 10s) -browse viewport Set viewport size (e.g. 375x812) +### 8. Test file uploads +```bash +$B upload "#file-input" /path/to/file.pdf +$B is visible ".upload-success" ``` -### Inspection -``` -browse js Run JS, print result -browse eval Run JS file against page -browse css Get computed CSS property -browse attrs Get element attributes as JSON -browse console Dump captured console messages -browse console --clear Clear console buffer -browse network Dump captured network requests -browse network --clear Clear network buffer -browse cookies Dump all cookies as JSON -browse storage localStorage + sessionStorage as JSON -browse storage set Set localStorage value -browse perf Page load performance timings +### 9. Test dialogs +```bash +$B dialog-accept "yes" # set up handler +$B click "#delete-button" # trigger dialog +$B dialog # see what appeared +$B snapshot -D # verify deletion happened ``` -### Visual -``` -browse screenshot [path] Screenshot (default: /tmp/browse-screenshot.png) -browse pdf [path] Save as PDF -browse responsive [prefix] Screenshots at mobile/tablet/desktop +### 10. Compare environments +```bash +$B diff https://staging.app.com https://prod.app.com ``` -### Compare -``` -browse diff Text diff between two pages -``` +## Snapshot Flags -### Multi-step (chain) ``` -echo '[["goto","https://example.com"],["snapshot","-i"],["click","@e1"],["screenshot","/tmp/result.png"]]' | browse chain +-i Interactive elements only (buttons, links, inputs) +-c Compact (no empty structural nodes) +-d Limit depth +-s Scope to CSS selector +-D Diff against previous snapshot +-a Annotated screenshot with ref labels +-o Output path for screenshot +-C Cursor-interactive elements (@c refs) ``` -### Tabs -``` -browse tabs List tabs (id, url, title) -browse tab Switch to tab -browse newtab [url] Open new tab -browse closetab [id] Close tab -``` +Combine: `$B snapshot -i -a -C -o /tmp/annotated.png` -### Server management -``` -browse status Server health, uptime, tab count -browse stop Shutdown server -browse restart Kill + restart server -``` +Use @refs after snapshot: `$B click @e3`, `$B fill @e4 "value"`, `$B click @c1` + +## Full Command List -## Speed Rules - -1. **Navigate once, query many times.** `goto` loads the page; then `text`, `js`, `css`, `screenshot` all run against the loaded page instantly. -2. **Use `snapshot -i` for interaction.** Get refs for all interactive elements, then click/fill by ref. No need to guess CSS selectors. -3. **Use `js` for precision.** `js "document.querySelector('.price').textContent"` is faster than parsing full page text. -4. **Use `links` to survey.** Faster than `text` when you just need navigation structure. -5. **Use `chain` for multi-step flows.** Avoids CLI overhead per step. -6. **Use `responsive` for layout checks.** One command = 3 viewport screenshots. - -## When to Use What - -| Task | Commands | -|------|----------| -| Read a page | `goto ` then `text` | -| Interact with elements | `snapshot -i` then `click @e3` | -| Check if element exists | `js "!!document.querySelector('.thing')"` | -| Extract specific data | `js "document.querySelector('.price').textContent"` | -| Visual check | `screenshot /tmp/x.png` then Read the image | -| Fill and submit form | `snapshot -i` → `fill @e4 "val"` → `click @e5` → `screenshot` | -| Check CSS | `css "selector" "property"` or `css @e3 "property"` | -| Inspect DOM | `html "selector"` or `attrs @e3` | -| Debug console errors | `console` | -| Check network requests | `network` | -| Check local dev | `goto http://127.0.0.1:3000` | -| Compare two pages | `diff ` | -| Mobile layout check | `responsive /tmp/prefix` | -| Multi-step flow | `echo '[...]' \| browse chain` | - -## Architecture - -- Persistent Chromium daemon on localhost (port 9400-9410) -- Bearer token auth per session -- State file: `/tmp/browse-server.json` -- Console log: `/tmp/browse-console.log` -- Network log: `/tmp/browse-network.log` -- Auto-shutdown after 30 min idle -- Chromium crash → server exits → auto-restarts on next command +**Navigate:** goto, back, forward, reload, url +**Read:** text, html, links, forms, accessibility +**Snapshot:** snapshot (with flags above) +**Interact:** click, fill, select, hover, type, press, scroll, wait, wait --networkidle, wait --load, viewport, upload, cookie-import, dialog-accept, dialog-dismiss +**Inspect:** js, eval, css, attrs, is, console, console --errors, network, dialog, cookies, storage, perf +**Visual:** screenshot, pdf, responsive +**Compare:** diff +**Multi-step:** chain (pipe JSON array) +**Tabs:** tabs, tab, newtab, closetab +**Server:** status, stop, restart diff --git a/browse/bin/find-browse b/browse/bin/find-browse new file mode 100755 index 0000000..7288203 --- /dev/null +++ b/browse/bin/find-browse @@ -0,0 +1,11 @@ +#!/bin/bash +# Find the gstack browse binary. Echoes path and exits 0, or exits 1 if not found. +ROOT=$(git rev-parse --show-toplevel 2>/dev/null) +if [ -n "$ROOT" ] && test -x "$ROOT/.claude/skills/gstack/browse/dist/browse"; then + echo "$ROOT/.claude/skills/gstack/browse/dist/browse" +elif test -x "$HOME/.claude/skills/gstack/browse/dist/browse"; then + echo "$HOME/.claude/skills/gstack/browse/dist/browse" +else + echo "ERROR: browse binary not found. Run: cd && ./setup" >&2 + exit 1 +fi diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 033ed87..f6726e1 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -5,10 +5,18 @@ * browser.on('disconnected') → log error → process.exit(1) * CLI detects dead server → auto-restarts on next command * We do NOT try to self-heal — don't hide failure. + * + * Dialog handling: + * page.on('dialog') → auto-accept by default → store in dialog buffer + * Prevents browser lockup from alert/confirm/prompt + * + * Context recreation (useragent): + * recreateContext() saves cookies/storage/URLs, creates new context, + * restores state. Falls back to clean slate on any failure. */ import { chromium, type Browser, type BrowserContext, type Page, type Locator } from 'playwright'; -import { addConsoleEntry, addNetworkEntry, networkBuffer, type LogEntry, type NetworkEntry } from './buffers'; +import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers'; export class BrowserManager { private browser: Browser | null = null; @@ -19,9 +27,20 @@ export class BrowserManager { private extraHeaders: Record = {}; private customUserAgent: string | null = null; - // ─── Ref Map (snapshot → @e1, @e2, ...) ──────────────────── + /** Server port — set after server starts, used by cookie-import-browser command */ + public serverPort: number = 0; + + // ─── Ref Map (snapshot → @e1, @e2, @c1, @c2, ...) ──────── private refMap: Map = new Map(); + // ─── Snapshot Diffing ───────────────────────────────────── + // NOT cleared on navigation — it's a text baseline for diffing + private lastSnapshot: string | null = null; + + // ─── Dialog Handling ────────────────────────────────────── + private dialogAutoAccept: boolean = true; + private dialogPromptText: string | null = null; + async launch() { this.browser = await chromium.launch({ headless: true }); @@ -32,9 +51,17 @@ export class BrowserManager { process.exit(1); }); - this.context = await this.browser.newContext({ + const contextOptions: any = { viewport: { width: 1280, height: 720 }, - }); + }; + if (this.customUserAgent) { + contextOptions.userAgent = this.customUserAgent; + } + this.context = await this.browser.newContext(contextOptions); + + if (Object.keys(this.extraHeaders).length > 0) { + await this.context.setExtraHTTPHeaders(this.extraHeaders); + } // Create first tab await this.newTab(); @@ -49,8 +76,20 @@ export class BrowserManager { } } - isHealthy(): boolean { - return this.browser !== null && this.browser.isConnected(); + /** Health check — verifies Chromium is connected AND responsive */ + async isHealthy(): Promise { + if (!this.browser || !this.browser.isConnected()) return false; + try { + const page = this.pages.get(this.activeTabId); + if (!page) return true; // connected but no pages — still healthy + await Promise.race([ + page.evaluate('1'), + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 2000)), + ]); + return true; + } catch { + return false; + } } // ─── Tab Management ──────────────────────────────────────── @@ -62,7 +101,7 @@ export class BrowserManager { this.pages.set(id, page); this.activeTabId = id; - // Wire up console/network capture + // Wire up console/network/dialog capture this.wirePageEvents(page); if (url) { @@ -101,19 +140,6 @@ export class BrowserManager { return this.pages.size; } - getTabList(): Array<{ id: number; url: string; title: string; active: boolean }> { - const tabs: Array<{ id: number; url: string; title: string; active: boolean }> = []; - for (const [id, page] of this.pages) { - tabs.push({ - id, - url: page.url(), - title: '', // title requires await, populated by caller - active: id === this.activeTabId, - }); - } - return tabs; - } - async getTabListWithTitles(): Promise> { const tabs: Array<{ id: number; url: string; title: string; active: boolean }> = []; for (const [id, page] of this.pages) { @@ -152,12 +178,12 @@ export class BrowserManager { } /** - * Resolve a selector that may be a @ref (e.g., "@e3") or a CSS selector. + * Resolve a selector that may be a @ref (e.g., "@e3", "@c1") or a CSS selector. * Returns { locator } for refs or { selector } for CSS selectors. */ resolveRef(selector: string): { locator: Locator } | { selector: string } { - if (selector.startsWith('@e')) { - const ref = selector.slice(1); // "e3" + if (selector.startsWith('@e') || selector.startsWith('@c')) { + const ref = selector.slice(1); // "e3" or "c1" const locator = this.refMap.get(ref); if (!locator) { throw new Error( @@ -173,6 +199,32 @@ export class BrowserManager { return this.refMap.size; } + // ─── Snapshot Diffing ───────────────────────────────────── + setLastSnapshot(text: string | null) { + this.lastSnapshot = text; + } + + getLastSnapshot(): string | null { + return this.lastSnapshot; + } + + // ─── Dialog Control ─────────────────────────────────────── + setDialogAutoAccept(accept: boolean) { + this.dialogAutoAccept = accept; + } + + getDialogAutoAccept(): boolean { + return this.dialogAutoAccept; + } + + setDialogPromptText(text: string | null) { + this.dialogPromptText = text; + } + + getDialogPromptText(): string | null { + return this.dialogPromptText; + } + // ─── Viewport ────────────────────────────────────────────── async setViewport(width: number, height: number) { await this.getPage().setViewportSize({ width, height }); @@ -187,21 +239,169 @@ export class BrowserManager { } // ─── User Agent ──────────────────────────────────────────── - // Note: user agent changes require a new context in Playwright - // For simplicity, we just store it and apply on next "restart" setUserAgent(ua: string) { this.customUserAgent = ua; } - // ─── Console/Network/Ref Wiring ──────────────────────────── + getUserAgent(): string | null { + return this.customUserAgent; + } + + /** + * Recreate the browser context to apply user agent changes. + * Saves and restores cookies, localStorage, sessionStorage, and open pages. + * Falls back to a clean slate on any failure. + */ + async recreateContext(): Promise { + if (!this.browser || !this.context) { + throw new Error('Browser not launched'); + } + + try { + // 1. Save state from current context + const savedCookies = await this.context.cookies(); + const savedPages: Array<{ url: string; isActive: boolean; storage: any }> = []; + + for (const [id, page] of this.pages) { + const url = page.url(); + let storage = null; + try { + storage = await page.evaluate(() => ({ + localStorage: { ...localStorage }, + sessionStorage: { ...sessionStorage }, + })); + } catch {} + savedPages.push({ + url: url === 'about:blank' ? '' : url, + isActive: id === this.activeTabId, + storage, + }); + } + + // 2. Close old pages and context + for (const page of this.pages.values()) { + await page.close().catch(() => {}); + } + this.pages.clear(); + await this.context.close().catch(() => {}); + + // 3. Create new context with updated settings + const contextOptions: any = { + viewport: { width: 1280, height: 720 }, + }; + if (this.customUserAgent) { + contextOptions.userAgent = this.customUserAgent; + } + this.context = await this.browser.newContext(contextOptions); + + if (Object.keys(this.extraHeaders).length > 0) { + await this.context.setExtraHTTPHeaders(this.extraHeaders); + } + + // 4. Restore cookies + if (savedCookies.length > 0) { + await this.context.addCookies(savedCookies); + } + + // 5. Re-create pages + let activeId: number | null = null; + for (const saved of savedPages) { + const page = await this.context.newPage(); + const id = this.nextTabId++; + this.pages.set(id, page); + this.wirePageEvents(page); + + if (saved.url) { + await page.goto(saved.url, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {}); + } + + // 6. Restore storage + if (saved.storage) { + try { + await page.evaluate((s: any) => { + if (s.localStorage) { + for (const [k, v] of Object.entries(s.localStorage)) { + localStorage.setItem(k, v as string); + } + } + if (s.sessionStorage) { + for (const [k, v] of Object.entries(s.sessionStorage)) { + sessionStorage.setItem(k, v as string); + } + } + }, saved.storage); + } catch {} + } + + if (saved.isActive) activeId = id; + } + + // If no pages were saved, create a blank one + if (this.pages.size === 0) { + await this.newTab(); + } else { + this.activeTabId = activeId ?? [...this.pages.keys()][0]; + } + + // Clear refs — pages are new, locators are stale + this.clearRefs(); + + return null; // success + } catch (err: any) { + // Fallback: create a clean context + blank tab + try { + this.pages.clear(); + if (this.context) await this.context.close().catch(() => {}); + + const contextOptions: any = { + viewport: { width: 1280, height: 720 }, + }; + if (this.customUserAgent) { + contextOptions.userAgent = this.customUserAgent; + } + this.context = await this.browser!.newContext(contextOptions); + await this.newTab(); + this.clearRefs(); + } catch { + // If even the fallback fails, we're in trouble — but browser is still alive + } + return `Context recreation failed: ${err.message}. Browser reset to blank tab.`; + } + } + + // ─── Console/Network/Dialog/Ref Wiring ──────────────────── private wirePageEvents(page: Page) { // Clear ref map on navigation — refs point to stale elements after page change + // (lastSnapshot is NOT cleared — it's a text baseline for diffing) page.on('framenavigated', (frame) => { if (frame === page.mainFrame()) { this.clearRefs(); } }); + // ─── Dialog auto-handling (prevents browser lockup) ───── + page.on('dialog', async (dialog) => { + const entry: DialogEntry = { + timestamp: Date.now(), + type: dialog.type(), + message: dialog.message(), + defaultValue: dialog.defaultValue() || undefined, + action: this.dialogAutoAccept ? 'accepted' : 'dismissed', + response: this.dialogAutoAccept ? (this.dialogPromptText ?? undefined) : undefined, + }; + addDialogEntry(entry); + + try { + if (this.dialogAutoAccept) { + await dialog.accept(this.dialogPromptText ?? undefined); + } else { + await dialog.dismiss(); + } + } catch { + // Dialog may have been dismissed by navigation — ignore + } + }); + page.on('console', (msg) => { addConsoleEntry({ timestamp: Date.now(), @@ -219,13 +419,13 @@ export class BrowserManager { }); page.on('response', (res) => { - // Find matching request entry and update it + // Find matching request entry and update it (backward scan) const url = res.url(); const status = res.status(); for (let i = networkBuffer.length - 1; i >= 0; i--) { - if (networkBuffer[i].url === url && !networkBuffer[i].status) { - networkBuffer[i].status = status; - networkBuffer[i].duration = Date.now() - networkBuffer[i].timestamp; + const entry = networkBuffer.get(i); + if (entry && entry.url === url && !entry.status) { + networkBuffer.set(i, { ...entry, status, duration: Date.now() - entry.timestamp }); break; } } @@ -240,8 +440,9 @@ export class BrowserManager { const body = await res.body().catch(() => null); const size = body ? body.length : 0; for (let i = networkBuffer.length - 1; i >= 0; i--) { - if (networkBuffer[i].url === url && !networkBuffer[i].size) { - networkBuffer[i].size = size; + const entry = networkBuffer.get(i); + if (entry && entry.url === url && !entry.size) { + networkBuffer.set(i, { ...entry, size }); break; } } @@ -250,4 +451,3 @@ export class BrowserManager { }); } } - diff --git a/browse/src/buffers.ts b/browse/src/buffers.ts index 7cd19a4..27d3796 100644 --- a/browse/src/buffers.ts +++ b/browse/src/buffers.ts @@ -1,8 +1,95 @@ /** * Shared buffers and types — extracted to break circular dependency * between server.ts and browser-manager.ts + * + * CircularBuffer: O(1) insert ring buffer with fixed capacity. + * + * ┌───┬───┬───┬───┬───┬───┐ + * │ 3 │ 4 │ 5 │ │ 1 │ 2 │ capacity=6, head=4, size=5 + * └───┴───┴───┴───┴─▲─┴───┘ + * │ + * head (oldest entry) + * + * push() writes at (head+size) % capacity, O(1) + * toArray() returns entries in insertion order, O(n) + * totalAdded keeps incrementing past capacity (flush cursor) */ +// ─── CircularBuffer ───────────────────────────────────────── + +export class CircularBuffer { + private buffer: (T | undefined)[]; + private head: number = 0; + private _size: number = 0; + private _totalAdded: number = 0; + readonly capacity: number; + + constructor(capacity: number) { + this.capacity = capacity; + this.buffer = new Array(capacity); + } + + push(entry: T): void { + const index = (this.head + this._size) % this.capacity; + this.buffer[index] = entry; + if (this._size < this.capacity) { + this._size++; + } else { + // Buffer full — advance head (overwrites oldest) + this.head = (this.head + 1) % this.capacity; + } + this._totalAdded++; + } + + /** Return entries in insertion order (oldest first) */ + toArray(): T[] { + const result: T[] = []; + for (let i = 0; i < this._size; i++) { + result.push(this.buffer[(this.head + i) % this.capacity] as T); + } + return result; + } + + /** Return the last N entries (most recent first → reversed to oldest first) */ + last(n: number): T[] { + const count = Math.min(n, this._size); + const result: T[] = []; + const start = (this.head + this._size - count) % this.capacity; + for (let i = 0; i < count; i++) { + result.push(this.buffer[(start + i) % this.capacity] as T); + } + return result; + } + + get length(): number { + return this._size; + } + + get totalAdded(): number { + return this._totalAdded; + } + + clear(): void { + this.head = 0; + this._size = 0; + // Don't reset totalAdded — flush cursor depends on it + } + + /** Get entry by index (0 = oldest) — used by network response matching */ + get(index: number): T | undefined { + if (index < 0 || index >= this._size) return undefined; + return this.buffer[(this.head + index) % this.capacity]; + } + + /** Set entry by index (0 = oldest) — used by network response matching */ + set(index: number, entry: T): void { + if (index < 0 || index >= this._size) return; + this.buffer[(this.head + index) % this.capacity] = entry; + } +} + +// ─── Entry Types ──────────────────────────────────────────── + export interface LogEntry { timestamp: number; level: string; @@ -18,27 +105,33 @@ export interface NetworkEntry { size?: number; } -export const consoleBuffer: LogEntry[] = []; -export const networkBuffer: NetworkEntry[] = []; +export interface DialogEntry { + timestamp: number; + type: string; // 'alert' | 'confirm' | 'prompt' | 'beforeunload' + message: string; + defaultValue?: string; + action: string; // 'accepted' | 'dismissed' + response?: string; // text provided for prompt +} + +// ─── Buffer Instances ─────────────────────────────────────── + const HIGH_WATER_MARK = 50_000; -// Total entries ever added — used by server.ts flush logic as a cursor -// that keeps advancing even after the ring buffer wraps. -export let consoleTotalAdded = 0; -export let networkTotalAdded = 0; +export const consoleBuffer = new CircularBuffer(HIGH_WATER_MARK); +export const networkBuffer = new CircularBuffer(HIGH_WATER_MARK); +export const dialogBuffer = new CircularBuffer(HIGH_WATER_MARK); + +// ─── Convenience add functions ────────────────────────────── export function addConsoleEntry(entry: LogEntry) { - if (consoleBuffer.length >= HIGH_WATER_MARK) { - consoleBuffer.shift(); - } consoleBuffer.push(entry); - consoleTotalAdded++; } export function addNetworkEntry(entry: NetworkEntry) { - if (networkBuffer.length >= HIGH_WATER_MARK) { - networkBuffer.shift(); - } networkBuffer.push(entry); - networkTotalAdded++; +} + +export function addDialogEntry(entry: DialogEntry) { + dialogBuffer.push(entry); } diff --git a/browse/src/cli.ts b/browse/src/cli.ts index 931b85c..dae76fb 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -211,20 +211,29 @@ Navigation: goto | back | forward | reload | url Content: text | html [sel] | links | forms | accessibility Interaction: click | fill | select hover | type | press - scroll [sel] | wait | viewport + scroll [sel] | wait | viewport + upload [file2...] + cookie-import + cookie-import-browser [browser] [--domain ] Inspection: js | eval | css | attrs - console [--clear] | network [--clear] + console [--clear|--errors] | network [--clear] | dialog [--clear] cookies | storage [set ] | perf + is (visible|hidden|enabled|disabled|checked|editable|focused) Visual: screenshot [path] | pdf [path] | responsive [prefix] -Snapshot: snapshot [-i] [-c] [-d N] [-s sel] +Snapshot: snapshot [-i] [-c] [-d N] [-s sel] [-D] [-a] [-o path] [-C] + -D/--diff: diff against previous snapshot + -a/--annotate: annotated screenshot with ref labels + -C/--cursor-interactive: find non-ARIA clickable elements Compare: diff Multi-step: chain (reads JSON from stdin) Tabs: tabs | tab | newtab [url] | closetab [id] Server: status | cookie = | header : useragent | stop | restart +Dialogs: dialog-accept [text] | dialog-dismiss Refs: After 'snapshot', use @e1, @e2... as selectors: - click @e3 | fill @e4 "value" | hover @e1`); + click @e3 | fill @e4 "value" | hover @e1 + @c refs from -C: click @c1`); process.exit(0); } diff --git a/browse/src/cookie-import-browser.ts b/browse/src/cookie-import-browser.ts new file mode 100644 index 0000000..29d9db3 --- /dev/null +++ b/browse/src/cookie-import-browser.ts @@ -0,0 +1,417 @@ +/** + * Chromium browser cookie import — read and decrypt cookies from real browsers + * + * Supports macOS Chromium-based browsers: Comet, Chrome, Arc, Brave, Edge. + * Pure logic module — no Playwright dependency, no HTTP concerns. + * + * Decryption pipeline (Chromium macOS "v10" format): + * + * ┌──────────────────────────────────────────────────────────────────┐ + * │ 1. Keychain: `security find-generic-password -s "" -w` │ + * │ → base64 password string │ + * │ │ + * │ 2. Key derivation: │ + * │ PBKDF2(password, salt="saltysalt", iter=1003, len=16, sha1) │ + * │ → 16-byte AES key │ + * │ │ + * │ 3. For each cookie with encrypted_value starting with "v10": │ + * │ - Ciphertext = encrypted_value[3:] │ + * │ - IV = 16 bytes of 0x20 (space character) │ + * │ - Plaintext = AES-128-CBC-decrypt(key, iv, ciphertext) │ + * │ - Remove PKCS7 padding │ + * │ - Skip first 32 bytes (HMAC-SHA256 authentication tag) │ + * │ - Remaining bytes = cookie value (UTF-8) │ + * │ │ + * │ 4. If encrypted_value is empty but `value` field is set, │ + * │ use value directly (unencrypted cookie) │ + * │ │ + * │ 5. Chromium epoch: microseconds since 1601-01-01 │ + * │ Unix seconds = (epoch - 11644473600000000) / 1000000 │ + * │ │ + * │ 6. sameSite: 0→"None", 1→"Lax", 2→"Strict", else→"Lax" │ + * └──────────────────────────────────────────────────────────────────┘ + */ + +import { Database } from 'bun:sqlite'; +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// ─── Types ────────────────────────────────────────────────────── + +export interface BrowserInfo { + name: string; + dataDir: string; // relative to ~/Library/Application Support/ + keychainService: string; + aliases: string[]; +} + +export interface DomainEntry { + domain: string; + count: number; +} + +export interface ImportResult { + cookies: PlaywrightCookie[]; + count: number; + failed: number; + domainCounts: Record; +} + +export interface PlaywrightCookie { + name: string; + value: string; + domain: string; + path: string; + expires: number; + secure: boolean; + httpOnly: boolean; + sameSite: 'Strict' | 'Lax' | 'None'; +} + +export class CookieImportError extends Error { + constructor( + message: string, + public code: string, + public action?: 'retry', + ) { + super(message); + this.name = 'CookieImportError'; + } +} + +// ─── Browser Registry ─────────────────────────────────────────── +// Hardcoded — NEVER interpolate user input into shell commands. + +const BROWSER_REGISTRY: BrowserInfo[] = [ + { name: 'Comet', dataDir: 'Comet/', keychainService: 'Comet Safe Storage', aliases: ['comet', 'perplexity'] }, + { name: 'Chrome', dataDir: 'Google/Chrome/', keychainService: 'Chrome Safe Storage', aliases: ['chrome', 'google-chrome'] }, + { name: 'Arc', dataDir: 'Arc/User Data/', keychainService: 'Arc Safe Storage', aliases: ['arc'] }, + { name: 'Brave', dataDir: 'BraveSoftware/Brave-Browser/', keychainService: 'Brave Safe Storage', aliases: ['brave'] }, + { name: 'Edge', dataDir: 'Microsoft Edge/', keychainService: 'Microsoft Edge Safe Storage', aliases: ['edge'] }, +]; + +// ─── Key Cache ────────────────────────────────────────────────── +// Cache derived AES keys per browser. First import per browser does +// Keychain + PBKDF2. Subsequent imports reuse the cached key. + +const keyCache = new Map(); + +// ─── Public API ───────────────────────────────────────────────── + +/** + * Find which browsers are installed (have a cookie DB on disk). + */ +export function findInstalledBrowsers(): BrowserInfo[] { + const appSupport = path.join(os.homedir(), 'Library', 'Application Support'); + return BROWSER_REGISTRY.filter(b => { + const dbPath = path.join(appSupport, b.dataDir, 'Default', 'Cookies'); + try { return fs.existsSync(dbPath); } catch { return false; } + }); +} + +/** + * List unique cookie domains + counts from a browser's DB. No decryption. + */ +export function listDomains(browserName: string, profile = 'Default'): { domains: DomainEntry[]; browser: string } { + const browser = resolveBrowser(browserName); + const dbPath = getCookieDbPath(browser, profile); + const db = openDb(dbPath, browser.name); + try { + const now = chromiumNow(); + const rows = db.query( + `SELECT host_key AS domain, COUNT(*) AS count + FROM cookies + WHERE has_expires = 0 OR expires_utc > ? + GROUP BY host_key + ORDER BY count DESC` + ).all(now) as DomainEntry[]; + return { domains: rows, browser: browser.name }; + } finally { + db.close(); + } +} + +/** + * Decrypt and return Playwright-compatible cookies for specific domains. + */ +export async function importCookies( + browserName: string, + domains: string[], + profile = 'Default', +): Promise { + if (domains.length === 0) return { cookies: [], count: 0, failed: 0, domainCounts: {} }; + + const browser = resolveBrowser(browserName); + const derivedKey = await getDerivedKey(browser); + const dbPath = getCookieDbPath(browser, profile); + const db = openDb(dbPath, browser.name); + + try { + const now = chromiumNow(); + // Parameterized query — no SQL injection + const placeholders = domains.map(() => '?').join(','); + const rows = db.query( + `SELECT host_key, name, value, encrypted_value, path, expires_utc, + is_secure, is_httponly, has_expires, samesite + FROM cookies + WHERE host_key IN (${placeholders}) + AND (has_expires = 0 OR expires_utc > ?) + ORDER BY host_key, name` + ).all(...domains, now) as RawCookie[]; + + const cookies: PlaywrightCookie[] = []; + let failed = 0; + const domainCounts: Record = {}; + + for (const row of rows) { + try { + const value = decryptCookieValue(row, derivedKey); + const cookie = toPlaywrightCookie(row, value); + cookies.push(cookie); + domainCounts[row.host_key] = (domainCounts[row.host_key] || 0) + 1; + } catch { + failed++; + } + } + + return { cookies, count: cookies.length, failed, domainCounts }; + } finally { + db.close(); + } +} + +// ─── Internal: Browser Resolution ─────────────────────────────── + +function resolveBrowser(nameOrAlias: string): BrowserInfo { + const needle = nameOrAlias.toLowerCase().trim(); + const found = BROWSER_REGISTRY.find(b => + b.aliases.includes(needle) || b.name.toLowerCase() === needle + ); + if (!found) { + const supported = BROWSER_REGISTRY.flatMap(b => b.aliases).join(', '); + throw new CookieImportError( + `Unknown browser '${nameOrAlias}'. Supported: ${supported}`, + 'unknown_browser', + ); + } + return found; +} + +function validateProfile(profile: string): void { + if (/[/\\]|\.\./.test(profile) || /[\x00-\x1f]/.test(profile)) { + throw new CookieImportError( + `Invalid profile name: '${profile}'`, + 'bad_request', + ); + } +} + +function getCookieDbPath(browser: BrowserInfo, profile: string): string { + validateProfile(profile); + const appSupport = path.join(os.homedir(), 'Library', 'Application Support'); + const dbPath = path.join(appSupport, browser.dataDir, profile, 'Cookies'); + if (!fs.existsSync(dbPath)) { + throw new CookieImportError( + `${browser.name} is not installed (no cookie database at ${dbPath})`, + 'not_installed', + ); + } + return dbPath; +} + +// ─── Internal: SQLite Access ──────────────────────────────────── + +function openDb(dbPath: string, browserName: string): Database { + try { + return new Database(dbPath, { readonly: true }); + } catch (err: any) { + if (err.message?.includes('SQLITE_BUSY') || err.message?.includes('database is locked')) { + return openDbFromCopy(dbPath, browserName); + } + if (err.message?.includes('SQLITE_CORRUPT') || err.message?.includes('malformed')) { + throw new CookieImportError( + `Cookie database for ${browserName} is corrupt`, + 'db_corrupt', + ); + } + throw err; + } +} + +function openDbFromCopy(dbPath: string, browserName: string): Database { + const tmpPath = `/tmp/browse-cookies-${browserName.toLowerCase()}-${crypto.randomUUID()}.db`; + try { + fs.copyFileSync(dbPath, tmpPath); + // Also copy WAL and SHM if they exist (for consistent reads) + const walPath = dbPath + '-wal'; + const shmPath = dbPath + '-shm'; + if (fs.existsSync(walPath)) fs.copyFileSync(walPath, tmpPath + '-wal'); + if (fs.existsSync(shmPath)) fs.copyFileSync(shmPath, tmpPath + '-shm'); + + const db = new Database(tmpPath, { readonly: true }); + // Schedule cleanup after the DB is closed + const origClose = db.close.bind(db); + db.close = () => { + origClose(); + try { fs.unlinkSync(tmpPath); } catch {} + try { fs.unlinkSync(tmpPath + '-wal'); } catch {} + try { fs.unlinkSync(tmpPath + '-shm'); } catch {} + }; + return db; + } catch { + // Clean up on failure + try { fs.unlinkSync(tmpPath); } catch {} + throw new CookieImportError( + `Cookie database is locked (${browserName} may be running). Try closing ${browserName} first.`, + 'db_locked', + 'retry', + ); + } +} + +// ─── Internal: Keychain Access (async, 10s timeout) ───────────── + +async function getDerivedKey(browser: BrowserInfo): Promise { + const cached = keyCache.get(browser.keychainService); + if (cached) return cached; + + const password = await getKeychainPassword(browser.keychainService); + const derived = crypto.pbkdf2Sync(password, 'saltysalt', 1003, 16, 'sha1'); + keyCache.set(browser.keychainService, derived); + return derived; +} + +async function getKeychainPassword(service: string): Promise { + // Use async Bun.spawn with timeout to avoid blocking the event loop. + // macOS may show an Allow/Deny dialog that blocks until the user responds. + const proc = Bun.spawn( + ['security', 'find-generic-password', '-s', service, '-w'], + { stdout: 'pipe', stderr: 'pipe' }, + ); + + const timeout = new Promise((_, reject) => + setTimeout(() => { + proc.kill(); + reject(new CookieImportError( + `macOS is waiting for Keychain permission. Look for a dialog asking to allow access to "${service}".`, + 'keychain_timeout', + 'retry', + )); + }, 10_000), + ); + + try { + const exitCode = await Promise.race([proc.exited, timeout]); + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + + if (exitCode !== 0) { + // Distinguish denied vs not found vs other + const errText = stderr.trim().toLowerCase(); + if (errText.includes('user canceled') || errText.includes('denied') || errText.includes('interaction not allowed')) { + throw new CookieImportError( + `Keychain access denied. Click "Allow" in the macOS dialog for "${service}".`, + 'keychain_denied', + 'retry', + ); + } + if (errText.includes('could not be found') || errText.includes('not found')) { + throw new CookieImportError( + `No Keychain entry for "${service}". Is this a Chromium-based browser?`, + 'keychain_not_found', + ); + } + throw new CookieImportError( + `Could not read Keychain: ${stderr.trim()}`, + 'keychain_error', + 'retry', + ); + } + + return stdout.trim(); + } catch (err) { + if (err instanceof CookieImportError) throw err; + throw new CookieImportError( + `Could not read Keychain: ${(err as Error).message}`, + 'keychain_error', + 'retry', + ); + } +} + +// ─── Internal: Cookie Decryption ──────────────────────────────── + +interface RawCookie { + host_key: string; + name: string; + value: string; + encrypted_value: Buffer | Uint8Array; + path: string; + expires_utc: number | bigint; + is_secure: number; + is_httponly: number; + has_expires: number; + samesite: number; +} + +function decryptCookieValue(row: RawCookie, key: Buffer): string { + // Prefer unencrypted value if present + if (row.value && row.value.length > 0) return row.value; + + const ev = Buffer.from(row.encrypted_value); + if (ev.length === 0) return ''; + + const prefix = ev.slice(0, 3).toString('utf-8'); + if (prefix !== 'v10') { + throw new Error(`Unknown encryption prefix: ${prefix}`); + } + + const ciphertext = ev.slice(3); + const iv = Buffer.alloc(16, 0x20); // 16 space characters + const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv); + const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + + // First 32 bytes are HMAC-SHA256 authentication tag; actual value follows + if (plaintext.length <= 32) return ''; + return plaintext.slice(32).toString('utf-8'); +} + +function toPlaywrightCookie(row: RawCookie, value: string): PlaywrightCookie { + return { + name: row.name, + value, + domain: row.host_key, + path: row.path || '/', + expires: chromiumEpochToUnix(row.expires_utc, row.has_expires), + secure: row.is_secure === 1, + httpOnly: row.is_httponly === 1, + sameSite: mapSameSite(row.samesite), + }; +} + +// ─── Internal: Chromium Epoch Conversion ──────────────────────── + +const CHROMIUM_EPOCH_OFFSET = 11644473600000000n; + +function chromiumNow(): bigint { + // Current time in Chromium epoch (microseconds since 1601-01-01) + return BigInt(Date.now()) * 1000n + CHROMIUM_EPOCH_OFFSET; +} + +function chromiumEpochToUnix(epoch: number | bigint, hasExpires: number): number { + if (hasExpires === 0 || epoch === 0 || epoch === 0n) return -1; // session cookie + const epochBig = BigInt(epoch); + const unixMicro = epochBig - CHROMIUM_EPOCH_OFFSET; + return Number(unixMicro / 1000000n); +} + +function mapSameSite(value: number): 'Strict' | 'Lax' | 'None' { + switch (value) { + case 0: return 'None'; + case 1: return 'Lax'; + case 2: return 'Strict'; + default: return 'Lax'; + } +} diff --git a/browse/src/cookie-picker-routes.ts b/browse/src/cookie-picker-routes.ts new file mode 100644 index 0000000..e185af0 --- /dev/null +++ b/browse/src/cookie-picker-routes.ts @@ -0,0 +1,200 @@ +/** + * Cookie picker route handler — HTTP + Playwright glue + * + * Handles all /cookie-picker/* routes. Imports from cookie-import-browser.ts + * (decryption) and cookie-picker-ui.ts (HTML generation). + * + * Routes (no auth — localhost-only, accepted risk): + * GET /cookie-picker → serves the picker HTML page + * GET /cookie-picker/browsers → list installed browsers + * GET /cookie-picker/domains → list domains + counts for a browser + * POST /cookie-picker/import → decrypt + import cookies to Playwright + * POST /cookie-picker/remove → clear cookies for domains + * GET /cookie-picker/imported → currently imported domains + counts + */ + +import type { BrowserManager } from './browser-manager'; +import { findInstalledBrowsers, listDomains, importCookies, CookieImportError, type PlaywrightCookie } from './cookie-import-browser'; +import { getCookiePickerHTML } from './cookie-picker-ui'; + +// ─── State ────────────────────────────────────────────────────── +// Tracks which domains were imported via the picker. +// /imported only returns cookies for domains in this Set. +// /remove clears from this Set. +const importedDomains = new Set(); +const importedCounts = new Map(); + +// ─── JSON Helpers ─────────────────────────────────────────────── + +function jsonResponse(data: any, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': `http://127.0.0.1:${parseInt(url.port, 10) || 9400}`, + }, + }); +} + +function errorResponse(message: string, code: string, status = 400, action?: string): Response { + return jsonResponse({ error: message, code, ...(action ? { action } : {}) }, status); +} + +// ─── Route Handler ────────────────────────────────────────────── + +export async function handleCookiePickerRoute( + url: URL, + req: Request, + bm: BrowserManager, +): Promise { + const pathname = url.pathname; + + // CORS preflight + if (req.method === 'OPTIONS') { + return new Response(null, { + status: 204, + headers: { + 'Access-Control-Allow-Origin': `http://127.0.0.1:${parseInt(url.port, 10) || 9400}`, + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + }, + }); + } + + try { + // GET /cookie-picker — serve the picker UI + if (pathname === '/cookie-picker' && req.method === 'GET') { + const port = parseInt(url.port, 10) || 9400; + const html = getCookiePickerHTML(port); + return new Response(html, { + status: 200, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + } + + // GET /cookie-picker/browsers — list installed browsers + if (pathname === '/cookie-picker/browsers' && req.method === 'GET') { + const browsers = findInstalledBrowsers(); + return jsonResponse({ + browsers: browsers.map(b => ({ + name: b.name, + aliases: b.aliases, + })), + }); + } + + // GET /cookie-picker/domains?browser= — list domains + counts + if (pathname === '/cookie-picker/domains' && req.method === 'GET') { + const browserName = url.searchParams.get('browser'); + if (!browserName) { + return errorResponse("Missing 'browser' parameter", 'missing_param'); + } + const result = listDomains(browserName); + return jsonResponse({ + browser: result.browser, + domains: result.domains, + }); + } + + // POST /cookie-picker/import — decrypt + import to Playwright session + if (pathname === '/cookie-picker/import' && req.method === 'POST') { + let body: any; + try { + body = await req.json(); + } catch { + return errorResponse('Invalid JSON body', 'bad_request'); + } + + const { browser, domains } = body; + if (!browser) return errorResponse("Missing 'browser' field", 'missing_param'); + if (!domains || !Array.isArray(domains) || domains.length === 0) { + return errorResponse("Missing or empty 'domains' array", 'missing_param'); + } + + // Decrypt cookies from the browser DB + const result = await importCookies(browser, domains); + + if (result.cookies.length === 0) { + return jsonResponse({ + imported: 0, + failed: result.failed, + domainCounts: {}, + message: result.failed > 0 + ? `All ${result.failed} cookies failed to decrypt` + : 'No cookies found for the specified domains', + }); + } + + // Add to Playwright context + const page = bm.getPage(); + await page.context().addCookies(result.cookies); + + // Track what was imported + for (const domain of Object.keys(result.domainCounts)) { + importedDomains.add(domain); + importedCounts.set(domain, (importedCounts.get(domain) || 0) + result.domainCounts[domain]); + } + + console.log(`[cookie-picker] Imported ${result.count} cookies for ${Object.keys(result.domainCounts).length} domains`); + + return jsonResponse({ + imported: result.count, + failed: result.failed, + domainCounts: result.domainCounts, + }); + } + + // POST /cookie-picker/remove — clear cookies for domains + if (pathname === '/cookie-picker/remove' && req.method === 'POST') { + let body: any; + try { + body = await req.json(); + } catch { + return errorResponse('Invalid JSON body', 'bad_request'); + } + + const { domains } = body; + if (!domains || !Array.isArray(domains) || domains.length === 0) { + return errorResponse("Missing or empty 'domains' array", 'missing_param'); + } + + const page = bm.getPage(); + const context = page.context(); + for (const domain of domains) { + await context.clearCookies({ domain }); + importedDomains.delete(domain); + importedCounts.delete(domain); + } + + console.log(`[cookie-picker] Removed cookies for ${domains.length} domains`); + + return jsonResponse({ + removed: domains.length, + domains, + }); + } + + // GET /cookie-picker/imported — currently imported domains + counts + if (pathname === '/cookie-picker/imported' && req.method === 'GET') { + const entries: Array<{ domain: string; count: number }> = []; + for (const domain of importedDomains) { + entries.push({ domain, count: importedCounts.get(domain) || 0 }); + } + entries.sort((a, b) => b.count - a.count); + + return jsonResponse({ + domains: entries, + totalDomains: entries.length, + totalCookies: entries.reduce((sum, e) => sum + e.count, 0), + }); + } + + return new Response('Not found', { status: 404 }); + } catch (err: any) { + if (err instanceof CookieImportError) { + return errorResponse(err.message, err.code, 400, err.action); + } + console.error(`[cookie-picker] Error: ${err.message}`); + return errorResponse(err.message || 'Internal error', 'internal_error', 500); + } +} diff --git a/browse/src/cookie-picker-ui.ts b/browse/src/cookie-picker-ui.ts new file mode 100644 index 0000000..010c2dd --- /dev/null +++ b/browse/src/cookie-picker-ui.ts @@ -0,0 +1,541 @@ +/** + * Cookie picker UI — self-contained HTML page + * + * Dark theme, two-panel layout, vanilla HTML/CSS/JS. + * Left: source browser domains with search + import buttons. + * Right: imported domains with trash buttons. + * No cookie values exposed anywhere. + */ + +export function getCookiePickerHTML(serverPort: number): string { + const baseUrl = `http://127.0.0.1:${serverPort}`; + + return ` + + + + +Cookie Import — gstack browse + + + + +
+

Cookie Import

+ localhost:${serverPort} +
+ + + +
+ +
+
Source Browser
+
+
+ +
+
+
Detecting browsers...
+
+ +
+ + +
+
Imported to Session
+
+
No cookies imported yet
+
+ +
+
+ + + +`; +} diff --git a/browse/src/meta-commands.ts b/browse/src/meta-commands.ts index 0fbe9ae..8d3f9eb 100644 --- a/browse/src/meta-commands.ts +++ b/browse/src/meta-commands.ts @@ -4,8 +4,43 @@ 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, @@ -73,6 +108,7 @@ export async function handleMetaCommand( 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}`; } @@ -80,6 +116,7 @@ export async function handleMetaCommand( 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}`; } @@ -87,6 +124,7 @@ export async function handleMetaCommand( 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 }, @@ -129,16 +167,14 @@ export async function handleMetaCommand( const { handleReadCommand } = await import('./read-commands'); const { handleWriteCommand } = await import('./write-commands'); - const WRITE_SET = new Set(['goto','back','forward','reload','click','fill','select','hover','type','press','scroll','wait','viewport','cookie','header','useragent']); - const READ_SET = new Set(['text','html','links','forms','accessibility','js','eval','css','attrs','console','network','cookies','storage','perf']); - for (const cmd of commands) { const [name, ...cmdArgs] = cmd; try { let result: string; - if (WRITE_SET.has(name)) result = await handleWriteCommand(name, cmdArgs, bm); - else if (READ_SET.has(name)) result = await handleReadCommand(name, cmdArgs, bm); - else result = await handleMetaCommand(name, cmdArgs, bm, shutdown); + if (CHAIN_WRITE.has(name)) result = await handleWriteCommand(name, cmdArgs, bm); + else if (CHAIN_READ.has(name)) result = await handleReadCommand(name, cmdArgs, bm); + else if (CHAIN_META.has(name)) result = await handleMetaCommand(name, cmdArgs, bm, shutdown); + else throw new Error(`Unknown command: ${name}`); results.push(`[${name}] ${result}`); } catch (err: any) { results.push(`[${name}] ERROR: ${err.message}`); @@ -153,26 +189,12 @@ export async function handleMetaCommand( const [url1, url2] = args; if (!url1 || !url2) throw new Error('Usage: browse diff '); - // Get text from URL1 const page = bm.getPage(); await page.goto(url1, { waitUntil: 'domcontentloaded', timeout: 15000 }); - const text1 = await page.evaluate(() => { - const body = document.body; - if (!body) return ''; - const clone = body.cloneNode(true) as HTMLElement; - clone.querySelectorAll('script, style, noscript, svg').forEach(el => el.remove()); - return clone.innerText.split('\n').map(l => l.trim()).filter(l => l).join('\n'); - }); - - // Get text from URL2 + const text1 = await getCleanText(page); + await page.goto(url2, { waitUntil: 'domcontentloaded', timeout: 15000 }); - const text2 = await page.evaluate(() => { - const body = document.body; - if (!body) return ''; - const clone = body.cloneNode(true) as HTMLElement; - clone.querySelectorAll('script, style, noscript, svg').forEach(el => el.remove()); - return clone.innerText.split('\n').map(l => l.trim()).filter(l => l).join('\n'); - }); + const text2 = await getCleanText(page); const changes = Diff.diffLines(text1, text2); const output: string[] = [`--- ${url1}`, `+++ ${url2}`, '']; diff --git a/browse/src/read-commands.ts b/browse/src/read-commands.ts index a473477..31d1018 100644 --- a/browse/src/read-commands.ts +++ b/browse/src/read-commands.ts @@ -6,8 +6,45 @@ */ import type { BrowserManager } from './browser-manager'; -import { consoleBuffer, networkBuffer } from './buffers'; +import { consoleBuffer, networkBuffer, dialogBuffer } from './buffers'; +import type { Page } from 'playwright'; import * as fs from 'fs'; +import * as path from 'path'; + +// Security: Path validation to prevent path traversal attacks +const SAFE_DIRECTORIES = ['/tmp', process.cwd()]; + +function validateReadPath(filePath: string): void { + if (path.isAbsolute(filePath)) { + const resolved = path.resolve(filePath); + const isSafe = SAFE_DIRECTORIES.some(dir => resolved === dir || resolved.startsWith(dir + '/')); + if (!isSafe) { + throw new Error(`Absolute path must be within: ${SAFE_DIRECTORIES.join(', ')}`); + } + } + const normalized = path.normalize(filePath); + if (normalized.includes('..')) { + throw new Error('Path traversal sequences (..) are not allowed'); + } +} + +/** + * 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 { + return await page.evaluate(() => { + const body = document.body; + if (!body) return ''; + const clone = body.cloneNode(true) as HTMLElement; + clone.querySelectorAll('script, style, noscript, svg').forEach(el => el.remove()); + return clone.innerText + .split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0) + .join('\n'); + }); +} export async function handleReadCommand( command: string, @@ -18,17 +55,7 @@ export async function handleReadCommand( switch (command) { case 'text': { - return await page.evaluate(() => { - const body = document.body; - if (!body) return ''; - const clone = body.cloneNode(true) as HTMLElement; - clone.querySelectorAll('script, style, noscript, svg').forEach(el => el.remove()); - return clone.innerText - .split('\n') - .map(line => line.trim()) - .filter(line => line.length > 0) - .join('\n'); - }); + return await getCleanText(page); } case 'html': { @@ -65,7 +92,7 @@ export async function handleReadCommand( id: input.id || undefined, placeholder: input.placeholder || undefined, required: input.required || undefined, - value: input.value || undefined, + value: input.type === 'password' ? '[redacted]' : (input.value || undefined), options: el.tagName === 'SELECT' ? [...(el as HTMLSelectElement).options].map(o => ({ value: o.value, text: o.text })) : undefined, @@ -98,6 +125,7 @@ export async function handleReadCommand( case 'eval': { const filePath = args[0]; if (!filePath) throw new Error('Usage: browse eval '); + validateReadPath(filePath); if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`); const code = fs.readFileSync(filePath, 'utf-8'); const result = await page.evaluate(code); @@ -154,26 +182,71 @@ export async function handleReadCommand( case 'console': { if (args[0] === '--clear') { - consoleBuffer.length = 0; + consoleBuffer.clear(); return 'Console buffer cleared.'; } - if (consoleBuffer.length === 0) return '(no console messages)'; - return consoleBuffer.map(e => + const entries = args[0] === '--errors' + ? consoleBuffer.toArray().filter(e => e.level === 'error' || e.level === 'warning') + : consoleBuffer.toArray(); + if (entries.length === 0) return args[0] === '--errors' ? '(no console errors)' : '(no console messages)'; + return entries.map(e => `[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}` ).join('\n'); } case 'network': { if (args[0] === '--clear') { - networkBuffer.length = 0; + networkBuffer.clear(); return 'Network buffer cleared.'; } if (networkBuffer.length === 0) return '(no network requests)'; - return networkBuffer.map(e => + return networkBuffer.toArray().map(e => `${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)` ).join('\n'); } + case 'dialog': { + if (args[0] === '--clear') { + dialogBuffer.clear(); + return 'Dialog buffer cleared.'; + } + if (dialogBuffer.length === 0) return '(no dialogs captured)'; + return dialogBuffer.toArray().map(e => + `[${new Date(e.timestamp).toISOString()}] [${e.type}] "${e.message}" → ${e.action}${e.response ? ` "${e.response}"` : ''}` + ).join('\n'); + } + + case 'is': { + const property = args[0]; + const selector = args[1]; + if (!property || !selector) throw new Error('Usage: browse is \nProperties: visible, hidden, enabled, disabled, checked, editable, focused'); + + const resolved = bm.resolveRef(selector); + let locator; + if ('locator' in resolved) { + locator = resolved.locator; + } else { + locator = page.locator(resolved.selector); + } + + switch (property) { + case 'visible': return String(await locator.isVisible()); + case 'hidden': return String(await locator.isHidden()); + case 'enabled': return String(await locator.isEnabled()); + case 'disabled': return String(await locator.isDisabled()); + case 'checked': return String(await locator.isChecked()); + case 'editable': return String(await locator.isEditable()); + case 'focused': { + const isFocused = await locator.evaluate( + (el) => el === document.activeElement + ); + return String(isFocused); + } + default: + throw new Error(`Unknown property: ${property}. Use: visible, hidden, enabled, disabled, checked, editable, focused`); + } + } + case 'cookies': { const cookies = await page.context().cookies(); return JSON.stringify(cookies, null, 2); @@ -184,7 +257,7 @@ export async function handleReadCommand( const key = args[1]; const value = args[2] || ''; await page.evaluate(([k, v]) => localStorage.setItem(k, v), [key, value]); - return `Set localStorage["${key}"] = "${value}"`; + return `Set localStorage["${key}"]`; } const storage = await page.evaluate(() => ({ localStorage: { ...localStorage }, diff --git a/browse/src/server.ts b/browse/src/server.ts index 71e3b5c..0825b17 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -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; + + 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; + } - 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; + // 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 { // Deterministic port from CONDUCTOR_PORT (e.g., 55040 - 45600 = 9440) @@ -134,6 +159,29 @@ async function findPort(): Promise { 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 { const { command, args = [] } = body; @@ -168,7 +216,7 @@ async function handleCommand(body: any): Promise { 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`); diff --git a/browse/src/snapshot.ts b/browse/src/snapshot.ts index d8d0da0..b0c7b80 100644 --- a/browse/src/snapshot.ts +++ b/browse/src/snapshot.ts @@ -8,11 +8,18 @@ * 4. Store Map on BrowserManager * 5. Return compact text output with refs prepended * + * Extended features: + * --diff / -D: Compare against last snapshot, return unified diff + * --annotate / -a: Screenshot with overlay boxes at each @ref + * --output / -o: Output path for annotated screenshot + * -C / --cursor-interactive: Scan for cursor:pointer/onclick/tabindex elements + * * Later: "click @e3" → look up Locator → locator.click() */ import type { Page, Locator } from 'playwright'; import type { BrowserManager } from './browser-manager'; +import * as Diff from 'diff'; // Roles considered "interactive" for the -i flag const INTERACTIVE_ROLES = new Set([ @@ -23,10 +30,14 @@ const INTERACTIVE_ROLES = new Set([ ]); interface SnapshotOptions { - interactive?: boolean; // -i: only interactive elements - compact?: boolean; // -c: remove empty structural elements - depth?: number; // -d N: limit tree depth - selector?: string; // -s SEL: scope to CSS selector + interactive?: boolean; // -i: only interactive elements + compact?: boolean; // -c: remove empty structural elements + depth?: number; // -d N: limit tree depth + selector?: string; // -s SEL: scope to CSS selector + diff?: boolean; // -D / --diff: diff against last snapshot + annotate?: boolean; // -a / --annotate: annotated screenshot + outputPath?: string; // -o / --output: path for annotated screenshot + cursorInteractive?: boolean; // -C / --cursor-interactive: scan cursor:pointer etc. } interface ParsedNode { @@ -63,6 +74,23 @@ export function parseSnapshotArgs(args: string[]): SnapshotOptions { opts.selector = args[++i]; if (!opts.selector) throw new Error('Usage: snapshot -s '); break; + case '-D': + case '--diff': + opts.diff = true; + break; + case '-a': + case '--annotate': + opts.annotate = true; + break; + case '-o': + case '--output': + opts.outputPath = args[++i]; + if (!opts.outputPath) throw new Error('Usage: snapshot -o '); + break; + case '-C': + case '--cursor-interactive': + opts.cursorInteractive = true; + break; default: throw new Error(`Unknown snapshot flag: ${args[i]}`); } @@ -201,6 +229,74 @@ export async function handleSnapshot( output.push(outputLine); } + // ─── Cursor-interactive scan (-C) ───────────────────────── + if (opts.cursorInteractive) { + try { + const cursorElements = await page.evaluate(() => { + const STANDARD_INTERACTIVE = new Set([ + 'A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'SUMMARY', 'DETAILS', + ]); + + const results: Array<{ selector: string; text: string; reason: string }> = []; + const allElements = document.querySelectorAll('*'); + + for (const el of allElements) { + // Skip standard interactive elements (already in ARIA tree) + if (STANDARD_INTERACTIVE.has(el.tagName)) continue; + // Skip hidden elements + if (!(el as HTMLElement).offsetParent && el.tagName !== 'BODY') continue; + + const style = getComputedStyle(el); + const hasCursorPointer = style.cursor === 'pointer'; + const hasOnclick = el.hasAttribute('onclick'); + const hasTabindex = el.hasAttribute('tabindex') && parseInt(el.getAttribute('tabindex')!, 10) >= 0; + const hasRole = el.hasAttribute('role'); + + if (!hasCursorPointer && !hasOnclick && !hasTabindex) continue; + // Skip if it has an ARIA role (likely already captured) + if (hasRole) continue; + + // Build deterministic nth-child CSS path + const parts: string[] = []; + let current: Element | null = el; + while (current && current !== document.documentElement) { + const parent = current.parentElement; + if (!parent) break; + const siblings = [...parent.children]; + const index = siblings.indexOf(current) + 1; + parts.unshift(`${current.tagName.toLowerCase()}:nth-child(${index})`); + current = parent; + } + const selector = parts.join(' > '); + + const text = (el as HTMLElement).innerText?.trim().slice(0, 80) || el.tagName.toLowerCase(); + const reasons: string[] = []; + if (hasCursorPointer) reasons.push('cursor:pointer'); + if (hasOnclick) reasons.push('onclick'); + if (hasTabindex) reasons.push(`tabindex=${el.getAttribute('tabindex')}`); + + results.push({ selector, text, reason: reasons.join(', ') }); + } + return results; + }); + + if (cursorElements.length > 0) { + output.push(''); + output.push('── cursor-interactive (not in ARIA tree) ──'); + let cRefCounter = 1; + for (const elem of cursorElements) { + const ref = `c${cRefCounter++}`; + const locator = page.locator(elem.selector); + refMap.set(ref, locator); + output.push(`@${ref} [${elem.reason}] "${elem.text}"`); + } + } + } catch { + output.push(''); + output.push('(cursor scan failed — CSP restriction)'); + } + } + // Store ref map on BrowserManager bm.setRefMap(refMap); @@ -208,5 +304,94 @@ export async function handleSnapshot( return '(no interactive elements found)'; } + const snapshotText = output.join('\n'); + + // ─── Annotated screenshot (-a) ──────────────────────────── + if (opts.annotate) { + const screenshotPath = opts.outputPath || '/tmp/browse-annotated.png'; + // Validate output path (consistent with screenshot/pdf/responsive) + const resolvedPath = require('path').resolve(screenshotPath); + const safeDirs = ['/tmp', process.cwd()]; + if (!safeDirs.some((dir: string) => resolvedPath === dir || resolvedPath.startsWith(dir + '/'))) { + throw new Error(`Path must be within: ${safeDirs.join(', ')}`); + } + try { + // Inject overlay divs at each ref's bounding box + const boxes: Array<{ ref: string; box: { x: number; y: number; width: number; height: number } }> = []; + for (const [ref, locator] of refMap) { + try { + const box = await locator.boundingBox({ timeout: 1000 }); + if (box) { + boxes.push({ ref: `@${ref}`, box }); + } + } catch { + // Element may be offscreen or hidden — skip + } + } + + await page.evaluate((boxes) => { + for (const { ref, box } of boxes) { + const overlay = document.createElement('div'); + overlay.className = '__browse_annotation__'; + overlay.style.cssText = ` + position: absolute; top: ${box.y}px; left: ${box.x}px; + width: ${box.width}px; height: ${box.height}px; + border: 2px solid red; background: rgba(255,0,0,0.1); + pointer-events: none; z-index: 99999; + font-size: 10px; color: red; font-weight: bold; + `; + const label = document.createElement('span'); + label.textContent = ref; + label.style.cssText = 'position: absolute; top: -14px; left: 0; background: red; color: white; padding: 0 3px; font-size: 10px;'; + overlay.appendChild(label); + document.body.appendChild(overlay); + } + }, boxes); + + await page.screenshot({ path: screenshotPath, fullPage: true }); + + // Always remove overlays + await page.evaluate(() => { + document.querySelectorAll('.__browse_annotation__').forEach(el => el.remove()); + }); + + output.push(''); + output.push(`[annotated screenshot: ${screenshotPath}]`); + } catch { + // Remove overlays even on screenshot failure + try { + await page.evaluate(() => { + document.querySelectorAll('.__browse_annotation__').forEach(el => el.remove()); + }); + } catch {} + } + } + + // ─── Diff mode (-D) ─────────────────────────────────────── + if (opts.diff) { + const lastSnapshot = bm.getLastSnapshot(); + if (!lastSnapshot) { + bm.setLastSnapshot(snapshotText); + return snapshotText + '\n\n(no previous snapshot to diff against — this snapshot stored as baseline)'; + } + + const changes = Diff.diffLines(lastSnapshot, snapshotText); + const diffOutput: string[] = ['--- previous snapshot', '+++ current snapshot', '']; + + for (const part of changes) { + const prefix = part.added ? '+' : part.removed ? '-' : ' '; + const diffLines = part.value.split('\n').filter(l => l.length > 0); + for (const line of diffLines) { + diffOutput.push(`${prefix} ${line}`); + } + } + + bm.setLastSnapshot(snapshotText); + return diffOutput.join('\n'); + } + + // Store for future diffs + bm.setLastSnapshot(snapshotText); + return output.join('\n'); } diff --git a/browse/src/write-commands.ts b/browse/src/write-commands.ts index e1c9194..08c9425 100644 --- a/browse/src/write-commands.ts +++ b/browse/src/write-commands.ts @@ -6,6 +6,9 @@ */ import type { BrowserManager } from './browser-manager'; +import { findInstalledBrowsers, importCookies } from './cookie-import-browser'; +import * as fs from 'fs'; +import * as path from 'path'; export async function handleWriteCommand( command: string, @@ -94,7 +97,7 @@ export async function handleWriteCommand( const text = args.join(' '); if (!text) throw new Error('Usage: browse type '); await page.keyboard.type(text); - return `Typed "${text}"`; + return `Typed ${text.length} characters`; } case 'press': { @@ -121,7 +124,20 @@ export async function handleWriteCommand( case 'wait': { const selector = args[0]; - if (!selector) throw new Error('Usage: browse wait '); + if (!selector) throw new Error('Usage: browse wait '); + if (selector === '--networkidle') { + const timeout = args[1] ? parseInt(args[1], 10) : 15000; + await page.waitForLoadState('networkidle', { timeout }); + return 'Network idle'; + } + if (selector === '--load') { + await page.waitForLoadState('load'); + return 'Page loaded'; + } + if (selector === '--domcontentloaded') { + await page.waitForLoadState('domcontentloaded'); + return 'DOM content loaded'; + } const timeout = args[1] ? parseInt(args[1], 10) : 15000; const resolved = bm.resolveRef(selector); if ('locator' in resolved) { @@ -153,7 +169,7 @@ export async function handleWriteCommand( domain: url.hostname, path: '/', }]); - return `Cookie set: ${name}=${value}`; + return `Cookie set: ${name}=****`; } case 'header': { @@ -163,14 +179,131 @@ export async function handleWriteCommand( const name = headerStr.slice(0, sep).trim(); const value = headerStr.slice(sep + 1).trim(); await bm.setExtraHeader(name, value); - return `Header set: ${name}: ${value}`; + const sensitiveHeaders = ['authorization', 'cookie', 'set-cookie', 'x-api-key', 'x-auth-token']; + const redactedValue = sensitiveHeaders.includes(name.toLowerCase()) ? '****' : value; + return `Header set: ${name}: ${redactedValue}`; } case 'useragent': { const ua = args.join(' '); if (!ua) throw new Error('Usage: browse useragent '); bm.setUserAgent(ua); - return `User agent set (applies on next restart): ${ua}`; + const error = await bm.recreateContext(); + if (error) { + return `User agent set to "${ua}" but: ${error}`; + } + return `User agent set: ${ua}`; + } + + case 'upload': { + const [selector, ...filePaths] = args; + if (!selector || filePaths.length === 0) throw new Error('Usage: browse upload [file2...]'); + + // Validate all files exist before upload + for (const fp of filePaths) { + if (!fs.existsSync(fp)) throw new Error(`File not found: ${fp}`); + } + + const resolved = bm.resolveRef(selector); + if ('locator' in resolved) { + await resolved.locator.setInputFiles(filePaths); + } else { + await page.locator(resolved.selector).setInputFiles(filePaths); + } + + const fileInfo = filePaths.map(fp => { + const stat = fs.statSync(fp); + return `${path.basename(fp)} (${stat.size}B)`; + }).join(', '); + return `Uploaded: ${fileInfo}`; + } + + case 'dialog-accept': { + const text = args.length > 0 ? args.join(' ') : null; + bm.setDialogAutoAccept(true); + bm.setDialogPromptText(text); + return text + ? `Dialogs will be accepted with text: "${text}"` + : 'Dialogs will be accepted'; + } + + case 'dialog-dismiss': { + bm.setDialogAutoAccept(false); + bm.setDialogPromptText(null); + return 'Dialogs will be dismissed'; + } + + case 'cookie-import': { + const filePath = args[0]; + if (!filePath) throw new Error('Usage: browse cookie-import '); + // Path validation — prevent reading arbitrary files + if (path.isAbsolute(filePath)) { + const safeDirs = ['/tmp', process.cwd()]; + const resolved = path.resolve(filePath); + if (!safeDirs.some(dir => resolved === dir || resolved.startsWith(dir + '/'))) { + throw new Error(`Path must be within: ${safeDirs.join(', ')}`); + } + } + if (path.normalize(filePath).includes('..')) { + throw new Error('Path traversal sequences (..) are not allowed'); + } + if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`); + const raw = fs.readFileSync(filePath, 'utf-8'); + let cookies: any[]; + try { cookies = JSON.parse(raw); } catch { throw new Error(`Invalid JSON in ${filePath}`); } + if (!Array.isArray(cookies)) throw new Error('Cookie file must contain a JSON array'); + + // Auto-fill domain from current page URL when missing (consistent with cookie command) + const pageUrl = new URL(page.url()); + const defaultDomain = pageUrl.hostname; + + for (const c of cookies) { + if (!c.name || c.value === undefined) throw new Error('Each cookie must have "name" and "value" fields'); + if (!c.domain) c.domain = defaultDomain; + if (!c.path) c.path = '/'; + } + + await page.context().addCookies(cookies); + return `Loaded ${cookies.length} cookies from ${filePath}`; + } + + case 'cookie-import-browser': { + // Two modes: + // 1. Direct CLI import: cookie-import-browser --domain + // 2. Open picker UI: cookie-import-browser [browser] + const browserArg = args[0]; + const domainIdx = args.indexOf('--domain'); + + if (domainIdx !== -1 && domainIdx + 1 < args.length) { + // Direct import mode — no UI + const domain = args[domainIdx + 1]; + const browser = browserArg || 'comet'; + const result = await importCookies(browser, [domain]); + if (result.cookies.length > 0) { + await page.context().addCookies(result.cookies); + } + const msg = [`Imported ${result.count} cookies for ${domain} from ${browser}`]; + if (result.failed > 0) msg.push(`(${result.failed} failed to decrypt)`); + return msg.join(' '); + } + + // Picker UI mode — open in user's browser + const port = bm.serverPort; + if (!port) throw new Error('Server port not available'); + + const browsers = findInstalledBrowsers(); + if (browsers.length === 0) { + throw new Error('No Chromium browsers found. Supported: Comet, Chrome, Arc, Brave, Edge'); + } + + const pickerUrl = `http://127.0.0.1:${port}/cookie-picker`; + try { + Bun.spawn(['open', pickerUrl], { stdout: 'ignore', stderr: 'ignore' }); + } catch { + // open may fail silently — URL is in the message below + } + + return `Cookie picker opened at ${pickerUrl}\nDetected browsers: ${browsers.map(b => b.name).join(', ')}\nSelect domains to import, then close the picker when done.`; } default: diff --git a/browse/test/commands.test.ts b/browse/test/commands.test.ts index 0d572bb..312b8ce 100644 --- a/browse/test/commands.test.ts +++ b/browse/test/commands.test.ts @@ -12,7 +12,7 @@ import { resolveServerScript } from '../src/cli'; import { handleReadCommand } from '../src/read-commands'; import { handleWriteCommand } from '../src/write-commands'; import { handleMetaCommand } from '../src/meta-commands'; -import { consoleBuffer, networkBuffer, addConsoleEntry, addNetworkEntry, consoleTotalAdded, networkTotalAdded } from '../src/buffers'; +import { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, CircularBuffer } from '../src/buffers'; import * as fs from 'fs'; import { spawn } from 'child_process'; import * as path from 'path'; @@ -457,14 +457,18 @@ describe('CLI lifecycle', () => { })); const cliPath = path.resolve(__dirname, '../src/cli.ts'); + // Build env without CONDUCTOR_PORT/BROWSE_PORT so BROWSE_PORT_START takes effect + const cliEnv: Record = {}; + for (const [k, v] of Object.entries(process.env)) { + if (k !== 'CONDUCTOR_PORT' && k !== 'BROWSE_PORT' && v !== undefined) cliEnv[k] = v; + } + cliEnv.BROWSE_STATE_FILE = stateFile; + // Use a random high port to avoid conflicts with running servers + cliEnv.BROWSE_PORT_START = String(9600 + Math.floor(Math.random() * 100)); const result = await new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => { const proc = spawn('bun', ['run', cliPath, 'status'], { timeout: 15000, - env: { - ...process.env, - BROWSE_STATE_FILE: stateFile, - BROWSE_PORT_START: '9520', - }, + env: cliEnv, }); let stdout = ''; let stderr = ''; @@ -492,37 +496,1106 @@ describe('CLI lifecycle', () => { describe('Buffer bounds', () => { test('console buffer caps at 50000 entries', () => { - consoleBuffer.length = 0; + consoleBuffer.clear(); for (let i = 0; i < 50_010; i++) { addConsoleEntry({ timestamp: i, level: 'log', text: `msg-${i}` }); } expect(consoleBuffer.length).toBe(50_000); - expect(consoleBuffer[0].text).toBe('msg-10'); - expect(consoleBuffer[consoleBuffer.length - 1].text).toBe('msg-50009'); - consoleBuffer.length = 0; + const entries = consoleBuffer.toArray(); + expect(entries[0].text).toBe('msg-10'); + expect(entries[entries.length - 1].text).toBe('msg-50009'); + consoleBuffer.clear(); }); test('network buffer caps at 50000 entries', () => { - networkBuffer.length = 0; + networkBuffer.clear(); for (let i = 0; i < 50_010; i++) { addNetworkEntry({ timestamp: i, method: 'GET', url: `http://x/${i}` }); } expect(networkBuffer.length).toBe(50_000); - expect(networkBuffer[0].url).toBe('http://x/10'); - expect(networkBuffer[networkBuffer.length - 1].url).toBe('http://x/50009'); - networkBuffer.length = 0; + const entries = networkBuffer.toArray(); + expect(entries[0].url).toBe('http://x/10'); + expect(entries[entries.length - 1].url).toBe('http://x/50009'); + networkBuffer.clear(); }); test('totalAdded counters keep incrementing past buffer cap', () => { - const startConsole = consoleTotalAdded; - const startNetwork = networkTotalAdded; + const startConsole = consoleBuffer.totalAdded; + const startNetwork = networkBuffer.totalAdded; for (let i = 0; i < 100; i++) { addConsoleEntry({ timestamp: i, level: 'log', text: `t-${i}` }); addNetworkEntry({ timestamp: i, method: 'GET', url: `http://t/${i}` }); } - expect(consoleTotalAdded).toBe(startConsole + 100); - expect(networkTotalAdded).toBe(startNetwork + 100); - consoleBuffer.length = 0; - networkBuffer.length = 0; + expect(consoleBuffer.totalAdded).toBe(startConsole + 100); + expect(networkBuffer.totalAdded).toBe(startNetwork + 100); + consoleBuffer.clear(); + networkBuffer.clear(); + }); +}); + +// ─── CircularBuffer Unit Tests ───────────────────────────────── + +describe('CircularBuffer', () => { + test('push and toArray return items in insertion order', () => { + const buf = new CircularBuffer(5); + buf.push(1); buf.push(2); buf.push(3); + expect(buf.toArray()).toEqual([1, 2, 3]); + expect(buf.length).toBe(3); + }); + + test('overwrites oldest when full', () => { + const buf = new CircularBuffer(3); + buf.push(1); buf.push(2); buf.push(3); buf.push(4); + expect(buf.toArray()).toEqual([2, 3, 4]); + expect(buf.length).toBe(3); + }); + + test('totalAdded increments past capacity', () => { + const buf = new CircularBuffer(2); + buf.push(1); buf.push(2); buf.push(3); buf.push(4); buf.push(5); + expect(buf.totalAdded).toBe(5); + expect(buf.length).toBe(2); + expect(buf.toArray()).toEqual([4, 5]); + }); + + test('last(n) returns most recent entries', () => { + const buf = new CircularBuffer(5); + for (let i = 1; i <= 5; i++) buf.push(i); + expect(buf.last(3)).toEqual([3, 4, 5]); + expect(buf.last(10)).toEqual([1, 2, 3, 4, 5]); // clamped + expect(buf.last(1)).toEqual([5]); + }); + + test('get and set work by index', () => { + const buf = new CircularBuffer(3); + buf.push('a'); buf.push('b'); buf.push('c'); + expect(buf.get(0)).toBe('a'); + expect(buf.get(2)).toBe('c'); + buf.set(1, 'B'); + expect(buf.get(1)).toBe('B'); + expect(buf.get(-1)).toBeUndefined(); + expect(buf.get(5)).toBeUndefined(); + }); + + test('clear resets size but not totalAdded', () => { + const buf = new CircularBuffer(5); + buf.push(1); buf.push(2); buf.push(3); + buf.clear(); + expect(buf.length).toBe(0); + expect(buf.totalAdded).toBe(3); + expect(buf.toArray()).toEqual([]); + }); + + test('works with capacity=1', () => { + const buf = new CircularBuffer(1); + buf.push(10); + expect(buf.toArray()).toEqual([10]); + buf.push(20); + expect(buf.toArray()).toEqual([20]); + expect(buf.totalAdded).toBe(2); + }); +}); + +// ─── Dialog Handling ───────────────────────────────────────── + +describe('Dialog handling', () => { + test('alert does not hang — auto-accepted', async () => { + await handleWriteCommand('goto', [baseUrl + '/dialog.html'], bm); + await handleWriteCommand('click', ['#alert-btn'], bm); + // If we get here, dialog was handled (no hang) + const result = await handleReadCommand('dialog', [], bm); + expect(result).toContain('alert'); + expect(result).toContain('Hello from alert'); + expect(result).toContain('accepted'); + }); + + test('confirm is auto-accepted by default', async () => { + await handleWriteCommand('goto', [baseUrl + '/dialog.html'], bm); + await handleWriteCommand('click', ['#confirm-btn'], bm); + // Wait for DOM update + await new Promise(r => setTimeout(r, 100)); + const result = await handleReadCommand('js', ['document.querySelector("#confirm-result").textContent'], bm); + expect(result).toBe('confirmed'); + }); + + test('dialog-dismiss changes behavior', async () => { + const setResult = await handleWriteCommand('dialog-dismiss', [], bm); + expect(setResult).toContain('dismissed'); + + await handleWriteCommand('goto', [baseUrl + '/dialog.html'], bm); + await handleWriteCommand('click', ['#confirm-btn'], bm); + await new Promise(r => setTimeout(r, 100)); + const result = await handleReadCommand('js', ['document.querySelector("#confirm-result").textContent'], bm); + expect(result).toBe('cancelled'); + + // Reset to accept + await handleWriteCommand('dialog-accept', [], bm); + }); + + test('dialog-accept with text provides prompt response', async () => { + const setResult = await handleWriteCommand('dialog-accept', ['TestUser'], bm); + expect(setResult).toContain('TestUser'); + + await handleWriteCommand('goto', [baseUrl + '/dialog.html'], bm); + await handleWriteCommand('click', ['#prompt-btn'], bm); + await new Promise(r => setTimeout(r, 100)); + const result = await handleReadCommand('js', ['document.querySelector("#prompt-result").textContent'], bm); + expect(result).toBe('TestUser'); + + // Reset + await handleWriteCommand('dialog-accept', [], bm); + }); + + test('dialog --clear clears buffer', async () => { + const cleared = await handleReadCommand('dialog', ['--clear'], bm); + expect(cleared).toContain('cleared'); + const after = await handleReadCommand('dialog', [], bm); + expect(after).toContain('no dialogs'); + }); +}); + +// ─── Element State Checks (is) ───────────────────────────────── + +describe('Element state checks', () => { + beforeAll(async () => { + await handleWriteCommand('goto', [baseUrl + '/states.html'], bm); + }); + + test('is visible returns true for visible element', async () => { + const result = await handleReadCommand('is', ['visible', '#visible-div'], bm); + expect(result).toBe('true'); + }); + + test('is hidden returns true for hidden element', async () => { + const result = await handleReadCommand('is', ['hidden', '#hidden-div'], bm); + expect(result).toBe('true'); + }); + + test('is visible returns false for hidden element', async () => { + const result = await handleReadCommand('is', ['visible', '#hidden-div'], bm); + expect(result).toBe('false'); + }); + + test('is enabled returns true for enabled input', async () => { + const result = await handleReadCommand('is', ['enabled', '#enabled-input'], bm); + expect(result).toBe('true'); + }); + + test('is disabled returns true for disabled input', async () => { + const result = await handleReadCommand('is', ['disabled', '#disabled-input'], bm); + expect(result).toBe('true'); + }); + + test('is checked returns true for checked checkbox', async () => { + const result = await handleReadCommand('is', ['checked', '#checked-box'], bm); + expect(result).toBe('true'); + }); + + test('is checked returns false for unchecked checkbox', async () => { + const result = await handleReadCommand('is', ['checked', '#unchecked-box'], bm); + expect(result).toBe('false'); + }); + + test('is editable returns true for normal input', async () => { + const result = await handleReadCommand('is', ['editable', '#enabled-input'], bm); + expect(result).toBe('true'); + }); + + test('is editable returns false for readonly input', async () => { + const result = await handleReadCommand('is', ['editable', '#readonly-input'], bm); + expect(result).toBe('false'); + }); + + test('is focused after click', async () => { + await handleWriteCommand('click', ['#enabled-input'], bm); + const result = await handleReadCommand('is', ['focused', '#enabled-input'], bm); + expect(result).toBe('true'); + }); + + test('is with @ref works', async () => { + await handleMetaCommand('snapshot', ['-i'], bm, async () => {}); + // Find a ref for the enabled input + const snap = await handleMetaCommand('snapshot', ['-i'], bm, async () => {}); + const textboxLine = snap.split('\n').find(l => l.includes('[textbox]')); + if (textboxLine) { + const refMatch = textboxLine.match(/@(e\d+)/); + if (refMatch) { + const ref = `@${refMatch[1]}`; + const result = await handleReadCommand('is', ['visible', ref], bm); + expect(result).toBe('true'); + } + } + }); + + test('is with unknown property throws', async () => { + try { + await handleReadCommand('is', ['bogus', '#enabled-input'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Unknown property'); + } + }); + + test('is with missing args throws', async () => { + try { + await handleReadCommand('is', ['visible'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); +}); + +// ─── File Upload ───────────────────────────────────────────────── + +describe('File upload', () => { + test('upload single file', async () => { + await handleWriteCommand('goto', [baseUrl + '/upload.html'], bm); + // Create a temp file to upload + const tempFile = '/tmp/browse-test-upload.txt'; + fs.writeFileSync(tempFile, 'test content'); + const result = await handleWriteCommand('upload', ['#file-input', tempFile], bm); + expect(result).toContain('Uploaded'); + expect(result).toContain('browse-test-upload.txt'); + + // Verify upload handler fired + await new Promise(r => setTimeout(r, 100)); + const text = await handleReadCommand('js', ['document.querySelector("#upload-result").textContent'], bm); + expect(text).toContain('browse-test-upload.txt'); + fs.unlinkSync(tempFile); + }); + + test('upload with @ref works', async () => { + await handleWriteCommand('goto', [baseUrl + '/upload.html'], bm); + const tempFile = '/tmp/browse-test-upload2.txt'; + fs.writeFileSync(tempFile, 'ref upload test'); + const snap = await handleMetaCommand('snapshot', ['-i'], bm, async () => {}); + // Find the file input ref (it won't appear as "file input" in aria — use CSS selector instead) + const result = await handleWriteCommand('upload', ['#file-input', tempFile], bm); + expect(result).toContain('Uploaded'); + fs.unlinkSync(tempFile); + }); + + test('upload nonexistent file throws', async () => { + await handleWriteCommand('goto', [baseUrl + '/upload.html'], bm); + try { + await handleWriteCommand('upload', ['#file-input', '/tmp/nonexistent-file-12345.txt'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('File not found'); + } + }); + + test('upload missing args throws', async () => { + try { + await handleWriteCommand('upload', ['#file-input'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); +}); + +// ─── Eval command ─────────────────────────────────────────────── + +describe('Eval', () => { + test('eval runs JS file', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const tempFile = '/tmp/browse-test-eval.js'; + fs.writeFileSync(tempFile, 'document.title + " — evaluated"'); + const result = await handleReadCommand('eval', [tempFile], bm); + expect(result).toBe('Test Page - Basic — evaluated'); + fs.unlinkSync(tempFile); + }); + + test('eval returns object as JSON', async () => { + const tempFile = '/tmp/browse-test-eval-obj.js'; + fs.writeFileSync(tempFile, '({title: document.title, keys: Object.keys(document.body.dataset)})'); + const result = await handleReadCommand('eval', [tempFile], bm); + const obj = JSON.parse(result); + expect(obj.title).toBe('Test Page - Basic'); + expect(Array.isArray(obj.keys)).toBe(true); + fs.unlinkSync(tempFile); + }); + + test('eval file not found throws', async () => { + try { + await handleReadCommand('eval', ['/tmp/nonexistent-eval.js'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('File not found'); + } + }); + + test('eval no arg throws', async () => { + try { + await handleReadCommand('eval', [], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); +}); + +// ─── Press command ────────────────────────────────────────────── + +describe('Press', () => { + test('press Tab moves focus', async () => { + await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm); + await handleWriteCommand('click', ['#email'], bm); + const result = await handleWriteCommand('press', ['Tab'], bm); + expect(result).toContain('Pressed Tab'); + }); + + test('press no arg throws', async () => { + try { + await handleWriteCommand('press', [], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); +}); + +// ─── Cookie command ───────────────────────────────────────────── + +describe('Cookie command', () => { + test('cookie sets value', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const result = await handleWriteCommand('cookie', ['testcookie=testvalue'], bm); + expect(result).toContain('Cookie set'); + + const cookies = await handleReadCommand('cookies', [], bm); + expect(cookies).toContain('testcookie'); + expect(cookies).toContain('testvalue'); + }); + + test('cookie no arg throws', async () => { + try { + await handleWriteCommand('cookie', [], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('cookie no = throws', async () => { + try { + await handleWriteCommand('cookie', ['invalid'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); +}); + +// ─── Header command ───────────────────────────────────────────── + +describe('Header command', () => { + test('header sets value and is sent', async () => { + const result = await handleWriteCommand('header', ['X-Test:test-value'], bm); + expect(result).toContain('Header set'); + + await handleWriteCommand('goto', [baseUrl + '/echo'], bm); + const echoText = await handleReadCommand('text', [], bm); + expect(echoText).toContain('x-test'); + expect(echoText).toContain('test-value'); + }); + + test('header no arg throws', async () => { + try { + await handleWriteCommand('header', [], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('header no colon throws', async () => { + try { + await handleWriteCommand('header', ['invalid'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); +}); + +// ─── PDF command ──────────────────────────────────────────────── + +describe('PDF', () => { + test('pdf saves file with size', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const pdfPath = '/tmp/browse-test.pdf'; + const result = await handleMetaCommand('pdf', [pdfPath], bm, async () => {}); + expect(result).toContain('PDF saved'); + expect(fs.existsSync(pdfPath)).toBe(true); + const stat = fs.statSync(pdfPath); + expect(stat.size).toBeGreaterThan(100); + fs.unlinkSync(pdfPath); + }); +}); + +// ─── Empty page edge cases ────────────────────────────────────── + +describe('Empty page', () => { + test('text returns empty on empty page', async () => { + await handleWriteCommand('goto', [baseUrl + '/empty.html'], bm); + const result = await handleReadCommand('text', [], bm); + expect(result).toBe(''); + }); + + test('links returns empty on empty page', async () => { + const result = await handleReadCommand('links', [], bm); + expect(result).toBe(''); + }); + + test('forms returns empty array on empty page', async () => { + const result = await handleReadCommand('forms', [], bm); + expect(JSON.parse(result)).toEqual([]); + }); +}); + +// ─── Error paths ──────────────────────────────────────────────── + +describe('Errors', () => { + // Write command errors + test('goto with no arg throws', async () => { + try { + await handleWriteCommand('goto', [], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('click with no arg throws', async () => { + try { + await handleWriteCommand('click', [], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('fill with no value throws', async () => { + try { + await handleWriteCommand('fill', ['#input'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('select with no value throws', async () => { + try { + await handleWriteCommand('select', ['#sel'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('hover with no arg throws', async () => { + try { + await handleWriteCommand('hover', [], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('type with no arg throws', async () => { + try { + await handleWriteCommand('type', [], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('wait with no arg throws', async () => { + try { + await handleWriteCommand('wait', [], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('viewport with bad format throws', async () => { + try { + await handleWriteCommand('viewport', ['badformat'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('useragent with no arg throws', async () => { + try { + await handleWriteCommand('useragent', [], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + // Read command errors + test('js with no expression throws', async () => { + try { + await handleReadCommand('js', [], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('css with missing property throws', async () => { + try { + await handleReadCommand('css', ['h1'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('attrs with no selector throws', async () => { + try { + await handleReadCommand('attrs', [], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + // Meta command errors + test('tab with non-numeric id throws', async () => { + try { + await handleMetaCommand('tab', ['abc'], bm, async () => {}); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('diff with missing urls throws', async () => { + try { + await handleMetaCommand('diff', [baseUrl + '/basic.html'], bm, async () => {}); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('chain with invalid JSON throws', async () => { + try { + await handleMetaCommand('chain', ['not json'], bm, async () => {}); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Invalid JSON'); + } + }); + + test('chain with no arg throws', async () => { + try { + await handleMetaCommand('chain', [], bm, async () => {}); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('unknown read command throws', async () => { + try { + await handleReadCommand('bogus' as any, [], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Unknown'); + } + }); + + test('unknown write command throws', async () => { + try { + await handleWriteCommand('bogus' as any, [], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Unknown'); + } + }); + + test('unknown meta command throws', async () => { + try { + await handleMetaCommand('bogus' as any, [], bm, async () => {}); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Unknown'); + } + }); +}); + +// ─── Workflow: Navigation + Snapshot + Interaction ─────────────── + +describe('Workflows', () => { + test('navigation → snapshot → click @ref → verify URL', async () => { + await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); + const snap = await handleMetaCommand('snapshot', ['-i'], bm, async () => {}); + // Find a link ref + const linkLine = snap.split('\n').find(l => l.includes('[link]')); + expect(linkLine).toBeDefined(); + const refMatch = linkLine!.match(/@(e\d+)/); + expect(refMatch).toBeDefined(); + // Click the link + await handleWriteCommand('click', [`@${refMatch![1]}`], bm); + // URL should have changed + const url = await handleMetaCommand('url', [], bm, async () => {}); + expect(url).toBeTruthy(); + }); + + test('form: goto → snapshot → fill @ref → click @ref', async () => { + await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); + const snap = await handleMetaCommand('snapshot', ['-i'], bm, async () => {}); + // Find textbox and button + const textboxLine = snap.split('\n').find(l => l.includes('[textbox]')); + const buttonLine = snap.split('\n').find(l => l.includes('[button]') && l.includes('"Submit"')); + if (textboxLine && buttonLine) { + const textRef = textboxLine.match(/@(e\d+)/)![1]; + const btnRef = buttonLine.match(/@(e\d+)/)![1]; + await handleWriteCommand('fill', [`@${textRef}`, 'testuser'], bm); + await handleWriteCommand('click', [`@${btnRef}`], bm); + } + }); + + test('tabs: newtab → goto → switch → verify isolation', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const tabsBefore = bm.getTabCount(); + await handleMetaCommand('newtab', [baseUrl + '/forms.html'], bm, async () => {}); + expect(bm.getTabCount()).toBe(tabsBefore + 1); + + const url = await handleMetaCommand('url', [], bm, async () => {}); + expect(url).toContain('/forms.html'); + + // Switch back to previous tab + const tabs = await bm.getTabListWithTitles(); + const prevTab = tabs.find(t => t.url.includes('/basic.html')); + if (prevTab) { + bm.switchTab(prevTab.id); + const url2 = await handleMetaCommand('url', [], bm, async () => {}); + expect(url2).toContain('/basic.html'); + } + + // Clean up extra tab + const allTabs = await bm.getTabListWithTitles(); + const formTab = allTabs.find(t => t.url.includes('/forms.html')); + if (formTab) await bm.closeTab(formTab.id); + }); + + test('cookies: set → read → reload → verify persistence', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + await handleWriteCommand('cookie', ['workflow-test=persisted'], bm); + await handleWriteCommand('reload', [], bm); + const cookies = await handleReadCommand('cookies', [], bm); + expect(cookies).toContain('workflow-test'); + expect(cookies).toContain('persisted'); + }); +}); + +// ─── Wait load states ────────────────────────────────────────── + +describe('Wait load states', () => { + test('wait --networkidle succeeds after page load', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const result = await handleWriteCommand('wait', ['--networkidle'], bm); + expect(result).toBe('Network idle'); + }); + + test('wait --load succeeds', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const result = await handleWriteCommand('wait', ['--load'], bm); + expect(result).toBe('Page loaded'); + }); + + test('wait --domcontentloaded succeeds', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const result = await handleWriteCommand('wait', ['--domcontentloaded'], bm); + expect(result).toBe('DOM content loaded'); + }); + + test('wait --networkidle with custom timeout', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const result = await handleWriteCommand('wait', ['--networkidle', '5000'], bm); + expect(result).toBe('Network idle'); + }); + + test('wait with selector still works', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const result = await handleWriteCommand('wait', ['#title'], bm); + expect(result).toContain('appeared'); + }); +}); + +// ─── Console --errors ────────────────────────────────────────── + +describe('Console --errors', () => { + test('console --errors filters to error and warning only', async () => { + // Clear existing entries + await handleReadCommand('console', ['--clear'], bm); + + // Add mixed entries + addConsoleEntry({ timestamp: Date.now(), level: 'log', text: 'info message' }); + addConsoleEntry({ timestamp: Date.now(), level: 'warning', text: 'warn message' }); + addConsoleEntry({ timestamp: Date.now(), level: 'error', text: 'error message' }); + + const result = await handleReadCommand('console', ['--errors'], bm); + expect(result).toContain('warn message'); + expect(result).toContain('error message'); + expect(result).not.toContain('info message'); + + // Cleanup + consoleBuffer.clear(); + }); + + test('console --errors returns empty message when no errors', async () => { + consoleBuffer.clear(); + addConsoleEntry({ timestamp: Date.now(), level: 'log', text: 'just a log' }); + + const result = await handleReadCommand('console', ['--errors'], bm); + expect(result).toBe('(no console errors)'); + + consoleBuffer.clear(); + }); + + test('console --errors on empty buffer', async () => { + consoleBuffer.clear(); + const result = await handleReadCommand('console', ['--errors'], bm); + expect(result).toBe('(no console errors)'); + }); + + test('console without flag still returns all messages', async () => { + consoleBuffer.clear(); + addConsoleEntry({ timestamp: Date.now(), level: 'log', text: 'all messages test' }); + + const result = await handleReadCommand('console', [], bm); + expect(result).toContain('all messages test'); + + consoleBuffer.clear(); + }); +}); + +// ─── Cookie Import ───────────────────────────────────────────── + +describe('Cookie import', () => { + test('cookie-import loads valid JSON cookies', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const tempFile = '/tmp/browse-test-cookies.json'; + const cookies = [ + { name: 'test-cookie', value: 'test-value' }, + { name: 'another', value: '123' }, + ]; + fs.writeFileSync(tempFile, JSON.stringify(cookies)); + + const result = await handleWriteCommand('cookie-import', [tempFile], bm); + expect(result).toBe('Loaded 2 cookies from /tmp/browse-test-cookies.json'); + + // Verify cookies were set + const cookieList = await handleReadCommand('cookies', [], bm); + expect(cookieList).toContain('test-cookie'); + expect(cookieList).toContain('test-value'); + expect(cookieList).toContain('another'); + + fs.unlinkSync(tempFile); + }); + + test('cookie-import auto-fills domain from page URL', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const tempFile = '/tmp/browse-test-cookies-nodomain.json'; + // Cookies without domain — should auto-fill from page URL + const cookies = [{ name: 'autofill-test', value: 'works' }]; + fs.writeFileSync(tempFile, JSON.stringify(cookies)); + + const result = await handleWriteCommand('cookie-import', [tempFile], bm); + expect(result).toContain('Loaded 1'); + + const cookieList = await handleReadCommand('cookies', [], bm); + expect(cookieList).toContain('autofill-test'); + + fs.unlinkSync(tempFile); + }); + + test('cookie-import preserves explicit domain', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const tempFile = '/tmp/browse-test-cookies-domain.json'; + const cookies = [{ name: 'explicit', value: 'domain', domain: 'example.com', path: '/foo' }]; + fs.writeFileSync(tempFile, JSON.stringify(cookies)); + + const result = await handleWriteCommand('cookie-import', [tempFile], bm); + expect(result).toContain('Loaded 1'); + + fs.unlinkSync(tempFile); + }); + + test('cookie-import with empty array succeeds', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const tempFile = '/tmp/browse-test-cookies-empty.json'; + fs.writeFileSync(tempFile, '[]'); + + const result = await handleWriteCommand('cookie-import', [tempFile], bm); + expect(result).toBe('Loaded 0 cookies from /tmp/browse-test-cookies-empty.json'); + + fs.unlinkSync(tempFile); + }); + + test('cookie-import throws on file not found', async () => { + try { + await handleWriteCommand('cookie-import', ['/tmp/nonexistent-cookies.json'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('File not found'); + } + }); + + test('cookie-import throws on invalid JSON', async () => { + const tempFile = '/tmp/browse-test-cookies-bad.json'; + fs.writeFileSync(tempFile, 'not json {{{'); + + try { + await handleWriteCommand('cookie-import', [tempFile], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Invalid JSON'); + } + + fs.unlinkSync(tempFile); + }); + + test('cookie-import throws on non-array JSON', async () => { + const tempFile = '/tmp/browse-test-cookies-obj.json'; + fs.writeFileSync(tempFile, '{"name": "not-an-array"}'); + + try { + await handleWriteCommand('cookie-import', [tempFile], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('JSON array'); + } + + fs.unlinkSync(tempFile); + }); + + test('cookie-import throws on cookie missing name', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const tempFile = '/tmp/browse-test-cookies-noname.json'; + fs.writeFileSync(tempFile, JSON.stringify([{ value: 'no-name' }])); + + try { + await handleWriteCommand('cookie-import', [tempFile], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('name'); + } + + fs.unlinkSync(tempFile); + }); + + test('cookie-import no arg throws', async () => { + try { + await handleWriteCommand('cookie-import', [], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); +}); + +// ─── Security: Redact sensitive values (PR #21) ───────────────── + +describe('Sensitive value redaction', () => { + test('type command does not echo typed text', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const result = await handleWriteCommand('type', ['my-secret-password'], bm); + expect(result).not.toContain('my-secret-password'); + expect(result).toContain('18 characters'); + }); + + test('cookie command redacts value', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const result = await handleWriteCommand('cookie', ['session=secret123'], bm); + expect(result).toContain('session'); + expect(result).toContain('****'); + expect(result).not.toContain('secret123'); + }); + + test('header command redacts Authorization value', async () => { + const result = await handleWriteCommand('header', ['Authorization:Bearer token-xyz'], bm); + expect(result).toContain('Authorization'); + expect(result).toContain('****'); + expect(result).not.toContain('token-xyz'); + }); + + test('header command shows non-sensitive values', async () => { + const result = await handleWriteCommand('header', ['Content-Type:application/json'], bm); + expect(result).toContain('Content-Type'); + expect(result).toContain('application/json'); + expect(result).not.toContain('****'); + }); + + test('header command redacts X-API-Key', async () => { + const result = await handleWriteCommand('header', ['X-API-Key:sk-12345'], bm); + expect(result).toContain('X-API-Key'); + expect(result).toContain('****'); + expect(result).not.toContain('sk-12345'); + }); + + test('storage set does not echo value', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const result = await handleReadCommand('storage', ['set', 'apiKey', 'secret-api-key-value'], bm); + expect(result).toContain('apiKey'); + expect(result).not.toContain('secret-api-key-value'); + }); + + test('forms redacts password field values', async () => { + await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm); + const formsResult = await handleReadCommand('forms', [], bm); + const forms = JSON.parse(formsResult); + // Find password fields and verify they're redacted + for (const form of forms) { + for (const field of form.fields) { + if (field.type === 'password') { + expect(field.value === undefined || field.value === '[redacted]').toBe(true); + } + } + } + }); +}); + +// ─── Security: Path traversal prevention (PR #26) ─────────────── + +describe('Path traversal prevention', () => { + test('screenshot rejects path outside safe dirs', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + try { + await handleMetaCommand('screenshot', ['/etc/evil.png'], bm, () => {}); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Path must be within'); + } + }); + + test('screenshot allows /tmp path', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const result = await handleMetaCommand('screenshot', ['/tmp/test-safe.png'], bm, () => {}); + expect(result).toContain('Screenshot saved'); + try { fs.unlinkSync('/tmp/test-safe.png'); } catch {} + }); + + test('pdf rejects path outside safe dirs', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + try { + await handleMetaCommand('pdf', ['/home/evil.pdf'], bm, () => {}); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Path must be within'); + } + }); + + test('responsive rejects path outside safe dirs', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + try { + await handleMetaCommand('responsive', ['/var/evil'], bm, () => {}); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Path must be within'); + } + }); + + test('eval rejects path traversal with ..', async () => { + try { + await handleReadCommand('eval', ['../../etc/passwd'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Path traversal'); + } + }); + + test('eval rejects absolute path outside safe dirs', async () => { + try { + await handleReadCommand('eval', ['/etc/passwd'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Absolute path must be within'); + } + }); + + test('eval allows /tmp path', async () => { + const tmpFile = '/tmp/test-eval-safe.js'; + fs.writeFileSync(tmpFile, 'document.title'); + try { + const result = await handleReadCommand('eval', [tmpFile], bm); + expect(typeof result).toBe('string'); + } finally { + try { fs.unlinkSync(tmpFile); } catch {} + } + }); + + test('screenshot rejects /tmpevil prefix collision', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + try { + await handleMetaCommand('screenshot', ['/tmpevil/steal.png'], bm, () => {}); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Path must be within'); + } + }); + + test('cookie-import rejects path traversal', async () => { + try { + await handleWriteCommand('cookie-import', ['../../etc/shadow'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Path traversal'); + } + }); + + test('cookie-import rejects absolute path outside safe dirs', async () => { + try { + await handleWriteCommand('cookie-import', ['/etc/passwd'], bm); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Path must be within'); + } + }); + + test('snapshot -a -o rejects path outside safe dirs', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + // First get a snapshot so refs exist + await handleMetaCommand('snapshot', ['-i'], bm, () => {}); + try { + await handleMetaCommand('snapshot', ['-a', '-o', '/etc/evil.png'], bm, () => {}); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Path must be within'); + } + }); +}); + +// ─── Chain command: cookie-import in chain ────────────────────── + +describe('Chain with cookie-import', () => { + test('cookie-import works inside chain', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const tmpCookies = '/tmp/test-chain-cookies.json'; + fs.writeFileSync(tmpCookies, JSON.stringify([ + { name: 'chain_test', value: 'chain_value', domain: 'localhost', path: '/' } + ])); + try { + const commands = JSON.stringify([ + ['cookie-import', tmpCookies], + ]); + const result = await handleMetaCommand('chain', [commands], bm, async () => {}); + expect(result).toContain('[cookie-import]'); + expect(result).toContain('Loaded 1 cookie'); + } finally { + try { fs.unlinkSync(tmpCookies); } catch {} + } }); }); diff --git a/browse/test/cookie-import-browser.test.ts b/browse/test/cookie-import-browser.test.ts new file mode 100644 index 0000000..1e91cf1 --- /dev/null +++ b/browse/test/cookie-import-browser.test.ts @@ -0,0 +1,397 @@ +/** + * Unit tests for cookie-import-browser.ts + * + * Uses a fixture SQLite database with cookies encrypted using a known test key. + * Mocks Keychain access to return the test password. + * + * Test key derivation (matches real Chromium pipeline): + * password = "test-keychain-password" + * key = PBKDF2(password, "saltysalt", 1003, 16, sha1) + * + * Encryption: AES-128-CBC with IV = 16 × 0x20, prefix "v10" + * First 32 bytes of plaintext = HMAC-SHA256 tag (random for tests) + * Remaining bytes = actual cookie value + */ + +import { describe, test, expect, beforeAll, afterAll, mock } from 'bun:test'; +import { Database } from 'bun:sqlite'; +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// ─── Test Constants ───────────────────────────────────────────── + +const TEST_PASSWORD = 'test-keychain-password'; +const TEST_KEY = crypto.pbkdf2Sync(TEST_PASSWORD, 'saltysalt', 1003, 16, 'sha1'); +const IV = Buffer.alloc(16, 0x20); +const CHROMIUM_EPOCH_OFFSET = 11644473600000000n; + +// Fixture DB path +const FIXTURE_DIR = path.join(import.meta.dir, 'fixtures'); +const FIXTURE_DB = path.join(FIXTURE_DIR, 'test-cookies.db'); + +// ─── Encryption Helper ────────────────────────────────────────── + +function encryptCookieValue(value: string): Buffer { + // 32-byte HMAC tag (random for test) + actual value + const hmacTag = crypto.randomBytes(32); + const plaintext = Buffer.concat([hmacTag, Buffer.from(value, 'utf-8')]); + + // PKCS7 pad to AES block size (16 bytes) + const blockSize = 16; + const padLen = blockSize - (plaintext.length % blockSize); + const padded = Buffer.concat([plaintext, Buffer.alloc(padLen, padLen)]); + + const cipher = crypto.createCipheriv('aes-128-cbc', TEST_KEY, IV); + cipher.setAutoPadding(false); // We padded manually + const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]); + + // Prefix with "v10" + return Buffer.concat([Buffer.from('v10'), encrypted]); +} + +function chromiumEpoch(unixSeconds: number): bigint { + return BigInt(unixSeconds) * 1000000n + CHROMIUM_EPOCH_OFFSET; +} + +// ─── Create Fixture Database ──────────────────────────────────── + +function createFixtureDb() { + fs.mkdirSync(FIXTURE_DIR, { recursive: true }); + if (fs.existsSync(FIXTURE_DB)) fs.unlinkSync(FIXTURE_DB); + + const db = new Database(FIXTURE_DB); + db.run(`CREATE TABLE cookies ( + host_key TEXT NOT NULL, + name TEXT NOT NULL, + value TEXT NOT NULL DEFAULT '', + encrypted_value BLOB NOT NULL DEFAULT x'', + path TEXT NOT NULL DEFAULT '/', + expires_utc INTEGER NOT NULL DEFAULT 0, + is_secure INTEGER NOT NULL DEFAULT 0, + is_httponly INTEGER NOT NULL DEFAULT 0, + has_expires INTEGER NOT NULL DEFAULT 0, + samesite INTEGER NOT NULL DEFAULT 1 + )`); + + const insert = db.prepare(`INSERT INTO cookies + (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, has_expires, samesite) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); + + const futureExpiry = Number(chromiumEpoch(Math.floor(Date.now() / 1000) + 86400 * 365)); + const pastExpiry = Number(chromiumEpoch(Math.floor(Date.now() / 1000) - 86400)); + + // Domain 1: .github.com — 3 encrypted cookies + insert.run('.github.com', 'session_id', '', encryptCookieValue('abc123'), '/', futureExpiry, 1, 1, 1, 1); + insert.run('.github.com', 'user_token', '', encryptCookieValue('token-xyz'), '/', futureExpiry, 1, 0, 1, 0); + insert.run('.github.com', 'theme', '', encryptCookieValue('dark'), '/', futureExpiry, 0, 0, 1, 2); + + // Domain 2: .google.com — 2 cookies + insert.run('.google.com', 'NID', '', encryptCookieValue('google-nid-value'), '/', futureExpiry, 1, 1, 1, 0); + insert.run('.google.com', 'SID', '', encryptCookieValue('google-sid-value'), '/', futureExpiry, 1, 1, 1, 1); + + // Domain 3: .example.com — 1 unencrypted cookie (value field set, no encrypted_value) + insert.run('.example.com', 'plain_cookie', 'hello-world', Buffer.alloc(0), '/', futureExpiry, 0, 0, 1, 1); + + // Domain 4: .expired.com — 1 expired cookie (should be filtered out) + insert.run('.expired.com', 'old', '', encryptCookieValue('expired-value'), '/', pastExpiry, 0, 0, 1, 1); + + // Domain 5: .session.com — session cookie (has_expires=0) + insert.run('.session.com', 'sess', '', encryptCookieValue('session-value'), '/', 0, 1, 1, 0, 1); + + // Domain 6: .corrupt.com — cookie with garbage encrypted_value + insert.run('.corrupt.com', 'bad', '', Buffer.from('v10' + 'not-valid-ciphertext-at-all'), '/', futureExpiry, 0, 0, 1, 1); + + // Domain 7: .mixed.com — one good, one corrupt + insert.run('.mixed.com', 'good', '', encryptCookieValue('mixed-good'), '/', futureExpiry, 0, 0, 1, 1); + insert.run('.mixed.com', 'bad', '', Buffer.from('v10' + 'garbage-data-here!!!'), '/', futureExpiry, 0, 0, 1, 1); + + db.close(); +} + +// ─── Mock Setup ───────────────────────────────────────────────── +// We need to mock: +// 1. The Keychain access (getKeychainPassword) to return TEST_PASSWORD +// 2. The cookie DB path resolution to use our fixture DB + +// We'll import the module after setting up the mocks +let findInstalledBrowsers: any; +let listDomains: any; +let importCookies: any; +let CookieImportError: any; + +beforeAll(async () => { + createFixtureDb(); + + // Mock Bun.spawn to return test password for keychain access + const origSpawn = Bun.spawn; + // @ts-ignore - monkey-patching for test + Bun.spawn = function(cmd: any, opts: any) { + // Intercept security find-generic-password calls + if (Array.isArray(cmd) && cmd[0] === 'security' && cmd[1] === 'find-generic-password') { + const service = cmd[3]; // -s + // Return test password for any known test service + return { + stdout: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(TEST_PASSWORD + '\n')); + controller.close(); + } + }), + stderr: new ReadableStream({ + start(controller) { controller.close(); } + }), + exited: Promise.resolve(0), + kill: () => {}, + }; + } + // Pass through other spawn calls + return origSpawn(cmd, opts); + }; + + // Import the module (uses our mocked Bun.spawn) + const mod = await import('../src/cookie-import-browser'); + findInstalledBrowsers = mod.findInstalledBrowsers; + listDomains = mod.listDomains; + importCookies = mod.importCookies; + CookieImportError = mod.CookieImportError; +}); + +afterAll(() => { + // Clean up fixture DB + try { fs.unlinkSync(FIXTURE_DB); } catch {} + try { fs.rmdirSync(FIXTURE_DIR); } catch {} +}); + +// ─── Helper: Override DB path for tests ───────────────────────── +// The real code resolves paths via ~/Library/Application Support//Default/Cookies +// We need to test against our fixture DB directly. We'll test the pure decryption functions +// by calling importCookies with a browser that points to our fixture. +// Since the module uses a hardcoded registry, we test the decryption logic via a different approach: +// We'll directly call the internal decryption by setting up the DB in the expected location. + +// For the unit tests below, we test the decryption pipeline by: +// 1. Creating encrypted cookies with known values +// 2. Decrypting them with the module's decryption logic +// The actual DB path resolution is tested separately. + +// ─── Tests ────────────────────────────────────────────────────── + +describe('Cookie Import Browser', () => { + + describe('Decryption Pipeline', () => { + test('encrypts and decrypts round-trip correctly', () => { + // Verify our test helper produces valid ciphertext + const encrypted = encryptCookieValue('hello-world'); + expect(encrypted.slice(0, 3).toString()).toBe('v10'); + + // Decrypt manually to verify + const ciphertext = encrypted.slice(3); + const decipher = crypto.createDecipheriv('aes-128-cbc', TEST_KEY, IV); + const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + // Skip 32-byte HMAC tag + const value = plaintext.slice(32).toString('utf-8'); + expect(value).toBe('hello-world'); + }); + + test('handles empty encrypted_value', () => { + const encrypted = encryptCookieValue(''); + const ciphertext = encrypted.slice(3); + const decipher = crypto.createDecipheriv('aes-128-cbc', TEST_KEY, IV); + const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + // 32-byte tag + empty value → slice(32) = empty + expect(plaintext.length).toBe(32); // just the HMAC tag, padded to block boundary? Actually 32 + 0 padded = 48 + // With PKCS7 padding: 32 bytes + 16 bytes of padding = 48 bytes padded → decrypts to 32 bytes + padding removed = 32 bytes + }); + + test('handles special characters in cookie values', () => { + const specialValue = 'a=b&c=d; path=/; expires=Thu, 01 Jan 2099'; + const encrypted = encryptCookieValue(specialValue); + const ciphertext = encrypted.slice(3); + const decipher = crypto.createDecipheriv('aes-128-cbc', TEST_KEY, IV); + const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + expect(plaintext.slice(32).toString('utf-8')).toBe(specialValue); + }); + }); + + describe('Fixture DB Structure', () => { + test('fixture DB has correct domain counts', () => { + const db = new Database(FIXTURE_DB, { readonly: true }); + const rows = db.query( + `SELECT host_key, COUNT(*) as count FROM cookies GROUP BY host_key ORDER BY count DESC` + ).all() as any[]; + db.close(); + + const counts = Object.fromEntries(rows.map((r: any) => [r.host_key, r.count])); + expect(counts['.github.com']).toBe(3); + expect(counts['.google.com']).toBe(2); + expect(counts['.example.com']).toBe(1); + expect(counts['.expired.com']).toBe(1); + expect(counts['.session.com']).toBe(1); + expect(counts['.corrupt.com']).toBe(1); + expect(counts['.mixed.com']).toBe(2); + }); + + test('encrypted cookies in fixture have v10 prefix', () => { + const db = new Database(FIXTURE_DB, { readonly: true }); + const rows = db.query( + `SELECT name, encrypted_value FROM cookies WHERE host_key = '.github.com'` + ).all() as any[]; + db.close(); + + for (const row of rows) { + const ev = Buffer.from(row.encrypted_value); + expect(ev.slice(0, 3).toString()).toBe('v10'); + } + }); + + test('decrypts all github.com cookies from fixture DB', () => { + const db = new Database(FIXTURE_DB, { readonly: true }); + const rows = db.query( + `SELECT name, value, encrypted_value FROM cookies WHERE host_key = '.github.com'` + ).all() as any[]; + db.close(); + + const expected: Record = { + 'session_id': 'abc123', + 'user_token': 'token-xyz', + 'theme': 'dark', + }; + + for (const row of rows) { + const ev = Buffer.from(row.encrypted_value); + if (ev.length === 0) continue; + const ciphertext = ev.slice(3); + const decipher = crypto.createDecipheriv('aes-128-cbc', TEST_KEY, IV); + const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + const value = plaintext.slice(32).toString('utf-8'); + expect(value).toBe(expected[row.name]); + } + }); + + test('unencrypted cookie uses value field directly', () => { + const db = new Database(FIXTURE_DB, { readonly: true }); + const row = db.query( + `SELECT value, encrypted_value FROM cookies WHERE host_key = '.example.com'` + ).get() as any; + db.close(); + + expect(row.value).toBe('hello-world'); + expect(Buffer.from(row.encrypted_value).length).toBe(0); + }); + }); + + describe('sameSite Mapping', () => { + test('maps sameSite values correctly', () => { + // Read from fixture DB and verify mapping + const db = new Database(FIXTURE_DB, { readonly: true }); + + // samesite=0 → None + const none = db.query(`SELECT samesite FROM cookies WHERE name = 'user_token'`).get() as any; + expect(none.samesite).toBe(0); + + // samesite=1 → Lax + const lax = db.query(`SELECT samesite FROM cookies WHERE name = 'session_id'`).get() as any; + expect(lax.samesite).toBe(1); + + // samesite=2 → Strict + const strict = db.query(`SELECT samesite FROM cookies WHERE name = 'theme'`).get() as any; + expect(strict.samesite).toBe(2); + + db.close(); + }); + }); + + describe('Chromium Epoch Conversion', () => { + test('converts Chromium epoch to Unix timestamp correctly', () => { + // Round-trip: pick a known Unix timestamp, convert to Chromium, convert back + const knownUnix = 1704067200; // 2024-01-01T00:00:00Z + const chromiumTs = BigInt(knownUnix) * 1000000n + CHROMIUM_EPOCH_OFFSET; + const unixTs = Number((chromiumTs - CHROMIUM_EPOCH_OFFSET) / 1000000n); + expect(unixTs).toBe(knownUnix); + }); + + test('session cookies (has_expires=0) get expires=-1', () => { + const db = new Database(FIXTURE_DB, { readonly: true }); + const row = db.query( + `SELECT has_expires, expires_utc FROM cookies WHERE host_key = '.session.com'` + ).get() as any; + db.close(); + expect(row.has_expires).toBe(0); + // When has_expires=0, the module should return expires=-1 + }); + }); + + describe('Error Handling', () => { + test('CookieImportError has correct properties', () => { + const err = new CookieImportError('test message', 'test_code', 'retry'); + expect(err.message).toBe('test message'); + expect(err.code).toBe('test_code'); + expect(err.action).toBe('retry'); + expect(err.name).toBe('CookieImportError'); + expect(err instanceof Error).toBe(true); + }); + + test('CookieImportError without action', () => { + const err = new CookieImportError('no action', 'some_code'); + expect(err.action).toBeUndefined(); + }); + }); + + describe('Browser Registry', () => { + test('findInstalledBrowsers returns array', () => { + const browsers = findInstalledBrowsers(); + expect(Array.isArray(browsers)).toBe(true); + // Each entry should have the right shape + for (const b of browsers) { + expect(b).toHaveProperty('name'); + expect(b).toHaveProperty('dataDir'); + expect(b).toHaveProperty('keychainService'); + expect(b).toHaveProperty('aliases'); + } + }); + }); + + describe('Corrupt Data Handling', () => { + test('garbage ciphertext produces decryption error', () => { + const garbage = Buffer.from('v10' + 'this-is-not-valid-ciphertext!!'); + const ciphertext = garbage.slice(3); + expect(() => { + const decipher = crypto.createDecipheriv('aes-128-cbc', TEST_KEY, IV); + Buffer.concat([decipher.update(ciphertext), decipher.final()]); + }).toThrow(); + }); + }); + + describe('Profile Validation', () => { + test('rejects path traversal in profile names', () => { + // The validateProfile function should reject profiles with / or .. + // We can't call it directly (internal), but we can test via listDomains + // which calls validateProfile + expect(() => listDomains('chrome', '../etc')).toThrow(/Invalid profile/); + expect(() => listDomains('chrome', 'Default/../../etc')).toThrow(/Invalid profile/); + }); + + test('rejects control characters in profile names', () => { + expect(() => listDomains('chrome', 'Default\x00evil')).toThrow(/Invalid profile/); + }); + }); + + describe('Unknown Browser', () => { + test('throws for unknown browser name', () => { + expect(() => listDomains('firefox')).toThrow(/Unknown browser.*firefox/i); + }); + + test('error includes list of supported browsers', () => { + try { + listDomains('firefox'); + throw new Error('Should have thrown'); + } catch (err: any) { + expect(err.code).toBe('unknown_browser'); + expect(err.message).toContain('comet'); + expect(err.message).toContain('chrome'); + } + }); + }); +}); diff --git a/browse/test/fixtures/cursor-interactive.html b/browse/test/fixtures/cursor-interactive.html new file mode 100644 index 0000000..0259081 --- /dev/null +++ b/browse/test/fixtures/cursor-interactive.html @@ -0,0 +1,22 @@ + + + + + Test Page - Cursor Interactive + + + +

Cursor Interactive Test

+ +
Click me (div)
+ Hover card (span) +
Focusable div
+
Onclick div
+ + + Normal Link + + diff --git a/browse/test/fixtures/dialog.html b/browse/test/fixtures/dialog.html new file mode 100644 index 0000000..bfc588a --- /dev/null +++ b/browse/test/fixtures/dialog.html @@ -0,0 +1,15 @@ + + + + + Test Page - Dialog + + +

Dialog Test

+ + + +

+

+ + diff --git a/browse/test/fixtures/empty.html b/browse/test/fixtures/empty.html new file mode 100644 index 0000000..8ba582f --- /dev/null +++ b/browse/test/fixtures/empty.html @@ -0,0 +1,2 @@ + + diff --git a/browse/test/fixtures/states.html b/browse/test/fixtures/states.html new file mode 100644 index 0000000..67debbf --- /dev/null +++ b/browse/test/fixtures/states.html @@ -0,0 +1,17 @@ + + + + + Test Page - Element States + + +

Element States Test

+ + + + +
Visible
+ + + + diff --git a/browse/test/fixtures/upload.html b/browse/test/fixtures/upload.html new file mode 100644 index 0000000..bb8aca6 --- /dev/null +++ b/browse/test/fixtures/upload.html @@ -0,0 +1,25 @@ + + + + + Test Page - Upload + + +

Upload Test

+ + +

+ + + diff --git a/browse/test/snapshot.test.ts b/browse/test/snapshot.test.ts index 846c82b..bc45f6a 100644 --- a/browse/test/snapshot.test.ts +++ b/browse/test/snapshot.test.ts @@ -11,6 +11,7 @@ import { BrowserManager } from '../src/browser-manager'; import { handleReadCommand } from '../src/read-commands'; import { handleWriteCommand } from '../src/write-commands'; import { handleMetaCommand } from '../src/meta-commands'; +import * as fs from 'fs'; let testServer: ReturnType; let bm: BrowserManager; @@ -199,3 +200,219 @@ describe('Ref invalidation', () => { expect(bm.getRefCount()).toBe(0); }); }); + +// ─── Snapshot Diffing ────────────────────────────────────────── + +describe('Snapshot diff', () => { + test('first snapshot -D stores baseline', async () => { + // Clear any previous snapshot + bm.setLastSnapshot(null); + await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); + const result = await handleMetaCommand('snapshot', ['-D'], bm, shutdown); + expect(result).toContain('no previous snapshot'); + expect(result).toContain('baseline'); + }); + + test('snapshot -D shows diff after change', async () => { + await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); + // Take first snapshot + await handleMetaCommand('snapshot', [], bm, shutdown); + // Modify DOM + await handleReadCommand('js', ['document.querySelector("h1").textContent = "Changed Title"'], bm); + // Take diff + const diff = await handleMetaCommand('snapshot', ['-D'], bm, shutdown); + expect(diff).toContain('---'); + expect(diff).toContain('+++'); + expect(diff).toContain('previous snapshot'); + expect(diff).toContain('current snapshot'); + }); + + test('snapshot -D with identical page shows no changes', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + await handleMetaCommand('snapshot', [], bm, shutdown); + const diff = await handleMetaCommand('snapshot', ['-D'], bm, shutdown); + // All lines should be unchanged (prefixed with space) + const lines = diff.split('\n').filter(l => l.startsWith('+') || l.startsWith('-')); + // Header lines start with --- and +++ so filter those + const contentChanges = lines.filter(l => !l.startsWith('---') && !l.startsWith('+++')); + expect(contentChanges.length).toBe(0); + }); +}); + +// ─── Annotated Screenshots ───────────────────────────────────── + +describe('Annotated screenshots', () => { + test('snapshot -a creates annotated screenshot', async () => { + const screenshotPath = '/tmp/browse-test-annotated.png'; + await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); + const result = await handleMetaCommand('snapshot', ['-a', '-o', screenshotPath], bm, shutdown); + expect(result).toContain('annotated screenshot'); + expect(result).toContain(screenshotPath); + expect(fs.existsSync(screenshotPath)).toBe(true); + const stat = fs.statSync(screenshotPath); + expect(stat.size).toBeGreaterThan(1000); + fs.unlinkSync(screenshotPath); + }); + + test('snapshot -a uses default path', async () => { + const defaultPath = '/tmp/browse-annotated.png'; + await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); + const result = await handleMetaCommand('snapshot', ['-a'], bm, shutdown); + expect(result).toContain('annotated screenshot'); + expect(fs.existsSync(defaultPath)).toBe(true); + fs.unlinkSync(defaultPath); + }); + + test('snapshot -a -i only annotates interactive', async () => { + const screenshotPath = '/tmp/browse-test-annotated-i.png'; + await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); + const result = await handleMetaCommand('snapshot', ['-i', '-a', '-o', screenshotPath], bm, shutdown); + expect(result).toContain('[button]'); + expect(result).toContain('[link]'); + expect(result).toContain('annotated screenshot'); + if (fs.existsSync(screenshotPath)) fs.unlinkSync(screenshotPath); + }); + + test('annotation overlays are cleaned up', async () => { + await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); + await handleMetaCommand('snapshot', ['-a'], bm, shutdown); + // Check that overlays are removed + const overlays = await handleReadCommand('js', ['document.querySelectorAll(".__browse_annotation__").length'], bm); + expect(overlays).toBe('0'); + // Clean up default file + try { fs.unlinkSync('/tmp/browse-annotated.png'); } catch {} + }); +}); + +// ─── Cursor-Interactive ──────────────────────────────────────── + +describe('Cursor-interactive', () => { + test('snapshot -C finds cursor:pointer elements', async () => { + await handleWriteCommand('goto', [baseUrl + '/cursor-interactive.html'], bm); + const result = await handleMetaCommand('snapshot', ['-C'], bm, shutdown); + expect(result).toContain('cursor-interactive'); + expect(result).toContain('@c'); + expect(result).toContain('cursor:pointer'); + }); + + test('snapshot -C includes onclick elements', async () => { + await handleWriteCommand('goto', [baseUrl + '/cursor-interactive.html'], bm); + const result = await handleMetaCommand('snapshot', ['-C'], bm, shutdown); + expect(result).toContain('onclick'); + }); + + test('snapshot -C includes tabindex elements', async () => { + await handleWriteCommand('goto', [baseUrl + '/cursor-interactive.html'], bm); + const result = await handleMetaCommand('snapshot', ['-C'], bm, shutdown); + expect(result).toContain('tabindex'); + }); + + test('@c ref is clickable', async () => { + await handleWriteCommand('goto', [baseUrl + '/cursor-interactive.html'], bm); + const snap = await handleMetaCommand('snapshot', ['-C'], bm, shutdown); + // Find a @c ref + const cLine = snap.split('\n').find(l => l.includes('@c')); + if (cLine) { + const refMatch = cLine.match(/@(c\d+)/); + if (refMatch) { + const result = await handleWriteCommand('click', [`@${refMatch[1]}`], bm); + expect(result).toContain('Clicked'); + } + } + }); + + test('snapshot -C on page with no cursor elements', async () => { + await handleWriteCommand('goto', [baseUrl + '/empty.html'], bm); + const result = await handleMetaCommand('snapshot', ['-C'], bm, shutdown); + // Should not contain cursor-interactive section + expect(result).not.toContain('cursor-interactive'); + }); + + test('snapshot -i -C combines both modes', async () => { + await handleWriteCommand('goto', [baseUrl + '/cursor-interactive.html'], bm); + const result = await handleMetaCommand('snapshot', ['-i', '-C'], bm, shutdown); + // Should have interactive elements (button, link) + expect(result).toContain('[button]'); + expect(result).toContain('[link]'); + // And cursor-interactive section + expect(result).toContain('cursor-interactive'); + }); +}); + +// ─── Snapshot Error Paths ─────────────────────────────────────── + +describe('Snapshot errors', () => { + test('unknown flag throws', async () => { + try { + await handleMetaCommand('snapshot', ['--bogus'], bm, shutdown); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Unknown snapshot flag'); + } + }); + + test('-d without number throws', async () => { + try { + await handleMetaCommand('snapshot', ['-d'], bm, shutdown); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('-s without selector throws', async () => { + try { + await handleMetaCommand('snapshot', ['-s'], bm, shutdown); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); + + test('-s with nonexistent selector throws', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + try { + await handleMetaCommand('snapshot', ['-s', '#nonexistent-element-12345'], bm, shutdown); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Selector not found'); + } + }); + + test('-o without path throws', async () => { + try { + await handleMetaCommand('snapshot', ['-o'], bm, shutdown); + expect(true).toBe(false); + } catch (err: any) { + expect(err.message).toContain('Usage'); + } + }); +}); + +// ─── Combined Flags ───────────────────────────────────────────── + +describe('Snapshot combined flags', () => { + test('-i -c -d 2 combines all filters', async () => { + await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); + const result = await handleMetaCommand('snapshot', ['-i', '-c', '-d', '2'], bm, shutdown); + // Should be filtered to interactive, compact, shallow + expect(result).toContain('[button]'); + expect(result).toContain('[link]'); + // Should NOT contain deep nested non-interactive elements + expect(result).not.toContain('[heading]'); + }); + + test('closetab last tab auto-creates new', async () => { + // Get down to 1 tab + const tabs = await bm.getTabListWithTitles(); + for (let i = 1; i < tabs.length; i++) { + await bm.closeTab(tabs[i].id); + } + expect(bm.getTabCount()).toBe(1); + // Close the last tab + const lastTab = (await bm.getTabListWithTitles())[0]; + await bm.closeTab(lastTab.id); + // Should have auto-created a new tab + expect(bm.getTabCount()).toBe(1); + }); +}); diff --git a/browse/test/test-server.ts b/browse/test/test-server.ts index aeb0a5b..3775882 100644 --- a/browse/test/test-server.ts +++ b/browse/test/test-server.ts @@ -14,6 +14,16 @@ export function startTestServer(port: number = 0): { server: ReturnType = {}; + req.headers.forEach((value, key) => { headers[key] = value; }); + return new Response(JSON.stringify(headers, null, 2), { + headers: { 'Content-Type': 'application/json' }, + }); + } + let filePath = url.pathname === '/' ? '/basic.html' : url.pathname; // Remove leading slash diff --git a/package.json b/package.json index d486f91..bc617a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "0.0.2", + "version": "0.3.1", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/qa/SKILL.md b/qa/SKILL.md new file mode 100644 index 0000000..9da05fa --- /dev/null +++ b/qa/SKILL.md @@ -0,0 +1,295 @@ +--- +name: qa +version: 1.0.0 +description: | + Systematically QA test a web application. Use when asked to "qa", "QA", "test this site", + "find bugs", "dogfood", or review quality. Three modes: full (systematic exploration), + quick (30-second smoke test), regression (compare against baseline). Produces structured + report with health score, screenshots, and repro steps. +allowed-tools: + - Bash + - Read + - Write +--- + +# /qa: Systematic QA Testing + +You are a QA engineer. Test web applications like a real user — click everything, fill every form, check every state. Produce a structured report with evidence. + +## Setup + +**Parse the user's request for these parameters:** + +| Parameter | Default | Override example | +|-----------|---------|-----------------| +| Target URL | (required) | `https://myapp.com`, `http://localhost:3000` | +| Mode | full | `--quick`, `--regression .gstack/qa-reports/baseline.json` | +| Output dir | `.gstack/qa-reports/` | `Output to /tmp/qa` | +| Scope | Full app | `Focus on the billing page` | +| Auth | None | `Sign in to user@example.com`, `Import cookies from cookies.json` | + +**Find the browse binary:** + +```bash +B=$(browse/bin/find-browse 2>/dev/null || ~/.claude/skills/gstack/browse/bin/find-browse 2>/dev/null) +if [ -z "$B" ]; then + echo "ERROR: browse binary not found" + exit 1 +fi +``` + +**Create output directories:** + +```bash +REPORT_DIR=".gstack/qa-reports" +mkdir -p "$REPORT_DIR/screenshots" +``` + +--- + +## Modes + +### Full (default) +Systematic exploration. Visit every reachable page. Document 5-10 well-evidenced issues. Produce health score. Takes 5-15 minutes depending on app size. + +### Quick (`--quick`) +30-second smoke test. Visit homepage + top 5 navigation targets. Check: page loads? Console errors? Broken links? Produce health score. No detailed issue documentation. + +### Regression (`--regression `) +Run full mode, then load `baseline.json` from a previous run. Diff: which issues are fixed? Which are new? What's the score delta? Append regression section to report. + +--- + +## Workflow + +### Phase 1: Initialize + +1. Find browse binary (see Setup above) +2. Create output directories +3. Copy report template from `qa/templates/qa-report-template.md` to output dir +4. Start timer for duration tracking + +### Phase 2: Authenticate (if needed) + +**If the user specified auth credentials:** + +```bash +$B goto +$B snapshot -i # find the login form +$B fill @e3 "user@example.com" +$B fill @e4 "[REDACTED]" # NEVER include real passwords in report +$B click @e5 # submit +$B snapshot -D # verify login succeeded +``` + +**If the user provided a cookie file:** + +```bash +$B cookie-import cookies.json +$B goto +``` + +**If 2FA/OTP is required:** Ask the user for the code and wait. + +**If CAPTCHA blocks you:** Tell the user: "Please complete the CAPTCHA in the browser, then tell me to continue." + +### Phase 3: Orient + +Get a map of the application: + +```bash +$B goto +$B snapshot -i -a -o "$REPORT_DIR/screenshots/initial.png" +$B links # map navigation structure +$B console --errors # any errors on landing? +``` + +**Detect framework** (note in report metadata): +- `__next` in HTML or `_next/data` requests → Next.js +- `csrf-token` meta tag → Rails +- `wp-content` in URLs → WordPress +- Client-side routing with no page reloads → SPA + +**For SPAs:** The `links` command may return few results because navigation is client-side. Use `snapshot -i` to find nav elements (buttons, menu items) instead. + +### Phase 4: Explore + +Visit pages systematically. At each page: + +```bash +$B goto +$B snapshot -i -a -o "$REPORT_DIR/screenshots/page-name.png" +$B console --errors +``` + +Then follow the **per-page exploration checklist** (see `qa/references/issue-taxonomy.md`): + +1. **Visual scan** — Look at the annotated screenshot for layout issues +2. **Interactive elements** — Click buttons, links, controls. Do they work? +3. **Forms** — Fill and submit. Test empty, invalid, edge cases +4. **Navigation** — Check all paths in and out +5. **States** — Empty state, loading, error, overflow +6. **Console** — Any new JS errors after interactions? +7. **Responsiveness** — Check mobile viewport if relevant: + ```bash + $B viewport 375x812 + $B screenshot "$REPORT_DIR/screenshots/page-mobile.png" + $B viewport 1280x720 + ``` + +**Depth judgment:** Spend more time on core features (homepage, dashboard, checkout, search) and less on secondary pages (about, terms, privacy). + +**Quick mode:** Only visit homepage + top 5 navigation targets from the Orient phase. Skip the per-page checklist — just check: loads? Console errors? Broken links visible? + +### Phase 5: Document + +Document each issue **immediately when found** — don't batch them. + +**Two evidence tiers:** + +**Interactive bugs** (broken flows, dead buttons, form failures): +1. Take a screenshot before the action +2. Perform the action +3. Take a screenshot showing the result +4. Use `snapshot -D` to show what changed +5. Write repro steps referencing screenshots + +```bash +$B screenshot "$REPORT_DIR/screenshots/issue-001-step-1.png" +$B click @e5 +$B screenshot "$REPORT_DIR/screenshots/issue-001-result.png" +$B snapshot -D +``` + +**Static bugs** (typos, layout issues, missing images): +1. Take a single annotated screenshot showing the problem +2. Describe what's wrong + +```bash +$B snapshot -i -a -o "$REPORT_DIR/screenshots/issue-002.png" +``` + +**Write each issue to the report immediately** using the template format from `qa/templates/qa-report-template.md`. + +### Phase 6: Wrap Up + +1. **Compute health score** using the rubric below +2. **Write "Top 3 Things to Fix"** — the 3 highest-severity issues +3. **Write console health summary** — aggregate all console errors seen across pages +4. **Update severity counts** in the summary table +5. **Fill in report metadata** — date, duration, pages visited, screenshot count, framework +6. **Save baseline** — write `baseline.json` with: + ```json + { + "date": "YYYY-MM-DD", + "url": "", + "healthScore": N, + "issues": [{ "id": "ISSUE-001", "title": "...", "severity": "...", "category": "..." }], + "categoryScores": { "console": N, "links": N, ... } + } + ``` + +**Regression mode:** After writing the report, load the baseline file. Compare: +- Health score delta +- Issues fixed (in baseline but not current) +- New issues (in current but not baseline) +- Append the regression section to the report + +--- + +## Health Score Rubric + +Compute each category score (0-100), then take the weighted average. + +### Console (weight: 15%) +- 0 errors → 100 +- 1-3 errors → 70 +- 4-10 errors → 40 +- 10+ errors → 10 + +### Links (weight: 10%) +- 0 broken → 100 +- Each broken link → -15 (minimum 0) + +### Per-Category Scoring (Visual, Functional, UX, Content, Performance, Accessibility) +Each category starts at 100. Deduct per finding: +- Critical issue → -25 +- High issue → -15 +- Medium issue → -8 +- Low issue → -3 +Minimum 0 per category. + +### Weights +| Category | Weight | +|----------|--------| +| Console | 15% | +| Links | 10% | +| Visual | 10% | +| Functional | 20% | +| UX | 15% | +| Performance | 10% | +| Content | 5% | +| Accessibility | 15% | + +### Final Score +`score = Σ (category_score × weight)` + +--- + +## Framework-Specific Guidance + +### Next.js +- Check console for hydration errors (`Hydration failed`, `Text content did not match`) +- Monitor `_next/data` requests in network — 404s indicate broken data fetching +- Test client-side navigation (click links, don't just `goto`) — catches routing issues +- Check for CLS (Cumulative Layout Shift) on pages with dynamic content + +### Rails +- Check for N+1 query warnings in console (if development mode) +- Verify CSRF token presence in forms +- Test Turbo/Stimulus integration — do page transitions work smoothly? +- Check for flash messages appearing and dismissing correctly + +### WordPress +- Check for plugin conflicts (JS errors from different plugins) +- Verify admin bar visibility for logged-in users +- Test REST API endpoints (`/wp-json/`) +- Check for mixed content warnings (common with WP) + +### General SPA (React, Vue, Angular) +- Use `snapshot -i` for navigation — `links` command misses client-side routes +- Check for stale state (navigate away and back — does data refresh?) +- Test browser back/forward — does the app handle history correctly? +- Check for memory leaks (monitor console after extended use) + +--- + +## Important Rules + +1. **Repro is everything.** Every issue needs at least one screenshot. No exceptions. +2. **Verify before documenting.** Retry the issue once to confirm it's reproducible, not a fluke. +3. **Never include credentials.** Write `[REDACTED]` for passwords in repro steps. +4. **Write incrementally.** Append each issue to the report as you find it. Don't batch. +5. **Never read source code.** Test as a user, not a developer. +6. **Check console after every interaction.** JS errors that don't surface visually are still bugs. +7. **Test like a user.** Use realistic data. Walk through complete workflows end-to-end. +8. **Depth over breadth.** 5-10 well-documented issues with evidence > 20 vague descriptions. +9. **Never delete output files.** Screenshots and reports accumulate — that's intentional. +10. **Use `snapshot -C` for tricky UIs.** Finds clickable divs that the accessibility tree misses. + +--- + +## Output Structure + +``` +.gstack/qa-reports/ +├── qa-report-{domain}-{YYYY-MM-DD}.md # Structured report +├── screenshots/ +│ ├── initial.png # Landing page annotated screenshot +│ ├── issue-001-step-1.png # Per-issue evidence +│ ├── issue-001-result.png +│ └── ... +└── baseline.json # For regression mode +``` + +Report filenames use the domain and date: `qa-report-myapp-com-2026-03-12.md` diff --git a/qa/references/issue-taxonomy.md b/qa/references/issue-taxonomy.md new file mode 100644 index 0000000..05c5741 --- /dev/null +++ b/qa/references/issue-taxonomy.md @@ -0,0 +1,85 @@ +# QA Issue Taxonomy + +## Severity Levels + +| Severity | Definition | Examples | +|----------|------------|----------| +| **critical** | Blocks a core workflow, causes data loss, or crashes the app | Form submit causes error page, checkout flow broken, data deleted without confirmation | +| **high** | Major feature broken or unusable, no workaround | Search returns wrong results, file upload silently fails, auth redirect loop | +| **medium** | Feature works but with noticeable problems, workaround exists | Slow page load (>5s), form validation missing but submit still works, layout broken on mobile only | +| **low** | Minor cosmetic or polish issue | Typo in footer, 1px alignment issue, hover state inconsistent | + +## Categories + +### 1. Visual/UI +- Layout breaks (overlapping elements, clipped text, horizontal scrollbar) +- Broken or missing images +- Incorrect z-index (elements appearing behind others) +- Font/color inconsistencies +- Animation glitches (jank, incomplete transitions) +- Alignment issues (off-grid, uneven spacing) +- Dark mode / theme issues + +### 2. Functional +- Broken links (404, wrong destination) +- Dead buttons (click does nothing) +- Form validation (missing, wrong, bypassed) +- Incorrect redirects +- State not persisting (data lost on refresh, back button) +- Race conditions (double-submit, stale data) +- Search returning wrong or no results + +### 3. UX +- Confusing navigation (no breadcrumbs, dead ends) +- Missing loading indicators (user doesn't know something is happening) +- Slow interactions (>500ms with no feedback) +- Unclear error messages ("Something went wrong" with no detail) +- No confirmation before destructive actions +- Inconsistent interaction patterns across pages +- Dead ends (no way back, no next action) + +### 4. Content +- Typos and grammar errors +- Outdated or incorrect text +- Placeholder / lorem ipsum text left in +- Truncated text (cut off without ellipsis or "more") +- Wrong labels on buttons or form fields +- Missing or unhelpful empty states + +### 5. Performance +- Slow page loads (>3 seconds) +- Janky scrolling (dropped frames) +- Layout shifts (content jumping after load) +- Excessive network requests (>50 on a single page) +- Large unoptimized images +- Blocking JavaScript (page unresponsive during load) + +### 6. Console/Errors +- JavaScript exceptions (uncaught errors) +- Failed network requests (4xx, 5xx) +- Deprecation warnings (upcoming breakage) +- CORS errors +- Mixed content warnings (HTTP resources on HTTPS) +- CSP violations + +### 7. Accessibility +- Missing alt text on images +- Unlabeled form inputs +- Keyboard navigation broken (can't tab to elements) +- Focus traps (can't escape a modal or dropdown) +- Missing or incorrect ARIA attributes +- Insufficient color contrast +- Content not reachable by screen reader + +## Per-Page Exploration Checklist + +For each page visited during a QA session: + +1. **Visual scan** — Take annotated screenshot (`snapshot -i -a -o`). Look for layout issues, broken images, alignment. +2. **Interactive elements** — Click every button, link, and control. Does each do what it says? +3. **Forms** — Fill and submit. Test empty submission, invalid data, edge cases (long text, special characters). +4. **Navigation** — Check all paths in/out. Breadcrumbs, back button, deep links, mobile menu. +5. **States** — Check empty state, loading state, error state, full/overflow state. +6. **Console** — Run `console --errors` after interactions. Any new JS errors or failed requests? +7. **Responsiveness** — If relevant, check mobile and tablet viewports. +8. **Auth boundaries** — What happens when logged out? Different user roles? diff --git a/qa/templates/qa-report-template.md b/qa/templates/qa-report-template.md new file mode 100644 index 0000000..d118ab8 --- /dev/null +++ b/qa/templates/qa-report-template.md @@ -0,0 +1,79 @@ +# QA Report: {APP_NAME} + +| Field | Value | +|-------|-------| +| **Date** | {DATE} | +| **URL** | {URL} | +| **Scope** | {SCOPE or "Full app"} | +| **Mode** | {full / quick / regression} | +| **Duration** | {DURATION} | +| **Pages visited** | {COUNT} | +| **Screenshots** | {COUNT} | +| **Framework** | {DETECTED or "Unknown"} | + +## Health Score: {SCORE}/100 + +| Category | Score | +|----------|-------| +| Console | {0-100} | +| Links | {0-100} | +| Visual | {0-100} | +| Functional | {0-100} | +| UX | {0-100} | +| Performance | {0-100} | +| Accessibility | {0-100} | + +## Top 3 Things to Fix + +1. **{ISSUE-NNN}: {title}** — {one-line description} +2. **{ISSUE-NNN}: {title}** — {one-line description} +3. **{ISSUE-NNN}: {title}** — {one-line description} + +## Console Health + +| Error | Count | First seen | +|-------|-------|------------| +| {error message} | {N} | {URL} | + +## Summary + +| Severity | Count | +|----------|-------| +| Critical | 0 | +| High | 0 | +| Medium | 0 | +| Low | 0 | +| **Total** | **0** | + +## Issues + +### ISSUE-001: {Short title} + +| Field | Value | +|-------|-------| +| **Severity** | critical / high / medium / low | +| **Category** | visual / functional / ux / content / performance / console / accessibility | +| **URL** | {page URL} | + +**Description:** {What is wrong, expected vs actual.} + +**Repro Steps:** + +1. Navigate to {URL} + ![Step 1](screenshots/issue-001-step-1.png) +2. {Action} + ![Step 2](screenshots/issue-001-step-2.png) +3. **Observe:** {what goes wrong} + ![Result](screenshots/issue-001-result.png) + +--- + +## Regression (if applicable) + +| Metric | Baseline | Current | Delta | +|--------|----------|---------|-------| +| Health score | {N} | {N} | {+/-N} | +| Issues | {N} | {N} | {+/-N} | + +**Fixed since baseline:** {list} +**New since baseline:** {list} diff --git a/retro/SKILL.md b/retro/SKILL.md index 05b6e15..ad5a758 100644 --- a/retro/SKILL.md +++ b/retro/SKILL.md @@ -1,9 +1,10 @@ --- name: retro -version: 1.0.0 +version: 2.0.0 description: | Weekly engineering retrospective. Analyzes commit history, work patterns, and code quality metrics with persistent history and trend tracking. + Team-aware: breaks down per-person contributions with praise and growth areas. allowed-tools: - Bash - Read @@ -13,7 +14,7 @@ allowed-tools: # /retro — Weekly Engineering Retrospective -Generates a comprehensive engineering retrospective analyzing commit history, work patterns, and code quality metrics. Designed for a senior IC/CTO-level builder using Claude Code as a force multiplier. +Generates a comprehensive engineering retrospective analyzing commit history, work patterns, and code quality metrics. Team-aware: identifies the user running the command, then analyzes every contributor with per-person praise and growth opportunities. Designed for a senior IC/CTO-level builder using Claude Code as a force multiplier. ## User-invocable When the user types `/retro`, run this skill. @@ -43,31 +44,42 @@ Usage: /retro [window] ### Step 1: Gather Raw Data -First, fetch origin to ensure we have the latest: +First, fetch origin and identify the current user: ```bash git fetch origin main --quiet +# Identify who is running the retro +git config user.name +git config user.email ``` +The name returned by `git config user.name` is **"you"** — the person reading this retro. All other authors are teammates. Use this to orient the narrative: "your" commits vs teammate contributions. + Run ALL of these git commands in parallel (they are independent): ```bash -# 1. All commits in window with timestamps, subject, hash, files changed, insertions, deletions -git log origin/main --since="" --format="%H|%ai|%s" --shortstat +# 1. All commits in window with timestamps, subject, hash, AUTHOR, files changed, insertions, deletions +git log origin/main --since="" --format="%H|%aN|%ae|%ai|%s" --shortstat -# 2. Per-commit test vs total LOC breakdown (single command, parse output) -# Each commit block starts with COMMIT:, followed by numstat lines. +# 2. Per-commit test vs total LOC breakdown with author +# Each commit block starts with COMMIT:|, followed by numstat lines. # Separate test files (matching test/|spec/|__tests__/) from production files. -git log origin/main --since="" --format="COMMIT:%H" --numstat +git log origin/main --since="" --format="COMMIT:%H|%aN" --numstat -# 3. Commit timestamps for session detection and hourly distribution +# 3. Commit timestamps for session detection and hourly distribution (with author) # Use TZ=America/Los_Angeles for Pacific time conversion -TZ=America/Los_Angeles git log origin/main --since="" --format="%at|%ai|%s" | sort -n +TZ=America/Los_Angeles git log origin/main --since="" --format="%at|%aN|%ai|%s" | sort -n # 4. Files most frequently changed (hotspot analysis) git log origin/main --since="" --format="" --name-only | grep -v '^$' | sort | uniq -c | sort -rn # 5. PR numbers from commit messages (extract #NNN patterns) git log origin/main --since="" --format="%s" | grep -oE '#[0-9]+' | sed 's/^#//' | sort -n | uniq | sed 's/^/#/' + +# 6. Per-author file hotspots (who touches what) +git log origin/main --since="" --format="AUTHOR:%aN" --name-only + +# 7. Per-author commit counts (quick summary) +git shortlog origin/main --since="" -sn --no-merges ``` ### Step 2: Compute Metrics @@ -77,6 +89,7 @@ Calculate and present these metrics in a summary table: | Metric | Value | |--------|-------| | Commits to main | N | +| Contributors | N | | PRs merged | N | | Total insertions | N | | Total deletions | N | @@ -88,6 +101,17 @@ Calculate and present these metrics in a summary table: | Detected sessions | N | | Avg LOC/session-hour | N | +Then show a **per-author leaderboard** immediately below: + +``` +Contributor Commits +/- Top area +You (garry) 32 +2400/-300 browse/ +alice 12 +800/-150 app/services/ +bob 3 +120/-40 tests/ +``` + +Sort by commits descending. The current user (from `git config user.name`) always appears first, labeled "You (name)". + ### Step 3: Commit Time Distribution Show hourly histogram in Pacific time using bar chart: @@ -158,27 +182,54 @@ From commit diffs, estimate PR sizes and bucket them: - LOC changed - Why it matters (infer from commit messages and files touched) -### Step 9: Week-over-Week Trends (if window >= 14d) +### Step 9: Team Member Analysis + +For each contributor (including the current user), compute: + +1. **Commits and LOC** — total commits, insertions, deletions, net LOC +2. **Areas of focus** — which directories/files they touched most (top 3) +3. **Commit type mix** — their personal feat/fix/refactor/test breakdown +4. **Session patterns** — when they code (their peak hours), session count +5. **Test discipline** — their personal test LOC ratio +6. **Biggest ship** — their single highest-impact commit or PR in the window + +**For the current user ("You"):** This section gets the deepest treatment. Include all the detail from the solo retro — session analysis, time patterns, focus score. Frame it in first person: "Your peak hours...", "Your biggest ship..." + +**For each teammate:** Write 2-3 sentences covering what they worked on and their pattern. Then: + +- **Praise** (1-2 specific things): Anchor in actual commits. Not "great work" — say exactly what was good. Examples: "Shipped the entire auth middleware rewrite in 3 focused sessions with 45% test coverage", "Every PR under 200 LOC — disciplined decomposition." +- **Opportunity for growth** (1 specific thing): Frame as a leveling-up suggestion, not criticism. Anchor in actual data. Examples: "Test ratio was 12% this week — adding test coverage to the payment module before it gets more complex would pay off", "5 fix commits on the same file suggest the original PR could have used a review pass." + +**If only one contributor (solo repo):** Skip the team breakdown and proceed as before — the retro is personal. + +**If there are Co-Authored-By trailers:** Parse `Co-Authored-By:` lines in commit messages. Credit those authors for the commit alongside the primary author. Note AI co-authors (e.g., `noreply@anthropic.com`) but do not include them as team members — instead, track "AI-assisted commits" as a separate metric. + +### Step 10: Week-over-Week Trends (if window >= 14d) If the time window is 14 days or more, split into weekly buckets and show trends: -- Commits per week +- Commits per week (total and per-author) - LOC per week - Test ratio per week - Fix ratio per week - Session count per week -### Step 10: Streak Tracking +### Step 11: Streak Tracking -Count consecutive days with at least 1 commit to origin/main, going back from today: +Count consecutive days with at least 1 commit to origin/main, going back from today. Track both team streak and personal streak: ```bash -# Get all unique commit dates (Pacific time) — no hard cutoff +# Team streak: all unique commit dates (Pacific time) — no hard cutoff TZ=America/Los_Angeles git log origin/main --format="%ad" --date=format:"%Y-%m-%d" | sort -u + +# Personal streak: only the current user's commits +TZ=America/Los_Angeles git log origin/main --author="" --format="%ad" --date=format:"%Y-%m-%d" | sort -u ``` -Count backward from today — how many consecutive days have at least one commit? This queries the full history so streaks of any length are reported accurately. Display: "Shipping streak: 47 consecutive days" +Count backward from today — how many consecutive days have at least one commit? This queries the full history so streaks of any length are reported accurately. Display both: +- "Team shipping streak: 47 consecutive days" +- "Your shipping streak: 32 consecutive days" -### Step 11: Load History & Compare +### Step 12: Load History & Compare Before saving the new snapshot, check for prior retro history: @@ -199,7 +250,7 @@ Deep sessions: 3 → 5 ↑2 **If no prior retros exist:** Skip the comparison section and append: "First retro recorded — run again next week to see trends." -### Step 12: Save Retro History +### Step 13: Save Retro History After computing all metrics (including streak) and loading any prior history for comparison, save a JSON snapshot: @@ -223,6 +274,7 @@ Use the Write tool to save the JSON file with this schema: "window": "7d", "metrics": { "commits": 47, + "contributors": 3, "prs_merged": 12, "insertions": 3200, "deletions": 800, @@ -236,15 +288,20 @@ Use the Write tool to save the JSON file with this schema: "loc_per_session_hour": 350, "feat_pct": 0.40, "fix_pct": 0.30, - "peak_hour": 22 + "peak_hour": 22, + "ai_assisted_commits": 32 + }, + "authors": { + "Garry Tan": { "commits": 32, "insertions": 2400, "deletions": 300, "test_ratio": 0.41, "top_area": "browse/" }, + "Alice": { "commits": 12, "insertions": 800, "deletions": 150, "test_ratio": 0.35, "top_area": "app/services/" } }, "version_range": ["1.16.0.0", "1.16.1.0"], "streak_days": 47, - "tweetable": "Week of Mar 1: 47 commits, 3.2k LOC, 38% tests, 12 PRs, peak: 10pm" + "tweetable": "Week of Mar 1: 47 commits (3 contributors), 3.2k LOC, 38% tests, 12 PRs, peak: 10pm" } ``` -### Step 13: Write the Narrative +### Step 14: Write the Narrative Structure the output as: @@ -252,7 +309,7 @@ Structure the output as: **Tweetable summary** (first line, before everything else): ``` -Week of Mar 1: 47 commits, 3.2k LOC, 38% tests, 12 PRs, peak: 10pm | Streak: 47d +Week of Mar 1: 47 commits (3 contributors), 3.2k LOC, 38% tests, 12 PRs, peak: 10pm | Streak: 47d ``` ## Engineering Retro: [date range] @@ -266,11 +323,11 @@ Week of Mar 1: 47 commits, 3.2k LOC, 38% tests, 12 PRs, peak: 10pm | Streak: 47d ### Time & Session Patterns (from Steps 3-4) -Narrative interpreting what the patterns mean: +Narrative interpreting what the team-wide patterns mean: - When the most productive hours are and what drives them - Whether sessions are getting longer or shorter over time -- Estimated hours per day of active coding -- How this maps to "CEO who also codes" lifestyle +- Estimated hours per day of active coding (team aggregate) +- Notable patterns: do team members code at the same time or in shifts? ### Shipping Velocity (from Steps 5-7) @@ -291,20 +348,49 @@ Narrative covering: - Focus score with interpretation - Ship of the week callout -### Top 3 Wins -Identify the 3 highest-impact things shipped in the window. For each: +### Your Week (personal deep-dive) +(from Step 9, for the current user only) + +This is the section the user cares most about. Include: +- Their personal commit count, LOC, test ratio +- Their session patterns and peak hours +- Their focus areas +- Their biggest ship +- **What you did well** (2-3 specific things anchored in commits) +- **Where to level up** (1-2 specific, actionable suggestions) + +### Team Breakdown +(from Step 9, for each teammate — skip if solo repo) + +For each teammate (sorted by commits descending), write a section: + +#### [Name] +- **What they shipped**: 2-3 sentences on their contributions, areas of focus, and commit patterns +- **Praise**: 1-2 specific things they did well, anchored in actual commits. Be genuine — what would you actually say in a 1:1? Examples: + - "Cleaned up the entire auth module in 3 small, reviewable PRs — textbook decomposition" + - "Added integration tests for every new endpoint, not just happy paths" + - "Fixed the N+1 query that was causing 2s load times on the dashboard" +- **Opportunity for growth**: 1 specific, constructive suggestion. Frame as investment, not criticism. Examples: + - "Test coverage on the payment module is at 8% — worth investing in before the next feature lands on top of it" + - "3 of the 5 PRs were 800+ LOC — breaking these up would catch issues earlier and make review easier" + - "All commits land between 1-4am — sustainable pace matters for code quality long-term" + +**AI collaboration note:** If many commits have `Co-Authored-By` AI trailers (e.g., Claude, Copilot), note the AI-assisted commit percentage as a team metric. Frame it neutrally — "N% of commits were AI-assisted" — without judgment. + +### Top 3 Team Wins +Identify the 3 highest-impact things shipped in the window across the whole team. For each: - What it was +- Who shipped it - Why it matters (product/architecture impact) -- What's impressive about the execution ### 3 Things to Improve -Specific, actionable, anchored in actual commits. Phrase as "to get even better, you could..." +Specific, actionable, anchored in actual commits. Mix personal and team-level suggestions. Phrase as "to get even better, the team could..." ### 3 Habits for Next Week -Small, practical, realistic for a very busy person. Each must be something that takes <5 minutes to adopt. +Small, practical, realistic. Each must be something that takes <5 minutes to adopt. At least one should be team-oriented (e.g., "review each other's PRs same-day"). ### Week-over-Week Trends -(if applicable, from Step 9) +(if applicable, from Step 10) --- @@ -324,7 +410,10 @@ When the user runs `/retro compare` (or `/retro compare 14d`): - Specific and concrete — always anchor in actual commits/code - Skip generic praise ("great job!") — say exactly what was good and why - Frame improvements as leveling up, not criticism -- Keep total output around 2500-3500 words +- **Praise should feel like something you'd actually say in a 1:1** — specific, earned, genuine +- **Growth suggestions should feel like investment advice** — "this is worth your time because..." not "you failed at..." +- Never compare teammates against each other negatively. Each person's section stands on its own. +- Keep total output around 3000-4500 words (slightly longer to accommodate team sections) - Use markdown tables and code blocks for data, prose for narrative - Output directly to the conversation — do NOT write to filesystem (except the `.context/retros/` JSON snapshot) diff --git a/setup b/setup index 73c6503..40430a4 100755 --- a/setup +++ b/setup @@ -6,7 +6,14 @@ GSTACK_DIR="$(cd "$(dirname "$0")" && pwd)" SKILLS_DIR="$(dirname "$GSTACK_DIR")" BROWSE_BIN="$GSTACK_DIR/browse/dist/browse" -# 1. Build browse binary if needed +ensure_playwright_browser() { + ( + cd "$GSTACK_DIR" + bun --eval 'import { chromium } from "playwright"; const browser = await chromium.launch(); await browser.close();' + ) >/dev/null 2>&1 +} + +# 1. Build browse binary if needed (smart rebuild: stale sources, package.json, lock) NEEDS_BUILD=0 if [ ! -x "$BROWSE_BIN" ]; then NEEDS_BUILD=1 @@ -32,7 +39,21 @@ if [ ! -x "$BROWSE_BIN" ]; then exit 1 fi -# 2. Only create skill symlinks if we're inside a .claude/skills directory +# 2. Ensure Playwright's Chromium is available +if ! ensure_playwright_browser; then + echo "Installing Playwright Chromium..." + ( + cd "$GSTACK_DIR" + bunx playwright install chromium + ) +fi + +if ! ensure_playwright_browser; then + echo "gstack setup failed: Playwright Chromium could not be launched" >&2 + exit 1 +fi + +# 3. Only create skill symlinks if we're inside a .claude/skills directory SKILLS_BASENAME="$(basename "$SKILLS_DIR")" if [ "$SKILLS_BASENAME" = "skills" ]; then linked=() diff --git a/setup-browser-cookies/SKILL.md b/setup-browser-cookies/SKILL.md new file mode 100644 index 0000000..28cc778 --- /dev/null +++ b/setup-browser-cookies/SKILL.md @@ -0,0 +1,82 @@ +--- +name: setup-browser-cookies +version: 1.0.0 +description: | + Import cookies from your real browser (Comet, Chrome, Arc, Brave, Edge) into the + headless browse session. Opens an interactive picker UI where you select which + cookie domains to import. Use before QA testing authenticated pages. +allowed-tools: + - Bash + - Read +--- + +# Setup Browser Cookies + +Import logged-in sessions from your real Chromium browser into the headless browse session. + +## How it works + +1. Find the browse binary +2. Run `cookie-import-browser` to detect installed browsers and open the picker UI +3. User selects which cookie domains to import in their browser +4. Cookies are decrypted and loaded into the Playwright session + +## Steps + +### 1. Find the browse binary + +```bash +B=$(browse/bin/find-browse 2>/dev/null || ~/.claude/skills/gstack/browse/bin/find-browse 2>/dev/null) +if [ -n "$B" ]; then + echo "READY: $B" +else + echo "NEEDS_SETUP" +fi +``` + +If `NEEDS_SETUP`: +1. Tell the user: "gstack browse needs a one-time build (~10 seconds). OK to proceed?" Then STOP and wait. +2. Run: `cd && ./setup` +3. If `bun` is not installed: `curl -fsSL https://bun.sh/install | bash` + +### 2. Open the cookie picker + +```bash +$B cookie-import-browser +``` + +This auto-detects installed Chromium browsers (Comet, Chrome, Arc, Brave, Edge) and opens +an interactive picker UI in your default browser where you can: +- Switch between installed browsers +- Search domains +- Click "+" to import a domain's cookies +- Click trash to remove imported cookies + +Tell the user: **"Cookie picker opened — select the domains you want to import in your browser, then tell me when you're done."** + +### 3. Direct import (alternative) + +If the user specifies a domain directly (e.g., `/setup-browser-cookies github.com`), skip the UI: + +```bash +$B cookie-import-browser comet --domain github.com +``` + +Replace `comet` with the appropriate browser if specified. + +### 4. Verify + +After the user confirms they're done: + +```bash +$B cookies +``` + +Show the user a summary of imported cookies (domain counts). + +## Notes + +- First import per browser may trigger a macOS Keychain dialog — click "Allow" / "Always Allow" +- Cookie picker is served on the same port as the browse server (no extra process) +- Only domain names and cookie counts are shown in the UI — no cookie values are exposed +- The browse session persists cookies between commands, so imported cookies work immediately