From 43f8f2ca60c744a6e02f746c2efe667cc505b9d3 Mon Sep 17 00:00:00 2001 From: "dash / @da5ch0 / aliases" Date: Fri, 13 Mar 2026 19:59:10 -0600 Subject: [PATCH 01/20] Delete setup fixed errors --- setup | 87 ----------------------------------------------------------- 1 file changed, 87 deletions(-) delete mode 100755 setup diff --git a/setup b/setup deleted file mode 100755 index 1f1ad09..0000000 --- a/setup +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env bash -# gstack setup — build browser binary + register all skills with Claude Code -set -e - -GSTACK_DIR="$(cd "$(dirname "$0")" && pwd)" -SKILLS_DIR="$(dirname "$GSTACK_DIR")" -BROWSE_BIN="$GSTACK_DIR/browse/dist/browse" - -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 -elif [ -n "$(find "$GSTACK_DIR/browse/src" -type f -newer "$BROWSE_BIN" -print -quit 2>/dev/null)" ]; then - NEEDS_BUILD=1 -elif [ "$GSTACK_DIR/package.json" -nt "$BROWSE_BIN" ]; then - NEEDS_BUILD=1 -elif [ -f "$GSTACK_DIR/bun.lock" ] && [ "$GSTACK_DIR/bun.lock" -nt "$BROWSE_BIN" ]; then - NEEDS_BUILD=1 -fi - -if [ "$NEEDS_BUILD" -eq 1 ]; then - echo "Building browse binary..." - ( - cd "$GSTACK_DIR" - bun install - bun run build - ) - # Safety net: write .version if build script didn't (e.g., git not available during build) - if [ ! -f "$GSTACK_DIR/browse/dist/.version" ]; then - git -C "$GSTACK_DIR" rev-parse HEAD > "$GSTACK_DIR/browse/dist/.version" 2>/dev/null || true - fi -fi - -if [ ! -x "$BROWSE_BIN" ]; then - echo "gstack setup failed: browse binary missing at $BROWSE_BIN" >&2 - exit 1 -fi - -# 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=() - for skill_dir in "$GSTACK_DIR"/*/; do - if [ -f "$skill_dir/SKILL.md" ]; then - skill_name="$(basename "$skill_dir")" - # Skip node_modules - [ "$skill_name" = "node_modules" ] && continue - target="$SKILLS_DIR/$skill_name" - # Create or update symlink; skip if a real file/directory exists - if [ -L "$target" ] || [ ! -e "$target" ]; then - ln -snf "gstack/$skill_name" "$target" - linked+=("$skill_name") - fi - fi - done - - echo "gstack ready." - echo " browse: $BROWSE_BIN" - if [ ${#linked[@]} -gt 0 ]; then - echo " linked skills: ${linked[*]}" - fi -else - echo "gstack ready." - echo " browse: $BROWSE_BIN" - echo " (skipped skill symlinks — not inside .claude/skills/)" -fi From 05fc69c867ba3446b47e3b0c6d9899ea9a1e1192 Mon Sep 17 00:00:00 2001 From: "dash / @da5ch0 / aliases" Date: Fri, 13 Mar 2026 19:59:27 -0600 Subject: [PATCH 02/20] Delete package.json fixing errors --- package.json | 34 ---------------------------------- 1 file changed, 34 deletions(-) delete mode 100644 package.json diff --git a/package.json b/package.json deleted file mode 100644 index bece501..0000000 --- a/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "gstack", - "version": "0.3.2", - "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", - "license": "MIT", - "type": "module", - "bin": { - "browse": "./browse/dist/browse" - }, - "scripts": { - "build": "bun build --compile browse/src/cli.ts --outfile browse/dist/browse && bun build --compile browse/src/find-browse.ts --outfile browse/dist/find-browse && git rev-parse HEAD > browse/dist/.version && rm -f .*.bun-build", - "dev": "bun run browse/src/cli.ts", - "server": "bun run browse/src/server.ts", - "test": "bun test", - "start": "bun run browse/src/server.ts" - }, - "dependencies": { - "playwright": "^1.58.2", - "diff": "^7.0.0" - }, - "engines": { - "bun": ">=1.0.0" - }, - "keywords": [ - "browser", - "automation", - "playwright", - "headless", - "cli", - "claude", - "ai-agent", - "devtools" - ] -} From e729674c4e897e12610838681f768487db9affd2 Mon Sep 17 00:00:00 2001 From: "dash / @da5ch0 / aliases" Date: Fri, 13 Mar 2026 19:59:40 -0600 Subject: [PATCH 03/20] Delete VERSION fixing errors --- VERSION | 1 - 1 file changed, 1 deletion(-) delete mode 100644 VERSION diff --git a/VERSION b/VERSION deleted file mode 100644 index d15723f..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -0.3.2 From 3bd6d92455fee5c2287c3dfba007c3a072d0f809 Mon Sep 17 00:00:00 2001 From: "dash / @da5ch0 / aliases" Date: Fri, 13 Mar 2026 20:00:15 -0600 Subject: [PATCH 04/20] Update TODO.md updated task items --- TODO.md | 118 +------------------------------------------------------- 1 file changed, 1 insertion(+), 117 deletions(-) diff --git a/TODO.md b/TODO.md index 0148e70..375f4b9 100644 --- a/TODO.md +++ b/TODO.md @@ -1,117 +1 @@ -# TODO — gstack roadmap - -## Phase 1: Foundations (v0.2.0) - - [x] Rename to gstack - - [x] Restructure to monorepo layout - - [x] Setup script for skill symlinks - - [x] Snapshot command with ref-based element selection - - [x] Snapshot tests - -## 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 (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 - - Browse staging/preview URL after push - - Screenshot key pages - - Check console for JS errors - - Compare staging vs prod via snapshot diff - - Include verification screenshots in PR body - - STOP if critical errors found - - [ ] review + browse: visual diff review - - Browse PR's preview deploy - - Annotated screenshots of changed pages - - Compare against production visually - - Check responsive layouts (mobile/tablet/desktop) - - Verify accessibility tree hasn't regressed - - [ ] deploy-verify skill: lightweight post-deploy smoke test - - Hit key URLs, verify 200s - - Screenshot critical pages - - Console error check - - Compare against baseline snapshots - - Pass/fail with evidence - -## Phase 5: State & Sessions - - [ ] Bundle server.ts into compiled binary (eliminate resolveServerScript() fallback chain entirely) (P2, M) - - [ ] 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) - - Count console errors across key pages - - Track trends over retro window - -## Phase 6: Advanced Browser - - [ ] Iframe support (frame , frame main) - - [ ] Semantic locators (find role/label/text/placeholder/testid with actions) - - [ ] Device emulation presets (set device "iPhone 16 Pro") - - [ ] Network mocking/routing (intercept, block, mock requests) - - [ ] Download handling (click-to-download with path control) - - [ ] Content safety (--max-output truncation, --allowed-domains) - - [ ] 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) - - [ ] Greptile training feedback loop — export suppression patterns to Greptile team for model improvement (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 - - One repo, one install, entire AI engineering workflow - - Bun compiled binary matches Rust CLI performance for this use case (bottleneck is Chromium, not CLI parsing) - - Accessibility tree snapshots use ~200-400 tokens vs ~3000-5000 for full DOM — critical for AI context efficiency - - Locator map approach for refs: store Map on BrowserManager, no DOM mutation, no CSP issues - - Snapshot scoping (-i, -c, -d, -s flags) is critical for performance on large pages - - All new commands follow existing pattern: add to command set, add switch case, return string +stop vibecoding! full stop From 2f6b44ea1ee14b002481a718b570bf48826acf57 Mon Sep 17 00:00:00 2001 From: "dash / @da5ch0 / aliases" Date: Fri, 13 Mar 2026 20:00:34 -0600 Subject: [PATCH 05/20] Delete SKILL.md fixed errors --- SKILL.md | 350 ------------------------------------------------------- 1 file changed, 350 deletions(-) delete mode 100644 SKILL.md diff --git a/SKILL.md b/SKILL.md deleted file mode 100644 index e561e2c..0000000 --- a/SKILL.md +++ /dev/null @@ -1,350 +0,0 @@ ---- -name: gstack -version: 1.1.0 -description: | - 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 browse: QA Testing & Dogfooding - -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) - -```bash -BROWSE_OUTPUT=$(browse/bin/find-browse 2>/dev/null || ~/.claude/skills/gstack/browse/bin/find-browse 2>/dev/null) -B=$(echo "$BROWSE_OUTPUT" | head -1) -META=$(echo "$BROWSE_OUTPUT" | grep "^META:" || true) -if [ -n "$B" ]; then - echo "READY: $B" - [ -n "$META" ] && echo "$META" -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` - -If you see `META:UPDATE_AVAILABLE`: -1. Parse the JSON payload to get `current`, `latest`, and `command`. -2. Tell the user: "A gstack update is available (current: X, latest: Y). OK to update?" -3. **STOP and wait for approval.** -4. Run the command from the META payload. -5. Re-run the setup check above to get the updated binary path. - -## IMPORTANT - -- Use the compiled binary via Bash: `$B ` -- NEVER use `mcp__claude-in-chrome__*` tools. They are slow and unreliable. -- Browser persists between calls — cookies, login sessions, and tabs carry over. -- Dialogs (alert/confirm/prompt) are auto-accepted by default — no browser lockup. - -## QA Workflows - -### Test a user flow (login, signup, checkout, etc.) - -```bash -B=~/.claude/skills/gstack/browse/dist/browse - -# 1. Go to the page -$B goto https://app.example.com/login - -# 2. See what's interactive -$B snapshot -i - -# 3. Fill the form using refs -$B fill @e3 "test@example.com" -$B fill @e4 "password123" -$B click @e5 - -# 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 -``` - -### Verify a deployment / check prod - -```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 -``` - -### Dogfood a feature end-to-end - -```bash -# Navigate to the feature -$B goto https://app.example.com/new-feature - -# Take annotated screenshot — shows every interactive element with labels -$B snapshot -i -a -o /tmp/feature-annotated.png - -# Find ALL clickable things (including divs with cursor:pointer) -$B snapshot -C - -# Walk through the flow -$B snapshot -i # baseline -$B click @e3 # interact -$B snapshot -D # what changed? (unified diff) - -# Check element states -$B is visible ".success-toast" -$B is enabled "#next-step-btn" -$B is checked "#agree-checkbox" - -# Check console for errors after interactions -$B console -``` - -### Test responsive layouts - -```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 -``` - -### Test file upload - -```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 -``` - -### Test forms with validation - -```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 -``` - -### 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 -``` - -### 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 -``` - -### Compare two pages / environments - -```bash -$B diff https://staging.app.com https://prod.app.com -``` - -### 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 -``` - -## 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" -``` - -## 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) -``` - -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 -| 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 -| 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 -| Command | Description | -|---------|-------------| -| `screenshot [path]` | Screenshot | -| `pdf [path]` | Save as PDF | -| `responsive [prefix]` | Mobile/tablet/desktop screenshots | -| `diff ` | Text diff between pages | - -### Tabs -| 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. From f068764af0b08fa52d86935e115b2547c2fb814e Mon Sep 17 00:00:00 2001 From: "dash / @da5ch0 / aliases" Date: Fri, 13 Mar 2026 20:00:51 -0600 Subject: [PATCH 06/20] Delete LICENSE license change suggestion --- LICENSE | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 LICENSE diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 3502951..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Garry Tan - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. From 10259c983e99cabaf638cc245eede3b331efa90f Mon Sep 17 00:00:00 2001 From: "dash / @da5ch0 / aliases" Date: Fri, 13 Mar 2026 20:01:04 -0600 Subject: [PATCH 07/20] Delete CONTRIBUTING.md --- CONTRIBUTING.md | 153 ------------------------------------------------ 1 file changed, 153 deletions(-) delete mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index d696904..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,153 +0,0 @@ -# Contributing to gstack - -Thanks for wanting to make gstack better. Whether you're fixing a typo in a skill prompt or building an entirely new workflow, this guide will get you up and running fast. - -## Quick start - -gstack skills are Markdown files that Claude Code discovers from a `skills/` directory. Normally they live at `~/.claude/skills/gstack/` (your global install). But when you're developing gstack itself, you want Claude Code to use the skills *in your working tree* — so edits take effect instantly without copying or deploying anything. - -That's what dev mode does. It symlinks your repo into the local `.claude/skills/` directory so Claude Code reads skills straight from your checkout. - -```bash -git clone && cd gstack -bun install # install dependencies -bin/dev-setup # activate dev mode -``` - -Now edit any `SKILL.md`, invoke it in Claude Code (e.g. `/review`), and see your changes live. When you're done developing: - -```bash -bin/dev-teardown # deactivate — back to your global install -``` - -## How dev mode works - -`bin/dev-setup` creates a `.claude/skills/` directory inside the repo (gitignored) and fills it with symlinks pointing back to your working tree. Claude Code sees the local `skills/` first, so your edits win over the global install. - -``` -gstack/ <- your working tree -├── .claude/skills/ <- created by dev-setup (gitignored) -│ ├── gstack -> ../../ <- symlink back to repo root -│ ├── review -> gstack/review -│ ├── ship -> gstack/ship -│ └── ... <- one symlink per skill -├── review/ -│ └── SKILL.md <- edit this, test with /review -├── ship/ -│ └── SKILL.md -├── browse/ -│ ├── src/ <- TypeScript source -│ └── dist/ <- compiled binary (gitignored) -└── ... -``` - -## Day-to-day workflow - -```bash -# 1. Enter dev mode -bin/dev-setup - -# 2. Edit a skill -vim review/SKILL.md - -# 3. Test it in Claude Code — changes are live -# > /review - -# 4. Editing browse source? Rebuild the binary -bun run build - -# 5. Done for the day? Tear down -bin/dev-teardown -``` - -## Running tests - -```bash -bun test # all tests (browse integration + snapshot) -bun run dev # run CLI in dev mode, e.g. bun run dev goto https://example.com -bun run build # compile binary to browse/dist/browse -``` - -Tests run against the browse binary directly — they don't require dev mode. - -## Things to know - -- **SKILL.md changes are instant.** They're just Markdown. Edit, save, invoke. -- **Browse source changes need a rebuild.** If you touch `browse/src/*.ts`, run `bun run build`. -- **Dev mode shadows your global install.** Project-local skills take priority over `~/.claude/skills/gstack`. `bin/dev-teardown` restores the global one. -- **Conductor workspaces are independent.** Each workspace is its own clone. Run `bin/dev-setup` in the one you're working in. -- **`.claude/skills/` is gitignored.** The symlinks never get committed. - -## Testing a branch in another repo - -When you're developing gstack in one workspace and want to test your branch in a -different project (e.g. testing browse changes against your real app), there are -two cases depending on how gstack is installed in that project. - -### Global install only (no `.claude/skills/gstack/` in the project) - -Point your global install at the branch: - -```bash -cd ~/.claude/skills/gstack -git fetch origin -git checkout origin/ # e.g. origin/v0.3.2 -bun install # in case deps changed -bun run build # rebuild the binary -``` - -Now open Claude Code in the other project — it picks up skills from -`~/.claude/skills/` automatically. To go back to main when you're done: - -```bash -cd ~/.claude/skills/gstack -git checkout main && git pull -bun run build -``` - -### Vendored project copy (`.claude/skills/gstack/` checked into the project) - -Some projects vendor gstack by copying it into the repo (no `.git` inside the -copy). Project-local skills take priority over global, so you need to update -the vendored copy too. This is a three-step process: - -1. **Update your global install to the branch** (so you have the source): - ```bash - cd ~/.claude/skills/gstack - git fetch origin - git checkout origin/ # e.g. origin/v0.3.2 - bun install && bun run build - ``` - -2. **Replace the vendored copy** in the other project: - ```bash - cd /path/to/other-project - - # Remove old skill symlinks and vendored copy - 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 - - # Copy from global install (strips .git so it stays vendored) - cp -Rf ~/.claude/skills/gstack .claude/skills/gstack - rm -rf .claude/skills/gstack/.git - - # Rebuild binary and re-create skill symlinks - cd .claude/skills/gstack && ./setup - ``` - -3. **Test your changes** — open Claude Code in that project and use the skills. - -To revert to main later, repeat steps 1-2 with `git checkout main && git pull` -instead of `git checkout origin/`. - -## Shipping your changes - -When you're happy with your skill edits: - -```bash -/ship -``` - -This runs tests, reviews the diff, bumps the version, and opens a PR. See `ship/SKILL.md` for the full workflow. From c96670fb5090ed6c1bb97b4ac4230da0eccf4a25 Mon Sep 17 00:00:00 2001 From: "dash / @da5ch0 / aliases" Date: Fri, 13 Mar 2026 20:01:18 -0600 Subject: [PATCH 08/20] Delete CLAUDE.md fixed errors --- CLAUDE.md | 45 --------------------------------------------- 1 file changed, 45 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 917afed..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,45 +0,0 @@ -# gstack development - -## Commands - -```bash -bun install # install dependencies -bun test # run integration tests (browse + snapshot) -bun run dev # run CLI in dev mode, e.g. bun run dev goto https://example.com -bun run build # compile binary to browse/dist/browse -``` - -## Project structure - -``` -gstack/ -├── browse/ # Headless browser CLI (Playwright) -│ ├── src/ # CLI + server + commands -│ ├── test/ # Integration tests + fixtures -│ └── dist/ # Compiled binary -├── ship/ # Ship workflow skill -├── review/ # PR review skill -├── plan-ceo-review/ # /plan-ceo-review skill -├── plan-eng-review/ # /plan-eng-review skill -├── retro/ # Retrospective skill -├── setup # One-time setup: build binary + symlink skills -├── SKILL.md # Browse skill (Claude discovers this) -└── package.json # Build scripts for browse -``` - -## Browser interaction - -When you need to interact with a browser (QA, dogfooding, cookie setup), use the -`/browse` skill or run the browse binary directly via `$B `. NEVER use -`mcp__claude-in-chrome__*` tools — they are slow, unreliable, and not what this -project uses. - -## Deploying to the active skill - -The active skill lives at `~/.claude/skills/gstack/`. After making changes: - -1. Push your branch -2. Fetch and reset in the skill directory: `cd ~/.claude/skills/gstack && git fetch origin && git reset --hard origin/main` -3. Rebuild: `cd ~/.claude/skills/gstack && bun run build` - -Or copy the binary directly: `cp browse/dist/browse ~/.claude/skills/gstack/browse/dist/browse` From 2a886a358dddcfd9d10e61c8233067ef5e772205 Mon Sep 17 00:00:00 2001 From: "dash / @da5ch0 / aliases" Date: Fri, 13 Mar 2026 20:01:56 -0600 Subject: [PATCH 09/20] Update CHANGELOG.md --- CHANGELOG.md | 111 +-------------------------------------------------- 1 file changed, 1 insertion(+), 110 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1c5c97..91a2e43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,110 +1 @@ -# Changelog - -## 0.3.2 — 2026-03-13 - -### Fixed -- Cookie import picker now returns JSON instead of HTML — `jsonResponse()` referenced `url` out of scope, crashing every API call -- `help` command routed correctly (was unreachable due to META_COMMANDS dispatch ordering) -- Stale servers from global install no longer shadow local changes — removed legacy `~/.claude/skills/gstack` fallback from `resolveServerScript()` -- Crash log path references updated from `/tmp/` to `.gstack/` - -### Added -- **Diff-aware QA mode** — `/qa` on a feature branch auto-analyzes `git diff`, identifies affected pages/routes, detects the running app on localhost, and tests only what changed. No URL needed. -- **Project-local browse state** — state file, logs, and all server state now live in `.gstack/` inside the project root (detected via `git rev-parse --show-toplevel`). No more `/tmp` state files. -- **Shared config module** (`browse/src/config.ts`) — centralizes path resolution for CLI and server, eliminates duplicated port/state logic -- **Random port selection** — server picks a random port 10000-60000 instead of scanning 9400-9409. No more CONDUCTOR_PORT magic offset. No more port collisions across workspaces. -- **Binary version tracking** — state file includes `binaryVersion` SHA; CLI auto-restarts the server when the binary is rebuilt -- **Legacy /tmp cleanup** — CLI scans for and removes old `/tmp/browse-server*.json` files, verifying PID ownership before sending signals -- **Greptile integration** — `/review` and `/ship` fetch and triage Greptile bot comments; `/retro` tracks Greptile batting average across weeks -- **Local dev mode** — `bin/dev-setup` symlinks skills from the repo for in-place development; `bin/dev-teardown` restores global install -- `help` command — agents can self-discover all commands and snapshot flags -- Version-aware `find-browse` with META signal protocol — detects stale binaries and prompts agents to update -- `browse/dist/find-browse` compiled binary with git SHA comparison against origin/main (4hr cached) -- `.version` file written at build time for binary version tracking -- Route-level tests for cookie picker (13 tests) and find-browse version check (10 tests) -- Config resolution tests (14 tests) covering git root detection, BROWSE_STATE_FILE override, ensureStateDir, readVersionHash, resolveServerScript, and version mismatch detection -- Browser interaction guidance in CLAUDE.md — prevents Claude from using mcp\_\_claude-in-chrome\_\_\* tools -- CONTRIBUTING.md with quick start, dev mode explanation, and instructions for testing branches in other repos - -### Changed -- State file location: `.gstack/browse.json` (was `/tmp/browse-server.json`) -- Log files location: `.gstack/browse-{console,network,dialog}.log` (was `/tmp/browse-*.log`) -- Atomic state file writes: `.json.tmp` → rename (prevents partial reads) -- CLI passes `BROWSE_STATE_FILE` to spawned server (server derives all paths from it) -- SKILL.md setup checks parse META signals and handle `META:UPDATE_AVAILABLE` -- `/qa` SKILL.md now describes four modes (diff-aware, full, quick, regression) with diff-aware as the default on feature branches -- `jsonResponse`/`errorResponse` use options objects to prevent positional parameter confusion -- Build script compiles both `browse` and `find-browse` binaries, cleans up `.bun-build` temp files -- README updated with Greptile setup instructions, diff-aware QA examples, and revised demo transcript - -### Removed -- `CONDUCTOR_PORT` magic offset (`browse_port = CONDUCTOR_PORT - 45600`) -- Port scan range 9400-9409 -- Legacy fallback to `~/.claude/skills/gstack/browse/src/server.ts` -- `DEVELOPING_GSTACK.md` (renamed to CONTRIBUTING.md) - -## 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 -- `setup` rebuilds stale binaries (not just missing ones) and exits non-zero if the build fails -- Fix `chain` command swallowing real errors from write commands (e.g. navigation timeout reported as "Unknown meta command") -- Fix unbounded restart loop in CLI when server crashes repeatedly on the same command -- Cap console/network buffers at 50k entries (ring buffer) instead of growing without bound -- Fix disk flush stopping silently after buffer hits the 50k cap -- Fix `ln -snf` in setup to avoid creating nested symlinks on upgrade -- Use `git fetch && git reset --hard` instead of `git pull` for upgrades (handles force-pushes) -- Simplify install: global-first with optional project copy (replaces submodule approach) -- Restructured README: hero, before/after, demo transcript, troubleshooting section -- Six skills (added `/retro`) - -## 0.0.1 — 2026-03-11 - -Initial release. - -- Five skills: `/plan-ceo-review`, `/plan-eng-review`, `/review`, `/ship`, `/browse` -- Headless browser CLI with 40+ commands, ref-based interaction, persistent Chromium daemon -- One-command install as Claude Code skills (submodule or global clone) -- `setup` script for binary compilation and skill symlinking +2026-03-13 fixed it! --dash From 6c538c579b7a6e95454a842caa45a6a42337535d Mon Sep 17 00:00:00 2001 From: "dash / @da5ch0 / aliases" Date: Fri, 13 Mar 2026 20:02:11 -0600 Subject: [PATCH 10/20] Delete BROWSER.md fixed errors --- BROWSER.md | 229 ----------------------------------------------------- 1 file changed, 229 deletions(-) delete mode 100644 BROWSER.md diff --git a/BROWSER.md b/BROWSER.md deleted file mode 100644 index 640bb65..0000000 --- a/BROWSER.md +++ /dev/null @@ -1,229 +0,0 @@ -# Browser — technical details - -This document covers the command reference and internals of gstack's headless browser. - -## Command reference - -| Category | Commands | What for | -|----------|----------|----------| -| 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] [-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, `@e` refs after `snapshot`, or `@c` refs after `snapshot -C`. 50+ commands total plus cookie import. - -## How it works - -gstack's browser is a compiled CLI binary that talks to a persistent local Chromium daemon over HTTP. The CLI is a thin client — it reads a state file, sends a command, and prints the response to stdout. The server does the real work via [Playwright](https://playwright.dev/). - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Claude Code │ -│ │ -│ "browse goto https://staging.myapp.com" │ -│ │ │ -│ ▼ │ -│ ┌──────────┐ HTTP POST ┌──────────────┐ │ -│ │ browse │ ──────────────── │ Bun HTTP │ │ -│ │ CLI │ localhost:rand │ server │ │ -│ │ │ Bearer token │ │ │ -│ │ compiled │ ◄────────────── │ Playwright │──── Chromium │ -│ │ binary │ plain text │ API calls │ (headless) │ -│ └──────────┘ └──────────────┘ │ -│ ~1ms startup persistent daemon │ -│ auto-starts on first call │ -│ auto-stops after 30 min idle │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### Lifecycle - -1. **First call**: CLI checks `.gstack/browse.json` (in the project root) for a running server. None found — it spawns `bun run browse/src/server.ts` in the background. The server launches headless Chromium via Playwright, picks a random port (10000-60000), generates a bearer token, writes the state file, and starts accepting HTTP requests. This takes ~3 seconds. - -2. **Subsequent calls**: CLI reads the state file, sends an HTTP POST with the bearer token, prints the response. ~100-200ms round trip. - -3. **Idle shutdown**: After 30 minutes with no commands, the server shuts down and cleans up the state file. Next call restarts it automatically. - -4. **Crash recovery**: If Chromium crashes, the server exits immediately (no self-healing — don't hide failure). The CLI detects the dead server on the next call and starts a fresh one. - -### Key components - -``` -browse/ -├── src/ -│ ├── 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 + 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) -``` - -### The snapshot system - -The browser's key innovation is ref-based element selection, built on Playwright's accessibility tree API: - -1. `page.locator(scope).ariaSnapshot()` returns a YAML-like accessibility tree -2. The snapshot parser assigns refs (`@e1`, `@e2`, ...) to each element -3. For each ref, it builds a Playwright `Locator` (using `getByRole` + nth-child) -4. The ref-to-Locator map is stored on `BrowserManager` -5. Later commands like `click @e3` look up the Locator and call `locator.click()` - -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 (`.gstack/browse.json`) with chmod 600. Every HTTP request must include `Authorization: Bearer `. This prevents other processes on the machine from controlling the browser. - -### Console, network, and dialog capture - -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: `.gstack/browse-console.log` -- Network: `.gstack/browse-network.log` -- Dialog: `.gstack/browse-dialog.log` - -The `console`, `network`, and `dialog` commands read from the in-memory buffers, not disk. - -### Dialog handling - -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 - -Each workspace gets its own isolated browser instance with its own Chromium process, tabs, cookies, and logs. State is stored in `.gstack/` inside the project root (detected via `git rev-parse --show-toplevel`). - -| Workspace | State file | Port | -|-----------|------------|------| -| `/code/project-a` | `/code/project-a/.gstack/browse.json` | random (10000-60000) | -| `/code/project-b` | `/code/project-b/.gstack/browse.json` | random (10000-60000) | - -No port collisions. No shared state. Each project is fully isolated. - -### Environment variables - -| Variable | Default | Description | -|----------|---------|-------------| -| `BROWSE_PORT` | 0 (random 10000-60000) | Fixed port for the HTTP server (debug override) | -| `BROWSE_IDLE_TIMEOUT` | 1800000 (30 min) | Idle shutdown timeout in ms | -| `BROWSE_STATE_FILE` | `.gstack/browse.json` | Path to state file (CLI passes to server) | -| `BROWSE_SERVER_SCRIPT` | auto-detected | Path to server.ts | - -### Performance - -| Tool | First call | Subsequent calls | Context overhead per call | -|------|-----------|-----------------|--------------------------| -| Chrome MCP | ~5s | ~2-5s | ~2000 tokens (schema + protocol) | -| Playwright MCP | ~3s | ~1-3s | ~1500 tokens (schema + protocol) | -| **gstack browse** | **~3s** | **~100-200ms** | **0 tokens** (plain text stdout) | - -The context overhead difference compounds fast. In a 20-command browser session, MCP tools burn 30,000-40,000 tokens on protocol framing alone. gstack burns zero. - -### Why CLI over MCP? - -MCP (Model Context Protocol) works well for remote services, but for local browser automation it adds pure overhead: - -- **Context bloat**: every MCP call includes full JSON schemas and protocol framing. A simple "get the page text" costs 10x more context tokens than it should. -- **Connection fragility**: persistent WebSocket/stdio connections drop and fail to reconnect. -- **Unnecessary abstraction**: Claude Code already has a Bash tool. A CLI that prints to stdout is the simplest possible interface. - -gstack skips all of this. Compiled binary. Plain text in, plain text out. No protocol. No schema. No connection management. - -## Acknowledgments - -The browser automation layer is built on [Playwright](https://playwright.dev/) by Microsoft. Playwright's accessibility tree API, locator system, and headless Chromium management are what make ref-based interaction possible. The snapshot system — assigning `@ref` labels to accessibility tree nodes and mapping them back to Playwright Locators — is built entirely on top of Playwright's primitives. Thank you to the Playwright team for building such a solid foundation. - -## Development - -### Prerequisites - -- [Bun](https://bun.sh/) v1.0+ -- Playwright's Chromium (installed automatically by `bun install`) - -### Quick start - -```bash -bun install # install dependencies + Playwright Chromium -bun test # run integration tests (~3s) -bun run dev # run CLI from source (no compile) -bun run build # compile to browse/dist/browse -``` - -### Dev mode vs compiled binary - -During development, use `bun run dev` instead of the compiled binary. It runs `browse/src/cli.ts` directly with Bun, so you get instant feedback without a compile step: - -```bash -bun run dev goto https://example.com -bun run dev text -bun run dev snapshot -i -bun run dev click @e3 -``` - -The compiled binary (`bun run build`) is only needed for distribution. It produces a single ~58MB executable at `browse/dist/browse` using Bun's `--compile` flag. - -### Running tests - -```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/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. 203 tests across 3 files, ~15 seconds total. - -### Source map - -| File | Role | -|------|------| -| `browse/src/cli.ts` | Entry point. Reads `.gstack/browse.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 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 - -The active skill lives at `~/.claude/skills/gstack/`. After making changes: - -1. Push your branch -2. Pull in the skill directory: `cd ~/.claude/skills/gstack && git pull` -3. Rebuild: `cd ~/.claude/skills/gstack && bun run build` - -Or copy the binary directly: `cp browse/dist/browse ~/.claude/skills/gstack/browse/dist/browse` - -### Adding a new command - -1. Add the handler in `read-commands.ts` (non-mutating) or `write-commands.ts` (mutating) -2. Register the route in `server.ts` -3. Add a test case in `browse/test/commands.test.ts` with an HTML fixture if needed -4. Run `bun test` to verify -5. Run `bun run build` to compile From 4aae203813d7e3ff1436005de852c58d84cc6dac Mon Sep 17 00:00:00 2001 From: "dash / @da5ch0 / aliases" Date: Fri, 13 Mar 2026 20:02:26 -0600 Subject: [PATCH 11/20] Delete .gitignore --- .gitignore | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 .gitignore diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 52f79ab..0000000 --- a/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -node_modules/ -browse/dist/ -.gstack/ -.claude/skills/ -/tmp/ -*.log -bun.lock -*.bun-build From 359f806e3264379d3e370b6b2b602bfd999fdb5d Mon Sep 17 00:00:00 2001 From: "dash / @da5ch0 / aliases" Date: Fri, 13 Mar 2026 20:02:42 -0600 Subject: [PATCH 12/20] Delete ship directory fixed errors in skills libraries --- ship/SKILL.md | 343 -------------------------------------------------- 1 file changed, 343 deletions(-) delete mode 100644 ship/SKILL.md diff --git a/ship/SKILL.md b/ship/SKILL.md deleted file mode 100644 index ff6a2ca..0000000 --- a/ship/SKILL.md +++ /dev/null @@ -1,343 +0,0 @@ ---- -name: ship -version: 1.0.0 -description: | - Ship workflow: merge main, run tests, review diff, bump VERSION, update CHANGELOG, commit, push, create PR. -allowed-tools: - - Bash - - Read - - Write - - Edit - - Grep - - Glob - - AskUserQuestion ---- - -# Ship: Fully Automated Ship Workflow - -You are running the `/ship` workflow. This is a **non-interactive, fully automated** workflow. Do NOT ask for confirmation at any step. The user said `/ship` which means DO IT. Run straight through and output the PR URL at the end. - -**Only stop for:** -- On `main` branch (abort) -- Merge conflicts that can't be auto-resolved (stop, show conflicts) -- Test failures (stop, show failures) -- Pre-landing review finds CRITICAL issues and user chooses to fix (not acknowledge or skip) -- MINOR or MAJOR version bump needed (ask — see Step 4) -- Greptile review comments that need user decision (complex fixes, false positives) - -**Never stop for:** -- Uncommitted changes (always include them) -- Version bump choice (auto-pick MICRO or PATCH — see Step 4) -- CHANGELOG content (auto-generate from diff) -- Commit message approval (auto-commit) -- Multi-file changesets (auto-split into bisectable commits) - ---- - -## Step 1: Pre-flight - -1. Check the current branch. If on `main`, **abort**: "You're on main. Ship from a feature branch." - -2. Run `git status` (never use `-uall`). Uncommitted changes are always included — no need to ask. - -3. Run `git diff main...HEAD --stat` and `git log main..HEAD --oneline` to understand what's being shipped. - ---- - -## Step 2: Merge origin/main (BEFORE tests) - -Fetch and merge `origin/main` into the feature branch so tests run against the merged state: - -```bash -git fetch origin main && git merge origin/main --no-edit -``` - -**If there are merge conflicts:** Try to auto-resolve if they are simple (VERSION, schema.rb, CHANGELOG ordering). If conflicts are complex or ambiguous, **STOP** and show them. - -**If already up to date:** Continue silently. - ---- - -## Step 3: Run tests (on merged code) - -**Do NOT run `RAILS_ENV=test bin/rails db:migrate`** — `bin/test-lane` already calls -`db:test:prepare` internally, which loads the schema into the correct lane database. -Running bare test migrations without INSTANCE hits an orphan DB and corrupts structure.sql. - -Run both test suites in parallel: - -```bash -bin/test-lane 2>&1 | tee /tmp/ship_tests.txt & -npm run test 2>&1 | tee /tmp/ship_vitest.txt & -wait -``` - -After both complete, read the output files and check pass/fail. - -**If any test fails:** Show the failures and **STOP**. Do not proceed. - -**If all pass:** Continue silently — just note the counts briefly. - ---- - -## Step 3.25: Eval Suites (conditional) - -Evals are mandatory when prompt-related files change. Skip this step entirely if no prompt files are in the diff. - -**1. Check if the diff touches prompt-related files:** - -```bash -git diff origin/main --name-only -``` - -Match against these patterns (from CLAUDE.md): -- `app/services/*_prompt_builder.rb` -- `app/services/*_generation_service.rb`, `*_writer_service.rb`, `*_designer_service.rb` -- `app/services/*_evaluator.rb`, `*_scorer.rb`, `*_classifier_service.rb`, `*_analyzer.rb` -- `app/services/concerns/*voice*.rb`, `*writing*.rb`, `*prompt*.rb`, `*token*.rb` -- `app/services/chat_tools/*.rb`, `app/services/x_thread_tools/*.rb` -- `config/system_prompts/*.txt` -- `test/evals/**/*` (eval infrastructure changes affect all suites) - -**If no matches:** Print "No prompt-related files changed — skipping evals." and continue to Step 3.5. - -**2. Identify affected eval suites:** - -Each eval runner (`test/evals/*_eval_runner.rb`) declares `PROMPT_SOURCE_FILES` listing which source files affect it. Grep these to find which suites match the changed files: - -```bash -grep -l "changed_file_basename" test/evals/*_eval_runner.rb -``` - -Map runner → test file: `post_generation_eval_runner.rb` → `post_generation_eval_test.rb`. - -**Special cases:** -- Changes to `test/evals/judges/*.rb`, `test/evals/support/*.rb`, or `test/evals/fixtures/` affect ALL suites that use those judges/support files. Check imports in the eval test files to determine which. -- Changes to `config/system_prompts/*.txt` — grep eval runners for the prompt filename to find affected suites. -- If unsure which suites are affected, run ALL suites that could plausibly be impacted. Over-testing is better than missing a regression. - -**3. Run affected suites at `EVAL_JUDGE_TIER=full`:** - -`/ship` is a pre-merge gate, so always use full tier (Sonnet structural + Opus persona judges). - -```bash -EVAL_JUDGE_TIER=full EVAL_VERBOSE=1 bin/test-lane --eval test/evals/_eval_test.rb 2>&1 | tee /tmp/ship_evals.txt -``` - -If multiple suites need to run, run them sequentially (each needs a test lane). If the first suite fails, stop immediately — don't burn API cost on remaining suites. - -**4. Check results:** - -- **If any eval fails:** Show the failures, the cost dashboard, and **STOP**. Do not proceed. -- **If all pass:** Note pass counts and cost. Continue to Step 3.5. - -**5. Save eval output** — include eval results and cost dashboard in the PR body (Step 8). - -**Tier reference (for context — /ship always uses `full`):** -| Tier | When | Speed (cached) | Cost | -|------|------|----------------|------| -| `fast` (Haiku) | Dev iteration, smoke tests | ~5s (14x faster) | ~$0.07/run | -| `standard` (Sonnet) | Default dev, `bin/test-lane --eval` | ~17s (4x faster) | ~$0.37/run | -| `full` (Opus persona) | **`/ship` and pre-merge** | ~72s (baseline) | ~$1.27/run | - ---- - -## Step 3.5: Pre-Landing Review - -Review the diff for structural issues that tests don't catch. - -1. Read `.claude/skills/review/checklist.md`. If the file cannot be read, **STOP** and report the error. - -2. Run `git diff origin/main` to get the full diff (scoped to feature changes against the freshly-fetched remote main). - -3. Apply the review checklist in two passes: - - **Pass 1 (CRITICAL):** SQL & Data Safety, LLM Output Trust Boundary - - **Pass 2 (INFORMATIONAL):** All remaining categories - -4. **Always output ALL findings** — both critical and informational. The user must see every issue found. - -5. Output a summary header: `Pre-Landing Review: N issues (X critical, Y informational)` - -6. **If CRITICAL issues found:** For EACH critical issue, use a separate AskUserQuestion with: - - The problem (`file:line` + description) - - Your recommended fix - - Options: A) Fix it now (recommend), B) Acknowledge and ship anyway, C) It's a false positive — skip - After resolving all critical issues: if the user chose A (fix) on any issue, apply the recommended fixes, then commit only the fixed files by name (`git add && git commit -m "fix: apply pre-landing review fixes"`), then **STOP** and tell the user to run `/ship` again to re-test with the fixes applied. If the user chose only B (acknowledge) or C (false positive) on all issues, continue with Step 4. - -7. **If only non-critical issues found:** Output them and continue. They will be included in the PR body at Step 8. - -8. **If no issues found:** Output `Pre-Landing Review: No issues found.` and continue. - -Save the review output — it goes into the PR body in Step 8. - ---- - -## Step 3.75: Address Greptile review comments (if PR exists) - -Read `.claude/skills/review/greptile-triage.md` and follow the fetch, filter, and classify steps. - -**If no PR exists, `gh` fails, API returns an error, or there are zero Greptile comments:** Skip this step silently. Continue to Step 4. - -**If Greptile comments are found:** - -Include a Greptile summary in your output: `+ N Greptile comments (X valid, Y fixed, Z FP)` - -For each classified comment: - -**VALID & ACTIONABLE:** Use AskUserQuestion with: -- The comment (file:line or [top-level] + body summary + permalink URL) -- Your recommended fix -- Options: A) Fix now (recommended), B) Acknowledge and ship anyway, C) It's a false positive -- If user chooses A: apply the fix, commit the fixed files (`git add && git commit -m "fix: address Greptile review — "`), reply to the comment (`"Fixed in ."`), and save to `~/.gstack/greptile-history.md` (type: fix). -- If user chooses C: reply explaining the false positive, save to history (type: fp). - -**VALID BUT ALREADY FIXED:** Reply acknowledging the catch — no AskUserQuestion needed: -- Post reply: `"Good catch — already fixed in ."` -- Save to `~/.gstack/greptile-history.md` (type: already-fixed) - -**FALSE POSITIVE:** Use AskUserQuestion: -- Show the comment and why you think it's wrong (file:line or [top-level] + body summary + permalink URL) -- Options: - - A) Reply to Greptile explaining the false positive (recommended if clearly wrong) - - B) Fix it anyway (if trivial) - - C) Ignore silently -- If user chooses A: post reply using the appropriate API from the triage doc, save to history (type: fp) - -**SUPPRESSED:** Skip silently — these are known false positives from previous triage. - -**After all comments are resolved:** If any fixes were applied, the tests from Step 3 are now stale. **Re-run tests** (Step 3) before continuing to Step 4. If no fixes were applied, continue to Step 4. - ---- - -## Step 4: Version bump (auto-decide) - -1. Read the current `VERSION` file (4-digit format: `MAJOR.MINOR.PATCH.MICRO`) - -2. **Auto-decide the bump level based on the diff:** - - Count lines changed (`git diff origin/main...HEAD --stat | tail -1`) - - **MICRO** (4th digit): < 50 lines changed, trivial tweaks, typos, config - - **PATCH** (3rd digit): 50+ lines changed, bug fixes, small-medium features - - **MINOR** (2nd digit): **ASK the user** — only for major features or significant architectural changes - - **MAJOR** (1st digit): **ASK the user** — only for milestones or breaking changes - -3. Compute the new version: - - Bumping a digit resets all digits to its right to 0 - - Example: `0.19.1.0` + PATCH → `0.19.2.0` - -4. Write the new version to the `VERSION` file. - ---- - -## Step 5: CHANGELOG (auto-generate) - -1. Read `CHANGELOG.md` header to know the format. - -2. Auto-generate the entry from **ALL commits on the branch** (not just recent ones): - - Use `git log main..HEAD --oneline` to see every commit being shipped - - Use `git diff main...HEAD` to see the full diff against main - - The CHANGELOG entry must be comprehensive of ALL changes going into the PR - - If existing CHANGELOG entries on the branch already cover some commits, replace them with one unified entry for the new version - - Categorize changes into applicable sections: - - `### Added` — new features - - `### Changed` — changes to existing functionality - - `### Fixed` — bug fixes - - `### Removed` — removed features - - Write concise, descriptive bullet points - - Insert after the file header (line 5), dated today - - Format: `## [X.Y.Z.W] - YYYY-MM-DD` - -**Do NOT ask the user to describe changes.** Infer from the diff and commit history. - ---- - -## Step 6: Commit (bisectable chunks) - -**Goal:** Create small, logical commits that work well with `git bisect` and help LLMs understand what changed. - -1. Analyze the diff and group changes into logical commits. Each commit should represent **one coherent change** — not one file, but one logical unit. - -2. **Commit ordering** (earlier commits first): - - **Infrastructure:** migrations, config changes, route additions - - **Models & services:** new models, services, concerns (with their tests) - - **Controllers & views:** controllers, views, JS/React components (with their tests) - - **VERSION + CHANGELOG:** always in the final commit - -3. **Rules for splitting:** - - A model and its test file go in the same commit - - A service and its test file go in the same commit - - A controller, its views, and its test go in the same commit - - Migrations are their own commit (or grouped with the model they support) - - Config/route changes can group with the feature they enable - - If the total diff is small (< 50 lines across < 4 files), a single commit is fine - -4. **Each commit must be independently valid** — no broken imports, no references to code that doesn't exist yet. Order commits so dependencies come first. - -5. Compose each commit message: - - First line: `: ` (type = feat/fix/chore/refactor/docs) - - Body: brief description of what this commit contains - - Only the **final commit** (VERSION + CHANGELOG) gets the version tag and co-author trailer: - -```bash -git commit -m "$(cat <<'EOF' -chore: bump version and changelog (vX.Y.Z.W) - -Co-Authored-By: Claude Opus 4.6 -EOF -)" -``` - ---- - -## Step 7: Push - -Push to the remote with upstream tracking: - -```bash -git push -u origin -``` - ---- - -## Step 8: Create PR - -Create a pull request using `gh`: - -```bash -gh pr create --title ": " --body "$(cat <<'EOF' -## Summary - - -## Pre-Landing Review - - -## Eval Results - - -## Greptile Review - - - - -## Test plan -- [x] All Rails tests pass (N runs, 0 failures) -- [x] All Vitest tests pass (N tests) - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - -**Output the PR URL** — this should be the final output the user sees. - ---- - -## Important Rules - -- **Never skip tests.** If tests fail, stop. -- **Never skip the pre-landing review.** If checklist.md is unreadable, stop. -- **Never force push.** Use regular `git push` only. -- **Never ask for confirmation** except for MINOR/MAJOR version bumps and CRITICAL review findings (one AskUserQuestion per critical issue with fix recommendation). -- **Always use the 4-digit version format** from the VERSION file. -- **Date format in CHANGELOG:** `YYYY-MM-DD` -- **Split commits for bisectability** — each commit = one logical change. -- **The goal is: user says `/ship`, next thing they see is the review + PR URL.** From d27d78d294013b7b5b38d76f9db3ac81263672ad Mon Sep 17 00:00:00 2001 From: "dash / @da5ch0 / aliases" Date: Fri, 13 Mar 2026 20:03:00 -0600 Subject: [PATCH 13/20] Delete setup-browser-cookies directory fixed errors in skill --- setup-browser-cookies/SKILL.md | 87 ---------------------------------- 1 file changed, 87 deletions(-) delete mode 100644 setup-browser-cookies/SKILL.md diff --git a/setup-browser-cookies/SKILL.md b/setup-browser-cookies/SKILL.md deleted file mode 100644 index cc1d143..0000000 --- a/setup-browser-cookies/SKILL.md +++ /dev/null @@ -1,87 +0,0 @@ ---- -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 -BROWSE_OUTPUT=$(browse/bin/find-browse 2>/dev/null || ~/.claude/skills/gstack/browse/bin/find-browse 2>/dev/null) -B=$(echo "$BROWSE_OUTPUT" | head -1) -META=$(echo "$BROWSE_OUTPUT" | grep "^META:" || true) -if [ -n "$B" ]; then - echo "READY: $B" - [ -n "$META" ] && echo "$META" -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` - -If you see `META:UPDATE_AVAILABLE`: tell the user an update is available, STOP and wait for approval, then run the command from the META payload and re-run the setup check. - -### 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 From 73692d8a5a193c06ebc2cf5d1a3d9a902e80042c Mon Sep 17 00:00:00 2001 From: "dash / @da5ch0 / aliases" Date: Fri, 13 Mar 2026 20:03:19 -0600 Subject: [PATCH 14/20] Delete review directory fixed backlog of issues in review process --- review/SKILL.md | 112 ---------------------------------- review/checklist.md | 125 -------------------------------------- review/greptile-triage.md | 122 ------------------------------------- 3 files changed, 359 deletions(-) delete mode 100644 review/SKILL.md delete mode 100644 review/checklist.md delete mode 100644 review/greptile-triage.md diff --git a/review/SKILL.md b/review/SKILL.md deleted file mode 100644 index 35075d1..0000000 --- a/review/SKILL.md +++ /dev/null @@ -1,112 +0,0 @@ ---- -name: review -version: 1.0.0 -description: | - Pre-landing PR review. Analyzes diff against main for SQL safety, LLM trust - boundary violations, conditional side effects, and other structural issues. -allowed-tools: - - Bash - - Read - - Edit - - Write - - Grep - - Glob - - AskUserQuestion ---- - -# Pre-Landing PR Review - -You are running the `/review` workflow. Analyze the current branch's diff against main for structural issues that tests don't catch. - ---- - -## Step 1: Check branch - -1. Run `git branch --show-current` to get the current branch. -2. If on `main`, output: **"Nothing to review — you're on main or have no changes against main."** and stop. -3. Run `git fetch origin main --quiet && git diff origin/main --stat` to check if there's a diff. If no diff, output the same message and stop. - ---- - -## Step 2: Read the checklist - -Read `.claude/skills/review/checklist.md`. - -**If the file cannot be read, STOP and report the error.** Do not proceed without the checklist. - ---- - -## Step 2.5: Check for Greptile review comments - -Read `.claude/skills/review/greptile-triage.md` and follow the fetch, filter, and classify steps. - -**If no PR exists, `gh` fails, API returns an error, or there are zero Greptile comments:** Skip this step silently. Greptile integration is additive — the review works without it. - -**If Greptile comments are found:** Store the classifications (VALID & ACTIONABLE, VALID BUT ALREADY FIXED, FALSE POSITIVE, SUPPRESSED) — you will need them in Step 5. - ---- - -## Step 3: Get the diff - -Fetch the latest main to avoid false positives from a stale local main: - -```bash -git fetch origin main --quiet -``` - -Run `git diff origin/main` to get the full diff. This includes both committed and uncommitted changes against the latest main. - ---- - -## Step 4: Two-pass review - -Apply the checklist against the diff in two passes: - -1. **Pass 1 (CRITICAL):** SQL & Data Safety, LLM Output Trust Boundary -2. **Pass 2 (INFORMATIONAL):** Conditional Side Effects, Magic Numbers & String Coupling, Dead Code & Consistency, LLM Prompt Issues, Test Gaps, View/Frontend - -Follow the output format specified in the checklist. Respect the suppressions — do NOT flag items listed in the "DO NOT flag" section. - ---- - -## Step 5: Output findings - -**Always output ALL findings** — both critical and informational. The user must see every issue. - -- If CRITICAL issues found: output all findings, then for EACH critical issue use a separate AskUserQuestion with the problem, your recommended fix, and options (A: Fix it now, B: Acknowledge, C: False positive — skip). - After all critical questions are answered, output a summary of what the user chose for each issue. If the user chose A (fix) on any issue, apply the recommended fixes. If only B/C were chosen, no action needed. -- If only non-critical issues found: output findings. No further action needed. -- If no issues found: output `Pre-Landing Review: No issues found.` - -### Greptile comment resolution - -After outputting your own findings, if Greptile comments were classified in Step 2.5: - -**Include a Greptile summary in your output header:** `+ N Greptile comments (X valid, Y fixed, Z FP)` - -1. **VALID & ACTIONABLE comments:** These are already included in your CRITICAL findings — they follow the same AskUserQuestion flow (A: Fix it now, B: Acknowledge, C: False positive). If the user chooses C (false positive), post a reply using the appropriate API from the triage doc and save the pattern to `~/.gstack/greptile-history.md` (type: fp). - -2. **FALSE POSITIVE comments:** Present each one via AskUserQuestion: - - Show the Greptile comment: file:line (or [top-level]) + body summary + permalink URL - - Explain concisely why it's a false positive - - Options: - - A) Reply to Greptile explaining why this is incorrect (recommended if clearly wrong) - - B) Fix it anyway (if low-effort and harmless) - - C) Ignore — don't reply, don't fix - - If the user chooses A, post a reply using the appropriate API from the triage doc and save the pattern to `~/.gstack/greptile-history.md` (type: fp). - -3. **VALID BUT ALREADY FIXED comments:** Reply acknowledging the catch — no AskUserQuestion needed: - - Post reply: `"Good catch — already fixed in ."` - - Save to `~/.gstack/greptile-history.md` (type: already-fixed) - -4. **SUPPRESSED comments:** Skip silently — these are known false positives from previous triage. - ---- - -## Important Rules - -- **Read the FULL diff before commenting.** Do not flag issues already addressed in the diff. -- **Read-only by default.** Only modify files if the user explicitly chooses "Fix it now" on a critical issue. Never commit, push, or create PRs. -- **Be terse.** One line problem, one line fix. No preamble. -- **Only flag real problems.** Skip anything that's fine. diff --git a/review/checklist.md b/review/checklist.md deleted file mode 100644 index e321890..0000000 --- a/review/checklist.md +++ /dev/null @@ -1,125 +0,0 @@ -# Pre-Landing Review Checklist - -## Instructions - -Review the `git diff origin/main` output for the issues listed below. Be specific — cite `file:line` and suggest fixes. Skip anything that's fine. Only flag real problems. - -**Two-pass review:** -- **Pass 1 (CRITICAL):** Run SQL & Data Safety and LLM Output Trust Boundary first. These can block `/ship`. -- **Pass 2 (INFORMATIONAL):** Run all remaining categories. These are included in the PR body but do not block. - -**Output format:** - -``` -Pre-Landing Review: N issues (X critical, Y informational) - -**CRITICAL** (blocking /ship): -- [file:line] Problem description - Fix: suggested fix - -**Issues** (non-blocking): -- [file:line] Problem description - Fix: suggested fix -``` - -If no issues found: `Pre-Landing Review: No issues found.` - -Be terse. For each issue: one line describing the problem, one line with the fix. No preamble, no summaries, no "looks good overall." - ---- - -## Review Categories - -### Pass 1 — CRITICAL - -#### SQL & Data Safety -- String interpolation in SQL (even if values are `.to_i`/`.to_f` — use `sanitize_sql_array` or Arel) -- TOCTOU races: check-then-set patterns that should be atomic `WHERE` + `update_all` -- `update_column`/`update_columns` bypassing validations on fields that have or should have constraints -- N+1 queries: `.includes()` missing for associations used in loops/views (especially avatar, attachments) - -#### Race Conditions & Concurrency -- Read-check-write without uniqueness constraint or `rescue RecordNotUnique; retry` (e.g., `where(hash:).first` then `save!` without handling concurrent insert) -- `find_or_create_by` on columns without unique DB index — concurrent calls can create duplicates -- Status transitions that don't use atomic `WHERE old_status = ? UPDATE SET new_status` — concurrent updates can skip or double-apply transitions -- `html_safe` on user-controlled data (XSS) — check any `.html_safe`, `raw()`, or string interpolation into `html_safe` output - -#### LLM Output Trust Boundary -- LLM-generated values (emails, URLs, names) written to DB or passed to mailers without format validation. Add lightweight guards (`EMAIL_REGEXP`, `URI.parse`, `.strip`) before persisting. -- Structured tool output (arrays, hashes) accepted without type/shape checks before database writes. - -### Pass 2 — INFORMATIONAL - -#### Conditional Side Effects -- Code paths that branch on a condition but forget to apply a side effect on one branch. Example: item promoted to verified but URL only attached when a secondary condition is true — the other branch promotes without the URL, creating an inconsistent record. -- Log messages that claim an action happened but the action was conditionally skipped. The log should reflect what actually occurred. - -#### Magic Numbers & String Coupling -- Bare numeric literals used in multiple files — should be named constants documented together -- Error message strings used as query filters elsewhere (grep for the string — is anything matching on it?) - -#### Dead Code & Consistency -- Variables assigned but never read -- Version mismatch between PR title and VERSION/CHANGELOG files -- CHANGELOG entries that describe changes inaccurately (e.g., "changed from X to Y" when X never existed) -- Comments/docstrings that describe old behavior after the code changed - -#### LLM Prompt Issues -- 0-indexed lists in prompts (LLMs reliably return 1-indexed) -- Prompt text listing available tools/capabilities that don't match what's actually wired up in the `tool_classes`/`tools` array -- Word/token limits stated in multiple places that could drift - -#### Test Gaps -- Negative-path tests that assert type/status but not the side effects (URL attached? field populated? callback fired?) -- Assertions on string content without checking format (e.g., asserting title present but not URL format) -- `.expects(:something).never` missing when a code path should explicitly NOT call an external service -- Security enforcement features (blocking, rate limiting, auth) without integration tests verifying the enforcement path works end-to-end - -#### Crypto & Entropy -- Truncation of data instead of hashing (last N chars instead of SHA-256) — less entropy, easier collisions -- `rand()` / `Random.rand` for security-sensitive values — use `SecureRandom` instead -- Non-constant-time comparisons (`==`) on secrets or tokens — vulnerable to timing attacks - -#### Time Window Safety -- Date-key lookups that assume "today" covers 24h — report at 8am PT only sees midnight→8am under today's key -- Mismatched time windows between related features — one uses hourly buckets, another uses daily keys for the same data - -#### Type Coercion at Boundaries -- Values crossing Ruby→JSON→JS boundaries where type could change (numeric vs string) — hash/digest inputs must normalize types -- Hash/digest inputs that don't call `.to_s` or equivalent before serialization — `{ cores: 8 }` vs `{ cores: "8" }` produce different hashes - -#### View/Frontend -- Inline ` - - - -
-

Cookie Import

- localhost:${serverPort} -
- - - -
- -
-
Source Browser
-
-
- -
-
-
Detecting browsers...
-
- -
- - -
-
Imported to Session
-
-
No cookies imported yet
-
- -
-
- - - -`; -} diff --git a/browse/src/find-browse.ts b/browse/src/find-browse.ts deleted file mode 100644 index 38b987a..0000000 --- a/browse/src/find-browse.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * find-browse — locate the gstack browse binary + check for updates. - * - * Compiled to browse/dist/find-browse (standalone binary, no bun runtime needed). - * - * Output protocol: - * Line 1: /path/to/binary (always present) - * Line 2+: META: (optional, 0 or more) - * - * META types: - * META:UPDATE_AVAILABLE — local binary is behind origin/main - * - * All version checks are best-effort: network failures, missing files, and - * cache errors degrade gracefully to outputting only the binary path. - */ - -import { existsSync } from 'fs'; -import { readFileSync, writeFileSync } from 'fs'; -import { join, dirname } from 'path'; -import { homedir } from 'os'; - -const REPO_URL = 'https://github.com/garrytan/gstack.git'; -const CACHE_PATH = '/tmp/gstack-latest-version'; -const CACHE_TTL = 14400; // 4 hours in seconds - -// ─── Binary Discovery ─────────────────────────────────────────── - -function getGitRoot(): string | null { - try { - const proc = Bun.spawnSync(['git', 'rev-parse', '--show-toplevel'], { - stdout: 'pipe', - stderr: 'pipe', - }); - if (proc.exitCode !== 0) return null; - return proc.stdout.toString().trim(); - } catch { - return null; - } -} - -export function locateBinary(): string | null { - const root = getGitRoot(); - const home = homedir(); - - // Workspace-local takes priority (for development) - if (root) { - const local = join(root, '.claude', 'skills', 'gstack', 'browse', 'dist', 'browse'); - if (existsSync(local)) return local; - } - - // Global fallback - const global = join(home, '.claude', 'skills', 'gstack', 'browse', 'dist', 'browse'); - if (existsSync(global)) return global; - - return null; -} - -// ─── Version Check ────────────────────────────────────────────── - -interface CacheEntry { - sha: string; - timestamp: number; -} - -function readCache(): CacheEntry | null { - try { - const content = readFileSync(CACHE_PATH, 'utf-8').trim(); - const parts = content.split(/\s+/); - if (parts.length < 2) return null; - const sha = parts[0]; - const timestamp = parseInt(parts[1], 10); - if (!sha || isNaN(timestamp)) return null; - // Validate SHA is hex - if (!/^[0-9a-f]{40}$/i.test(sha)) return null; - return { sha, timestamp }; - } catch { - return null; - } -} - -function writeCache(sha: string, timestamp: number): void { - try { - writeFileSync(CACHE_PATH, `${sha} ${timestamp}\n`); - } catch { - // Cache write failure is non-fatal - } -} - -function fetchRemoteSHA(): string | null { - try { - const proc = Bun.spawnSync(['git', 'ls-remote', REPO_URL, 'refs/heads/main'], { - stdout: 'pipe', - stderr: 'pipe', - timeout: 10_000, // 10s timeout - }); - if (proc.exitCode !== 0) return null; - const output = proc.stdout.toString().trim(); - const sha = output.split(/\s+/)[0]; - if (!sha || !/^[0-9a-f]{40}$/i.test(sha)) return null; - return sha; - } catch { - return null; - } -} - -function resolveSkillDir(binaryPath: string): string | null { - const home = homedir(); - const globalPrefix = join(home, '.claude', 'skills', 'gstack'); - if (binaryPath.startsWith(globalPrefix)) return globalPrefix; - - // Workspace-local: binary is at $ROOT/.claude/skills/gstack/browse/dist/browse - // Skill dir is $ROOT/.claude/skills/gstack - const parts = binaryPath.split('/.claude/skills/gstack/'); - if (parts.length === 2) return parts[0] + '/.claude/skills/gstack'; - - return null; -} - -export function checkVersion(binaryDir: string): string | null { - // Read local version - const versionFile = join(binaryDir, '.version'); - let localSHA: string; - try { - localSHA = readFileSync(versionFile, 'utf-8').trim(); - } catch { - return null; // No .version file → skip check - } - if (!localSHA) return null; - - const now = Math.floor(Date.now() / 1000); - - // Check cache - let remoteSHA: string | null = null; - const cache = readCache(); - if (cache && (now - cache.timestamp) < CACHE_TTL) { - remoteSHA = cache.sha; - } - - // Fetch from remote if cache miss - if (!remoteSHA) { - remoteSHA = fetchRemoteSHA(); - if (remoteSHA) { - writeCache(remoteSHA, now); - } - } - - if (!remoteSHA) return null; // Offline or error → skip check - - // Compare - if (localSHA === remoteSHA) return null; // Up to date - - // Determine skill directory for update command - const binaryPath = join(binaryDir, 'browse'); - const skillDir = resolveSkillDir(binaryPath); - if (!skillDir) return null; - - const payload = JSON.stringify({ - current: localSHA.slice(0, 8), - latest: remoteSHA.slice(0, 8), - command: `cd ${skillDir} && git stash && git fetch origin && git reset --hard origin/main && ./setup`, - }); - - return `META:UPDATE_AVAILABLE ${payload}`; -} - -// ─── Main ─────────────────────────────────────────────────────── - -function main() { - const bin = locateBinary(); - if (!bin) { - process.stderr.write('ERROR: browse binary not found. Run: cd && ./setup\n'); - process.exit(1); - } - - console.log(bin); - - const meta = checkVersion(dirname(bin)); - if (meta) console.log(meta); -} - -main(); diff --git a/browse/src/meta-commands.ts b/browse/src/meta-commands.ts deleted file mode 100644 index 8d3f9eb..0000000 --- a/browse/src/meta-commands.ts +++ /dev/null @@ -1,221 +0,0 @@ -/** - * Meta commands — tabs, server control, screenshots, chain, diff, snapshot - */ - -import type { BrowserManager } from './browser-manager'; -import { handleSnapshot } from './snapshot'; -import { getCleanText } from './read-commands'; -import * as Diff from 'diff'; -import * as fs from 'fs'; -import * as path from 'path'; - -// Security: Path validation to prevent path traversal attacks -const SAFE_DIRECTORIES = ['/tmp', process.cwd()]; - -function validateOutputPath(filePath: string): void { - const resolved = path.resolve(filePath); - const isSafe = SAFE_DIRECTORIES.some(dir => resolved === dir || resolved.startsWith(dir + '/')); - if (!isSafe) { - throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`); - } -} - -// Command sets for chain routing (mirrors server.ts — kept local to avoid circular import) -const CHAIN_READ = new Set([ - 'text', 'html', 'links', 'forms', 'accessibility', - 'js', 'eval', 'css', 'attrs', - 'console', 'network', 'cookies', 'storage', 'perf', - 'dialog', 'is', -]); -const CHAIN_WRITE = new Set([ - 'goto', 'back', 'forward', 'reload', - 'click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait', - 'viewport', 'cookie', 'cookie-import', 'header', 'useragent', - 'upload', 'dialog-accept', 'dialog-dismiss', - 'cookie-import-browser', -]); -const CHAIN_META = new Set([ - 'tabs', 'tab', 'newtab', 'closetab', - 'status', 'stop', 'restart', - 'screenshot', 'pdf', 'responsive', - 'chain', 'diff', - 'url', 'snapshot', -]); - -export async function handleMetaCommand( - command: string, - args: string[], - bm: BrowserManager, - shutdown: () => Promise | void -): Promise { - switch (command) { - // ─── Tabs ────────────────────────────────────────── - case 'tabs': { - const tabs = await bm.getTabListWithTitles(); - return tabs.map(t => - `${t.active ? '→ ' : ' '}[${t.id}] ${t.title || '(untitled)'} — ${t.url}` - ).join('\n'); - } - - case 'tab': { - const id = parseInt(args[0], 10); - if (isNaN(id)) throw new Error('Usage: browse tab '); - bm.switchTab(id); - return `Switched to tab ${id}`; - } - - case 'newtab': { - const url = args[0]; - const id = await bm.newTab(url); - return `Opened tab ${id}${url ? ` → ${url}` : ''}`; - } - - case 'closetab': { - const id = args[0] ? parseInt(args[0], 10) : undefined; - await bm.closeTab(id); - return `Closed tab${id ? ` ${id}` : ''}`; - } - - // ─── Server Control ──────────────────────────────── - case 'status': { - const page = bm.getPage(); - const tabs = bm.getTabCount(); - return [ - `Status: healthy`, - `URL: ${page.url()}`, - `Tabs: ${tabs}`, - `PID: ${process.pid}`, - ].join('\n'); - } - - case 'url': { - return bm.getCurrentUrl(); - } - - case 'stop': { - await shutdown(); - return 'Server stopped'; - } - - case 'restart': { - // Signal that we want a restart — the CLI will detect exit and restart - console.log('[browse] Restart requested. Exiting for CLI to restart.'); - await shutdown(); - return 'Restarting...'; - } - - // ─── Visual ──────────────────────────────────────── - case 'screenshot': { - const page = bm.getPage(); - const screenshotPath = args[0] || '/tmp/browse-screenshot.png'; - validateOutputPath(screenshotPath); - await page.screenshot({ path: screenshotPath, fullPage: true }); - return `Screenshot saved: ${screenshotPath}`; - } - - case 'pdf': { - const page = bm.getPage(); - const pdfPath = args[0] || '/tmp/browse-page.pdf'; - validateOutputPath(pdfPath); - await page.pdf({ path: pdfPath, format: 'A4' }); - return `PDF saved: ${pdfPath}`; - } - - case 'responsive': { - const page = bm.getPage(); - const prefix = args[0] || '/tmp/browse-responsive'; - validateOutputPath(prefix); - const viewports = [ - { name: 'mobile', width: 375, height: 812 }, - { name: 'tablet', width: 768, height: 1024 }, - { name: 'desktop', width: 1280, height: 720 }, - ]; - const originalViewport = page.viewportSize(); - const results: string[] = []; - - for (const vp of viewports) { - await page.setViewportSize({ width: vp.width, height: vp.height }); - const path = `${prefix}-${vp.name}.png`; - await page.screenshot({ path, fullPage: true }); - results.push(`${vp.name} (${vp.width}x${vp.height}): ${path}`); - } - - // Restore original viewport - if (originalViewport) { - await page.setViewportSize(originalViewport); - } - - return results.join('\n'); - } - - // ─── Chain ───────────────────────────────────────── - case 'chain': { - // Read JSON array from args[0] (if provided) or expect it was passed as body - const jsonStr = args[0]; - if (!jsonStr) throw new Error('Usage: echo \'[["goto","url"],["text"]]\' | browse chain'); - - let commands: string[][]; - try { - commands = JSON.parse(jsonStr); - } catch { - throw new Error('Invalid JSON. Expected: [["command", "arg1", "arg2"], ...]'); - } - - if (!Array.isArray(commands)) throw new Error('Expected JSON array of commands'); - - const results: string[] = []; - const { handleReadCommand } = await import('./read-commands'); - const { handleWriteCommand } = await import('./write-commands'); - - for (const cmd of commands) { - const [name, ...cmdArgs] = cmd; - try { - let result: string; - if (CHAIN_WRITE.has(name)) result = await handleWriteCommand(name, cmdArgs, bm); - else if (CHAIN_READ.has(name)) result = await handleReadCommand(name, cmdArgs, bm); - else if (CHAIN_META.has(name)) result = await handleMetaCommand(name, cmdArgs, bm, shutdown); - else throw new Error(`Unknown command: ${name}`); - results.push(`[${name}] ${result}`); - } catch (err: any) { - results.push(`[${name}] ERROR: ${err.message}`); - } - } - - return results.join('\n\n'); - } - - // ─── Diff ────────────────────────────────────────── - case 'diff': { - const [url1, url2] = args; - if (!url1 || !url2) throw new Error('Usage: browse diff '); - - const page = bm.getPage(); - await page.goto(url1, { waitUntil: 'domcontentloaded', timeout: 15000 }); - const text1 = await getCleanText(page); - - await page.goto(url2, { waitUntil: 'domcontentloaded', timeout: 15000 }); - const text2 = await getCleanText(page); - - const changes = Diff.diffLines(text1, text2); - const output: string[] = [`--- ${url1}`, `+++ ${url2}`, '']; - - for (const part of changes) { - const prefix = part.added ? '+' : part.removed ? '-' : ' '; - const lines = part.value.split('\n').filter(l => l.length > 0); - for (const line of lines) { - output.push(`${prefix} ${line}`); - } - } - - return output.join('\n'); - } - - // ─── Snapshot ───────────────────────────────────── - case 'snapshot': { - return await handleSnapshot(args, bm); - } - - default: - throw new Error(`Unknown meta command: ${command}`); - } -} diff --git a/browse/src/read-commands.ts b/browse/src/read-commands.ts deleted file mode 100644 index 31d1018..0000000 --- a/browse/src/read-commands.ts +++ /dev/null @@ -1,294 +0,0 @@ -/** - * Read commands — extract data from pages without side effects - * - * text, html, links, forms, accessibility, js, eval, css, attrs, - * console, network, cookies, storage, perf - */ - -import type { BrowserManager } from './browser-manager'; -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, - args: string[], - bm: BrowserManager -): Promise { - const page = bm.getPage(); - - switch (command) { - case 'text': { - return await getCleanText(page); - } - - case 'html': { - const selector = args[0]; - if (selector) { - const resolved = bm.resolveRef(selector); - if ('locator' in resolved) { - return await resolved.locator.innerHTML({ timeout: 5000 }); - } - return await page.innerHTML(resolved.selector); - } - return await page.content(); - } - - case 'links': { - const links = await page.evaluate(() => - [...document.querySelectorAll('a[href]')].map(a => ({ - text: a.textContent?.trim().slice(0, 120) || '', - href: (a as HTMLAnchorElement).href, - })).filter(l => l.text && l.href) - ); - return links.map(l => `${l.text} → ${l.href}`).join('\n'); - } - - case 'forms': { - const forms = await page.evaluate(() => { - return [...document.querySelectorAll('form')].map((form, i) => { - const fields = [...form.querySelectorAll('input, select, textarea')].map(el => { - const input = el as HTMLInputElement; - return { - tag: el.tagName.toLowerCase(), - type: input.type || undefined, - name: input.name || undefined, - id: input.id || undefined, - placeholder: input.placeholder || undefined, - required: input.required || 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, - }; - }); - return { - index: i, - action: form.action || undefined, - method: form.method || 'get', - id: form.id || undefined, - fields, - }; - }); - }); - return JSON.stringify(forms, null, 2); - } - - case 'accessibility': { - const snapshot = await page.locator("body").ariaSnapshot(); - return snapshot; - } - - case 'js': { - const expr = args[0]; - if (!expr) throw new Error('Usage: browse js '); - const result = await page.evaluate(expr); - return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? ''); - } - - 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); - return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? ''); - } - - case 'css': { - const [selector, property] = args; - if (!selector || !property) throw new Error('Usage: browse css '); - const resolved = bm.resolveRef(selector); - if ('locator' in resolved) { - const value = await resolved.locator.evaluate( - (el, prop) => getComputedStyle(el).getPropertyValue(prop), - property - ); - return value; - } - const value = await page.evaluate( - ([sel, prop]) => { - const el = document.querySelector(sel); - if (!el) return `Element not found: ${sel}`; - return getComputedStyle(el).getPropertyValue(prop); - }, - [resolved.selector, property] - ); - return value; - } - - case 'attrs': { - const selector = args[0]; - if (!selector) throw new Error('Usage: browse attrs '); - const resolved = bm.resolveRef(selector); - if ('locator' in resolved) { - const attrs = await resolved.locator.evaluate((el) => { - const result: Record = {}; - for (const attr of el.attributes) { - result[attr.name] = attr.value; - } - return result; - }); - return JSON.stringify(attrs, null, 2); - } - const attrs = await page.evaluate((sel) => { - const el = document.querySelector(sel); - if (!el) return `Element not found: ${sel}`; - const result: Record = {}; - for (const attr of el.attributes) { - result[attr.name] = attr.value; - } - return result; - }, resolved.selector); - return typeof attrs === 'string' ? attrs : JSON.stringify(attrs, null, 2); - } - - case 'console': { - if (args[0] === '--clear') { - consoleBuffer.clear(); - return 'Console buffer cleared.'; - } - 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.clear(); - return 'Network buffer cleared.'; - } - if (networkBuffer.length === 0) return '(no network requests)'; - 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); - } - - case 'storage': { - if (args[0] === 'set' && args[1]) { - const key = args[1]; - const value = args[2] || ''; - await page.evaluate(([k, v]) => localStorage.setItem(k, v), [key, value]); - return `Set localStorage["${key}"]`; - } - const storage = await page.evaluate(() => ({ - localStorage: { ...localStorage }, - sessionStorage: { ...sessionStorage }, - })); - return JSON.stringify(storage, null, 2); - } - - case 'perf': { - const timings = await page.evaluate(() => { - const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming; - if (!nav) return 'No navigation timing data available.'; - return { - dns: Math.round(nav.domainLookupEnd - nav.domainLookupStart), - tcp: Math.round(nav.connectEnd - nav.connectStart), - ssl: Math.round(nav.secureConnectionStart > 0 ? nav.connectEnd - nav.secureConnectionStart : 0), - ttfb: Math.round(nav.responseStart - nav.requestStart), - download: Math.round(nav.responseEnd - nav.responseStart), - domParse: Math.round(nav.domInteractive - nav.responseEnd), - domReady: Math.round(nav.domContentLoadedEventEnd - nav.startTime), - load: Math.round(nav.loadEventEnd - nav.startTime), - total: Math.round(nav.loadEventEnd - nav.startTime), - }; - }); - if (typeof timings === 'string') return timings; - return Object.entries(timings) - .map(([k, v]) => `${k.padEnd(12)} ${v}ms`) - .join('\n'); - } - - default: - throw new Error(`Unknown read command: ${command}`); - } -} diff --git a/browse/src/server.ts b/browse/src/server.ts deleted file mode 100644 index 5886813..0000000 --- a/browse/src/server.ts +++ /dev/null @@ -1,362 +0,0 @@ -/** - * gstack browse server — persistent Chromium daemon - * - * Architecture: - * Bun.serve HTTP on localhost → routes commands to Playwright - * 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) - * - * State: - * State file: /.gstack/browse.json (set via BROWSE_STATE_FILE env) - * Log files: /.gstack/browse-{console,network,dialog}.log - * Port: random 10000-60000 (or BROWSE_PORT env for debug override) - */ - -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 { resolveConfig, ensureStateDir, readVersionHash } from './config'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as crypto from 'crypto'; - -// ─── Config ───────────────────────────────────────────────────── -const config = resolveConfig(); -ensureStateDir(config); - -// ─── Auth ─────────────────────────────────────────────────────── -const AUTH_TOKEN = crypto.randomUUID(); -const BROWSE_PORT = parseInt(process.env.BROWSE_PORT || '0', 10); -const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || '1800000', 10); // 30 min - -function validateAuth(req: Request): boolean { - const header = req.headers.get('authorization'); - return header === `Bearer ${AUTH_TOKEN}`; -} - -// ─── Buffer (from buffers.ts) ──────────────────────────────────── -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 = config.consoleLog; -const NETWORK_LOG_PATH = config.networkLog; -const DIALOG_LOG_PATH = config.dialogLog; -let lastConsoleFlushed = 0; -let lastNetworkFlushed = 0; -let lastDialogFlushed = 0; -let flushInProgress = false; - -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; - } - - // 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; - } -} - -// Flush every 1 second -const flushInterval = setInterval(flushBuffers, 1000); - -// ─── Idle Timer ──────────────────────────────────────────────── -let lastActivity = Date.now(); - -function resetIdleTimer() { - lastActivity = Date.now(); -} - -const idleCheckInterval = setInterval(() => { - if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) { - console.log(`[browse] Idle for ${IDLE_TIMEOUT_MS / 1000}s, shutting down`); - shutdown(); - } -}, 60_000); - -// ─── 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', -]); - -export const WRITE_COMMANDS = new Set([ - 'goto', 'back', 'forward', 'reload', - 'click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait', - 'viewport', 'cookie', 'cookie-import', 'cookie-import-browser', 'header', 'useragent', - 'upload', 'dialog-accept', 'dialog-dismiss', -]); - -export const META_COMMANDS = new Set([ - 'tabs', 'tab', 'newtab', 'closetab', - 'status', 'stop', 'restart', - 'screenshot', 'pdf', 'responsive', - 'chain', 'diff', - 'url', 'snapshot', -]); - -// ─── Server ──────────────────────────────────────────────────── -const browserManager = new BrowserManager(); -let isShuttingDown = false; - -// Find port: explicit BROWSE_PORT, or random in 10000-60000 -async function findPort(): Promise { - // Explicit port override (for debugging) - if (BROWSE_PORT) { - try { - const testServer = Bun.serve({ port: BROWSE_PORT, fetch: () => new Response('ok') }); - testServer.stop(); - return BROWSE_PORT; - } catch { - throw new Error(`[browse] Port ${BROWSE_PORT} (from BROWSE_PORT env) is in use`); - } - } - - // Random port with retry - const MIN_PORT = 10000; - const MAX_PORT = 60000; - const MAX_RETRIES = 5; - for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { - const port = MIN_PORT + Math.floor(Math.random() * (MAX_PORT - MIN_PORT)); - try { - const testServer = Bun.serve({ port, fetch: () => new Response('ok') }); - testServer.stop(); - return port; - } catch { - continue; - } - } - throw new Error(`[browse] No available port after ${MAX_RETRIES} attempts in range ${MIN_PORT}-${MAX_PORT}`); -} - -/** - * 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; - - if (!command) { - return new Response(JSON.stringify({ error: 'Missing "command" field' }), { - status: 400, - headers: { 'Content-Type': 'application/json' }, - }); - } - - try { - let result: string; - - if (READ_COMMANDS.has(command)) { - result = await handleReadCommand(command, args, browserManager); - } else if (WRITE_COMMANDS.has(command)) { - result = await handleWriteCommand(command, args, browserManager); - } else if (META_COMMANDS.has(command)) { - result = await handleMetaCommand(command, args, browserManager, shutdown); - } else if (command === 'help') { - const helpText = [ - 'gstack browse — headless browser for AI agents', - '', - 'Commands:', - ' Navigation: goto , back, forward, reload', - ' Interaction: click , fill , select , hover, type, press, scroll, wait', - ' Read: text [sel], html [sel], links, forms, accessibility, cookies, storage, console, network, perf', - ' Evaluate: js , eval , css , attrs , is ', - ' Snapshot: snapshot [-i] [-c] [-d N] [-s sel] [-D] [-a] [-o path] [-C]', - ' Screenshot: screenshot [path], pdf [path], responsive ', - ' Tabs: tabs, tab , newtab [url], closetab [id]', - ' State: cookie , cookie-import , cookie-import-browser [browser]', - ' Headers: header [name] [value], useragent [string]', - ' Upload: upload [file2...]', - ' Dialogs: dialog, dialog-accept [text], dialog-dismiss', - ' Meta: status, stop, restart, diff, chain, help', - '', - 'Snapshot flags:', - ' -i interactive only -c compact (remove empty nodes)', - ' -d N limit depth -s sel scope to CSS selector', - ' -D diff vs previous -a annotated screenshot with ref labels', - ' -o path output file -C cursor-interactive elements', - ].join('\n'); - return new Response(helpText, { - status: 200, - headers: { 'Content-Type': 'text/plain' }, - }); - } else { - return new Response(JSON.stringify({ - error: `Unknown command: ${command}`, - hint: `Available commands: ${[...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS].sort().join(', ')}`, - }), { - status: 400, - headers: { 'Content-Type': 'application/json' }, - }); - } - - return new Response(result, { - status: 200, - headers: { 'Content-Type': 'text/plain' }, - }); - } catch (err: any) { - return new Response(JSON.stringify({ error: wrapError(err) }), { - status: 500, - headers: { 'Content-Type': 'application/json' }, - }); - } -} - -async function shutdown() { - if (isShuttingDown) return; - isShuttingDown = true; - - console.log('[browse] Shutting down...'); - clearInterval(flushInterval); - clearInterval(idleCheckInterval); - await flushBuffers(); // Final flush (async now) - - await browserManager.close(); - - // Clean up state file - try { fs.unlinkSync(config.stateFile); } catch {} - - process.exit(0); -} - -// Handle signals -process.on('SIGTERM', shutdown); -process.on('SIGINT', shutdown); - -// ─── Start ───────────────────────────────────────────────────── -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(); - - // Launch browser - await browserManager.launch(); - - const startTime = Date.now(); - const server = Bun.serve({ - port, - hostname: '127.0.0.1', - fetch: async (req) => { - resetIdleTimer(); - - const url = new URL(req.url); - - // Cookie picker routes — no auth required (localhost-only) - if (url.pathname.startsWith('/cookie-picker')) { - return handleCookiePickerRoute(url, req, browserManager); - } - - // Health check — no auth required (now async) - if (url.pathname === '/health') { - const healthy = await browserManager.isHealthy(); - return new Response(JSON.stringify({ - status: healthy ? 'healthy' : 'unhealthy', - uptime: Math.floor((Date.now() - startTime) / 1000), - tabs: browserManager.getTabCount(), - currentUrl: browserManager.getCurrentUrl(), - }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - } - - // All other endpoints require auth - if (!validateAuth(req)) { - return new Response(JSON.stringify({ error: 'Unauthorized' }), { - status: 401, - headers: { 'Content-Type': 'application/json' }, - }); - } - - if (url.pathname === '/command' && req.method === 'POST') { - const body = await req.json(); - return handleCommand(body); - } - - return new Response('Not found', { status: 404 }); - }, - }); - - // Write state file (atomic: write .tmp then rename) - const state = { - pid: process.pid, - port, - token: AUTH_TOKEN, - startedAt: new Date().toISOString(), - serverPath: path.resolve(import.meta.dir, 'server.ts'), - binaryVersion: readVersionHash() || undefined, - }; - const tmpFile = config.stateFile + '.tmp'; - fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { mode: 0o600 }); - fs.renameSync(tmpFile, config.stateFile); - - browserManager.serverPort = port; - console.log(`[browse] Server running on http://127.0.0.1:${port} (PID: ${process.pid})`); - console.log(`[browse] State file: ${config.stateFile}`); - console.log(`[browse] Idle timeout: ${IDLE_TIMEOUT_MS / 1000}s`); -} - -start().catch((err) => { - console.error(`[browse] Failed to start: ${err.message}`); - process.exit(1); -}); diff --git a/browse/src/snapshot.ts b/browse/src/snapshot.ts deleted file mode 100644 index b0c7b80..0000000 --- a/browse/src/snapshot.ts +++ /dev/null @@ -1,397 +0,0 @@ -/** - * Snapshot command — accessibility tree with ref-based element selection - * - * Architecture (Locator map — no DOM mutation): - * 1. page.locator(scope).ariaSnapshot() → YAML-like accessibility tree - * 2. Parse tree, assign refs @e1, @e2, ... - * 3. Build Playwright Locator for each ref (getByRole + nth) - * 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([ - 'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox', - 'listbox', 'menuitem', 'menuitemcheckbox', 'menuitemradio', - 'option', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab', - 'treeitem', -]); - -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 - 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 { - indent: number; - role: string; - name: string | null; - props: string; // e.g., "[level=1]" - children: string; // inline text content after ":" - rawLine: string; -} - -/** - * Parse CLI args into SnapshotOptions - */ -export function parseSnapshotArgs(args: string[]): SnapshotOptions { - const opts: SnapshotOptions = {}; - for (let i = 0; i < args.length; i++) { - switch (args[i]) { - case '-i': - case '--interactive': - opts.interactive = true; - break; - case '-c': - case '--compact': - opts.compact = true; - break; - case '-d': - case '--depth': - opts.depth = parseInt(args[++i], 10); - if (isNaN(opts.depth!)) throw new Error('Usage: snapshot -d '); - break; - case '-s': - case '--selector': - 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]}`); - } - } - return opts; -} - -/** - * Parse one line of ariaSnapshot output. - * - * Format examples: - * - heading "Test" [level=1] - * - link "Link A": - * - /url: /a - * - textbox "Name" - * - paragraph: Some text - * - combobox "Role": - */ -function parseLine(line: string): ParsedNode | null { - // Match: (indent)(- )(role)( "name")?( [props])?(: inline)? - const match = line.match(/^(\s*)-\s+(\w+)(?:\s+"([^"]*)")?(?:\s+(\[.*?\]))?\s*(?::\s*(.*))?$/); - if (!match) { - // Skip metadata lines like "- /url: /a" - return null; - } - return { - indent: match[1].length, - role: match[2], - name: match[3] ?? null, - props: match[4] || '', - children: match[5]?.trim() || '', - rawLine: line, - }; -} - -/** - * Take an accessibility snapshot and build the ref map. - */ -export async function handleSnapshot( - args: string[], - bm: BrowserManager -): Promise { - const opts = parseSnapshotArgs(args); - const page = bm.getPage(); - - // Get accessibility tree via ariaSnapshot - let rootLocator: Locator; - if (opts.selector) { - rootLocator = page.locator(opts.selector); - const count = await rootLocator.count(); - if (count === 0) throw new Error(`Selector not found: ${opts.selector}`); - } else { - rootLocator = page.locator('body'); - } - - const ariaText = await rootLocator.ariaSnapshot(); - if (!ariaText || ariaText.trim().length === 0) { - bm.setRefMap(new Map()); - return '(no accessible elements found)'; - } - - // Parse the ariaSnapshot output - const lines = ariaText.split('\n'); - const refMap = new Map(); - const output: string[] = []; - let refCounter = 1; - - // Track role+name occurrences for nth() disambiguation - const roleNameCounts = new Map(); - const roleNameSeen = new Map(); - - // First pass: count role+name pairs for disambiguation - for (const line of lines) { - const node = parseLine(line); - if (!node) continue; - const key = `${node.role}:${node.name || ''}`; - roleNameCounts.set(key, (roleNameCounts.get(key) || 0) + 1); - } - - // Second pass: assign refs and build locators - for (const line of lines) { - const node = parseLine(line); - if (!node) continue; - - const depth = Math.floor(node.indent / 2); - const isInteractive = INTERACTIVE_ROLES.has(node.role); - - // Depth filter - if (opts.depth !== undefined && depth > opts.depth) continue; - - // Interactive filter: skip non-interactive but still count for locator indices - if (opts.interactive && !isInteractive) { - // Still track for nth() counts - const key = `${node.role}:${node.name || ''}`; - roleNameSeen.set(key, (roleNameSeen.get(key) || 0) + 1); - continue; - } - - // Compact filter: skip elements with no name and no inline content that aren't interactive - if (opts.compact && !isInteractive && !node.name && !node.children) continue; - - // Assign ref - const ref = `e${refCounter++}`; - const indent = ' '.repeat(depth); - - // Build Playwright locator - const key = `${node.role}:${node.name || ''}`; - const seenIndex = roleNameSeen.get(key) || 0; - roleNameSeen.set(key, seenIndex + 1); - const totalCount = roleNameCounts.get(key) || 1; - - let locator: Locator; - if (opts.selector) { - locator = page.locator(opts.selector).getByRole(node.role as any, { - name: node.name || undefined, - }); - } else { - locator = page.getByRole(node.role as any, { - name: node.name || undefined, - }); - } - - // Disambiguate with nth() if multiple elements share role+name - if (totalCount > 1) { - locator = locator.nth(seenIndex); - } - - refMap.set(ref, locator); - - // Format output line - let outputLine = `${indent}@${ref} [${node.role}]`; - if (node.name) outputLine += ` "${node.name}"`; - if (node.props) outputLine += ` ${node.props}`; - if (node.children) outputLine += `: ${node.children}`; - - 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); - - if (output.length === 0) { - 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 deleted file mode 100644 index 08c9425..0000000 --- a/browse/src/write-commands.ts +++ /dev/null @@ -1,312 +0,0 @@ -/** - * Write commands — navigate and interact with pages (side effects) - * - * goto, back, forward, reload, click, fill, select, hover, type, - * press, scroll, wait, viewport, cookie, header, useragent - */ - -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, - args: string[], - bm: BrowserManager -): Promise { - const page = bm.getPage(); - - switch (command) { - case 'goto': { - const url = args[0]; - if (!url) throw new Error('Usage: browse goto '); - const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 }); - const status = response?.status() || 'unknown'; - return `Navigated to ${url} (${status})`; - } - - case 'back': { - await page.goBack({ waitUntil: 'domcontentloaded', timeout: 15000 }); - return `Back → ${page.url()}`; - } - - case 'forward': { - await page.goForward({ waitUntil: 'domcontentloaded', timeout: 15000 }); - return `Forward → ${page.url()}`; - } - - case 'reload': { - await page.reload({ waitUntil: 'domcontentloaded', timeout: 15000 }); - return `Reloaded ${page.url()}`; - } - - case 'click': { - const selector = args[0]; - if (!selector) throw new Error('Usage: browse click '); - const resolved = bm.resolveRef(selector); - if ('locator' in resolved) { - await resolved.locator.click({ timeout: 5000 }); - } else { - await page.click(resolved.selector, { timeout: 5000 }); - } - // Wait briefly for any navigation/DOM update - await page.waitForLoadState('domcontentloaded').catch(() => {}); - return `Clicked ${selector} → now at ${page.url()}`; - } - - case 'fill': { - const [selector, ...valueParts] = args; - const value = valueParts.join(' '); - if (!selector || !value) throw new Error('Usage: browse fill '); - const resolved = bm.resolveRef(selector); - if ('locator' in resolved) { - await resolved.locator.fill(value, { timeout: 5000 }); - } else { - await page.fill(resolved.selector, value, { timeout: 5000 }); - } - return `Filled ${selector}`; - } - - case 'select': { - const [selector, ...valueParts] = args; - const value = valueParts.join(' '); - if (!selector || !value) throw new Error('Usage: browse select '); - const resolved = bm.resolveRef(selector); - if ('locator' in resolved) { - await resolved.locator.selectOption(value, { timeout: 5000 }); - } else { - await page.selectOption(resolved.selector, value, { timeout: 5000 }); - } - return `Selected "${value}" in ${selector}`; - } - - case 'hover': { - const selector = args[0]; - if (!selector) throw new Error('Usage: browse hover '); - const resolved = bm.resolveRef(selector); - if ('locator' in resolved) { - await resolved.locator.hover({ timeout: 5000 }); - } else { - await page.hover(resolved.selector, { timeout: 5000 }); - } - return `Hovered ${selector}`; - } - - case 'type': { - const text = args.join(' '); - if (!text) throw new Error('Usage: browse type '); - await page.keyboard.type(text); - return `Typed ${text.length} characters`; - } - - case 'press': { - const key = args[0]; - if (!key) throw new Error('Usage: browse press (e.g., Enter, Tab, Escape)'); - await page.keyboard.press(key); - return `Pressed ${key}`; - } - - case 'scroll': { - const selector = args[0]; - if (selector) { - const resolved = bm.resolveRef(selector); - if ('locator' in resolved) { - await resolved.locator.scrollIntoViewIfNeeded({ timeout: 5000 }); - } else { - await page.locator(resolved.selector).scrollIntoViewIfNeeded({ timeout: 5000 }); - } - return `Scrolled ${selector} into view`; - } - await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); - return 'Scrolled to bottom'; - } - - case 'wait': { - const selector = args[0]; - 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) { - await resolved.locator.waitFor({ state: 'visible', timeout }); - } else { - await page.waitForSelector(resolved.selector, { timeout }); - } - return `Element ${selector} appeared`; - } - - case 'viewport': { - const size = args[0]; - if (!size || !size.includes('x')) throw new Error('Usage: browse viewport (e.g., 375x812)'); - const [w, h] = size.split('x').map(Number); - await bm.setViewport(w, h); - return `Viewport set to ${w}x${h}`; - } - - case 'cookie': { - const cookieStr = args[0]; - if (!cookieStr || !cookieStr.includes('=')) throw new Error('Usage: browse cookie ='); - const eq = cookieStr.indexOf('='); - const name = cookieStr.slice(0, eq); - const value = cookieStr.slice(eq + 1); - const url = new URL(page.url()); - await page.context().addCookies([{ - name, - value, - domain: url.hostname, - path: '/', - }]); - return `Cookie set: ${name}=****`; - } - - case 'header': { - const headerStr = args[0]; - if (!headerStr || !headerStr.includes(':')) throw new Error('Usage: browse header :'); - const sep = headerStr.indexOf(':'); - const name = headerStr.slice(0, sep).trim(); - const value = headerStr.slice(sep + 1).trim(); - await bm.setExtraHeader(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); - 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: - throw new Error(`Unknown write command: ${command}`); - } -} diff --git a/browse/test/commands.test.ts b/browse/test/commands.test.ts deleted file mode 100644 index 1f6ad2f..0000000 --- a/browse/test/commands.test.ts +++ /dev/null @@ -1,1598 +0,0 @@ -/** - * Integration tests for all browse commands - * - * Tests run against a local test server serving fixture HTML files. - * A real browse server is started and commands are sent via the CLI HTTP interface. - */ - -import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; -import { startTestServer } from './test-server'; -import { BrowserManager } from '../src/browser-manager'; -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, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, CircularBuffer } from '../src/buffers'; -import * as fs from 'fs'; -import { spawn } from 'child_process'; -import * as path from 'path'; - -let testServer: ReturnType; -let bm: BrowserManager; -let baseUrl: string; - -beforeAll(async () => { - testServer = startTestServer(0); - baseUrl = testServer.url; - - bm = new BrowserManager(); - await bm.launch(); -}); - -afterAll(() => { - // Force kill browser instead of graceful close (avoids hang) - try { testServer.server.stop(); } catch {} - // bm.close() can hang — just let process exit handle it - setTimeout(() => process.exit(0), 500); -}); - -// ─── Navigation ───────────────────────────────────────────────── - -describe('Navigation', () => { - test('goto navigates to URL', async () => { - const result = await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); - expect(result).toContain('Navigated to'); - expect(result).toContain('200'); - }); - - test('url returns current URL', async () => { - const result = await handleMetaCommand('url', [], bm, async () => {}); - expect(result).toContain('/basic.html'); - }); - - test('back goes back', async () => { - await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm); - const result = await handleWriteCommand('back', [], bm); - expect(result).toContain('Back'); - }); - - test('forward goes forward', async () => { - const result = await handleWriteCommand('forward', [], bm); - expect(result).toContain('Forward'); - }); - - test('reload reloads page', async () => { - const result = await handleWriteCommand('reload', [], bm); - expect(result).toContain('Reloaded'); - }); -}); - -// ─── Content Extraction ───────────────────────────────────────── - -describe('Content extraction', () => { - beforeAll(async () => { - await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); - }); - - test('text returns cleaned page text', async () => { - const result = await handleReadCommand('text', [], bm); - expect(result).toContain('Hello World'); - expect(result).toContain('Item one'); - expect(result).not.toContain('

'); - }); - - test('html returns full page HTML', async () => { - const result = await handleReadCommand('html', [], bm); - expect(result).toContain(''); - expect(result).toContain('

Hello World

'); - }); - - test('html with selector returns element innerHTML', async () => { - const result = await handleReadCommand('html', ['#content'], bm); - expect(result).toContain('Some body text here.'); - expect(result).toContain('
  • Item one
  • '); - }); - - test('links returns all links', async () => { - const result = await handleReadCommand('links', [], bm); - expect(result).toContain('Page 1'); - expect(result).toContain('Page 2'); - expect(result).toContain('External'); - expect(result).toContain('→'); - }); - - test('forms discovers form fields', async () => { - await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm); - const result = await handleReadCommand('forms', [], bm); - const forms = JSON.parse(result); - expect(forms.length).toBe(2); - expect(forms[0].id).toBe('login-form'); - expect(forms[0].method).toBe('post'); - expect(forms[0].fields.length).toBeGreaterThanOrEqual(2); - expect(forms[1].id).toBe('profile-form'); - - // Check field discovery - const emailField = forms[0].fields.find((f: any) => f.name === 'email'); - expect(emailField).toBeDefined(); - expect(emailField.type).toBe('email'); - expect(emailField.required).toBe(true); - }); - - test('accessibility returns ARIA tree', async () => { - await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); - const result = await handleReadCommand('accessibility', [], bm); - expect(result).toContain('Hello World'); - }); -}); - -// ─── JavaScript / CSS / Attrs ─────────────────────────────────── - -describe('Inspection', () => { - beforeAll(async () => { - await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); - }); - - test('js evaluates expression', async () => { - const result = await handleReadCommand('js', ['document.title'], bm); - expect(result).toBe('Test Page - Basic'); - }); - - test('js returns objects as JSON', async () => { - const result = await handleReadCommand('js', ['({a: 1, b: 2})'], bm); - const obj = JSON.parse(result); - expect(obj.a).toBe(1); - expect(obj.b).toBe(2); - }); - - test('css returns computed property', async () => { - const result = await handleReadCommand('css', ['h1', 'color'], bm); - // Navy color - expect(result).toContain('0, 0, 128'); - }); - - test('css returns font-family', async () => { - const result = await handleReadCommand('css', ['body', 'font-family'], bm); - expect(result).toContain('Helvetica'); - }); - - test('attrs returns element attributes', async () => { - const result = await handleReadCommand('attrs', ['#content'], bm); - const attrs = JSON.parse(result); - expect(attrs.id).toBe('content'); - expect(attrs['data-testid']).toBe('main-content'); - expect(attrs['data-version']).toBe('1.0'); - }); -}); - -// ─── Interaction ──────────────────────────────────────────────── - -describe('Interaction', () => { - test('fill + click works on form', async () => { - await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm); - - let result = await handleWriteCommand('fill', ['#email', 'test@example.com'], bm); - expect(result).toContain('Filled'); - - result = await handleWriteCommand('fill', ['#password', 'secret123'], bm); - expect(result).toContain('Filled'); - - // Verify values were set - const emailVal = await handleReadCommand('js', ['document.querySelector("#email").value'], bm); - expect(emailVal).toBe('test@example.com'); - - result = await handleWriteCommand('click', ['#login-btn'], bm); - expect(result).toContain('Clicked'); - }); - - test('select works on dropdown', async () => { - await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm); - const result = await handleWriteCommand('select', ['#role', 'admin'], bm); - expect(result).toContain('Selected'); - - const val = await handleReadCommand('js', ['document.querySelector("#role").value'], bm); - expect(val).toBe('admin'); - }); - - test('hover works', async () => { - const result = await handleWriteCommand('hover', ['h1'], bm); - expect(result).toContain('Hovered'); - }); - - test('wait finds existing element', async () => { - await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); - const result = await handleWriteCommand('wait', ['#title'], bm); - expect(result).toContain('appeared'); - }); - - test('scroll works', async () => { - const result = await handleWriteCommand('scroll', ['footer'], bm); - expect(result).toContain('Scrolled'); - }); - - test('viewport changes size', async () => { - const result = await handleWriteCommand('viewport', ['375x812'], bm); - expect(result).toContain('Viewport set'); - - const size = await handleReadCommand('js', ['`${window.innerWidth}x${window.innerHeight}`'], bm); - expect(size).toBe('375x812'); - - // Reset - await handleWriteCommand('viewport', ['1280x720'], bm); - }); - - test('type and press work', async () => { - await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm); - await handleWriteCommand('click', ['#name'], bm); - - const result = await handleWriteCommand('type', ['John Doe'], bm); - expect(result).toContain('Typed'); - - const val = await handleReadCommand('js', ['document.querySelector("#name").value'], bm); - expect(val).toBe('John Doe'); - }); -}); - -// ─── SPA / Console / Network ─────────────────────────────────── - -describe('SPA and buffers', () => { - test('wait handles delayed rendering', async () => { - await handleWriteCommand('goto', [baseUrl + '/spa.html'], bm); - const result = await handleWriteCommand('wait', ['.loaded'], bm); - expect(result).toContain('appeared'); - - const text = await handleReadCommand('text', [], bm); - expect(text).toContain('SPA Content Loaded'); - }); - - test('console captures messages', async () => { - const result = await handleReadCommand('console', [], bm); - expect(result).toContain('[SPA] Starting render'); - expect(result).toContain('[SPA] Render complete'); - }); - - test('console --clear clears buffer', async () => { - const result = await handleReadCommand('console', ['--clear'], bm); - expect(result).toContain('cleared'); - - const after = await handleReadCommand('console', [], bm); - expect(after).toContain('no console messages'); - }); - - test('network captures requests', async () => { - const result = await handleReadCommand('network', [], bm); - expect(result).toContain('GET'); - expect(result).toContain('/spa.html'); - }); - - test('network --clear clears buffer', async () => { - const result = await handleReadCommand('network', ['--clear'], bm); - expect(result).toContain('cleared'); - }); -}); - -// ─── Cookies / Storage ────────────────────────────────────────── - -describe('Cookies and storage', () => { - test('cookies returns array', async () => { - await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); - const result = await handleReadCommand('cookies', [], bm); - // Test server doesn't set cookies, so empty array - expect(result).toBe('[]'); - }); - - test('storage set and get works', async () => { - await handleReadCommand('storage', ['set', 'testKey', 'testValue'], bm); - const result = await handleReadCommand('storage', [], bm); - const storage = JSON.parse(result); - expect(storage.localStorage.testKey).toBe('testValue'); - }); -}); - -// ─── Performance ──────────────────────────────────────────────── - -describe('Performance', () => { - test('perf returns timing data', async () => { - await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); - const result = await handleReadCommand('perf', [], bm); - expect(result).toContain('dns'); - expect(result).toContain('ttfb'); - expect(result).toContain('load'); - expect(result).toContain('ms'); - }); -}); - -// ─── Visual ───────────────────────────────────────────────────── - -describe('Visual', () => { - test('screenshot saves file', async () => { - await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); - const screenshotPath = '/tmp/browse-test-screenshot.png'; - const result = await handleMetaCommand('screenshot', [screenshotPath], bm, async () => {}); - expect(result).toContain('Screenshot saved'); - expect(fs.existsSync(screenshotPath)).toBe(true); - const stat = fs.statSync(screenshotPath); - expect(stat.size).toBeGreaterThan(1000); - fs.unlinkSync(screenshotPath); - }); - - test('responsive saves 3 screenshots', async () => { - await handleWriteCommand('goto', [baseUrl + '/responsive.html'], bm); - const prefix = '/tmp/browse-test-resp'; - const result = await handleMetaCommand('responsive', [prefix], bm, async () => {}); - expect(result).toContain('mobile'); - expect(result).toContain('tablet'); - expect(result).toContain('desktop'); - - expect(fs.existsSync(`${prefix}-mobile.png`)).toBe(true); - expect(fs.existsSync(`${prefix}-tablet.png`)).toBe(true); - expect(fs.existsSync(`${prefix}-desktop.png`)).toBe(true); - - // Cleanup - fs.unlinkSync(`${prefix}-mobile.png`); - fs.unlinkSync(`${prefix}-tablet.png`); - fs.unlinkSync(`${prefix}-desktop.png`); - }); -}); - -// ─── Tabs ─────────────────────────────────────────────────────── - -describe('Tabs', () => { - test('tabs lists all tabs', async () => { - const result = await handleMetaCommand('tabs', [], bm, async () => {}); - expect(result).toContain('['); - expect(result).toContain(']'); - }); - - test('newtab opens new tab', async () => { - const result = await handleMetaCommand('newtab', [baseUrl + '/forms.html'], bm, async () => {}); - expect(result).toContain('Opened tab'); - - const tabCount = bm.getTabCount(); - expect(tabCount).toBeGreaterThanOrEqual(2); - }); - - test('tab switches to specific tab', async () => { - const result = await handleMetaCommand('tab', ['1'], bm, async () => {}); - expect(result).toContain('Switched to tab 1'); - }); - - test('closetab closes a tab', async () => { - const before = bm.getTabCount(); - // Close the last opened tab - const tabs = await bm.getTabListWithTitles(); - const lastTab = tabs[tabs.length - 1]; - const result = await handleMetaCommand('closetab', [String(lastTab.id)], bm, async () => {}); - expect(result).toContain('Closed tab'); - expect(bm.getTabCount()).toBe(before - 1); - }); -}); - -// ─── Diff ─────────────────────────────────────────────────────── - -describe('Diff', () => { - test('diff shows differences between pages', async () => { - const result = await handleMetaCommand( - 'diff', - [baseUrl + '/basic.html', baseUrl + '/forms.html'], - bm, - async () => {} - ); - expect(result).toContain('---'); - expect(result).toContain('+++'); - // basic.html has "Hello World", forms.html has "Form Test Page" - expect(result).toContain('Hello World'); - expect(result).toContain('Form Test Page'); - }); -}); - -// ─── Chain ────────────────────────────────────────────────────── - -describe('Chain', () => { - test('chain executes sequence of commands', async () => { - const commands = JSON.stringify([ - ['goto', baseUrl + '/basic.html'], - ['js', 'document.title'], - ['css', 'h1', 'color'], - ]); - const result = await handleMetaCommand('chain', [commands], bm, async () => {}); - expect(result).toContain('[goto]'); - expect(result).toContain('Test Page - Basic'); - expect(result).toContain('[css]'); - }); - - test('chain reports real error when write command fails', async () => { - const commands = JSON.stringify([ - ['goto', 'http://localhost:1/unreachable'], - ]); - const result = await handleMetaCommand('chain', [commands], bm, async () => {}); - expect(result).toContain('[goto] ERROR:'); - expect(result).not.toContain('Unknown meta command'); - expect(result).not.toContain('Unknown read command'); - }); -}); - -// ─── Status ───────────────────────────────────────────────────── - -describe('Status', () => { - test('status reports health', async () => { - const result = await handleMetaCommand('status', [], bm, async () => {}); - expect(result).toContain('Status: healthy'); - expect(result).toContain('Tabs:'); - }); -}); - -// ─── CLI server script resolution ─────────────────────────────── - -describe('CLI server script resolution', () => { - test('prefers adjacent browse/src/server.ts for compiled project installs', () => { - const root = fs.mkdtempSync('/tmp/gstack-cli-'); - const execPath = path.join(root, '.claude/skills/gstack/browse/dist/browse'); - const serverPath = path.join(root, '.claude/skills/gstack/browse/src/server.ts'); - - fs.mkdirSync(path.dirname(execPath), { recursive: true }); - fs.mkdirSync(path.dirname(serverPath), { recursive: true }); - fs.writeFileSync(serverPath, '// test server\n'); - - const resolved = resolveServerScript( - { HOME: path.join(root, 'empty-home') }, - '$bunfs/root', - execPath - ); - - expect(resolved).toBe(serverPath); - - fs.rmSync(root, { recursive: true, force: true }); - }); -}); - -// ─── CLI lifecycle ────────────────────────────────────────────── - -describe('CLI lifecycle', () => { - test('dead state file triggers a clean restart', async () => { - const stateFile = `/tmp/browse-test-state-${Date.now()}.json`; - fs.writeFileSync(stateFile, JSON.stringify({ - port: 1, - token: 'fake', - pid: 999999, - })); - - const cliPath = path.resolve(__dirname, '../src/cli.ts'); - const cliEnv: Record = {}; - for (const [k, v] of Object.entries(process.env)) { - if (v !== undefined) cliEnv[k] = v; - } - cliEnv.BROWSE_STATE_FILE = stateFile; - const result = await new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => { - const proc = spawn('bun', ['run', cliPath, 'status'], { - timeout: 15000, - env: cliEnv, - }); - let stdout = ''; - let stderr = ''; - proc.stdout.on('data', (d) => stdout += d.toString()); - proc.stderr.on('data', (d) => stderr += d.toString()); - proc.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr })); - }); - - let restartedPid: number | null = null; - if (fs.existsSync(stateFile)) { - restartedPid = JSON.parse(fs.readFileSync(stateFile, 'utf-8')).pid; - fs.unlinkSync(stateFile); - } - if (restartedPid) { - try { process.kill(restartedPid, 'SIGTERM'); } catch {} - } - - expect(result.code).toBe(0); - expect(result.stdout).toContain('Status: healthy'); - expect(result.stderr).toContain('Starting server'); - }, 20000); -}); - -// ─── Buffer bounds ────────────────────────────────────────────── - -describe('Buffer bounds', () => { - test('console buffer caps at 50000 entries', () => { - consoleBuffer.clear(); - for (let i = 0; i < 50_010; i++) { - addConsoleEntry({ timestamp: i, level: 'log', text: `msg-${i}` }); - } - expect(consoleBuffer.length).toBe(50_000); - 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.clear(); - for (let i = 0; i < 50_010; i++) { - addNetworkEntry({ timestamp: i, method: 'GET', url: `http://x/${i}` }); - } - expect(networkBuffer.length).toBe(50_000); - 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 = 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(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/config.test.ts b/browse/test/config.test.ts deleted file mode 100644 index 780385f..0000000 --- a/browse/test/config.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { describe, test, expect } from 'bun:test'; -import { resolveConfig, ensureStateDir, readVersionHash, getGitRoot } from '../src/config'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; - -describe('config', () => { - describe('getGitRoot', () => { - test('returns a path when in a git repo', () => { - const root = getGitRoot(); - expect(root).not.toBeNull(); - expect(fs.existsSync(path.join(root!, '.git'))).toBe(true); - }); - }); - - describe('resolveConfig', () => { - test('uses git root by default', () => { - const config = resolveConfig({}); - const gitRoot = getGitRoot(); - expect(gitRoot).not.toBeNull(); - expect(config.projectDir).toBe(gitRoot); - expect(config.stateDir).toBe(path.join(gitRoot!, '.gstack')); - expect(config.stateFile).toBe(path.join(gitRoot!, '.gstack', 'browse.json')); - }); - - test('derives paths from BROWSE_STATE_FILE when set', () => { - const stateFile = '/tmp/test-config/.gstack/browse.json'; - const config = resolveConfig({ BROWSE_STATE_FILE: stateFile }); - expect(config.stateFile).toBe(stateFile); - expect(config.stateDir).toBe('/tmp/test-config/.gstack'); - expect(config.projectDir).toBe('/tmp/test-config'); - }); - - test('log paths are in stateDir', () => { - const config = resolveConfig({}); - expect(config.consoleLog).toBe(path.join(config.stateDir, 'browse-console.log')); - expect(config.networkLog).toBe(path.join(config.stateDir, 'browse-network.log')); - expect(config.dialogLog).toBe(path.join(config.stateDir, 'browse-dialog.log')); - }); - }); - - describe('ensureStateDir', () => { - test('creates directory if it does not exist', () => { - const tmpDir = path.join(os.tmpdir(), `browse-config-test-${Date.now()}`); - const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') }); - expect(fs.existsSync(config.stateDir)).toBe(false); - ensureStateDir(config); - expect(fs.existsSync(config.stateDir)).toBe(true); - // Cleanup - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - test('is a no-op if directory already exists', () => { - const tmpDir = path.join(os.tmpdir(), `browse-config-test-${Date.now()}`); - const stateDir = path.join(tmpDir, '.gstack'); - fs.mkdirSync(stateDir, { recursive: true }); - const config = resolveConfig({ BROWSE_STATE_FILE: path.join(stateDir, 'browse.json') }); - ensureStateDir(config); // should not throw - expect(fs.existsSync(config.stateDir)).toBe(true); - // Cleanup - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - }); - - describe('readVersionHash', () => { - test('returns null when .version file does not exist', () => { - const result = readVersionHash('/nonexistent/path/browse'); - expect(result).toBeNull(); - }); - - test('reads version from .version file adjacent to execPath', () => { - const tmpDir = path.join(os.tmpdir(), `browse-version-test-${Date.now()}`); - fs.mkdirSync(tmpDir, { recursive: true }); - const versionFile = path.join(tmpDir, '.version'); - fs.writeFileSync(versionFile, 'abc123def\n'); - const result = readVersionHash(path.join(tmpDir, 'browse')); - expect(result).toBe('abc123def'); - // Cleanup - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - }); -}); - -describe('resolveServerScript', () => { - // Import the function from cli.ts - const { resolveServerScript } = require('../src/cli'); - - test('uses BROWSE_SERVER_SCRIPT env when set', () => { - const result = resolveServerScript({ BROWSE_SERVER_SCRIPT: '/custom/server.ts' }, '', ''); - expect(result).toBe('/custom/server.ts'); - }); - - test('finds server.ts adjacent to cli.ts in dev mode', () => { - const srcDir = path.resolve(__dirname, '../src'); - const result = resolveServerScript({}, srcDir, ''); - expect(result).toBe(path.join(srcDir, 'server.ts')); - }); - - test('throws when server.ts cannot be found', () => { - expect(() => resolveServerScript({}, '/nonexistent/$bunfs', '/nonexistent/browse')) - .toThrow('Cannot find server.ts'); - }); -}); - -describe('version mismatch detection', () => { - test('detects when versions differ', () => { - const stateVersion = 'abc123'; - const currentVersion = 'def456'; - expect(stateVersion !== currentVersion).toBe(true); - }); - - test('no mismatch when versions match', () => { - const stateVersion = 'abc123'; - const currentVersion = 'abc123'; - expect(stateVersion !== currentVersion).toBe(false); - }); - - test('no mismatch when either version is null', () => { - const currentVersion: string | null = null; - const stateVersion: string | undefined = 'abc123'; - // Version mismatch only triggers when both are present - const shouldRestart = currentVersion !== null && stateVersion !== undefined && currentVersion !== stateVersion; - expect(shouldRestart).toBe(false); - }); -}); diff --git a/browse/test/cookie-import-browser.test.ts b/browse/test/cookie-import-browser.test.ts deleted file mode 100644 index 1e91cf1..0000000 --- a/browse/test/cookie-import-browser.test.ts +++ /dev/null @@ -1,397 +0,0 @@ -/** - * 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/cookie-picker-routes.test.ts b/browse/test/cookie-picker-routes.test.ts deleted file mode 100644 index ca55c47..0000000 --- a/browse/test/cookie-picker-routes.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * Tests for cookie-picker route handler - * - * Tests the HTTP glue layer directly with mock BrowserManager objects. - * Verifies that all routes return valid JSON (not HTML) with correct CORS headers. - */ - -import { describe, test, expect } from 'bun:test'; -import { handleCookiePickerRoute } from '../src/cookie-picker-routes'; - -// ─── Mock BrowserManager ────────────────────────────────────── - -function mockBrowserManager() { - const addedCookies: any[] = []; - const clearedDomains: string[] = []; - return { - bm: { - getPage: () => ({ - context: () => ({ - addCookies: (cookies: any[]) => { addedCookies.push(...cookies); }, - clearCookies: (opts: { domain: string }) => { clearedDomains.push(opts.domain); }, - }), - }), - } as any, - addedCookies, - clearedDomains, - }; -} - -function makeUrl(path: string, port = 9470): URL { - return new URL(`http://127.0.0.1:${port}${path}`); -} - -function makeReq(method: string, body?: any): Request { - const opts: RequestInit = { method }; - if (body) { - opts.body = JSON.stringify(body); - opts.headers = { 'Content-Type': 'application/json' }; - } - return new Request('http://127.0.0.1:9470', opts); -} - -// ─── Tests ────────────────────────────────────────────────────── - -describe('cookie-picker-routes', () => { - describe('CORS', () => { - test('OPTIONS returns 204 with correct CORS headers', async () => { - const { bm } = mockBrowserManager(); - const url = makeUrl('/cookie-picker/browsers'); - const req = new Request('http://127.0.0.1:9470', { method: 'OPTIONS' }); - - const res = await handleCookiePickerRoute(url, req, bm); - - expect(res.status).toBe(204); - expect(res.headers.get('Access-Control-Allow-Origin')).toBe('http://127.0.0.1:9470'); - expect(res.headers.get('Access-Control-Allow-Methods')).toContain('POST'); - }); - - test('JSON responses include correct CORS origin with port', async () => { - const { bm } = mockBrowserManager(); - const url = makeUrl('/cookie-picker/browsers', 9450); - const req = new Request('http://127.0.0.1:9450', { method: 'GET' }); - - const res = await handleCookiePickerRoute(url, req, bm); - - expect(res.headers.get('Access-Control-Allow-Origin')).toBe('http://127.0.0.1:9450'); - }); - }); - - describe('JSON responses (not HTML)', () => { - test('GET /cookie-picker/browsers returns JSON', async () => { - const { bm } = mockBrowserManager(); - const url = makeUrl('/cookie-picker/browsers'); - const req = new Request('http://127.0.0.1:9470', { method: 'GET' }); - - const res = await handleCookiePickerRoute(url, req, bm); - - expect(res.status).toBe(200); - expect(res.headers.get('Content-Type')).toBe('application/json'); - const body = await res.json(); - expect(body).toHaveProperty('browsers'); - expect(Array.isArray(body.browsers)).toBe(true); - }); - - test('GET /cookie-picker/domains without browser param returns JSON error', async () => { - const { bm } = mockBrowserManager(); - const url = makeUrl('/cookie-picker/domains'); - const req = new Request('http://127.0.0.1:9470', { method: 'GET' }); - - const res = await handleCookiePickerRoute(url, req, bm); - - expect(res.status).toBe(400); - expect(res.headers.get('Content-Type')).toBe('application/json'); - const body = await res.json(); - expect(body).toHaveProperty('error'); - expect(body).toHaveProperty('code', 'missing_param'); - }); - - test('POST /cookie-picker/import with invalid JSON returns JSON error', async () => { - const { bm } = mockBrowserManager(); - const url = makeUrl('/cookie-picker/import'); - const req = new Request('http://127.0.0.1:9470', { - method: 'POST', - body: 'not json', - headers: { 'Content-Type': 'application/json' }, - }); - - const res = await handleCookiePickerRoute(url, req, bm); - - expect(res.status).toBe(400); - expect(res.headers.get('Content-Type')).toBe('application/json'); - const body = await res.json(); - expect(body.code).toBe('bad_request'); - }); - - test('POST /cookie-picker/import missing browser field returns JSON error', async () => { - const { bm } = mockBrowserManager(); - const url = makeUrl('/cookie-picker/import'); - const req = makeReq('POST', { domains: ['.example.com'] }); - - const res = await handleCookiePickerRoute(url, req, bm); - - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.code).toBe('missing_param'); - }); - - test('POST /cookie-picker/import missing domains returns JSON error', async () => { - const { bm } = mockBrowserManager(); - const url = makeUrl('/cookie-picker/import'); - const req = makeReq('POST', { browser: 'Chrome' }); - - const res = await handleCookiePickerRoute(url, req, bm); - - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.code).toBe('missing_param'); - }); - - test('POST /cookie-picker/remove with invalid JSON returns JSON error', async () => { - const { bm } = mockBrowserManager(); - const url = makeUrl('/cookie-picker/remove'); - const req = new Request('http://127.0.0.1:9470', { - method: 'POST', - body: '{bad', - headers: { 'Content-Type': 'application/json' }, - }); - - const res = await handleCookiePickerRoute(url, req, bm); - - expect(res.status).toBe(400); - expect(res.headers.get('Content-Type')).toBe('application/json'); - }); - - test('POST /cookie-picker/remove missing domains returns JSON error', async () => { - const { bm } = mockBrowserManager(); - const url = makeUrl('/cookie-picker/remove'); - const req = makeReq('POST', {}); - - const res = await handleCookiePickerRoute(url, req, bm); - - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.code).toBe('missing_param'); - }); - - test('GET /cookie-picker/imported returns JSON with domain list', async () => { - const { bm } = mockBrowserManager(); - const url = makeUrl('/cookie-picker/imported'); - const req = new Request('http://127.0.0.1:9470', { method: 'GET' }); - - const res = await handleCookiePickerRoute(url, req, bm); - - expect(res.status).toBe(200); - expect(res.headers.get('Content-Type')).toBe('application/json'); - const body = await res.json(); - expect(body).toHaveProperty('domains'); - expect(body).toHaveProperty('totalDomains'); - expect(body).toHaveProperty('totalCookies'); - }); - }); - - describe('routing', () => { - test('GET /cookie-picker returns HTML', async () => { - const { bm } = mockBrowserManager(); - const url = makeUrl('/cookie-picker'); - const req = new Request('http://127.0.0.1:9470', { method: 'GET' }); - - const res = await handleCookiePickerRoute(url, req, bm); - - expect(res.status).toBe(200); - expect(res.headers.get('Content-Type')).toContain('text/html'); - }); - - test('unknown path returns 404', async () => { - const { bm } = mockBrowserManager(); - const url = makeUrl('/cookie-picker/nonexistent'); - const req = new Request('http://127.0.0.1:9470', { method: 'GET' }); - - const res = await handleCookiePickerRoute(url, req, bm); - - expect(res.status).toBe(404); - }); - }); -}); diff --git a/browse/test/find-browse.test.ts b/browse/test/find-browse.test.ts deleted file mode 100644 index 43e1300..0000000 --- a/browse/test/find-browse.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Tests for find-browse version check logic - * - * Tests the checkVersion() and locateBinary() functions directly. - * Uses temp directories with mock .version files and cache files. - */ - -import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; -import { checkVersion, locateBinary } from '../src/find-browse'; -import { mkdtempSync, writeFileSync, rmSync, existsSync, mkdirSync } from 'fs'; -import { join } from 'path'; -import { tmpdir } from 'os'; - -let tempDir: string; - -beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), 'find-browse-test-')); -}); - -afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); - // Clean up test cache - try { rmSync('/tmp/gstack-latest-version'); } catch {} -}); - -describe('checkVersion', () => { - test('returns null when .version file is missing', () => { - const result = checkVersion(tempDir); - expect(result).toBeNull(); - }); - - test('returns null when .version file is empty', () => { - writeFileSync(join(tempDir, '.version'), ''); - const result = checkVersion(tempDir); - expect(result).toBeNull(); - }); - - test('returns null when .version has only whitespace', () => { - writeFileSync(join(tempDir, '.version'), ' \n'); - const result = checkVersion(tempDir); - expect(result).toBeNull(); - }); - - test('returns null when local SHA matches remote (cache hit)', () => { - const sha = 'a'.repeat(40); - writeFileSync(join(tempDir, '.version'), sha); - // Write cache with same SHA, recent timestamp - const now = Math.floor(Date.now() / 1000); - writeFileSync('/tmp/gstack-latest-version', `${sha} ${now}\n`); - - const result = checkVersion(tempDir); - expect(result).toBeNull(); - }); - - test('returns META:UPDATE_AVAILABLE when SHAs differ (cache hit)', () => { - const localSha = 'a'.repeat(40); - const remoteSha = 'b'.repeat(40); - writeFileSync(join(tempDir, '.version'), localSha); - // Create a fake browse binary path so resolveSkillDir works - const browsePath = join(tempDir, 'browse'); - writeFileSync(browsePath, ''); - // Write cache with different SHA, recent timestamp - const now = Math.floor(Date.now() / 1000); - writeFileSync('/tmp/gstack-latest-version', `${remoteSha} ${now}\n`); - - const result = checkVersion(tempDir); - // Result may be null if resolveSkillDir can't determine skill dir from temp path - // That's expected — the META signal requires a known skill dir path - if (result !== null) { - expect(result).toStartWith('META:UPDATE_AVAILABLE'); - const jsonStr = result.replace('META:UPDATE_AVAILABLE ', ''); - const payload = JSON.parse(jsonStr); - expect(payload.current).toBe('a'.repeat(8)); - expect(payload.latest).toBe('b'.repeat(8)); - expect(payload.command).toContain('git stash'); - expect(payload.command).toContain('git reset --hard origin/main'); - expect(payload.command).toContain('./setup'); - } - }); - - test('uses cached SHA when cache is fresh (< 4hr)', () => { - const localSha = 'a'.repeat(40); - const remoteSha = 'a'.repeat(40); - writeFileSync(join(tempDir, '.version'), localSha); - // Cache is 1 hour old — should still be valid - const oneHourAgo = Math.floor(Date.now() / 1000) - 3600; - writeFileSync('/tmp/gstack-latest-version', `${remoteSha} ${oneHourAgo}\n`); - - const result = checkVersion(tempDir); - expect(result).toBeNull(); // SHAs match - }); - - test('treats expired cache as stale', () => { - const localSha = 'a'.repeat(40); - writeFileSync(join(tempDir, '.version'), localSha); - // Cache is 5 hours old — should be stale - const fiveHoursAgo = Math.floor(Date.now() / 1000) - 18000; - writeFileSync('/tmp/gstack-latest-version', `${'b'.repeat(40)} ${fiveHoursAgo}\n`); - - // This will try git ls-remote which may fail in test env — that's OK - // The important thing is it doesn't use the stale cache value - const result = checkVersion(tempDir); - // Result depends on whether git ls-remote succeeds in test environment - // If offline, returns null (graceful degradation) - expect(result === null || typeof result === 'string').toBe(true); - }); - - test('handles corrupt cache file gracefully', () => { - const localSha = 'a'.repeat(40); - writeFileSync(join(tempDir, '.version'), localSha); - writeFileSync('/tmp/gstack-latest-version', 'garbage data here'); - - // Should not throw, should treat as stale - const result = checkVersion(tempDir); - expect(result === null || typeof result === 'string').toBe(true); - }); - - test('handles cache with invalid SHA gracefully', () => { - const localSha = 'a'.repeat(40); - writeFileSync(join(tempDir, '.version'), localSha); - writeFileSync('/tmp/gstack-latest-version', `not-a-sha ${Math.floor(Date.now() / 1000)}\n`); - - // Invalid SHA should be treated as no cache - const result = checkVersion(tempDir); - expect(result === null || typeof result === 'string').toBe(true); - }); -}); - -describe('locateBinary', () => { - test('returns null when no binary exists at known paths', () => { - // This test depends on the test environment — if a real binary exists at - // ~/.claude/skills/gstack/browse/dist/browse, it will find it. - // We mainly test that the function doesn't throw. - const result = locateBinary(); - expect(result === null || typeof result === 'string').toBe(true); - }); - - test('returns string path when binary exists', () => { - const result = locateBinary(); - if (result !== null) { - expect(existsSync(result)).toBe(true); - } - }); -}); diff --git a/browse/test/fixtures/basic.html b/browse/test/fixtures/basic.html deleted file mode 100644 index 21904c8..0000000 --- a/browse/test/fixtures/basic.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - - Test Page - Basic - - - - -

    Hello World

    -

    This is a highlighted paragraph.

    - -
    -

    Some body text here.

    -
      -
    • Item one
    • -
    • Item two
    • -
    • Item three
    • -
    -
    -
    Footer text
    - - diff --git a/browse/test/fixtures/cursor-interactive.html b/browse/test/fixtures/cursor-interactive.html deleted file mode 100644 index 0259081..0000000 --- a/browse/test/fixtures/cursor-interactive.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - - 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 deleted file mode 100644 index bfc588a..0000000 --- a/browse/test/fixtures/dialog.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - Test Page - Dialog - - -

    Dialog Test

    - - - -

    -

    - - diff --git a/browse/test/fixtures/empty.html b/browse/test/fixtures/empty.html deleted file mode 100644 index 8ba582f..0000000 --- a/browse/test/fixtures/empty.html +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/browse/test/fixtures/forms.html b/browse/test/fixtures/forms.html deleted file mode 100644 index 8a6b730..0000000 --- a/browse/test/fixtures/forms.html +++ /dev/null @@ -1,55 +0,0 @@ - - - - - Test Page - Forms - - - -

    Form Test Page

    - -
    - - - - - -
    - -
    - - - - - - - - -
    - -
    Form submitted!
    - - - - diff --git a/browse/test/fixtures/responsive.html b/browse/test/fixtures/responsive.html deleted file mode 100644 index 3c7c89d..0000000 --- a/browse/test/fixtures/responsive.html +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - Test Page - Responsive - - - -
    -

    Responsive Layout Test

    -

    You are on mobile

    -

    You are on desktop

    -
    -
    Card 1
    -
    Card 2
    -
    Card 3
    -
    Card 4
    -
    Card 5
    -
    Card 6
    -
    -
    - - diff --git a/browse/test/fixtures/snapshot.html b/browse/test/fixtures/snapshot.html deleted file mode 100644 index 3753202..0000000 --- a/browse/test/fixtures/snapshot.html +++ /dev/null @@ -1,55 +0,0 @@ - - - - - Snapshot Test Page - - - -

    Snapshot Test

    -

    Subheading

    - - - -
    -

    Form Section

    -
    - - - - - - - -
    -
    - -
    -
    - -
    -
    - -

    Some paragraph text that is not interactive.

    - - - - diff --git a/browse/test/fixtures/spa.html b/browse/test/fixtures/spa.html deleted file mode 100644 index 2ea176d..0000000 --- a/browse/test/fixtures/spa.html +++ /dev/null @@ -1,24 +0,0 @@ - - - - - Test Page - SPA - - - -
    Loading...
    - - - diff --git a/browse/test/fixtures/states.html b/browse/test/fixtures/states.html deleted file mode 100644 index 67debbf..0000000 --- a/browse/test/fixtures/states.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - - Test Page - Element States - - -

    Element States Test

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

    Upload Test

    - - -

    - - - diff --git a/browse/test/snapshot.test.ts b/browse/test/snapshot.test.ts deleted file mode 100644 index bc45f6a..0000000 --- a/browse/test/snapshot.test.ts +++ /dev/null @@ -1,418 +0,0 @@ -/** - * Snapshot command tests - * - * Tests: accessibility tree snapshots, ref-based element selection, - * ref invalidation on navigation, and ref resolution in commands. - */ - -import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; -import { startTestServer } from './test-server'; -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; -let baseUrl: string; -const shutdown = async () => {}; - -beforeAll(async () => { - testServer = startTestServer(0); - baseUrl = testServer.url; - - bm = new BrowserManager(); - await bm.launch(); -}); - -afterAll(() => { - try { testServer.server.stop(); } catch {} - setTimeout(() => process.exit(0), 500); -}); - -// ─── Snapshot Output ──────────────────────────────────────────── - -describe('Snapshot', () => { - test('snapshot returns accessibility tree with refs', async () => { - await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); - const result = await handleMetaCommand('snapshot', [], bm, shutdown); - expect(result).toContain('@e'); - expect(result).toContain('[heading]'); - expect(result).toContain('"Snapshot Test"'); - expect(result).toContain('[button]'); - expect(result).toContain('[link]'); - }); - - test('snapshot -i returns only interactive elements', async () => { - await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); - const result = await handleMetaCommand('snapshot', ['-i'], bm, shutdown); - expect(result).toContain('[button]'); - expect(result).toContain('[link]'); - expect(result).toContain('[textbox]'); - // Should NOT contain non-interactive roles like heading or paragraph - expect(result).not.toContain('[heading]'); - }); - - test('snapshot -c returns compact output', async () => { - await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); - const full = await handleMetaCommand('snapshot', [], bm, shutdown); - const compact = await handleMetaCommand('snapshot', ['-c'], bm, shutdown); - // Compact should have fewer lines (empty structural elements removed) - const fullLines = full.split('\n').length; - const compactLines = compact.split('\n').length; - expect(compactLines).toBeLessThanOrEqual(fullLines); - }); - - test('snapshot -d 2 limits depth', async () => { - await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); - const shallow = await handleMetaCommand('snapshot', ['-d', '2'], bm, shutdown); - const deep = await handleMetaCommand('snapshot', [], bm, shutdown); - // Shallow should have fewer or equal lines - expect(shallow.split('\n').length).toBeLessThanOrEqual(deep.split('\n').length); - }); - - test('snapshot -s "#main" scopes to selector', async () => { - await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); - const scoped = await handleMetaCommand('snapshot', ['-s', '#main'], bm, shutdown); - // Should contain elements inside #main - expect(scoped).toContain('[button]'); - expect(scoped).toContain('"Submit"'); - // Should NOT contain elements outside #main (like nav links) - expect(scoped).not.toContain('"Internal Link"'); - }); - - test('snapshot on page with no interactive elements', async () => { - // Navigate to about:blank which has minimal content - await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); - const result = await handleMetaCommand('snapshot', ['-i'], bm, shutdown); - // basic.html has links, so this should find those - expect(result).toContain('[link]'); - }); - - test('second snapshot generates fresh refs', async () => { - await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); - const snap1 = await handleMetaCommand('snapshot', [], bm, shutdown); - const snap2 = await handleMetaCommand('snapshot', [], bm, shutdown); - // Both should have @e1 (refs restart from 1) - expect(snap1).toContain('@e1'); - expect(snap2).toContain('@e1'); - }); -}); - -// ─── Ref-Based Interaction ────────────────────────────────────── - -describe('Ref resolution', () => { - test('click @ref works after snapshot', async () => { - await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); - const snap = await handleMetaCommand('snapshot', ['-i'], bm, shutdown); - // Find a button ref - const buttonLine = snap.split('\n').find(l => l.includes('[button]') && l.includes('"Submit"')); - expect(buttonLine).toBeDefined(); - const refMatch = buttonLine!.match(/@(e\d+)/); - expect(refMatch).toBeDefined(); - const ref = `@${refMatch![1]}`; - const result = await handleWriteCommand('click', [ref], bm); - expect(result).toContain('Clicked'); - }); - - test('fill @ref works after snapshot', async () => { - await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); - const snap = await handleMetaCommand('snapshot', ['-i'], bm, shutdown); - // Find a textbox ref (Username) - const textboxLine = snap.split('\n').find(l => l.includes('[textbox]') && l.includes('"Username"')); - expect(textboxLine).toBeDefined(); - const refMatch = textboxLine!.match(/@(e\d+)/); - expect(refMatch).toBeDefined(); - const ref = `@${refMatch![1]}`; - const result = await handleWriteCommand('fill', [ref, 'testuser'], bm); - expect(result).toContain('Filled'); - }); - - test('hover @ref works after snapshot', async () => { - await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); - const snap = await handleMetaCommand('snapshot', ['-i'], bm, shutdown); - const linkLine = snap.split('\n').find(l => l.includes('[link]')); - expect(linkLine).toBeDefined(); - const refMatch = linkLine!.match(/@(e\d+)/); - const ref = `@${refMatch![1]}`; - const result = await handleWriteCommand('hover', [ref], bm); - expect(result).toContain('Hovered'); - }); - - test('html @ref returns innerHTML', async () => { - await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); - const snap = await handleMetaCommand('snapshot', [], bm, shutdown); - // Find a heading ref - const headingLine = snap.split('\n').find(l => l.includes('[heading]') && l.includes('"Snapshot Test"')); - expect(headingLine).toBeDefined(); - const refMatch = headingLine!.match(/@(e\d+)/); - const ref = `@${refMatch![1]}`; - const result = await handleReadCommand('html', [ref], bm); - expect(result).toContain('Snapshot Test'); - }); - - test('css @ref returns computed CSS', async () => { - await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); - const snap = await handleMetaCommand('snapshot', [], bm, shutdown); - const headingLine = snap.split('\n').find(l => l.includes('[heading]') && l.includes('"Snapshot Test"')); - const refMatch = headingLine!.match(/@(e\d+)/); - const ref = `@${refMatch![1]}`; - const result = await handleReadCommand('css', [ref, 'font-family'], bm); - expect(result).toBeTruthy(); - }); - - test('attrs @ref returns element attributes', async () => { - await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); - const snap = await handleMetaCommand('snapshot', ['-i'], bm, shutdown); - const textboxLine = snap.split('\n').find(l => l.includes('[textbox]') && l.includes('"Username"')); - const refMatch = textboxLine!.match(/@(e\d+)/); - const ref = `@${refMatch![1]}`; - const result = await handleReadCommand('attrs', [ref], bm); - expect(result).toContain('id'); - }); -}); - -// ─── Ref Invalidation ─────────────────────────────────────────── - -describe('Ref invalidation', () => { - test('stale ref after goto returns clear error', async () => { - await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); - await handleMetaCommand('snapshot', ['-i'], bm, shutdown); - // Navigate away — should invalidate refs - await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); - // Try to use old ref - try { - await handleWriteCommand('click', ['@e1'], bm); - expect(true).toBe(false); // Should not reach here - } catch (err: any) { - expect(err.message).toContain('not found'); - expect(err.message).toContain('snapshot'); - } - }); - - test('refs cleared on page navigation', async () => { - await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm); - await handleMetaCommand('snapshot', ['-i'], bm, shutdown); - expect(bm.getRefCount()).toBeGreaterThan(0); - // Navigate - await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); - 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 deleted file mode 100644 index 3775882..0000000 --- a/browse/test/test-server.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Tiny Bun.serve for test fixtures - * Serves HTML files from test/fixtures/ on a random available port - */ - -import * as path from 'path'; -import * as fs from 'fs'; - -const FIXTURES_DIR = path.resolve(import.meta.dir, 'fixtures'); - -export function startTestServer(port: number = 0): { server: ReturnType; url: string } { - const server = Bun.serve({ - port, - hostname: '127.0.0.1', - fetch(req) { - const url = new URL(req.url); - - // Echo endpoint — returns request headers as JSON - if (url.pathname === '/echo') { - const headers: Record = {}; - 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 - filePath = filePath.replace(/^\//, ''); - const fullPath = path.join(FIXTURES_DIR, filePath); - - if (!fs.existsSync(fullPath)) { - return new Response('Not Found', { status: 404 }); - } - - const content = fs.readFileSync(fullPath, 'utf-8'); - const ext = path.extname(fullPath); - const contentType = ext === '.html' ? 'text/html' : 'text/plain'; - - return new Response(content, { - headers: { 'Content-Type': contentType }, - }); - }, - }); - - const url = `http://127.0.0.1:${server.port}`; - return { server, url }; -} - -// If run directly, start and print URL -if (import.meta.main) { - const { server, url } = startTestServer(9450); - console.log(`Test server running at ${url}`); - console.log(`Fixtures: ${FIXTURES_DIR}`); - console.log('Press Ctrl+C to stop'); -} From b86882ed457ded8c1d4ce1fb13a785ab99333028 Mon Sep 17 00:00:00 2001 From: "dash / @da5ch0 / aliases" Date: Fri, 13 Mar 2026 20:07:40 -0600 Subject: [PATCH 20/20] Delete bin directory recompiled binaries. working now! --- bin/dev-setup | 36 ------------------------------------ bin/dev-teardown | 39 --------------------------------------- 2 files changed, 75 deletions(-) delete mode 100755 bin/dev-setup delete mode 100755 bin/dev-teardown diff --git a/bin/dev-setup b/bin/dev-setup deleted file mode 100755 index 709cca4..0000000 --- a/bin/dev-setup +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bash -# Set up gstack for local development — test skills from within this repo. -# -# Creates .claude/skills/gstack → (symlink to repo root) so Claude Code -# discovers skills from your working tree. Changes take effect immediately. -# -# Usage: bin/dev-setup # set up -# bin/dev-teardown # clean up -set -e - -REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" - -# 1. Create .claude/skills/ inside the repo -mkdir -p "$REPO_ROOT/.claude/skills" - -# 2. Symlink .claude/skills/gstack → repo root -# This makes setup think it's inside a real .claude/skills/ directory -GSTACK_LINK="$REPO_ROOT/.claude/skills/gstack" -if [ -L "$GSTACK_LINK" ]; then - echo "Updating existing symlink..." - rm "$GSTACK_LINK" -elif [ -d "$GSTACK_LINK" ]; then - echo "Error: .claude/skills/gstack is a real directory, not a symlink." >&2 - echo "Remove it manually if you want to use dev mode." >&2 - exit 1 -fi -ln -s "$REPO_ROOT" "$GSTACK_LINK" - -# 3. Run setup via the symlink so it detects .claude/skills/ as its parent -"$GSTACK_LINK/setup" - -echo "" -echo "Dev mode active. Skills resolve from this working tree." -echo "Edit any SKILL.md and test immediately — no copy/deploy needed." -echo "" -echo "To tear down: bin/dev-teardown" diff --git a/bin/dev-teardown b/bin/dev-teardown deleted file mode 100755 index e333a75..0000000 --- a/bin/dev-teardown +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env bash -# Remove local dev skill symlinks. Restores global gstack as the active install. -set -e - -REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -SKILLS_DIR="$REPO_ROOT/.claude/skills" - -if [ ! -d "$SKILLS_DIR" ]; then - echo "Nothing to tear down — .claude/skills/ doesn't exist." - exit 0 -fi - -# Remove individual skill symlinks -removed=() -for link in "$SKILLS_DIR"/*/; do - name="$(basename "$link")" - [ "$name" = "gstack" ] && continue - if [ -L "${link%/}" ]; then - rm "${link%/}" - removed+=("$name") - fi -done - -# Remove the gstack symlink -if [ -L "$SKILLS_DIR/gstack" ]; then - rm "$SKILLS_DIR/gstack" - removed+=("gstack") -fi - -# Clean up empty dirs -rmdir "$SKILLS_DIR" 2>/dev/null || true -rmdir "$REPO_ROOT/.claude" 2>/dev/null || true - -if [ ${#removed[@]} -gt 0 ]; then - echo "Removed: ${removed[*]}" -else - echo "No symlinks found." -fi -echo "Dev mode deactivated. Global gstack (~/.claude/skills/gstack) is now active."