mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
feat: add rotate command for iOS and Android (#344)
* feat: add device rotation command * fix: simplify rotate command handling
This commit is contained in:
committed by
GitHub
parent
8df45b69a8
commit
7df452b3b5
@@ -53,6 +53,7 @@ In practice, most work follows the same pattern:
|
||||
2. `open` a target app or URL.
|
||||
3. `snapshot -i` to inspect the current screen.
|
||||
4. `press`, `fill`, `scroll`, `get`, or `wait` using refs or selectors. On iOS and Android, default snapshot text follows the same visible-first contract: refs shown in default output are actionable now, while hidden content is surfaced as scroll/list discovery hints instead of tappable off-screen refs.
|
||||
Use `rotate <orientation>` when a flow needs a deterministic portrait or landscape state on mobile targets.
|
||||
5. `diff snapshot` or re-snapshot after UI changes.
|
||||
6. `close` when the session is finished.
|
||||
|
||||
|
||||
+16
@@ -578,6 +578,22 @@ extension RunnerTests {
|
||||
case .home:
|
||||
pressHomeButton()
|
||||
return Response(ok: true, data: DataPayload(message: "home"))
|
||||
case .rotate:
|
||||
guard let orientation = command.orientation?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!orientation.isEmpty
|
||||
else {
|
||||
return Response(ok: false, error: ErrorPayload(message: "rotate requires orientation"))
|
||||
}
|
||||
if rotateDevice(to: orientation) {
|
||||
return Response(
|
||||
ok: true,
|
||||
data: DataPayload(message: "rotate", orientation: orientation)
|
||||
)
|
||||
}
|
||||
return Response(
|
||||
ok: false,
|
||||
error: ErrorPayload(message: "unsupported rotate orientation: \(orientation)")
|
||||
)
|
||||
case .appSwitcher:
|
||||
performAppSwitcherGesture(app: activeApp)
|
||||
return Response(ok: true, data: DataPayload(message: "appSwitcher"))
|
||||
|
||||
@@ -79,6 +79,27 @@ extension RunnerTests {
|
||||
#endif
|
||||
}
|
||||
|
||||
func rotateDevice(to orientationName: String) -> Bool {
|
||||
#if os(macOS)
|
||||
return false
|
||||
#else
|
||||
switch orientationName {
|
||||
case "portrait":
|
||||
XCUIDevice.shared.orientation = .portrait
|
||||
case "portrait-upside-down":
|
||||
XCUIDevice.shared.orientation = .portraitUpsideDown
|
||||
case "landscape-left":
|
||||
XCUIDevice.shared.orientation = .landscapeLeft
|
||||
case "landscape-right":
|
||||
XCUIDevice.shared.orientation = .landscapeRight
|
||||
default:
|
||||
return false
|
||||
}
|
||||
sleepFor(0.2)
|
||||
return true
|
||||
#endif
|
||||
}
|
||||
|
||||
private func pressTvRemoteMenuIfAvailable() -> Bool {
|
||||
#if os(tvOS)
|
||||
XCUIRemote.shared.press(.menu)
|
||||
|
||||
@@ -189,6 +189,7 @@ extension RunnerTests {
|
||||
.back,
|
||||
.backInApp,
|
||||
.backSystem,
|
||||
.rotate,
|
||||
.appSwitcher,
|
||||
.keyboardDismiss,
|
||||
.pinch:
|
||||
|
||||
@@ -18,6 +18,7 @@ enum CommandType: String, Codable {
|
||||
case backInApp
|
||||
case backSystem
|
||||
case home
|
||||
case rotate
|
||||
case appSwitcher
|
||||
case keyboardDismiss
|
||||
case alert
|
||||
@@ -47,6 +48,7 @@ struct Command: Codable {
|
||||
let y2: Double?
|
||||
let durationMs: Double?
|
||||
let direction: String?
|
||||
let orientation: String?
|
||||
let scale: Double?
|
||||
let outPath: String?
|
||||
let fps: Int?
|
||||
@@ -89,6 +91,7 @@ struct DataPayload: Codable {
|
||||
let visible: Bool?
|
||||
let wasVisible: Bool?
|
||||
let dismissed: Bool?
|
||||
let orientation: String?
|
||||
|
||||
init(
|
||||
message: String? = nil,
|
||||
@@ -108,7 +111,8 @@ struct DataPayload: Codable {
|
||||
currentUptimeMs: Double? = nil,
|
||||
visible: Bool? = nil,
|
||||
wasVisible: Bool? = nil,
|
||||
dismissed: Bool? = nil
|
||||
dismissed: Bool? = nil,
|
||||
orientation: String? = nil
|
||||
) {
|
||||
self.message = message
|
||||
self.text = text
|
||||
@@ -128,6 +132,7 @@ struct DataPayload: Codable {
|
||||
self.visible = visible
|
||||
self.wasVisible = wasVisible
|
||||
self.dismissed = dismissed
|
||||
self.orientation = orientation
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ Protocol and maintenance references:
|
||||
- `RunnerTests+Transport.swift`: TCP request handling and HTTP parsing/encoding.
|
||||
- `RunnerTests+CommandExecution.swift`: command dispatch (`execute*`) and command switch.
|
||||
- `RunnerTests+Lifecycle.swift`: activation/retry/stabilization and recording lifecycle helpers.
|
||||
- `RunnerTests+Interaction.swift`: tap/drag/swipe/type/back/home/app-switcher helpers.
|
||||
- `RunnerTests+Interaction.swift`: tap/drag/swipe/type/back/home/rotate/app-switcher helpers.
|
||||
- `RunnerTests+Snapshot.swift`: fast/raw snapshot builders and include/filter helpers.
|
||||
- `RunnerTests+SystemModal.swift`: SpringBoard/system modal detection and modal snapshot shaping.
|
||||
- `RunnerTests+ScreenRecorder.swift`: nested `ScreenRecorder` implementation.
|
||||
|
||||
@@ -36,6 +36,10 @@ Examples:
|
||||
{ "command": "recordStart", "outPath": "/tmp/demo.mp4", "fps": 30 }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "command": "rotate", "orientation": "landscape-left" }
|
||||
```
|
||||
|
||||
The current command names are defined in:
|
||||
|
||||
- [`../src/platforms/ios/runner-client.ts`](../src/platforms/ios/runner-client.ts)
|
||||
|
||||
@@ -150,6 +150,7 @@ test('core commands support iOS simulator, iOS device, and Android', () => {
|
||||
'perf',
|
||||
'press',
|
||||
'record',
|
||||
'rotate',
|
||||
'screenshot',
|
||||
'scroll',
|
||||
'scrollintoview',
|
||||
@@ -201,7 +202,16 @@ test('macOS supports the Apple runner interaction core but excludes mobile-only
|
||||
[{ device: macOsDevice, expected: true, label: 'on macOS' }],
|
||||
);
|
||||
assertCommandSupport(
|
||||
['app-switcher', 'boot', 'home', 'install', 'install-from-source', 'push', 'reinstall'],
|
||||
[
|
||||
'app-switcher',
|
||||
'boot',
|
||||
'home',
|
||||
'install',
|
||||
'install-from-source',
|
||||
'push',
|
||||
'reinstall',
|
||||
'rotate',
|
||||
],
|
||||
[{ device: macOsDevice, expected: false, label: 'on macOS' }],
|
||||
);
|
||||
});
|
||||
@@ -242,6 +252,11 @@ test('tvOS follows iOS capability matrix by device kind', () => {
|
||||
false,
|
||||
'keyboard on tvOS simulator',
|
||||
);
|
||||
assert.equal(
|
||||
isCommandSupportedOnDevice('rotate', tvOsSimulator),
|
||||
false,
|
||||
'rotate on tvOS simulator',
|
||||
);
|
||||
});
|
||||
|
||||
test('unknown commands default to supported', () => {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { beforeEach, test, vi } from 'vitest';
|
||||
import assert from 'node:assert/strict';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
vi.mock('../../platforms/ios/runner-client.ts', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../platforms/ios/runner-client.ts')>();
|
||||
return { ...actual, runIosRunnerCommand: vi.fn() };
|
||||
});
|
||||
|
||||
import { dispatchCommand } from '../dispatch.ts';
|
||||
import { runIosRunnerCommand } from '../../platforms/ios/runner-client.ts';
|
||||
import type { DeviceInfo } from '../../utils/device.ts';
|
||||
|
||||
const mockRunIosRunnerCommand = vi.mocked(runIosRunnerCommand);
|
||||
|
||||
const ANDROID_DEVICE: DeviceInfo = {
|
||||
platform: 'android',
|
||||
id: 'emulator-5554',
|
||||
name: 'Pixel',
|
||||
kind: 'emulator',
|
||||
booted: true,
|
||||
};
|
||||
|
||||
const IOS_DEVICE: DeviceInfo = {
|
||||
platform: 'ios',
|
||||
id: 'ios-device-1',
|
||||
name: 'iPhone',
|
||||
kind: 'device',
|
||||
booted: true,
|
||||
};
|
||||
|
||||
async function withMockedAdb(
|
||||
tempPrefix: string,
|
||||
run: (argsLogPath: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), tempPrefix));
|
||||
const adbPath = path.join(tmpDir, 'adb');
|
||||
const argsLogPath = path.join(tmpDir, 'args.log');
|
||||
await fs.writeFile(
|
||||
adbPath,
|
||||
'#!/bin/sh\nprintf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
|
||||
'utf8',
|
||||
);
|
||||
await fs.chmod(adbPath, 0o755);
|
||||
|
||||
const previousPath = process.env.PATH;
|
||||
const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE;
|
||||
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
|
||||
process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath;
|
||||
|
||||
try {
|
||||
await run(argsLogPath);
|
||||
} finally {
|
||||
process.env.PATH = previousPath;
|
||||
if (previousArgsFile === undefined) delete process.env.AGENT_DEVICE_TEST_ARGS_FILE;
|
||||
else process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile;
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockRunIosRunnerCommand.mockResolvedValue({ message: 'rotate', orientation: 'landscape-left' });
|
||||
});
|
||||
|
||||
test('dispatch rotate normalizes aliases before Android execution', async () => {
|
||||
await withMockedAdb('agent-device-dispatch-rotate-android-', async (argsLogPath) => {
|
||||
const result = await dispatchCommand(ANDROID_DEVICE, 'rotate', ['left']);
|
||||
|
||||
assert.equal(result?.action, 'rotate');
|
||||
assert.equal(result?.orientation, 'landscape-left');
|
||||
|
||||
const logged = await fs.readFile(argsLogPath, 'utf8');
|
||||
assert.match(logged, /shell\nsettings\nput\nsystem\naccelerometer_rotation\n0/);
|
||||
assert.match(logged, /shell\nsettings\nput\nsystem\nuser_rotation\n1/);
|
||||
});
|
||||
});
|
||||
|
||||
test('dispatch rotate sends normalized orientation to the iOS runner', async () => {
|
||||
const result = await dispatchCommand(IOS_DEVICE, 'rotate', ['right'], undefined, {
|
||||
appBundleId: 'com.example.app',
|
||||
});
|
||||
|
||||
assert.equal(result?.action, 'rotate');
|
||||
assert.equal(result?.orientation, 'landscape-right');
|
||||
assert.equal(mockRunIosRunnerCommand.mock.calls.length, 1);
|
||||
assert.deepEqual(mockRunIosRunnerCommand.mock.calls[0]?.[1], {
|
||||
command: 'rotate',
|
||||
orientation: 'landscape-right',
|
||||
appBundleId: 'com.example.app',
|
||||
});
|
||||
});
|
||||
@@ -149,6 +149,12 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
|
||||
apple: { simulator: true, device: true },
|
||||
android: { emulator: true, device: true, unknown: true },
|
||||
},
|
||||
rotate: {
|
||||
apple: { simulator: true, device: true },
|
||||
android: { emulator: true, device: true, unknown: true },
|
||||
supports: (device) =>
|
||||
device.platform === 'android' || (device.platform === 'ios' && device.target !== 'tv'),
|
||||
},
|
||||
screenshot: {
|
||||
apple: { simulator: true, device: true },
|
||||
android: { emulator: true, device: true, unknown: true },
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { AppError } from '../utils/errors.ts';
|
||||
|
||||
export type DeviceRotation =
|
||||
| 'portrait'
|
||||
| 'portrait-upside-down'
|
||||
| 'landscape-left'
|
||||
| 'landscape-right';
|
||||
|
||||
export function parseDeviceRotation(input: string | undefined): DeviceRotation {
|
||||
if (input === undefined) {
|
||||
throw new AppError(
|
||||
'INVALID_ARGS',
|
||||
'rotate requires an orientation argument. Use portrait|portrait-upside-down|landscape-left|landscape-right.',
|
||||
);
|
||||
}
|
||||
const normalized = input?.trim().toLowerCase();
|
||||
switch (normalized) {
|
||||
case 'portrait':
|
||||
return 'portrait';
|
||||
case 'portrait-upside-down':
|
||||
case 'upside-down':
|
||||
return 'portrait-upside-down';
|
||||
case 'landscape-left':
|
||||
case 'left':
|
||||
return 'landscape-left';
|
||||
case 'landscape-right':
|
||||
case 'right':
|
||||
return 'landscape-right';
|
||||
default:
|
||||
throw new AppError(
|
||||
'INVALID_ARGS',
|
||||
`Invalid rotation: ${input}. Use portrait|portrait-upside-down|landscape-left|landscape-right.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
runRepeatedSeries,
|
||||
} from './dispatch-series.ts';
|
||||
import { readNotificationPayload } from './dispatch-payload.ts';
|
||||
import { parseDeviceRotation } from './device-rotation.ts';
|
||||
|
||||
export { resolveTargetDevice } from './dispatch-resolve.ts';
|
||||
export { shouldUseIosTapSeries, shouldUseIosDragSeries };
|
||||
@@ -514,6 +515,15 @@ export async function dispatchCommand(
|
||||
await interactor.home();
|
||||
return { action: 'home', ...successText('Home') };
|
||||
}
|
||||
case 'rotate': {
|
||||
const orientation = parseDeviceRotation(positionals[0]);
|
||||
await interactor.rotate(orientation);
|
||||
return {
|
||||
action: 'rotate',
|
||||
orientation,
|
||||
...successText(`Rotated to ${orientation}`),
|
||||
};
|
||||
}
|
||||
case 'app-switcher': {
|
||||
await interactor.appSwitcher();
|
||||
return { action: 'app-switcher', ...successText('Opened app switcher') };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AppError } from '../utils/errors.ts';
|
||||
import type { DeviceInfo } from '../utils/device.ts';
|
||||
import type { DeviceRotation } from './device-rotation.ts';
|
||||
import type { ScrollDirection } from './scroll-gesture.ts';
|
||||
import {
|
||||
appSwitcherAndroid,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
openAndroidDevice,
|
||||
pressAndroid,
|
||||
readAndroidClipboardText,
|
||||
rotateAndroid,
|
||||
swipeAndroid,
|
||||
scrollAndroid,
|
||||
scrollIntoViewAndroid,
|
||||
@@ -91,6 +93,7 @@ export type Interactor = {
|
||||
screenshot(outPath: string, options?: ScreenshotOptions): Promise<void>;
|
||||
back(mode?: BackMode): Promise<void>;
|
||||
home(): Promise<void>;
|
||||
rotate(orientation: DeviceRotation): Promise<void>;
|
||||
appSwitcher(): Promise<void>;
|
||||
readClipboard(): Promise<string>;
|
||||
writeClipboard(text: string): Promise<void>;
|
||||
@@ -124,6 +127,7 @@ export function getInteractor(device: DeviceInfo, runnerContext: RunnerContext):
|
||||
screenshot: (outPath) => screenshotAndroid(device, outPath),
|
||||
back: (_mode) => backAndroid(device),
|
||||
home: () => homeAndroid(device),
|
||||
rotate: (orientation) => rotateAndroid(device, orientation),
|
||||
appSwitcher: () => appSwitcherAndroid(device),
|
||||
readClipboard: () => readAndroidClipboardText(device),
|
||||
writeClipboard: (text) => writeAndroidClipboardText(device, text),
|
||||
@@ -165,6 +169,13 @@ export function getInteractor(device: DeviceInfo, runnerContext: RunnerContext):
|
||||
runnerOpts,
|
||||
);
|
||||
},
|
||||
rotate: async (orientation) => {
|
||||
await runIosRunnerCommand(
|
||||
device,
|
||||
{ command: 'rotate', orientation, appBundleId: runnerContext.appBundleId },
|
||||
runnerOpts,
|
||||
);
|
||||
},
|
||||
appSwitcher: async () => {
|
||||
await runIosRunnerCommand(
|
||||
device,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
resolveAndroidApp,
|
||||
pushAndroidNotification,
|
||||
readAndroidClipboardText,
|
||||
rotateAndroid,
|
||||
setAndroidSetting,
|
||||
scrollAndroid,
|
||||
swipeAndroid,
|
||||
@@ -724,6 +725,20 @@ test('setAndroidSetting appearance toggle rejects unknown current mode output',
|
||||
);
|
||||
});
|
||||
|
||||
test('rotateAndroid locks auto-rotate and sets user rotation', async () => {
|
||||
await withMockedAdb(
|
||||
'agent-device-android-rotate-landscape-left-',
|
||||
'#!/bin/sh\nprintf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
|
||||
async ({ argsLogPath, device }) => {
|
||||
await rotateAndroid(device, 'landscape-left');
|
||||
const lines = (await fs.readFile(argsLogPath, 'utf8')).trim().split('\n').filter(Boolean);
|
||||
const logged = lines.join(' ');
|
||||
assert.match(logged, /shell settings put system accelerometer_rotation 0/);
|
||||
assert.match(logged, /shell settings put system user_rotation 1/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('setAndroidSetting fingerprint match uses adb shell cmd fingerprint touch', async () => {
|
||||
await withMockedAdb(
|
||||
'agent-device-android-fingerprint-match-',
|
||||
|
||||
@@ -21,6 +21,7 @@ export {
|
||||
swipeAndroid,
|
||||
backAndroid,
|
||||
homeAndroid,
|
||||
rotateAndroid,
|
||||
appSwitcherAndroid,
|
||||
longPressAndroid,
|
||||
typeAndroid,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { runCmd } from '../../utils/exec.ts';
|
||||
import { AppError } from '../../utils/errors.ts';
|
||||
import type { DeviceInfo } from '../../utils/device.ts';
|
||||
import type { DeviceRotation } from '../../core/device-rotation.ts';
|
||||
import { buildScrollGesturePlan, type ScrollDirection } from '../../core/scroll-gesture.ts';
|
||||
import { DEFAULT_ANDROID_SCROLL_INTO_VIEW_MAX_SCROLLS } from '../../utils/scroll-into-view.ts';
|
||||
import { findBounds, parseBounds, readNodeAttributes } from './ui-hierarchy.ts';
|
||||
@@ -42,6 +43,21 @@ export async function homeAndroid(device: DeviceInfo): Promise<void> {
|
||||
await runCmd('adb', adbArgs(device, ['shell', 'input', 'keyevent', '3']));
|
||||
}
|
||||
|
||||
export async function rotateAndroid(
|
||||
device: DeviceInfo,
|
||||
orientation: DeviceRotation,
|
||||
): Promise<void> {
|
||||
const userRotation = resolveAndroidUserRotation(orientation);
|
||||
await runCmd(
|
||||
'adb',
|
||||
adbArgs(device, ['shell', 'settings', 'put', 'system', 'accelerometer_rotation', '0']),
|
||||
);
|
||||
await runCmd(
|
||||
'adb',
|
||||
adbArgs(device, ['shell', 'settings', 'put', 'system', 'user_rotation', userRotation]),
|
||||
);
|
||||
}
|
||||
|
||||
export async function appSwitcherAndroid(device: DeviceInfo): Promise<void> {
|
||||
await runCmd('adb', adbArgs(device, ['shell', 'input', 'keyevent', '187']));
|
||||
}
|
||||
@@ -276,6 +292,21 @@ export async function scrollIntoViewAndroid(
|
||||
});
|
||||
}
|
||||
|
||||
function resolveAndroidUserRotation(orientation: DeviceRotation): string {
|
||||
switch (orientation) {
|
||||
case 'portrait':
|
||||
return '0';
|
||||
case 'landscape-left':
|
||||
return '1';
|
||||
case 'portrait-upside-down':
|
||||
return '2';
|
||||
case 'landscape-right':
|
||||
return '3';
|
||||
default:
|
||||
throw new AppError('INVALID_ARGS', `Unsupported Android rotation: ${orientation}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAndroidScreenSize(
|
||||
device: DeviceInfo,
|
||||
): Promise<{ width: number; height: number }> {
|
||||
|
||||
@@ -130,6 +130,7 @@ const runnerProtocolCommandFixtures: Record<RunnerCommand['command'], RunnerComm
|
||||
backInApp: { command: 'backInApp' },
|
||||
backSystem: { command: 'backSystem' },
|
||||
home: { command: 'home' },
|
||||
rotate: { command: 'rotate', orientation: 'landscape-left' },
|
||||
appSwitcher: { command: 'appSwitcher' },
|
||||
keyboardDismiss: { command: 'keyboardDismiss' },
|
||||
alert: { command: 'alert', action: 'accept' },
|
||||
@@ -206,6 +207,7 @@ test('runner protocol fixtures cover every runner command with JSON-safe samples
|
||||
'readText',
|
||||
'recordStart',
|
||||
'recordStop',
|
||||
'rotate',
|
||||
'screenshot',
|
||||
'shutdown',
|
||||
'snapshot',
|
||||
@@ -224,6 +226,7 @@ test('runner protocol fixtures cover every runner command with JSON-safe samples
|
||||
assert.equal(roundTrip.mouseClick.button, 'secondary');
|
||||
assert.equal(roundTrip.snapshot.scope, 'app');
|
||||
assert.equal(roundTrip.screenshot.fullscreen, true);
|
||||
assert.equal(roundTrip.rotate.orientation, 'landscape-left');
|
||||
assert.equal(roundTrip.recordStart.fps, 30);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AppError } from '../../utils/errors.ts';
|
||||
import type { ClickButton } from '../../core/click-button.ts';
|
||||
import type { DeviceRotation } from '../../core/device-rotation.ts';
|
||||
import { createRequestCanceledError, isRequestCanceled } from '../../daemon/request-cancel.ts';
|
||||
import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
|
||||
import type { RunnerSession } from './runner-session.ts';
|
||||
@@ -23,6 +24,7 @@ export type RunnerCommand = {
|
||||
| 'backInApp'
|
||||
| 'backSystem'
|
||||
| 'home'
|
||||
| 'rotate'
|
||||
| 'appSwitcher'
|
||||
| 'keyboardDismiss'
|
||||
| 'alert'
|
||||
@@ -47,6 +49,7 @@ export type RunnerCommand = {
|
||||
y2?: number;
|
||||
durationMs?: number;
|
||||
direction?: 'up' | 'down' | 'left' | 'right';
|
||||
orientation?: DeviceRotation;
|
||||
scale?: number;
|
||||
outPath?: string;
|
||||
fps?: number;
|
||||
|
||||
@@ -573,6 +573,12 @@ test('parseArgs supports trigger-app-event payload argument', () => {
|
||||
assert.deepEqual(parsed.positionals, ['screenshot_taken', '{"source":"qa"}']);
|
||||
});
|
||||
|
||||
test('parseArgs accepts rotate orientation aliases', () => {
|
||||
const parsed = parseArgs(['rotate', 'left'], { strictFlags: true });
|
||||
assert.equal(parsed.command, 'rotate');
|
||||
assert.deepEqual(parsed.positionals, ['left']);
|
||||
});
|
||||
|
||||
test('usageForCommand resolves longpress help', () => {
|
||||
const help = usageForCommand('longpress');
|
||||
assert.equal(help === null, false);
|
||||
@@ -620,6 +626,7 @@ test('usage includes concise top-level commands', () => {
|
||||
assert.match(usageText, /keyboard \[action\]/);
|
||||
assert.match(usageText, /trigger-app-event <event> \[payloadJson\]/);
|
||||
assert.match(usageText, /pinch <scale> \[x\] \[y\]/);
|
||||
assert.match(usageText, /rotate <orientation>/);
|
||||
assert.match(usageText, /record start \[path\] \| record stop/);
|
||||
assert.match(usageText, /trace start \[path\] \| trace stop/);
|
||||
});
|
||||
@@ -961,6 +968,13 @@ test('keyboard command usage is documented', () => {
|
||||
assert.match(help, /Inspect Android keyboard visibility\/type or dismiss the device keyboard/);
|
||||
});
|
||||
|
||||
test('rotate command usage is documented', () => {
|
||||
const help = usageForCommand('rotate');
|
||||
if (help === null) throw new Error('Expected command help text');
|
||||
assert.match(help, /rotate <portrait\|portrait-upside-down\|landscape-left\|landscape-right>/);
|
||||
assert.match(help, /Rotate device orientation on iOS and Android/);
|
||||
});
|
||||
|
||||
test('settings usage documents canonical faceid states', () => {
|
||||
const help = usageForCommand('settings');
|
||||
if (help === null) throw new Error('Expected command help text');
|
||||
|
||||
@@ -1093,6 +1093,13 @@ const COMMAND_SCHEMAS: Record<string, CommandSchema> = {
|
||||
positionalArgs: [],
|
||||
allowedFlags: [],
|
||||
},
|
||||
rotate: {
|
||||
usageOverride: 'rotate <portrait|portrait-upside-down|landscape-left|landscape-right>',
|
||||
helpDescription: 'Rotate device orientation on iOS and Android',
|
||||
summary: 'Rotate device orientation',
|
||||
positionalArgs: ['orientation'],
|
||||
allowedFlags: [],
|
||||
},
|
||||
'app-switcher': {
|
||||
helpDescription: 'Open app switcher (where supported)',
|
||||
summary: 'Open app switcher',
|
||||
|
||||
@@ -23,6 +23,8 @@ agent-device back
|
||||
agent-device back --in-app
|
||||
agent-device back --system
|
||||
agent-device home
|
||||
agent-device rotate portrait
|
||||
agent-device rotate landscape-left
|
||||
agent-device app-switcher
|
||||
```
|
||||
|
||||
@@ -40,6 +42,8 @@ agent-device app-switcher
|
||||
- `back` now defaults to app-owned back navigation. On Apple targets that means visible in-app back UI only. On Android this currently maps to the same back keyevent because Android routes in-app back through that platform event.
|
||||
- `back --in-app` is an explicit alias for the default app-owned behavior.
|
||||
- `back --system` asks for system back input explicitly. On Android this is the normal back keyevent. On iOS and tvOS it uses the platform back gesture or Siri Remote menu action. On macOS, where there is no generic system back input, `back --system` reports unavailable instead of falling back to app-owned navigation.
|
||||
- `rotate <orientation>` forces a mobile device into `portrait`, `portrait-upside-down`, `landscape-left`, or `landscape-right`.
|
||||
- `rotate` is supported on iOS and Android mobile targets. macOS and tvOS do not expose it.
|
||||
- On iOS devices, `http(s)://` URLs open in Safari when no app is active. Custom scheme URLs require an active app in the session.
|
||||
- `AGENT_DEVICE_SESSION` and `AGENT_DEVICE_PLATFORM` can pre-bind a default session/platform for CLI automation runs, so normal commands (`open`, `snapshot`, `press`, `fill`, `screenshot`, `devices`, and `batch`) do not need those flags repeated on every call.
|
||||
- A configured `AGENT_DEVICE_SESSION` implies bound-session lock mode by default. The CLI forwards that policy to the daemon, which enforces the same conflict handling for CLI, typed client, and direct RPC requests.
|
||||
@@ -150,7 +154,7 @@ agent-device snapshot -i --platform apple --target desktop
|
||||
- In macOS app sessions, `screenshot` captures the target app window bounds rather than the full desktop.
|
||||
- Prefer selector or `@ref`-driven interactions on macOS. Window position can shift between runs, so raw x/y point commands are less stable than snapshot-derived targets.
|
||||
- Use `click --button secondary` for context menus on macOS, then run `snapshot -i` again.
|
||||
- Mobile-only helpers remain unsupported on macOS: `boot`, `home`, `app-switcher`, `install`, `reinstall`, `install-from-source`, and `push`.
|
||||
- Mobile-only helpers remain unsupported on macOS: `boot`, `home`, `rotate`, `app-switcher`, `install`, `reinstall`, `install-from-source`, and `push`.
|
||||
|
||||
Recommended loops:
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ For exploratory QA and bug-hunting workflows, see `skills/dogfood/SKILL.md` in t
|
||||
|
||||
## Platform support highlights
|
||||
|
||||
- iOS core runner commands: `snapshot`, `snapshot --diff`, `diff snapshot`, `wait`, `click`, `fill`, `get`, `is`, `find`, `press`, `long-press`, `focus`, `type`, `scroll`, `scrollintoview`, `back`, `home`, `app-switcher`, `open` (app), `close`, `screenshot`, `apps`, `appstate`, `install`, `install-from-source`, `reinstall`, `trigger-app-event`.
|
||||
- iOS core runner commands: `snapshot`, `snapshot --diff`, `diff snapshot`, `wait`, `click`, `fill`, `get`, `is`, `find`, `press`, `long-press`, `focus`, `type`, `scroll`, `scrollintoview`, `back`, `home`, `rotate`, `app-switcher`, `open` (app), `close`, `screenshot`, `apps`, `appstate`, `install`, `install-from-source`, `reinstall`, `trigger-app-event`.
|
||||
- iOS `appstate` is session-scoped on the selected target device.
|
||||
- iOS/tvOS simulator-only: `settings`, `push`, `clipboard`.
|
||||
- Apple simulators and macOS desktop app sessions: `alert`, `pinch`.
|
||||
@@ -31,7 +31,7 @@ For exploratory QA and bug-hunting workflows, see `skills/dogfood/SKILL.md` in t
|
||||
- Physical devices use runner screenshot capture (`XCUIScreen.main.screenshot()` frames) stitched into MP4, so FPS is best-effort (not guaranteed 60 even with `--fps 60`).
|
||||
- Physical-device recording requires an active app session context (`open <app>` first).
|
||||
- Physical-device recording defaults to 15 FPS and supports `--fps` caps.
|
||||
- Android supports the same core interaction set, plus `push` notification simulation, `clipboard read/write`, and `keyboard status|get|dismiss`.
|
||||
- Android supports the same core interaction set, plus `rotate`, `push` notification simulation, `clipboard read/write`, and `keyboard status|get|dismiss`.
|
||||
- iOS supports `keyboard dismiss` through the XCTest runner when the on-screen keyboard is visible.
|
||||
- App-event triggers are available on iOS and Android through app-defined deep-link hooks (`trigger-app-event`), using active session context or explicit device selectors.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user