Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ const SUPPORTS_REF: Record<string, (device: DeviceInfo) => boolean> = {
audio: isAudioProbeSupportedDevice,
};
const HINT_REF: Record<string, (device: DeviceInfo) => 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,
Expand Down
22 changes: 20 additions & 2 deletions src/core/__tests__/capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions src/core/__tests__/capability-plugin-routing-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,10 @@ const SUPPORTS_REF: Record<string, (device: DeviceInfo) => boolean> = {
audio: supportsHostAudioProbe,
};
const HINT_REF: Record<string, (device: DeviceInfo) => 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,
Expand Down
10 changes: 7 additions & 3 deletions src/core/command-descriptor/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
4 changes: 4 additions & 0 deletions src/platforms/apple/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
21 changes: 7 additions & 14 deletions test/integration/ios-simulator-e2e/coverage-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<PublicCommand, IosSimulatorCoverageEntry>;

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);
}
11 changes: 0 additions & 11 deletions test/integration/ios-simulator-e2e/live-device-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,3 @@ export async function assertDeviceLifecycle(context: LiveContext): Promise<void>
}
throw primaryError;
}

export async function assertKnownGaps(context: LiveContext): Promise<void> {
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');
}
6 changes: 1 addition & 5 deletions test/integration/ios-simulator-e2e/live-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -42,10 +42,6 @@ const LIVE_SCENARIOS = bindIosSimulatorScenarios<LiveContext>({
fixtureReplays: assertFixtureReplays,
formInput: assertFormInput,
inventoryInstall: assertInventoryAndInstall,
knownGaps: async (context) => {
await assertKnownGaps(context);
await assertClose(context);
},
lifecycleSystem: assertLifecycleAndSystem,
observabilityArtifacts: assertObservabilityAndArtifacts,
});
Expand Down
2 changes: 0 additions & 2 deletions test/integration/ios-simulator-e2e/scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ type ScenarioRunnerKey =
| 'fixtureReplays'
| 'formInput'
| 'inventoryInstall'
| 'knownGaps'
| 'lifecycleSystem'
| 'observabilityArtifacts';

Expand All @@ -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;
Expand Down
26 changes: 12 additions & 14 deletions test/integration/smoke-ios-simulator-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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`);
}
}
});

Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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',
);
});

Expand Down
Loading