diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index bc92c4317..28ca87f35 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -252,7 +252,12 @@ extension RunnerTests { ) case .tap: if let selectorKey = command.selectorKey, let selectorValue = command.selectorValue { - let match = findElement(app: activeApp, selectorKey: selectorKey, selectorValue: selectorValue) + let match = findElement( + app: activeApp, + selectorKey: selectorKey, + selectorValue: selectorValue, + allowNonHittableFallback: command.allowNonHittableSelectorTap == true + ) if match.isAmbiguous { return Response(ok: false, error: ErrorPayload(code: "AMBIGUOUS_MATCH", message: "selector matched multiple elements")) } @@ -264,16 +269,24 @@ extension RunnerTests { var outcome = RunnerInteractionOutcome.performed let timing = measureGesture { withTemporaryScrollIdleTimeoutIfSupported(activeApp) { - outcome = activateElement(app: activeApp, element: element, action: "tap by selector") + if match.usedNonHittableFallback { + // Maestro compatibility: RN E2E backdoor controls can be 1x1 and + // reported non-hittable by XCTest, while Maestro still taps their + // resolved bounds. Keep this behind the explicit replay-only flag. + outcome = tapAt(app: activeApp, x: frame.midX, y: frame.midY) + } else { + outcome = activateElement(app: activeApp, element: element, action: "tap by selector") + } } } if let response = unsupportedResponse(for: outcome) { return response } + waitForTextEntryReadinessAfterTap(app: activeApp, element: element) return Response( ok: true, data: DataPayload( - message: "tapped", + message: match.usedNonHittableFallback ? "tapped via non-hittable coordinate fallback" : "tapped", gestureStartUptimeMs: timing.gestureStartUptimeMs, gestureEndUptimeMs: timing.gestureEndUptimeMs, x: touchFrame?.x, diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift index 188897624..5e8fb6437 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift @@ -27,6 +27,7 @@ extension RunnerTests { struct SelectorElementMatch { let element: XCUIElement? let isAmbiguous: Bool + let usedNonHittableFallback: Bool } enum TextTypingRepairMode { @@ -177,10 +178,15 @@ extension RunnerTests { return element.exists ? element : nil } - func findElement(app: XCUIApplication, selectorKey: String, selectorValue: String) -> SelectorElementMatch { + func findElement( + app: XCUIApplication, + selectorKey: String, + selectorValue: String, + allowNonHittableFallback: Bool = false + ) -> SelectorElementMatch { let value = selectorValue.trimmingCharacters(in: .whitespacesAndNewlines) guard !value.isEmpty else { - return SelectorElementMatch(element: nil, isAmbiguous: false) + return SelectorElementMatch(element: nil, isAmbiguous: false, usedNonHittableFallback: false) } let predicate: NSPredicate switch selectorKey { @@ -193,21 +199,47 @@ extension RunnerTests { case "text": predicate = NSPredicate(format: "label ==[c] %@ OR identifier ==[c] %@ OR value ==[c] %@", value, value, value) default: - return SelectorElementMatch(element: nil, isAmbiguous: false) + return SelectorElementMatch(element: nil, isAmbiguous: false, usedNonHittableFallback: false) } var matchedElement: XCUIElement? + var nonHittableElement: XCUIElement? let matches = app.descendants(matching: .any).matching(predicate).allElementsBoundByIndex for element in matches where element.exists { - guard element.isHittable else { + if !element.isHittable { + if allowNonHittableFallback && hasTappableFrame(app: app, element: element) { + guard nonHittableElement == nil else { + return SelectorElementMatch(element: nil, isAmbiguous: true, usedNonHittableFallback: false) + } + nonHittableElement = element + } continue } guard matchedElement == nil else { - return SelectorElementMatch(element: nil, isAmbiguous: true) + return SelectorElementMatch(element: nil, isAmbiguous: true, usedNonHittableFallback: false) } matchedElement = element } - return SelectorElementMatch(element: matchedElement, isAmbiguous: false) + if let matchedElement { + return SelectorElementMatch(element: matchedElement, isAmbiguous: false, usedNonHittableFallback: false) + } + return SelectorElementMatch( + element: nonHittableElement, + isAmbiguous: false, + usedNonHittableFallback: nonHittableElement != nil + ) + } + + private func hasTappableFrame(app: XCUIApplication, element: XCUIElement) -> Bool { + let frame = element.frame + if frame.isEmpty { + return false + } + let appFrame = app.frame + if appFrame.isEmpty { + return true + } + return appFrame.contains(CGPoint(x: frame.midX, y: frame.midY)) } func queryElement(app: XCUIApplication, selectorKey: String, selectorValue: String) -> Response { @@ -780,6 +812,35 @@ extension RunnerTests { #endif } + func waitForTextEntryReadinessAfterTap(app: XCUIApplication, element: XCUIElement) { +#if os(iOS) + switch element.elementType { + case .textField, .secureTextField, .searchField, .textView: + if waitForFocusedTextInput(app: app, timeout: TextEntryTiming.readinessTimeout) != nil { + return + } + let frame = element.frame + if !frame.isEmpty { + _ = tapAt(app: app, x: frame.midX, y: frame.midY) + _ = waitForFocusedTextInput(app: app, timeout: TextEntryTiming.readinessTimeout) + } + default: + return + } +#endif + } + + private func waitForFocusedTextInput(app: XCUIApplication, timeout: TimeInterval) -> XCUIElement? { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let focused = focusedTextInput(app: app) { + return focused + } + sleepFor(TextEntryTiming.pollInterval) + } + return focusedTextInput(app: app) + } + private func textEntryRefreshPoint(for element: XCUIElement?) -> CGPoint? { guard let element else { return nil diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift index 59ccb88a6..ab3021f79 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift @@ -37,6 +37,7 @@ struct Command: Codable { let text: String? let selectorKey: String? let selectorValue: String? + let allowNonHittableSelectorTap: Bool? let delayMs: Int? let textEntryMode: String? let clearFirst: Bool? diff --git a/src/compat/__tests__/replay-input.test.ts b/src/compat/__tests__/replay-input.test.ts index 225b5bc56..a863f519c 100644 --- a/src/compat/__tests__/replay-input.test.ts +++ b/src/compat/__tests__/replay-input.test.ts @@ -32,7 +32,7 @@ test('parseReplayInput routes compat replay scripts through the selected parser' parsed.actions.map((action) => [action.command, action.positionals]), [ ['open', ['com.callstack.agentdevicelab']], - ['click', ['id="submit-order"']], + ['__maestroTapOn', ['id="submit-order"']], ], ); }); @@ -60,7 +60,7 @@ env: parsed.actions.map((action) => [action.command, action.positionals]), [ ['open', ['cli-app']], - ['click', ['id="shell-button"']], + ['__maestroTapOn', ['id="shell-button"']], ], ); }); diff --git a/src/compat/maestro/__tests__/replay-flow.test.ts b/src/compat/maestro/__tests__/replay-flow.test.ts index cf17308d5..f54d670af 100644 --- a/src/compat/maestro/__tests__/replay-flow.test.ts +++ b/src/compat/maestro/__tests__/replay-flow.test.ts @@ -14,6 +14,8 @@ env: - launchApp - tapOn: id: home-open-form +- tapOn: + point: 20%,20% - doubleTapOn: id: release-notice delay: 150 @@ -37,6 +39,11 @@ env: start: 50%, 75% end: 50%, 35% duration: 300 +- swipe: + direction: LEFT +- scrollUntilVisible: + element: Discover + direction: UP - takeScreenshot: ./screens/form.png - hideKeyboard - stopApp @@ -47,34 +54,110 @@ env: parsed.actions.map((entry) => [entry.command, entry.positionals]), [ ['open', ['com.callstack.agentdevicelab']], - ['click', ['id="home-open-form"']], + ['__maestroTapOn', ['id="home-open-form"']], + ['__maestroTapPointPercent', ['20', '20']], ['click', ['id="release-notice"']], ['click', ['label="Agent Device Tester"']], ['open', ['exp://localhost:8082']], - ['click', ['label="Full name" || text="Full name" || id="Full name"']], + ['__maestroTapOn', ['label="Full name" || text="Full name" || id="Full name"']], ['type', ['Ada Lovelace']], ['wait', ['label="Checkout form"', '5000']], ['is', ['hidden', 'label="Missing banner"']], ['wait', ['id="submit-order"', '7000']], ['scroll', ['down']], ['scroll', ['down', '0.4']], + ['scroll', ['right']], + [ + '__maestroScrollUntilVisible', + ['label="Discover" || text="Discover" || id="Discover"', '5000', 'up'], + ], ['screenshot', ['./screens/form.png']], ['keyboard', ['dismiss']], ['close', ['com.callstack.agentdevicelab']], ], ); - assert.equal(parsed.actions[2]?.flags.doubleTap, true); - assert.equal(parsed.actions[2]?.flags.intervalMs, 150); - assert.equal(parsed.actions[3]?.flags.holdMs, 3000); + assert.equal(parsed.actions[3]?.flags.doubleTap, true); + assert.equal(parsed.actions[3]?.flags.intervalMs, 150); + assert.equal(parsed.actions[4]?.flags.holdMs, 3000); + assert.equal(parsed.actions[1]?.flags.allowNonHittableSelectorTap, true); + assert.equal(parsed.actions[6]?.flags?.allowNonHittableSelectorTap, undefined); +}); + +test('parseMaestroReplayFlow maps iOS openLink through the app id when available', () => { + const parsed = parseMaestroReplayFlow( + `appId: com.callstack.agentdevicelab +--- +- openLink: exp://localhost:8082 +`, + { platform: 'ios' }, + ); + + assert.deepEqual( + parsed.actions.map((entry) => [entry.command, entry.positionals]), + [['open', ['com.callstack.agentdevicelab', 'exp://localhost:8082']]], + ); +}); + +test('parseMaestroReplayFlow executes runScript and exposes output variables', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-maestro-runscript-')); + const scriptPath = path.join(root, 'setup.js'); + const flowPath = path.join(root, 'flow.yml'); + fs.writeFileSync( + scriptPath, + ` +var res = {body: '{"appviewDid":"did:plc:test"}'} +output.result = SERVER_PATH + ':' + json(res.body).appviewDid +`, + ); + + const parsed = parseMaestroReplayFlow( + `appId: com.callstack.agentdevicelab +--- +- runScript: + file: ./setup.js + env: + SERVER_PATH: local +- inputText: \${output.result} +`, + { sourcePath: flowPath }, + ); + + assert.deepEqual( + parsed.actions.map((entry) => [entry.command, entry.positionals]), + [['type', ['local:did:plc:test']]], + ); +}); + +test('parseMaestroReplayFlow reports runScript http failures with command context', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-maestro-runscript-fail-')); + const scriptPath = path.join(root, 'setup.js'); + const flowPath = path.join(root, 'flow.yml'); + fs.writeFileSync(scriptPath, `output.result = http.post('http://127.0.0.1:1').body`); + + assert.throws( + () => + parseMaestroReplayFlow( + `appId: com.callstack.agentdevicelab +--- +- runScript: ./setup.js +`, + { sourcePath: flowPath }, + ), + (error) => + error instanceof AppError && + error.code === 'COMMAND_FAILED' && + /runScript failed/.test(error.message) && + /http\.post failed/.test(error.message), + ); }); test('parseMaestroReplayFlow rejects unsupported Maestro commands', () => { assert.throws( - () => parseMaestroReplayFlow('---\n- scrollUntilVisible: Save\n'), + () => parseMaestroReplayFlow('---\n- travelThroughTime: Save\n'), (error) => error instanceof AppError && error.code === 'INVALID_ARGS' && - /scrollUntilVisible/.test(error.message) && + /travelThroughTime/.test(error.message) && /issues\/558/.test(error.message) && /issues\/new/.test(error.message) && /line 2/.test(error.message), @@ -103,52 +186,7 @@ test('parseMaestroReplayFlow preserves selector state and absolute swipe command assert.deepEqual(parsed.actionLines, [3, 6]); }); -test('parseMaestroReplayFlow maps easy Maestro device and utility commands', () => { - const parsed = parseMaestroReplayFlow(`appId: com.callstack.agentdevicelab -env: - VIDEO_PATH: ./recordings/checkout.mp4 ---- -- setAirplaneMode: true -- setAirplaneMode: false -- setLocation: - latitude: 52.2297 - longitude: 21.0122 -- setOrientation: landscapeLeft -- setPermissions: - camera: allow - microphone: deny - photos: unset - location: always -- killApp -- killApp: com.callstack.other -- pasteText: hello there -- startRecording: - path: \${VIDEO_PATH} -- stopRecording -- assertTrue: true -`); - - assert.deepEqual( - parsed.actions.map((entry) => [entry.command, entry.positionals]), - [ - ['settings', ['airplane', 'on']], - ['settings', ['airplane', 'off']], - ['settings', ['location', 'set', '52.2297', '21.0122']], - ['rotate', ['landscape-left']], - ['settings', ['permission', 'grant', 'camera']], - ['settings', ['permission', 'deny', 'microphone']], - ['settings', ['permission', 'reset', 'photos']], - ['settings', ['permission', 'grant', 'location-always']], - ['close', ['com.callstack.agentdevicelab']], - ['close', ['com.callstack.other']], - ['type', ['hello there']], - ['record', ['start', './recordings/checkout.mp4']], - ['record', ['stop']], - ], - ); -}); - -test('parseMaestroReplayFlow rejects unsupported easy-mapping variants loudly', () => { +test('parseMaestroReplayFlow rejects deferred Maestro utility commands loudly', () => { assert.throws( () => parseMaestroReplayFlow('---\n- assertTrue: "${READY}"\n'), (error) => @@ -160,11 +198,11 @@ test('parseMaestroReplayFlow rejects unsupported easy-mapping variants loudly', ); assert.throws( - () => parseMaestroReplayFlow('---\n- setPermissions:\n camera: always\n'), + () => parseMaestroReplayFlow('---\n- setPermissions:\n camera: allow\n'), (error) => error instanceof AppError && error.code === 'INVALID_ARGS' && - /setPermissions state "always"/.test(error.message) && + /setPermissions/.test(error.message) && /issues\/558/.test(error.message) && /line 2/.test(error.message), ); @@ -196,12 +234,12 @@ test('parseMaestroReplayFlow reports top-level command lines around nested lists - runFlow: commands: - tapOn: Nested -- scrollUntilVisible: Save +- travelThroughTime: Save `), (error) => error instanceof AppError && error.code === 'INVALID_ARGS' && - /scrollUntilVisible/.test(error.message) && + /travelThroughTime/.test(error.message) && /line 6/.test(error.message), ); }); @@ -251,14 +289,14 @@ onFlowComplete: assert.deepEqual( parsed.actions.map((entry) => [entry.command, entry.positionals]), [ - ['click', ['label="Before" || text="Before" || id="Before"']], - ['click', ['label="Nested" || text="Nested" || id="Nested"']], - ['click', ['id="child-repeat"']], - ['click', ['id="child-repeat"']], - ['click', ['label="iOS only" || text="iOS only" || id="iOS only"']], - ['click', ['label="Again" || text="Again" || id="Again"']], - ['click', ['label="Again" || text="Again" || id="Again"']], - ['click', ['label="After" || text="After" || id="After"']], + ['__maestroTapOn', ['label="Before" || text="Before" || id="Before"']], + ['__maestroTapOn', ['label="Nested" || text="Nested" || id="Nested"']], + ['__maestroTapOn', ['id="child-repeat"']], + ['__maestroTapOn', ['id="child-repeat"']], + ['__maestroTapOn', ['label="iOS only" || text="iOS only" || id="iOS only"']], + ['__maestroTapOn', ['label="Again" || text="Again" || id="Again"']], + ['__maestroTapOn', ['label="Again" || text="Again" || id="Again"']], + ['__maestroTapOn', ['label="After" || text="After" || id="After"']], ], ); }); @@ -279,57 +317,67 @@ test('parseMaestroReplayFlow skips platform-gated runFlow commands for other pla assert.deepEqual( parsed.actions.map((entry) => [entry.command, entry.positionals]), - [['click', ['label="Shared" || text="Shared" || id="Shared"']]], + [['__maestroTapOn', ['label="Shared" || text="Shared" || id="Shared"']]], ); }); -test('parseMaestroReplayFlow tolerates false launchApp reset options and rejects reset side effects', () => { +test('parseMaestroReplayFlow keeps visible-gated runFlow commands for runtime evaluation', () => { + const parsed = parseMaestroReplayFlow( + `appId: com.callstack.agentdevicelab +--- +- runFlow: + when: + visible: Continue + commands: + - tapOn: Continue +`, + { platform: 'ios' }, + ); + + assert.equal(parsed.actions[0]?.command, '__maestroRunFlowWhen'); + assert.deepEqual(parsed.actions[0]?.positionals, [ + 'visible', + 'label="Continue" || text="Continue" || id="Continue"', + ]); + assert.deepEqual(parsed.actions[0]?.flags.batchSteps, [ + { + command: '__maestroTapOn', + positionals: ['label="Continue" || text="Continue" || id="Continue"'], + flags: {}, + }, + ]); +}); + +test('parseMaestroReplayFlow accepts launchApp reset options', () => { const parsed = parseMaestroReplayFlow(`appId: com.callstack.agentdevicelab --- - launchApp: - clearState: false - clearKeychain: false + clearState: true + clearKeychain: true + arguments: + "-EXDevMenuIsOnboardingFinished": true + launchArguments: + "-Example": "ignored" stopApp: true `); assert.deepEqual( parsed.actions.map((entry) => [entry.command, entry.positionals, entry.flags]), - [['open', ['com.callstack.agentdevicelab'], { relaunch: true }]], - ); - - assert.throws( - () => - parseMaestroReplayFlow(`appId: com.callstack.agentdevicelab ---- -- launchApp: - clearState: true -`), - (error) => - error instanceof AppError && - error.code === 'INVALID_ARGS' && - /clearState: true/.test(error.message) && - /line 3/.test(error.message), + [ + [ + 'open', + ['com.callstack.agentdevicelab'], + { + maestroClearState: true, + relaunch: true, + launchArgs: ['-EXDevMenuIsOnboardingFinished', 'true', '-Example', 'ignored'], + }, + ], + ], ); }); -test('parseMaestroReplayFlow rejects runtime-dependent flow control for now', () => { - assert.throws( - () => - parseMaestroReplayFlow(`appId: com.callstack.agentdevicelab ---- -- runFlow: - when: - visible: Continue - commands: - - tapOn: Continue -`), - (error) => - error instanceof AppError && - error.code === 'INVALID_ARGS' && - /when.visible/.test(error.message) && - /line 3/.test(error.message), - ); - +test('parseMaestroReplayFlow rejects unsupported runtime-dependent flow control', () => { assert.throws( () => parseMaestroReplayFlow(`appId: com.callstack.agentdevicelab @@ -360,21 +408,21 @@ test('parseMaestroReplayFlow parses the test-app Maestro suite fixture', () => { parsed.actions.map((entry) => entry.command), [ 'wait', - 'click', + '__maestroTapOn', 'wait', - 'click', + '__maestroTapOn', 'type', - 'click', + '__maestroTapOn', 'type', - 'click', + '__maestroTapOn', 'wait', 'wait', 'scroll', - 'click', + '__maestroTapOn', 'wait', - 'click', + '__maestroTapOn', 'wait', - 'click', + '__maestroTapOn', 'wait', 'wait', ], diff --git a/src/compat/maestro/command-mapper.ts b/src/compat/maestro/command-mapper.ts index 76a3bc1b7..0d1f12c81 100644 --- a/src/compat/maestro/command-mapper.ts +++ b/src/compat/maestro/command-mapper.ts @@ -1,23 +1,13 @@ import type { SessionAction } from '../../daemon/types.ts'; import { AppError } from '../../utils/errors.ts'; -import { - convertAssertTrue, - convertKillApp, - convertLaunchApp, - convertSetAirplaneMode, - convertSetLocation, - convertSetOrientation, - convertSetPermissions, - convertStartRecording, - convertStopApp, - convertStopRecording, -} from './device-actions.ts'; +import { convertLaunchApp, convertStopApp } from './device-actions.ts'; import { convertDoubleTapOn, convertExtendedWaitUntil, convertLongPressOn, convertPressKey, convertScroll, + convertScrollUntilVisible, convertSwipe, convertTapOn, maestroSelector, @@ -25,17 +15,14 @@ import { } from './interactions.ts'; import { action, - assertOnlyKeys, - isPlainRecord, - normalizeCommandList, - normalizePlatformValue, - readEnvMap, readTimeoutMs, + requireAppId, requireStringValue, resolveMaestroString, unsupportedCommand, - unsupportedMaestroSyntax, } from './support.ts'; +import { convertRepeat, convertRunFlow } from './flow-control.ts'; +import { executeRunScript } from './run-script.ts'; import type { MaestroCommand, MaestroCommandMapperDeps, @@ -43,7 +30,6 @@ import type { MaestroParseContext, } from './types.ts'; -const MAX_REPEAT_EXPANSIONS = 100; type MaestroCommandHandler = (params: { value: unknown; config: MaestroFlowConfig; @@ -63,36 +49,33 @@ const MAP_COMMAND_HANDLERS: Record = { pasteText: ({ value, context, name }) => [ action('type', [resolveMaestroString(requireStringValue(name, value), context)]), ], - openLink: ({ value, context, name }) => [ - action('open', [resolveMaestroString(requireStringValue(name, value), context)]), - ], + openLink: ({ value, config, context, name }) => [convertOpenLink(value, config, context, name)], assertVisible: ({ value, context, name }) => [ action('wait', [maestroSelector(value, name, [], context), '5000']), ], assertNotVisible: ({ value, context, name }) => [ action('is', ['hidden', maestroSelector(value, name, [], context)]), ], - assertTrue: ({ value, context }) => convertAssertTrue(value, context), extendedWaitUntil: ({ value, context }) => convertExtendedWaitUntil(value, context), takeScreenshot: ({ value, context, name }) => [ action('screenshot', [resolveMaestroString(requireStringValue(name, value), context)]), ], scroll: ({ value }) => [convertScroll(value)], + scrollUntilVisible: ({ value, context }) => convertScrollUntilVisible(value, context), swipe: ({ value }) => [convertSwipe(value)], hideKeyboard: () => [action('keyboard', ['dismiss'])], pressKey: ({ value }) => [convertPressKey(value)], back: () => [action('back')], waitForAnimationToEnd: ({ value }) => [action('wait', [String(readTimeoutMs(value, 250))])], stopApp: ({ value, config, context }) => [convertStopApp(value, config, context)], - killApp: ({ value, config, context }) => [convertKillApp(value, config, context)], - setAirplaneMode: ({ value, context }) => [convertSetAirplaneMode(value, context)], - setLocation: ({ value, context }) => [convertSetLocation(value, context)], - setOrientation: ({ value, context }) => [convertSetOrientation(value, context)], - setPermissions: ({ value, context }) => convertSetPermissions(value, context), - startRecording: ({ value, context }) => [convertStartRecording(value, context)], - stopRecording: ({ value }) => [convertStopRecording(value)], - runFlow: ({ value, config, context, deps }) => convertRunFlow(value, config, context, deps), - repeat: ({ value, config, context, deps }) => convertRepeat(value, config, context, deps), + runScript: ({ value, context }) => { + executeRunScript(value, context); + return []; + }, + runFlow: ({ value, config, context, deps }) => + convertRunFlow(value, config, context, deps, convertCommandList), + repeat: ({ value, config, context, deps }) => + convertRepeat(value, config, context, deps, convertCommandList), }; const SCALAR_COMMAND_HANDLERS: Record< @@ -105,9 +88,6 @@ const SCALAR_COMMAND_HANDLERS: Record< back: () => [action('back')], waitForAnimationToEnd: () => [action('wait', ['250'])], stopApp: (config, context) => [convertStopApp(undefined, config, context)], - killApp: (config, context) => [convertKillApp(undefined, config, context)], - startRecording: () => [action('record', ['start'])], - stopRecording: () => [action('record', ['stop'])], }; export function convertMaestroCommandWithLine( @@ -156,63 +136,17 @@ function convertScalarCommand( return handler(config, context); } -function convertRunFlow( +function convertOpenLink( value: unknown, config: MaestroFlowConfig, context: MaestroParseContext, - deps: MaestroCommandMapperDeps, -): SessionAction[] { - if (typeof value === 'string') { - return deps.parseRunFlowFile(resolveMaestroString(value, context), context).actions; - } - if (!isPlainRecord(value)) { - throw new AppError('INVALID_ARGS', 'runFlow expects a file path string or map.'); - } - assertOnlyKeys(value, 'runFlow', ['file', 'commands', 'env', 'when', 'label']); - if (!shouldRunFlow(value.when, context)) return []; - - const runContext = { - ...context, - env: { ...context.env, ...readEnvMap(value.env, 'runFlow.env'), ...context.envOverrides }, - }; - if (typeof value.file === 'string') { - return deps.parseRunFlowFile(resolveMaestroString(value.file, runContext), runContext).actions; - } - if (Array.isArray(value.commands)) { - return convertCommandList(normalizeCommandList(value.commands), config, runContext, deps); - } - throw new AppError('INVALID_ARGS', 'runFlow map requires either file or commands.'); -} - -function convertRepeat( - value: unknown, - config: MaestroFlowConfig, - context: MaestroParseContext, - deps: MaestroCommandMapperDeps, -): SessionAction[] { - if (!isPlainRecord(value)) { - throw new AppError('INVALID_ARGS', 'repeat expects a map.'); - } - assertOnlyKeys(value, 'repeat', ['times', 'commands', 'while']); - if (value.while !== undefined) { - throw unsupportedMaestroSyntax( - 'Maestro repeat.while is not supported yet. Only deterministic repeat.times is supported.', - ); - } - const times = readRepeatTimes(value.times, context); - if (!Array.isArray(value.commands)) { - throw new AppError('INVALID_ARGS', 'repeat requires a commands list.'); - } - if (times > MAX_REPEAT_EXPANSIONS) { - throw new AppError( - 'INVALID_ARGS', - `repeat.times must be <= ${MAX_REPEAT_EXPANSIONS} for deterministic replay expansion.`, - ); + name: string, +): SessionAction { + const url = resolveMaestroString(requireStringValue(name, value), context); + if (context.platform === 'ios' && config.appId) { + return action('open', [resolveMaestroString(requireAppId(config, name), context), url]); } - const commands = normalizeCommandList(value.commands); - return Array.from({ length: times }).flatMap(() => - convertCommandList(commands, config, context, deps), - ); + return action('open', [url]); } function convertCommandList( @@ -225,50 +159,3 @@ function convertCommandList( convertMaestroCommandWithLine(command, config, index + 1, context, deps), ); } - -function shouldRunFlow(value: unknown, context: MaestroParseContext): boolean { - if (value === undefined || value === null) return true; - if (!isPlainRecord(value)) { - throw new AppError('INVALID_ARGS', 'runFlow.when expects a map.'); - } - assertOnlyKeys(value, 'runFlow.when', ['platform', 'visible', 'notVisible', 'true']); - rejectUnsupportedCondition(value, 'visible', 'when.visible'); - rejectUnsupportedCondition(value, 'notVisible', 'when.notVisible'); - rejectUnsupportedCondition(value, 'true', 'when.true'); - if (value.platform === undefined) return true; - const platform = normalizePlatformValue(value.platform, 'runFlow.when.platform'); - if (!context.platform) { - throw new AppError( - 'INVALID_ARGS', - 'Maestro runFlow.when.platform requires replay to be run with --platform ios|android.', - ); - } - return platform === context.platform; -} - -function readRepeatTimes(value: unknown, context: MaestroParseContext): number { - const resolved = typeof value === 'string' ? resolveMaestroString(value, context) : value; - const numeric = - typeof resolved === 'number' - ? resolved - : typeof resolved === 'string' && /^\d+$/.test(resolved) - ? Number(resolved) - : undefined; - if (numeric === undefined || !Number.isInteger(numeric) || numeric < 0) { - throw new AppError( - 'INVALID_ARGS', - 'repeat.times must be a non-negative integer or ${VAR} resolving to one.', - ); - } - return numeric; -} - -function rejectUnsupportedCondition( - value: Record, - key: string, - label: string, -): void { - if (value[key] !== undefined) { - throw unsupportedMaestroSyntax(`Maestro ${label} is not supported yet.`); - } -} diff --git a/src/compat/maestro/device-actions.ts b/src/compat/maestro/device-actions.ts index 95e33db4d..8dcaa62b0 100644 --- a/src/compat/maestro/device-actions.ts +++ b/src/compat/maestro/device-actions.ts @@ -4,50 +4,11 @@ import { action, assertOnlyKeys, isPlainRecord, - normalizeToken, - readBooleanLiteral, requireAppId, resolveMaestroString, - resolveMaybeMaestroString, unsupportedMaestroSyntax, } from './support.ts'; -import type { MaestroFlowConfig, MaestroParseContext, PermissionCommand } from './types.ts'; - -const SUPPORTED_PERMISSION_TARGETS = new Set([ - 'accessibility', - 'calendar', - 'camera', - 'contacts', - 'contacts-limited', - 'input-monitoring', - 'location', - 'location-always', - 'media-library', - 'microphone', - 'motion', - 'notifications', - 'photos', - 'reminders', - 'screen-recording', - 'siri', -]); - -const BASIC_PERMISSION_STATES: Record = { - allow: 'grant', - grant: 'grant', - granted: 'grant', - deny: 'deny', - denied: 'deny', - reset: 'reset', - unset: 'reset', - revoke: 'reset', - revoked: 'reset', -}; - -const MODE_PERMISSION_STATES: Record = { - limited: { command: 'grant', mode: 'limited' }, - full: { command: 'grant', mode: 'full' }, -}; +import type { MaestroFlowConfig, MaestroParseContext } from './types.ts'; export function convertLaunchApp( value: unknown, @@ -70,16 +31,19 @@ export function convertLaunchApp( 'permissions', 'launchArguments', ]); - rejectTruthyLaunchOption(value, 'clearState'); - rejectTruthyLaunchOption(value, 'clearKeychain'); - rejectUnsupportedLaunchOption(value, 'arguments'); rejectUnsupportedLaunchOption(value, 'permissions'); - rejectUnsupportedLaunchOption(value, 'launchArguments'); const appId = resolveMaestroString( typeof value.appId === 'string' ? value.appId : requireAppId(config, 'launchApp'), context, ); - return action('open', [appId], { relaunch: value.stopApp === true }); + const launchArgs = readLaunchArgs(value, context); + const shouldClearState = value.clearState === true; + const shouldRelaunch = value.stopApp === true || shouldClearState || launchArgs.length > 0; + return action('open', [appId], { + relaunch: shouldRelaunch, + ...(shouldClearState ? { maestroClearState: true } : {}), + ...(launchArgs.length > 0 ? { launchArgs } : {}), + }); } export function convertStopApp( @@ -94,173 +58,32 @@ export function convertStopApp( throw new AppError('INVALID_ARGS', 'stopApp expects a string appId or no value.'); } -export function convertSetAirplaneMode( - value: unknown, - context: MaestroParseContext, -): SessionAction { - const enabled = readBooleanLiteral(resolveMaybeMaestroString(value, context), 'setAirplaneMode'); - return action('settings', ['airplane', enabled ? 'on' : 'off']); -} - -export function convertSetLocation(value: unknown, context: MaestroParseContext): SessionAction { - if (!isPlainRecord(value)) { - throw new AppError('INVALID_ARGS', 'setLocation expects a map.'); - } - assertOnlyKeys(value, 'setLocation', ['latitude', 'longitude', 'lat', 'lon', 'lng']); - const latitude = readCoordinate(value.latitude ?? value.lat, 'setLocation.latitude', context); - const longitude = readCoordinate( - value.longitude ?? value.lon ?? value.lng, - 'setLocation.longitude', - context, - ); - return action('settings', ['location', 'set', latitude, longitude]); -} - -export function convertSetOrientation(value: unknown, context: MaestroParseContext): SessionAction { - const raw = resolveMaybeMaestroString(value, context); - if (typeof raw !== 'string') { - throw new AppError('INVALID_ARGS', 'setOrientation expects a string value.'); - } - const orientation = normalizeToken(raw); - switch (orientation) { - case 'portrait': - case 'landscape-left': - case 'landscape-right': - return action('rotate', [orientation]); - case 'portrait-upside-down': - case 'upside-down': - return action('rotate', ['portrait-upside-down']); - default: - throw unsupportedMaestroSyntax( - `Maestro setOrientation "${raw}" cannot be mapped to a supported rotate orientation.`, - ); - } +function readLaunchArgs(value: Record, context: MaestroParseContext): string[] { + return [ + ...readLaunchArgValue(value.arguments, 'launchApp.arguments', context), + ...readLaunchArgValue(value.launchArguments, 'launchApp.launchArguments', context), + ]; } -export function convertSetPermissions( - value: unknown, - context: MaestroParseContext, -): SessionAction[] { - if (!isPlainRecord(value)) { - throw new AppError('INVALID_ARGS', 'setPermissions expects a map.'); +function readLaunchArgValue(value: unknown, name: string, context: MaestroParseContext): string[] { + if (value === undefined || value === null) return []; + if (typeof value === 'string') return [resolveMaestroString(value, context)]; + if (Array.isArray(value)) { + return value.map((entry, index) => readLaunchArgScalar(entry, `${name}[${index}]`, context)); } - return Object.entries(value).map(([rawTarget, rawState]) => { - const { target, command, mode } = readPermissionMapping(rawTarget, rawState, context); - return action('settings', ['permission', command, target, ...(mode ? [mode] : [])]); - }); -} - -export function convertKillApp( - value: unknown, - config: MaestroFlowConfig, - context: MaestroParseContext, -): SessionAction { - if (value === null || value === undefined) { - return action('close', [resolveMaestroString(requireAppId(config, 'killApp'), context)]); + if (isPlainRecord(value)) { + return Object.entries(value).flatMap(([key, entry]) => [ + resolveMaestroString(key, context), + readLaunchArgScalar(entry, `${name}.${key}`, context), + ]); } - if (typeof value === 'string') return action('close', [resolveMaestroString(value, context)]); - throw new AppError('INVALID_ARGS', 'killApp expects a string appId or no value.'); + throw new AppError('INVALID_ARGS', `${name} expects a string, list, or map.`); } -export function convertStartRecording(value: unknown, context: MaestroParseContext): SessionAction { - if (value === null || value === undefined) return action('record', ['start']); - if (typeof value === 'string') - return action('record', ['start', resolveMaestroString(value, context)]); - if (!isPlainRecord(value)) { - throw new AppError('INVALID_ARGS', 'startRecording expects a string path, map, or no value.'); - } - assertOnlyKeys(value, 'startRecording', ['path', 'file']); - const rawPath = value.path ?? value.file; - if (rawPath === undefined) return action('record', ['start']); - if (typeof rawPath !== 'string') { - throw new AppError('INVALID_ARGS', 'startRecording path must be a string.'); - } - return action('record', ['start', resolveMaestroString(rawPath, context)]); -} - -export function convertStopRecording(value: unknown): SessionAction { - if (value !== null && value !== undefined) { - throw new AppError('INVALID_ARGS', 'stopRecording expects no value.'); - } - return action('record', ['stop']); -} - -export function convertAssertTrue(value: unknown, context: MaestroParseContext): SessionAction[] { - const resolved = resolveMaybeMaestroString(value, context); - if (resolved === true || (typeof resolved === 'string' && normalizeToken(resolved) === 'true')) { - return []; - } - if ( - resolved === false || - (typeof resolved === 'string' && normalizeToken(resolved) === 'false') - ) { - throw new AppError('INVALID_ARGS', 'Maestro assertTrue literal evaluated to false.'); - } - throw unsupportedMaestroSyntax('Only literal Maestro assertTrue true/false is supported.'); -} - -function readCoordinate(value: unknown, name: string, context: MaestroParseContext): string { - const resolved = resolveMaybeMaestroString(value, context); - const numeric = - typeof resolved === 'number' - ? resolved - : typeof resolved === 'string' && resolved.trim().length > 0 - ? Number(resolved) - : Number.NaN; - if (!Number.isFinite(numeric)) { - throw new AppError('INVALID_ARGS', `${name} must be a finite number.`); - } - return String(numeric); -} - -function readPermissionMapping( - rawTarget: string, - rawState: unknown, - context: MaestroParseContext, -): { target: string; command: PermissionCommand; mode?: string } { - let target = normalizeToken(rawTarget); - const resolvedState = resolveMaybeMaestroString(rawState, context); - if (typeof resolvedState !== 'string') { - throw new AppError('INVALID_ARGS', `setPermissions.${rawTarget} expects a string state.`); - } - const state = normalizeToken(resolvedState); - if (target === 'location' && state === 'always') target = 'location-always'; - - if (!SUPPORTED_PERMISSION_TARGETS.has(target)) { - throw unsupportedMaestroSyntax( - `Maestro setPermissions target "${rawTarget}" cannot be mapped to a supported settings permission target.`, - ); - } - - const basicCommand = BASIC_PERMISSION_STATES[state]; - if (basicCommand) return { target, command: basicCommand }; - - const modeMapping = MODE_PERMISSION_STATES[state]; - if (modeMapping) return { target, ...modeMapping }; - - const locationCommand = readLocationPermissionCommand(target, state); - if (locationCommand) return { target, command: locationCommand }; - - throw unsupportedMaestroSyntax( - `Maestro setPermissions state "${resolvedState}" cannot be mapped to grant, deny, or reset.`, - ); -} - -function readLocationPermissionCommand( - target: string, - state: string, -): PermissionCommand | undefined { - if (target === 'location-always' && state === 'always') return 'grant'; - if (target === 'location' && (state === 'while-in-use' || state === 'when-in-use')) { - return 'grant'; - } - return undefined; -} - -function rejectTruthyLaunchOption(value: Record, key: string): void { - if (value[key] === true) { - throw unsupportedMaestroSyntax(`Maestro launchApp ${key}: true is not supported yet.`); - } +function readLaunchArgScalar(value: unknown, name: string, context: MaestroParseContext): string { + if (typeof value === 'string') return resolveMaestroString(value, context); + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + throw new AppError('INVALID_ARGS', `${name} must be a string, number, or boolean.`); } function rejectUnsupportedLaunchOption(value: Record, key: string): void { diff --git a/src/compat/maestro/flow-control.ts b/src/compat/maestro/flow-control.ts new file mode 100644 index 000000000..d11e8af87 --- /dev/null +++ b/src/compat/maestro/flow-control.ts @@ -0,0 +1,203 @@ +import type { CommandFlags } from '../../core/dispatch.ts'; +import type { SessionAction } from '../../daemon/types.ts'; +import { AppError } from '../../utils/errors.ts'; +import { maestroSelector } from './interactions.ts'; +import { MAESTRO_RUNTIME_COMMAND } from './runtime-commands.ts'; +import { + action, + assertOnlyKeys, + isPlainRecord, + normalizeCommandList, + normalizePlatformValue, + readEnvMap, + resolveMaestroString, + unsupportedMaestroSyntax, +} from './support.ts'; +import type { + MaestroCommand, + MaestroCommandMapperDeps, + MaestroFlowConfig, + MaestroParseContext, +} from './types.ts'; + +const MAX_REPEAT_EXPANSIONS = 100; + +type ConvertCommandList = ( + commands: MaestroCommand[], + config: MaestroFlowConfig, + context: MaestroParseContext, + deps: MaestroCommandMapperDeps, +) => SessionAction[]; + +export function convertRunFlow( + value: unknown, + config: MaestroFlowConfig, + context: MaestroParseContext, + deps: MaestroCommandMapperDeps, + convertCommandList: ConvertCommandList, +): SessionAction[] { + if (typeof value === 'string') { + return deps.parseRunFlowFile(resolveMaestroString(value, context), context).actions; + } + if (!isPlainRecord(value)) { + throw new AppError('INVALID_ARGS', 'runFlow expects a file path string or map.'); + } + assertOnlyKeys(value, 'runFlow', ['file', 'commands', 'env', 'when', 'label']); + const condition = readRunFlowCondition(value.when, context); + if (!condition.shouldRun) return []; + + const runContext = { + ...context, + env: { ...context.env, ...readEnvMap(value.env, 'runFlow.env'), ...context.envOverrides }, + }; + const actions = readRunFlowActions(value, config, runContext, deps, convertCommandList); + return wrapRunFlowCondition(actions, condition); +} + +export function convertRepeat( + value: unknown, + config: MaestroFlowConfig, + context: MaestroParseContext, + deps: MaestroCommandMapperDeps, + convertCommandList: ConvertCommandList, +): SessionAction[] { + if (!isPlainRecord(value)) { + throw new AppError('INVALID_ARGS', 'repeat expects a map.'); + } + assertOnlyKeys(value, 'repeat', ['times', 'commands', 'while']); + if (value.while !== undefined) { + throw unsupportedMaestroSyntax( + 'Maestro repeat.while is not supported yet. Only deterministic repeat.times is supported.', + ); + } + const times = readRepeatTimes(value.times, context); + if (!Array.isArray(value.commands)) { + throw new AppError('INVALID_ARGS', 'repeat requires a commands list.'); + } + if (times > MAX_REPEAT_EXPANSIONS) { + throw new AppError( + 'INVALID_ARGS', + `repeat.times must be <= ${MAX_REPEAT_EXPANSIONS} for deterministic replay expansion.`, + ); + } + const commands = normalizeCommandList(value.commands); + return Array.from({ length: times }).flatMap(() => + convertCommandList(commands, config, context, deps), + ); +} + +function readRunFlowActions( + value: Record, + config: MaestroFlowConfig, + context: MaestroParseContext, + deps: MaestroCommandMapperDeps, + convertCommandList: ConvertCommandList, +): SessionAction[] { + if (typeof value.file === 'string') { + return deps.parseRunFlowFile(resolveMaestroString(value.file, context), context).actions; + } + if (Array.isArray(value.commands)) { + return convertCommandList(normalizeCommandList(value.commands), config, context, deps); + } + throw new AppError('INVALID_ARGS', 'runFlow map requires either file or commands.'); +} + +type RunFlowCondition = { + shouldRun: boolean; + visibleSelector?: string; + notVisibleSelector?: string; +}; + +function readRunFlowCondition(value: unknown, context: MaestroParseContext): RunFlowCondition { + if (value === undefined || value === null) return { shouldRun: true }; + if (!isPlainRecord(value)) { + throw new AppError('INVALID_ARGS', 'runFlow.when expects a map.'); + } + assertOnlyKeys(value, 'runFlow.when', ['platform', 'visible', 'notVisible', 'true']); + rejectUnsupportedCondition(value, 'true', 'when.true'); + if (value.platform !== undefined) { + const platform = normalizePlatformValue(value.platform, 'runFlow.when.platform'); + if (!context.platform) { + throw new AppError( + 'INVALID_ARGS', + 'Maestro runFlow.when.platform requires replay to be run with --platform ios|android.', + ); + } + if (platform !== context.platform) return { shouldRun: false }; + } + return { + shouldRun: true, + ...(value.visible !== undefined + ? { visibleSelector: maestroSelector(value.visible, 'runFlow.when.visible', [], context) } + : {}), + ...(value.notVisible !== undefined + ? { + notVisibleSelector: maestroSelector( + value.notVisible, + 'runFlow.when.notVisible', + [], + context, + ), + } + : {}), + }; +} + +function wrapRunFlowCondition( + actions: SessionAction[], + condition: RunFlowCondition, +): SessionAction[] { + if (!condition.visibleSelector && !condition.notVisibleSelector) return actions; + if (condition.visibleSelector && condition.notVisibleSelector) { + throw unsupportedMaestroSyntax( + 'Maestro runFlow.when cannot combine visible and notVisible yet.', + ); + } + return [ + action( + MAESTRO_RUNTIME_COMMAND.runFlowWhen, + condition.visibleSelector + ? ['visible', condition.visibleSelector] + : ['notVisible', condition.notVisibleSelector ?? ''], + { batchSteps: actions.map(sessionActionToBatchStep) }, + ), + ]; +} + +function sessionActionToBatchStep( + entry: SessionAction, +): NonNullable[number] { + return { + command: entry.command, + positionals: entry.positionals, + flags: entry.flags, + ...(entry.runtime !== undefined ? { runtime: entry.runtime } : {}), + }; +} + +function readRepeatTimes(value: unknown, context: MaestroParseContext): number { + const resolved = typeof value === 'string' ? resolveMaestroString(value, context) : value; + const numeric = + typeof resolved === 'number' + ? resolved + : typeof resolved === 'string' && /^\d+$/.test(resolved) + ? Number(resolved) + : undefined; + if (numeric === undefined || !Number.isInteger(numeric) || numeric < 0) { + throw new AppError( + 'INVALID_ARGS', + 'repeat.times must be a non-negative integer or ${VAR} resolving to one.', + ); + } + return numeric; +} + +function rejectUnsupportedCondition( + value: Record, + key: string, + label: string, +): void { + if (value[key] !== undefined) { + throw unsupportedMaestroSyntax(`Maestro ${label} is not supported yet.`); + } +} diff --git a/src/compat/maestro/interactions.ts b/src/compat/maestro/interactions.ts index c61271606..012e85226 100644 --- a/src/compat/maestro/interactions.ts +++ b/src/compat/maestro/interactions.ts @@ -9,12 +9,30 @@ import { resolveMaestroString, unsupportedMaestroSyntax, } from './support.ts'; +import { + parseAbsolutePoint, + parseMaestroPoint, + readScrollPositionalsFromPercentSwipe, +} from './points.ts'; +import { MAESTRO_RUNTIME_COMMAND } from './runtime-commands.ts'; import type { MaestroParseContext } from './types.ts'; export function convertTapOn(value: unknown, context: MaestroParseContext): SessionAction { + if (typeof value === 'string') { + return action(MAESTRO_RUNTIME_COMMAND.tapOn, [ + visibleTextSelector(resolveMaestroString(value, context)), + ]); + } if (isPlainRecord(value) && typeof value.point === 'string') { assertOnlyKeys(value, 'tapOn', ['point', 'repeat', 'delay']); - const point = parsePoint(value.point); + const point = parseMaestroPoint(value.point); + if (point.kind === 'percent') { + return action( + MAESTRO_RUNTIME_COMMAND.tapPointPercent, + [String(point.x), String(point.y)], + tapFlags(value), + ); + } return action('click', [String(point.x), String(point.y)], tapFlags(value)); } if (isPlainRecord(value)) { @@ -30,16 +48,16 @@ export function convertTapOn(value: unknown, context: MaestroParseContext): Sess ]); } return action( - 'click', + MAESTRO_RUNTIME_COMMAND.tapOn, [maestroSelector(value, 'tapOn', ['repeat', 'delay', 'optional', 'label'], context)], - tapFlags(value), + { ...tapFlags(value), allowNonHittableSelectorTap: true }, ); } export function convertDoubleTapOn(value: unknown, context: MaestroParseContext): SessionAction { if (isPlainRecord(value) && typeof value.point === 'string') { assertOnlyKeys(value, 'doubleTapOn', ['point', 'delay']); - const point = parsePoint(value.point); + const point = parseAbsolutePoint(value.point); return action('click', [String(point.x), String(point.y)], doubleTapFlags(value)); } if (isPlainRecord(value)) { @@ -55,7 +73,7 @@ export function convertDoubleTapOn(value: unknown, context: MaestroParseContext) export function convertLongPressOn(value: unknown, context: MaestroParseContext): SessionAction { if (isPlainRecord(value) && typeof value.point === 'string') { assertOnlyKeys(value, 'longPressOn', ['point']); - const point = parsePoint(value.point); + const point = parseAbsolutePoint(value.point); return action('longpress', [String(point.x), String(point.y), '3000']); } if (isPlainRecord(value)) { @@ -105,16 +123,43 @@ export function convertScroll(value: unknown): SessionAction { return action('scroll', ['down']); } +export function convertScrollUntilVisible( + value: unknown, + context: MaestroParseContext, +): SessionAction[] { + if (typeof value === 'string') { + return [ + action(MAESTRO_RUNTIME_COMMAND.scrollUntilVisible, [ + visibleTextSelector(resolveMaestroString(value, context)), + '5000', + 'down', + ]), + ]; + } + if (!isPlainRecord(value)) { + throw new AppError('INVALID_ARGS', 'scrollUntilVisible expects a string or map.'); + } + assertOnlyKeys(value, 'scrollUntilVisible', ['element', 'direction', 'timeout']); + const selector = maestroSelector(value.element, 'scrollUntilVisible.element', [], context); + const direction = + typeof value.direction === 'string' ? readScrollUntilVisibleDirection(value.direction) : 'down'; + const timeoutMs = String(readTimeoutMs(value, 5000)); + return [action(MAESTRO_RUNTIME_COMMAND.scrollUntilVisible, [selector, timeoutMs, direction])]; +} + export function convertSwipe(value: unknown): SessionAction { if (!isPlainRecord(value)) { throw new AppError('INVALID_ARGS', 'swipe expects a map.'); } - assertOnlyKeys(value, 'swipe', ['start', 'end', 'duration']); + assertOnlyKeys(value, 'swipe', ['start', 'end', 'direction', 'duration']); + if (typeof value.direction === 'string') { + return action('scroll', readScrollPositionalsFromDirectionSwipe(value.direction)); + } if (typeof value.start !== 'string' || typeof value.end !== 'string') { throw unsupportedMaestroSyntax('Only Maestro swipe start/end coordinates are supported.'); } - const start = parseSwipePoint(value.start); - const end = parseSwipePoint(value.end); + const start = parseMaestroPoint(value.start); + const end = parseMaestroPoint(value.end); const durationMs = typeof value.duration === 'number' && Number.isFinite(value.duration) ? String(Math.max(16, Math.floor(value.duration))) @@ -136,10 +181,39 @@ export function convertSwipe(value: unknown): SessionAction { ); } +function readScrollPositionalsFromDirectionSwipe(direction: string): string[] { + switch (direction.toLowerCase()) { + case 'up': + return ['down']; + case 'down': + return ['up']; + case 'left': + return ['right']; + case 'right': + return ['left']; + default: + throw unsupportedMaestroSyntax('Maestro swipe direction must be UP, DOWN, LEFT, or RIGHT.'); + } +} + +function readScrollUntilVisibleDirection(direction: string): string { + switch (direction.toLowerCase()) { + case 'up': + case 'down': + case 'left': + case 'right': + return direction.toLowerCase(); + default: + throw unsupportedMaestroSyntax( + 'Maestro scrollUntilVisible.direction must be UP, DOWN, LEFT, or RIGHT.', + ); + } +} + export function convertPressKey(value: unknown): SessionAction { const key = requireStringValue('pressKey', value).toLowerCase(); if (key === 'back') return action('back'); - if (key === 'enter' || key === 'return') return action('press', ['return']); + if (key === 'enter' || key === 'return') return action(MAESTRO_RUNTIME_COMMAND.pressEnter); if (key === 'home') return action('home'); throw unsupportedMaestroSyntax(`Maestro pressKey "${key}" is not supported yet.`); } @@ -189,12 +263,11 @@ function selectorTerm(key: string, value: string): string { function tapFlags(value: unknown): SessionAction['flags'] | undefined { if (!isPlainRecord(value)) return undefined; const flags: SessionAction['flags'] = {}; - if (typeof value.repeat === 'number' && Number.isInteger(value.repeat) && value.repeat > 1) { - flags.count = value.repeat; - } - if (typeof value.delay === 'number' && Number.isInteger(value.delay) && value.delay >= 0) { - flags.intervalMs = value.delay; - } + const repeat = positiveInteger(value.repeat); + const delay = nonNegativeInteger(value.delay); + if (repeat && repeat > 1) flags.count = repeat; + if (delay !== undefined) flags.intervalMs = delay; + if (value.optional === true) flags.maestroOptional = true; return Object.keys(flags).length > 0 ? flags : undefined; } @@ -206,57 +279,10 @@ function doubleTapFlags(value: unknown): SessionAction['flags'] { return flags; } -function parsePoint(value: string): { x: number; y: number } { - const match = value.match(/^(\d+),(\d+)$/); - if (!match) { - throw unsupportedMaestroSyntax( - 'Only absolute Maestro point selectors like "100,200" are supported.', - ); - } - return { x: Number(match[1]), y: Number(match[2]) }; -} - -type SwipePoint = - | { - kind: 'absolute'; - x: number; - y: number; - } - | { - kind: 'percent'; - x: number; - y: number; - }; - -function parseSwipePoint(value: string): SwipePoint { - const absolute = value.match(/^\s*(\d+)\s*,\s*(\d+)\s*$/); - if (absolute) { - return { kind: 'absolute', x: Number(absolute[1]), y: Number(absolute[2]) }; - } - const percent = value.match(/^\s*(\d+(?:\.\d+)?)%\s*,\s*(\d+(?:\.\d+)?)%\s*$/); - if (percent) { - return { kind: 'percent', x: Number(percent[1]), y: Number(percent[2]) }; - } - throw unsupportedMaestroSyntax( - 'Only Maestro swipe coordinates like "100,200" or "50%,75%" are supported.', - ); -} - -function readScrollPositionalsFromPercentSwipe( - start: Extract, - end: Extract, -): string[] { - const deltaX = end.x - start.x; - const deltaY = end.y - start.y; - if (Math.abs(deltaX) === 0 && Math.abs(deltaY) === 0) { - throw new AppError('INVALID_ARGS', 'swipe start and end cannot be the same point.'); - } - const vertical = Math.abs(deltaY) >= Math.abs(deltaX); - const direction = vertical ? (deltaY < 0 ? 'down' : 'up') : deltaX < 0 ? 'right' : 'left'; - const amount = Math.min(1, Math.max(0.01, Math.abs(vertical ? deltaY : deltaX) / 100)); - return [direction, formatAmount(amount)]; +function positiveInteger(value: unknown): number | undefined { + return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined; } -function formatAmount(value: number): string { - return value.toFixed(2).replace(/0+$/, '').replace(/\.$/, ''); +function nonNegativeInteger(value: unknown): number | undefined { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : undefined; } diff --git a/src/compat/maestro/points.ts b/src/compat/maestro/points.ts new file mode 100644 index 000000000..c0e25ffb4 --- /dev/null +++ b/src/compat/maestro/points.ts @@ -0,0 +1,57 @@ +import { AppError } from '../../utils/errors.ts'; +import { unsupportedMaestroSyntax } from './support.ts'; + +export type MaestroPoint = + | { + kind: 'absolute'; + x: number; + y: number; + } + | { + kind: 'percent'; + x: number; + y: number; + }; + +export function parseAbsolutePoint(value: string): { x: number; y: number } { + const match = value.match(/^(\d+),(\d+)$/); + if (!match) { + throw unsupportedMaestroSyntax( + 'Only absolute Maestro point selectors like "100,200" are supported.', + ); + } + return { x: Number(match[1]), y: Number(match[2]) }; +} + +export function parseMaestroPoint(value: string): MaestroPoint { + const absolute = value.match(/^\s*(\d+)\s*,\s*(\d+)\s*$/); + if (absolute) { + return { kind: 'absolute', x: Number(absolute[1]), y: Number(absolute[2]) }; + } + const percent = value.match(/^\s*(\d+(?:\.\d+)?)%\s*,\s*(\d+(?:\.\d+)?)%\s*$/); + if (percent) { + return { kind: 'percent', x: Number(percent[1]), y: Number(percent[2]) }; + } + throw unsupportedMaestroSyntax( + 'Only Maestro swipe coordinates like "100,200" or "50%,75%" are supported.', + ); +} + +export function readScrollPositionalsFromPercentSwipe( + start: Extract, + end: Extract, +): string[] { + const deltaX = end.x - start.x; + const deltaY = end.y - start.y; + if (Math.abs(deltaX) === 0 && Math.abs(deltaY) === 0) { + throw new AppError('INVALID_ARGS', 'swipe start and end cannot be the same point.'); + } + const vertical = Math.abs(deltaY) >= Math.abs(deltaX); + const direction = vertical ? (deltaY < 0 ? 'down' : 'up') : deltaX < 0 ? 'right' : 'left'; + const amount = Math.min(1, Math.max(0.01, Math.abs(vertical ? deltaY : deltaX) / 100)); + return [direction, formatAmount(amount)]; +} + +function formatAmount(value: number): string { + return value.toFixed(2).replace(/0+$/, '').replace(/\.$/, ''); +} diff --git a/src/compat/maestro/run-script.ts b/src/compat/maestro/run-script.ts new file mode 100644 index 000000000..9e036568e --- /dev/null +++ b/src/compat/maestro/run-script.ts @@ -0,0 +1,166 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { AppError } from '../../utils/errors.ts'; +import { runCmdSync } from '../../utils/exec.ts'; +import { + assertOnlyKeys, + isPlainRecord, + readEnvMap, + requireStringValue, + resolveMaestroString, +} from './support.ts'; +import type { MaestroParseContext } from './types.ts'; + +const RUN_SCRIPT_TIMEOUT_MS = 30_000; + +type HttpResponse = { + status: number; + body: string; + headers: Record; +}; + +const HTTP_REQUEST_SCRIPT = ` +const fs = require('node:fs'); +const input = JSON.parse(fs.readFileSync(0, 'utf8')); +fetch(input.url, { + method: input.method, + headers: input.headers, + body: input.body, +}).then(async response => { + process.stdout.write(JSON.stringify({ + status: response.status, + body: await response.text(), + headers: Object.fromEntries(response.headers.entries()), + })); +}).catch(error => { + console.error(error && error.stack ? error.stack : String(error)); + process.exit(1); +}); +`; + +export function executeRunScript(value: unknown, context: MaestroParseContext): void { + const scriptConfig = readRunScriptConfig(value, context); + const scriptPath = resolveRunScriptPath(scriptConfig.file, context); + const script = fs.readFileSync(scriptPath, 'utf8'); + const output: Record = {}; + const scriptEnv = { + ...context.env, + ...scriptConfig.env, + ...context.envOverrides, + }; + + try { + vm.runInNewContext(script, buildScriptGlobals(scriptEnv, output), { + filename: scriptPath, + timeout: RUN_SCRIPT_TIMEOUT_MS, + }); + } catch (error) { + throw new AppError( + 'COMMAND_FAILED', + `Maestro runScript failed for ${scriptPath}: ${error instanceof Error ? error.message : String(error)}`, + { scriptPath }, + error instanceof Error ? error : undefined, + ); + } + + for (const [key, rawValue] of Object.entries(output)) { + context.env[`output.${key}`] = stringifyOutputValue(rawValue); + } +} + +function readRunScriptConfig( + value: unknown, + context: MaestroParseContext, +): { file: string; env: Record } { + if (typeof value === 'string') { + return { file: resolveMaestroString(value, context), env: {} }; + } + if (!isPlainRecord(value)) { + throw new AppError('INVALID_ARGS', 'runScript expects a file path string or map.'); + } + assertOnlyKeys(value, 'runScript', ['file', 'env']); + const file = resolveMaestroString(requireStringValue('runScript.file', value.file), context); + const rawEnv = readEnvMap(value.env, 'runScript.env'); + const env = Object.fromEntries( + Object.entries(rawEnv).map(([key, envValue]) => [key, resolveMaestroString(envValue, context)]), + ); + return { file, env }; +} + +function resolveRunScriptPath(filePath: string, context: MaestroParseContext): string { + if (path.isAbsolute(filePath)) return filePath; + if (!context.baseDir) { + throw new AppError( + 'INVALID_ARGS', + 'runScript file paths require replay input to have a source path.', + ); + } + return path.resolve(context.baseDir, filePath); +} + +function buildScriptGlobals( + env: Record, + output: Record, +): vm.Context { + return { + ...env, + output, + json: (value: string) => JSON.parse(value) as unknown, + http: { + post: (url: string, options?: { headers?: Record; body?: string }) => + runHttpRequestSync('POST', url, options), + }, + }; +} + +function runHttpRequestSync( + method: string, + url: string, + options?: { headers?: Record; body?: string }, +): HttpResponse { + const result = runCmdSync(process.execPath, ['-e', HTTP_REQUEST_SCRIPT], { + stdin: JSON.stringify({ + method, + url, + headers: options?.headers ?? {}, + body: options?.body ?? '', + }), + timeoutMs: RUN_SCRIPT_TIMEOUT_MS, + allowFailure: true, + }); + if (result.exitCode !== 0) { + throw new AppError( + 'COMMAND_FAILED', + `Maestro runScript http.${method.toLowerCase()} failed for ${url}: ${trimHttpErrorOutput(result.stderr)}`, + { + exitCode: result.exitCode, + stderr: result.stderr, + }, + ); + } + try { + return JSON.parse(result.stdout) as HttpResponse; + } catch (error) { + throw new AppError( + 'COMMAND_FAILED', + `Maestro runScript http.${method.toLowerCase()} returned invalid JSON for ${url}`, + { + stdout: result.stdout.slice(0, 1000), + stderr: result.stderr.slice(0, 1000), + }, + error instanceof Error ? error : undefined, + ); + } +} + +function stringifyOutputValue(value: unknown): string { + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + return JSON.stringify(value); +} + +function trimHttpErrorOutput(stderr: string): string { + const trimmed = stderr.trim(); + return trimmed.length > 0 ? trimmed.slice(0, 1000) : 'request process exited without stderr'; +} diff --git a/src/compat/maestro/runtime-commands.ts b/src/compat/maestro/runtime-commands.ts new file mode 100644 index 000000000..02b816a4c --- /dev/null +++ b/src/compat/maestro/runtime-commands.ts @@ -0,0 +1,7 @@ +export const MAESTRO_RUNTIME_COMMAND = { + pressEnter: '__maestroPressEnter', + runFlowWhen: '__maestroRunFlowWhen', + scrollUntilVisible: '__maestroScrollUntilVisible', + tapOn: '__maestroTapOn', + tapPointPercent: '__maestroTapPointPercent', +} as const; diff --git a/src/compat/maestro/support.ts b/src/compat/maestro/support.ts index 3bd998faf..997a5c70d 100644 --- a/src/compat/maestro/support.ts +++ b/src/compat/maestro/support.ts @@ -63,24 +63,6 @@ export function normalizePlatformValue(value: unknown, name: string): 'android' return platform; } -export function normalizeToken(value: string): string { - return value - .trim() - .replace(/([a-z0-9])([A-Z])/g, '$1-$2') - .replace(/[\s_]+/g, '-') - .toLowerCase(); -} - -export function readBooleanLiteral(value: unknown, command: string): boolean { - if (typeof value === 'boolean') return value; - if (typeof value === 'string') { - const normalized = normalizeToken(value); - if (normalized === 'true') return true; - if (normalized === 'false') return false; - } - throw new AppError('INVALID_ARGS', `${command} expects a boolean value.`); -} - export function readEnvMap(value: unknown, name: string): Record { if (value === undefined || value === null) return {}; if (!isPlainRecord(value)) { @@ -113,15 +95,11 @@ export function requireStringValue(command: string, value: unknown): string { } export function resolveMaestroString(value: string, context: MaestroParseContext): string { - return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (match, key: string) => { + return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_.]*)\}/g, (match, key: string) => { return Object.prototype.hasOwnProperty.call(context.env, key) ? context.env[key] : match; }); } -export function resolveMaybeMaestroString(value: unknown, context: MaestroParseContext): unknown { - return typeof value === 'string' ? resolveMaestroString(value, context) : value; -} - export function unsupportedCommand(command: string): never { throw unsupportedMaestroSyntax(`Maestro command "${command}" is not supported yet.`); } diff --git a/src/compat/maestro/types.ts b/src/compat/maestro/types.ts index 81012d5e7..8ea39be92 100644 --- a/src/compat/maestro/types.ts +++ b/src/compat/maestro/types.ts @@ -31,5 +31,3 @@ export type MaestroParseContext = { export type MaestroCommandMapperDeps = { parseRunFlowFile(filePath: string, context: MaestroParseContext): MaestroReplayFlow; }; - -export type PermissionCommand = 'grant' | 'deny' | 'reset'; diff --git a/src/core/__tests__/dispatch-open.test.ts b/src/core/__tests__/dispatch-open.test.ts index c55cbbe68..807122348 100644 --- a/src/core/__tests__/dispatch-open.test.ts +++ b/src/core/__tests__/dispatch-open.test.ts @@ -23,3 +23,26 @@ test('dispatch open rejects URL as first argument when second URL is provided', }, ); }); + +test('dispatch open rejects Android launch arguments instead of dropping them', async () => { + const device: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }; + + await assert.rejects( + () => + dispatchCommand(device, 'open', ['com.example.app'], undefined, { + launchArgs: ['--fixture', 'demo'], + }), + (error: unknown) => { + assert.equal(error instanceof AppError, true); + assert.equal((error as AppError).code, 'UNSUPPORTED_OPERATION'); + assert.match((error as AppError).message, /Apple platforms/i); + return true; + }, + ); +}); diff --git a/src/core/dispatch-context.ts b/src/core/dispatch-context.ts index 37387f42b..9bf959b8b 100644 --- a/src/core/dispatch-context.ts +++ b/src/core/dispatch-context.ts @@ -12,7 +12,11 @@ export type BatchStep = { export type CommandFlags = Omit & { batchSteps?: BatchStep[]; + launchArgs?: string[]; replayBackend?: string; + allowNonHittableSelectorTap?: boolean; + maestroOptional?: boolean; + maestroClearState?: boolean; }; export type DispatchContext = ScreenshotDispatchFlags & { @@ -20,6 +24,8 @@ export type DispatchContext = ScreenshotDispatchFlags & { appBundleId?: string; activity?: string; launchConsole?: string; + launchArgs?: string[]; + maestroClearState?: boolean; verbose?: boolean; logPath?: string; traceLogPath?: string; @@ -40,9 +46,11 @@ export type DispatchContext = ScreenshotDispatchFlags & { pauseMs?: number; pattern?: 'one-way' | 'ping-pong'; surface?: SessionSurface; + allowNonHittableSelectorTap?: boolean; directElementSelector?: { key: 'id' | 'label' | 'text' | 'value'; value: string; raw: string; + allowNonHittableTap?: boolean; }; }; diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 543ce191f..e491a4d79 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -10,7 +10,7 @@ import { pushAndroidNotification } from '../platforms/android/notifications.ts'; import { getInteractor } from './interactors.ts'; import type { Interactor, RunnerContext } from './interactor-types.ts'; import { runIosRunnerCommand } from '../platforms/ios/runner-client.ts'; -import { pushIosNotification } from '../platforms/ios/apps.ts'; +import { clearIosSimulatorAppState, pushIosNotification } from '../platforms/ios/apps.ts'; import { isDeepLinkTarget } from './open-target.ts'; import { parseTriggerAppEventArgs, resolveAppEventUrl } from './app-events.ts'; import { @@ -205,6 +205,7 @@ async function handleOpenCommand( await interactor.open(app, { activity: context?.activity, appBundleId: context?.appBundleId, + launchArgs: context?.launchArgs, url, }); return { app, url, ...successText(`Opened: ${app}`) }; @@ -212,10 +213,32 @@ async function handleOpenCommand( if (launchConsole && isDeepLinkTarget(app)) { throw new AppError('INVALID_ARGS', LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE); } + if (device.platform === 'android' && context?.launchArgs && context.launchArgs.length > 0) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'Launch arguments are currently supported only on Apple platforms.', + ); + } + if (context?.maestroClearState) { + if (isDeepLinkTarget(app)) { + throw new AppError( + 'INVALID_ARGS', + 'Maestro launchApp.clearState requires an app target, not a deep link.', + ); + } + if (device.platform !== 'ios' || device.kind !== 'simulator') { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'Maestro launchApp.clearState is currently supported only on iOS simulators.', + ); + } + await clearIosSimulatorAppState(device, app); + } await interactor.open(app, { activity: context?.activity, appBundleId: context?.appBundleId, launchConsole, + launchArgs: context?.launchArgs, }); return { app, ...(launchConsole ? { launchConsole } : {}), ...successText(`Opened: ${app}`) }; } diff --git a/src/core/interactor-types.ts b/src/core/interactor-types.ts index 5da2f3a17..5e4acf3a8 100644 --- a/src/core/interactor-types.ts +++ b/src/core/interactor-types.ts @@ -29,6 +29,7 @@ export type ScreenshotOptions = { export type ElementSelectorTapOptions = { key: 'id' | 'label' | 'text' | 'value'; value: string; + allowNonHittableTap?: boolean; }; export type SnapshotOptions = BaseSnapshotOptions & { @@ -44,7 +45,13 @@ export type SnapshotResult = Omit & export type Interactor = { open( app: string, - options?: { activity?: string; appBundleId?: string; launchConsole?: string; url?: string }, + options?: { + activity?: string; + appBundleId?: string; + launchConsole?: string; + launchArgs?: string[]; + url?: string; + }, ): Promise; openDevice(): Promise; close(app: string): Promise; diff --git a/src/core/interactors/apple.ts b/src/core/interactors/apple.ts index a841b8c99..744adcb01 100644 --- a/src/core/interactors/apple.ts +++ b/src/core/interactors/apple.ts @@ -30,6 +30,7 @@ export function createAppleInteractor( openIosApp(device, app, { appBundleId: options?.appBundleId, launchConsole: options?.launchConsole, + launchArgs: options?.launchArgs, url: options?.url, }), openDevice: () => openIosDevice(device), diff --git a/src/daemon/__tests__/context.test.ts b/src/daemon/__tests__/context.test.ts index 090314199..bf117b083 100644 --- a/src/daemon/__tests__/context.test.ts +++ b/src/daemon/__tests__/context.test.ts @@ -14,6 +14,18 @@ test('contextFromFlags forwards scroll pixels from CLI flags', () => { assert.equal(context.pixels, 240); }); +test('contextFromFlags forwards internal non-hittable selector tap flag', () => { + const flags: CommandFlags = { allowNonHittableSelectorTap: true }; + const context = contextFromFlags('/tmp/agent-device.log', flags); + assert.equal(context.allowNonHittableSelectorTap, true); +}); + +test('contextFromFlags forwards Maestro clearState launch compatibility flag', () => { + const flags: CommandFlags = { maestroClearState: true }; + const context = contextFromFlags('/tmp/agent-device.log', flags); + assert.equal(context.maestroClearState, true); +}); + test('contextFromFlags forwards screenshot flags from CLI flags', () => { const flags: CommandFlags = { screenshotFullscreen: true, diff --git a/src/daemon/context.ts b/src/daemon/context.ts index 05c0864d4..a3fd128c7 100644 --- a/src/daemon/context.ts +++ b/src/daemon/context.ts @@ -8,6 +8,9 @@ import { getDiagnosticsMeta } from '../utils/diagnostics.ts'; export type DaemonCommandContext = DispatchContext & ScreenshotRuntimeFlags; +// Flat compatibility mapper: keeping each CLI flag visible here makes request +// context drift easier to spot than splitting the same optional fields apart. +// fallow-ignore-next-line complexity export function contextFromFlags( logPath: string, flags: CommandFlags | undefined, @@ -21,6 +24,8 @@ export function contextFromFlags( appBundleId, activity: flags?.activity, launchConsole: flags?.launchConsole, + launchArgs: flags?.launchArgs, + maestroClearState: flags?.maestroClearState, verbose: flags?.verbose, logPath, traceLogPath, @@ -41,5 +46,6 @@ export function contextFromFlags( backMode: flags?.backMode, pauseMs: flags?.pauseMs, pattern: flags?.pattern, + allowNonHittableSelectorTap: flags?.allowNonHittableSelectorTap, }; } diff --git a/src/daemon/direct-ios-selector.ts b/src/daemon/direct-ios-selector.ts index 5063f6632..aa0e82058 100644 --- a/src/daemon/direct-ios-selector.ts +++ b/src/daemon/direct-ios-selector.ts @@ -6,6 +6,7 @@ export type DirectIosSelectorTarget = { key: 'id' | 'label' | 'text' | 'value'; value: string; raw: string; + allowNonHittableTap?: boolean; }; export function readSimpleIosSelectorTarget(params: { diff --git a/src/daemon/handlers/__tests__/find.test.ts b/src/daemon/handlers/__tests__/find.test.ts index 7efbca153..9b7d165ba 100644 --- a/src/daemon/handlers/__tests__/find.test.ts +++ b/src/daemon/handlers/__tests__/find.test.ts @@ -121,6 +121,42 @@ test('handleFindCommands click returns deterministic metadata across locator var } }); +test('handleFindCommands click prefers on-screen duplicate text matches', async () => { + const { response, invokeCalls } = await runFindClickScenario({ + positionals: ['Sign in', 'click'], + nodes: [ + { + index: 0, + ref: 'e1', + type: 'Application', + hittable: true, + rect: { x: 0, y: 0, width: 440, height: 956 }, + }, + { + index: 1, + ref: 'e2', + type: 'Button', + label: 'Sign in', + hittable: false, + rect: { x: -199, y: 186, width: 70, height: 33 }, + parentIndex: 0, + }, + { + index: 2, + ref: 'e3', + type: 'Button', + label: 'Sign in', + hittable: false, + rect: { x: 40, y: 870, width: 360, height: 44 }, + parentIndex: 0, + }, + ], + }); + + expect(response.ok).toBe(true); + expect(invokeCalls[0].positionals?.[0]).toBe('@e3'); +}); + test('handleFindCommands wait bypasses snapshot cache while Android freshness recovery is active', async () => { const sessionName = 'android-find-wait'; const session: SessionState = { diff --git a/src/daemon/handlers/__tests__/interaction.test.ts b/src/daemon/handlers/__tests__/interaction.test.ts index 60e4eb7d9..116fb6f0a 100644 --- a/src/daemon/handlers/__tests__/interaction.test.ts +++ b/src/daemon/handlers/__tests__/interaction.test.ts @@ -410,6 +410,43 @@ test('click simple iOS id selector uses direct runner selector tap without snaps } }); +test('click simple iOS selector forwards Maestro non-hittable tap backdoor', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-maestro-selector-fallback'; + sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + + mockDispatch.mockResolvedValue({ + message: 'tapped via non-hittable coordinate fallback', + x: 439.5, + y: 101.5, + referenceWidth: 440, + referenceHeight: 956, + }); + + const response = await handleInteractionCommands({ + req: { + token: 't', + session: sessionName, + command: 'click', + positionals: ['id="e2eSignInAlice"'], + flags: { allowNonHittableSelectorTap: true }, + }, + sessionName, + sessionStore, + contextFromFlags, + }); + + expect(response?.ok).toBe(true); + const pressCalls = mockDispatch.mock.calls.filter((call) => call[1] === 'press'); + expect(pressCalls.length).toBe(1); + expect((pressCalls[0]?.[4] as Record)?.directElementSelector).toEqual({ + key: 'id', + value: 'e2eSignInAlice', + raw: 'id="e2eSignInAlice"', + allowNonHittableTap: true, + }); +}); + test('click simple iOS id selector falls back to snapshot coordinates when direct tap fails', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-direct-selector-fallback'; diff --git a/src/daemon/handlers/__tests__/session-replay-vars.test.ts b/src/daemon/handlers/__tests__/session-replay-vars.test.ts index db01e2b9c..72cea0e02 100644 --- a/src/daemon/handlers/__tests__/session-replay-vars.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-vars.test.ts @@ -441,6 +441,422 @@ test('runReplayScriptFile applies CLI env overrides before Maestro compat mappin assert.deepEqual(calls[1]?.positionals, ['id="shell-button"']); }); +test('runReplayScriptFile retries Maestro scrollUntilVisible with scroll probes', async () => { + const calls: CapturedInvocation[] = []; + let waitAttempts = 0; + const { response } = await runReplayFixture({ + label: 'maestro-scroll-until-visible', + script: [ + 'appId: demo.app', + '---', + '- scrollUntilVisible:', + ' element: Discover', + ' direction: UP', + ' timeout: 1200', + '', + ].join('\n'), + flags: { replayBackend: 'maestro' }, + invoke: async (req) => { + calls.push({ command: req.command, positionals: req.positionals, flags: req.flags }); + if (req.command === 'scroll') return { ok: true, data: {} }; + if (req.command === 'find') { + return { + ok: false, + error: { code: 'COMMAND_FAILED', message: 'find wait timed out' }, + }; + } + waitAttempts += 1; + if (waitAttempts === 3) return { ok: true, data: { waitedMs: 1100 } }; + return { + ok: false, + error: { code: 'COMMAND_FAILED', message: 'wait timed out' }, + }; + }, + }); + + assert.equal(response.ok, true); + assert.deepEqual( + calls.map((call) => [call.command, call.positionals]), + [ + ['wait', ['label="Discover" || text="Discover" || id="Discover"', '500']], + ['find', ['Discover', 'wait', '500']], + ['scroll', ['up']], + ['wait', ['label="Discover" || text="Discover" || id="Discover"', '500']], + ['find', ['Discover', 'wait', '500']], + ['scroll', ['up']], + ['wait', ['label="Discover" || text="Discover" || id="Discover"', '200']], + ], + ); +}); + +test('runReplayScriptFile lets Maestro scrollUntilVisible use fuzzy visible text matching', async () => { + const calls: CapturedInvocation[] = []; + const { response } = await runReplayFixture({ + label: 'maestro-scroll-until-visible-fuzzy-text', + script: ['appId: demo.app', '---', '- scrollUntilVisible:', ' element: Discover', ''].join( + '\n', + ), + flags: { replayBackend: 'maestro' }, + invoke: async (req) => { + calls.push({ command: req.command, positionals: req.positionals, flags: req.flags }); + if (req.command === 'find') return { ok: true, data: { found: true } }; + return { + ok: false, + error: { code: 'COMMAND_FAILED', message: 'wait timed out' }, + }; + }, + }); + + assert.equal(response.ok, true); + assert.deepEqual( + calls.map((call) => [call.command, call.positionals]), + [ + ['wait', ['label="Discover" || text="Discover" || id="Discover"', '500']], + ['find', ['Discover', 'wait', '500']], + ], + ); +}); + +test('runReplayScriptFile lets Maestro tapOn use fuzzy visible text matching', async () => { + const calls: CapturedInvocation[] = []; + const { response } = await runReplayFixture({ + label: 'maestro-tap-visible-text-fuzzy', + script: ['appId: demo.app', '---', '- tapOn: Discover', ''].join('\n'), + flags: { replayBackend: 'maestro' }, + invoke: async (req) => { + calls.push({ command: req.command, positionals: req.positionals, flags: req.flags }); + if (req.command === 'find') return { ok: true, data: { found: true } }; + return { + ok: false, + error: { code: 'COMMAND_FAILED', message: 'Selector did not match' }, + }; + }, + }); + + assert.equal(response.ok, true); + assert.deepEqual( + calls.map((call) => [call.command, call.positionals]), + [['find', ['Discover', 'click']]], + ); +}); + +test('runReplayScriptFile retries Maestro fuzzy tapOn without raw selector fallback', async () => { + const calls: CapturedInvocation[] = []; + let findAttempts = 0; + const { response } = await runReplayFixture({ + label: 'maestro-tap-visible-text-fuzzy-retry', + script: ['appId: demo.app', '---', '- tapOn: Discover', ''].join('\n'), + flags: { replayBackend: 'maestro' }, + invoke: async (req) => { + calls.push({ command: req.command, positionals: req.positionals, flags: req.flags }); + if (req.command === 'find') { + findAttempts += 1; + if (findAttempts === 2) return { ok: true, data: { found: true } }; + } + return { + ok: false, + error: { code: 'ELEMENT_NOT_FOUND', message: 'element not found' }, + }; + }, + }); + + assert.equal(response.ok, true); + assert.deepEqual( + calls.map((call) => [call.command, call.positionals]), + [ + ['find', ['Discover', 'click']], + ['find', ['Discover', 'click']], + ], + ); +}); + +test('runReplayScriptFile lets optional Maestro fuzzy tapOn hit native alert labels', async () => { + const calls: CapturedInvocation[] = []; + const { response } = await runReplayFixture({ + label: 'maestro-tap-visible-text-optional-native-label', + script: [ + 'appId: demo.app', + '---', + '- tapOn:', + ' text: Not Now', + ' optional: true', + '', + ].join('\n'), + flags: { replayBackend: 'maestro' }, + invoke: async (req) => { + calls.push({ command: req.command, positionals: req.positionals, flags: req.flags }); + if (req.command === 'click' && req.positionals?.[0] === 'label="Not Now"') { + return { ok: true, data: { dismissed: true } }; + } + return { + ok: false, + error: { code: 'ELEMENT_NOT_FOUND', message: 'element not found' }, + }; + }, + }); + + assert.equal(response.ok, true); + assert.deepEqual( + calls.map((call) => [call.command, call.positionals]), + [ + ['find', ['Not Now', 'click']], + ['click', ['label="Not Now"']], + ], + ); +}); + +test('runReplayScriptFile resolves Maestro percentage point taps from snapshot size', async () => { + const calls: CapturedInvocation[] = []; + const { response } = await runReplayFixture({ + label: 'maestro-tap-point-percent', + script: ['appId: demo.app', '---', '- tapOn:', ' point: 20%,20%', ''].join('\n'), + flags: { replayBackend: 'maestro' }, + invoke: async (req) => { + calls.push({ command: req.command, positionals: req.positionals, flags: req.flags }); + if (req.command === 'snapshot') { + return { + ok: true, + data: { + nodes: [ + { + index: 0, + type: 'application', + rect: { x: 0, y: 0, width: 1000, height: 2000 }, + }, + ], + }, + }; + } + return { ok: true, data: {} }; + }, + }); + + assert.equal(response.ok, true); + assert.deepEqual( + calls.map((call) => [call.command, call.positionals]), + [ + ['snapshot', []], + ['click', ['200', '400']], + ], + ); + assert.equal(calls[0]?.flags?.noRecord, true); +}); + +test('runReplayScriptFile retries Maestro tapOn until the selector appears', async () => { + const calls: CapturedInvocation[] = []; + let clickAttempts = 0; + const { response } = await runReplayFixture({ + label: 'maestro-tap-on-retry', + script: ['appId: demo.app', '---', '- tapOn:', ' id: delayedButton', ''].join('\n'), + flags: { replayBackend: 'maestro' }, + invoke: async (req) => { + calls.push({ command: req.command, positionals: req.positionals, flags: req.flags }); + clickAttempts += 1; + if (clickAttempts === 3) return { ok: true, data: {} }; + return { + ok: false, + error: { code: 'ELEMENT_NOT_FOUND', message: 'element not found' }, + }; + }, + }); + + assert.equal(response.ok, true); + assert.deepEqual( + calls.map((call) => [call.command, call.positionals]), + [ + ['click', ['id="delayedButton"']], + ['click', ['id="delayedButton"']], + ['click', ['id="delayedButton"']], + ], + ); +}); + +test('runReplayScriptFile recovers Maestro enter submit after iOS runner transport reset', async () => { + const calls: CapturedInvocation[] = []; + const { response } = await runReplayFixture({ + label: 'maestro-press-enter-recover', + script: ['appId: demo.app', '---', '- pressKey: Enter', ''].join('\n'), + flags: { replayBackend: 'maestro' }, + invoke: async (req) => { + calls.push({ command: req.command, positionals: req.positionals, flags: req.flags }); + if (req.command === 'snapshot') return { ok: true, data: {} }; + return { + ok: false, + error: { code: 'UNKNOWN', message: 'fetch failed' }, + }; + }, + }); + + assert.equal(response.ok, true); + assert.deepEqual( + calls.map((call) => [call.command, call.positionals]), + [ + ['type', ['\n']], + ['snapshot', []], + ], + ); +}); + +test('runReplayScriptFile skips Maestro runFlow.when.visible commands when absent', async () => { + const calls: CapturedInvocation[] = []; + const { response } = await runReplayFixture({ + label: 'maestro-run-flow-when-visible-skip', + script: [ + 'appId: demo.app', + '---', + '- runFlow:', + ' when:', + ' visible: Continue', + ' commands:', + ' - tapOn: Continue', + '', + ].join('\n'), + flags: { replayBackend: 'maestro' }, + invoke: async (req) => { + calls.push({ command: req.command, positionals: req.positionals, flags: req.flags }); + return { + ok: false, + error: { code: 'COMMAND_FAILED', message: 'not visible' }, + }; + }, + }); + + assert.equal(response.ok, true); + assert.deepEqual( + calls.map((call) => [call.command, call.positionals]), + [['is', ['visible', 'label="Continue" || text="Continue" || id="Continue"']]], + ); +}); + +test('runReplayScriptFile skips Maestro runFlow.when.visible commands on false predicate', async () => { + const calls: CapturedInvocation[] = []; + const { response } = await runReplayFixture({ + label: 'maestro-run-flow-when-visible-false-skip', + script: [ + 'appId: demo.app', + '---', + '- runFlow:', + ' when:', + ' visible: Continue', + ' commands:', + ' - tapOn: Continue', + '', + ].join('\n'), + flags: { replayBackend: 'maestro' }, + invoke: async (req) => { + calls.push({ command: req.command, positionals: req.positionals, flags: req.flags }); + return { ok: true, data: { pass: false } }; + }, + }); + + assert.equal(response.ok, true); + assert.deepEqual( + calls.map((call) => [call.command, call.positionals]), + [['is', ['visible', 'label="Continue" || text="Continue" || id="Continue"']]], + ); +}); + +test('runReplayScriptFile propagates Maestro runFlow.when runtime errors', async () => { + const { response } = await runReplayFixture({ + label: 'maestro-run-flow-when-visible-runtime-error', + script: [ + 'appId: demo.app', + '---', + '- runFlow:', + ' when:', + ' visible: Continue', + ' commands:', + ' - tapOn: Continue', + '', + ].join('\n'), + flags: { replayBackend: 'maestro' }, + invoke: async () => ({ + ok: false, + error: { code: 'UNKNOWN', message: 'fetch failed' }, + }), + }); + + assert.equal(response.ok, false); + if (!response.ok) { + assert.equal(response.error.code, 'UNKNOWN'); + assert.match(response.error.message, /fetch failed/); + } +}); + +test('runReplayScriptFile runs Maestro runFlow.when.visible commands when present', async () => { + const calls: CapturedInvocation[] = []; + const { response } = await runReplayFixture({ + label: 'maestro-run-flow-when-visible-run', + script: [ + 'appId: demo.app', + '---', + '- runFlow:', + ' when:', + ' visible: Continue', + ' commands:', + ' - tapOn: Continue', + '', + ].join('\n'), + flags: { replayBackend: 'maestro' }, + invoke: async (req) => { + calls.push({ command: req.command, positionals: req.positionals, flags: req.flags }); + if (req.command === 'is') return { ok: true, data: { pass: true } }; + if (req.command === 'click') { + return { + ok: false, + error: { code: 'COMMAND_FAILED', message: 'Selector did not match' }, + }; + } + return { ok: true, data: {} }; + }, + }); + + assert.equal(response.ok, true); + assert.deepEqual( + calls.map((call) => [call.command, call.positionals]), + [ + ['is', ['visible', 'label="Continue" || text="Continue" || id="Continue"']], + ['find', ['Continue', 'click']], + ], + ); +}); + +test('runReplayScriptFile runs nested Maestro runtime commands inside runFlow.when', async () => { + const calls: CapturedInvocation[] = []; + const { response } = await runReplayFixture({ + label: 'maestro-run-flow-when-nested-runtime', + script: [ + 'appId: demo.app', + '---', + '- runFlow:', + ' when:', + ' visible: Feed', + ' commands:', + ' - scrollUntilVisible:', + ' element: Done', + ' direction: DOWN', + ' timeout: 500', + '', + ].join('\n'), + flags: { replayBackend: 'maestro' }, + invoke: async (req) => { + calls.push({ command: req.command, positionals: req.positionals, flags: req.flags }); + if (req.command === 'is') return { ok: true, data: { pass: true } }; + if (req.command === 'wait') return { ok: true, data: { found: true } }; + return { ok: true, data: {} }; + }, + }); + + assert.equal(response.ok, true); + assert.deepEqual( + calls.map((call) => [call.command, call.positionals]), + [ + ['is', ['visible', 'label="Feed" || text="Feed" || id="Feed"']], + ['wait', ['label="Done" || text="Done" || id="Done"', '500']], + ], + ); +}); + test('runReplayScriptFile reads shell env from request (client-collected), not daemon process.env', async () => { // Ensure the daemon's own process.env does NOT contain AD_VAR_APP. assert.equal(process.env.AD_VAR_APP, undefined); diff --git a/src/daemon/handlers/find.ts b/src/daemon/handlers/find.ts index 42f2c9104..66363d85b 100644 --- a/src/daemon/handlers/find.ts +++ b/src/daemon/handlers/find.ts @@ -37,6 +37,10 @@ type ResolvedMatch = { actionFlags: Record; }; +type FindMatchResult = + | { ok: true; node: SnapshotState['nodes'][number] } + | { ok: false; response: DaemonResponse }; + export async function handleFindCommands(params: { req: DaemonRequest; sessionName: string; @@ -67,8 +71,7 @@ export async function handleFindCommands(params: { }); if (runtimeResponse) return runtimeResponse; const session = sessionStore.get(sessionName); - const isReadOnly = - action === 'exists' || action === 'wait' || action === 'get_text' || action === 'get_attrs'; + const isReadOnly = isReadOnlyFindAction(action); if (!session && !isReadOnly) { return errorResponse('SESSION_NOT_FOUND', 'No active session. Run open first.'); } @@ -76,9 +79,10 @@ export async function handleFindCommands(params: { if (!session) { await ensureDeviceReady(device); } - const scope = shouldScopeFind(locator) ? query : undefined; - const requiresRect = - action === 'click' || action === 'focus' || action === 'fill' || action === 'type'; + const requiresRect = findActionRequiresRect(action); + // Interaction targets need the full compact tree so duplicate labels can be + // resolved against viewport visibility before an off-screen subtree wins. + const scope = shouldScopeFind(locator) && !requiresRect ? query : undefined; const interactiveOnly = requiresRect; let lastSnapshotAt = 0; let lastNodes: SnapshotState['nodes'] | null = null; @@ -134,29 +138,16 @@ export async function handleFindCommands(params: { } const { nodes } = await fetchNodes(); - const bestMatches = findBestMatchesByLocator(nodes, locator, query, { - requireRect: requiresRect, + const matchResult = resolveFindMatch({ + nodes, + locator, + query, + requiresRect, + flags: req.flags, }); - - if (requiresRect && bestMatches.matches.length > 1) { - if (req.flags?.findFirst) { - bestMatches.matches = [bestMatches.matches[0]]; - } else if (req.flags?.findLast) { - bestMatches.matches = [bestMatches.matches[bestMatches.matches.length - 1]]; - } else { - return buildAmbiguousMatchError(bestMatches.matches, locator, query); - } - } - - const node = bestMatches.matches[0] ?? null; - if (!node) { - return errorResponse('COMMAND_FAILED', 'find did not match any element'); - } - - const resolvedNode = - action === 'click' || action === 'focus' || action === 'fill' || action === 'type' - ? (findNearestHittableAncestor(nodes, node) ?? node) - : node; + if (!matchResult.ok) return matchResult.response; + const node = matchResult.node; + const resolvedNode = requiresRect ? resolveInteractiveMatchNode(nodes, node) : node; const ref = `@${resolvedNode.ref}`; const actionFlags = { ...(req.flags ?? {}), noRecord: true }; const match: ResolvedMatch = { node, resolvedNode, ref, nodes, actionFlags }; @@ -177,6 +168,97 @@ export async function handleFindCommands(params: { // --- Per-action handlers --- +function isReadOnlyFindAction(action: string): boolean { + return ( + action === 'exists' || action === 'wait' || action === 'get_text' || action === 'get_attrs' + ); +} + +function findActionRequiresRect(action: string): boolean { + return action === 'click' || action === 'focus' || action === 'fill' || action === 'type'; +} + +function resolveFindMatch(params: { + nodes: SnapshotState['nodes']; + locator: FindLocator; + query: string; + requiresRect: boolean; + flags: DaemonRequest['flags']; +}): FindMatchResult { + const { nodes, locator, query, requiresRect, flags } = params; + const bestMatches = findBestMatchesByLocator(nodes, locator, query, { + requireRect: requiresRect, + }); + if (requiresRect) { + bestMatches.matches = preferOnscreenMatches(bestMatches.matches, nodes); + } + + if (requiresRect && bestMatches.matches.length > 1) { + if (flags?.findFirst) { + bestMatches.matches = [bestMatches.matches[0]]; + } else if (flags?.findLast) { + bestMatches.matches = [bestMatches.matches[bestMatches.matches.length - 1]]; + } else { + return { ok: false, response: buildAmbiguousMatchError(bestMatches.matches, locator, query) }; + } + } + + const node = bestMatches.matches[0] ?? null; + if (!node) { + return { + ok: false, + response: errorResponse('COMMAND_FAILED', 'find did not match any element'), + }; + } + return { ok: true, node }; +} + +function preferOnscreenMatches( + matches: SnapshotState['nodes'], + nodes: SnapshotState['nodes'], +): SnapshotState['nodes'] { + const viewport = nodes[0]?.rect; + if (!viewport) return matches; + const onscreen = matches.filter((node) => { + if (!node.rect) return false; + const center = centerOfRect(node.rect); + return ( + center.x >= viewport.x && + center.x <= viewport.x + viewport.width && + center.y >= viewport.y && + center.y <= viewport.y + viewport.height + ); + }); + return onscreen.length > 0 ? onscreen : matches; +} + +function resolveInteractiveMatchNode( + nodes: SnapshotState['nodes'], + node: SnapshotState['nodes'][number], +): SnapshotState['nodes'][number] { + const ancestor = findNearestHittableAncestor(nodes, node); + if (!ancestor) return node; + if (node.rect && isRootInteractionContainer(ancestor, nodes[0])) { + return node; + } + return ancestor; +} + +function isRootInteractionContainer( + node: SnapshotState['nodes'][number], + root: SnapshotState['nodes'][number] | undefined, +): boolean { + if (!root?.rect || !node.rect) return false; + const type = node.type?.toLowerCase() ?? ''; + if (!type.includes('application') && !type.includes('window')) return false; + return ( + node.rect.x === root.rect.x && + node.rect.y === root.rect.y && + node.rect.width === root.rect.width && + node.rect.height === root.rect.height + ); +} + async function handleFindWait( ctx: FindContext, fetchNodes: () => Promise<{ nodes: SnapshotState['nodes'] }>, @@ -266,7 +348,11 @@ async function handleFindClick(ctx: FindContext, match: ResolvedMatch): Promise< flags: match.actionFlags, }); if (!response.ok) return response; - const matchCoords = match.resolvedNode.rect ? centerOfRect(match.resolvedNode.rect) : null; + const matchCoords = match.resolvedNode.rect + ? centerOfRect(match.resolvedNode.rect) + : match.node.rect + ? centerOfRect(match.node.rect) + : null; const matchData: Record = { ref: match.ref, locator, query }; if (matchCoords) { matchData.x = matchCoords.x; @@ -312,7 +398,35 @@ async function handleFindFill( } async function handleFindFocus(ctx: FindContext, match: ResolvedMatch): Promise { - const { req, sessionStore, session, device, command, logPath } = ctx; + const response = await dispatchFocusForFindMatch(ctx, match); + if (!response.ok) return response; + recordFindAction(ctx, match, 'focus'); + return response; +} + +async function handleFindType( + ctx: FindContext, + match: ResolvedMatch, + value: string | undefined, +): Promise { + const { req, device, logPath, session } = ctx; + if (!value) { + return errorResponse('INVALID_ARGS', 'find type requires text'); + } + const focusResponse = await dispatchFocusForFindMatch(ctx, match); + if (!focusResponse.ok) return focusResponse; + const response = await dispatchCommand(device, 'type', [value], req.flags?.out, { + ...contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), + }); + recordFindAction(ctx, match, 'type'); + return { ok: true, data: response ?? { ref: match.ref } }; +} + +async function dispatchFocusForFindMatch( + ctx: FindContext, + match: ResolvedMatch, +): Promise { + const { req, device, logPath, session } = ctx; const coords = match.node.rect ? centerOfRect(match.node.rect) : null; if (!coords) { return errorResponse('COMMAND_FAILED', 'matched element has no bounds'); @@ -326,45 +440,19 @@ async function handleFindFocus(ctx: FindContext, match: ResolvedMatch): Promise< ...contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), }, ); - if (session) { - sessionStore.recordAction(session, { - command, - positionals: req.positionals ?? [], - flags: req.flags ?? {}, - result: { ref: match.ref, action: 'focus' }, - }); - } return { ok: true, data: response ?? { ref: match.ref } }; } -async function handleFindType( - ctx: FindContext, - match: ResolvedMatch, - value: string | undefined, -): Promise { - const { req, sessionStore, session, device, command, logPath } = ctx; - if (!value) { - return errorResponse('INVALID_ARGS', 'find type requires text'); - } - const coords = match.node.rect ? centerOfRect(match.node.rect) : null; - if (!coords) { - return errorResponse('COMMAND_FAILED', 'matched element has no bounds'); - } - await dispatchCommand(device, 'focus', [String(coords.x), String(coords.y)], req.flags?.out, { - ...contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), - }); - const response = await dispatchCommand(device, 'type', [value], req.flags?.out, { - ...contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), - }); +function recordFindAction(ctx: FindContext, match: ResolvedMatch, action: string): void { + const { req, sessionStore, session, command } = ctx; if (session) { sessionStore.recordAction(session, { command, positionals: req.positionals ?? [], flags: req.flags ?? {}, - result: { ref: match.ref, action: 'type' }, + result: { ref: match.ref, action }, }); } - return { ok: true, data: response ?? { ref: match.ref } }; } // --- Helpers --- diff --git a/src/daemon/handlers/interaction-touch.ts b/src/daemon/handlers/interaction-touch.ts index 9c46354ff..abeab8f6d 100644 --- a/src/daemon/handlers/interaction-touch.ts +++ b/src/daemon/handlers/interaction-touch.ts @@ -257,7 +257,12 @@ function readDirectIosSelectorTapTarget(params: { if (commandLabel !== 'click') return null; if (target.kind !== 'selector') return null; if (hasNonDefaultClickOptions(flags)) return null; - return readSimpleIosSelectorTarget({ session, selectorExpression: target.selector }); + const selector = readSimpleIosSelectorTarget({ session, selectorExpression: target.selector }); + if (!selector) return null; + return { + ...selector, + ...(flags?.allowNonHittableSelectorTap ? { allowNonHittableTap: true } : {}), + }; } function hasNonDefaultClickOptions(flags: CommandFlags | undefined): boolean { diff --git a/src/daemon/handlers/session-replay-maestro-runtime.ts b/src/daemon/handlers/session-replay-maestro-runtime.ts new file mode 100644 index 000000000..56cac5e6a --- /dev/null +++ b/src/daemon/handlers/session-replay-maestro-runtime.ts @@ -0,0 +1,414 @@ +import { type CommandFlags } from '../../core/dispatch.ts'; +import { MAESTRO_RUNTIME_COMMAND } from '../../compat/maestro/runtime-commands.ts'; +import type { SnapshotState } from '../../utils/snapshot.ts'; +import { sleep } from '../../utils/timeouts.ts'; +import { parseSelectorChain } from '../selectors.ts'; +import { getSnapshotReferenceFrame } from '../touch-reference-frame.ts'; +import type { DaemonRequest, DaemonResponse, SessionAction } from '../types.ts'; +import { errorResponse } from './response.ts'; + +const MAESTRO_SCROLL_UNTIL_VISIBLE_PROBE_MS = 500; +const MAESTRO_TAP_ON_TIMEOUT_MS = 30000; +const MAESTRO_TAP_ON_RETRY_MS = 250; + +type ReplayBaseRequest = Omit; + +type MaestroReplayInvoker = (params: { + action: SessionAction; + line: number; + step: number; +}) => Promise; + +type MaestroRuntimeInvoke = (req: DaemonRequest) => Promise; + +type MaestroScrollUntilVisibleParams = { + baseReq: ReplayBaseRequest; + positionals: string[]; + invoke: MaestroRuntimeInvoke; +}; + +type MaestroTapOnParams = { + baseReq: ReplayBaseRequest; + positionals: string[]; + invoke: MaestroRuntimeInvoke; +}; + +type MaestroRunFlowWhenCondition = + | { ok: true; mode: string; predicate: string; selector: string } + | { ok: false; response: DaemonResponse }; + +export async function invokeMaestroRuntimeCommand(params: { + command: string; + baseReq: ReplayBaseRequest; + positionals: string[]; + batchSteps: CommandFlags['batchSteps'] | undefined; + line: number; + step: number; + invoke: (req: DaemonRequest) => Promise; + invokeReplayAction: MaestroReplayInvoker; +}): Promise { + switch (params.command) { + case MAESTRO_RUNTIME_COMMAND.scrollUntilVisible: + return await invokeMaestroScrollUntilVisible(params); + case MAESTRO_RUNTIME_COMMAND.tapOn: + return await invokeMaestroTapOn(params); + case MAESTRO_RUNTIME_COMMAND.tapPointPercent: + return await invokeMaestroTapPointPercent(params); + case MAESTRO_RUNTIME_COMMAND.runFlowWhen: + return await invokeMaestroRunFlowWhen(params); + case MAESTRO_RUNTIME_COMMAND.pressEnter: + return await invokeMaestroPressEnter(params); + default: + return undefined; + } +} + +async function invokeMaestroScrollUntilVisible( + params: MaestroScrollUntilVisibleParams, +): Promise { + const [selector, timeoutValue = '5000', direction = 'down'] = params.positionals; + if (!selector) { + return errorResponse('INVALID_ARGS', 'scrollUntilVisible requires a selector.'); + } + const timeoutMs = Number(timeoutValue); + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + return errorResponse('INVALID_ARGS', 'scrollUntilVisible timeout must be a positive number.'); + } + const fuzzyTextQuery = extractMaestroVisibleTextQuery(selector); + const attempts = Math.max(1, Math.ceil(timeoutMs / MAESTRO_SCROLL_UNTIL_VISIBLE_PROBE_MS)); + let lastWaitResponse: DaemonResponse | undefined; + + for (let index = 0; index < attempts; index += 1) { + const probe = await probeMaestroScrollVisibility( + params, + selector, + fuzzyTextQuery, + scrollProbeMs(timeoutMs, index), + ); + if (probe.visible) return probe.response; + lastWaitResponse = probe.response; + + if (index === attempts - 1) break; + + const scrollResponse = await params.invoke({ + ...params.baseReq, + command: 'scroll', + positionals: [direction], + }); + if (!scrollResponse.ok) return scrollResponse; + } + + return withMaestroScrollTimeoutContext(lastWaitResponse, selector, timeoutMs); +} + +async function probeMaestroScrollVisibility( + params: MaestroScrollUntilVisibleParams, + selector: string, + fuzzyTextQuery: string | null, + probeMs: number, +): Promise<{ visible: boolean; response: DaemonResponse }> { + const waitResponse = await params.invoke({ + ...params.baseReq, + command: 'wait', + positionals: [selector, String(probeMs)], + }); + if (waitResponse.ok) return { visible: true, response: waitResponse }; + if (!fuzzyTextQuery) return { visible: false, response: waitResponse }; + + const fuzzyResponse = await params.invoke({ + ...params.baseReq, + command: 'find', + positionals: [fuzzyTextQuery, 'wait', String(probeMs)], + }); + return { visible: fuzzyResponse.ok, response: fuzzyResponse }; +} + +function scrollProbeMs(timeoutMs: number, index: number): number { + return Math.min( + MAESTRO_SCROLL_UNTIL_VISIBLE_PROBE_MS, + Math.max(1, timeoutMs - index * MAESTRO_SCROLL_UNTIL_VISIBLE_PROBE_MS), + ); +} + +async function invokeMaestroTapPointPercent(params: { + baseReq: ReplayBaseRequest; + positionals: string[]; + invoke: (req: DaemonRequest) => Promise; +}): Promise { + const [xValue, yValue] = params.positionals; + const xPercent = Number(xValue); + const yPercent = Number(yValue); + if (!Number.isFinite(xPercent) || !Number.isFinite(yPercent)) { + return errorResponse('INVALID_ARGS', 'tapOn percentage point requires numeric x/y values.'); + } + + const snapshotResponse = await params.invoke({ + ...params.baseReq, + command: 'snapshot', + positionals: [], + flags: { + ...params.baseReq.flags, + noRecord: true, + snapshotRaw: true, + snapshotForceFull: true, + }, + }); + if (!snapshotResponse.ok) return snapshotResponse; + + const snapshot = readSnapshotState(snapshotResponse.data); + if (!snapshot) { + return errorResponse( + 'COMMAND_FAILED', + 'Unable to read snapshot data for Maestro percentage point tap.', + ); + } + + const frame = getSnapshotReferenceFrame(snapshot); + if (!frame) { + return errorResponse( + 'COMMAND_FAILED', + 'Unable to resolve screen size for Maestro percentage point tap.', + ); + } + + return await params.invoke({ + ...params.baseReq, + command: 'click', + positionals: [ + String(Math.round((frame.referenceWidth * xPercent) / 100)), + String(Math.round((frame.referenceHeight * yPercent) / 100)), + ], + }); +} + +function readSnapshotState(data: unknown): SnapshotState | undefined { + if ( + typeof data === 'object' && + data !== null && + Array.isArray((data as { nodes?: unknown }).nodes) + ) { + return data as SnapshotState; + } + return undefined; +} + +async function invokeMaestroTapOn(params: MaestroTapOnParams): Promise { + const [selector] = params.positionals; + if (!selector) { + return errorResponse('INVALID_ARGS', 'tapOn requires a selector.'); + } + const startedAt = Date.now(); + const fuzzyTextQuery = extractMaestroVisibleTextQuery(selector); + let lastResponse: DaemonResponse | undefined; + while (Date.now() - startedAt < MAESTRO_TAP_ON_TIMEOUT_MS) { + if (fuzzyTextQuery) { + const attempt = await invokeMaestroFuzzyTapOn(params, fuzzyTextQuery); + if (!attempt.retry) return attempt.response; + lastResponse = attempt.response; + await sleep(MAESTRO_TAP_ON_RETRY_MS); + continue; + } + + const clickResponse = await params.invoke({ + ...params.baseReq, + command: 'click', + positionals: [selector], + }); + if (clickResponse.ok) return clickResponse; + lastResponse = clickResponse; + await sleep(MAESTRO_TAP_ON_RETRY_MS); + } + + if (params.baseReq.flags?.maestroOptional === true) { + return { ok: true, data: { skipped: true, optional: true, selector } }; + } + return ( + lastResponse ?? errorResponse('COMMAND_FAILED', `tapOn timed out for selector: ${selector}`) + ); +} + +async function invokeMaestroFuzzyTapOn( + params: MaestroTapOnParams, + query: string, +): Promise<{ retry: boolean; response: DaemonResponse }> { + const findResponse = await params.invoke({ + ...params.baseReq, + command: 'find', + positionals: [query, 'click'], + }); + if (findResponse.ok) return { retry: false, response: findResponse }; + if (params.baseReq.flags?.maestroOptional !== true) { + return { retry: true, response: findResponse }; + } + + const nativeLabelResponse = await params.invoke({ + ...params.baseReq, + command: 'click', + positionals: [simpleLabelSelector(query)], + }); + return { retry: !nativeLabelResponse.ok, response: nativeLabelResponse }; +} + +async function invokeMaestroRunFlowWhen(params: { + baseReq: ReplayBaseRequest; + positionals: string[]; + batchSteps: CommandFlags['batchSteps'] | undefined; + line: number; + step: number; + invoke: (req: DaemonRequest) => Promise; + invokeReplayAction: MaestroReplayInvoker; +}): Promise { + const condition = readMaestroRunFlowWhenCondition(params.positionals); + if (!condition.ok) return condition.response; + const conditionResponse = await params.invoke({ + ...params.baseReq, + command: 'is', + positionals: [condition.predicate, condition.selector], + flags: { ...params.baseReq.flags, noRecord: true }, + }); + if (isMaestroWhenConditionMiss(conditionResponse)) { + return { + ok: true, + data: { skipped: true, condition: condition.mode, selector: condition.selector }, + }; + } + if (!conditionResponse.ok) return conditionResponse; + return await invokeMaestroRunFlowWhenSteps(params, condition); +} + +function readMaestroRunFlowWhenCondition(positionals: string[]): MaestroRunFlowWhenCondition { + const [mode, selector] = positionals; + if ((mode !== 'visible' && mode !== 'notVisible') || !selector) { + return { + ok: false, + response: errorResponse( + 'INVALID_ARGS', + 'runFlow.when requires visible/notVisible and a selector.', + ), + }; + } + return { + ok: true, + mode, + predicate: mode === 'visible' ? 'visible' : 'hidden', + selector, + }; +} + +async function invokeMaestroRunFlowWhenSteps( + params: { + batchSteps: CommandFlags['batchSteps'] | undefined; + line: number; + step: number; + invokeReplayAction: MaestroReplayInvoker; + }, + condition: Extract, +): Promise { + const steps = (params.batchSteps ?? []).map(batchStepToSessionAction); + for (const [index, action] of steps.entries()) { + // Preserve stable parent-step ordering for nested runtime commands while + // keeping the substep distinguishable in traces. + const response = await params.invokeReplayAction({ + action, + line: params.line, + step: params.step + index / 1000, + }); + if (!response.ok) return response; + } + + return { + ok: true, + data: { ran: steps.length, condition: condition.mode, selector: condition.selector }, + }; +} + +function isMaestroWhenConditionMiss(response: DaemonResponse): boolean { + if (response.ok) return response.data?.pass === false; + if (response.error.code !== 'COMMAND_FAILED') return false; + return response.error.details?.blockedBy !== 'android_foreground_surface'; +} + +async function invokeMaestroPressEnter(params: { + baseReq: ReplayBaseRequest; + invoke: (req: DaemonRequest) => Promise; +}): Promise { + const response = await params.invoke({ + ...params.baseReq, + command: 'type', + positionals: ['\n'], + }); + if (response.ok) return response; + const message = response.error.message.toLowerCase(); + if (!message.includes('fetch failed')) return response; + + // Maestro compatibility: some iOS apps submit on Enter and immediately reset + // the runner transport. Treat this as recovered only after a fresh snapshot + // proves the runner connection is usable again; it does not assert UI state. + const snapshotResponse = await params.invoke({ + ...params.baseReq, + command: 'snapshot', + positionals: [], + flags: { ...params.baseReq.flags, noRecord: true }, + }); + if (!snapshotResponse.ok) return response; + return { + ok: true, + data: { + recovered: true, + warning: 'Enter key submit reset the iOS runner transport; recovered after snapshot.', + }, + }; +} + +function batchStepToSessionAction( + step: NonNullable[number], +): SessionAction { + const action: SessionAction = { + ts: Date.now(), + command: step.command, + positionals: step.positionals ?? [], + flags: step.flags ?? {}, + }; + if (step.runtime && typeof step.runtime === 'object') { + action.runtime = step.runtime as SessionAction['runtime']; + } + return action; +} + +function extractMaestroVisibleTextQuery(selectorExpression: string): string | null { + const chain = parseSelectorChain(selectorExpression); + const terms = chain.selectors.flatMap((selector) => selector.terms); + if (terms.length === 0) return null; + // Mixed selectors may encode more than a visible-text lookup, so they keep + // the exact selector path instead of fuzzy text fallback. + if (!terms.some((term) => term.key === 'label' || term.key === 'text')) return null; + if (!terms.every((term) => ['label', 'text', 'id'].includes(term.key))) return null; + const values = terms.map((term) => (typeof term.value === 'string' ? term.value : '')); + const first = values[0]; + if (!first || !values.every((value) => value === first)) return null; + return first; +} + +function simpleLabelSelector(value: string): string { + return `label=${JSON.stringify(value)}`; +} + +function withMaestroScrollTimeoutContext( + response: DaemonResponse | undefined, + selector: string, + timeoutMs: number, +): DaemonResponse { + if (!response || response.ok) { + return errorResponse( + 'COMMAND_FAILED', + `scrollUntilVisible timed out after ${timeoutMs}ms for selector: ${selector}`, + ); + } + return { + ok: false, + error: { + ...response.error, + message: `scrollUntilVisible timed out after ${timeoutMs}ms for selector: ${selector}. Last wait: ${response.error.message}`, + }, + }; +} diff --git a/src/daemon/handlers/session-replay-runtime.ts b/src/daemon/handlers/session-replay-runtime.ts index 12ff2cee0..b2774457d 100644 --- a/src/daemon/handlers/session-replay-runtime.ts +++ b/src/daemon/handlers/session-replay-runtime.ts @@ -10,6 +10,7 @@ import { healReplayAction } from './session-replay-heal.ts'; import { formatScriptActionSummary } from '../../replay/script-utils.ts'; import { mergeParentFlags } from './handler-utils.ts'; import { errorResponse } from './response.ts'; +import { invokeMaestroRuntimeCommand } from './session-replay-maestro-runtime.ts'; import { buildReplayVarScope, collectReplayShellEnv, @@ -180,15 +181,41 @@ async function invokeReplayAction(params: { command: resolved.command, positionals: resolved.positionals ?? [], }); - const response = await invoke({ + const flags = buildReplayActionFlags(req.flags, resolved.flags); + const baseReq = { token: req.token, session: sessionName, - command: resolved.command, - positionals: resolved.positionals ?? [], - flags: buildReplayActionFlags(req.flags, resolved.flags), + flags, runtime: resolved.runtime, meta: req.meta, - }); + }; + const response = + (await invokeMaestroRuntimeCommand({ + command: resolved.command, + baseReq, + positionals: resolved.positionals ?? [], + batchSteps: resolved.flags?.batchSteps, + line, + step, + invoke, + invokeReplayAction: async (nested) => + await invokeReplayAction({ + req, + sessionName, + action: nested.action, + scope, + filePath, + line: nested.line, + step: nested.step, + tracePath, + invoke, + }), + })) ?? + (await invoke({ + ...baseReq, + command: resolved.command, + positionals: resolved.positionals ?? [], + })); const finishedAt = Date.now(); appendReplayTraceEvent(tracePath, { type: 'replay_action_stop', diff --git a/src/daemon/types.ts b/src/daemon/types.ts index d5f3720bd..aa5df7f59 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -228,6 +228,7 @@ export type SessionAction = { snapshotDepth?: number; snapshotScope?: string; snapshotRaw?: boolean; + launchArgs?: string[]; saveScript?: boolean | string; noRecord?: boolean; }; diff --git a/src/platforms/ios/apps.ts b/src/platforms/ios/apps.ts index 70bd66553..6f091f1f1 100644 --- a/src/platforms/ios/apps.ts +++ b/src/platforms/ios/apps.ts @@ -127,7 +127,7 @@ export async function resolveIosApp(device: DeviceInfo, app: string): Promise { const launchConsole = options?.launchConsole?.trim(); if (launchConsole && (device.platform !== 'ios' || device.kind !== 'simulator')) { @@ -185,7 +185,10 @@ export async function openIosApp( const bundleId = options?.appBundleId ?? (await resolveIosApp(device, app)); if (device.kind === 'simulator') { - await launchIosSimulatorApp(device, bundleId, launchConsole ? { launchConsole } : undefined); + await launchIosSimulatorApp(device, bundleId, { + ...(launchConsole ? { launchConsole } : {}), + ...(options?.launchArgs ? { launchArgs: options.launchArgs } : {}), + }); return; } @@ -235,6 +238,53 @@ export async function closeIosApp(device: DeviceInfo, app: string): Promise { + if (device.platform !== 'ios' || device.kind !== 'simulator') { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'Maestro launchApp.clearState is currently supported only on iOS simulators.', + ); + } + + const bundleId = await resolveIosApp(device, app); + await ensureBootedSimulator(device); + await closeIosApp(device, bundleId); + + const result = await runSimctl(device, ['get_app_container', device.id, bundleId, 'data'], { + allowFailure: true, + }); + if (result.exitCode !== 0) { + throw new AppError('COMMAND_FAILED', `simctl get_app_container failed for ${bundleId}`, { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + }); + } + + const containerPath = result.stdout.trim(); + if (!containerPath) { + throw new AppError( + 'COMMAND_FAILED', + `simctl get_app_container returned an empty data container path for ${bundleId}`, + ); + } + + const entries = await fs.readdir(containerPath); + await Promise.all( + entries.map((entry) => + fs.rm(path.join(containerPath, entry), { + recursive: true, + force: true, + }), + ), + ); + + return { bundleId, containerPath }; +} + export async function uninstallIosApp( device: DeviceInfo, app: string, @@ -884,7 +934,7 @@ function isIosBiometricCapabilityMissing(stdout: string, stderr: string): boolea async function launchIosSimulatorApp( device: DeviceInfo, bundleId: string, - options?: { launchConsole?: string }, + options?: { launchConsole?: string; launchArgs?: string[] }, ): Promise { await ensureBootedSimulator(device); @@ -947,11 +997,12 @@ async function launchIosSimulatorApp( function buildIosSimulatorLaunchArgs( deviceId: string, bundleId: string, - options?: { launchConsole?: string }, + options?: { launchConsole?: string; launchArgs?: string[] }, ): string[] { const args = ['launch']; if (options?.launchConsole) args.push('--console-pty'); args.push(deviceId, bundleId); + if (options?.launchArgs) args.push(...options.launchArgs); return args; } diff --git a/src/platforms/ios/interactions.ts b/src/platforms/ios/interactions.ts index 9dfcc4e51..73e6ceac4 100644 --- a/src/platforms/ios/interactions.ts +++ b/src/platforms/ios/interactions.ts @@ -76,6 +76,7 @@ export function iosRunnerOverrides( command: 'tap', selectorKey: selector.key, selectorValue: selector.value, + allowNonHittableSelectorTap: selector.allowNonHittableTap, appBundleId: ctx.appBundleId, }, runnerOpts, @@ -124,7 +125,7 @@ export function iosRunnerOverrides( command: 'type', text, delayMs, - textEntryMode: 'append', + textEntryMode: text === '\n' ? undefined : 'append', appBundleId: ctx.appBundleId, }, runnerOpts, diff --git a/src/platforms/ios/runner-contract.ts b/src/platforms/ios/runner-contract.ts index 17d12f140..4232830d1 100644 --- a/src/platforms/ios/runner-contract.ts +++ b/src/platforms/ios/runner-contract.ts @@ -42,6 +42,7 @@ export type RunnerCommand = { text?: string; selectorKey?: 'id' | 'label' | 'text' | 'value'; selectorValue?: string; + allowNonHittableSelectorTap?: boolean; delayMs?: number; textEntryMode?: 'append' | 'replace'; action?: 'get' | 'accept' | 'dismiss'; diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index fa365cedf..65654be91 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -887,10 +887,9 @@ test('usageForCommand includes Maestro replay flag', () => { assert.match(help, /--maestro/); assert.match(help, /doubleTapOn/); assert.match(help, /pasteText/); - assert.match(help, /setPermissions/); - assert.match(help, /startRecording\/stopRecording/); assert.match(help, /runFlow file\/inline/); assert.match(help, /repeat\.times/); + assert.match(help, /stopApp/); assert.match(help, /Unsupported syntax fails loudly/); assert.match(help, /issues\/558/); }); diff --git a/src/utils/__tests__/video.test.ts b/src/utils/__tests__/video.test.ts index be1b4de7c..149d8365c 100644 --- a/src/utils/__tests__/video.test.ts +++ b/src/utils/__tests__/video.test.ts @@ -3,6 +3,8 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { AppError } from '../errors.ts'; +import { withCommandExecutorOverride } from '../exec.ts'; import { isPlayableVideo } from '../video.ts'; function makeAtom(type: string, payload = Buffer.alloc(0)): Buffer { @@ -17,13 +19,9 @@ test('isPlayableVideo falls back to MP4 container validation when swift is unava const videoPath = path.join(tmpDir, 'sample.mp4'); await fs.writeFile(videoPath, Buffer.concat([makeAtom('ftyp'), makeAtom('moov')])); - const previousPath = process.env.PATH; - process.env.PATH = ''; - try { - assert.equal(await isPlayableVideo(videoPath), true); + assert.equal(await withUnavailableSwift(() => isPlayableVideo(videoPath)), true); } finally { - process.env.PATH = previousPath; await fs.rm(tmpDir, { recursive: true, force: true }); } }); @@ -33,13 +31,15 @@ test('isPlayableVideo fallback rejects files without playable MP4 atoms', async const videoPath = path.join(tmpDir, 'sample.mp4'); await fs.writeFile(videoPath, Buffer.concat([makeAtom('ftyp'), makeAtom('mdat')])); - const previousPath = process.env.PATH; - process.env.PATH = ''; - try { - assert.equal(await isPlayableVideo(videoPath), false); + assert.equal(await withUnavailableSwift(() => isPlayableVideo(videoPath)), false); } finally { - process.env.PATH = previousPath; await fs.rm(tmpDir, { recursive: true, force: true }); } }); + +async function withUnavailableSwift(fn: () => Promise): Promise { + return await withCommandExecutorOverride(() => { + throw new AppError('TOOL_MISSING', 'swift unavailable for test'); + }, fn); +} diff --git a/src/utils/command-schema.ts b/src/utils/command-schema.ts index 0eebb42e8..d7f0f9f0b 100644 --- a/src/utils/command-schema.ts +++ b/src/utils/command-schema.ts @@ -1255,7 +1255,7 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ type: 'boolean', usageLabel: '--maestro', usageDescription: - 'Replay: treat input as a Maestro YAML compatibility flow. Supported subset: launchApp without state-reset side effects, runFlow file/inline with when.platform, onFlowStart/onFlowComplete, deterministic repeat.times, tapOn, doubleTapOn, longPressOn, inputText, pasteText, openLink, assertVisible, assertNotVisible, assertTrue literal true/false, extendedWaitUntil, scroll, absolute/percentage swipe, takeScreenshot, hideKeyboard, pressKey back/enter/home, back, waitForAnimationToEnd, stopApp/killApp, setAirplaneMode, setLocation, setOrientation, supported setPermissions targets, and startRecording/stopRecording. ' + + 'Replay: treat input as a Maestro YAML compatibility flow. Supported subset: launchApp with Apple-platform launch arguments and iOS simulator clearState, runFlow file/inline with when.platform/visible/notVisible, runScript file/env with parse-time http.post/json/output variables, onFlowStart/onFlowComplete, deterministic repeat.times, tapOn including optional and absolute/percentage point taps, doubleTapOn, longPressOn, inputText, pasteText, openLink, assertVisible, assertNotVisible, extendedWaitUntil, scroll, scrollUntilVisible, absolute/percentage swipe, takeScreenshot, hideKeyboard, pressKey back/enter/home, back, waitForAnimationToEnd, and stopApp. ' + 'Unsupported syntax fails loudly with a link to https://github.com/callstackincubator/agent-device/issues/558', }, { diff --git a/website/docs/docs/replay-e2e.md b/website/docs/docs/replay-e2e.md index 675266000..7846bf503 100644 --- a/website/docs/docs/replay-e2e.md +++ b/website/docs/docs/replay-e2e.md @@ -61,11 +61,11 @@ Maestro compatibility translates supported YAML commands into Agent Device repla - Supported and unsupported capabilities: https://github.com/callstackincubator/agent-device/issues/558 - New focused compatibility request: https://github.com/callstackincubator/agent-device/issues/new -Currently supported areas include app launch without state-reset side effects, file and inline `runFlow` with `when.platform`, `onFlowStart` / `onFlowComplete`, deterministic `repeat.times`, `tapOn`, `doubleTapOn`, `longPressOn`, `inputText`, `pasteText`, `openLink`, visibility assertions, literal `assertTrue`, `extendedWaitUntil`, `scroll`, absolute/percentage `swipe`, screenshots, keyboard dismiss, basic `pressKey`, `back`, animation waits, `stopApp` / `killApp`, airplane mode, mock location, orientation, supported permission targets, and screen recording. +Currently supported areas include app launch with Apple-platform launch arguments and iOS simulator `clearState`, file and inline `runFlow` with `when.platform`, `when.visible`, and `when.notVisible`, `onFlowStart` / `onFlowComplete`, deterministic `repeat.times`, `tapOn` including `optional` and absolute/percentage point taps, `doubleTapOn`, `longPressOn`, `inputText`, `pasteText`, `openLink`, visibility assertions, `extendedWaitUntil`, `scroll`, `scrollUntilVisible`, absolute/percentage `swipe`, screenshots, keyboard dismiss, basic `pressKey`, `back`, animation waits, and `stopApp`. `runScript` is supported only as a Maestro compatibility feature for file/env scripts that use `http.post`, `json`, and `output` variables; it executes during flow parsing, can make network requests, and is not a native `.ad` command or security sandbox. Maestro `env` values use the same replay precedence as `.ad` files: flow `env` is the default, shell `AD_VAR_*` values override it, and CLI `-e KEY=VALUE` wins over both. -Runtime-dependent Maestro features such as `scrollUntilVisible`, `repeat.while`, `runFlow.when.visible`, `runScript`, `evalScript`, text clearing, and app state reset are tracked separately because they require neutral Agent Device runtime or device capabilities before they can be mapped safely. +Unsupported Maestro features such as `repeat.while`, `runFlow.when.true`, full expression predicates, `evalScript`, text clearing, selector relations such as `index` / `childOf`, device utility commands, Android app launch arguments, and Android app state reset are tracked separately because they require neutral Agent Device runtime or device capabilities before they can be mapped safely. ## Run a lightweight `.ad` suite