mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
refactor: migrate viewport to request runtime (#1864)
* refactor: migrate viewport to request runtime * fix: preserve viewport cutover evidence
This commit is contained in:
committed by
GitHub
parent
fda81c5121
commit
37b1bc8cbd
@@ -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 });
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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: [] });
|
||||
});
|
||||
@@ -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) --
|
||||
{
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -281,6 +281,7 @@ const factOwnedCapabilityOperations: Readonly<
|
||||
close: ['closeApplication', 'finalizeApplicationClose'],
|
||||
prepare: ['prepareAppleRunner'],
|
||||
runtime: ['clearRuntimeHints'],
|
||||
viewport: ['setViewport'],
|
||||
});
|
||||
|
||||
function factOwnedCapabilityAvailable(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}` };
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user