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', ); });