From e70fac7b442977d1d3e7286bf44afa1f485f13bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 30 Jul 2026 20:22:17 +0200 Subject: [PATCH] fix(ios): deny viewport during capability admission on Apple targets (#1507) 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 75d5bc4fe..d7cbf929b 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 4f6206437..74c5699b0 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 91f0031c0..eca8a9414 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 1ebe09bb7..2cba24e77 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 634c6c60b..77a8b15fb 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 c4b686c82..755a44841 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 75c129135..a86f11459 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 55048af6e..dfb607e5b 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 324850446..85686fc23 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 e2bceb944..a55ce3b3f 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 b573d5e51..3c8f72cfb 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', ); });