diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift index c58d46e97..9d7dd4423 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift @@ -335,168 +335,6 @@ extension RunnerTests { && point.y <= frame.maxY + tolerance } - func isKeyboardVisible(app: XCUIApplication) -> Bool { - return visibleKeyboardFrame(app: app) != nil - } - - func dismissKeyboard(app: XCUIApplication) -> (wasVisible: Bool, dismissed: Bool, visible: Bool) { - let wasVisible = isKeyboardVisible(app: app) - guard wasVisible else { - return (wasVisible: false, dismissed: false, visible: false) - } - -#if os(tvOS) - _ = pressTvRemote(.menu) - sleepFor(0.2) - let visible = isKeyboardVisible(app: app) - return (wasVisible: true, dismissed: !visible, visible: visible) -#else - if tapKeyboardDismissControl(app: app) { - sleepFor(0.2) - let visible = isKeyboardVisible(app: app) - return (wasVisible: true, dismissed: !visible, visible: visible) - } - - return (wasVisible: true, dismissed: false, visible: isKeyboardVisible(app: app)) -#endif - } - - func pressKeyboardReturn(app: XCUIApplication) -> (wasVisible: Bool, pressed: Bool, visible: Bool) { -#if os(tvOS) - return (wasVisible: false, pressed: pressTvRemote(.select), visible: false) -#elseif os(iOS) - let wasVisible = isKeyboardVisible(app: app) - if tapKeyboardReturnControl(app: app) { - sleepFor(0.2) - return (wasVisible: wasVisible, pressed: true, visible: isKeyboardVisible(app: app)) - } - - var typed = false - let exceptionMessage = RunnerObjCExceptionCatcher.catchException({ - app.typeText(XCUIKeyboardKey.return.rawValue) - typed = true - }) - if let exceptionMessage { - NSLog( - "AGENT_DEVICE_RUNNER_KEYBOARD_RETURN_IGNORED_EXCEPTION=%@", - exceptionMessage - ) - if let singleTarget = singleTextEntryElement(app: app) { - return pressKeyboardReturn(on: singleTarget, app: app, wasVisible: wasVisible) - } - return (wasVisible: wasVisible, pressed: false, visible: isKeyboardVisible(app: app)) - } - sleepFor(0.2) - return (wasVisible: wasVisible, pressed: typed, visible: isKeyboardVisible(app: app)) -#else - return (wasVisible: false, pressed: false, visible: false) -#endif - } - - private func pressKeyboardReturn( - on element: XCUIElement, - app: XCUIApplication, - wasVisible: Bool - ) -> (wasVisible: Bool, pressed: Bool, visible: Bool) { -#if os(iOS) - let exceptionMessage = RunnerObjCExceptionCatcher.catchException({ - element.tap() - element.typeText(XCUIKeyboardKey.return.rawValue) - }) - if let exceptionMessage { - NSLog( - "AGENT_DEVICE_RUNNER_KEYBOARD_RETURN_TARGET_IGNORED_EXCEPTION=%@", - exceptionMessage - ) - return (wasVisible: wasVisible, pressed: false, visible: isKeyboardVisible(app: app)) - } - sleepFor(0.2) - return (wasVisible: wasVisible, pressed: true, visible: isKeyboardVisible(app: app)) -#else - return (wasVisible: wasVisible, pressed: false, visible: false) -#endif - } - - private func singleTextEntryElement(app: XCUIApplication) -> XCUIElement? { -#if os(iOS) - let matches = safely("KEYBOARD_RETURN_TEXT_ENTRY_QUERY", []) { - app.descendants(matching: .any).allElementsBoundByIndex.filter { element in - guard element.exists else { return false } - switch element.elementType { - case .textField, .secureTextField, .searchField, .textView: - return true - default: - return false - } - } - } - return matches.count == 1 ? matches[0] : nil -#else - return nil -#endif - } - - private func tapKeyboardDismissControl(app: XCUIApplication) -> Bool { -#if os(tvOS) - return false -#else - guard let keyboardFrame = visibleKeyboardFrame(app: app) else { - return false - } - for label in ["Hide keyboard", "Dismiss keyboard", "Done"] { - let candidates = [ - app.keyboards.buttons[label], - app.keyboards.keys[label], - app.keyboards.toolbars.buttons[label], - ] - if let hittable = candidates.first(where: { $0.exists && $0.isHittable }) { - hittable.tap() - return true - } - - let toolbarButtonPredicate = NSPredicate( - format: "label == %@ OR identifier == %@", - label, - label - ) - let toolbarButtons = app.descendants(matching: .button) - .matching(toolbarButtonPredicate) - .allElementsBoundByIndex - if let hittable = toolbarButtons.first(where: { - $0.exists && $0.isHittable && isKeyboardAccessoryControl($0, keyboardFrame: keyboardFrame) - }) { - hittable.tap() - return true - } - } - return false -#endif - } - - private func tapKeyboardReturnControl(app: XCUIApplication) -> Bool { -#if os(iOS) - for label in ["return", "Return", "Enter", "Go", "Search", "Next", "Done", "Send", "Join"] { - let candidates = [ - app.keyboards.buttons[label], - app.keyboards.keys[label], - ] - if let hittable = candidates.first(where: { $0.exists && $0.isHittable }) { - hittable.tap() - return true - } - } -#endif - return false - } - - private func isKeyboardAccessoryControl(_ element: XCUIElement, keyboardFrame: CGRect) -> Bool { - let frame = element.frame - guard !frame.isEmpty && !keyboardFrame.isEmpty else { - return false - } - return frame.intersects(keyboardFrame) || abs(frame.maxY - keyboardFrame.minY) <= 80 - } - private func readableText(for element: XCUIElement) -> String? { let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines) let identifier = element.identifier.trimmingCharacters(in: .whitespacesAndNewlines) @@ -1005,20 +843,6 @@ extension RunnerTests { #endif } - private func visibleKeyboardFrame(app: XCUIApplication) -> CGRect? { -#if os(iOS) - return safely("KEYBOARD_FRAME") { - let keyboard = app.keyboards.firstMatch - guard keyboard.exists else { return nil } - let keyboardFrame = keyboard.frame - guard !keyboardFrame.isEmpty else { return nil } - return keyboardFrame - } -#else - return nil -#endif - } - func axFreeSynthesizedDragPlan( app: XCUIApplication, x: Double, diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Keyboard.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Keyboard.swift new file mode 100644 index 000000000..991e56132 --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Keyboard.swift @@ -0,0 +1,184 @@ +import XCTest + +private enum KeyboardDismissObservationTiming { + static let timeout: TimeInterval = 2 +} + +extension RunnerTests { + func isKeyboardVisible(app: XCUIApplication) -> Bool { + return visibleKeyboardFrame(app: app) != nil + } + + func dismissKeyboard(app: XCUIApplication) -> (wasVisible: Bool, dismissed: Bool, visible: Bool) { + let keyboard = app.keyboards.firstMatch + let wasVisible = isKeyboardVisible(app: app) + guard wasVisible else { + return (wasVisible: false, dismissed: false, visible: false) + } + +#if os(tvOS) + _ = pressTvRemote(.menu) + sleepFor(0.2) + let visible = isKeyboardVisible(app: app) + return (wasVisible: true, dismissed: !visible, visible: visible) +#else + if tapKeyboardDismissControl(app: app) { + _ = keyboard.waitForNonExistence(timeout: KeyboardDismissObservationTiming.timeout) + let visible = isKeyboardVisible(app: app) + return (wasVisible: true, dismissed: !visible, visible: visible) + } + + return (wasVisible: true, dismissed: false, visible: isKeyboardVisible(app: app)) +#endif + } + + func pressKeyboardReturn(app: XCUIApplication) -> (wasVisible: Bool, pressed: Bool, visible: Bool) { +#if os(tvOS) + return (wasVisible: false, pressed: pressTvRemote(.select), visible: false) +#elseif os(iOS) + let wasVisible = isKeyboardVisible(app: app) + if tapKeyboardReturnControl(app: app) { + sleepFor(0.2) + return (wasVisible: wasVisible, pressed: true, visible: isKeyboardVisible(app: app)) + } + + var typed = false + let exceptionMessage = RunnerObjCExceptionCatcher.catchException({ + app.typeText(XCUIKeyboardKey.return.rawValue) + typed = true + }) + if let exceptionMessage { + NSLog( + "AGENT_DEVICE_RUNNER_KEYBOARD_RETURN_IGNORED_EXCEPTION=%@", + exceptionMessage + ) + if let singleTarget = singleTextEntryElement(app: app) { + return pressKeyboardReturn(on: singleTarget, app: app, wasVisible: wasVisible) + } + return (wasVisible: wasVisible, pressed: false, visible: isKeyboardVisible(app: app)) + } + sleepFor(0.2) + return (wasVisible: wasVisible, pressed: typed, visible: isKeyboardVisible(app: app)) +#else + return (wasVisible: false, pressed: false, visible: false) +#endif + } + + func visibleKeyboardFrame(app: XCUIApplication) -> CGRect? { +#if os(iOS) + return safely("KEYBOARD_FRAME") { + let keyboard = app.keyboards.firstMatch + guard keyboard.exists else { return nil } + let keyboardFrame = keyboard.frame + guard !keyboardFrame.isEmpty else { return nil } + return keyboardFrame + } +#else + return nil +#endif + } + + private func pressKeyboardReturn( + on element: XCUIElement, + app: XCUIApplication, + wasVisible: Bool + ) -> (wasVisible: Bool, pressed: Bool, visible: Bool) { +#if os(iOS) + let exceptionMessage = RunnerObjCExceptionCatcher.catchException({ + element.tap() + element.typeText(XCUIKeyboardKey.return.rawValue) + }) + if let exceptionMessage { + NSLog( + "AGENT_DEVICE_RUNNER_KEYBOARD_RETURN_TARGET_IGNORED_EXCEPTION=%@", + exceptionMessage + ) + return (wasVisible: wasVisible, pressed: false, visible: isKeyboardVisible(app: app)) + } + sleepFor(0.2) + return (wasVisible: wasVisible, pressed: true, visible: isKeyboardVisible(app: app)) +#else + return (wasVisible: wasVisible, pressed: false, visible: false) +#endif + } + + private func singleTextEntryElement(app: XCUIApplication) -> XCUIElement? { +#if os(iOS) + let matches = safely("KEYBOARD_RETURN_TEXT_ENTRY_QUERY", []) { + app.descendants(matching: .any).allElementsBoundByIndex.filter { element in + guard element.exists else { return false } + switch element.elementType { + case .textField, .secureTextField, .searchField, .textView: + return true + default: + return false + } + } + } + return matches.count == 1 ? matches[0] : nil +#else + return nil +#endif + } + + private func tapKeyboardDismissControl(app: XCUIApplication) -> Bool { +#if os(tvOS) + return false +#else + guard let keyboardFrame = visibleKeyboardFrame(app: app) else { + return false + } + for label in ["Hide keyboard", "Dismiss keyboard", "Done"] { + let candidates = [ + app.keyboards.buttons[label], + app.keyboards.keys[label], + app.keyboards.toolbars.buttons[label], + ] + if let hittable = candidates.first(where: { $0.exists && $0.isHittable }) { + hittable.tap() + return true + } + + let toolbarButtonPredicate = NSPredicate( + format: "label == %@ OR identifier == %@", + label, + label + ) + let toolbarButtons = app.descendants(matching: .button) + .matching(toolbarButtonPredicate) + .allElementsBoundByIndex + if let hittable = toolbarButtons.first(where: { + $0.exists && $0.isHittable && isKeyboardAccessoryControl($0, keyboardFrame: keyboardFrame) + }) { + hittable.tap() + return true + } + } + return false +#endif + } + + private func tapKeyboardReturnControl(app: XCUIApplication) -> Bool { +#if os(iOS) + for label in ["return", "Return", "Enter", "Go", "Search", "Next", "Done", "Send", "Join"] { + let candidates = [ + app.keyboards.buttons[label], + app.keyboards.keys[label], + ] + if let hittable = candidates.first(where: { $0.exists && $0.isHittable }) { + hittable.tap() + return true + } + } +#endif + return false + } + + private func isKeyboardAccessoryControl(_ element: XCUIElement, keyboardFrame: CGRect) -> Bool { + let frame = element.frame + guard !frame.isEmpty && !keyboardFrame.isEmpty else { + return false + } + return frame.intersects(keyboardFrame) || abs(frame.maxY - keyboardFrame.minY) <= 80 + } +} diff --git a/src/core/dispatch-context.ts b/src/core/dispatch-context.ts index 5953fed9a..714a9c6b9 100644 --- a/src/core/dispatch-context.ts +++ b/src/core/dispatch-context.ts @@ -17,8 +17,8 @@ export type DispatchContext = ScreenshotDispatchFlags & { activity?: string; launchConsole?: string; launchArgs?: string[]; - // iOS simulator only: relaunch via a single `simctl launch - // --terminate-running-process` instead of a separate terminate + launch. + // iOS simulator only: terminate the current app inside the platform open, + // either during `simctl launch` or immediately before `simctl openurl`. terminateRunningApp?: boolean; clearAppState?: boolean; verbose?: boolean; diff --git a/src/daemon/handlers/__tests__/session-open-launch-url.test.ts b/src/daemon/handlers/__tests__/session-open-launch-url.test.ts index c045c1f90..b4fc8bbd2 100644 --- a/src/daemon/handlers/__tests__/session-open-launch-url.test.ts +++ b/src/daemon/handlers/__tests__/session-open-launch-url.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'vitest'; import * as os from 'node:os'; import * as path from 'node:path'; +import { IOS_DEVICE, IOS_SIMULATOR } from '../../../__tests__/test-utils/index.ts'; import { setActiveProviderDeviceRuntimes } from '../../../provider-device-runtime.ts'; import { makeSession, @@ -12,15 +13,6 @@ import { import { buildSessionOpenLaunchPlan } from '../session-open-launch-url.ts'; import { handleSessionCommands } from '../session.ts'; -const iosSimulator = { - platform: 'apple' as const, - appleOs: 'ios' as const, - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator' as const, - booted: true, -}; - describe('buildSessionOpenLaunchPlan', () => { test('folds an iOS launch URL into the app open', () => { expect( @@ -49,6 +41,20 @@ describe('buildSessionOpenLaunchPlan', () => { }); }); + test('keeps clear-state on the direct app launch before the URL open', () => { + expect( + buildSessionOpenLaunchPlan({ + openPositionals: ['com.example.app'], + runtime: { platform: 'ios', launchUrl: 'myapp://automation' }, + flags: { clearAppState: true }, + foldRuntimeLaunchUrl: true, + }), + ).toEqual({ + openPositionals: ['com.example.app'], + followUpLaunchUrl: 'myapp://automation', + }); + }); + test('keeps the existing two-dispatch contract when URL folding is unavailable', () => { expect( buildSessionOpenLaunchPlan({ @@ -67,7 +73,7 @@ describe('buildSessionOpenLaunchPlan', () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-simulator-runtime-url-relaunch-session'; sessionStore.set(sessionName, { - ...makeSession(sessionName, iosSimulator), + ...makeSession(sessionName, IOS_SIMULATOR), appName: 'com.example.app', }); sessionStore.setRuntimeHints(sessionName, { @@ -76,7 +82,7 @@ describe('buildSessionOpenLaunchPlan', () => { }); const calls: Array<{ command: string; terminateRunningApp?: boolean }> = []; - mockResolveTargetDevice.mockResolvedValue(iosSimulator); + mockResolveTargetDevice.mockResolvedValue(IOS_SIMULATOR); mockDispatch.mockImplementation(async (_device, command, positionals, _out, context) => { calls.push({ command: `${command}:${positionals.join(' ')}`, @@ -108,22 +114,69 @@ describe('buildSessionOpenLaunchPlan', () => { ]); }); + test('clears state on the app launch before opening the runtime URL', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-simulator-runtime-url-clear-state-session'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, IOS_SIMULATOR), + appName: 'com.example.app', + }); + sessionStore.setRuntimeHints(sessionName, { + platform: 'ios', + launchUrl: 'myapp://automation', + }); + + const calls: Array<{ command: string; clearAppState?: boolean }> = []; + mockResolveTargetDevice.mockResolvedValue(IOS_SIMULATOR); + mockDispatch.mockImplementation(async (_device, command, positionals, _out, context) => { + calls.push({ + command: `${command}:${positionals.join(' ')}`, + clearAppState: context?.clearAppState, + }); + return {}; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: [], + flags: { relaunch: true, clearAppState: true }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + expect(calls).toEqual([ + { + command: 'close:com.example.app', + clearAppState: true, + }, + { + command: 'open:com.example.app', + clearAppState: true, + }, + { + command: 'open:myapp://automation', + clearAppState: undefined, + }, + ]); + }); + test('keeps a physical iOS runtime URL as a follow-up open', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-device-runtime-url-session'; - const iosDevice = { - ...iosSimulator, - id: 'device-1', - name: 'My iPhone', - kind: 'device' as const, - }; sessionStore.setRuntimeHints(sessionName, { platform: 'ios', launchUrl: 'https://example.com/automation', }); const calls: string[] = []; - mockResolveTargetDevice.mockResolvedValue(iosDevice); + mockResolveTargetDevice.mockResolvedValue(IOS_DEVICE); mockDispatch.mockImplementation(async (_device, command, positionals) => { calls.push(`${command}:${positionals.join(' ')}`); return {}; @@ -151,7 +204,7 @@ describe('buildSessionOpenLaunchPlan', () => { const sessionStore = makeSessionStore(); const sessionName = 'provider-ios-runtime-url-session'; const providerSimulator = { - ...iosSimulator, + ...IOS_SIMULATOR, id: 'provider:ios:lease-a', name: 'Provider iPhone', }; diff --git a/src/daemon/handlers/session-open-launch-url.ts b/src/daemon/handlers/session-open-launch-url.ts index 7f73ecf2e..1c540a358 100644 --- a/src/daemon/handlers/session-open-launch-url.ts +++ b/src/daemon/handlers/session-open-launch-url.ts @@ -25,9 +25,11 @@ export function buildSessionOpenLaunchPlan(params: { return { openPositionals }; } - const hasDirectLaunchOptions = - Boolean(flags?.launchConsole?.trim()) || Boolean(flags?.launchArgs?.length); - if (foldRuntimeLaunchUrl && !hasDirectLaunchOptions) { + const requiresDirectAppLaunch = + flags?.clearAppState === true || + Boolean(flags?.launchConsole?.trim()) || + Boolean(flags?.launchArgs?.length); + if (foldRuntimeLaunchUrl && !requiresDirectAppLaunch) { return { openPositionals: [openTarget, launchUrl] }; } return { @@ -46,11 +48,12 @@ export async function dispatchSessionOpenFollowUpLaunchUrl(params: { }): Promise { const { launchUrl, device, req, logPath, appBundleId, traceLogPath } = params; const followUpFlags = req.flags - ? (Object.fromEntries( - Object.entries(req.flags).filter( - ([name]) => name !== 'launchConsole' && name !== 'launchArgs', - ), - ) as NonNullable) + ? { + ...req.flags, + clearAppState: undefined, + launchConsole: undefined, + launchArgs: undefined, + } : undefined; const context = contextFromFlags(logPath, followUpFlags, appBundleId, traceLogPath); await dispatchCommand(device, 'open', [launchUrl], req.flags?.out, context); diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts index 376fb29f8..bded3efab 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -227,6 +227,7 @@ async function completeOpenCommand(params: { let sessionAppBundleId = appBundleId; const openCommandStartedAtMs = Date.now(); const timing: OpenTiming = {}; + const usesLocalIosSimulatorLifecycle = isIosSimulator(device) && !isActiveProviderDevice(device); const shouldPrewarmIosRunner = isIosFamily(device) && @@ -266,11 +267,10 @@ async function completeOpenCommand(params: { // touches the runner there (both ride simctl), so the xcodebuild ramp // overlaps the app relaunch instead of following it. Real devices tear the // runner down in relaunchCloseApp, so their prewarm stays post-open. - if (shouldPrewarmIosRunner && isIosSimulator(device) && !shouldPrewarmRunnerBeforeOpen) { + if (shouldPrewarmIosRunner && usesLocalIosSimulatorLifecycle && !shouldPrewarmRunnerBeforeOpen) { schedulePrewarm(); } - const usesLocalIosSimulatorLifecycle = isIosSimulator(device) && !isActiveProviderDevice(device); const launchPlan = buildSessionOpenLaunchPlan({ openPositionals, runtime, @@ -371,11 +371,7 @@ async function completeOpenCommand(params: { } else if (runnerPrewarm && !runnerPrewarmAwaited) { timing.runnerPrewarmWaited = false; } - if ( - !isActiveProviderDevice(device) && - isIosSimulator(device) && - (shouldRelaunch || runnerTargetPredatesOpen) - ) { + if (usesLocalIosSimulatorLifecycle && (shouldRelaunch || runnerTargetPredatesOpen)) { await notifyIosRunnerAppRelaunched(device, runnerPrewarmOptions); } sessionAppBundleId = await inferAndroidPackageAfterOpen(device, openTarget, sessionAppBundleId); diff --git a/src/platforms/apple/core/__tests__/app-launch-url.test.ts b/src/platforms/apple/core/__tests__/app-launch-url.test.ts index 4f589303d..a94e3c586 100644 --- a/src/platforms/apple/core/__tests__/app-launch-url.test.ts +++ b/src/platforms/apple/core/__tests__/app-launch-url.test.ts @@ -44,22 +44,3 @@ test('iOS simulator URL relaunch terminates the app before opening the URL', asy ['xcrun', ['simctl', 'openurl', 'sim-1', 'myapp://automation'], undefined], ]); }); - -test('iOS simulator deep-link relaunch terminates the active app before opening the URL', async () => { - await openIosApp(IOS_TEST_SIMULATOR, 'myapp://automation', { - appBundleId: 'com.example.app', - terminateRunningApp: true, - }); - - assert.deepEqual(mockRunCmd.mock.calls, [ - [ - 'xcrun', - ['simctl', 'terminate', 'sim-1', 'com.example.app'], - { - allowFailure: true, - timeoutMs: IOS_SIMULATOR_TERMINATE_TIMEOUT_MS, - }, - ], - ['xcrun', ['simctl', 'openurl', 'sim-1', 'myapp://automation'], undefined], - ]); -}); diff --git a/src/platforms/apple/core/app-launch.ts b/src/platforms/apple/core/app-launch.ts index ebbe480aa..5b9c60f4f 100644 --- a/src/platforms/apple/core/app-launch.ts +++ b/src/platforms/apple/core/app-launch.ts @@ -108,9 +108,6 @@ export async function openIosApp( throw new AppError('INVALID_ARGS', LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE); } if (device.kind === 'simulator') { - if (options?.terminateRunningApp && options.appBundleId) { - await terminateIosSimulatorApp(device, options.appBundleId); - } await openIosSimulatorUrl(device, deepLinkTarget, launchArgs); return; } diff --git a/test/integration/ios-simulator-e2e/behavior-coverage.ts b/test/integration/ios-simulator-e2e/behavior-coverage.ts index 687237389..64ef54f72 100644 --- a/test/integration/ios-simulator-e2e/behavior-coverage.ts +++ b/test/integration/ios-simulator-e2e/behavior-coverage.ts @@ -27,7 +27,8 @@ type BehaviorCoverageEntry = */ export const IOS_SIMULATOR_BEHAVIOR_COVERAGE = { 'cold-start-deep-link-navigation': { - assertion: 'a terminated fixture opens a deep route, renders payload, and navigates back', + assertion: + 'launchApp(clearState) removes seeded app data, opens the stored deep route, renders its payload, and navigates onward', level: 'live', owner: 'smoke:automation-input', }, diff --git a/test/integration/ios-simulator-e2e/live-automation-scenario.ts b/test/integration/ios-simulator-e2e/live-automation-scenario.ts index 6fb56fcd2..834430c19 100644 --- a/test/integration/ios-simulator-e2e/live-automation-scenario.ts +++ b/test/integration/ios-simulator-e2e/live-automation-scenario.ts @@ -1,7 +1,10 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; import { PUBLIC_COMMANDS } from '../../../src/command-catalog.ts'; import { assertElementText, assertJsonContains, assertWaitText } from './live-assertions.ts'; +import { clearStateLaunchUrlMaestroFlow } from './live-fixtures.ts'; import { type LiveContext, runStep, verifyBehavior, verifyCommand } from './live-harness.ts'; const C = PUBLIC_COMMANDS; @@ -46,6 +49,7 @@ export async function assertAutomationInput(context: LiveContext): Promise await assertWaitText(context, 'Automation lab'); await assertElementText(context, 'id="automation-event-name"', 'cold.start'); await assertElementText(context, 'id="automation-event-payload"', '{"source":"deep-link"}'); + await assertClearStateLaunchUrl(context); await runStep(context, 'navigate onward from cold deep link', [ 'click', 'id="automation-continue-catalog"', @@ -58,7 +62,7 @@ export async function assertAutomationInput(context: LiveContext): Promise verifyBehavior( context, 'cold-start-deep-link-navigation', - 'cold deep route rendered decoded payload and continued into the fixture catalog', + 'launchApp(clearState) removed a seeded data canary, rendered the stored deep route and payload, and continued into the fixture catalog', ); await runStep(context, 'wait for settings tab target', ['wait', 'label="Settings"', '10000']); @@ -144,6 +148,40 @@ export async function assertAutomationInput(context: LiveContext): Promise verifyCommand(context, C.back, 'back returns from automation route to Settings'); } +async function assertClearStateLaunchUrl(context: LiveContext): Promise { + const prepared = await runStep(context, 'prepare fixture data clear canary', [ + 'settings', + 'clear-app-state', + context.appId, + ]); + const containerPath = prepared.json?.data?.containerPath; + assert.ok( + typeof containerPath === 'string' && path.isAbsolute(containerPath), + `clear-state response should expose an absolute data container: ${JSON.stringify(prepared.json)}`, + ); + + const canaryPath = path.join(containerPath, 'Documents', 'agent-device-clear-state-canary.txt'); + fs.mkdirSync(path.dirname(canaryPath), { recursive: true }); + fs.writeFileSync(canaryPath, 'must be removed by launchApp(clearState: true)\n'); + assert.equal(fs.existsSync(canaryPath), true, `failed to seed clear-state canary: ${canaryPath}`); + + const flowPath = path.join(context.artifactDir, 'clear-state-launch-url.yaml'); + fs.writeFileSync(flowPath, clearStateLaunchUrlMaestroFlow(context.appId)); + const replay = await runStep(context, 'launch clear-state fixture through stored URL', [ + 'replay', + flowPath, + '--maestro', + ]); + assert.equal(replay.json?.data?.replayed, 3, JSON.stringify(replay.json)); + assert.equal( + fs.existsSync(canaryPath), + false, + `launchApp(clearState: true) retained data canary: ${canaryPath}`, + ); + await assertElementText(context, 'id="automation-event-name"', 'cold.start'); + await assertElementText(context, 'id="automation-event-payload"', '{"source":"deep-link"}'); +} + async function acceptDeepLinkConfirmationIfPresent(context: LiveContext): Promise { const destination = await runStep( context, diff --git a/test/integration/ios-simulator-e2e/live-fixtures.ts b/test/integration/ios-simulator-e2e/live-fixtures.ts new file mode 100644 index 000000000..a39cf5d30 --- /dev/null +++ b/test/integration/ios-simulator-e2e/live-fixtures.ts @@ -0,0 +1,12 @@ +export function clearStateLaunchUrlMaestroFlow(appId: string): string { + return `appId: ${JSON.stringify(appId)} +name: Clear state launch URL +--- +- launchApp: + clearState: true +- assertVisible: + text: Automation lab +- assertVisible: + text: cold.start +`; +} diff --git a/test/integration/ios-simulator-e2e/live-harness.ts b/test/integration/ios-simulator-e2e/live-harness.ts index 69d763cda..4bd5ff6c8 100644 --- a/test/integration/ios-simulator-e2e/live-harness.ts +++ b/test/integration/ios-simulator-e2e/live-harness.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; +import { resolveDaemonPaths } from '../../../src/daemon/config.ts'; import { type CliJsonResult, formatResultDebug, runBuiltCliJson } from '../cli-json.ts'; import { type IosSimulatorBehaviorId } from './behavior-coverage.ts'; import { liveCommandsForScenario } from './coverage-manifest.ts'; @@ -34,6 +35,7 @@ export type LiveContext = { env: NodeJS.ProcessEnv; session: string; sessionOpen: boolean; + stateDir: string; stepHistory: StepRecord[]; tier: Tier; udid: string; @@ -58,6 +60,9 @@ export function createContext(): LiveContext { env: process.env, session: `ios-e2e-${tier}-${process.pid.toString(36)}`, sessionOpen: false, + stateDir: resolveDaemonPaths(process.env.AGENT_DEVICE_STATE_DIR, { + env: process.env, + }).baseDir, stepHistory: [], tier, udid: requiredEnv('AGENT_DEVICE_IOS_UDID'), @@ -251,6 +256,8 @@ function withCommonFlags(context: LiveContext, args: string[]): string[] { context.udid, '--session', context.session, + '--state-dir', + context.stateDir, ...(args.includes('--json') ? [] : ['--json']), ]; }