From 9919b881db4129cb8aef3208b5893ee693ea63f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 30 Jul 2026 19:54:19 +0200 Subject: [PATCH] fix(ios): deny viewport during capability admission on Apple targets The capability matrix admitted `viewport` on every Apple simulator and device while no Apple interactor implements `setViewport`, so the command was routed to the device and only rejected inside dispatch with the backend-shaped "viewport is not supported by this backend". Nothing can serve it there: Apple screen geometry is fixed by the selected device type and neither simctl nor XCTest exposes a resize primitive, so viewport is a web-surface contract (its CLI summary and docs already say so). Deny it in the descriptor's capability facet, matching the adjacent Android denial, and give the Apple family an unsupported hint that names the surface that does support it. Admission now fails before the request reaches the device: "viewport is not supported on this device" with supportedOn: web. The iOS simulator coverage manifest's known-gap row becomes a capability-denial row owned by the static coverage test, which retires the whole known-gap level and the `full:known-gaps` live scenario that existed only to pin this failure. Closes #1407 --- CHANGELOG.md | 1 + .../apple-os-capability-table-parity.test.ts | 4 +++ src/core/__tests__/capabilities.test.ts | 22 ++++++++++++++-- .../capability-plugin-routing-parity.test.ts | 4 +++ src/core/command-descriptor/registry.ts | 10 ++++--- src/platforms/apple/plugin.ts | 4 +++ .../ios-simulator-e2e/coverage-manifest.ts | 21 +++++---------- .../live-device-lifecycle.ts | 11 -------- .../ios-simulator-e2e/live-runner.ts | 6 +---- .../ios-simulator-e2e/scenarios.ts | 2 -- .../smoke-ios-simulator-coverage.test.ts | 26 +++++++++---------- 11 files changed, 60 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75d5bc4fec..d7cbf929bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- `viewport` is now rejected during capability admission on Apple targets instead of reaching the device and failing inside dispatch. No Apple backend can resize a screen — simulator and device geometry is fixed by the selected device type — so `viewport` on iOS/iPadOS/tvOS/macOS now fails with `UNSUPPORTED_OPERATION`, `viewport is not supported on this device`, and a hint pointing at `--platform web` and at picking a different simulator. `capabilities` no longer advertises `viewport` on Apple targets. Web viewport resizing (`agent-device viewport 1280 900 --platform web`) is unchanged, and Android was already denied. - `--save-script` is now accepted only by the commands that declare it — `open`, `close`, and `replay`. A hand-built daemon request (or a `batch` step) that set `saveScript` on any other command, such as `record` or `trace`, used to arm script publication and could write a `.ad` artifact; it is now rejected with `INVALID_ARGS` before the request reaches admission, the device, or any handler. CLI, Node, and MCP usage of `--save-script` on its documented commands is unchanged. - `diff screenshot` no longer runs the retired best-effort OCR and non-text analyzers. Their optional `ocr` and `nonTextDeltas` fields remain in the result type for source compatibility but are no longer emitted; use the baseline/current images and diff artifact with vision for qualitative interpretation. - Breaking: removed the deprecated `--session-locked` and `--session-lock-conflicts` flags. Use `--session-lock reject|strip` instead; passing either old flag now fails with `Unknown flag: ... Use --session-lock reject|strip instead.` diff --git a/src/__tests__/contracts/apple-os-capability-table-parity.test.ts b/src/__tests__/contracts/apple-os-capability-table-parity.test.ts index 4f6206437b..74c5699b08 100644 --- a/src/__tests__/contracts/apple-os-capability-table-parity.test.ts +++ b/src/__tests__/contracts/apple-os-capability-table-parity.test.ts @@ -97,6 +97,10 @@ const SUPPORTS_REF: Record boolean> = { audio: isAudioProbeSupportedDevice, }; const HINT_REF: Record string | undefined> = { + viewport: (device) => + device.platform === 'apple' + ? 'viewport resizes web targets only (--platform web). Apple screen geometry is fixed by the selected simulator or device type — open a different simulator to test another screen size.' + : undefined, apps: coreDeviceOnlyPhysicalOperationHint, install: coreDeviceOnlyPhysicalOperationHint, reinstall: coreDeviceOnlyPhysicalOperationHint, diff --git a/src/core/__tests__/capabilities.test.ts b/src/core/__tests__/capabilities.test.ts index 91f0031c07..eca8a9414a 100644 --- a/src/core/__tests__/capabilities.test.ts +++ b/src/core/__tests__/capabilities.test.ts @@ -221,9 +221,9 @@ test('core commands support iOS simulator, iOS device, and Android', () => { ); }); -test('Android denies Apple runner preparation and viewport mutation until durable backends exist', () => { +test('Android denies Apple runner preparation until a durable backend exists', () => { assertCommandSupport( - ['prepare', 'viewport'], + ['prepare'], [ { device: iosSimulator, expected: true, label: 'on iOS simulator' }, { device: macOsDevice, expected: true, label: 'on macOS' }, @@ -234,6 +234,24 @@ test('Android denies Apple runner preparation and viewport mutation until durabl ); }); +test('viewport resizing is admitted only on web, where a backend exists', () => { + assertCommandSupport( + ['viewport'], + [ + { device: webDevice, expected: true, label: 'on web' }, + { device: iosSimulator, expected: false, label: 'on iOS simulator' }, + { device: iosDevice, expected: false, label: 'on iOS device' }, + { device: macOsDevice, expected: false, label: 'on macOS' }, + { device: tvOsSimulator, expected: false, label: 'on tvOS simulator' }, + { device: androidDevice, expected: false, label: 'on Android device' }, + { device: androidEmulator, expected: false, label: 'on Android emulator' }, + { device: linuxDevice, expected: false, label: 'on linux' }, + ], + ); + assert.match(unsupportedHintForDevice('viewport', iosSimulator) ?? '', /--platform web/); + assert.equal(unsupportedHintForDevice('viewport', webDevice), undefined); +}); + test('capabilities reject CoreDevice-only commands for XCTest-backed devices', () => { const coreDeviceOnlyCommands = [ 'apps', diff --git a/src/core/__tests__/capability-plugin-routing-parity.test.ts b/src/core/__tests__/capability-plugin-routing-parity.test.ts index 1ebe09bb76..2cba24e774 100644 --- a/src/core/__tests__/capability-plugin-routing-parity.test.ts +++ b/src/core/__tests__/capability-plugin-routing-parity.test.ts @@ -157,6 +157,10 @@ const SUPPORTS_REF: Record boolean> = { audio: supportsHostAudioProbe, }; const HINT_REF: Record string | undefined> = { + viewport: (device) => + device.platform === 'apple' + ? 'viewport resizes web targets only (--platform web). Apple screen geometry is fixed by the selected simulator or device type — open a different simulator to test another screen size.' + : undefined, apps: coreDeviceOnlyPhysicalOperationHint, install: coreDeviceOnlyPhysicalOperationHint, reinstall: coreDeviceOnlyPhysicalOperationHint, diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index 634c6c60b0..77a8b15fb8 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -1144,9 +1144,13 @@ export const RAW_COMMAND_DESCRIPTORS = [ recordingEffect: 'mutates-app', daemon: { route: 'generic', refFrameEffect: 'may-invalidate' }, dispatch: {}, - // Android has no durable viewport set/read/reset lifecycle. Deny it until - // that contract, including cleanup, exists instead of accepting a no-op. - capability: { apple: APPLE_SIM_AND_DEVICE, android: {}, linux: LINUX_NONE }, + // Viewport resizing is a web-surface contract (`WEB_SETTING_COMMANDS` in + // src/core/capabilities.ts adds the only admitting bucket). No device platform + // has a durable viewport set/read/reset lifecycle: Apple screen geometry is + // fixed by the selected simulator/device type and neither simctl nor XCTest can + // resize it, and Android has no backend either. Deny both instead of admitting a + // command dispatch can only reject (#1407). + capability: { apple: {}, android: {}, linux: LINUX_NONE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, }, diff --git a/src/platforms/apple/plugin.ts b/src/platforms/apple/plugin.ts index c4b686c826..755a448412 100644 --- a/src/platforms/apple/plugin.ts +++ b/src/platforms/apple/plugin.ts @@ -120,6 +120,10 @@ const APPLE_UNSUPPORTED_HINT_BY_DEFAULT: Record< [PUBLIC_COMMANDS.logs]: coreDeviceOnlyPhysicalOperationHint, [PUBLIC_COMMANDS.perf]: coreDeviceOnlyPhysicalOperationHint, [PUBLIC_COMMANDS.record]: coreDeviceOnlyPhysicalOperationHint, + [PUBLIC_COMMANDS.viewport]: (device) => + device.platform === 'apple' + ? 'viewport resizes web targets only (--platform web). Apple screen geometry is fixed by the selected simulator or device type — open a different simulator to test another screen size.' + : undefined, [PUBLIC_COMMANDS.tvRemote]: (device) => device.platform === 'android' ? device.target === 'tv' diff --git a/test/integration/ios-simulator-e2e/coverage-manifest.ts b/test/integration/ios-simulator-e2e/coverage-manifest.ts index 75c129135b..a86f11459d 100644 --- a/test/integration/ios-simulator-e2e/coverage-manifest.ts +++ b/test/integration/ios-simulator-e2e/coverage-manifest.ts @@ -17,12 +17,6 @@ export type IosSimulatorCoverageEntry = assertion: string; level: 'command-contract' | 'workflow-live' | 'capability-denial'; owner: RepositoryEvidence; - } - | { - assertion: string; - level: 'known-gap'; - owner: string; - trackingIssue: string; }; const C = PUBLIC_COMMANDS; @@ -199,19 +193,18 @@ export const IOS_SIMULATOR_E2E_COVERAGE = { }, [C.type]: live('smoke:form-input', 'typed suffix is read back from a focused fixture field'), [C.viewport]: { - assertion: 'capability currently admits iOS while the Apple interactor has no viewport backend', - level: 'known-gap', - owner: 'full:known-gaps', - trackingIssue: '#1407', + assertion: 'iOS simulator capability model rejects viewport resizing, a web-only contract', + level: 'capability-denial', + owner: { + path: 'test/integration/smoke-ios-simulator-coverage.test.ts', + test: 'capability classifications match executable simulator behavior', + }, }, [C.wait]: live('smoke:automation-input', 'polling observes durable fixture state'), } satisfies Record; export function liveCommandsForScenario(scenarioId: string): PublicCommand[] { return Object.entries(IOS_SIMULATOR_E2E_COVERAGE) - .filter( - ([, entry]) => - (entry.level === 'live' || entry.level === 'known-gap') && entry.owner === scenarioId, - ) + .filter(([, entry]) => entry.level === 'live' && entry.owner === scenarioId) .map(([command]) => command as PublicCommand); } diff --git a/test/integration/ios-simulator-e2e/live-device-lifecycle.ts b/test/integration/ios-simulator-e2e/live-device-lifecycle.ts index 55048af6ea..dfb607e5bd 100644 --- a/test/integration/ios-simulator-e2e/live-device-lifecycle.ts +++ b/test/integration/ios-simulator-e2e/live-device-lifecycle.ts @@ -63,14 +63,3 @@ export async function assertDeviceLifecycle(context: LiveContext): Promise } throw primaryError; } - -export async function assertKnownGaps(context: LiveContext): Promise { - const viewport = await runStep( - context, - 'pin unsupported Apple viewport backend', - ['viewport', '390', '844'], - { expectFailure: true }, - ); - assert.equal(viewport.json?.error?.code, 'UNSUPPORTED_OPERATION', JSON.stringify(viewport.json)); - verifyCommand(context, C.viewport, 'live Apple dispatch returns typed UNSUPPORTED_OPERATION'); -} diff --git a/test/integration/ios-simulator-e2e/live-runner.ts b/test/integration/ios-simulator-e2e/live-runner.ts index 3248504464..85686fc236 100644 --- a/test/integration/ios-simulator-e2e/live-runner.ts +++ b/test/integration/ios-simulator-e2e/live-runner.ts @@ -11,7 +11,7 @@ import { requireNodeRect, } from './live-assertions.ts'; import { assertAutomationInput } from './live-automation-scenario.ts'; -import { assertDeviceLifecycle, assertKnownGaps } from './live-device-lifecycle.ts'; +import { assertDeviceLifecycle } from './live-device-lifecycle.ts'; import { assertLifecycleAndSystem, assertObservabilityAndArtifacts, @@ -42,10 +42,6 @@ const LIVE_SCENARIOS = bindIosSimulatorScenarios({ fixtureReplays: assertFixtureReplays, formInput: assertFormInput, inventoryInstall: assertInventoryAndInstall, - knownGaps: async (context) => { - await assertKnownGaps(context); - await assertClose(context); - }, lifecycleSystem: assertLifecycleAndSystem, observabilityArtifacts: assertObservabilityAndArtifacts, }); diff --git a/test/integration/ios-simulator-e2e/scenarios.ts b/test/integration/ios-simulator-e2e/scenarios.ts index e2bceb944c..a55ce3b3f5 100644 --- a/test/integration/ios-simulator-e2e/scenarios.ts +++ b/test/integration/ios-simulator-e2e/scenarios.ts @@ -10,7 +10,6 @@ type ScenarioRunnerKey = | 'fixtureReplays' | 'formInput' | 'inventoryInstall' - | 'knownGaps' | 'lifecycleSystem' | 'observabilityArtifacts'; @@ -29,7 +28,6 @@ const SCENARIO_DEFINITIONS: readonly ScenarioDefinition[] = [ runner: 'observabilityArtifacts', tier: 'full', }, - { id: 'full:known-gaps', runner: 'knownGaps', tier: 'full' }, { id: 'full:fixture-replays', runner: 'fixtureReplays', tier: 'full' }, { id: 'full:device-lifecycle', runner: 'deviceLifecycle', tier: 'full' }, ] as const; diff --git a/test/integration/smoke-ios-simulator-coverage.test.ts b/test/integration/smoke-ios-simulator-coverage.test.ts index b573d5e51b..3c8f72cfbf 100644 --- a/test/integration/smoke-ios-simulator-coverage.test.ts +++ b/test/integration/smoke-ios-simulator-coverage.test.ts @@ -11,7 +11,10 @@ import { swipePayloadFromPositionals, } from '@agent-device/contracts/interaction'; import { PUBLIC_COMMANDS } from '../../src/command-catalog.ts'; -import { isCommandSupportedOnDevice } from '../../src/core/capabilities.ts'; +import { + isCommandSupportedOnDevice, + unsupportedHintForDevice, +} from '../../src/core/capabilities.ts'; import { parseReplayScriptDetailed } from '../../src/replay/script.ts'; import { IOS_SIMULATOR_BEHAVIOR_COVERAGE } from './ios-simulator-e2e/behavior-coverage.ts'; import { @@ -44,9 +47,6 @@ test('iOS simulator coverage exhaustively classifies the public catalog', () => assert.ok(entry.owner.path.trim().length > 0, `${command} needs an evidence path`); assert.ok(entry.owner.test.trim().length > 0, `${command} needs named evidence`); } - if (entry.level === 'known-gap') { - assert.match(entry.trackingIssue, /^#\d+$/, `${command} gap needs a tracking issue`); - } } }); @@ -56,7 +56,7 @@ test('live command claims are owned by executable scenarios', () => { ); for (const [command, entry] of Object.entries(IOS_SIMULATOR_E2E_COVERAGE)) { - if (entry.level !== 'live' && entry.level !== 'known-gap') continue; + if (entry.level !== 'live') continue; const scenario = scenariosById.get(entry.owner); assert.ok(scenario, `${command} references missing scenario ${entry.owner}`); assert.ok( @@ -96,7 +96,7 @@ test('mobile behavior patterns are owned by live scenarios or executable workflo test('non-live owners name concrete executable repository evidence', () => { for (const [command, entry] of Object.entries(IOS_SIMULATOR_E2E_COVERAGE)) { - if (entry.level === 'live' || entry.level === 'known-gap') continue; + if (entry.level === 'live') continue; const ownerPath = path.resolve(entry.owner.path); assert.ok(fs.existsSync(ownerPath), `${command} owner does not exist: ${entry.owner.path}`); assert.ok( @@ -161,14 +161,12 @@ test('capability classifications match executable simulator behavior', () => { assert.equal(isCommandSupportedOnDevice(PUBLIC_COMMANDS.tvRemote, IOS_SIMULATOR), false); assert.equal(IOS_SIMULATOR_E2E_COVERAGE[PUBLIC_COMMANDS.tvRemote].level, 'capability-denial'); - assert.equal(isCommandSupportedOnDevice(PUBLIC_COMMANDS.viewport, IOS_SIMULATOR), true); - assert.equal(IOS_SIMULATOR_E2E_COVERAGE[PUBLIC_COMMANDS.viewport].level, 'known-gap'); - const viewportScenario = IOS_SIMULATOR_LIVE_SCENARIOS.find( - (scenario) => scenario.id === IOS_SIMULATOR_E2E_COVERAGE[PUBLIC_COMMANDS.viewport].owner, - ); - assert.ok( - viewportScenario && - liveCommandsForScenario(viewportScenario.id).includes(PUBLIC_COMMANDS.viewport), + assert.equal(isCommandSupportedOnDevice(PUBLIC_COMMANDS.viewport, IOS_SIMULATOR), false); + assert.equal(IOS_SIMULATOR_E2E_COVERAGE[PUBLIC_COMMANDS.viewport].level, 'capability-denial'); + assert.match( + unsupportedHintForDevice(PUBLIC_COMMANDS.viewport, IOS_SIMULATOR) ?? '', + /--platform web/, + 'viewport denial names the surface that does support it', ); });