diff --git a/SKILL.md b/SKILL.md index 60405f27de..276c73cd6b 100644 --- a/SKILL.md +++ b/SKILL.md @@ -902,7 +902,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`. | `type ` | Type into focused element | | `upload [file2...]` | Upload file(s) | | `useragent ` | Set user agent | -| `viewport [] [--scale ]` | Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). --scale requires a context rebuild. | +| `viewport [|auto] [--scale ]` | 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 ` | Wait for element, network idle, or page load (timeout: 15s) | ### Inspection diff --git a/browse/SKILL.md b/browse/SKILL.md index ef562d6c12..d669f4595b 100644 --- a/browse/SKILL.md +++ b/browse/SKILL.md @@ -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 @@ -860,7 +861,7 @@ $B prettyscreenshot --cleanup --scroll-to ".pricing" --width 1440 ~/Desktop/hero | `type ` | Type into focused element | | `upload [file2...]` | Upload file(s) | | `useragent ` | Set user agent | -| `viewport [] [--scale ]` | Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). --scale requires a context rebuild. | +| `viewport [|auto] [--scale ]` | 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 ` | Wait for element, network idle, or page load (timeout: 15s) | ### Inspection diff --git a/browse/SKILL.md.tmpl b/browse/SKILL.md.tmpl index a466fc4468..5715fa80bc 100644 --- a/browse/SKILL.md.tmpl +++ b/browse/SKILL.md.tmpl @@ -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 diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 2bc1c597db..8e5cbce794 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -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; @@ -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; } @@ -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 { + 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; @@ -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. @@ -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; } @@ -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; } @@ -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) { @@ -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 diff --git a/browse/src/cli.ts b/browse/src/cli.ts index 59327b7923..8861a7f641 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -1006,7 +1006,7 @@ Navigation: goto | back | forward | reload | url Content: text | html [sel] | links | forms | accessibility Interaction: click | fill | select hover | type | press - scroll [sel] | wait | viewport + scroll [sel] | wait | viewport upload [file2...] cookie-import cookie-import-browser [browser] [--domain ] diff --git a/browse/src/commands.ts b/browse/src/commands.ts index 7e647a0028..87e670fb19 100644 --- a/browse/src/commands.ts +++ b/browse/src/commands.ts @@ -127,7 +127,7 @@ export const COMMAND_DESCRIPTIONS: Record' }, 'upload': { category: 'Interaction', description: 'Upload file(s)', usage: 'upload [file2...]' }, - 'viewport':{ category: 'Interaction', description: 'Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). --scale requires a context rebuild.', usage: 'viewport [] [--scale ]' }, + '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 [|auto] [--scale ]' }, 'cookie': { category: 'Interaction', description: 'Set cookie on current page domain', usage: 'cookie =' }, 'cookie-import': { category: 'Interaction', description: 'Import cookies from JSON file', usage: 'cookie-import ' }, '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]' }, diff --git a/browse/src/write-commands.ts b/browse/src/write-commands.ts index 4a847141d2..2ee1283d65 100644 --- a/browse/src/write-commands.ts +++ b/browse/src/write-commands.ts @@ -523,7 +523,18 @@ export async function handleWriteCommand( } if (sizeArg === undefined && scaleArg === undefined) { - throw new Error('Usage: browse viewport [] [--scale ] (e.g. 375x812, or --scale 2 to keep current size)'); + throw new Error('Usage: browse viewport [|auto] [--scale ] (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. diff --git a/browse/test/commands.test.ts b/browse/test/commands.test.ts index b3870c0ccf..f03281c34f 100644 --- a/browse/test/commands.test.ts +++ b/browse/test/commands.test.ts @@ -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)', () => { diff --git a/gstack/llms.txt b/gstack/llms.txt index a11b045d17..a59e7cd7b4 100644 --- a/gstack/llms.txt +++ b/gstack/llms.txt @@ -108,7 +108,7 @@ Run with `browse [args]`. Full reference: `browse/SKILL.md`. - `type `: Type into focused element - `upload [file2...]`: Upload file(s) - `useragent `: Set user agent -- `viewport [] [--scale ]`: Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). +- `viewport [|auto] [--scale ]`: Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). - `wait `: Wait for element, network idle, or page load (timeout: 15s) ### Meta