diff --git a/SKILL.md b/SKILL.md index 75ce5b8f2..52d75b414 100644 --- a/SKILL.md +++ b/SKILL.md @@ -591,6 +591,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`. | `cookie =` | Set cookie on current page domain | | `cookie-import ` | Import cookies from JSON file | | `cookie-import-browser [browser] [--domain d]` | Import cookies from Comet, Chrome, Arc, Brave, or Edge (opens picker, or use --domain for direct import) | +| `device ` | Apply Playwright device profile (viewport, user-agent, DPR, touch). Use "list" to show 143 profiles, "clear" to reset | | `dialog-accept [text]` | Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response | | `dialog-dismiss` | Auto-dismiss next dialog | | `fill ` | Fill input | diff --git a/browse/SKILL.md b/browse/SKILL.md index 901135fa8..0683082e1 100644 --- a/browse/SKILL.md +++ b/browse/SKILL.md @@ -488,6 +488,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`. | `cookie =` | Set cookie on current page domain | | `cookie-import ` | Import cookies from JSON file | | `cookie-import-browser [browser] [--domain d]` | Import cookies from Comet, Chrome, Arc, Brave, or Edge (opens picker, or use --domain for direct import) | +| `device ` | Apply Playwright device profile (viewport, user-agent, DPR, touch). Use "list" to show 143 profiles, "clear" to reset | | `dialog-accept [text]` | Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response | | `dialog-dismiss` | Auto-dismiss next dialog | | `fill ` | Fill input | diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 43ce4c969..36e739d29 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -42,6 +42,7 @@ export class BrowserManager { private nextTabId: number = 1; private extraHeaders: Record = {}; private customUserAgent: string | null = null; + private activeDevice: { viewport: { width: number; height: number }; userAgent: string; deviceScaleFactor: number; isMobile: boolean; hasTouch: boolean } | null = null; /** Server port — set after server starts, used by cookie-import-browser command */ public serverPort: number = 0; @@ -72,11 +73,16 @@ export class BrowserManager { }); const contextOptions: BrowserContextOptions = { - viewport: { width: 1280, height: 720 }, + viewport: this.activeDevice?.viewport ?? { width: 1280, height: 720 }, }; if (this.customUserAgent) { contextOptions.userAgent = this.customUserAgent; } + if (this.activeDevice) { + contextOptions.deviceScaleFactor = this.activeDevice.deviceScaleFactor; + contextOptions.isMobile = this.activeDevice.isMobile; + contextOptions.hasTouch = this.activeDevice.hasTouch; + } this.context = await this.browser.newContext(contextOptions); if (Object.keys(this.extraHeaders).length > 0) { @@ -275,6 +281,22 @@ export class BrowserManager { await this.getPage().setViewportSize({ width, height }); } + // ─── Device Emulation ───────────────────────────────────── + async setDevice(descriptor: { viewport: { width: number; height: number }; userAgent: string; deviceScaleFactor: number; isMobile: boolean; hasTouch: boolean } | null) { + this.activeDevice = descriptor; + if (descriptor) { + this.customUserAgent = descriptor.userAgent; + await this.getPage().setViewportSize(descriptor.viewport); + } else { + this.customUserAgent = null; + await this.getPage().setViewportSize({ width: 1280, height: 720 }); + } + } + + getDevice() { + return this.activeDevice; + } + // ─── Extra Headers ───────────────────────────────────────── async setExtraHeader(name: string, value: string) { this.extraHeaders[name] = value; @@ -401,11 +423,16 @@ export class BrowserManager { // 3. Create new context with updated settings const contextOptions: BrowserContextOptions = { - viewport: { width: 1280, height: 720 }, + viewport: this.activeDevice?.viewport ?? { width: 1280, height: 720 }, }; if (this.customUserAgent) { contextOptions.userAgent = this.customUserAgent; } + if (this.activeDevice) { + contextOptions.deviceScaleFactor = this.activeDevice.deviceScaleFactor; + contextOptions.isMobile = this.activeDevice.isMobile; + contextOptions.hasTouch = this.activeDevice.hasTouch; + } this.context = await this.browser.newContext(contextOptions); if (Object.keys(this.extraHeaders).length > 0) { @@ -423,11 +450,16 @@ export class BrowserManager { if (this.context) await this.context.close().catch(() => {}); const contextOptions: BrowserContextOptions = { - viewport: { width: 1280, height: 720 }, + viewport: this.activeDevice?.viewport ?? { width: 1280, height: 720 }, }; if (this.customUserAgent) { contextOptions.userAgent = this.customUserAgent; } + if (this.activeDevice) { + contextOptions.deviceScaleFactor = this.activeDevice.deviceScaleFactor; + contextOptions.isMobile = this.activeDevice.isMobile; + contextOptions.hasTouch = this.activeDevice.hasTouch; + } this.context = await this.browser!.newContext(contextOptions); await this.newTab(); this.clearRefs(); @@ -473,11 +505,16 @@ export class BrowserManager { // 3. Create context and restore state into new headed browser try { const contextOptions: BrowserContextOptions = { - viewport: { width: 1280, height: 720 }, + viewport: this.activeDevice?.viewport ?? { width: 1280, height: 720 }, }; if (this.customUserAgent) { contextOptions.userAgent = this.customUserAgent; } + if (this.activeDevice) { + contextOptions.deviceScaleFactor = this.activeDevice.deviceScaleFactor; + contextOptions.isMobile = this.activeDevice.isMobile; + contextOptions.hasTouch = this.activeDevice.hasTouch; + } const newContext = await newBrowser.newContext(contextOptions); if (Object.keys(this.extraHeaders).length > 0) { diff --git a/browse/src/commands.ts b/browse/src/commands.ts index c3509af11..3b0e1bace 100644 --- a/browse/src/commands.ts +++ b/browse/src/commands.ts @@ -20,7 +20,7 @@ export const READ_COMMANDS = new Set([ 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', + 'viewport', 'device', 'cookie', 'cookie-import', 'cookie-import-browser', 'header', 'useragent', 'upload', 'dialog-accept', 'dialog-dismiss', ]); @@ -71,6 +71,7 @@ export const COMMAND_DESCRIPTIONS: Record' }, 'upload': { category: 'Interaction', description: 'Upload file(s)', usage: 'upload [file2...]' }, 'viewport':{ category: 'Interaction', description: 'Set viewport size', usage: 'viewport ' }, + 'device': { category: 'Interaction', description: 'Apply Playwright device profile (viewport, user-agent, DPR, touch). Use "list" to show 143 profiles, "clear" to reset', usage: 'device ' }, '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 Comet, Chrome, Arc, Brave, or Edge (opens picker, or use --domain for direct import)', usage: 'cookie-import-browser [browser] [--domain d]' }, diff --git a/browse/src/meta-commands.ts b/browse/src/meta-commands.ts index 16ed7f849..012ab016d 100644 --- a/browse/src/meta-commands.ts +++ b/browse/src/meta-commands.ts @@ -7,6 +7,7 @@ import { handleSnapshot } from './snapshot'; import { getCleanText } from './read-commands'; import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands'; import { validateNavigationUrl } from './url-validation'; +import { devices } from 'playwright'; import * as Diff from 'diff'; import * as fs from 'fs'; import * as path from 'path'; @@ -158,24 +159,43 @@ export async function handleMetaCommand( const page = bm.getPage(); const prefix = args[0] || `${TEMP_DIR}/browse-responsive`; validateOutputPath(prefix); + const mobileDevice = devices['iPhone 15 Pro']; + const tabletDevice = devices['iPad (gen 7)']; const viewports = [ - { name: 'mobile', width: 375, height: 812 }, - { name: 'tablet', width: 768, height: 1024 }, - { name: 'desktop', width: 1280, height: 720 }, + { name: 'mobile', device: mobileDevice, width: mobileDevice.viewport.width, height: mobileDevice.viewport.height }, + { name: 'tablet', device: tabletDevice, width: tabletDevice.viewport.width, height: tabletDevice.viewport.height }, + { name: 'desktop', device: null, width: 1280, height: 720 }, ]; const originalViewport = page.viewportSize(); + const savedDevice = bm.getDevice(); const results: string[] = []; for (const vp of viewports) { - await page.setViewportSize({ width: vp.width, height: vp.height }); + if (vp.device) { + await bm.setDevice({ + viewport: vp.device.viewport, + userAgent: vp.device.userAgent, + deviceScaleFactor: vp.device.deviceScaleFactor, + isMobile: vp.device.isMobile, + hasTouch: vp.device.hasTouch, + }); + } else { + await bm.setDevice(null); + 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); + // Restore previous device/viewport + if (savedDevice) { + await bm.setDevice(savedDevice); + } else { + await bm.setDevice(null); + if (originalViewport) { + await page.setViewportSize(originalViewport); + } } return results.join('\n'); diff --git a/browse/src/write-commands.ts b/browse/src/write-commands.ts index 73b44ca72..a91c984e7 100644 --- a/browse/src/write-commands.ts +++ b/browse/src/write-commands.ts @@ -8,6 +8,7 @@ import type { BrowserManager } from './browser-manager'; import { findInstalledBrowsers, importCookies } from './cookie-import-browser'; import { validateNavigationUrl } from './url-validation'; +import { devices } from 'playwright'; import * as fs from 'fs'; import * as path from 'path'; import { TEMP_DIR, isPathWithin } from './platform'; @@ -196,6 +197,42 @@ export async function handleWriteCommand( return `Viewport set to ${w}x${h}`; } + case 'device': { + const subcommand = args[0]; + if (!subcommand) throw new Error('Usage: browse device '); + + if (subcommand === 'list') { + const names = Object.keys(devices); + return `${names.length} devices available:\n${names.join('\n')}`; + } + + if (subcommand === 'clear') { + await bm.setDevice(null); + return 'Device profile cleared. Using default viewport (1280x720).'; + } + + const name = args.join(' '); + const descriptor = devices[name]; + if (!descriptor) { + const match = Object.keys(devices).find(k => + k.toLowerCase().includes(name.toLowerCase()) + ); + if (match) { + throw new Error(`Device "${name}" not found. Did you mean "${match}"?`); + } + throw new Error(`Device "${name}" not found. Run "device list" to see available profiles.`); + } + + await bm.setDevice({ + viewport: descriptor.viewport, + userAgent: descriptor.userAgent, + deviceScaleFactor: descriptor.deviceScaleFactor, + isMobile: descriptor.isMobile, + hasTouch: descriptor.hasTouch, + }); + return `Device: ${name}\n Viewport: ${descriptor.viewport.width}x${descriptor.viewport.height}\n DPR: ${descriptor.deviceScaleFactor}\n Mobile: ${descriptor.isMobile}\n Touch: ${descriptor.hasTouch}\n UA: ${descriptor.userAgent.slice(0, 80)}...`; + } + case 'cookie': { const cookieStr = args[0]; if (!cookieStr || !cookieStr.includes('=')) throw new Error('Usage: browse cookie ='); diff --git a/docs/images/device-after-iphone.png b/docs/images/device-after-iphone.png new file mode 100644 index 000000000..0588a593b Binary files /dev/null and b/docs/images/device-after-iphone.png differ diff --git a/docs/images/device-before-desktop.png b/docs/images/device-before-desktop.png new file mode 100644 index 000000000..a4b33cafd Binary files /dev/null and b/docs/images/device-before-desktop.png differ