diff --git a/bun.lock b/bun.lock index 20613f6e..9c78574b 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,7 @@ "@mozilla/readability": "^0.6.0", "cli-table3": "^0.6.5", "commander": "^14.0.3", - "js-yaml": "^4.1.0", + "js-yaml": "^4.3.0", "turndown": "^7.2.2", "turndown-plugin-gfm": "^1.0.2", "undici": "^6.27.0", @@ -241,7 +241,7 @@ "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], - "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], "jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="], diff --git a/package-lock.json b/package-lock.json index a3bba12f..a1f10bf4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "cli-table3": "^0.6.5", "cloakbrowser": "0.4.5", "commander": "^14.0.3", - "js-yaml": "^4.1.0", + "js-yaml": "^4.3.0", "playwright-core": "1.61.1", "turndown": "^7.2.2", "turndown-plugin-gfm": "^1.0.2", @@ -1950,9 +1950,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "funding": [ { "type": "github", diff --git a/package.json b/package.json index 73cd0731..cbe6dc42 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,7 @@ "cli-table3": "^0.6.5", "cloakbrowser": "0.4.5", "commander": "^14.0.3", - "js-yaml": "^4.1.0", + "js-yaml": "^4.3.0", "playwright-core": "1.61.1", "turndown": "^7.2.2", "turndown-plugin-gfm": "^1.0.2", diff --git a/src/browser/runtime/local-cloak/actions.ts b/src/browser/runtime/local-cloak/actions.ts index 20ed4813..4a19b309 100644 --- a/src/browser/runtime/local-cloak/actions.ts +++ b/src/browser/runtime/local-cloak/actions.ts @@ -1,7 +1,7 @@ import type { BrowserRuntimeCommand, BrowserRuntimeResult } from '../../protocol.js'; import { waitForDownload } from './downloads.js'; import type { CloakSessionManager } from './session-manager.js'; -import type { Frame, Page as PlaywrightPage } from 'playwright-core'; +import type { BrowserContext, Frame, Page as PlaywrightPage } from 'playwright-core'; class CloakActionError extends Error { constructor( @@ -63,16 +63,45 @@ function execTarget(page: PlaywrightPage, frameIndex: number | undefined, pageId return frame; } -async function applyScreenshotViewport(page: PlaywrightPage, command: BrowserRuntimeCommand): Promise<{ width: number; height: number } | null> { +async function captureScreenshot(page: PlaywrightPage, context: BrowserContext, command: BrowserRuntimeCommand): Promise { const width = Number.isFinite(command.width) && command.width! > 0 ? Math.ceil(command.width!) : undefined; const height = !command.fullPage && Number.isFinite(command.height) && command.height! > 0 ? Math.ceil(command.height!) : undefined; - if (width === undefined && height === undefined) return null; + const options = { + type: command.format ?? 'png', + quality: command.format === 'jpeg' ? command.quality : undefined, + fullPage: command.fullPage, + } as const; + if (width === undefined && height === undefined) return page.screenshot(options); + const current = page.viewportSize(); - await page.setViewportSize({ - width: width ?? current?.width ?? 1280, - height: height ?? current?.height ?? 720, - }); - return current; + if (current) { + // Emulated viewport: override for the shot, then restore the prior fixed size. + await page.setViewportSize({ width: width ?? current.width, height: height ?? current.height }); + try { + return await page.screenshot(options); + } finally { + await page.setViewportSize(current); + } + } + + // Real-window context (viewport: null): setViewportSize can't return to a windowed + // state, so override reversibly via CDP and clear it so the override is per-shot only. + const windowSize = width === undefined || height === undefined + ? await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })) + : { width: 0, height: 0 }; + const cdp = await context.newCDPSession(page); + try { + await cdp.send('Emulation.setDeviceMetricsOverride', { + width: width ?? windowSize.width, + height: height ?? windowSize.height, + deviceScaleFactor: 0, + mobile: false, + }); + return await page.screenshot(options); + } finally { + await cdp.send('Emulation.clearDeviceMetricsOverride').catch(() => {}); + await cdp.detach().catch(() => {}); + } } export async function dispatchCloakAction(manager: CloakSessionManager, command: BrowserRuntimeCommand): Promise { @@ -99,17 +128,8 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: } case 'screenshot': { const lease = await resolveLease(manager, command); - const previousViewport = await applyScreenshotViewport(lease.page, command); - try { - const buffer = await lease.page.screenshot({ - type: command.format ?? 'png', - quality: command.format === 'jpeg' ? command.quality : undefined, - fullPage: command.fullPage, - }); - return { id: command.id, ok: true, data: buffer.toString('base64'), page: lease.pageId }; - } finally { - if (previousViewport) await lease.page.setViewportSize(previousViewport); - } + const buffer = await captureScreenshot(lease.page, lease.context, command); + return { id: command.id, ok: true, data: buffer.toString('base64'), page: lease.pageId }; } case 'close-window': { if (command.page) { diff --git a/src/browser/runtime/local-cloak/provider.test.ts b/src/browser/runtime/local-cloak/provider.test.ts index 6161c7e2..80e717c3 100644 --- a/src/browser/runtime/local-cloak/provider.test.ts +++ b/src/browser/runtime/local-cloak/provider.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it, vi } from 'vitest'; import { LocalCloakRuntimeProvider } from './provider.js'; -function fakePage(url: string) { +function fakePage(url: string, initialViewport: { width: number; height: number } | null = { width: 1280, height: 720 }) { let closed = false; - let viewportSize = { width: 1280, height: 720 }; + let viewportSize = initialViewport; return { isClosed: vi.fn(() => closed), goto: vi.fn(async (nextUrl: string) => { @@ -26,8 +26,9 @@ function fakePage(url: string) { }; } -function makeProviderWithFakePage() { - const pages = [fakePage('https://example.com/')]; +function makeProviderWithFakePage(initialViewport: { width: number; height: number } | null = { width: 1280, height: 720 }) { + const pages = [fakePage('https://example.com/', initialViewport)]; + const cdpSession = { send: vi.fn().mockResolvedValue(undefined), detach: vi.fn().mockResolvedValue(undefined) }; const context = { on: vi.fn(), pages: vi.fn(() => pages.filter((page) => !page.isClosed())), @@ -36,6 +37,7 @@ function makeProviderWithFakePage() { pages.push(page); return page; }), + newCDPSession: vi.fn().mockResolvedValue(cdpSession), cookies: vi.fn().mockResolvedValue([{ name: 'sid', value: '1', domain: 'example.com', path: '/' }]), close: vi.fn().mockResolvedValue(undefined), }; @@ -43,7 +45,7 @@ function makeProviderWithFakePage() { baseDir: '/tmp/webcmd-test', launchPersistentContext: vi.fn().mockResolvedValue(context), }); - return { provider, page: pages[0], pages, context }; + return { provider, page: pages[0], pages, context, cdpSession }; } describe('LocalCloakRuntimeProvider', () => { @@ -173,6 +175,21 @@ describe('LocalCloakRuntimeProvider', () => { expect(page.screenshot).toHaveBeenCalledTimes(1); }); + it('reversibly overrides via CDP and never pins the viewport when the context has no fixed viewport', async () => { + const { provider, page, cdpSession } = makeProviderWithFakePage(null); + const nav = await provider.dispatch({ id: 'nav', action: 'navigate', session: 'work', surface: 'browser', url: 'https://example.com/', profileId: 'default' }); + + await provider.dispatch({ id: 'shot', action: 'screenshot', session: 'work', surface: 'browser', page: nav.page, format: 'png', width: 375, height: 812, profileId: 'default' }); + + // The override must not permanently pin the real window via setViewportSize (#120). + expect(page.setViewportSize).not.toHaveBeenCalled(); + expect(cdpSession.send).toHaveBeenCalledWith('Emulation.setDeviceMetricsOverride', expect.objectContaining({ width: 375, height: 812 })); + // ...and it must be cleared afterward so the override is per-shot only. + expect(cdpSession.send).toHaveBeenCalledWith('Emulation.clearDeviceMetricsOverride'); + expect(cdpSession.detach).toHaveBeenCalledTimes(1); + expect(page.screenshot).toHaveBeenCalledTimes(1); + }); + it('ignores screenshot height overrides for full-page captures while applying width', async () => { const { provider, page } = makeProviderWithFakePage(); const nav = await provider.dispatch({ id: 'nav', action: 'navigate', session: 'work', surface: 'browser', url: 'https://example.com/', profileId: 'default' });