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
36 changes: 28 additions & 8 deletions extension/dist/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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(() => {
});
}
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1267,6 +1267,8 @@ async function handleScreenshot(cmd: Command, workspace: string): Promise<Result
format: cmd.format,
quality: cmd.quality,
fullPage: cmd.fullPage,
width: cmd.width,
height: cmd.height,
});
return pageScopedResult(cmd.id, tabId, data);
} catch (err) {
Expand Down
155 changes: 155 additions & 0 deletions extension/src/cdp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,158 @@ describe('cdp attach recovery', () => {
});

});

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',
);
});
});
48 changes: 32 additions & 16 deletions extension/src/cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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 {
Expand All @@ -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(() => {});
}
}
Expand Down
4 changes: 4 additions & 0 deletions extension/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand Down
60 changes: 51 additions & 9 deletions src/browser/cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,16 +244,58 @@ class CDPPage extends BasePage {
}

async screenshot(options: ScreenshotOptions = {}): Promise<string> {
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<boolean> {
Expand Down
4 changes: 4 additions & 0 deletions src/browser/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
Loading
Loading