From 5eb082872546b019fba485d6081dc821088377d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 8 Jul 2026 18:42:25 +0200 Subject: [PATCH 1/6] fix: normalize iOS simulator screenshot density --- src/__tests__/client.test.ts | 20 +++ src/backend.ts | 1 + src/cli/commands/__tests__/screenshot.test.ts | 13 +- src/cli/commands/screenshot.ts | 25 ++- .../__tests__/args-parse-interaction.test.ts | 3 + src/client/client-types.ts | 1 + src/client/client.ts | 5 + src/commands/capture/index.test.ts | 4 +- src/commands/capture/runtime/screenshot.ts | 1 + .../capture/screenshot-options.test.ts | 10 ++ src/commands/capture/screenshot.ts | 7 +- src/commands/runtime-types.ts | 1 + src/contracts/screenshot.ts | 34 +++- src/core/dispatch.ts | 1 + src/core/interactor-types.ts | 1 + .../request-router-screenshot.test.ts | 153 ++++++++++++++++++ src/daemon/__tests__/response-views.test.ts | 10 ++ src/daemon/request-generic-dispatch.ts | 38 ++++- src/daemon/response-views.ts | 5 + .../apple/core/__tests__/index.test.ts | 79 ++++++++- src/platforms/apple/core/screenshot.ts | 49 +++++- src/platforms/apple/interactor.ts | 1 + src/replay/__tests__/script.test.ts | 13 +- src/utils/png-resize.ts | 17 ++ src/utils/png-size.ts | 13 ++ src/utils/screenshot-result.ts | 15 ++ 26 files changed, 505 insertions(+), 15 deletions(-) create mode 100644 src/utils/png-size.ts diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index 89b93f5ec..d44af5dc5 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -835,6 +835,11 @@ test('client capture.screenshot normalizes overlay refs from daemon response dat ok: true, data: { path: '/tmp/screenshot.png', + width: 402, + height: 874, + logicalWidth: 402, + logicalHeight: 874, + pixelDensity: 1, overlayRefs: [ { ref: '@e1', @@ -864,6 +869,11 @@ test('client capture.screenshot normalizes overlay refs from daemon response dat assert.deepEqual(result, { path: '/tmp/screenshot.png', + width: 402, + height: 874, + logicalWidth: 402, + logicalHeight: 874, + pixelDensity: 1, overlayRefs: [ { ref: '@e1', @@ -929,6 +939,11 @@ test('capture.screenshot normalizes the default-level result (unchanged)', async ok: true, data: { path: '/tmp/shot.png', + width: 1206, + height: 2622, + logicalWidth: 402, + logicalHeight: 874, + pixelDensity: 3, overlayRefs: [{ ref: 'e1', label: 'Login', x: 0, y: 0, width: 10, height: 10 }], }, }; @@ -938,6 +953,11 @@ test('capture.screenshot normalizes the default-level result (unchanged)', async const result = await client.capture.screenshot(); assert.equal(result.path, '/tmp/shot.png'); + assert.equal(result.width, 1206); + assert.equal(result.height, 2622); + assert.equal(result.logicalWidth, 402); + assert.equal(result.logicalHeight, 874); + assert.equal(result.pixelDensity, 3); assert.deepEqual(result.identifiers, { session: 'qa' }); }); diff --git a/src/backend.ts b/src/backend.ts index 806d30be0..fc61faec5 100644 --- a/src/backend.ts +++ b/src/backend.ts @@ -74,6 +74,7 @@ export type BackendFindTextResult = { export type BackendScreenshotOptions = { fullscreen?: boolean; overlayRefs?: boolean; + pixelDensity?: number; stabilize?: boolean; normalizeStatusBar?: boolean; surface?: SessionSurface; diff --git a/src/cli/commands/__tests__/screenshot.test.ts b/src/cli/commands/__tests__/screenshot.test.ts index 4347faa47..89a7013d6 100644 --- a/src/cli/commands/__tests__/screenshot.test.ts +++ b/src/cli/commands/__tests__/screenshot.test.ts @@ -53,17 +53,26 @@ test('screenshot --level digest --json preserves the digest payload through the assert.deepEqual(parsed.data, digest); }); -test('screenshot --json at the default level still emits the normalized { path, overlayRefs } shape', async () => { +test('screenshot --json at the default level still emits normalized screenshot metadata', async () => { const full = { path: '/tmp/shot.png', + width: 402, + height: 874, + logicalWidth: 402, + logicalHeight: 874, + pixelDensity: 1, overlayRefs: [{ ref: 'e1', label: 'Login', x: 0, y: 0, width: 10, height: 10 }], }; const client = clientReturning(full); const flags = { json: true } as CliFlags; const out = await captureStdout(() => screenshotCommand({ positionals: [], flags, client })); - const parsed = JSON.parse(out) as { data: { path: string; overlayCount?: number } }; + const parsed = JSON.parse(out) as { + data: { path: string; width?: number; pixelDensity?: number }; + }; assert.equal(parsed.data.path, '/tmp/shot.png'); + assert.equal(parsed.data.width, 402); + assert.equal(parsed.data.pixelDensity, 1); assert.ok(!('overlayCount' in parsed.data)); }); diff --git a/src/cli/commands/screenshot.ts b/src/cli/commands/screenshot.ts index 99a29e23d..05b44ff9d 100644 --- a/src/cli/commands/screenshot.ts +++ b/src/cli/commands/screenshot.ts @@ -25,12 +25,17 @@ export const screenshotCommand: ClientCommandHandler = async ({ positionals, fla } const data = { path: result.path, + ...(typeof result.width === 'number' ? { width: result.width } : {}), + ...(typeof result.height === 'number' ? { height: result.height } : {}), + ...(typeof result.logicalWidth === 'number' ? { logicalWidth: result.logicalWidth } : {}), + ...(typeof result.logicalHeight === 'number' ? { logicalHeight: result.logicalHeight } : {}), + ...(typeof result.pixelDensity === 'number' ? { pixelDensity: result.pixelDensity } : {}), ...(result.overlayRefs ? { overlayRefs: result.overlayRefs } : {}), }; writeCommandOutput(flags, data, () => result.overlayRefs - ? `Annotated ${result.overlayRefs.length} refs onto ${result.path}` - : result.path, + ? `${formatScreenshotSummary(result)} with ${result.overlayRefs.length} refs annotated` + : formatScreenshotSummary(result), ); return true; }; @@ -99,6 +104,7 @@ function createClientScreenshotBackend( path: outPath, session: context.session, overlayRefs: options?.overlayRefs, + pixelDensity: options?.pixelDensity, fullscreen: options?.fullscreen, normalizeStatusBar: options?.normalizeStatusBar, stabilize: options?.stabilize, @@ -106,12 +112,27 @@ function createClientScreenshotBackend( }); return { path: result.path, + ...(typeof result.width === 'number' ? { width: result.width } : {}), + ...(typeof result.height === 'number' ? { height: result.height } : {}), + ...(typeof result.logicalWidth === 'number' ? { logicalWidth: result.logicalWidth } : {}), + ...(typeof result.logicalHeight === 'number' + ? { logicalHeight: result.logicalHeight } + : {}), + ...(typeof result.pixelDensity === 'number' ? { pixelDensity: result.pixelDensity } : {}), ...(result.overlayRefs ? { overlayRefs: result.overlayRefs } : {}), }; }, }; } +function formatScreenshotSummary(result: CaptureScreenshotResult): string { + if (typeof result.width !== 'number' || typeof result.height !== 'number') { + return result.path; + } + const densitySuffix = typeof result.pixelDensity === 'number' ? ` @${result.pixelDensity}x` : ''; + return `${result.path} (${result.width}x${result.height}${densitySuffix})`; +} + function resolveClientBackendPlatform(flags: CliFlags): AgentDeviceBackend['platform'] { switch (flags.platform) { case 'android': diff --git a/src/cli/parser/__tests__/args-parse-interaction.test.ts b/src/cli/parser/__tests__/args-parse-interaction.test.ts index dd3c0b2e9..91a30e4f6 100644 --- a/src/cli/parser/__tests__/args-parse-interaction.test.ts +++ b/src/cli/parser/__tests__/args-parse-interaction.test.ts @@ -227,6 +227,8 @@ test('parseArgs recognizes screenshot flags', () => { [ 'screenshot', 'page.png', + '--pixel-density', + '2', '--full', '-f', '--fullscreen', @@ -241,6 +243,7 @@ test('parseArgs recognizes screenshot flags', () => { ); assert.equal(parsed.command, 'screenshot'); assert.deepEqual(parsed.positionals, ['page.png']); + assert.equal(parsed.flags.screenshotPixelDensity, 2); assert.equal(parsed.flags.screenshotFullscreen, true); assert.equal(parsed.flags.screenshotMaxSize, 1024); assert.equal(parsed.flags.screenshotNoStabilize, true); diff --git a/src/client/client-types.ts b/src/client/client-types.ts index 2249ea233..38dbedd79 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -433,6 +433,7 @@ export type CaptureSnapshotResult = { export type CaptureScreenshotOptions = AgentDeviceRequestOverrides & { path?: string; overlayRefs?: boolean; + pixelDensity?: number; fullscreen?: boolean; maxSize?: number; stabilize?: boolean; diff --git a/src/client/client.ts b/src/client/client.ts index 42ad08bac..a77582df7 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -310,6 +310,11 @@ export function createAgentDeviceClient( const screenshot = readScreenshotResultData(data); return { path: readRequiredString(data, 'path'), + width: screenshot?.width, + height: screenshot?.height, + logicalWidth: screenshot?.logicalWidth, + logicalHeight: screenshot?.logicalHeight, + pixelDensity: screenshot?.pixelDensity, overlayRefs: screenshot?.overlayRefs, identifiers: { session }, }; diff --git a/src/commands/capture/index.test.ts b/src/commands/capture/index.test.ts index bda54fe11..33f1c0972 100644 --- a/src/commands/capture/index.test.ts +++ b/src/commands/capture/index.test.ts @@ -80,10 +80,11 @@ describe('capture command interface', () => { test('reads screenshot path and writes screenshot flags', () => { const input = screenshotCliReader( ['page.png'], - flags({ screenshotFullscreen: true, screenshotMaxSize: 1024 }), + flags({ screenshotPixelDensity: 2, screenshotFullscreen: true, screenshotMaxSize: 1024 }), ); expect(input).toMatchObject({ path: 'page.png', + pixelDensity: 2, fullscreen: true, maxSize: 1024, }); @@ -91,6 +92,7 @@ describe('capture command interface', () => { command: 'screenshot', positionals: ['page.png'], options: { + screenshotPixelDensity: 2, screenshotFullscreen: true, screenshotMaxSize: 1024, }, diff --git a/src/commands/capture/runtime/screenshot.ts b/src/commands/capture/runtime/screenshot.ts index d5b94def6..4bb54d20f 100644 --- a/src/commands/capture/runtime/screenshot.ts +++ b/src/commands/capture/runtime/screenshot.ts @@ -40,6 +40,7 @@ export const screenshotCommand: RuntimeCommand< { fullscreen: options.fullscreen, overlayRefs: options.overlayRefs, + pixelDensity: options.pixelDensity, stabilize: options.stabilize, normalizeStatusBar: options.normalizeStatusBar, surface: options.surface, diff --git a/src/commands/capture/screenshot-options.test.ts b/src/commands/capture/screenshot-options.test.ts index 3ac9df3f7..ba19b600d 100644 --- a/src/commands/capture/screenshot-options.test.ts +++ b/src/commands/capture/screenshot-options.test.ts @@ -14,6 +14,7 @@ test('screenshot flag projection maps CLI flags to runtime options', () => { assert.deepEqual( screenshotOptionsFromFlags({ overlayRefs: true, + screenshotPixelDensity: 2, screenshotFullscreen: true, screenshotMaxSize: 1024, screenshotNoStabilize: true, @@ -21,6 +22,7 @@ test('screenshot flag projection maps CLI flags to runtime options', () => { }), { overlayRefs: true, + pixelDensity: 2, fullscreen: true, maxSize: 1024, stabilize: false, @@ -33,6 +35,7 @@ test('screenshot flag projection maps public options to request flags', () => { assert.deepEqual( screenshotFlagsFromOptions({ overlayRefs: true, + pixelDensity: 3, fullscreen: true, maxSize: 512, stabilize: false, @@ -40,6 +43,7 @@ test('screenshot flag projection maps public options to request flags', () => { }), { overlayRefs: true, + screenshotPixelDensity: 3, screenshotFullscreen: true, screenshotMaxSize: 512, screenshotNoStabilize: true, @@ -64,10 +68,14 @@ test('screenshot script flags use the shared recorded flag contract', () => { assert.deepEqual(result, { handled: true, nextIndex: 0 }); result = readScreenshotScriptFlag({ args: ['--normalize-status-bar'], index: 0, flags }); assert.deepEqual(result, { handled: true, nextIndex: 0 }); + result = readScreenshotScriptFlag({ args: ['--pixel-density', '3'], index: 0, flags }); + assert.deepEqual(result, { handled: true, nextIndex: 1 }); appendScreenshotScriptFlags(parts, flags); assert.deepEqual(parts, [ + '--pixel-density', + '3', '--fullscreen', '--max-size', '640', @@ -75,6 +83,7 @@ test('screenshot script flags use the shared recorded flag contract', () => { '--normalize-status-bar', ]); assert.deepEqual(SCREENSHOT_ACTION_FLAG_KEYS, [ + 'screenshotPixelDensity', 'screenshotFullscreen', 'screenshotMaxSize', 'screenshotNoStabilize', @@ -87,6 +96,7 @@ test('screenshot script flags use the shared recorded flag contract', () => { assert.deepEqual(SCREENSHOT_COMMAND_FLAG_KEYS, [ 'out', 'overlayRefs', + 'screenshotPixelDensity', 'screenshotFullscreen', 'screenshotMaxSize', 'screenshotNoStabilize', diff --git a/src/commands/capture/screenshot.ts b/src/commands/capture/screenshot.ts index 72ec7a185..4d24b95e4 100644 --- a/src/commands/capture/screenshot.ts +++ b/src/commands/capture/screenshot.ts @@ -23,6 +23,9 @@ const screenshotCommandMetadata = defineFieldCommandMetadata( { path: stringField('Output path.'), overlayRefs: booleanField(), + pixelDensity: integerField('Output screenshot pixel density in pixels per logical point.', { + min: 1, + }), fullscreen: booleanField(), maxSize: integerField(), stabilize: booleanField(), @@ -38,9 +41,9 @@ const screenshotCommandDefinition = defineExecutableCommand( const screenshotCliSchema = { helpDescription: - 'Capture screenshot (web defaults to the viewport; use --fullscreen, --full, or -f for the entire page. macOS app sessions default to the app window; use --fullscreen for full desktop, --max-size to downscale, --overlay-refs to annotate current refs, --normalize-status-bar for deterministic iOS simulator chrome, or --no-stabilize for low-latency Android capture loops)', + 'Capture screenshot (web defaults to the viewport; use --fullscreen, --full, or -f for the entire page. iOS simulators default to 1x logical-point output; use --pixel-density to request a different screenshot density. macOS app sessions default to the app window; use --fullscreen for full desktop, --max-size to downscale, --overlay-refs to annotate current refs, --normalize-status-bar for deterministic iOS simulator chrome, or --no-stabilize for low-latency Android capture loops)', summary: - 'Capture screenshot with optional web full-page, desktop, downscale, or ref overlay modes', + 'Capture screenshot with optional density, full-page, desktop, downscale, or ref overlay modes', positionalArgs: ['path?'], allowedFlags: SCREENSHOT_COMMAND_FLAG_KEYS, } as const; diff --git a/src/commands/runtime-types.ts b/src/commands/runtime-types.ts index 5ed59403d..3e654cdc0 100644 --- a/src/commands/runtime-types.ts +++ b/src/commands/runtime-types.ts @@ -28,6 +28,7 @@ export type ScreenshotCommandOptions = CommandContext & { out?: FileOutputRef; fullscreen?: boolean; overlayRefs?: boolean; + pixelDensity?: number; maxSize?: number; stabilize?: boolean; normalizeStatusBar?: boolean; diff --git a/src/contracts/screenshot.ts b/src/contracts/screenshot.ts index 7a8f58c2d..1fcc0a4d6 100644 --- a/src/contracts/screenshot.ts +++ b/src/contracts/screenshot.ts @@ -3,6 +3,7 @@ import { AppError } from '../kernel/errors.ts'; export const SCREENSHOT_COMMAND_FLAG_KEYS = [ 'out', 'overlayRefs', + 'screenshotPixelDensity', 'screenshotFullscreen', 'screenshotMaxSize', 'screenshotNoStabilize', @@ -10,6 +11,7 @@ export const SCREENSHOT_COMMAND_FLAG_KEYS = [ ] as const; export const SCREENSHOT_ACTION_FLAG_KEYS = [ + 'screenshotPixelDensity', 'screenshotFullscreen', 'screenshotMaxSize', 'screenshotNoStabilize', @@ -28,6 +30,15 @@ type ScreenshotSpecificFlagDefinition = { }; export const SCREENSHOT_SPECIFIC_FLAG_DEFINITIONS: readonly ScreenshotSpecificFlagDefinition[] = [ + { + key: 'screenshotPixelDensity', + names: ['--pixel-density'], + type: 'int', + min: 1, + usageLabel: '--pixel-density ', + usageDescription: + 'Screenshot: output PNG pixel density in pixels per logical point (currently supported on iOS simulators)', + }, { key: 'screenshotFullscreen', names: ['--fullscreen', '--full', '-f'], @@ -65,6 +76,7 @@ export const SCREENSHOT_SPECIFIC_FLAG_DEFINITIONS: readonly ScreenshotSpecificFl export type ScreenshotRequestFlags = { out?: string; overlayRefs?: boolean; + screenshotPixelDensity?: number; screenshotFullscreen?: boolean; screenshotMaxSize?: number; screenshotNoStabilize?: boolean; @@ -73,11 +85,15 @@ export type ScreenshotRequestFlags = { export type ScreenshotDispatchFlags = Pick< ScreenshotRequestFlags, - 'screenshotFullscreen' | 'screenshotNoStabilize' | 'screenshotNormalizeStatusBar' + | 'screenshotPixelDensity' + | 'screenshotFullscreen' + | 'screenshotNoStabilize' + | 'screenshotNormalizeStatusBar' >; export type ScreenshotRuntimeFlags = Pick< ScreenshotRequestFlags, + | 'screenshotPixelDensity' | 'screenshotFullscreen' | 'screenshotMaxSize' | 'screenshotNoStabilize' @@ -86,6 +102,7 @@ export type ScreenshotRuntimeFlags = Pick< export type ScreenshotPublicOptions = { overlayRefs?: boolean; + pixelDensity?: number; fullscreen?: boolean; maxSize?: number; stabilize?: boolean; @@ -94,6 +111,7 @@ export type ScreenshotPublicOptions = { export type ScreenshotRuntimeOptions = { overlayRefs?: boolean; + pixelDensity?: number; fullscreen?: boolean; maxSize?: number; stabilize?: boolean; @@ -105,6 +123,7 @@ export function screenshotOptionsFromFlags( ): ScreenshotRuntimeOptions { return stripUndefined({ overlayRefs: flags?.overlayRefs, + pixelDensity: flags?.screenshotPixelDensity, fullscreen: flags?.screenshotFullscreen, maxSize: flags?.screenshotMaxSize, stabilize: flags?.screenshotNoStabilize ? false : undefined, @@ -117,6 +136,7 @@ export function screenshotFlagsFromOptions( ): Partial { return stripUndefined({ overlayRefs: options.overlayRefs, + screenshotPixelDensity: options.screenshotPixelDensity ?? options.pixelDensity, screenshotFullscreen: options.screenshotFullscreen ?? options.fullscreen, screenshotMaxSize: options.screenshotMaxSize ?? options.maxSize, screenshotNoStabilize: @@ -130,6 +150,9 @@ export function appendScreenshotScriptFlags( parts: string[], flags: Partial | undefined, ): void { + if (typeof flags?.screenshotPixelDensity === 'number') { + parts.push('--pixel-density', String(flags.screenshotPixelDensity)); + } if (flags?.screenshotFullscreen) parts.push('--fullscreen'); if (typeof flags?.screenshotMaxSize === 'number') { parts.push('--max-size', String(flags.screenshotMaxSize)); @@ -157,6 +180,15 @@ export function readScreenshotScriptFlag(params: { flags.screenshotNormalizeStatusBar = true; return { handled: true, nextIndex: index }; } + if (token === '--pixel-density') { + const value = args[index + 1]; + const pixelDensity = value === undefined ? NaN : Number(value); + if (!Number.isInteger(pixelDensity) || pixelDensity < 1) { + throw new AppError('INVALID_ARGS', 'screenshot --pixel-density requires a positive integer'); + } + flags.screenshotPixelDensity = pixelDensity; + return { handled: true, nextIndex: index + 1 }; + } if (token === '--max-size') { const value = args[index + 1]; const maxSize = value === undefined ? NaN : Number(value); diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 0412c1a4e..e6a48c434 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -312,6 +312,7 @@ async function handleScreenshotCommand( const screenshotOptions = screenshotOptionsFromFlags(context); await interactor.screenshot(screenshotPath, { appBundleId: context?.appBundleId, + pixelDensity: screenshotOptions.pixelDensity, fullscreen: screenshotOptions.fullscreen, normalizeStatusBar: screenshotOptions.normalizeStatusBar, stabilize: screenshotOptions.stabilize, diff --git a/src/core/interactor-types.ts b/src/core/interactor-types.ts index 640670e1e..06cc9ad12 100644 --- a/src/core/interactor-types.ts +++ b/src/core/interactor-types.ts @@ -41,6 +41,7 @@ export type { BackMode }; export type ScreenshotOptions = { appBundleId?: string; + pixelDensity?: number; fullscreen?: boolean; normalizeStatusBar?: boolean; stabilize?: boolean; diff --git a/src/daemon/__tests__/request-router-screenshot.test.ts b/src/daemon/__tests__/request-router-screenshot.test.ts index f42f2ff4f..3179f3d70 100644 --- a/src/daemon/__tests__/request-router-screenshot.test.ts +++ b/src/daemon/__tests__/request-router-screenshot.test.ts @@ -81,6 +81,9 @@ test('screenshot resolves relative positional path against request cwd', async ( mockDispatch.mockImplementation(async (_device, command, positionals) => { if (command === 'screenshot') { capturedPath = positionals[0]; + if (capturedPath) { + writeSolidPng(capturedPath); + } } return {}; }); @@ -165,6 +168,9 @@ test('router serializes concurrent commands for the same device across sessions' order.push(`start-${command}`); active += 1; maxActive = Math.max(maxActive, active); + if (command === 'screenshot') { + writeSolidPng('/tmp/first.png'); + } await new Promise((resolve) => { gates.push(() => { active -= 1; @@ -292,6 +298,9 @@ test('screenshot keeps absolute positional path unchanged', async () => { mockDispatch.mockImplementation(async (_device, command, positionals) => { if (command === 'screenshot') { capturedPath = positionals[0]; + if (capturedPath) { + writeSolidPng(capturedPath); + } } return {}; }); @@ -325,6 +334,9 @@ test('screenshot runtime supplies default output path when none is requested', a mockDispatch.mockImplementation(async (_device, command, positionals) => { if (command === 'screenshot') { capturedPath = positionals[0]; + if (capturedPath) { + writeSolidPng(capturedPath); + } } return {}; }); @@ -353,6 +365,48 @@ test('screenshot runtime supplies default output path when none is requested', a } }); +test('iOS simulator screenshot response includes output dimensions and logical density metadata', async () => { + const sessionStore = makeSessionStore('agent-device-router-screenshot-'); + sessionStore.set('default', makeIosSession('default')); + const screenshotPath = path.join(os.tmpdir(), `agent-device-ios-meta-${Date.now()}.png`); + + mockDispatch.mockImplementation(async (_device, command) => { + if (command === 'screenshot') { + writeSolidPng(screenshotPath, 402, 874); + return { path: screenshotPath }; + } + return {}; + }); + + const handler = createRequestHandler({ + logPath: path.join(os.tmpdir(), 'daemon.log'), + token: 'test-token', + sessionStore, + leaseRegistry: new LeaseRegistry(), + trackDownloadableArtifact: () => 'artifact-id', + }); + + const response = await handler({ + token: 'test-token', + session: 'default', + command: 'screenshot', + positionals: [screenshotPath], + meta: { requestId: 'req-ios-screenshot-meta' }, + }); + + expect(response.ok).toBe(true); + if (response.ok) { + expect(response.data).toMatchObject({ + path: screenshotPath, + width: 402, + height: 874, + logicalWidth: 402, + logicalHeight: 874, + pixelDensity: 1, + }); + } +}); + test('screenshot resolves --out flag path against request cwd', async () => { const callerCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-screenshot-out-cwd-')); const sessionStore = makeSessionStore('agent-device-router-screenshot-'); @@ -363,6 +417,9 @@ test('screenshot resolves --out flag path against request cwd', async () => { mockDispatch.mockImplementation(async (_device, command, _positionals, outPath) => { if (command === 'screenshot') { capturedOut = outPath; + if (capturedOut) { + writeSolidPng(capturedOut); + } } return {}; }); @@ -617,3 +674,99 @@ test('screenshot --overlay-refs uses a fresh snapshot instead of stale session s const png = PNG.sync.read(fs.readFileSync(screenshotPath)); expect(Array.from(png.data.slice(0, 4))).not.toEqual([255, 255, 255, 255]); }); + +test('screenshot --pixel-density keeps overlay refs aligned to scaled iOS simulator output', async () => { + const sessionStore = makeSessionStore('agent-device-router-screenshot-'); + sessionStore.set('default', makeIosSession('default')); + const screenshotPath = path.join(os.tmpdir(), `agent-device-overlay-2x-${Date.now()}.png`); + + mockDispatch.mockImplementation(async (_device, command) => { + if (command === 'screenshot') { + writeSolidPng(screenshotPath, 804, 1748); + return { path: screenshotPath }; + } + if (command === 'snapshot') { + return { + nodes: [ + { + index: 0, + type: 'Application', + rect: { x: 0, y: 0, width: 402, height: 874 }, + }, + { + index: 1, + type: 'XCUIElementTypeButton', + label: 'Continue', + hittable: true, + rect: { x: 10, y: 20, width: 80, height: 30 }, + }, + ], + }; + } + return {}; + }); + + const handler = createRequestHandler({ + logPath: path.join(os.tmpdir(), 'daemon.log'), + token: 'test-token', + sessionStore, + leaseRegistry: new LeaseRegistry(), + trackDownloadableArtifact: () => 'artifact-id', + }); + + const response = await handler({ + token: 'test-token', + session: 'default', + command: 'screenshot', + positionals: [screenshotPath], + flags: { overlayRefs: true, screenshotPixelDensity: 2 }, + meta: { requestId: 'req-overlay-2x' }, + }); + + expect(response.ok).toBe(true); + if (response.ok) { + expect(response.data).toMatchObject({ + width: 804, + height: 1748, + logicalWidth: 402, + logicalHeight: 874, + pixelDensity: 2, + overlayRefs: [ + { + ref: 'e2', + label: 'Continue', + overlayRect: { x: 20, y: 40, width: 160, height: 60 }, + center: { x: 100, y: 70 }, + }, + ], + }); + } +}); + +test('screenshot --pixel-density is rejected outside iOS-family simulators', async () => { + const sessionStore = makeSessionStore('agent-device-router-screenshot-'); + sessionStore.set('default', makeSession('default')); + + const handler = createRequestHandler({ + logPath: path.join(os.tmpdir(), 'daemon.log'), + token: 'test-token', + sessionStore, + leaseRegistry: new LeaseRegistry(), + trackDownloadableArtifact: () => 'artifact-id', + }); + + const response = await handler({ + token: 'test-token', + session: 'default', + command: 'screenshot', + positionals: ['/tmp/android.png'], + flags: { screenshotPixelDensity: 2 }, + meta: { requestId: 'req-unsupported-density' }, + }); + + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error.message).toContain('currently supported only on iOS-family simulators'); + } + expect(mockDispatch).not.toHaveBeenCalled(); +}); diff --git a/src/daemon/__tests__/response-views.test.ts b/src/daemon/__tests__/response-views.test.ts index c7f52186b..d234299db 100644 --- a/src/daemon/__tests__/response-views.test.ts +++ b/src/daemon/__tests__/response-views.test.ts @@ -61,6 +61,11 @@ const overlayRef = (ref: string, label: string | undefined) => ({ const SCREENSHOT_DATA: DaemonResponseData = { path: '/tmp/agent-device-screenshot-xyz/screenshot.png', + width: 402, + height: 874, + logicalWidth: 402, + logicalHeight: 874, + pixelDensity: 1, overlayRefs: [ overlayRef('e1', 'Continue'), overlayRef('e2', undefined), // label omitted → stays undefined in the digest @@ -83,6 +88,11 @@ test('digest collapses overlay geometry to count + leveled refs, keeps cheap fie const digest = screenshotView!(SCREENSHOT_DATA, 'digest'); expect(digest).toEqual({ path: '/tmp/agent-device-screenshot-xyz/screenshot.png', + width: 402, + height: 874, + logicalWidth: 402, + logicalHeight: 874, + pixelDensity: 1, overlayCount: 2, overlayRefs: [ { ref: 'e1', label: 'Continue' }, diff --git a/src/daemon/request-generic-dispatch.ts b/src/daemon/request-generic-dispatch.ts index dbd7852a4..3fabc9e94 100644 --- a/src/daemon/request-generic-dispatch.ts +++ b/src/daemon/request-generic-dispatch.ts @@ -4,6 +4,7 @@ import { requireCommandSupported } from './handlers/response.ts'; import { SessionStore } from './session-store.ts'; import type { DaemonCommandContext } from './context.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; +import { isIosFamily } from '../kernel/device.ts'; import { buildSnapshotState, captureSnapshotData } from './handlers/snapshot-capture.ts'; import { setSessionSnapshot } from './session-snapshot.ts'; import { @@ -24,9 +25,10 @@ import { recordTouchVisualizationEvent, } from './recording-gestures.ts'; import { markPostGestureStabilization } from './post-gesture-stabilization.ts'; -import { normalizeError } from '../kernel/errors.ts'; +import { AppError, normalizeError } from '../kernel/errors.ts'; import { shouldGuardAndroidBlockingDialog } from './daemon-command-registry.ts'; import { isActiveProviderDevice } from '../provider-device-runtime.ts'; +import { readPngSize } from '../utils/png-size.ts'; const GESTURE_PLATFORM_COMMANDS: Readonly> = { pan: 'pan', @@ -191,6 +193,7 @@ async function executeGenericPlatformCommand(params: { ...dispatchContext, }); } + assertSupportedScreenshotPixelDensity(session, request.flags?.screenshotPixelDensity); const data = await dispatchScreenshotViaRuntime({ session, sessionName: params.sessionName, @@ -201,6 +204,9 @@ async function executeGenericPlatformCommand(params: { if (request.flags?.overlayRefs && typeof data?.path === 'string') { await applyScreenshotOverlay(session, data, params.logPath); } + if (typeof data?.path === 'string') { + await attachScreenshotMetadata(session, data, request.flags?.screenshotPixelDensity); + } return data; } @@ -333,6 +339,36 @@ async function applyScreenshotOverlay( data.overlayRefs = overlayRefs; } +function assertSupportedScreenshotPixelDensity( + session: SessionState, + pixelDensity: number | undefined, +): void { + if (pixelDensity === undefined) return; + if (isIosFamily(session.device) && session.device.kind === 'simulator') return; + throw new AppError( + 'UNSUPPORTED_OPERATION', + '--pixel-density is currently supported only on iOS-family simulators', + ); +} + +async function attachScreenshotMetadata( + session: SessionState, + data: Record, + requestedPixelDensity: number | undefined, +): Promise { + const path = data.path; + if (typeof path !== 'string') return; + const size = await readPngSize(path); + data.width = size.width; + data.height = size.height; + if (isIosFamily(session.device) && session.device.kind === 'simulator') { + const pixelDensity = requestedPixelDensity ?? 1; + data.pixelDensity = pixelDensity; + data.logicalWidth = Math.round(size.width / pixelDensity); + data.logicalHeight = Math.round(size.height / pixelDensity); + } +} + function recordVisualizationAndAction(params: { session: SessionState; sessionStore: SessionStore; diff --git a/src/daemon/response-views.ts b/src/daemon/response-views.ts index 4f851d104..13fc8f7e6 100644 --- a/src/daemon/response-views.ts +++ b/src/daemon/response-views.ts @@ -61,6 +61,11 @@ function screenshotView(data: DaemonResponseData, level: ResponseLevel): DaemonR .map((overlay) => ({ ref: overlay.ref, label: overlay.label })); return { ...(typeof data.path === 'string' ? { path: data.path } : {}), + ...(typeof data.width === 'number' ? { width: data.width } : {}), + ...(typeof data.height === 'number' ? { height: data.height } : {}), + ...(typeof data.logicalWidth === 'number' ? { logicalWidth: data.logicalWidth } : {}), + ...(typeof data.logicalHeight === 'number' ? { logicalHeight: data.logicalHeight } : {}), + ...(typeof data.pixelDensity === 'number' ? { pixelDensity: data.pixelDensity } : {}), overlayCount: overlays.length, overlayRefs, ...(data.artifacts !== undefined ? { artifacts: data.artifacts } : {}), diff --git a/src/platforms/apple/core/__tests__/index.test.ts b/src/platforms/apple/core/__tests__/index.test.ts index 24146138b..896bc13c0 100644 --- a/src/platforms/apple/core/__tests__/index.test.ts +++ b/src/platforms/apple/core/__tests__/index.test.ts @@ -88,6 +88,7 @@ import { AppError } from '../../../../kernel/errors.ts'; import { runCmd } from '../../../../utils/exec.ts'; import { retryWithPolicy } from '../../../../utils/retry.ts'; import { parseIosDeviceAppsPayload, parseIosDeviceProcessesPayload } from '../devicectl.ts'; +import { PNG } from '../../../../utils/png.ts'; const IOS_TEST_DEVICE: DeviceInfo = { platform: 'apple', @@ -785,6 +786,7 @@ test('captureSimulatorScreenshotWithFallback falls back to runner after retry ex ensureBooted: ensureBootedSimulator, prepareStatusBarForScreenshot: prepareStatusBarForScreenshot, captureWithRetry: captureSimulatorScreenshotWithRetry, + normalizeDensity: async () => {}, captureWithRunner: captureScreenshotViaRunner, shouldFallbackToRunner: shouldRetryIosSimulatorScreenshot, }, @@ -829,6 +831,7 @@ test('captureSimulatorScreenshotWithFallback falls back to runner after simctl s ensureBooted: ensureBootedSimulator, prepareStatusBarForScreenshot: prepareStatusBarForScreenshot, captureWithRetry: captureSimulatorScreenshotWithRetry, + normalizeDensity: async () => {}, captureWithRunner: captureScreenshotViaRunner, shouldFallbackToRunner: shouldRetryIosSimulatorScreenshot, }, @@ -850,6 +853,7 @@ test('captureSimulatorScreenshotWithFallback continues when status bar preparati await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, '/tmp/out.png', { appBundleId: 'com.example.app', normalizeStatusBar: true, + deps: { normalizeDensity: async () => {} }, }); assert.equal(mockPrepareStatusBarForScreenshot.mock.calls.length, 1); assert.equal(mockRetryWithPolicy.mock.calls.length > 0, true); @@ -864,6 +868,7 @@ test('captureSimulatorScreenshotWithFallback can skip session-backed simulator b await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, '/tmp/out.png', { appBundleId: 'com.example.app', skipIosSimulatorBootCheck: true, + deps: { normalizeDensity: async () => {} }, }); assert.equal(mockEnsureBootedSimulator.mock.calls.length, 0); @@ -893,6 +898,7 @@ test('captureSimulatorScreenshotWithFallback boots skipped-check simulator after ensureBooted, prepareStatusBarForScreenshot, captureWithRetry, + normalizeDensity: async () => {}, captureWithRunner, shouldFallbackToRunner: shouldRetryIosSimulatorScreenshot, }, @@ -929,6 +935,7 @@ test('captureSimulatorScreenshotWithFallback keeps runner fallback after skipped ensureBooted, prepareStatusBarForScreenshot, captureWithRetry, + normalizeDensity: async () => {}, captureWithRunner, shouldFallbackToRunner: shouldRetryIosSimulatorScreenshot, }, @@ -949,6 +956,7 @@ test('captureSimulatorScreenshotWithFallback ignores status bar restore failures await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, '/tmp/out.png', { appBundleId: 'com.example.app', normalizeStatusBar: true, + deps: { normalizeDensity: async () => {} }, }); assert.equal(mockPrepareStatusBarForScreenshot.mock.calls.length, 1); assert.equal(mockRetryWithPolicy.mock.calls.length > 0, true); @@ -963,6 +971,7 @@ test('captureSimulatorScreenshotWithFallback skips status bar normalization by d await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, '/tmp/out.png', { appBundleId: 'com.example.app', + deps: { normalizeDensity: async () => {} }, }); assert.equal(mockPrepareStatusBarForScreenshot.mock.calls.length, 0); @@ -1009,6 +1018,7 @@ test('captureSimulatorScreenshotWithFallback emits fallback diagnostic before us ensureBooted: ensureBootedSimulator, prepareStatusBarForScreenshot: prepareStatusBarForScreenshot, captureWithRetry: captureSimulatorScreenshotWithRetry, + normalizeDensity: async () => {}, captureWithRunner: captureScreenshotViaRunner, shouldFallbackToRunner: shouldRetryIosSimulatorScreenshot, }, @@ -1055,6 +1065,7 @@ test('captureSimulatorScreenshotWithFallback uses simulator runner fallback by d const outPath = path.join(tmpDir, 'out.png'); await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, outPath, { appBundleId: 'com.example.app', + deps: { normalizeDensity: async () => {} }, }); assert.equal(mockRunAppleRunnerCommand.mock.calls.length, 1); assert.equal(await fs.readFile(outPath, 'utf8'), 'default-fallback'); @@ -1238,6 +1249,9 @@ test('screenshotIos retries simulator capture timeouts and eventually succeeds', const commandLogPath = path.join(tmpDir, 'commands.log'); const screenshotCountPath = path.join(tmpDir, 'screenshot-attempts.count'); const outPath = path.join(tmpDir, 'screen.png'); + const sourcePngPath = path.join(tmpDir, 'source.png'); + + await fs.writeFile(sourcePngPath, PNG.sync.write(new PNG({ width: 1206, height: 2622 }))); await fs.writeFile( xcrunPath, @@ -1248,6 +1262,10 @@ test('screenshotIos retries simulator capture timeouts and eventually succeeds', ' echo \'{"devices":{"com.apple.CoreSimulator.SimRuntime.iOS-18-0":[{"udid":"sim-1","state":"Booted"}]}}\'', ' exit 0', 'fi', + 'if [ "$1" = "simctl" ] && [ "$2" = "getenv" ] && [ "$3" = "sim-1" ] && [ "$4" = "SIMULATOR_MAINSCREEN_SCALE" ]; then', + ' echo "3"', + ' exit 0', + 'fi', 'if [ "$1" = "simctl" ] && [ "$2" = "io" ] && [ "$3" = "sim-1" ] && [ "$4" = "screenshot" ]; then', ' count=0', ' if [ -f "$AGENT_DEVICE_TEST_SCREENSHOT_COUNT_FILE" ]; then', @@ -1260,7 +1278,7 @@ test('screenshotIos retries simulator capture timeouts and eventually succeeds', ' echo "Timeout waiting for screen surfaces" >&2', ' exit 60', ' fi', - ' printf "png-bytes" > "$5"', + ' cp "$AGENT_DEVICE_TEST_SCREENSHOT_SOURCE_FILE" "$5"', ' exit 0', 'fi', 'echo "unexpected xcrun args: $*" >&2', @@ -1280,9 +1298,11 @@ test('screenshotIos retries simulator capture timeouts and eventually succeeds', const previousPath = process.env.PATH; const previousCommandLog = process.env.AGENT_DEVICE_TEST_COMMAND_LOG; const previousScreenshotCountFile = process.env.AGENT_DEVICE_TEST_SCREENSHOT_COUNT_FILE; + const previousScreenshotSourceFile = process.env.AGENT_DEVICE_TEST_SCREENSHOT_SOURCE_FILE; process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`; process.env.AGENT_DEVICE_TEST_COMMAND_LOG = commandLogPath; process.env.AGENT_DEVICE_TEST_SCREENSHOT_COUNT_FILE = screenshotCountPath; + process.env.AGENT_DEVICE_TEST_SCREENSHOT_SOURCE_FILE = sourcePngPath; mockRetryWithPolicy.mockImplementation(async (fn, policy, options) => { assert.ok(policy); assert.ok(options); @@ -1308,7 +1328,9 @@ test('screenshotIos retries simulator capture timeouts and eventually succeeds', try { await screenshotIos(IOS_TEST_SIMULATOR, outPath); - assert.equal(await fs.readFile(outPath, 'utf8'), 'png-bytes'); + const png = PNG.sync.read(await fs.readFile(outPath)); + assert.equal(png.width, 402); + assert.equal(png.height, 874); assert.equal(await fs.readFile(screenshotCountPath, 'utf8'), '3\n'); const logLines = (await fs.readFile(commandLogPath, 'utf8')).trim().split('\n').filter(Boolean); @@ -1335,10 +1357,63 @@ test('screenshotIos retries simulator capture timeouts and eventually succeeds', if (previousScreenshotCountFile === undefined) delete process.env.AGENT_DEVICE_TEST_SCREENSHOT_COUNT_FILE; else process.env.AGENT_DEVICE_TEST_SCREENSHOT_COUNT_FILE = previousScreenshotCountFile; + if (previousScreenshotSourceFile === undefined) + delete process.env.AGENT_DEVICE_TEST_SCREENSHOT_SOURCE_FILE; + else process.env.AGENT_DEVICE_TEST_SCREENSHOT_SOURCE_FILE = previousScreenshotSourceFile; await fs.rm(tmpDir, { recursive: true, force: true }); } }, 10_000); +test('screenshotIos keeps requested simulator pixel density', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-screenshot-density-')); + const xcrunPath = path.join(tmpDir, 'xcrun'); + const outPath = path.join(tmpDir, 'screen.png'); + const sourcePngPath = path.join(tmpDir, 'source.png'); + + await fs.writeFile(sourcePngPath, PNG.sync.write(new PNG({ width: 1206, height: 2622 }))); + await fs.writeFile( + xcrunPath, + [ + '#!/bin/sh', + 'if [ "$1" = "simctl" ] && [ "$2" = "list" ] && [ "$3" = "devices" ] && [ "$4" = "-j" ]; then', + ' echo \'{"devices":{"com.apple.CoreSimulator.SimRuntime.iOS-18-0":[{"udid":"sim-1","state":"Booted"}]}}\'', + ' exit 0', + 'fi', + 'if [ "$1" = "simctl" ] && [ "$2" = "getenv" ] && [ "$3" = "sim-1" ] && [ "$4" = "SIMULATOR_MAINSCREEN_SCALE" ]; then', + ' echo "3"', + ' exit 0', + 'fi', + 'if [ "$1" = "simctl" ] && [ "$2" = "io" ] && [ "$3" = "sim-1" ] && [ "$4" = "screenshot" ]; then', + ' cp "$AGENT_DEVICE_TEST_SCREENSHOT_SOURCE_FILE" "$5"', + ' exit 0', + 'fi', + 'echo "unexpected xcrun args: $*" >&2', + 'exit 1', + '', + ].join('\n'), + 'utf8', + ); + await fs.chmod(xcrunPath, 0o755); + + const previousPath = process.env.PATH; + const previousScreenshotSourceFile = process.env.AGENT_DEVICE_TEST_SCREENSHOT_SOURCE_FILE; + process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`; + process.env.AGENT_DEVICE_TEST_SCREENSHOT_SOURCE_FILE = sourcePngPath; + + try { + await screenshotIos(IOS_TEST_SIMULATOR, outPath, { pixelDensity: 2 }); + const png = PNG.sync.read(await fs.readFile(outPath)); + assert.equal(png.width, 804); + assert.equal(png.height, 1748); + } finally { + process.env.PATH = previousPath; + if (previousScreenshotSourceFile === undefined) + delete process.env.AGENT_DEVICE_TEST_SCREENSHOT_SOURCE_FILE; + else process.env.AGENT_DEVICE_TEST_SCREENSHOT_SOURCE_FILE = previousScreenshotSourceFile; + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}); + test('openIosApp web URL on iOS device without app falls back to Safari', async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-safari-test-')); const xcrunPath = path.join(tmpDir, 'xcrun'); diff --git a/src/platforms/apple/core/screenshot.ts b/src/platforms/apple/core/screenshot.ts index f16f3cda3..34088ecd1 100644 --- a/src/platforms/apple/core/screenshot.ts +++ b/src/platforms/apple/core/screenshot.ts @@ -4,6 +4,8 @@ import { isMacOs, type DeviceInfo } from '../../../kernel/device.ts'; import { emitDiagnostic } from '../../../utils/diagnostics.ts'; import { AppError } from '../../../kernel/errors.ts'; import type { ExecOptions } from '../../../utils/exec.ts'; +import { resizePngFile } from '../../../utils/png-resize.ts'; +import { readPngSize } from '../../../utils/png-size.ts'; import { Deadline, retryWithPolicy } from '../../../utils/retry.ts'; import { @@ -30,6 +32,11 @@ type SimulatorScreenshotFlowDeps = { ensureBooted: (device: DeviceInfo) => Promise; prepareStatusBarForScreenshot: (device: DeviceInfo) => Promise<() => Promise>; captureWithRetry: (device: DeviceInfo, outPath: string) => Promise; + normalizeDensity: ( + device: DeviceInfo, + outPath: string, + pixelDensity: number | undefined, + ) => Promise; captureWithRunner: ( device: DeviceInfo, outPath: string, @@ -43,16 +50,18 @@ type SimulatorScreenshotFlowDeps = { type SimulatorScreenshotFlowOptions = { appBundleId?: string; fullscreen?: boolean; + pixelDensity?: number; normalizeStatusBar?: boolean; runnerOptions?: AppleRunnerCommandOptions; skipIosSimulatorBootCheck?: boolean; - deps?: SimulatorScreenshotFlowDeps; + deps?: Partial; }; const defaultSimulatorScreenshotFlowDeps: SimulatorScreenshotFlowDeps = { ensureBooted: ensureBootedSimulator, prepareStatusBarForScreenshot: prepareSimulatorStatusBarForScreenshot, captureWithRetry: captureSimulatorScreenshotWithRetry, + normalizeDensity: normalizeIosSimulatorScreenshotDensity, captureWithRunner: captureScreenshotViaRunner, shouldFallbackToRunner: shouldRetryIosSimulatorScreenshot, }; @@ -110,7 +119,7 @@ export async function captureSimulatorScreenshotWithFallback( ); } - const deps = options.deps ?? defaultSimulatorScreenshotFlowDeps; + const deps = { ...defaultSimulatorScreenshotFlowDeps, ...(options.deps ?? {}) }; if (!options.skipIosSimulatorBootCheck) { await deps.ensureBooted(device); @@ -126,6 +135,7 @@ export async function captureSimulatorScreenshotWithFallback( try { try { await deps.captureWithRetry(device, outPath); + await deps.normalizeDensity(device, outPath, options.pixelDensity); return; } catch (error) { let screenshotError = error; @@ -136,6 +146,7 @@ export async function captureSimulatorScreenshotWithFallback( await deps.ensureBooted(device); try { await deps.captureWithRetry(device, outPath); + await deps.normalizeDensity(device, outPath, options.pixelDensity); return; } catch (retryError) { screenshotError = retryError; @@ -153,6 +164,7 @@ export async function captureSimulatorScreenshotWithFallback( options.fullscreen, options.runnerOptions, ); + await deps.normalizeDensity(device, outPath, options.pixelDensity); } finally { await restoreStatusBar().catch((error) => emitStatusBarDiagnostic(device, 'restore_failed', error), @@ -455,4 +467,37 @@ function commandFailureText(error: AppError): string { return `${error.message}\n${stdout}\n${stderr}\n${args}`.toLowerCase(); } +async function normalizeIosSimulatorScreenshotDensity( + device: DeviceInfo, + outPath: string, + pixelDensity: number | undefined, +): Promise { + const sourcePixelDensity = await readIosSimulatorMainScreenScale(device); + const targetPixelDensity = pixelDensity ?? 1; + if (sourcePixelDensity === targetPixelDensity) { + return; + } + + const metadata = await readPngSize(outPath); + const logicalWidth = metadata.width / sourcePixelDensity; + const logicalHeight = metadata.height / sourcePixelDensity; + const targetWidth = Math.max(1, Math.round(logicalWidth * targetPixelDensity)); + const targetHeight = Math.max(1, Math.round(logicalHeight * targetPixelDensity)); + await resizePngFile(outPath, targetWidth, targetHeight); +} + +async function readIosSimulatorMainScreenScale(device: DeviceInfo): Promise { + const scaleResult = await runSimctl(device, ['getenv', device.id, 'SIMULATOR_MAINSCREEN_SCALE'], { + timeoutMs: 5_000, + }); + const scale = Number(scaleResult.stdout.trim()); + if (!Number.isFinite(scale) || scale <= 0) { + throw new AppError( + 'COMMAND_FAILED', + 'Failed to read iOS simulator screenshot scale from SIMULATOR_MAINSCREEN_SCALE', + ); + } + return scale; +} + export { prepareSimulatorStatusBarForScreenshot } from './screenshot-status-bar.ts'; diff --git a/src/platforms/apple/interactor.ts b/src/platforms/apple/interactor.ts index 6b96eefda..752a4a94e 100644 --- a/src/platforms/apple/interactor.ts +++ b/src/platforms/apple/interactor.ts @@ -58,6 +58,7 @@ export function createAppleInteractor( } await screenshotIos(device, outPath, { appBundleId: options?.appBundleId, + pixelDensity: options?.pixelDensity, fullscreen: options?.fullscreen, runnerOptions: runnerOpts, normalizeStatusBar: options?.normalizeStatusBar, diff --git a/src/replay/__tests__/script.test.ts b/src/replay/__tests__/script.test.ts index 6a0b9f14f..d4e1eb994 100644 --- a/src/replay/__tests__/script.test.ts +++ b/src/replay/__tests__/script.test.ts @@ -74,16 +74,25 @@ test('screenshot replay script round-trips screenshot flags', () => { ts: Date.now(), command: 'screenshot', positionals: ['./page.png'], - flags: { screenshotFullscreen: true, screenshotMaxSize: 1024, screenshotNoStabilize: true }, + flags: { + screenshotPixelDensity: 2, + screenshotFullscreen: true, + screenshotMaxSize: 1024, + screenshotNoStabilize: true, + }, }, ]; writeReplayScript(replayPath, actions, makeSession()); const script = fs.readFileSync(replayPath, 'utf8'); - assert.match(script, /screenshot "\.\/page\.png" --fullscreen --max-size 1024 --no-stabilize/); + assert.match( + script, + /screenshot "\.\/page\.png" --pixel-density 2 --fullscreen --max-size 1024 --no-stabilize/, + ); const parsed = parseReplayScriptDetailed(script).actions; assert.deepEqual(parsed[0]?.positionals, ['./page.png']); + assert.equal(parsed[0]?.flags.screenshotPixelDensity, 2); assert.equal(parsed[0]?.flags.screenshotFullscreen, true); assert.equal(parsed[0]?.flags.screenshotMaxSize, 1024); assert.equal(parsed[0]?.flags.screenshotNoStabilize, true); diff --git a/src/utils/png-resize.ts b/src/utils/png-resize.ts index 1be52b90d..493fae1b1 100644 --- a/src/utils/png-resize.ts +++ b/src/utils/png-resize.ts @@ -28,6 +28,23 @@ export async function resizePngFileToMaxSize(filePath: string, maxSize: number): await fs.writeFile(filePath, await encodePngAsync(resized)); } +export async function resizePngFile( + filePath: string, + width: number, + height: number, +): Promise { + if (!Number.isInteger(width) || width < 1 || !Number.isInteger(height) || height < 1) { + throw new AppError('INVALID_ARGS', 'Screenshot resize dimensions must be positive integers'); + } + + const source = await decodePngAsync(await fs.readFile(filePath), 'screenshot'); + if (source.width === width && source.height === height) { + return; + } + + await fs.writeFile(filePath, await encodePngAsync(resizePngBox(source, width, height))); +} + function resizePngBox(source: PNG, width: number, height: number): PNG { const output = new PNG({ width, height }); for (let y = 0; y < height; y += 1) { diff --git a/src/utils/png-size.ts b/src/utils/png-size.ts new file mode 100644 index 000000000..75bf60dbe --- /dev/null +++ b/src/utils/png-size.ts @@ -0,0 +1,13 @@ +import { promises as fs } from 'node:fs'; +import { AppError } from '../kernel/errors.ts'; + +export async function readPngSize(filePath: string): Promise<{ width: number; height: number }> { + const buffer = await fs.readFile(filePath); + if (buffer.length < 24 || buffer.toString('ascii', 12, 16) !== 'IHDR') { + throw new AppError('COMMAND_FAILED', 'Screenshot file is not a valid PNG'); + } + return { + width: buffer.readUInt32BE(16), + height: buffer.readUInt32BE(20), + }; +} diff --git a/src/utils/screenshot-result.ts b/src/utils/screenshot-result.ts index 3a6705ac1..2311416c6 100644 --- a/src/utils/screenshot-result.ts +++ b/src/utils/screenshot-result.ts @@ -3,6 +3,11 @@ import { isRecord, parsePoint, parseRect } from './parsing.ts'; export type ScreenshotResultData = { path?: string; + width?: number; + height?: number; + logicalWidth?: number; + logicalHeight?: number; + pixelDensity?: number; overlayRefs?: ScreenshotOverlayRef[]; }; @@ -17,6 +22,11 @@ type ScreenshotOverlayRefData = { export function readScreenshotResultData(value: unknown): ScreenshotResultData | undefined { if (!isRecord(value)) return undefined; const path = typeof value.path === 'string' ? value.path : undefined; + const width = typeof value.width === 'number' ? value.width : undefined; + const height = typeof value.height === 'number' ? value.height : undefined; + const logicalWidth = typeof value.logicalWidth === 'number' ? value.logicalWidth : undefined; + const logicalHeight = typeof value.logicalHeight === 'number' ? value.logicalHeight : undefined; + const pixelDensity = typeof value.pixelDensity === 'number' ? value.pixelDensity : undefined; const overlayRefs = Array.isArray(value.overlayRefs) ? value.overlayRefs.filter(isScreenshotOverlayRefData).flatMap((entry) => { const overlayRef = readScreenshotOverlayRef(entry); @@ -25,6 +35,11 @@ export function readScreenshotResultData(value: unknown): ScreenshotResultData | : undefined; return { ...(path ? { path } : {}), + ...(width !== undefined ? { width } : {}), + ...(height !== undefined ? { height } : {}), + ...(logicalWidth !== undefined ? { logicalWidth } : {}), + ...(logicalHeight !== undefined ? { logicalHeight } : {}), + ...(pixelDensity !== undefined ? { pixelDensity } : {}), ...(overlayRefs ? { overlayRefs } : {}), }; } From f28d6931c1b266e614456cc39f76911f068094ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 8 Jul 2026 19:00:17 +0200 Subject: [PATCH 2/6] fix: avoid density metadata after screenshot downscale --- .../request-router-screenshot.test.ts | 43 +++++++++++++++++++ src/daemon/request-generic-dispatch.ts | 15 +++++-- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/daemon/__tests__/request-router-screenshot.test.ts b/src/daemon/__tests__/request-router-screenshot.test.ts index 3179f3d70..281f3a554 100644 --- a/src/daemon/__tests__/request-router-screenshot.test.ts +++ b/src/daemon/__tests__/request-router-screenshot.test.ts @@ -407,6 +407,49 @@ test('iOS simulator screenshot response includes output dimensions and logical d } }); +test('iOS simulator screenshot omits logical density metadata after --max-size downscale', async () => { + const sessionStore = makeSessionStore('agent-device-router-screenshot-'); + sessionStore.set('default', makeIosSession('default')); + const screenshotPath = path.join(os.tmpdir(), `agent-device-ios-max-size-${Date.now()}.png`); + + mockDispatch.mockImplementation(async (_device, command) => { + if (command === 'screenshot') { + writeSolidPng(screenshotPath, 804, 1748); + return { path: screenshotPath }; + } + return {}; + }); + + const handler = createRequestHandler({ + logPath: path.join(os.tmpdir(), 'daemon.log'), + token: 'test-token', + sessionStore, + leaseRegistry: new LeaseRegistry(), + trackDownloadableArtifact: () => 'artifact-id', + }); + + const response = await handler({ + token: 'test-token', + session: 'default', + command: 'screenshot', + positionals: [screenshotPath], + flags: { screenshotMaxSize: 437, screenshotPixelDensity: 2 }, + meta: { requestId: 'req-ios-screenshot-max-size-meta' }, + }); + + expect(response.ok).toBe(true); + if (response.ok) { + expect(response.data).toMatchObject({ + path: screenshotPath, + width: 201, + height: 437, + }); + expect(response.data).not.toHaveProperty('logicalWidth'); + expect(response.data).not.toHaveProperty('logicalHeight'); + expect(response.data).not.toHaveProperty('pixelDensity'); + } +}); + test('screenshot resolves --out flag path against request cwd', async () => { const callerCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-screenshot-out-cwd-')); const sessionStore = makeSessionStore('agent-device-router-screenshot-'); diff --git a/src/daemon/request-generic-dispatch.ts b/src/daemon/request-generic-dispatch.ts index 3fabc9e94..5daed18c4 100644 --- a/src/daemon/request-generic-dispatch.ts +++ b/src/daemon/request-generic-dispatch.ts @@ -205,7 +205,10 @@ async function executeGenericPlatformCommand(params: { await applyScreenshotOverlay(session, data, params.logPath); } if (typeof data?.path === 'string') { - await attachScreenshotMetadata(session, data, request.flags?.screenshotPixelDensity); + await attachScreenshotMetadata(session, data, { + requestedPixelDensity: request.flags?.screenshotPixelDensity, + maxSize: request.flags?.screenshotMaxSize, + }); } return data; } @@ -354,15 +357,19 @@ function assertSupportedScreenshotPixelDensity( async function attachScreenshotMetadata( session: SessionState, data: Record, - requestedPixelDensity: number | undefined, + options: { requestedPixelDensity: number | undefined; maxSize: number | undefined }, ): Promise { const path = data.path; if (typeof path !== 'string') return; const size = await readPngSize(path); data.width = size.width; data.height = size.height; - if (isIosFamily(session.device) && session.device.kind === 'simulator') { - const pixelDensity = requestedPixelDensity ?? 1; + if ( + isIosFamily(session.device) && + session.device.kind === 'simulator' && + options.maxSize === undefined + ) { + const pixelDensity = options.requestedPixelDensity ?? 1; data.pixelDensity = pixelDensity; data.logicalWidth = Math.round(size.width / pixelDensity); data.logicalHeight = Math.round(size.height / pixelDensity); From f5f1ea0fc34921a9383229d26c73738ad72394f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 8 Jul 2026 19:17:16 +0200 Subject: [PATCH 3/6] fix: satisfy screenshot density CI gates --- scripts/integration-progress-model.ts | 4 +- src/cli/commands/screenshot.ts | 25 +----- .../parser/__tests__/cli-help-topics.test.ts | 2 +- src/contracts/screenshot.ts | 80 ++++++++++++------- src/daemon/request-generic-dispatch.ts | 38 ++++++--- src/daemon/response-views.ts | 23 ++++-- src/platforms/apple/interactor.ts | 55 ++++++++----- src/utils/screenshot-result.ts | 30 ++++--- 8 files changed, 157 insertions(+), 100 deletions(-) diff --git a/scripts/integration-progress-model.ts b/scripts/integration-progress-model.ts index 1b4c4511f..1c5f17867 100644 --- a/scripts/integration-progress-model.ts +++ b/scripts/integration-progress-model.ts @@ -320,9 +320,9 @@ function summarizeProviderScenarioFlagExclusions() { keys: ['headless'], }, { - name: 'Apple screenshot status-bar normalization', + name: 'Apple simulator screenshot rendering options', owner: 'iOS platform and screenshot-diff runtime tests', - keys: ['screenshotNormalizeStatusBar'], + keys: ['screenshotNormalizeStatusBar', 'screenshotPixelDensity'], }, ]; } diff --git a/src/cli/commands/screenshot.ts b/src/cli/commands/screenshot.ts index 05b44ff9d..dab6c984a 100644 --- a/src/cli/commands/screenshot.ts +++ b/src/cli/commands/screenshot.ts @@ -5,6 +5,7 @@ import { resolveUserPath } from '../../utils/path-resolution.ts'; import type { AgentDeviceBackend } from '../../backend.ts'; import type { AgentDeviceClient, CaptureScreenshotResult } from '../../client/client.ts'; import { runCliCommand } from '../../commands/cli-runner.ts'; +import { pickScreenshotResultData } from '../../utils/screenshot-result.ts'; import type { CliFlags } from '../parser/cli-flags.ts'; import { writeCommandOutput } from './shared.ts'; import type { ClientCommandHandler } from './router-types.ts'; @@ -23,18 +24,10 @@ export const screenshotCommand: ClientCommandHandler = async ({ positionals, fla writeCommandOutput(flags, result, () => JSON.stringify(result, null, 2)); return true; } - const data = { - path: result.path, - ...(typeof result.width === 'number' ? { width: result.width } : {}), - ...(typeof result.height === 'number' ? { height: result.height } : {}), - ...(typeof result.logicalWidth === 'number' ? { logicalWidth: result.logicalWidth } : {}), - ...(typeof result.logicalHeight === 'number' ? { logicalHeight: result.logicalHeight } : {}), - ...(typeof result.pixelDensity === 'number' ? { pixelDensity: result.pixelDensity } : {}), - ...(result.overlayRefs ? { overlayRefs: result.overlayRefs } : {}), - }; + const data = pickScreenshotResultData(result); writeCommandOutput(flags, data, () => result.overlayRefs - ? `${formatScreenshotSummary(result)} with ${result.overlayRefs.length} refs annotated` + ? `Annotated ${result.overlayRefs.length} refs onto ${result.path}` : formatScreenshotSummary(result), ); return true; @@ -110,17 +103,7 @@ function createClientScreenshotBackend( stabilize: options?.stabilize, surface: options?.surface, }); - return { - path: result.path, - ...(typeof result.width === 'number' ? { width: result.width } : {}), - ...(typeof result.height === 'number' ? { height: result.height } : {}), - ...(typeof result.logicalWidth === 'number' ? { logicalWidth: result.logicalWidth } : {}), - ...(typeof result.logicalHeight === 'number' - ? { logicalHeight: result.logicalHeight } - : {}), - ...(typeof result.pixelDensity === 'number' ? { pixelDensity: result.pixelDensity } : {}), - ...(result.overlayRefs ? { overlayRefs: result.overlayRefs } : {}), - }; + return pickScreenshotResultData(result); }, }; } diff --git a/src/cli/parser/__tests__/cli-help-topics.test.ts b/src/cli/parser/__tests__/cli-help-topics.test.ts index 51d29fd27..51ee1cb17 100644 --- a/src/cli/parser/__tests__/cli-help-topics.test.ts +++ b/src/cli/parser/__tests__/cli-help-topics.test.ts @@ -618,7 +618,7 @@ test('usage renders concise commands inline with descriptions', async () => { assert.match(help, / test \.\.\.\s{2,}Run replay test suites/); assert.match( help, - / screenshot \[path\]\s{2,}Capture screenshot with optional web full-page, desktop/, + / screenshot \[path\]\s{2,}Capture screenshot with optional density, full-page, desktop/, ); assert.match( help, diff --git a/src/contracts/screenshot.ts b/src/contracts/screenshot.ts index 1fcc0a4d6..e098014ac 100644 --- a/src/contracts/screenshot.ts +++ b/src/contracts/screenshot.ts @@ -73,6 +73,21 @@ export const SCREENSHOT_SPECIFIC_FLAG_DEFINITIONS: readonly ScreenshotSpecificFl }, ]; +const SCREENSHOT_SCRIPT_BOOLEAN_FLAGS = [ + { tokens: ['--fullscreen', '--full', '-f'], key: 'screenshotFullscreen' }, + { tokens: ['--no-stabilize'], key: 'screenshotNoStabilize' }, + { tokens: ['--normalize-status-bar'], key: 'screenshotNormalizeStatusBar' }, +] as const; + +const SCREENSHOT_SCRIPT_INT_FLAGS = [ + { + token: '--pixel-density', + key: 'screenshotPixelDensity', + label: 'screenshot --pixel-density', + }, + { token: '--max-size', key: 'screenshotMaxSize', label: 'screenshot --max-size' }, +] as const; + export type ScreenshotRequestFlags = { out?: string; overlayRefs?: boolean; @@ -168,39 +183,42 @@ export function readScreenshotScriptFlag(params: { }): { handled: true; nextIndex: number } | { handled: false } { const { args, flags, index } = params; const token = args[index]; - if (token === '--fullscreen' || token === '--full' || token === '-f') { - flags.screenshotFullscreen = true; - return { handled: true, nextIndex: index }; - } - if (token === '--no-stabilize') { - flags.screenshotNoStabilize = true; - return { handled: true, nextIndex: index }; - } - if (token === '--normalize-status-bar') { - flags.screenshotNormalizeStatusBar = true; - return { handled: true, nextIndex: index }; - } - if (token === '--pixel-density') { - const value = args[index + 1]; - const pixelDensity = value === undefined ? NaN : Number(value); - if (!Number.isInteger(pixelDensity) || pixelDensity < 1) { - throw new AppError('INVALID_ARGS', 'screenshot --pixel-density requires a positive integer'); - } - flags.screenshotPixelDensity = pixelDensity; - return { handled: true, nextIndex: index + 1 }; - } - if (token === '--max-size') { - const value = args[index + 1]; - const maxSize = value === undefined ? NaN : Number(value); - if (!Number.isInteger(maxSize) || maxSize < 1) { - throw new AppError('INVALID_ARGS', 'screenshot --max-size requires a positive integer'); - } - flags.screenshotMaxSize = maxSize; - return { handled: true, nextIndex: index + 1 }; - } - return { handled: false }; + return ( + readScreenshotBooleanScriptFlag(token, flags, index) ?? + readScreenshotIntScriptFlag({ args, index, flags, token }) ?? { handled: false } + ); } function stripUndefined>(value: T): T { return Object.fromEntries(Object.entries(value).filter((entry) => entry[1] !== undefined)) as T; } + +function readScreenshotBooleanScriptFlag( + token: string | undefined, + flags: Partial, + index: number, +): { handled: true; nextIndex: number } | undefined { + const definition = SCREENSHOT_SCRIPT_BOOLEAN_FLAGS.find((entry) => + entry.tokens.some((candidate) => candidate === token), + ); + if (!definition) return undefined; + flags[definition.key] = true; + return { handled: true, nextIndex: index }; +} + +function readScreenshotIntScriptFlag(params: { + args: readonly string[]; + index: number; + flags: Partial; + token: string | undefined; +}): { handled: true; nextIndex: number } | undefined { + const definition = SCREENSHOT_SCRIPT_INT_FLAGS.find((entry) => entry.token === params.token); + if (!definition) return undefined; + const value = params.args[params.index + 1]; + const parsed = value === undefined ? NaN : Number(value); + if (!Number.isInteger(parsed) || parsed < 1) { + throw new AppError('INVALID_ARGS', `${definition.label} requires a positive integer`); + } + params.flags[definition.key] = parsed; + return { handled: true, nextIndex: params.index + 1 }; +} diff --git a/src/daemon/request-generic-dispatch.ts b/src/daemon/request-generic-dispatch.ts index 5daed18c4..a87bee850 100644 --- a/src/daemon/request-generic-dispatch.ts +++ b/src/daemon/request-generic-dispatch.ts @@ -187,12 +187,25 @@ async function executeGenericPlatformCommand(params: { out: string | undefined; dispatchContext: DaemonCommandContext; }): Promise | void> { - const { session, command, request, positionals, out, dispatchContext } = params; - if (command !== 'screenshot') { - return await dispatchCommand(session.device, command, positionals, out, { - ...dispatchContext, - }); + const { session, command, positionals, out, dispatchContext } = params; + if (command === 'screenshot') { + return await executeScreenshotPlatformCommand(params); } + return await dispatchCommand(session.device, command, positionals, out, { + ...dispatchContext, + }); +} + +async function executeScreenshotPlatformCommand(params: { + session: SessionState; + sessionName: string; + logPath: string; + request: DaemonRequest; + positionals: string[]; + out: string | undefined; + dispatchContext: DaemonCommandContext; +}): Promise> { + const { session, request, positionals, out, dispatchContext } = params; assertSupportedScreenshotPixelDensity(session, request.flags?.screenshotPixelDensity); const data = await dispatchScreenshotViaRuntime({ session, @@ -201,15 +214,16 @@ async function executeGenericPlatformCommand(params: { outputPlacement: resolveScreenshotOutputPlacement(request), dispatchContext, }); - if (request.flags?.overlayRefs && typeof data?.path === 'string') { - await applyScreenshotOverlay(session, data, params.logPath); + if (typeof data.path !== 'string') { + return data; } - if (typeof data?.path === 'string') { - await attachScreenshotMetadata(session, data, { - requestedPixelDensity: request.flags?.screenshotPixelDensity, - maxSize: request.flags?.screenshotMaxSize, - }); + if (request.flags?.overlayRefs) { + await applyScreenshotOverlay(session, data, params.logPath); } + await attachScreenshotMetadata(session, data, { + requestedPixelDensity: request.flags?.screenshotPixelDensity, + maxSize: request.flags?.screenshotMaxSize, + }); return data; } diff --git a/src/daemon/response-views.ts b/src/daemon/response-views.ts index 13fc8f7e6..81c1de9c6 100644 --- a/src/daemon/response-views.ts +++ b/src/daemon/response-views.ts @@ -40,6 +40,13 @@ function snapshotView(data: DaemonResponseData, level: ResponseLevel): DaemonRes } const DIGEST_OVERLAY_LIMIT = 12; +const SCREENSHOT_DIGEST_NUMBER_FIELDS = [ + 'width', + 'height', + 'logicalWidth', + 'logicalHeight', + 'pixelDensity', +] as const; /** * Token-cheap screenshot digest: the captured `path` (the primary result), the @@ -60,18 +67,22 @@ function screenshotView(data: DaemonResponseData, level: ResponseLevel): DaemonR .slice(0, DIGEST_OVERLAY_LIMIT) .map((overlay) => ({ ref: overlay.ref, label: overlay.label })); return { - ...(typeof data.path === 'string' ? { path: data.path } : {}), - ...(typeof data.width === 'number' ? { width: data.width } : {}), - ...(typeof data.height === 'number' ? { height: data.height } : {}), - ...(typeof data.logicalWidth === 'number' ? { logicalWidth: data.logicalWidth } : {}), - ...(typeof data.logicalHeight === 'number' ? { logicalHeight: data.logicalHeight } : {}), - ...(typeof data.pixelDensity === 'number' ? { pixelDensity: data.pixelDensity } : {}), + ...pickScreenshotDigestMetadata(data), overlayCount: overlays.length, overlayRefs, ...(data.artifacts !== undefined ? { artifacts: data.artifacts } : {}), }; } +function pickScreenshotDigestMetadata(data: DaemonResponseData): DaemonResponseData { + const metadata: DaemonResponseData = {}; + if (typeof data.path === 'string') metadata.path = data.path; + for (const field of SCREENSHOT_DIGEST_NUMBER_FIELDS) { + if (typeof data[field] === 'number') metadata[field] = data[field]; + } + return metadata; +} + // The semantic attributes of a single matched node an agent reasons about. The // verbose framing a digest drops — geometry (`rect`), tree indices // (`index`/`parentIndex`/`depth`), and process/app plumbing diff --git a/src/platforms/apple/interactor.ts b/src/platforms/apple/interactor.ts index 752a4a94e..338ea83dc 100644 --- a/src/platforms/apple/interactor.ts +++ b/src/platforms/apple/interactor.ts @@ -16,7 +16,12 @@ import { withDiagnosticTimer } from '../../utils/diagnostics.ts'; import { isMacOs, isTvOsDevice, type DeviceInfo } from '../../kernel/device.ts'; import { AppError } from '../../kernel/errors.ts'; import type { RawSnapshotNode } from '../../kernel/snapshot.ts'; -import type { Interactor, RunnerContext } from '../../core/interactor-types.ts'; +import type { + Interactor, + RunnerCallOptions, + RunnerContext, + ScreenshotOptions, +} from '../../core/interactor-types.ts'; import { readSnapshotQualityVerdict, type SnapshotQualityVerdict, @@ -48,23 +53,7 @@ export function createAppleInteractor( }), openDevice: () => openIosDevice(device), close: (app) => closeIosApp(device, app), - screenshot: async (outPath, options) => { - if (isMacOs(device) && options?.surface && options.surface !== 'app') { - await runMacOsScreenshotAction(outPath, { - surface: options.surface, - fullscreen: options.fullscreen, - }); - return; - } - await screenshotIos(device, outPath, { - appBundleId: options?.appBundleId, - pixelDensity: options?.pixelDensity, - fullscreen: options?.fullscreen, - runnerOptions: runnerOpts, - normalizeStatusBar: options?.normalizeStatusBar, - skipIosSimulatorBootCheck: options?.skipIosSimulatorBootCheck, - }); - }, + screenshot: (outPath, options) => runAppleScreenshot(device, outPath, options, runnerOpts), snapshot: async (options) => { const result = readAppleSnapshotResult( await withDiagnosticTimer( @@ -166,6 +155,36 @@ export function createAppleInteractor( }; } +async function runAppleScreenshot( + device: DeviceInfo, + outPath: string, + options: ScreenshotOptions = {}, + runnerOpts: RunnerCallOptions, +): Promise { + if (usesMacOsSurfaceScreenshot(device, options.surface)) { + await runMacOsScreenshotAction(outPath, { + surface: options.surface, + fullscreen: options.fullscreen, + }); + return; + } + await screenshotIos(device, outPath, { + appBundleId: options.appBundleId, + pixelDensity: options.pixelDensity, + fullscreen: options.fullscreen, + runnerOptions: runnerOpts, + normalizeStatusBar: options.normalizeStatusBar, + skipIosSimulatorBootCheck: options.skipIosSimulatorBootCheck, + }); +} + +function usesMacOsSurfaceScreenshot( + device: DeviceInfo, + surface: ScreenshotOptions['surface'], +): surface is Exclude { + return isMacOs(device) && surface !== undefined && surface !== 'app'; +} + function readAppleSnapshotResult(result: Record): { nodes?: RawSnapshotNode[]; truncated?: boolean; diff --git a/src/utils/screenshot-result.ts b/src/utils/screenshot-result.ts index 2311416c6..a9fcabb67 100644 --- a/src/utils/screenshot-result.ts +++ b/src/utils/screenshot-result.ts @@ -11,6 +11,18 @@ export type ScreenshotResultData = { overlayRefs?: ScreenshotOverlayRef[]; }; +export function pickScreenshotResultData(value: ScreenshotResultData): ScreenshotResultData { + return { + ...(typeof value.path === 'string' ? { path: value.path } : {}), + ...(typeof value.width === 'number' ? { width: value.width } : {}), + ...(typeof value.height === 'number' ? { height: value.height } : {}), + ...(typeof value.logicalWidth === 'number' ? { logicalWidth: value.logicalWidth } : {}), + ...(typeof value.logicalHeight === 'number' ? { logicalHeight: value.logicalHeight } : {}), + ...(typeof value.pixelDensity === 'number' ? { pixelDensity: value.pixelDensity } : {}), + ...(value.overlayRefs ? { overlayRefs: value.overlayRefs } : {}), + }; +} + type ScreenshotOverlayRefData = { ref?: unknown; label?: unknown; @@ -33,15 +45,15 @@ export function readScreenshotResultData(value: unknown): ScreenshotResultData | return overlayRef ? [overlayRef] : []; }) : undefined; - return { - ...(path ? { path } : {}), - ...(width !== undefined ? { width } : {}), - ...(height !== undefined ? { height } : {}), - ...(logicalWidth !== undefined ? { logicalWidth } : {}), - ...(logicalHeight !== undefined ? { logicalHeight } : {}), - ...(pixelDensity !== undefined ? { pixelDensity } : {}), - ...(overlayRefs ? { overlayRefs } : {}), - }; + return pickScreenshotResultData({ + path, + width, + height, + logicalWidth, + logicalHeight, + pixelDensity, + overlayRefs, + }); } function readScreenshotOverlayRef( From 2e528da213817a31ac84da5a604172a2e50f2118 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 8 Jul 2026 20:01:45 +0200 Subject: [PATCH 4/6] fix: harden screenshot metadata collection --- .../request-router-screenshot.test.ts | 37 ++++++++ src/daemon/request-generic-dispatch.ts | 15 ++-- .../core/__tests__/screenshot-density.test.ts | 85 +++++++++++++++++++ src/platforms/apple/core/screenshot.ts | 8 ++ src/utils/__tests__/png-size.test.ts | 33 +++++++ src/utils/png-size.ts | 20 +++-- 6 files changed, 185 insertions(+), 13 deletions(-) create mode 100644 src/platforms/apple/core/__tests__/screenshot-density.test.ts create mode 100644 src/utils/__tests__/png-size.test.ts diff --git a/src/daemon/__tests__/request-router-screenshot.test.ts b/src/daemon/__tests__/request-router-screenshot.test.ts index 281f3a554..73af0e588 100644 --- a/src/daemon/__tests__/request-router-screenshot.test.ts +++ b/src/daemon/__tests__/request-router-screenshot.test.ts @@ -407,6 +407,43 @@ test('iOS simulator screenshot response includes output dimensions and logical d } }); +test('non-iOS screenshot response tolerates malformed PNG metadata', async () => { + const sessionStore = makeSessionStore('agent-device-router-screenshot-'); + sessionStore.set('default', makeSession('default')); + const screenshotPath = path.join(os.tmpdir(), `agent-device-android-truncated-${Date.now()}.png`); + + mockDispatch.mockImplementation(async (_device, command) => { + if (command === 'screenshot') { + fs.writeFileSync(screenshotPath, Buffer.alloc(0)); + return { path: screenshotPath }; + } + return {}; + }); + + const handler = createRequestHandler({ + logPath: path.join(os.tmpdir(), 'daemon.log'), + token: 'test-token', + sessionStore, + leaseRegistry: new LeaseRegistry(), + trackDownloadableArtifact: () => 'artifact-id', + }); + + const response = await handler({ + token: 'test-token', + session: 'default', + command: 'screenshot', + positionals: [screenshotPath], + meta: { requestId: 'req-android-screenshot-malformed-png' }, + }); + + expect(response.ok).toBe(true); + if (response.ok) { + expect(response.data).toMatchObject({ path: screenshotPath }); + expect(response.data).not.toHaveProperty('width'); + expect(response.data).not.toHaveProperty('height'); + } +}); + test('iOS simulator screenshot omits logical density metadata after --max-size downscale', async () => { const sessionStore = makeSessionStore('agent-device-router-screenshot-'); sessionStore.set('default', makeIosSession('default')); diff --git a/src/daemon/request-generic-dispatch.ts b/src/daemon/request-generic-dispatch.ts index a87bee850..c8c0a8da9 100644 --- a/src/daemon/request-generic-dispatch.ts +++ b/src/daemon/request-generic-dispatch.ts @@ -375,14 +375,17 @@ async function attachScreenshotMetadata( ): Promise { const path = data.path; if (typeof path !== 'string') return; - const size = await readPngSize(path); + const requiresMetadata = isIosFamily(session.device) && session.device.kind === 'simulator'; + let size: { width: number; height: number }; + try { + size = await readPngSize(path); + } catch (error) { + if (requiresMetadata) throw error; + return; + } data.width = size.width; data.height = size.height; - if ( - isIosFamily(session.device) && - session.device.kind === 'simulator' && - options.maxSize === undefined - ) { + if (requiresMetadata && options.maxSize === undefined) { const pixelDensity = options.requestedPixelDensity ?? 1; data.pixelDensity = pixelDensity; data.logicalWidth = Math.round(size.width / pixelDensity); diff --git a/src/platforms/apple/core/__tests__/screenshot-density.test.ts b/src/platforms/apple/core/__tests__/screenshot-density.test.ts new file mode 100644 index 000000000..c2b9231f3 --- /dev/null +++ b/src/platforms/apple/core/__tests__/screenshot-density.test.ts @@ -0,0 +1,85 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { DeviceInfo } from '../../../../kernel/device.ts'; +import { PNG } from '../../../../utils/png.ts'; +import { screenshotIos } from '../screenshot.ts'; + +test('screenshotIos caches simulator screen scale per device', async () => { + const tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'agent-device-ios-screenshot-scale-cache-'), + ); + const xcrunPath = path.join(tmpDir, 'xcrun'); + const commandLogPath = path.join(tmpDir, 'commands.log'); + const outPath = path.join(tmpDir, 'screen.png'); + const sourcePngPath = path.join(tmpDir, 'source.png'); + const device: DeviceInfo = { + platform: 'apple', + id: 'sim-scale-cache', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }; + + await fs.writeFile(sourcePngPath, PNG.sync.write(new PNG({ width: 1206, height: 2622 }))); + await fs.writeFile( + xcrunPath, + [ + '#!/bin/sh', + 'echo "__XCRUN__ $*" >> "$AGENT_DEVICE_TEST_COMMAND_LOG"', + 'if [ "$1" = "simctl" ] && [ "$2" = "list" ] && [ "$3" = "devices" ] && [ "$4" = "-j" ]; then', + ' echo \'{"devices":{"com.apple.CoreSimulator.SimRuntime.iOS-18-0":[{"udid":"sim-scale-cache","state":"Booted"}]}}\'', + ' exit 0', + 'fi', + 'if [ "$1" = "simctl" ] && [ "$2" = "getenv" ] && [ "$3" = "sim-scale-cache" ] && [ "$4" = "SIMULATOR_MAINSCREEN_SCALE" ]; then', + ' echo "3"', + ' exit 0', + 'fi', + 'if [ "$1" = "simctl" ] && [ "$2" = "io" ] && [ "$3" = "sim-scale-cache" ] && [ "$4" = "screenshot" ]; then', + ' cp "$AGENT_DEVICE_TEST_SCREENSHOT_SOURCE_FILE" "$5"', + ' exit 0', + 'fi', + 'echo "unexpected xcrun args: $*" >&2', + 'exit 1', + '', + ].join('\n'), + 'utf8', + ); + await fs.chmod(xcrunPath, 0o755); + + const previousPath = process.env.PATH; + const previousCommandLog = process.env.AGENT_DEVICE_TEST_COMMAND_LOG; + const previousScreenshotSourceFile = process.env.AGENT_DEVICE_TEST_SCREENSHOT_SOURCE_FILE; + process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`; + process.env.AGENT_DEVICE_TEST_COMMAND_LOG = commandLogPath; + process.env.AGENT_DEVICE_TEST_SCREENSHOT_SOURCE_FILE = sourcePngPath; + + try { + await screenshotIos(device, outPath); + await screenshotIos(device, outPath); + + const logLines = (await fs.readFile(commandLogPath, 'utf8')).trim().split('\n').filter(Boolean); + assert.equal( + logLines.filter( + (line) => line === '__XCRUN__ simctl getenv sim-scale-cache SIMULATOR_MAINSCREEN_SCALE', + ).length, + 1, + ); + assert.equal( + logLines.filter( + (line) => line === '__XCRUN__ simctl io sim-scale-cache screenshot ' + outPath, + ).length, + 2, + ); + } finally { + process.env.PATH = previousPath; + if (previousCommandLog === undefined) delete process.env.AGENT_DEVICE_TEST_COMMAND_LOG; + else process.env.AGENT_DEVICE_TEST_COMMAND_LOG = previousCommandLog; + if (previousScreenshotSourceFile === undefined) + delete process.env.AGENT_DEVICE_TEST_SCREENSHOT_SOURCE_FILE; + else process.env.AGENT_DEVICE_TEST_SCREENSHOT_SOURCE_FILE = previousScreenshotSourceFile; + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}); diff --git a/src/platforms/apple/core/screenshot.ts b/src/platforms/apple/core/screenshot.ts index 34088ecd1..bd3b2f92e 100644 --- a/src/platforms/apple/core/screenshot.ts +++ b/src/platforms/apple/core/screenshot.ts @@ -65,6 +65,9 @@ const defaultSimulatorScreenshotFlowDeps: SimulatorScreenshotFlowDeps = { captureWithRunner: captureScreenshotViaRunner, shouldFallbackToRunner: shouldRetryIosSimulatorScreenshot, }; + +const iosSimulatorMainScreenScaleCache = new Map(); + export async function screenshotIos( device: DeviceInfo, outPath: string, @@ -487,6 +490,10 @@ async function normalizeIosSimulatorScreenshotDensity( } async function readIosSimulatorMainScreenScale(device: DeviceInfo): Promise { + const cachedScale = iosSimulatorMainScreenScaleCache.get(device.id); + if (cachedScale !== undefined) { + return cachedScale; + } const scaleResult = await runSimctl(device, ['getenv', device.id, 'SIMULATOR_MAINSCREEN_SCALE'], { timeoutMs: 5_000, }); @@ -497,6 +504,7 @@ async function readIosSimulatorMainScreenScale(device: DeviceInfo): Promise { + const filePath = tmpPngPath('size'); + fs.writeFileSync(filePath, PNG.sync.write(new PNG({ width: 17, height: 23 }))); + + assert.deepEqual(await readPngSize(filePath), { width: 17, height: 23 }); +}); + +test('readPngSize rejects malformed PNG files', async () => { + const filePath = tmpPngPath('malformed'); + fs.writeFileSync(filePath, Buffer.alloc(0)); + + await assert.rejects(readPngSize(filePath), (error) => { + assert.equal(error instanceof AppError, true); + assert.equal((error as AppError).code, 'COMMAND_FAILED'); + return true; + }); +}); + +function tmpPngPath(prefix: string): string { + return path.join( + fs.mkdtempSync(path.join(os.tmpdir(), `agent-device-png-${prefix}-`)), + 'image.png', + ); +} diff --git a/src/utils/png-size.ts b/src/utils/png-size.ts index 75bf60dbe..849cb8437 100644 --- a/src/utils/png-size.ts +++ b/src/utils/png-size.ts @@ -2,12 +2,18 @@ import { promises as fs } from 'node:fs'; import { AppError } from '../kernel/errors.ts'; export async function readPngSize(filePath: string): Promise<{ width: number; height: number }> { - const buffer = await fs.readFile(filePath); - if (buffer.length < 24 || buffer.toString('ascii', 12, 16) !== 'IHDR') { - throw new AppError('COMMAND_FAILED', 'Screenshot file is not a valid PNG'); + const file = await fs.open(filePath, 'r'); + try { + const buffer = Buffer.alloc(24); + const { bytesRead } = await file.read(buffer, 0, buffer.length, 0); + if (bytesRead < buffer.length || buffer.toString('ascii', 12, 16) !== 'IHDR') { + throw new AppError('COMMAND_FAILED', 'Screenshot file is not a valid PNG'); + } + return { + width: buffer.readUInt32BE(16), + height: buffer.readUInt32BE(20), + }; + } finally { + await file.close(); } - return { - width: buffer.readUInt32BE(16), - height: buffer.readUInt32BE(20), - }; } From 5c7cc5f59b6885ba458e8642fc964e3049bb73b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 8 Jul 2026 20:16:32 +0200 Subject: [PATCH 5/6] refactor: centralize screenshot density policy --- src/daemon/request-generic-dispatch.ts | 60 +++++--------------- src/platforms/apple/core/screenshot.ts | 18 +++--- src/utils/screenshot-density.ts | 77 ++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 56 deletions(-) create mode 100644 src/utils/screenshot-density.ts diff --git a/src/daemon/request-generic-dispatch.ts b/src/daemon/request-generic-dispatch.ts index c8c0a8da9..162d4fb58 100644 --- a/src/daemon/request-generic-dispatch.ts +++ b/src/daemon/request-generic-dispatch.ts @@ -4,7 +4,6 @@ import { requireCommandSupported } from './handlers/response.ts'; import { SessionStore } from './session-store.ts'; import type { DaemonCommandContext } from './context.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; -import { isIosFamily } from '../kernel/device.ts'; import { buildSnapshotState, captureSnapshotData } from './handlers/snapshot-capture.ts'; import { setSessionSnapshot } from './session-snapshot.ts'; import { @@ -25,10 +24,13 @@ import { recordTouchVisualizationEvent, } from './recording-gestures.ts'; import { markPostGestureStabilization } from './post-gesture-stabilization.ts'; -import { AppError, normalizeError } from '../kernel/errors.ts'; +import { normalizeError } from '../kernel/errors.ts'; import { shouldGuardAndroidBlockingDialog } from './daemon-command-registry.ts'; import { isActiveProviderDevice } from '../provider-device-runtime.ts'; -import { readPngSize } from '../utils/png-size.ts'; +import { + assertSupportedScreenshotPixelDensity, + readScreenshotResultMetadata, +} from '../utils/screenshot-density.ts'; const GESTURE_PLATFORM_COMMANDS: Readonly> = { pan: 'pan', @@ -206,7 +208,7 @@ async function executeScreenshotPlatformCommand(params: { dispatchContext: DaemonCommandContext; }): Promise> { const { session, request, positionals, out, dispatchContext } = params; - assertSupportedScreenshotPixelDensity(session, request.flags?.screenshotPixelDensity); + assertSupportedScreenshotPixelDensity(session.device, request.flags?.screenshotPixelDensity); const data = await dispatchScreenshotViaRuntime({ session, sessionName: params.sessionName, @@ -220,10 +222,15 @@ async function executeScreenshotPlatformCommand(params: { if (request.flags?.overlayRefs) { await applyScreenshotOverlay(session, data, params.logPath); } - await attachScreenshotMetadata(session, data, { - requestedPixelDensity: request.flags?.screenshotPixelDensity, - maxSize: request.flags?.screenshotMaxSize, - }); + Object.assign( + data, + await readScreenshotResultMetadata({ + device: session.device, + path: data.path, + requestedPixelDensity: request.flags?.screenshotPixelDensity, + maxSize: request.flags?.screenshotMaxSize, + }), + ); return data; } @@ -356,43 +363,6 @@ async function applyScreenshotOverlay( data.overlayRefs = overlayRefs; } -function assertSupportedScreenshotPixelDensity( - session: SessionState, - pixelDensity: number | undefined, -): void { - if (pixelDensity === undefined) return; - if (isIosFamily(session.device) && session.device.kind === 'simulator') return; - throw new AppError( - 'UNSUPPORTED_OPERATION', - '--pixel-density is currently supported only on iOS-family simulators', - ); -} - -async function attachScreenshotMetadata( - session: SessionState, - data: Record, - options: { requestedPixelDensity: number | undefined; maxSize: number | undefined }, -): Promise { - const path = data.path; - if (typeof path !== 'string') return; - const requiresMetadata = isIosFamily(session.device) && session.device.kind === 'simulator'; - let size: { width: number; height: number }; - try { - size = await readPngSize(path); - } catch (error) { - if (requiresMetadata) throw error; - return; - } - data.width = size.width; - data.height = size.height; - if (requiresMetadata && options.maxSize === undefined) { - const pixelDensity = options.requestedPixelDensity ?? 1; - data.pixelDensity = pixelDensity; - data.logicalWidth = Math.round(size.width / pixelDensity); - data.logicalHeight = Math.round(size.height / pixelDensity); - } -} - function recordVisualizationAndAction(params: { session: SessionState; sessionStore: SessionStore; diff --git a/src/platforms/apple/core/screenshot.ts b/src/platforms/apple/core/screenshot.ts index bd3b2f92e..c79a26a39 100644 --- a/src/platforms/apple/core/screenshot.ts +++ b/src/platforms/apple/core/screenshot.ts @@ -6,6 +6,7 @@ import { AppError } from '../../../kernel/errors.ts'; import type { ExecOptions } from '../../../utils/exec.ts'; import { resizePngFile } from '../../../utils/png-resize.ts'; import { readPngSize } from '../../../utils/png-size.ts'; +import { computeDensityScaledScreenshotSize } from '../../../utils/screenshot-density.ts'; import { Deadline, retryWithPolicy } from '../../../utils/retry.ts'; import { @@ -476,17 +477,12 @@ async function normalizeIosSimulatorScreenshotDensity( pixelDensity: number | undefined, ): Promise { const sourcePixelDensity = await readIosSimulatorMainScreenScale(device); - const targetPixelDensity = pixelDensity ?? 1; - if (sourcePixelDensity === targetPixelDensity) { - return; - } - - const metadata = await readPngSize(outPath); - const logicalWidth = metadata.width / sourcePixelDensity; - const logicalHeight = metadata.height / sourcePixelDensity; - const targetWidth = Math.max(1, Math.round(logicalWidth * targetPixelDensity)); - const targetHeight = Math.max(1, Math.round(logicalHeight * targetPixelDensity)); - await resizePngFile(outPath, targetWidth, targetHeight); + const targetSize = computeDensityScaledScreenshotSize( + await readPngSize(outPath), + sourcePixelDensity, + pixelDensity, + ); + if (targetSize) await resizePngFile(outPath, targetSize.width, targetSize.height); } async function readIosSimulatorMainScreenScale(device: DeviceInfo): Promise { diff --git a/src/utils/screenshot-density.ts b/src/utils/screenshot-density.ts new file mode 100644 index 000000000..ca04615fe --- /dev/null +++ b/src/utils/screenshot-density.ts @@ -0,0 +1,77 @@ +import { isIosFamily, type DeviceInfo } from '../kernel/device.ts'; +import { AppError } from '../kernel/errors.ts'; +import type { ScreenshotResultData } from './screenshot-result.ts'; +import { readPngSize } from './png-size.ts'; + +type ScreenshotDensityDevice = Pick; + +type ScreenshotPixelSize = { + width: number; + height: number; +}; + +const DEFAULT_SCREENSHOT_PIXEL_DENSITY = 1; + +export function assertSupportedScreenshotPixelDensity( + device: ScreenshotDensityDevice, + pixelDensity: number | undefined, +): void { + if (pixelDensity === undefined || supportsScreenshotPixelDensity(device)) return; + throw new AppError( + 'UNSUPPORTED_OPERATION', + '--pixel-density is currently supported only on iOS-family simulators', + ); +} + +export function computeDensityScaledScreenshotSize( + size: ScreenshotPixelSize, + sourcePixelDensity: number, + requestedPixelDensity: number | undefined, +): ScreenshotPixelSize | undefined { + const targetPixelDensity = resolveScreenshotPixelDensity(requestedPixelDensity); + if (sourcePixelDensity === targetPixelDensity) return undefined; + + return { + width: Math.max(1, Math.round((size.width / sourcePixelDensity) * targetPixelDensity)), + height: Math.max(1, Math.round((size.height / sourcePixelDensity) * targetPixelDensity)), + }; +} + +export async function readScreenshotResultMetadata(params: { + device: ScreenshotDensityDevice; + path: string; + requestedPixelDensity: number | undefined; + maxSize: number | undefined; +}): Promise { + const requiresDensityMetadata = supportsScreenshotPixelDensity(params.device); + let size: ScreenshotPixelSize; + try { + size = await readPngSize(params.path); + } catch (error) { + if (requiresDensityMetadata) throw error; + return {}; + } + const result: ScreenshotResultData = { + width: size.width, + height: size.height, + }; + if (!supportsScreenshotPixelDensity(params.device) || params.maxSize !== undefined) { + return result; + } + + const pixelDensity = resolveScreenshotPixelDensity(params.requestedPixelDensity); + return { + ...result, + logicalWidth: Math.round(size.width / pixelDensity), + logicalHeight: Math.round(size.height / pixelDensity), + pixelDensity, + }; +} + +function resolveScreenshotPixelDensity(pixelDensity: number | undefined): number { + return pixelDensity ?? DEFAULT_SCREENSHOT_PIXEL_DENSITY; +} + +function supportsScreenshotPixelDensity(device: ScreenshotDensityDevice): boolean { + return isIosFamily(device) && device.kind === 'simulator'; +} From f770fc82d220cfc54d3734bd9a400cd275d81637 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 8 Jul 2026 20:19:53 +0200 Subject: [PATCH 6/6] refactor: reuse screenshot density support check --- src/utils/screenshot-density.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/screenshot-density.ts b/src/utils/screenshot-density.ts index ca04615fe..638655c1a 100644 --- a/src/utils/screenshot-density.ts +++ b/src/utils/screenshot-density.ts @@ -55,7 +55,7 @@ export async function readScreenshotResultMetadata(params: { width: size.width, height: size.height, }; - if (!supportsScreenshotPixelDensity(params.device) || params.maxSize !== undefined) { + if (!requiresDensityMetadata || params.maxSize !== undefined) { return result; }