From d53bd78968421994fa9a0222e43cfcbf53833798 Mon Sep 17 00:00:00 2001 From: Benjamin Liu Date: Wed, 6 May 2026 04:21:09 +0900 Subject: [PATCH] feat(browser): add --width / --height / --full-page flags to screenshot Closes #1334. Exposes viewport overrides for `opencli browser screenshot` so an adapter or ad-hoc shell user can render a page at a fixed width and capture the full scrollable height. The ljg-card HTML to PNG pipeline use case. Behavior: - `--width W` only overrides device-metrics width; height is left unchanged. - `--height H` only overrides height (ignored under `--full-page`). - `--full-page` keeps the existing `captureBeyondViewport` shortcut. - `--full-page --width W` first reflows at W, then re-overrides to (W, contentH) so the captured image reflects the layout at the requested width. - Override is always cleared in `finally`, including on capture failure. --- extension/dist/background.js | 36 ++++++-- extension/src/background.ts | 2 + extension/src/cdp.test.ts | 155 +++++++++++++++++++++++++++++++++++ extension/src/cdp.ts | 48 +++++++---- extension/src/protocol.ts | 4 + src/browser/cdp.ts | 60 ++++++++++++-- src/browser/daemon-client.ts | 4 + src/browser/page.test.ts | 36 ++++++++ src/browser/page.ts | 2 + src/cli.ts | 28 ++++++- src/types.ts | 4 + 11 files changed, 342 insertions(+), 37 deletions(-) diff --git a/extension/dist/background.js b/extension/dist/background.js index 2eef70fb2..1c91b7791 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -119,17 +119,35 @@ const evaluateAsync = evaluate; async function screenshot(tabId, options = {}) { await ensureAttached(tabId); const format = options.format ?? "png"; - if (options.fullPage) { - const metrics = await chrome.debugger.sendCommand({ tabId }, "Page.getLayoutMetrics"); - const size = metrics.cssContentSize || metrics.contentSize; - if (size) { + const fullPage = options.fullPage === true; + const overrideWidth = options.width && options.width > 0 ? Math.ceil(options.width) : void 0; + const overrideHeight = !fullPage && options.height && options.height > 0 ? Math.ceil(options.height) : void 0; + const needsOverride = fullPage || overrideWidth !== void 0 || overrideHeight !== void 0; + if (needsOverride) { + if (overrideWidth !== void 0 && fullPage) { await chrome.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", { mobile: false, - width: Math.ceil(size.width), - height: Math.ceil(size.height), + width: overrideWidth, + height: 0, deviceScaleFactor: 1 }); } + let finalWidth = overrideWidth ?? 0; + let finalHeight = overrideHeight ?? 0; + if (fullPage) { + const metrics = await chrome.debugger.sendCommand({ tabId }, "Page.getLayoutMetrics"); + const size = metrics.cssContentSize || metrics.contentSize; + if (size) { + if (finalWidth === 0) finalWidth = Math.ceil(size.width); + finalHeight = Math.ceil(size.height); + } + } + await chrome.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", { + mobile: false, + width: finalWidth, + height: finalHeight, + deviceScaleFactor: 1 + }); } try { const params = { format }; @@ -139,7 +157,7 @@ async function screenshot(tabId, options = {}) { const result = await chrome.debugger.sendCommand({ tabId }, "Page.captureScreenshot", params); return result.data; } finally { - if (options.fullPage) { + if (needsOverride) { await chrome.debugger.sendCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => { }); } @@ -1434,7 +1452,9 @@ async function handleScreenshot(cmd, workspace) { const data = await screenshot(tabId, { format: cmd.format, quality: cmd.quality, - fullPage: cmd.fullPage + fullPage: cmd.fullPage, + width: cmd.width, + height: cmd.height }); return pageScopedResult(cmd.id, tabId, data); } catch (err) { diff --git a/extension/src/background.ts b/extension/src/background.ts index c546e59a0..3dbe52080 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -1267,6 +1267,8 @@ async function handleScreenshot(cmd: Command, workspace: string): Promise { }); }); + +function chromeMockForScreenshot(content: { width: number; height: number } = { width: 1024, height: 2048 }) { + const calls: Array<{ method: string; params?: unknown }> = []; + const debuggerApi = { + attach: vi.fn(async () => {}), + detach: vi.fn(async () => {}), + sendCommand: vi.fn(async (_target: unknown, method: string, params?: unknown) => { + calls.push({ method, params }); + if (method === 'Page.captureScreenshot') return { data: 'BASE64DATA' }; + if (method === 'Page.getLayoutMetrics') return { cssContentSize: content }; + return {}; + }), + onDetach: { addListener: vi.fn() }, + onEvent: { addListener: vi.fn() }, + }; + const tabs = { + get: vi.fn(async () => ({ id: 1, windowId: 1, url: 'https://example.com' })), + onRemoved: { addListener: vi.fn() }, + onUpdated: { addListener: vi.fn() }, + }; + return { + chrome: { tabs, debugger: debuggerApi, scripting: {}, runtime: { id: 'opencli-test' } }, + debuggerApi, + calls, + }; +} + +describe('cdp screenshot', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('takes a viewport screenshot without overriding device metrics by default', async () => { + const { chrome, calls } = chromeMockForScreenshot(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + const data = await mod.screenshot(1); + + expect(data).toBe('BASE64DATA'); + const methods = calls.map((c) => c.method); + expect(methods).not.toContain('Emulation.setDeviceMetricsOverride'); + expect(methods).not.toContain('Emulation.clearDeviceMetricsOverride'); + expect(methods).toContain('Page.captureScreenshot'); + }); + + it('overrides only width when --width is given without --full-page', async () => { + const { chrome, calls } = chromeMockForScreenshot(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + await mod.screenshot(1, { width: 1080 }); + + const overrides = calls.filter((c) => c.method === 'Emulation.setDeviceMetricsOverride'); + expect(overrides).toHaveLength(1); + expect(overrides[0].params).toEqual({ mobile: false, width: 1080, height: 0, deviceScaleFactor: 1 }); + expect(calls.some((c) => c.method === 'Page.getLayoutMetrics')).toBe(false); + expect(calls.at(-1)?.method).toBe('Emulation.clearDeviceMetricsOverride'); + }); + + it('overrides only height when --height is given without --full-page', async () => { + const { chrome, calls } = chromeMockForScreenshot(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + await mod.screenshot(1, { height: 720 }); + + const overrides = calls.filter((c) => c.method === 'Emulation.setDeviceMetricsOverride'); + expect(overrides).toHaveLength(1); + expect(overrides[0].params).toEqual({ mobile: false, width: 0, height: 720, deviceScaleFactor: 1 }); + expect(calls.at(-1)?.method).toBe('Emulation.clearDeviceMetricsOverride'); + }); + + it('uses content size for fullPage screenshots without explicit dimensions', async () => { + const { chrome, calls } = chromeMockForScreenshot({ width: 1024, height: 2048 }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + await mod.screenshot(1, { fullPage: true }); + + const overrides = calls.filter((c) => c.method === 'Emulation.setDeviceMetricsOverride'); + expect(overrides).toHaveLength(1); + expect(overrides[0].params).toEqual({ mobile: false, width: 1024, height: 2048, deviceScaleFactor: 1 }); + expect(calls.at(-1)?.method).toBe('Emulation.clearDeviceMetricsOverride'); + }); + + it('ignores --height under --full-page so the existing measure-from-content path is preserved', async () => { + const { chrome, calls } = chromeMockForScreenshot({ width: 1024, height: 2048 }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + await mod.screenshot(1, { fullPage: true, height: 600 }); + + const overrides = calls.filter((c) => c.method === 'Emulation.setDeviceMetricsOverride'); + expect(overrides).toHaveLength(1); + expect(overrides[0].params).toEqual({ mobile: false, width: 1024, height: 2048, deviceScaleFactor: 1 }); + expect(calls.at(-1)?.method).toBe('Emulation.clearDeviceMetricsOverride'); + }); + + it('reflows at the requested width before measuring full-page height', async () => { + // Simulate that at width=1080 the page reflows to a different content height. + const { chrome, calls } = chromeMockForScreenshot({ width: 1080, height: 1500 }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + await mod.screenshot(1, { fullPage: true, width: 1080 }); + + const overrides = calls.filter((c) => c.method === 'Emulation.setDeviceMetricsOverride'); + expect(overrides).toHaveLength(2); + expect(overrides[0].params).toEqual({ mobile: false, width: 1080, height: 0, deviceScaleFactor: 1 }); + expect(overrides[1].params).toEqual({ mobile: false, width: 1080, height: 1500, deviceScaleFactor: 1 }); + + const layoutBetween = calls.findIndex((c) => c.method === 'Page.getLayoutMetrics'); + const firstOverride = calls.findIndex((c) => c.method === 'Emulation.setDeviceMetricsOverride'); + expect(layoutBetween).toBeGreaterThan(firstOverride); + expect(calls.at(-1)?.method).toBe('Emulation.clearDeviceMetricsOverride'); + }); + + it('clears the device metrics override even when capture throws', async () => { + const debuggerApi = { + attach: vi.fn(async () => {}), + detach: vi.fn(async () => {}), + sendCommand: vi.fn(async (_t: unknown, method: string) => { + if (method === 'Page.captureScreenshot') throw new Error('capture-failed'); + if (method === 'Page.getLayoutMetrics') return { cssContentSize: { width: 800, height: 600 } }; + return {}; + }), + onDetach: { addListener: vi.fn() }, + onEvent: { addListener: vi.fn() }, + }; + const chrome = { + tabs: { + get: vi.fn(async () => ({ id: 1, windowId: 1, url: 'https://example.com' })), + onRemoved: { addListener: vi.fn() }, + onUpdated: { addListener: vi.fn() }, + }, + debugger: debuggerApi, + scripting: {}, + runtime: { id: 'opencli-test' }, + }; + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + await expect(mod.screenshot(1, { width: 800 })).rejects.toThrow('capture-failed'); + + expect(debuggerApi.sendCommand).toHaveBeenCalledWith( + { tabId: 1 }, + 'Emulation.clearDeviceMetricsOverride', + ); + }); +}); diff --git a/extension/src/cdp.ts b/extension/src/cdp.ts index 42d030623..6932daa2e 100644 --- a/extension/src/cdp.ts +++ b/extension/src/cdp.ts @@ -190,29 +190,46 @@ export const evaluateAsync = evaluate; */ export async function screenshot( tabId: number, - options: { format?: 'png' | 'jpeg'; quality?: number; fullPage?: boolean } = {}, + options: { format?: 'png' | 'jpeg'; quality?: number; fullPage?: boolean; width?: number; height?: number } = {}, ): Promise { await ensureAttached(tabId); const format = options.format ?? 'png'; - - // For full-page screenshots, get the full page dimensions first - if (options.fullPage) { - // Get full page metrics - const metrics = await chrome.debugger.sendCommand({ tabId }, 'Page.getLayoutMetrics') as { - contentSize?: { width: number; height: number }; - cssContentSize?: { width: number; height: number }; - }; - const size = metrics.cssContentSize || metrics.contentSize; - if (size) { - // Set device metrics to full page size + const fullPage = options.fullPage === true; + const overrideWidth = options.width && options.width > 0 ? Math.ceil(options.width) : undefined; + // height is ignored under fullPage so the existing measure-from-content path stays unchanged for users who pass --height alongside --full-page. + const overrideHeight = !fullPage && options.height && options.height > 0 ? Math.ceil(options.height) : undefined; + const needsOverride = fullPage || overrideWidth !== undefined || overrideHeight !== undefined; + + if (needsOverride) { + // When width is set, apply it first so layout reflows before we read content size. + if (overrideWidth !== undefined && fullPage) { await chrome.debugger.sendCommand({ tabId }, 'Emulation.setDeviceMetricsOverride', { mobile: false, - width: Math.ceil(size.width), - height: Math.ceil(size.height), + width: overrideWidth, + height: 0, deviceScaleFactor: 1, }); } + let finalWidth = overrideWidth ?? 0; + let finalHeight = overrideHeight ?? 0; + if (fullPage) { + const metrics = await chrome.debugger.sendCommand({ tabId }, 'Page.getLayoutMetrics') as { + contentSize?: { width: number; height: number }; + cssContentSize?: { width: number; height: number }; + }; + const size = metrics.cssContentSize || metrics.contentSize; + if (size) { + if (finalWidth === 0) finalWidth = Math.ceil(size.width); + finalHeight = Math.ceil(size.height); + } + } + await chrome.debugger.sendCommand({ tabId }, 'Emulation.setDeviceMetricsOverride', { + mobile: false, + width: finalWidth, + height: finalHeight, + deviceScaleFactor: 1, + }); } try { @@ -227,8 +244,7 @@ export async function screenshot( return result.data; } finally { - // Reset device metrics if we changed them for full-page - if (options.fullPage) { + if (needsOverride) { await chrome.debugger.sendCommand({ tabId }, 'Emulation.clearDeviceMetricsOverride').catch(() => {}); } } diff --git a/extension/src/protocol.ts b/extension/src/protocol.ts index 17b1fa7ab..09b679ccf 100644 --- a/extension/src/protocol.ts +++ b/extension/src/protocol.ts @@ -50,6 +50,10 @@ export interface Command { quality?: number; /** Whether to capture full page (not just viewport) */ fullPage?: boolean; + /** Override viewport width in CSS pixels for screenshot (0 / undefined = use current) */ + width?: number; + /** Override viewport height in CSS pixels for screenshot (0 / undefined = use current; ignored when fullPage) */ + height?: number; /** Local file paths for set-file-input action */ files?: string[]; /** CSS selector for file input element (set-file-input action) */ diff --git a/src/browser/cdp.ts b/src/browser/cdp.ts index 799505eb3..e7d99f4b1 100644 --- a/src/browser/cdp.ts +++ b/src/browser/cdp.ts @@ -244,16 +244,58 @@ class CDPPage extends BasePage { } async screenshot(options: ScreenshotOptions = {}): Promise { - const result = await this.bridge.send('Page.captureScreenshot', { - format: options.format ?? 'png', - quality: options.format === 'jpeg' ? (options.quality ?? 80) : undefined, - captureBeyondViewport: options.fullPage ?? false, - }); - const base64 = isRecord(result) && typeof result.data === 'string' ? result.data : ''; - if (options.path) { - await saveBase64ToFile(base64, options.path); + const fullPage = options.fullPage === true; + const overrideWidth = options.width && options.width > 0 ? Math.ceil(options.width) : undefined; + // height is ignored under fullPage so the captureBeyondViewport path stays unchanged for users who pass --height alongside --full-page. + const overrideHeight = !fullPage && options.height && options.height > 0 ? Math.ceil(options.height) : undefined; + const needsOverride = overrideWidth !== undefined || overrideHeight !== undefined; + + if (needsOverride) { + if (overrideWidth !== undefined && fullPage) { + await this.bridge.send('Emulation.setDeviceMetricsOverride', { + mobile: false, + width: overrideWidth, + height: 0, + deviceScaleFactor: 1, + }); + } + let finalWidth = overrideWidth ?? 0; + let finalHeight = overrideHeight ?? 0; + if (fullPage) { + const metrics = await this.bridge.send('Page.getLayoutMetrics'); + const m = isRecord(metrics) ? metrics : {}; + const css = isRecord(m.cssContentSize) ? m.cssContentSize : undefined; + const fb = isRecord(m.contentSize) ? m.contentSize : undefined; + const size = css ?? fb; + if (size && typeof size.width === 'number' && typeof size.height === 'number') { + if (finalWidth === 0) finalWidth = Math.ceil(size.width); + finalHeight = Math.ceil(size.height); + } + } + await this.bridge.send('Emulation.setDeviceMetricsOverride', { + mobile: false, + width: finalWidth, + height: finalHeight, + deviceScaleFactor: 1, + }); + } + + try { + const result = await this.bridge.send('Page.captureScreenshot', { + format: options.format ?? 'png', + quality: options.format === 'jpeg' ? (options.quality ?? 80) : undefined, + captureBeyondViewport: !needsOverride && fullPage, + }); + const base64 = isRecord(result) && typeof result.data === 'string' ? result.data : ''; + if (options.path) { + await saveBase64ToFile(base64, options.path); + } + return base64; + } finally { + if (needsOverride) { + await this.bridge.send('Emulation.clearDeviceMetricsOverride').catch(() => {}); + } } - return base64; } async startNetworkCapture(pattern: string = ''): Promise { diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts index 6bc13751c..1250f2f5d 100644 --- a/src/browser/daemon-client.ts +++ b/src/browser/daemon-client.ts @@ -36,6 +36,10 @@ export interface DaemonCommand { format?: 'png' | 'jpeg'; quality?: number; fullPage?: boolean; + /** Override viewport width in CSS pixels for screenshot (0 / undefined = use current) */ + width?: number; + /** Override viewport height in CSS pixels for screenshot (0 / undefined = use current; ignored when fullPage) */ + height?: number; /** Local file paths for set-file-input action */ files?: string[]; diff --git a/src/browser/page.test.ts b/src/browser/page.test.ts index 0a4dd220c..0b1ae19d5 100644 --- a/src/browser/page.test.ts +++ b/src/browser/page.test.ts @@ -278,3 +278,39 @@ describe('Page active target tracking', () => { expect(evalCall?.[1]).not.toHaveProperty('page'); }); }); + +describe('Page.screenshot', () => { + beforeEach(() => { + sendCommandMock.mockReset(); + sendCommandFullMock.mockReset(); + warnMock.mockReset(); + }); + + it('forwards width / height / fullPage options to the bridge', async () => { + sendCommandMock.mockResolvedValueOnce('BASE64'); + + const page = new Page('browser:default'); + const data = await page.screenshot({ fullPage: true, width: 1080 }); + + expect(data).toBe('BASE64'); + expect(sendCommandMock).toHaveBeenCalledWith('screenshot', expect.objectContaining({ + workspace: 'browser:default', + fullPage: true, + width: 1080, + })); + }); + + it('omits viewport overrides when none are set', async () => { + sendCommandMock.mockResolvedValueOnce('BASE64'); + + const page = new Page('browser:default'); + await page.screenshot(); + + const call = sendCommandMock.mock.calls.at(-1); + expect(call?.[0]).toBe('screenshot'); + const args = call?.[1] as Record; + expect(args.width).toBeUndefined(); + expect(args.height).toBeUndefined(); + expect(args.fullPage).toBeUndefined(); + }); +}); diff --git a/src/browser/page.ts b/src/browser/page.ts index 8580caa0e..520782499 100644 --- a/src/browser/page.ts +++ b/src/browser/page.ts @@ -213,6 +213,8 @@ export class Page extends BasePage { format: options.format, quality: options.quality, fullPage: options.fullPage, + width: options.width, + height: options.height, }) as string; if (options.path) { diff --git a/src/cli.ts b/src/cli.ts index d3189de58..d9cd0a177 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,7 +9,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { Command } from 'commander'; +import { Command, InvalidArgumentError } from 'commander'; import { styleText } from 'node:util'; import { findPackageRoot, getBuiltEntryCandidates } from './package-paths.js'; import { type CliCommand, fullName, getRegistry, strategyLabel } from './registry.js'; @@ -36,6 +36,7 @@ import { log } from './logger.js'; import { bindTab, BrowserCommandError, fetchDaemonStatus, sendCommand } from './browser/daemon-client.js'; import { aliasForContextId, loadProfileConfig, renameProfile, resolveProfileContextId, setDefaultProfile } from './browser/profile.js'; import { formatDaemonVersion, isDaemonStale } from './browser/daemon-version.js'; +import type { ScreenshotOptions } from './types.js'; const CLI_FILE = fileURLToPath(import.meta.url); const DEFAULT_BROWSER_WORKSPACE = 'browser:default'; @@ -455,6 +456,17 @@ function parsePositiveIntOption(val: string | undefined, label: string, fallback return parsed; } +function parseScreenshotDim(val: string, label: string): number { + if (!/^\d+$/.test(val)) { + throw new InvalidArgumentError(`--${label} must be a positive integer (got "${val}")`); + } + const parsed = parseInt(val, 10); + if (parsed <= 0) { + throw new InvalidArgumentError(`--${label} must be a positive integer (got "${val}")`); + } + return parsed; +} + function applyVerbose(opts: { verbose?: boolean }): void { if (opts.verbose) process.env.OPENCLI_VERBOSE = '1'; } @@ -974,13 +986,21 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command })); addBrowserTabOption(browser.command('screenshot').argument('[path]', 'Save to file (base64 if omitted)')) + .option('--full-page', 'Capture the full scrollable page, not just the viewport', false) + .option('--width ', 'Override viewport width in CSS pixels for this screenshot only', (v: string) => parseScreenshotDim(v, 'width')) + .option('--height ', 'Override viewport height in CSS pixels for this screenshot only (ignored with --full-page)', (v: string) => parseScreenshotDim(v, 'height')) .description('Take screenshot') - .action(browserAction(async (page, path) => { + .action(browserAction(async (page, path, opts) => { + const shotOpts: ScreenshotOptions = { + fullPage: opts.fullPage === true, + width: opts.width, + height: opts.height, + }; if (path) { - await page.screenshot({ path }); + await page.screenshot({ ...shotOpts, path }); console.log(`Screenshot saved to: ${path}`); } else { - console.log(await page.screenshot({ format: 'png' })); + console.log(await page.screenshot({ ...shotOpts, format: 'png' })); } })); diff --git a/src/types.ts b/src/types.ts index 9cd3d6a91..5b74f62c0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,6 +35,10 @@ export interface ScreenshotOptions { format?: 'png' | 'jpeg'; quality?: number; fullPage?: boolean; + /** Override viewport width in CSS pixels for the screenshot only (cleared after). */ + width?: number; + /** Override viewport height in CSS pixels for the screenshot only (ignored when fullPage). */ + height?: number; path?: string; }