Skip to content
Open
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
2 changes: 1 addition & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -902,7 +902,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
| `type <text>` | Type into focused element |
| `upload <sel> <file> [file2...]` | Upload file(s) |
| `useragent <string>` | Set user agent |
| `viewport [<WxH>] [--scale <n>]` | Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). --scale requires a context rebuild. |
| `viewport [<WxH>|auto] [--scale <n>]` | Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). --scale requires a context rebuild. Use `auto` (alias `reset`/`unpin`) to clear a pinned size and follow the window again. |
| `wait <sel|--networkidle|--load>` | Wait for element, network idle, or page load (timeout: 15s) |

### Inspection
Expand Down
3 changes: 2 additions & 1 deletion browse/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,7 @@ $B js "document.body.textContent.includes('Success')"
$B responsive /tmp/layout # mobile + tablet + desktop screenshots
$B viewport 375x812 # or set specific viewport
$B screenshot /tmp/mobile.png
$B viewport auto # unpin: viewport follows the window again (no restart)
```

### 8. Test file uploads
Expand Down Expand Up @@ -860,7 +861,7 @@ $B prettyscreenshot --cleanup --scroll-to ".pricing" --width 1440 ~/Desktop/hero
| `type <text>` | Type into focused element |
| `upload <sel> <file> [file2...]` | Upload file(s) |
| `useragent <string>` | Set user agent |
| `viewport [<WxH>] [--scale <n>]` | Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). --scale requires a context rebuild. |
| `viewport [<WxH>|auto] [--scale <n>]` | Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). --scale requires a context rebuild. Use `auto` (alias `reset`/`unpin`) to clear a pinned size and follow the window again. |
| `wait <sel|--networkidle|--load>` | Wait for element, network idle, or page load (timeout: 15s) |

### Inspection
Expand Down
1 change: 1 addition & 0 deletions browse/SKILL.md.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ $B js "document.body.textContent.includes('Success')"
$B responsive /tmp/layout # mobile + tablet + desktop screenshots
$B viewport 375x812 # or set specific viewport
$B screenshot /tmp/mobile.png
$B viewport auto # unpin: viewport follows the window again (no restart)
```

### 8. Test file uploads
Expand Down
81 changes: 69 additions & 12 deletions browse/src/browser-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,11 @@ export class BrowserManager {
// track the latest so context recreation restores it instead of hardcoding 1280x720.
private deviceScaleFactor: number = 1;
private currentViewport: { width: number; height: number } = { width: 1280, height: 720 };
// When true, contexts are (re)created with Playwright `viewport: null` so the
// viewport follows the real window size instead of being pinned to
// currentViewport. Set by `viewport auto`; cleared whenever an explicit size
// or deviceScaleFactor is applied. recreateContext() honors it.
private viewportAuto: boolean = false;

/** Server port — set after server starts, used by cookie-import-browser command */
public serverPort: number = 0;
Expand Down Expand Up @@ -394,10 +399,7 @@ export class BrowserManager {
void handleChromiumDisconnect(this.browser);
});

const contextOptions: BrowserContextOptions = {
viewport: { width: this.currentViewport.width, height: this.currentViewport.height },
deviceScaleFactor: this.deviceScaleFactor,
};
const contextOptions: BrowserContextOptions = this.viewportContextOptions();
if (this.customUserAgent) {
contextOptions.userAgent = this.customUserAgent;
}
Expand Down Expand Up @@ -1226,9 +1228,51 @@ export class BrowserManager {
// ─── Viewport ──────────────────────────────────────────────
async setViewport(width: number, height: number) {
this.currentViewport = { width, height };
// An explicit size pins the viewport, so leave window-follow ("auto") mode.
this.viewportAuto = false;
await this.getPage().setViewportSize({ width, height });
}

/**
* Restore window-following viewport (Playwright `viewport: null`), undoing a
* prior `viewport WxH` pin without tearing down the Chrome session. Rebuilds
* the context (state, cookies, tab URLs preserved) the same way a
* `viewport --scale` change does. deviceScaleFactor resets to 1 because a
* non-1 scale requires a concrete viewport size — `viewport: null` and a
* custom scale are mutually exclusive in Playwright.
*
* Returns null on success, or an error string if the new context couldn't be
* built (state may have been lost, per recreateContext's fallback behavior).
*/
async resetViewportToAuto(): Promise<string | null> {
if (this.connectionMode === 'headed') {
throw new Error('viewport auto is not supported in headed mode — the viewport already follows the real browser window.');
}
if (this.viewportAuto) {
return null; // already following the window; nothing to rebuild
}

const prevAuto = this.viewportAuto;
const prevScale = this.deviceScaleFactor;
this.viewportAuto = true;
this.deviceScaleFactor = 1;

const err = await this.recreateContext();
if (err !== null) {
// recreateContext's fallback built a blank context using the NEW (auto)
// settings. Roll the tracked fields back, then force a second recreate so
// live state matches tracked state (mirrors setDeviceScaleFactor).
this.viewportAuto = prevAuto;
this.deviceScaleFactor = prevScale;
const rollbackErr = await this.recreateContext();
if (rollbackErr !== null) {
return `${err} (rollback also encountered: ${rollbackErr})`;
}
return err;
}
return null;
}

// ─── Extra Headers ─────────────────────────────────────────
async setExtraHeader(name: string, value: string) {
this.extraHeaders[name] = value;
Expand Down Expand Up @@ -1402,6 +1446,21 @@ export class BrowserManager {
this.clearRefs();
}

/**
* Viewport/scale slice of the context options, honoring window-follow mode.
* In `viewportAuto` mode we pass `viewport: null` (and no deviceScaleFactor,
* which Playwright forbids alongside a null viewport) so the page tracks the
* real window size; otherwise we pin currentViewport + deviceScaleFactor.
*/
private viewportContextOptions(): BrowserContextOptions {
return this.viewportAuto
? { viewport: null }
: {
viewport: { width: this.currentViewport.width, height: this.currentViewport.height },
deviceScaleFactor: this.deviceScaleFactor,
};
}

/**
* Recreate the browser context to apply user agent changes.
* Saves and restores cookies, localStorage, sessionStorage, and open pages.
Expand All @@ -1428,10 +1487,7 @@ export class BrowserManager {
await this.context.close().catch(() => {});

// 3. Create new context with updated settings
const contextOptions: BrowserContextOptions = {
viewport: { width: this.currentViewport.width, height: this.currentViewport.height },
deviceScaleFactor: this.deviceScaleFactor,
};
const contextOptions: BrowserContextOptions = this.viewportContextOptions();
if (this.customUserAgent) {
contextOptions.userAgent = this.customUserAgent;
}
Expand All @@ -1452,10 +1508,7 @@ export class BrowserManager {
this.tabSessions.clear();
if (this.context) await this.context.close().catch(() => {});

const contextOptions: BrowserContextOptions = {
viewport: { width: this.currentViewport.width, height: this.currentViewport.height },
deviceScaleFactor: this.deviceScaleFactor,
};
const contextOptions: BrowserContextOptions = this.viewportContextOptions();
if (this.customUserAgent) {
contextOptions.userAgent = this.customUserAgent;
}
Expand Down Expand Up @@ -1493,8 +1546,11 @@ export class BrowserManager {

const prevScale = this.deviceScaleFactor;
const prevViewport = { ...this.currentViewport };
const prevAuto = this.viewportAuto;
this.deviceScaleFactor = scale;
this.currentViewport = { width, height };
// An explicit scale pins a concrete viewport, so leave window-follow mode.
this.viewportAuto = false;

const err = await this.recreateContext();
if (err !== null) {
Expand All @@ -1505,6 +1561,7 @@ export class BrowserManager {
// so live state matches tracked state.
this.deviceScaleFactor = prevScale;
this.currentViewport = prevViewport;
this.viewportAuto = prevAuto;
const rollbackErr = await this.recreateContext();
if (rollbackErr !== null) {
// Second recreate also failed — we're in a clean blank slate via fallback, but
Expand Down
2 changes: 1 addition & 1 deletion browse/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1006,7 +1006,7 @@ Navigation: goto <url> | back | forward | reload | url
Content: text | html [sel] | links | forms | accessibility
Interaction: click <sel> | fill <sel> <val> | select <sel> <val>
hover <sel> | type <text> | press <key>
scroll [sel] | wait <sel|--networkidle|--load> | viewport <WxH>
scroll [sel] | wait <sel|--networkidle|--load> | viewport <WxH|auto>
upload <sel> <file1> [file2...]
cookie-import <json-file>
cookie-import-browser [browser] [--domain <d>]
Expand Down
2 changes: 1 addition & 1 deletion browse/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
'scroll': { category: 'Interaction', description: 'With a selector, smooth-scrolls the element into view. Without a selector, jumps to page bottom. No --by/--to amount option; for pixel-precise scrolling use `js window.scrollTo(0, N)`.', usage: 'scroll [sel|@ref]' },
'wait': { category: 'Interaction', description: 'Wait for element, network idle, or page load (timeout: 15s)', usage: 'wait <sel|--networkidle|--load>' },
'upload': { category: 'Interaction', description: 'Upload file(s)', usage: 'upload <sel> <file> [file2...]' },
'viewport':{ category: 'Interaction', description: 'Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). --scale requires a context rebuild.', usage: 'viewport [<WxH>] [--scale <n>]' },
'viewport':{ category: 'Interaction', description: 'Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). --scale requires a context rebuild. Use `auto` (alias `reset`/`unpin`) to clear a pinned size and follow the window again.', usage: 'viewport [<WxH>|auto] [--scale <n>]' },
'cookie': { category: 'Interaction', description: 'Set cookie on current page domain', usage: 'cookie <name>=<value>' },
'cookie-import': { category: 'Interaction', description: 'Import cookies from JSON file', usage: 'cookie-import <json>' },
'cookie-import-browser': { category: 'Interaction', description: 'Import cookies from installed Chromium browsers (opens picker, or use --domain for direct import)', usage: 'cookie-import-browser [browser] [--domain d]' },
Expand Down
13 changes: 12 additions & 1 deletion browse/src/write-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,18 @@ export async function handleWriteCommand(
}

if (sizeArg === undefined && scaleArg === undefined) {
throw new Error('Usage: browse viewport [<WxH>] [--scale <n>] (e.g. 375x812, or --scale 2 to keep current size)');
throw new Error('Usage: browse viewport [<WxH>|auto] [--scale <n>] (e.g. 375x812, auto to unpin, or --scale 2 to keep current size)');
}

// `viewport auto` (aliases `reset`/`unpin`): clear a prior WxH pin and
// restore window-following without tearing down the session.
if (sizeArg === 'auto' || sizeArg === 'reset' || sizeArg === 'unpin') {
if (scaleArg !== undefined) {
throw new Error('viewport auto cannot be combined with --scale (a custom scale needs a concrete viewport size).');
}
const err = await bm.resetViewportToAuto();
if (err) return `Viewport partially reset: ${err}`;
return 'Viewport unpinned: now follows the window (auto); device scale reset to 1x.';
}

// Resolve width/height: either from sizeArg or from current viewport if --scale-only.
Expand Down
50 changes: 50 additions & 0 deletions browse/test/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2348,6 +2348,56 @@ describe('viewport --scale', () => {
});
});

// ─── viewport auto / reset / unpin (#1059) ──────────────────────

describe('viewport auto (unpin)', () => {
test('viewport auto clears a pinned size and restores window-following', async () => {
// Pin a size: Playwright reports the emulated viewport.
await handleWriteCommand('viewport', ['375x812'], bm);
expect(bm.getPage().viewportSize()).toEqual({ width: 375, height: 812 });

// Unpin: the recreated context uses `viewport: null`, so viewportSize() is null.
const result = await handleWriteCommand('viewport', ['auto'], bm);
expect(result).toContain('unpinned');
expect(bm.getPage().viewportSize()).toBeNull();

// Reset for following tests.
await handleWriteCommand('viewport', ['1280x720'], bm);
});

test('reset and unpin are accepted aliases', async () => {
await handleWriteCommand('viewport', ['400x300'], bm);
await handleWriteCommand('viewport', ['reset'], bm);
expect(bm.getPage().viewportSize()).toBeNull();

await handleWriteCommand('viewport', ['200x200'], bm);
await handleWriteCommand('viewport', ['unpin'], bm);
expect(bm.getPage().viewportSize()).toBeNull();

await handleWriteCommand('viewport', ['1280x720'], bm);
});

test('viewport auto resets deviceScaleFactor to 1', async () => {
await handleWriteCommand('viewport', ['200x200', '--scale', '2'], bm);
expect(bm.getDeviceScaleFactor()).toBe(2);

await handleWriteCommand('viewport', ['auto'], bm);
expect(bm.getDeviceScaleFactor()).toBe(1);
expect(bm.getPage().viewportSize()).toBeNull();

await handleWriteCommand('viewport', ['1280x720', '--scale', '1'], bm);
});

test('viewport auto cannot be combined with --scale', async () => {
try {
await handleWriteCommand('viewport', ['auto', '--scale', '2'], bm);
expect(true).toBe(false);
} catch (err: any) {
expect(err.message).toMatch(/cannot be combined with --scale/);
}
});
});

// ─── setContent replay across context recreation ────────────────

describe('setContent replay (load-html survives viewport --scale)', () => {
Expand Down
2 changes: 1 addition & 1 deletion gstack/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ Run with `browse <command> [args]`. Full reference: `browse/SKILL.md`.
- `type <text>`: Type into focused element
- `upload <sel> <file> [file2...]`: Upload file(s)
- `useragent <string>`: Set user agent
- `viewport [<WxH>] [--scale <n>]`: Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots).
- `viewport [<WxH>|auto] [--scale <n>]`: Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots).
- `wait <sel|--networkidle|--load>`: Wait for element, network idle, or page load (timeout: 15s)

### Meta
Expand Down
Loading