Skip to content

Commit 0ba71d6

Browse files
authored
Merge pull request #123 from rajarshidattapy/fix/screenshot-viewport-restore-120
feat: enhance screenshot functionality to restore viewport size and support CDP overrides
2 parents ea93b63 + 13e5b0a commit 0ba71d6

3 files changed

Lines changed: 63 additions & 26 deletions

File tree

bun.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/browser/runtime/local-cloak/actions.ts

Lines changed: 39 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { BrowserRuntimeCommand, BrowserRuntimeResult } from '../../protocol.js';
22
import { waitForDownload } from './downloads.js';
33
import type { CloakSessionManager } from './session-manager.js';
4-
import type { Frame, Page as PlaywrightPage } from 'playwright-core';
4+
import type { BrowserContext, Frame, Page as PlaywrightPage } from 'playwright-core';
55

66
class CloakActionError extends Error {
77
constructor(
@@ -63,16 +63,45 @@ function execTarget(page: PlaywrightPage, frameIndex: number | undefined, pageId
6363
return frame;
6464
}
6565

66-
async function applyScreenshotViewport(page: PlaywrightPage, command: BrowserRuntimeCommand): Promise<{ width: number; height: number } | null> {
66+
async function captureScreenshot(page: PlaywrightPage, context: BrowserContext, command: BrowserRuntimeCommand): Promise<Buffer> {
6767
const width = Number.isFinite(command.width) && command.width! > 0 ? Math.ceil(command.width!) : undefined;
6868
const height = !command.fullPage && Number.isFinite(command.height) && command.height! > 0 ? Math.ceil(command.height!) : undefined;
69-
if (width === undefined && height === undefined) return null;
69+
const options = {
70+
type: command.format ?? 'png',
71+
quality: command.format === 'jpeg' ? command.quality : undefined,
72+
fullPage: command.fullPage,
73+
} as const;
74+
if (width === undefined && height === undefined) return page.screenshot(options);
75+
7076
const current = page.viewportSize();
71-
await page.setViewportSize({
72-
width: width ?? current?.width ?? 1280,
73-
height: height ?? current?.height ?? 720,
74-
});
75-
return current;
77+
if (current) {
78+
// Emulated viewport: override for the shot, then restore the prior fixed size.
79+
await page.setViewportSize({ width: width ?? current.width, height: height ?? current.height });
80+
try {
81+
return await page.screenshot(options);
82+
} finally {
83+
await page.setViewportSize(current);
84+
}
85+
}
86+
87+
// Real-window context (viewport: null): setViewportSize can't return to a windowed
88+
// state, so override reversibly via CDP and clear it so the override is per-shot only.
89+
const windowSize = width === undefined || height === undefined
90+
? await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight }))
91+
: { width: 0, height: 0 };
92+
const cdp = await context.newCDPSession(page);
93+
try {
94+
await cdp.send('Emulation.setDeviceMetricsOverride', {
95+
width: width ?? windowSize.width,
96+
height: height ?? windowSize.height,
97+
deviceScaleFactor: 0,
98+
mobile: false,
99+
});
100+
return await page.screenshot(options);
101+
} finally {
102+
await cdp.send('Emulation.clearDeviceMetricsOverride').catch(() => {});
103+
await cdp.detach().catch(() => {});
104+
}
76105
}
77106

78107
export async function dispatchCloakAction(manager: CloakSessionManager, command: BrowserRuntimeCommand): Promise<BrowserRuntimeResult> {
@@ -99,17 +128,8 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command:
99128
}
100129
case 'screenshot': {
101130
const lease = await resolveLease(manager, command);
102-
const previousViewport = await applyScreenshotViewport(lease.page, command);
103-
try {
104-
const buffer = await lease.page.screenshot({
105-
type: command.format ?? 'png',
106-
quality: command.format === 'jpeg' ? command.quality : undefined,
107-
fullPage: command.fullPage,
108-
});
109-
return { id: command.id, ok: true, data: buffer.toString('base64'), page: lease.pageId };
110-
} finally {
111-
if (previousViewport) await lease.page.setViewportSize(previousViewport);
112-
}
131+
const buffer = await captureScreenshot(lease.page, lease.context, command);
132+
return { id: command.id, ok: true, data: buffer.toString('base64'), page: lease.pageId };
113133
}
114134
case 'close-window': {
115135
if (command.page) {

src/browser/runtime/local-cloak/provider.test.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { describe, expect, it, vi } from 'vitest';
22
import { LocalCloakRuntimeProvider } from './provider.js';
33

4-
function fakePage(url: string) {
4+
function fakePage(url: string, initialViewport: { width: number; height: number } | null = { width: 1280, height: 720 }) {
55
let closed = false;
6-
let viewportSize = { width: 1280, height: 720 };
6+
let viewportSize = initialViewport;
77
return {
88
isClosed: vi.fn(() => closed),
99
goto: vi.fn(async (nextUrl: string) => {
@@ -26,8 +26,9 @@ function fakePage(url: string) {
2626
};
2727
}
2828

29-
function makeProviderWithFakePage() {
30-
const pages = [fakePage('https://example.com/')];
29+
function makeProviderWithFakePage(initialViewport: { width: number; height: number } | null = { width: 1280, height: 720 }) {
30+
const pages = [fakePage('https://example.com/', initialViewport)];
31+
const cdpSession = { send: vi.fn().mockResolvedValue(undefined), detach: vi.fn().mockResolvedValue(undefined) };
3132
const context = {
3233
on: vi.fn(),
3334
pages: vi.fn(() => pages.filter((page) => !page.isClosed())),
@@ -36,14 +37,15 @@ function makeProviderWithFakePage() {
3637
pages.push(page);
3738
return page;
3839
}),
40+
newCDPSession: vi.fn().mockResolvedValue(cdpSession),
3941
cookies: vi.fn().mockResolvedValue([{ name: 'sid', value: '1', domain: 'example.com', path: '/' }]),
4042
close: vi.fn().mockResolvedValue(undefined),
4143
};
4244
const provider = new LocalCloakRuntimeProvider({
4345
baseDir: '/tmp/webcmd-test',
4446
launchPersistentContext: vi.fn().mockResolvedValue(context),
4547
});
46-
return { provider, page: pages[0], pages, context };
48+
return { provider, page: pages[0], pages, context, cdpSession };
4749
}
4850

4951
describe('LocalCloakRuntimeProvider', () => {
@@ -173,6 +175,21 @@ describe('LocalCloakRuntimeProvider', () => {
173175
expect(page.screenshot).toHaveBeenCalledTimes(1);
174176
});
175177

178+
it('reversibly overrides via CDP and never pins the viewport when the context has no fixed viewport', async () => {
179+
const { provider, page, cdpSession } = makeProviderWithFakePage(null);
180+
const nav = await provider.dispatch({ id: 'nav', action: 'navigate', session: 'work', surface: 'browser', url: 'https://example.com/', profileId: 'default' });
181+
182+
await provider.dispatch({ id: 'shot', action: 'screenshot', session: 'work', surface: 'browser', page: nav.page, format: 'png', width: 375, height: 812, profileId: 'default' });
183+
184+
// The override must not permanently pin the real window via setViewportSize (#120).
185+
expect(page.setViewportSize).not.toHaveBeenCalled();
186+
expect(cdpSession.send).toHaveBeenCalledWith('Emulation.setDeviceMetricsOverride', expect.objectContaining({ width: 375, height: 812 }));
187+
// ...and it must be cleared afterward so the override is per-shot only.
188+
expect(cdpSession.send).toHaveBeenCalledWith('Emulation.clearDeviceMetricsOverride');
189+
expect(cdpSession.detach).toHaveBeenCalledTimes(1);
190+
expect(page.screenshot).toHaveBeenCalledTimes(1);
191+
});
192+
176193
it('ignores screenshot height overrides for full-page captures while applying width', async () => {
177194
const { provider, page } = makeProviderWithFakePage();
178195
const nav = await provider.dispatch({ id: 'nav', action: 'navigate', session: 'work', surface: 'browser', url: 'https://example.com/', profileId: 'default' });

0 commit comments

Comments
 (0)