refactor: migrate viewport to request runtime (#1864)

* refactor: migrate viewport to request runtime

* fix: preserve viewport cutover evidence
This commit is contained in:
Michał Pierzchała
2026-08-19 10:38:27 +02:00
committed by GitHub
parent fda81c5121
commit 37b1bc8cbd
51 changed files with 657 additions and 122 deletions
@@ -52,4 +52,5 @@ export type {
FindLocator,
ScreenshotResultData,
} from '../snapshot-types.ts';
export { readViewportDimensions } from '../viewport.ts';
export type { ViewportCommandResult } from '../viewport.ts';
@@ -215,6 +215,7 @@ export {
defineUse,
resolveSnapshotRuntimePlan,
snapshotRuntimePlanUses,
viewportRuntimeUse,
} from '../platform-runtime-operations.ts';
export type { SnapshotRuntimePlan } from '../platform-runtime-operations.ts';
export type {
@@ -249,6 +250,12 @@ export type {
SnapshotRuntimeOperationFacts,
SnapshotResult,
} from '../snapshot-runtime.ts';
export { viewportRuntimeOperationFacts } from '../viewport-runtime.ts';
export type {
SetViewportInput,
ViewportRuntimeOperationFacts,
ViewportRuntimeOperations,
} from '../viewport-runtime.ts';
export type {
AppStateRuntimeCommand,
AppStateRuntimeCommandResult,
@@ -13,6 +13,7 @@ import type { NetworkRuntimeHost, NetworkRuntimeOperations } from './network-run
import type { ScreenRecordingRuntimeHost } from './screen-recording-runtime-host.ts';
import type { ScreenRecordingRuntimeOperations } from './screen-recording-runtime.ts';
import type { SnapshotRuntimeHost, SnapshotRuntimeOperations } from './snapshot-runtime.ts';
import type { ViewportRuntimeOperations } from './viewport-runtime.ts';
import type {
DeviceReadinessRuntimeHost,
DeviceReadinessRuntimeOperations,
@@ -43,6 +44,7 @@ export type PlatformRuntimeOperations = AppLogRuntimeOperations &
NetworkRuntimeOperations &
ScreenRecordingRuntimeOperations &
SnapshotRuntimeOperations &
ViewportRuntimeOperations &
DeviceReadinessRuntimeOperations &
DeviceShutdownRuntimeOperations &
ApplicationLifecycleRuntimeOperations;
@@ -61,6 +63,7 @@ export const bootTargetHeadlessUse = defineUse({
});
export const appsRuntimeUse = defineUse({ required: ['ensureReady', 'listApps'] });
export const captureSnapshotUse = defineUse({ required: ['captureSnapshot'] });
export const viewportRuntimeUse = defineUse({ required: ['setViewport'] });
const captureSnapshotWithCustomActionsUse = defineUse({
required: ['captureSnapshot', 'captureSnapshotWithCustomActions'],
});
@@ -11,6 +11,7 @@ import type {
RuntimeOwnerRef,
} from './platform-runtime.ts';
import { snapshotRuntimeOperationFacts } from './snapshot-runtime.ts';
import { viewportRuntimeOperationFacts } from './viewport-runtime.ts';
/**
* A runtime-contract helper for provider ownership gaps. It never assigns lifecycle semantics:
@@ -24,6 +25,7 @@ export type UnavailablePlatformRuntimeFacts = Readonly<{
network: RuntimeOperationUnavailability;
screenRecording?: RuntimeOperationUnavailability;
snapshot?: RuntimeOperationUnavailability;
viewport?: RuntimeOperationUnavailability;
readiness?: RuntimeOperationUnavailability;
shutdown?: RuntimeOperationUnavailability;
lifecycle: ApplicationLifecycleOperationFacts;
@@ -37,6 +39,7 @@ type FrozenUnavailablePlatformRuntimeFacts = Readonly<{
network: RuntimeOperationUnavailability;
screenRecording: RuntimeOperationUnavailability;
snapshot: RuntimeOperationUnavailability;
viewport: RuntimeOperationUnavailability;
readiness: RuntimeOperationUnavailability;
shutdown: RuntimeOperationUnavailability;
lifecycle: ApplicationLifecycleOperationFacts;
@@ -69,6 +72,7 @@ export function createUnavailablePlatformRuntimeFacts(
network,
screenRecording,
snapshot,
viewport,
readiness,
shutdown,
lifecycle,
@@ -99,6 +103,7 @@ export function createUnavailablePlatformRuntimeFacts(
customActions: snapshot,
withoutActiveApp: snapshot,
}),
...viewportRuntimeOperationFacts({ setViewport: viewport }),
ensureReady: readiness,
bootTarget: readiness,
bootTargetHeadless: readiness,
@@ -121,6 +126,7 @@ function freezeUnavailableFacts(
...(unavailable.screenRecording ?? unavailable.network),
}),
snapshot: Object.freeze({ ...(unavailable.snapshot ?? unavailable.network) }),
viewport: Object.freeze({ ...(unavailable.viewport ?? unavailable.network) }),
readiness: Object.freeze({ ...(unavailable.readiness ?? unavailable.network) }),
shutdown: Object.freeze({ ...(unavailable.shutdown ?? unavailable.network) }),
lifecycle: applicationLifecycleOperationFacts(unavailable.lifecycle),
@@ -0,0 +1,7 @@
import { expect, test } from 'vitest';
import { viewportRuntimeOperationFacts } from './viewport-runtime.ts';
test('builds the exact viewport operation fact catalog', () => {
const setViewport = { available: true } as const;
expect(viewportRuntimeOperationFacts({ setViewport })).toEqual({ setViewport });
});
@@ -0,0 +1,15 @@
import type { RuntimeOperationFact } from './platform-runtime.ts';
export type SetViewportInput = Readonly<{ width: number; height: number }>;
export type ViewportRuntimeOperations = Readonly<{
setViewport(input: SetViewportInput): Promise<void>;
}>;
export type ViewportRuntimeOperationFacts = Readonly<{
setViewport: RuntimeOperationFact;
}>;
export function viewportRuntimeOperationFacts(input: ViewportRuntimeOperationFacts) {
return Object.freeze({ setViewport: input.setViewport });
}
+20 -7
View File
@@ -1,10 +1,23 @@
/**
* Closed result of the `viewport` command. Mirrors the dispatch handler's return
* EXACTLY (src/core/dispatch.ts `handleViewportCommand`) — `{ width, height }`
* plus the always-present `successText` message. The generic dispatch path
* returns this object unchanged (viewport has no Android dialog guard, so no
* `warning` is ever appended), so the shape is intentionally closed.
*/
import { AppError } from '@agent-device/kernel/errors';
function readViewportDimension(value: string | undefined, label: 'width' | 'height'): number {
const parsed = value === undefined ? NaN : Number(value);
if (!Number.isInteger(parsed) || parsed < 1) {
throw new AppError('INVALID_ARGS', `viewport ${label} must be a positive integer`);
}
return parsed;
}
export function readViewportDimensions(positionals: readonly string[]) {
if (positionals.length !== 2) {
throw new AppError('INVALID_ARGS', 'viewport requires exactly two arguments: <width> <height>');
}
return {
width: readViewportDimension(positionals[0], 'width'),
height: readViewportDimension(positionals[1], 'height'),
};
}
export type ViewportCommandResult = {
width: number;
height: number;
@@ -96,6 +96,8 @@ test.each([
expect(facts.operations.captureSnapshot).toEqual({ available: true });
expect(facts.operations.captureSnapshotWithCustomActions.available).toBe(false);
expect(facts.operations.captureSnapshotWithoutActiveApp).toEqual({ available: true });
expect(facts.operations.setViewport).toMatchObject({ available: false });
expect(binding.operations.setViewport).toBeUndefined();
expect(binding.operations.captureSnapshot).toBeTypeOf('function');
await expect(binding.operations.ensureReady?.({})).resolves.toMatchObject({
+7
View File
@@ -12,6 +12,7 @@ import {
bindLocalSnapshotInteractor,
localRuntimeOwner,
snapshotRuntimeOperationFacts,
viewportRuntimeOperationFacts,
} from '@agent-device/contracts/platform';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { createAndroidAppLogRuntime } from './logs/runtime.ts';
@@ -77,6 +78,11 @@ const snapshotCustomActionsUnavailable = Object.freeze({
reason: 'unsupported-platform-leaf',
hint: 'Re-run without --actions, or target an iOS simulator.',
} as const);
const viewportUnavailable = Object.freeze({
available: false,
reason: 'unsupported-platform-leaf',
hint: 'viewport resizes web targets only (--platform web).',
} as const);
function androidLifecycleFacts(device: DeviceInfo) {
const openTarget = androidOpenTargetFact(device);
@@ -131,6 +137,7 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor
customActions: snapshotCustomActionsUnavailable,
withoutActiveApp: device.kind === 'simulator' ? snapshotKindUnavailable : available,
}),
...viewportRuntimeOperationFacts({ setViewport: viewportUnavailable }),
ensureReady: available,
bootTarget: available,
bootTargetHeadless: device.kind === 'emulator' ? available : headlessUnavailable,
@@ -98,6 +98,12 @@ test.each([
device.appleOs !== 'macos' && device.appleOs !== 'watchos',
);
expect(facts.operations.bootTargetHeadless.available).toBe(false);
expect(facts.operations.setViewport).toEqual({
available: false,
reason: 'unsupported-platform-leaf',
hint: '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.',
});
expect(binding.operations.setViewport).toBeUndefined();
expectAppleSnapshotAvailability(binding, device);
});
+7
View File
@@ -10,6 +10,7 @@ import {
availableApplicationLifecycleOperations,
localRuntimeOwner,
snapshotRuntimeOperationFacts,
viewportRuntimeOperationFacts,
} from '@agent-device/contracts/platform';
import {
isIosFamily,
@@ -37,6 +38,11 @@ const unavailable = Object.freeze({
available: false,
reason: 'unsupported-platform-leaf',
} as const);
const viewportUnavailable = Object.freeze({
available: false,
reason: 'unsupported-platform-leaf',
hint: '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.',
} as const);
const appStateUnavailable = Object.freeze({
available: false,
reason: 'unsupported-platform-leaf',
@@ -206,6 +212,7 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR
screenRecordingReattach: recordingFacts,
screenRecordingCleanup: recordingFacts,
...appleSnapshotFacts(device),
...viewportRuntimeOperationFacts({ setViewport: viewportUnavailable }),
ensureReady: readiness,
bootTarget: boot,
bootTargetHeadless: headlessUnavailable,
@@ -68,6 +68,8 @@ test.each([
expect(facts.operations.captureSnapshot).toEqual({ available: true });
expect(facts.operations.captureSnapshotWithCustomActions.available).toBe(false);
expect(facts.operations.captureSnapshotWithoutActiveApp).toEqual({ available: true });
expect(facts.operations.setViewport).toMatchObject({ available: false });
expect(binding.operations.setViewport).toBeUndefined();
await expect(binding.operations.ensureReady?.({})).resolves.toMatchObject({ booted: true });
await expect(
binding.operations.listApps?.({ device: runtimeDevice, filter: 'all' }),
@@ -100,6 +102,7 @@ test('rejects the non-discovered HarmonyOS simulator cell for appstate', async (
});
expect(binding.facts.operations.appState).toEqual(appStateUnavailable);
expect(binding.facts.operations.setViewport).toMatchObject({ available: false });
expect(binding.operations.appState).toBeUndefined();
});
@@ -10,6 +10,7 @@ import {
bindLocalSnapshotInteractor,
localRuntimeOwner,
snapshotRuntimeOperationFacts,
viewportRuntimeOperationFacts,
} from '@agent-device/contracts/platform';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { createHarmonyAppLogRuntime } from './logs/runtime.ts';
@@ -67,6 +68,11 @@ const snapshotCustomActionsUnavailable = Object.freeze({
reason: 'unsupported-platform-leaf',
hint: 'Re-run without --actions, or target an iOS simulator.',
} as const);
const viewportUnavailable = Object.freeze({
available: false,
reason: 'unsupported-platform-leaf',
hint: 'viewport resizes web targets only (--platform web).',
} as const);
function harmonyLifecycleFacts(device: DeviceInfo) {
const openTarget = harmonyOpenTargetFact(device);
@@ -123,6 +129,7 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor
? available
: snapshotKindUnavailable,
}),
...viewportRuntimeOperationFacts({ setViewport: viewportUnavailable }),
ensureReady: available,
bootTarget: unavailable,
bootTargetHeadless: unavailable,
@@ -104,6 +104,8 @@ test.each([
expect(binding.facts.operations.captureSnapshotWithoutActiveApp.available).toBe(
device.kind === 'device',
);
expect(binding.facts.operations.setViewport).toMatchObject({ available: false });
expect(binding.operations.setViewport).toBeUndefined();
expect(binding.operations.captureSnapshot).toBeTypeOf(
device.kind === 'device' ? 'function' : 'undefined',
);
@@ -135,6 +135,8 @@ test.each([
reason: 'unsupported-platform-leaf',
});
expect(binding.operations.captureSnapshot).toBeUndefined();
expect(binding.facts.operations.setViewport).toMatchObject({ available: false });
expect(binding.operations.setViewport).toBeUndefined();
expectLifecycleFacts(binding, legacy);
},
);
+35
View File
@@ -50,6 +50,8 @@ test('preserves a narrow web provider dump including empty successful entries',
expect(binding.facts.operations.captureSnapshot).toEqual({ available: true });
expect(binding.facts.operations.captureSnapshotWithCustomActions.available).toBe(false);
expect(binding.facts.operations.captureSnapshotWithoutActiveApp).toEqual({ available: true });
expect(binding.facts.operations.setViewport).toEqual({ available: true });
expect(binding.operations.setViewport).toBeTypeOf('function');
expect(binding.operations.captureSnapshot).toBeTypeOf('function');
expectLifecycleFacts(binding);
});
@@ -133,10 +135,43 @@ test.each([
portReverse: false,
});
expect(binding.facts.operations.captureSnapshot.available).toBe(false);
expect(binding.facts.operations.setViewport.available).toBe(false);
expect(binding.operations.setViewport).toBeUndefined();
expect(binding.operations.captureSnapshot).toBeUndefined();
},
);
test('binds viewport resizing through the local web interactor and honors cancellation', async () => {
const setViewport = vi.fn(async () => undefined);
const runtimeHost = {
...host({ mode: 'local' }),
localInteractors: {
resolve: async () => ({ setViewport }) as unknown as Interactor,
},
};
const binding = await createWebPlatformRuntime(runtimeHost).bind({
device,
intent: { kind: 'ordinary' },
scope: scope(),
});
await binding.operations.setViewport?.({ width: 1280, height: 900 });
expect(setViewport).toHaveBeenCalledTimes(1);
expect(setViewport).toHaveBeenCalledWith(1280, 900);
const canceled = new AbortController();
canceled.abort(new Error('request canceled'));
const canceledBinding = await createWebPlatformRuntime(runtimeHost).bind({
device,
intent: { kind: 'ordinary' },
scope: { ...scope(), signal: canceled.signal },
});
await expect(
canceledBinding.operations.setViewport?.({ width: 800, height: 600 }),
).rejects.toThrow('request canceled');
expect(setViewport).toHaveBeenCalledTimes(1);
});
type LegacyLifecycleCell = Readonly<{
openTarget: boolean;
prepareAppleRunner: boolean;
+20
View File
@@ -12,6 +12,7 @@ import {
localRuntimeOwner,
snapshotRuntimeOperationFacts,
sameRuntimeOwner,
viewportRuntimeOperationFacts,
} from '@agent-device/contracts/platform';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { AppError } from '@agent-device/kernel/errors';
@@ -167,6 +168,22 @@ function bindWebRuntime(
resolveInteractor: host.localInteractors.resolve,
})
: {}),
...(facts.operations.setViewport.available
? {
setViewport: async (input) => {
signal.throwIfAborted();
const interactor = await host.localInteractors.resolve(device, { signal });
if (!interactor.setViewport) {
throw new AppError(
'UNSUPPORTED_OPERATION',
'viewport is not supported by the bound web interactor',
);
}
await interactor.setViewport(input.width, input.height);
signal.throwIfAborted();
},
}
: {}),
...availableApplicationLifecycleOperations(
bindWebApplicationLifecycle({ host: host.localInteractors, device, signal }),
facts.operations,
@@ -214,6 +231,9 @@ function webRuntimeFacts(
customActions: snapshotCustomActionsUnavailable,
withoutActiveApp: device.kind === 'device' ? available : openTargetKindUnavailable,
}),
...viewportRuntimeOperationFacts({
setViewport: device.kind === 'device' ? available : openTargetKindUnavailable,
}),
ensureReady: readinessUnavailable,
bootTarget: readinessUnavailable,
bootTargetHeadless: readinessUnavailable,
@@ -279,6 +279,8 @@ test.each([
runtimeDevice.platform === 'apple',
);
expect(binding.facts.operations.captureSnapshotWithoutActiveApp).toEqual({ available: true });
expect(binding.facts.operations.setViewport).toMatchObject({ available: false });
expect(binding.operations.setViewport).toBeUndefined();
expect(binding.operations.captureSnapshot).toBeTypeOf('function');
await expect(
binding.operations.listApps?.({ device: runtimeDevice, filter: 'all' }),
@@ -336,6 +338,7 @@ test('fails closed for a stale Android identity before exposing facts or binding
});
expect(facts.operations.captureSnapshotWithCustomActions).toMatchObject({ available: false });
expect(facts.operations.captureSnapshotWithoutActiveApp).toMatchObject({ available: false });
expect(facts.operations.setViewport).toMatchObject({ available: false });
await expect(
owner.bind({ device: staleDevice, intent: { kind: 'ordinary' }, scope }),
).rejects.toMatchObject({
@@ -26,6 +26,7 @@ import {
providerRuntimeOwner,
sameRuntimeOwner,
snapshotRuntimeOperationFacts,
viewportRuntimeOperationFacts,
} from '@agent-device/contracts/platform';
import {
createLimrunAppLogEnvelope,
@@ -84,6 +85,11 @@ const customSnapshotUnavailable = Object.freeze({
reason: 'unsupported-provider-mode',
hint: 'Custom snapshot actions are available only for Limrun iOS simulator sessions.',
} as const);
const viewportUnavailable = Object.freeze({
available: false,
reason: 'unsupported-provider-mode',
hint: 'Limrun does not expose viewport resizing.',
} as const);
const recordingUnavailable = Object.freeze({
available: false,
reason: 'unsupported-provider-mode',
@@ -425,6 +431,7 @@ function facts(
: customSnapshotUnavailable,
withoutActiveApp: available,
}),
...viewportRuntimeOperationFacts({ setViewport: viewportUnavailable }),
ensureReady: available,
bootTarget: available,
bootTargetHeadless: headlessUnavailable,
@@ -463,6 +470,7 @@ function recoveryFacts(
customActions: liveSessionUnavailable,
withoutActiveApp: liveSessionUnavailable,
}),
...viewportRuntimeOperationFacts({ setViewport: liveSessionUnavailable }),
ensureReady: liveSessionUnavailable,
bootTarget: liveSessionUnavailable,
bootTargetHeadless: liveSessionUnavailable,
@@ -235,6 +235,8 @@ test('captures through only the active exact WebDriver interactor', async () =>
expect(binding.facts.operations.captureSnapshot).toEqual({ available: true });
expect(binding.facts.operations.captureSnapshotWithCustomActions.available).toBe(false);
expect(binding.facts.operations.captureSnapshotWithoutActiveApp).toEqual({ available: true });
expect(binding.facts.operations.setViewport).toMatchObject({ available: false });
expect(binding.operations.setViewport).toBeUndefined();
await expect(
binding.operations.captureSnapshot?.({ options: { interactiveOnly: true } }),
).resolves.toEqual({ backend: 'android', nodes: [] });
@@ -261,6 +263,7 @@ test.each([
expect(facts.operations.captureSnapshot.available).toBe(false);
expect(facts.operations.captureSnapshotWithCustomActions.available).toBe(false);
expect(facts.operations.captureSnapshotWithoutActiveApp.available).toBe(false);
expect(facts.operations.setViewport.available).toBe(false);
if (state.isSessionActive()) {
const binding = await owner.bind({
device,
@@ -5,6 +5,7 @@ import {
createUnavailablePlatformRuntimeFacts,
sameRuntimeOwner,
snapshotRuntimeOperationFacts,
viewportRuntimeOperationFacts,
type AppDeploymentInput,
type DeployMaterializedAppInput,
type DeviceBinding,
@@ -73,6 +74,11 @@ const snapshotCustomActionsUnavailable = Object.freeze({
reason: 'unsupported-provider-mode',
hint: 'WebDriver provider runtimes do not expose iOS simulator custom snapshot actions.',
} as const);
const viewportUnavailable = Object.freeze({
available: false,
reason: 'unsupported-provider-mode',
hint: 'WebDriver provider runtimes do not expose viewport resizing.',
} as const);
const appStateUnavailable = Object.freeze({
available: false,
@@ -291,6 +297,7 @@ function webDriverFacts(
customActions: inactiveSession,
withoutActiveApp: inactiveSession,
}),
...viewportRuntimeOperationFacts({ setViewport: inactiveSession }),
ensureReady: inactiveSession,
bootTarget: inactiveSession,
bootTargetHeadless: inactiveSession,
@@ -350,6 +357,7 @@ function webDriverFacts(
? available
: snapshotUnavailable,
}),
...viewportRuntimeOperationFacts({ setViewport: viewportUnavailable }),
ensureReady: available,
bootTarget: available,
bootTargetHeadless: headlessUnavailable,
@@ -507,6 +507,23 @@ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [
},
extensions: [diffRetiredDispatchProjectionProof],
},
{
rule: 'R34 viewport-runtime-cutover',
command: 'viewport',
subject: 'web viewport resize',
tier: 'request-scoped',
execution: 'device-runtime',
legacyRetirement: {
routeNames: ['handleViewportCommand'],
},
runtimeTypeNames: ['ViewportRuntimeOperations'],
operations: { names: ['setViewport'] },
singularExecution: {
routes: ['dispatchGenericCommand'],
operations: ['setViewport'],
operationOwners: { setViewport: ['resolveBoundViewportRuntime'] },
},
},
];
function snapshotRetiredDispatchProjectionProof(
@@ -87,10 +87,6 @@ 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,
perf: coreDeviceOnlyPhysicalOperationHint,
'tv-remote': (device) => {
if (device.platform === 'android') {
@@ -30,6 +30,7 @@ export const unavailableDeploymentSnapshotAndShutdownOperationFacts = Object.fre
withoutActiveApp: unavailable,
}),
...unavailableShutdownOperationFacts,
setViewport: unavailable,
});
/** Default facts for tests that are unrelated to application lifecycle commands. */
+2 -3
View File
@@ -1,6 +1,6 @@
import { PUBLIC_COMMANDS } from '../../command-catalog.ts';
import type { ViewportCommandOptions } from '@agent-device/contracts/client';
import { readViewportDimension } from '../../core/viewport-dimension.ts';
import { readViewportDimensions } from '@agent-device/contracts/capture';
import type { CommandSchemaOverride } from '../../cli-schema/types.ts';
import { integerField, requiredField } from '../command-input.ts';
import { defineExecutableCommand } from '../command-contract.ts';
@@ -30,8 +30,7 @@ const viewportCliSchema = {
const viewportCliReader: CliReader = (positionals, flags) => ({
...commonInputFromFlags(flags),
width: readViewportDimension(positionals[0], 'width'),
height: readViewportDimension(positionals[1], 'height'),
...readViewportDimensions(positionals),
});
const viewportDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.viewport, (input) => {
-18
View File
@@ -187,24 +187,6 @@ test('core commands support iOS simulator, iOS device, and Android', () => {
);
});
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);
});
// #1783: hover is a pointer state, so it is admitted exactly where a pointer
// exists (the web backend's mouse move) and denied on every touch platform.
test('hover is admitted only on web, where a pointer backend exists', () => {
@@ -147,10 +147,6 @@ 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,
perf: coreDeviceOnlyPhysicalOperationHint,
'tv-remote': (device) => {
if (device.platform === 'android') {
@@ -0,0 +1,43 @@
import { expect, test, vi } from 'vitest';
import { dispatchCommand } from '../dispatch.ts';
import { withWebProvider, type WebProvider } from '../../platforms/web/provider.ts';
const webDevice = {
id: 'web',
name: 'Web',
platform: 'web',
kind: 'device',
booted: true,
} as const;
test('legacy dispatch no longer reaches the web interactor viewport operation', async () => {
const setViewport = vi.fn(async () => undefined);
const provider = makeWebProvider({ setViewport });
await expect(
withWebProvider(
provider,
async () => await dispatchCommand(webDevice, 'viewport', ['1280', '900']),
),
).rejects.toMatchObject({
code: 'INVALID_ARGS',
message: 'Unknown command: viewport',
});
expect(setViewport).not.toHaveBeenCalled();
});
function makeWebProvider(overrides: Partial<WebProvider> = {}): WebProvider {
return {
open: async () => {},
close: async () => {},
snapshot: async () => ({ nodes: [] }),
screenshot: async () => {},
setViewport: async () => {},
click: async () => {},
fill: async () => {},
typeText: async () => {},
scroll: async () => {},
...overrides,
};
}
-2
View File
@@ -68,11 +68,9 @@ const WEB_INTERACTION_COMMANDS = [
'scroll',
'type',
] as const;
const WEB_SETTING_COMMANDS = ['viewport'] as const;
const WEB_SUPPORTED_COMMANDS = new Set<string>([
...WEB_QUERY_COMMANDS,
...WEB_INTERACTION_COMMANDS,
...WEB_SETTING_COMMANDS,
]);
// Built from the additive command-descriptor registry (ADR-0008, Phase 1 step 3).
// The hand-authored literal was deleted after #906 proved deriveCapabilityMatrix is
@@ -71,6 +71,7 @@ const NO_CAPABILITY_PUBLIC_COMMANDS = new Set<string>([
PUBLIC_COMMANDS.snapshot,
PUBLIC_COMMANDS.test,
PUBLIC_COMMANDS.trace,
PUBLIC_COMMANDS.viewport,
]);
type TestCommandDescriptor = (typeof commandDescriptors)[number];
@@ -199,7 +200,10 @@ test('platform dispatch command list is built from descriptor dispatch facets',
});
test('generic route commands that reach platform dispatch declare the dispatch facet', () => {
const nonDispatchGenericCommands = new Set<string>([PUBLIC_COMMANDS.gesture]);
const nonDispatchGenericCommands = new Set<string>([
PUBLIC_COMMANDS.gesture,
PUBLIC_COMMANDS.viewport,
]);
for (const descriptor of commandDescriptors) {
const route = readDaemonRouteForTest(descriptor);
@@ -0,0 +1,15 @@
import { expect, test } from 'vitest';
import { viewportRuntimeUse } from '@agent-device/contracts/platform';
import { commandDescriptors } from '../registry.ts';
test('viewport descriptor declares its complete runtime use with no legacy projection', () => {
const viewport = commandDescriptors.find(({ name }) => name === 'viewport');
expect(viewport).not.toHaveProperty('capability');
expect(viewport).not.toHaveProperty('dispatch');
expect(viewport?.platformExecution).toEqual({
kind: 'device-runtime',
uses: [viewportRuntimeUse],
});
expect(viewportRuntimeUse).toEqual({ required: ['setViewport'], preferred: [] });
});
+3 -10
View File
@@ -31,6 +31,7 @@ import {
runtimeCommandRuntimePlanUses,
screenRecordingRuntimePlanUses,
shutdownTargetUse,
viewportRuntimeUse,
} from '@agent-device/contracts/platform';
import { readDeclaredPlatformExecution } from './platform-execution-entry.ts';
import type {
@@ -1139,7 +1140,7 @@ export const RAW_COMMAND_DESCRIPTORS = [
// Hover is a pointer-only state (#1783): the web provider moves the mouse
// without pressing. Touch platforms have no hover, so no device bucket
// admits it; `WEB_INTERACTION_COMMANDS` in src/core/capabilities.ts adds the
// web bucket, the same way `viewport` is web-only.
// web bucket; runtime-owned web-only commands use exact operation facts instead.
capability: { apple: {}, android: {}, linux: LINUX_NONE },
timeoutPolicy: postActionObservationTimeoutPolicy('hover', PRESERVE_DAEMON_TIMEOUT_POLICY),
postActionObservation: postActionObservation('hover'),
@@ -1371,17 +1372,9 @@ export const RAW_COMMAND_DESCRIPTORS = [
recordsSessionAction: true,
recordingEffect: 'mutates-app',
daemon: { route: 'generic', refFrameEffect: 'may-invalidate' },
dispatch: {},
// 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,
platformExecution: LEGACY_PLATFORM_EXECUTION,
platformExecution: { kind: 'device-runtime', uses: [viewportRuntimeUse] },
},
// -- capability/batch-only commands (no daemon route) --
{
-18
View File
@@ -26,7 +26,6 @@ import {
handleTypeCommand,
} from './dispatch-interactions.ts';
import { getInteractor } from './interactors.ts';
import { readViewportDimension } from './viewport-dimension.ts';
export type { DispatchContext } from './dispatch-context.ts';
export { resolveTargetDevice } from './dispatch-resolve.ts';
@@ -161,7 +160,6 @@ const DISPATCH_HANDLERS: Record<DispatchCommand, DispatchHandler> = {
handleTriggerAppEventCommand(device, interactor, positionals, context),
screenshot: ({ interactor, positionals, outPath, context }) =>
handleScreenshotCommand(interactor, positionals, outPath, context),
viewport: ({ interactor, positionals }) => handleViewportCommand(interactor, positionals),
back: async ({ interactor, context }) => {
await interactor.back(context?.backMode);
return { action: 'back', mode: context?.backMode ?? 'in-app', ...successText('Back') };
@@ -262,22 +260,6 @@ async function handleScreenshotCommand(
return { path: screenshotPath, ...successText(`Saved screenshot: ${screenshotPath}`) };
}
async function handleViewportCommand(
interactor: Interactor,
positionals: string[],
): Promise<Record<string, unknown>> {
if (positionals.length !== 2) {
throw new AppError('INVALID_ARGS', 'viewport requires exactly two arguments: <width> <height>');
}
const width = readViewportDimension(positionals[0], 'width');
const height = readViewportDimension(positionals[1], 'height');
if (!interactor.setViewport) {
throw new AppError('UNSUPPORTED_OPERATION', 'viewport is not supported by this backend');
}
await interactor.setViewport(width, height);
return { width, height, ...successText(`Viewport set: ${width}x${height}`) };
}
async function handleClipboardCommand(
interactor: Interactor,
positionals: string[],
-12
View File
@@ -1,12 +0,0 @@
import { AppError } from '@agent-device/kernel/errors';
export function readViewportDimension(
value: string | undefined,
label: 'width' | 'height',
): number {
const parsed = value === undefined ? NaN : Number(value);
if (!Number.isInteger(parsed) || parsed < 1) {
throw new AppError('INVALID_ARGS', `viewport ${label} must be a positive integer`);
}
return parsed;
}
+5 -1
View File
@@ -37,7 +37,10 @@ vi.mock('../handlers/interaction-snapshot.ts', async (importOriginal) => {
import { dispatchCommand } from '../../core/dispatch.ts';
import { captureSnapshotForSession } from '../handlers/interaction-snapshot.ts';
import { dispatchGenericCommand } from '../request-generic-dispatch.ts';
import {
dispatchGenericCommand,
executeGenericPlatformCommand,
} from '../request-generic-dispatch.ts';
const mockDispatch = vi.mocked(dispatchCommand);
const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotForSession);
@@ -157,6 +160,7 @@ async function dispatchGeneric(params: {
logPath: '',
sessionStore: params.sessionStore,
contextFromFlags,
executePlatformCommand: executeGenericPlatformCommand,
});
}
@@ -0,0 +1,205 @@
import { expect, test, vi } from 'vitest';
import {
localRuntimeOwner,
narrowDeviceBinding,
viewportRuntimeOperationFacts,
viewportRuntimeUse,
type DeviceBinding,
type DeviceRuntimeGateway,
type PlatformRuntimeOperations,
type RuntimeFacts,
type RuntimeOperationFact,
} from '@agent-device/contracts/platform';
import { deviceShape } from '@agent-device/kernel/device';
import { makeSession } from '../../__tests__/test-utils/session-factories.ts';
import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts';
import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts';
import { LeaseRegistry } from '../lease-registry.ts';
import { activateCompleteRefFrame } from '../ref-frame.ts';
import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts';
import { resolveBoundViewportRuntime } from '../viewport-runtime.ts';
import { createRequestHandler } from './test-device-runtime-gateway.ts';
const webDevice = {
id: 'web',
name: 'Web',
platform: 'web',
kind: 'device',
booted: true,
} as const;
const appleDevice = {
id: 'ios-simulator',
name: 'iPhone',
platform: 'apple',
appleOs: 'ios',
kind: 'simulator',
target: 'mobile',
booted: true,
} as const;
const available = Object.freeze({ available: true } as const);
const unavailable = Object.freeze({
available: false,
reason: 'owner-capability-missing' as const,
hint: 'viewport is not supported by the exact runtime owner',
});
function runtimeHarness(
fact: RuntimeOperationFact = available,
device: typeof webDevice | typeof appleDevice = webDevice,
) {
const setViewport = vi.fn(async () => undefined);
const facts: RuntimeFacts<PlatformRuntimeOperations> = {
device: { ...deviceShape(device), providerMode: 'local' },
operations: {
setViewport: fact,
} as RuntimeFacts<PlatformRuntimeOperations>['operations'],
};
const binding = {
device,
owner: localRuntimeOwner(device.platform),
facts,
operations: { setViewport },
[Symbol.asyncDispose]: async () => {},
} satisfies DeviceBinding<PlatformRuntimeOperations>;
const inspectFacts: InspectDeviceRuntimeFacts = vi.fn(async () => facts);
const bindDevice = vi.fn(async (_device, use) =>
narrowDeviceBinding(binding, use),
) as unknown as BindDeviceRuntime;
const bind = vi.fn(async () => binding);
const gateway: DeviceRuntimeGateway<PlatformRuntimeOperations> = {
inspectFacts,
bind,
shutdown: async () => {},
};
return { setViewport, inspectFacts, bindDevice, bind, gateway };
}
test('resolves one admitted binding and exposes one normalized viewport operation', async () => {
const harness = runtimeHarness(
viewportRuntimeOperationFacts({ setViewport: available }).setViewport,
);
const resolved = await resolveBoundViewportRuntime({
device: webDevice,
positionals: ['1280', '900'],
inspectFacts: harness.inspectFacts,
bindDevice: harness.bindDevice,
});
expect(resolved).toBeTypeOf('function');
if (typeof resolved !== 'function') return;
expect(harness.inspectFacts).toHaveBeenCalledTimes(1);
expect(harness.inspectFacts).toHaveBeenCalledWith(webDevice);
expect(harness.bindDevice).toHaveBeenCalledTimes(1);
expect(harness.bindDevice).toHaveBeenCalledWith(webDevice, viewportRuntimeUse);
expect(await resolved()).toEqual({
width: 1280,
height: 900,
message: 'Viewport set: 1280x900',
});
expect(harness.setViewport).toHaveBeenCalledTimes(1);
expect(harness.setViewport).toHaveBeenCalledWith({ width: 1280, height: 900 });
});
test('rejects invalid dimensions before inspection or binding', async () => {
const harness = runtimeHarness();
await expect(
resolveBoundViewportRuntime({
device: webDevice,
positionals: ['0', '900'],
inspectFacts: harness.inspectFacts,
bindDevice: harness.bindDevice,
}),
).rejects.toMatchObject({ code: 'INVALID_ARGS' });
expect(harness.inspectFacts).not.toHaveBeenCalled();
expect(harness.bindDevice).not.toHaveBeenCalled();
});
test('rejects an unavailable exact-owner fact before binding', async () => {
const harness = runtimeHarness(unavailable);
const resolved = await resolveBoundViewportRuntime({
device: webDevice,
positionals: ['1280', '900'],
inspectFacts: harness.inspectFacts,
bindDevice: harness.bindDevice,
});
expect(resolved).toEqual({
ok: false,
error: {
code: 'UNSUPPORTED_OPERATION',
message: 'viewport is not supported on this device',
hint: unavailable.hint,
},
});
expect(harness.inspectFacts).toHaveBeenCalledTimes(1);
expect(harness.bindDevice).not.toHaveBeenCalled();
});
test('preserves the Apple viewport recovery hint through admission', async () => {
const hint =
'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.';
const harness = runtimeHarness(
{ available: false, reason: 'unsupported-platform-leaf', hint },
appleDevice,
);
const resolved = await resolveBoundViewportRuntime({
device: appleDevice,
positionals: ['1280', '900'],
inspectFacts: harness.inspectFacts,
bindDevice: harness.bindDevice,
});
expect(resolved).toEqual({
ok: false,
error: {
code: 'UNSUPPORTED_OPERATION',
message: 'viewport is not supported on this device',
hint,
},
});
expect(harness.inspectFacts).toHaveBeenCalledOnce();
expect(harness.bindDevice).not.toHaveBeenCalled();
});
test('request router joins viewport admission to execution, recording, and ref invalidation', async () => {
const harness = runtimeHarness();
const sessionStore = makeSessionStore('agent-device-viewport-generic-');
const session = makeSession('viewport-runtime', { device: webDevice });
activateCompleteRefFrame(session);
sessionStore.set(session.name, session);
const handler = createRequestHandler({
logPath: '/tmp/daemon.log',
token: 't',
sessionStore,
leaseRegistry: new LeaseRegistry(),
deviceInventoryGateways: createTestDeviceInventoryGateways(),
deviceRuntimeGateway: harness.gateway,
trackDownloadableArtifact: () => 'artifact',
});
const response = await handler({
command: 'viewport',
positionals: ['1280', '900'],
token: 't',
session: session.name,
flags: {},
meta: { requestId: 'viewport-router-join' },
});
expect(response).toMatchObject({
ok: true,
data: { width: 1280, height: 900, message: 'Viewport set: 1280x900' },
});
expect(session.refFrameState).toBe('expired');
expect(session.actions.at(-1)).toMatchObject({
command: 'viewport',
positionals: ['1280', '900'],
});
expect(harness.inspectFacts).toHaveBeenCalledTimes(1);
expect(harness.bind).toHaveBeenCalledTimes(1);
expect(harness.setViewport).toHaveBeenCalledTimes(1);
});
@@ -356,6 +356,7 @@ function sourceRuntimeFacts(
customActions: unavailable,
withoutActiveApp: unavailable,
}),
setViewport: unavailable,
deployApp: unavailable,
materializeAppSource: materializationAvailable ? { available: true } : unavailable,
deployMaterializedApp: materializationAvailable ? { available: true } : unavailable,
@@ -68,6 +68,7 @@ function createAdmissionFacts(
customActions: unavailable,
withoutActiveApp: unavailable,
}),
setViewport: unavailable,
deployApp: options.deployAvailable ? available : unavailable,
materializeAppSource: options.sourceAvailable ? available : unavailable,
deployMaterializedApp: options.sourceAvailable ? available : unavailable,
@@ -138,6 +138,7 @@ function readinessFacts(device: DeviceInfo): RuntimeFacts<PlatformRuntimeOperati
customActions: unavailable,
withoutActiveApp: unavailable,
}),
setViewport: unavailable,
deployApp: operationAvailability(deployment.deploy),
materializeAppSource: operationAvailability(deployment.source),
deployMaterializedApp: operationAvailability(deployment.source),
+1
View File
@@ -281,6 +281,7 @@ const factOwnedCapabilityOperations: Readonly<
close: ['closeApplication', 'finalizeApplicationClose'],
prepare: ['prepareAppleRunner'],
runtime: ['clearRuntimeHints'],
viewport: ['setViewport'],
});
function factOwnedCapabilityAvailable(
+8 -4
View File
@@ -47,6 +47,7 @@ export async function dispatchGenericCommand(params: {
appBundleId?: string,
traceLogPath?: string,
) => DaemonCommandContext;
executePlatformCommand: typeof executeGenericPlatformCommand;
}): Promise<DaemonResponse> {
const { req, session, logPath, sessionStore, contextFromFlags } = params;
const platformCommand = req.command;
@@ -78,14 +79,14 @@ export async function dispatchGenericCommand(params: {
surface: session.surface,
};
// ADR 0014 side-effect seam for generic-route leaves (back/home/rotate/scroll/
// tv-remote/app-switcher/viewport/focus, ...). The daemon effect classification
// tv-remote/app-switcher/viewport/focus). Effect classification
// is the honesty guard that decides which of these mutate; expire the frame
// before dispatching so a later ref cannot reuse it. Read-only generic leaves
// (screenshot) are classified `preserve` and leave the frame untouched.
if (resolveRefFrameEffect(req) === 'may-invalidate') {
expireRefFrame(session);
}
let data = await executeGenericPlatformCommand({
let data = await params.executePlatformCommand({
session,
sessionName: params.sessionName,
logPath,
@@ -208,7 +209,10 @@ async function ensureGenericCommandReady(
session: SessionState,
platformCommand: string,
): Promise<DaemonResponse | null> {
const unsupported = requireCommandSupported(platformCommand, session.device, { hint: true });
const unsupported =
platformCommand === 'viewport'
? null
: requireCommandSupported(platformCommand, session.device, { hint: true });
if (unsupported) return unsupported;
if (
session.device.platform !== 'android' ||
@@ -228,7 +232,7 @@ async function ensureGenericCommandReady(
};
}
async function executeGenericPlatformCommand(params: {
export async function executeGenericPlatformCommand(params: {
session: SessionState;
sessionName: string;
logPath: string;
+15 -1
View File
@@ -67,6 +67,7 @@ import {
createScreenRecordingAdmissionLedger,
type ScreenRecordingAdmissionLedger,
} from './screen-recording-admission-ledger.ts';
import { resolveBoundViewportRuntime } from './viewport-runtime.ts';
// ---------------------------------------------------------------------------
// Request handler API
@@ -425,7 +426,19 @@ async function dispatchGenericForLockedScope(params: {
return noActiveSessionError();
}
const { dispatchGenericCommand } = await loadGenericRequestHandlerModule();
const viewportRuntime =
lockedScope.req.command === 'viewport'
? await resolveBoundViewportRuntime({
device: session.device,
positionals: lockedScope.req.positionals ?? [],
inspectFacts: lockedScope.inspectFacts,
bindDevice: lockedScope.bindDevice,
})
: undefined;
if (viewportRuntime && typeof viewportRuntime !== 'function') return viewportRuntime;
const { dispatchGenericCommand, executeGenericPlatformCommand } =
await loadGenericRequestHandlerModule();
const dispatchResponse = await dispatchGenericCommand({
req: lockedScope.req,
session,
@@ -433,6 +446,7 @@ async function dispatchGenericForLockedScope(params: {
logPath,
sessionStore,
contextFromFlags: lockedScope.contextFromFlags,
executePlatformCommand: viewportRuntime ?? executeGenericPlatformCommand,
});
return dispatchResponse;
}
+2
View File
@@ -33,6 +33,8 @@ type RuntimeAdmissionRequest = Readonly<{
unavailableResponse?: UnavailableRuntimeResponse;
}>;
export type RuntimeAdmissionBindings = Pick<RuntimeAdmissionRequest, 'inspectFacts' | 'bindDevice'>;
/**
* The one facts-admission seam every migrated command route shares. It performs exactly one
* side-effect-free inspection and hands back the binding gateway only once the exact device cell
+25
View File
@@ -0,0 +1,25 @@
import { readViewportDimensions } from '@agent-device/contracts/capture';
import { viewportRuntimeUse } from '@agent-device/contracts/platform';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { admitRuntimeUse, type RuntimeAdmissionBindings } from './runtime-admission.ts';
export async function resolveBoundViewportRuntime(
params: {
device: DeviceInfo;
positionals: string[];
} & RuntimeAdmissionBindings,
) {
const input = readViewportDimensions(params.positionals);
const admission = await admitRuntimeUse({
command: 'viewport',
device: params.device,
use: viewportRuntimeUse,
inspectFacts: params.inspectFacts,
bindDevice: params.bindDevice,
});
if (admission.type === 'response') return admission.response;
return async () => {
await admission.runtime.operations.setViewport(input);
return { ...input, message: `Viewport set: ${input.width}x${input.height}` };
};
}
+87 -2
View File
@@ -1,5 +1,16 @@
import type { PlatformRuntimeHost, PlatformRuntimeOwner } from '@agent-device/contracts/platform';
import { providerRuntimeOwner } from '@agent-device/contracts/platform';
import type {
DeviceBinding,
PlatformRuntimeHost,
PlatformRuntimeOperations,
PlatformRuntimeOwner,
} from '@agent-device/contracts/platform';
import {
applicationLifecycleOperationFacts,
createUnavailablePlatformRuntimeFacts,
localRuntimeOwner,
providerRuntimeOwner,
viewportRuntimeOperationFacts,
} from '@agent-device/contracts/platform';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { createLimrunRuntime } from '@agent-device/provider-limrun';
import { describe, expect, test, vi } from 'vitest';
@@ -20,6 +31,80 @@ import {
} from './platform-runtime-gateway.fixtures.ts';
describe('composed platform runtime gateway', () => {
test('loads only the selected web owner for viewport facts and binding', async () => {
const webDevice: DeviceInfo = {
platform: 'web',
id: 'web',
name: 'Web',
kind: 'device',
target: 'desktop',
booted: true,
};
const unavailable = { available: false, reason: 'unsupported-platform-leaf' } as const;
const available = { available: true } as const;
const owner = localRuntimeOwner('web');
const baseFacts = createUnavailablePlatformRuntimeFacts(webDevice, owner, {
appLog: unavailable,
network: unavailable,
lifecycle: applicationLifecycleOperationFacts({
resolveOpenTarget: unavailable,
prepareApplicationOpen: unavailable,
openApplication: unavailable,
applyRuntimeHints: unavailable,
clearRuntimeHints: unavailable,
closeApplication: unavailable,
finalizeApplicationClose: unavailable,
prepareAppleRunner: unavailable,
configureProviderPortReverse: unavailable,
}),
});
const facts = {
...baseFacts,
operations: {
...baseFacts.operations,
...viewportRuntimeOperationFacts({ setViewport: available }),
},
};
const setViewport = vi.fn(async () => undefined);
const binding: DeviceBinding<PlatformRuntimeOperations> = {
device: webDevice,
owner,
facts,
operations: { setViewport },
[Symbol.asyncDispose]: async () => {},
};
const webLoad = vi.fn(async () => ({
owner,
ownsDevice: () => true,
inspectFacts: async () => facts,
bind: async () => binding,
shutdown: async () => {},
}));
const appleLoad = vi.fn(async () => {
throw new Error('unselected Apple runtime must stay lazy');
});
const runtimeGateway = createComposedPlatformRuntimeGateway({
modules: new Map([
['web', { family: 'web', loadRuntime: webLoad }],
['apple', { family: 'apple', loadRuntime: appleLoad }],
]),
loadHost: async () => ({}) as PlatformRuntimeHost,
});
await expect(runtimeGateway.inspectFacts(webDevice)).resolves.toMatchObject({
operations: { setViewport: { available: true } },
});
const selected = await runtimeGateway.bind({
device: webDevice,
intent: { kind: 'ordinary' },
scope,
});
await selected.operations.setViewport?.({ width: 1280, height: 900 });
expect(webLoad).toHaveBeenCalledTimes(1);
expect(appleLoad).not.toHaveBeenCalled();
expect(setViewport).toHaveBeenCalledWith({ width: 1280, height: 900 });
});
// Which resources need recovering is the host's composition (see
// platform-runtime-application-resources.test.ts). The gateway owns only the lazy host load and
// the once-per-process shape of each durable phase.
-4
View File
@@ -94,10 +94,6 @@ const APPLE_UNSUPPORTED_HINT_BY_DEFAULT: Record<
(device: DeviceInfo) => string | undefined
> = {
[PUBLIC_COMMANDS.perf]: 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'
@@ -45,6 +45,11 @@ const ANDROID_APPLICATION_LIFECYCLE_CONTRACT_EVIDENCE = defineAndroidContractEvi
[C.prepare],
'Android platform runtime classifies prepareAppleRunner unavailable',
);
const ANDROID_VIEWPORT_RUNTIME_CONTRACT_EVIDENCE = defineAndroidContractEvidence(
'src/daemon/__tests__/viewport-runtime.test.ts',
[C.viewport],
'rejects an unavailable exact-owner fact before binding',
);
/** One primary, observable owner for every public command on an Android emulator. */
export const ANDROID_EMULATOR_E2E_COVERAGE = {
@@ -199,10 +204,10 @@ export const ANDROID_EMULATOR_E2E_COVERAGE = {
assertion: 'Android emulator capability model rejects hover, a pointer-only web contract',
level: 'capability-denial',
},
[C.viewport]: {
assertion: 'Android emulator capability model rejects standalone viewport control',
level: 'capability-denial',
},
[C.viewport]: contract(
ANDROID_VIEWPORT_RUNTIME_CONTRACT_EVIDENCE,
'Android viewport fails closed through its unavailable exact-owner runtime fact',
),
[C.wait]: live('smoke:automation-system', 'wait observes durable fixture landmarks'),
} satisfies Record<PublicCommand, AndroidEmulatorCoverageEntry>;
@@ -203,14 +203,11 @@ export const IOS_SIMULATOR_E2E_COVERAGE = {
test: 'capability classifications match executable simulator behavior',
},
},
[C.viewport]: {
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.viewport]: contract(
'src/daemon/__tests__/viewport-runtime.test.ts',
'rejects an unavailable exact-owner fact before binding',
'iOS viewport fails closed through its unavailable exact-owner runtime fact',
),
[C.wait]: live('smoke:automation-input', 'polling observes durable fixture state'),
} satisfies Record<PublicCommand, IosSimulatorCoverageEntry>;
@@ -47,8 +47,8 @@ test('Android emulator coverage exhaustively classifies the public catalog', ()
test('Android coverage report summary accounts for every manifest classification', () => {
const summary = ANDROID_EMULATOR_COVERAGE_CLASSIFICATION_SUMMARY;
assert.deepEqual(summary, {
capabilityDenial: 3,
contract: 10,
capabilityDenial: 2,
contract: 11,
gap: 0,
live: 41,
total: 54,
@@ -259,11 +259,14 @@ test('Android behavior patterns are owned by live fixture journeys', () => {
);
});
test('Android catalog denials exclude fact-owned lifecycle commands', () => {
for (const command of [PUBLIC_COMMANDS.tvRemote, PUBLIC_COMMANDS.viewport]) {
assert.equal(isCommandSupportedOnDevice(command, ANDROID_EMULATOR), false, command);
assert.equal(ANDROID_EMULATOR_E2E_COVERAGE[command].level, 'capability-denial', command);
}
test('Android catalog denials exclude fact-owned runtime commands', () => {
assert.equal(isCommandSupportedOnDevice(PUBLIC_COMMANDS.tvRemote, ANDROID_EMULATOR), false);
assert.equal(ANDROID_EMULATOR_E2E_COVERAGE[PUBLIC_COMMANDS.tvRemote].level, 'capability-denial');
assert.equal(
ANDROID_EMULATOR_E2E_COVERAGE[PUBLIC_COMMANDS.viewport].level,
'command-contract',
'viewport support is controlled by its platform runtime facts, not the capability catalog',
);
assert.equal(
ANDROID_EMULATOR_E2E_COVERAGE[PUBLIC_COMMANDS.prepare].level,
'command-contract',
@@ -11,10 +11,7 @@ import {
swipePayloadFromPositionals,
} from '@agent-device/contracts/interaction';
import { PUBLIC_COMMANDS } from '../../src/command-catalog.ts';
import {
isCommandSupportedOnDevice,
unsupportedHintForDevice,
} from '../../src/core/capabilities.ts';
import { isCommandSupportedOnDevice } from '../../src/core/capabilities.ts';
import { parseReplayScriptDetailed } from '@agent-device/ad-script';
import { isValidSelectorExpression } from '@agent-device/selectors';
import { IOS_SIMULATOR_BEHAVIOR_COVERAGE } from './ios-simulator-e2e/behavior-coverage.ts';
@@ -143,6 +140,14 @@ test('live iOS scenarios reference fixture identifiers that exist', () => {
test('capability classifications match executable simulator behavior', () => {
for (const [command, entry] of Object.entries(IOS_SIMULATOR_E2E_COVERAGE)) {
if (command === PUBLIC_COMMANDS.viewport) {
assert.equal(
entry.level,
'command-contract',
'viewport admission belongs to the exact-owner runtime fact',
);
continue;
}
const supported = isCommandSupportedOnDevice(command, IOS_SIMULATOR);
if (command === PUBLIC_COMMANDS.audio) {
assert.equal(
@@ -162,12 +167,10 @@ 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), 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',
assert.equal(
IOS_SIMULATOR_E2E_COVERAGE[PUBLIC_COMMANDS.viewport].level,
'command-contract',
'viewport denial is owned by exact platform runtime facts',
);
});
+11 -2
View File
@@ -4,7 +4,7 @@ import { createServer, type Server } from 'node:http';
import path from 'node:path';
import test from 'node:test';
import { type CliJsonResult, formatResultDebug, runBuiltCliJson } from './cli-json.ts';
import { assertPngFile } from './provider-scenarios/assertions.ts';
import { assertPngDimensions, assertPngFile } from './provider-scenarios/assertions.ts';
const TEST_NAME = 'live web platform e2e smoke';
const WEB_E2E_ENABLED = process.env.AGENT_DEVICE_WEB_E2E === '1';
@@ -49,6 +49,7 @@ async function runWebSmoke(context: WebSmokeContext): Promise<void> {
await runStep(context, 'verify managed web backend', ['web', 'doctor', '--json']);
await runStep(context, 'open local fixture', ['open', context.url, ...context.common]);
opened = true;
await assertWebViewport(context);
await assertInitialWebSurface(context);
await assertWebNetwork(context);
await assertReadAndVisibility(context);
@@ -59,6 +60,13 @@ async function runWebSmoke(context: WebSmokeContext): Promise<void> {
}
}
async function assertWebViewport(context: WebSmokeContext): Promise<void> {
await assertCommandData(context, 'resize browser viewport', ['viewport', '640', '480'], {
width: 640,
height: 480,
});
}
async function createWebSmokeContext(): Promise<WebSmokeContext> {
const artifactDir = createArtifactDir();
const stateDir = path.join(artifactDir, 'agent-device-state');
@@ -186,10 +194,11 @@ async function assertWebScreenshot(context: WebSmokeContext): Promise<void> {
await assertCommandData(
context,
'capture screenshot artifact',
['screenshot', context.screenshotPath, '--full', '--no-stabilize'],
['screenshot', context.screenshotPath, '--no-stabilize'],
{ path: context.screenshotPath },
);
assertPngFile(context.screenshotPath);
assertPngDimensions(context.screenshotPath, 640, 480);
}
async function assertCommandData(