Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion BROWSER.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ This document covers the command reference and internals of gstack's headless br
| 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 |
| Visual | `screenshot [--viewport] [--clip x,y,w,h] [sel\|@ref] [path]`, `pdf`, `responsive` | See what Claude sees |
| Compare | `diff <url1> <url2>` | Spot differences between environments |
| Dialogs | `dialog-accept [text]`, `dialog-dismiss` | Control alert/confirm/prompt handling |
| Tabs | `tabs`, `tab`, `newtab`, `closetab` | Multi-page workflows |
Expand Down Expand Up @@ -92,6 +92,21 @@ No DOM mutation. No injected scripts. Just Playwright's native accessibility API
- `--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 <path>` 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.

### Screenshot modes

The `screenshot` command supports four modes:

| Mode | Syntax | Playwright API |
|------|--------|----------------|
| Full page (default) | `screenshot [path]` | `page.screenshot({ fullPage: true })` |
| Viewport only | `screenshot --viewport [path]` | `page.screenshot({ fullPage: false })` |
| Element crop | `screenshot "#sel" [path]` or `screenshot @e3 [path]` | `locator.screenshot()` |
| Region clip | `screenshot --clip x,y,w,h [path]` | `page.screenshot({ clip })` |

Element crop accepts CSS selectors (`.class`, `#id`, `[attr]`) or `@e`/`@c` refs from `snapshot`. Auto-detection: `@e`/`@c` prefix = ref, `.`/`#`/`[` prefix = CSS selector, `--` prefix = flag, everything else = output path.

Mutual exclusion: `--clip` + selector and `--viewport` + `--clip` both throw errors. Unknown flags (e.g. `--bogus`) also throw.

### 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 <token>`. This prevents other processes on the machine from controlling the browser.
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 0.3.7 — 2026-03-14

### Added
- **Screenshot element/region clipping**`screenshot` command now supports element crop via CSS selector or @ref (`screenshot "#hero" out.png`, `screenshot @e3 out.png`), region clip (`screenshot --clip x,y,w,h out.png`), and viewport-only mode (`screenshot --viewport out.png`). Uses Playwright's native `locator.screenshot()` and `page.screenshot({ clip })`. Full page remains the default.
- 10 new tests covering all screenshot modes (viewport, CSS, @ref, clip) and error paths (unknown flag, mutual exclusion, invalid coords, path validation, nonexistent selector).

## 0.3.6 — 2026-03-14

### Added
Expand Down
13 changes: 12 additions & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,17 @@ $B viewport 375x812 # iPhone
$B screenshot /tmp/mobile.png
$B viewport 1440x900 # Desktop
$B screenshot /tmp/desktop.png

# Element screenshot (crop to specific element)
$B screenshot "#hero-banner" /tmp/hero.png
$B snapshot -i
$B screenshot @e3 /tmp/button.png

# Region crop
$B screenshot --clip 0,0,800,600 /tmp/above-fold.png

# Viewport only (no scroll)
$B screenshot --viewport /tmp/viewport.png
```

### Test file upload
Expand Down Expand Up @@ -337,7 +348,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
| `diff <url1> <url2>` | Text diff between pages |
| `pdf [path]` | Save as PDF |
| `responsive [prefix]` | Screenshots at mobile (375x812), tablet (768x1024), desktop (1280x720). Saves as {prefix}-mobile.png etc. |
| `screenshot [path]` | Save screenshot |
| `screenshot [--viewport] [--clip x,y,w,h] [selector|@ref] [path]` | Save screenshot (supports element crop via CSS/@ref, --clip region, --viewport) |

### Snapshot
| Command | Description |
Expand Down
11 changes: 11 additions & 0 deletions SKILL.md.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,17 @@ $B viewport 375x812 # iPhone
$B screenshot /tmp/mobile.png
$B viewport 1440x900 # Desktop
$B screenshot /tmp/desktop.png
# Element screenshot (crop to specific element)
$B screenshot "#hero-banner" /tmp/hero.png
$B snapshot -i
$B screenshot @e3 /tmp/button.png
# Region crop
$B screenshot --clip 0,0,800,600 /tmp/above-fold.png
# Viewport only (no scroll)
$B screenshot --viewport /tmp/viewport.png
```

### Test file upload
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.3.6
0.3.7
2 changes: 1 addition & 1 deletion browse/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
| `diff <url1> <url2>` | Text diff between pages |
| `pdf [path]` | Save as PDF |
| `responsive [prefix]` | Screenshots at mobile (375x812), tablet (768x1024), desktop (1280x720). Saves as {prefix}-mobile.png etc. |
| `screenshot [path]` | Save screenshot |
| `screenshot [--viewport] [--clip x,y,w,h] [selector|@ref] [path]` | Save screenshot (supports element crop via CSS/@ref, --clip region, --viewport) |

### Snapshot
| Command | Description |
Expand Down
3 changes: 2 additions & 1 deletion browse/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,8 @@ Inspection: js <expr> | eval <file> | css <sel> <prop> | attrs <sel>
console [--clear|--errors] | network [--clear] | dialog [--clear]
cookies | storage [set <k> <v>] | perf
is <prop> <sel> (visible|hidden|enabled|disabled|checked|editable|focused)
Visual: screenshot [path] | pdf [path] | responsive [prefix]
Visual: screenshot [--viewport] [--clip x,y,w,h] [@ref|sel] [path]
pdf [path] | responsive [prefix]
Snapshot: snapshot [-i] [-c] [-d N] [-s sel] [-D] [-a] [-o path] [-C]
-D/--diff: diff against previous snapshot
-a/--annotate: annotated screenshot with ref labels
Expand Down
2 changes: 1 addition & 1 deletion browse/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
'dialog-accept': { category: 'Interaction', description: 'Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response', usage: 'dialog-accept [text]' },
'dialog-dismiss': { category: 'Interaction', description: 'Auto-dismiss next dialog' },
// Visual
'screenshot': { category: 'Visual', description: 'Save screenshot', usage: 'screenshot [path]' },
'screenshot': { category: 'Visual', description: 'Save screenshot (supports element crop via CSS/@ref, --clip region, --viewport)', usage: 'screenshot [--viewport] [--clip x,y,w,h] [selector|@ref] [path]' },
'pdf': { category: 'Visual', description: 'Save as PDF', usage: 'pdf [path]' },
'responsive': { category: 'Visual', description: 'Screenshots at mobile (375x812), tablet (768x1024), desktop (1280x720). Saves as {prefix}-mobile.png etc.', usage: 'responsive [prefix]' },
'diff': { category: 'Visual', description: 'Text diff between pages', usage: 'diff <url1> <url2>' },
Expand Down
60 changes: 56 additions & 4 deletions browse/src/meta-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,63 @@ export async function handleMetaCommand(

// ─── Visual ────────────────────────────────────────
case 'screenshot': {
// Parse priority: flags (--viewport, --clip) → selector (@ref, CSS) → output path
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}`;
let outputPath = '/tmp/browse-screenshot.png';
let clipRect: { x: number; y: number; width: number; height: number } | undefined;
let targetSelector: string | undefined;
let viewportOnly = false;

const remaining: string[] = [];
for (let i = 0; i < args.length; i++) {
if (args[i] === '--viewport') {
viewportOnly = true;
} else if (args[i] === '--clip') {
const coords = args[++i];
if (!coords) throw new Error('Usage: screenshot --clip x,y,w,h [path]');
const parts = coords.split(',').map(Number);
if (parts.length !== 4 || parts.some(isNaN))
throw new Error('Usage: screenshot --clip x,y,width,height — all must be numbers');
clipRect = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
} else if (args[i].startsWith('--')) {
throw new Error(`Unknown screenshot flag: ${args[i]}`);
} else {
remaining.push(args[i]);
}
}

// Separate target (selector/@ref) from output path
for (const arg of remaining) {
if (arg.startsWith('@e') || arg.startsWith('@c') || arg.startsWith('.') || arg.startsWith('#') || arg.includes('[')) {
targetSelector = arg;
} else {
outputPath = arg;
}
}

validateOutputPath(outputPath);

if (clipRect && targetSelector) {
throw new Error('Cannot use --clip with a selector/ref — choose one');
}
if (viewportOnly && clipRect) {
throw new Error('Cannot use --viewport with --clip — choose one');
}

if (targetSelector) {
const resolved = bm.resolveRef(targetSelector);
const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector);
await locator.screenshot({ path: outputPath, timeout: 5000 });
return `Screenshot saved (element): ${outputPath}`;
}

if (clipRect) {
await page.screenshot({ path: outputPath, clip: clipRect });
return `Screenshot saved (clip ${clipRect.x},${clipRect.y},${clipRect.width},${clipRect.height}): ${outputPath}`;
}

await page.screenshot({ path: outputPath, fullPage: !viewportOnly });
return `Screenshot saved${viewportOnly ? ' (viewport)' : ''}: ${outputPath}`;
}

case 'pdf': {
Expand Down
101 changes: 101 additions & 0 deletions browse/test/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,107 @@ describe('Visual', () => {
fs.unlinkSync(screenshotPath);
});

test('screenshot --viewport saves viewport-only', async () => {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
const p = '/tmp/browse-test-viewport.png';
const result = await handleMetaCommand('screenshot', ['--viewport', p], bm, async () => {});
expect(result).toContain('Screenshot saved (viewport)');
expect(fs.existsSync(p)).toBe(true);
expect(fs.statSync(p).size).toBeGreaterThan(1000);
fs.unlinkSync(p);
});

test('screenshot with CSS selector crops to element', async () => {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
const p = '/tmp/browse-test-element-css.png';
const result = await handleMetaCommand('screenshot', ['#title', p], bm, async () => {});
expect(result).toContain('Screenshot saved (element)');
expect(fs.existsSync(p)).toBe(true);
expect(fs.statSync(p).size).toBeGreaterThan(100);
fs.unlinkSync(p);
});

test('screenshot with @ref crops to element', async () => {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
await handleMetaCommand('snapshot', [], bm, async () => {});
const p = '/tmp/browse-test-element-ref.png';
const result = await handleMetaCommand('screenshot', ['@e1', p], bm, async () => {});
expect(result).toContain('Screenshot saved (element)');
expect(fs.existsSync(p)).toBe(true);
expect(fs.statSync(p).size).toBeGreaterThan(100);
fs.unlinkSync(p);
});

test('screenshot --clip crops to region', async () => {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
const p = '/tmp/browse-test-clip.png';
const result = await handleMetaCommand('screenshot', ['--clip', '0,0,100,100', p], bm, async () => {});
expect(result).toContain('Screenshot saved (clip 0,0,100,100)');
expect(fs.existsSync(p)).toBe(true);
expect(fs.statSync(p).size).toBeGreaterThan(100);
fs.unlinkSync(p);
});

test('screenshot --clip + selector throws', async () => {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
try {
await handleMetaCommand('screenshot', ['--clip', '0,0,100,100', '#title'], bm, async () => {});
expect(true).toBe(false);
} catch (err: any) {
expect(err.message).toContain('Cannot use --clip with a selector/ref');
}
});

test('screenshot --viewport + --clip throws', async () => {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
try {
await handleMetaCommand('screenshot', ['--viewport', '--clip', '0,0,100,100'], bm, async () => {});
expect(true).toBe(false);
} catch (err: any) {
expect(err.message).toContain('Cannot use --viewport with --clip');
}
});

test('screenshot --clip with invalid coords throws', async () => {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
try {
await handleMetaCommand('screenshot', ['--clip', 'abc'], bm, async () => {});
expect(true).toBe(false);
} catch (err: any) {
expect(err.message).toContain('all must be numbers');
}
});

test('screenshot unknown flag throws', async () => {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
try {
await handleMetaCommand('screenshot', ['--bogus', '/tmp/foo.png'], bm, async () => {});
expect(true).toBe(false);
} catch (err: any) {
expect(err.message).toContain('Unknown screenshot flag');
}
});

test('screenshot --viewport still validates path', async () => {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
try {
await handleMetaCommand('screenshot', ['--viewport', '/etc/evil.png'], bm, async () => {});
expect(true).toBe(false);
} catch (err: any) {
expect(err.message).toContain('Path must be within');
}
});

test('screenshot with nonexistent selector throws timeout', async () => {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
try {
await handleMetaCommand('screenshot', ['.nonexistent-element-xyz'], bm, async () => {});
expect(true).toBe(false);
} catch (err: any) {
expect(err.message).toBeDefined();
}
}, 10000);

test('responsive saves 3 screenshots', async () => {
await handleWriteCommand('goto', [baseUrl + '/responsive.html'], bm);
const prefix = '/tmp/browse-test-resp';
Expand Down