diff --git a/CHANGELOG.md b/CHANGELOG.md index 89500c54b4..f303b02429 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- On an iOS simulator, `open --launch-url ` now reports every launch-confirmation state instead of only the answered one. When the runner reads the `Open in ""?` prompt, its title is now the alert's own texts rather than any labelled element inside the alert's root, so home-screen chrome can no longer masquerade as the title and block the answer. When the open sees a prompt it did not answer, `open` returns `launchConfirmation: "unanswered"` with a typed `launchConfirmationReason` (`alert-unrecognized` or `url-owner-unresolved`) and a warning naming the next step; when a confirmation step itself failed or could not be verified (alert read, accept, or runner), `open` returns `launchConfirmationProbeFailure` naming that step with a check-with-`alert get` warning. `client.apps.open` and `open --json` carry the same fields. - Add `uninstall ` and `client.apps.uninstall({ app })` to remove an installed app on adb-backed Android targets, iOS simulators, and CoreDevice iOS devices. Both report `wasInstalled`. A package name or bundle id that is not installed succeeds with `wasInstalled: false`. Other targets, Appium and Limrun sessions included, refuse it before anything runs. - Add `settings mobile-data on|off` for adb-backed Android targets. It switches mobile data through `svc data` and reports the `mobileData` state stored in the global `mobile_data` row; multi-SIM read-back is not covered yet. Every other target refuses it before anything runs, and `--app` is refused because the switch is device-wide. - On iOS, a `fill` that runs under a degraded XCTest snapshot channel now aims at a point, including ref, selector and replay fills. When the runner identifies no text input under that point, it refuses with `NO_TEXT_INPUT_AT_POINT` (`error.details.reason: no_text_input_at_point`, `dispatched: no`) before dispatching its focus tap, and no field changes; when the input lookup itself cannot answer, it refuses with `TEXT_INPUT_PROBE_UNAVAILABLE` (`error.details.reason: text_input_probe_unavailable`). Previously such a fill cleared and typed through the app-level first responder, so it could rewrite a field the caller never named, or report a clear failure after emptying it. A ref or selector fill whose rect centre misses the real field (an outer container, a non-input node) now needs a point inside the input's own rect. diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m b/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m index 420e806ae7..83b2f213aa 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m @@ -839,6 +839,21 @@ - (void)viewDidLoad { } } + // The shape a SpringBoard confirmation root carries on an older iOS: the window's own title + // text plus host chrome whose accessibility label is not copy (the Home Screen page readout), + // placed first so a walk over every descendant reads it before the title. + if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-alert-title-page-chrome"]) { + label.isAccessibilityElement = NO; + UIView *pageChrome = [[UIView alloc] initWithFrame:CGRectMake(140, 300, 100, 20)]; + pageChrome.isAccessibilityElement = YES; + pageChrome.accessibilityLabel = @"0 pages"; + [self.view addSubview:pageChrome]; + UILabel *popupTitle = [[UILabel alloc] initWithFrame:CGRectMake(80, 340, 240, 24)]; + popupTitle.text = @"Relaunch to continue"; + popupTitle.accessibilityIdentifier = @"agent-device-alert-title"; + [self.view addSubview:popupTitle]; + } + if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-crowded-screen"]) { for (NSUInteger row = 0; row < 500; row++) { UILabel *rowLabel = [[UILabel alloc] initWithFrame:CGRectMake(0, 60 + (row % 30) * 24, 400, 16)]; diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift index af23abf8c5..6a18c2b241 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift @@ -270,23 +270,45 @@ extension RunnerTests { return alert.buttons[index] } + /// The alert's own title: the first non-empty static text inside the alert that is neither a + /// button nor alert chrome, then the alert element's own label, then the first labelled + /// descendant that is not a button or chrome. An alert presents its copy as static text, so the + /// static-text read answers a root that carries host chrome whose accessibility label is not the + /// alert's title (a SpringBoard sheet over the Home Screen includes the page readout). private func preferredAlertTitle(_ element: XCUIElement, buttons: [XCUIElement]) -> String { let buttonLabels = Set(buttons.map { $0.label.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }) - let descendants = element.descendants(matching: .any).allElementsBoundByIndex - for descendant in descendants { - let text = descendant.label.trimmingCharacters(in: .whitespacesAndNewlines) - if text.isEmpty || - isGenericAlertLabel(text) || - buttonLabels.contains(text.lowercased()) || - descendant.elementType == .navigationBar || - actionableTypes.contains(descendant.elementType) - { + let isButtonLabel: (String) -> Bool = { buttonLabels.contains($0.lowercased()) } + if let text = firstAlertText(safeElementsQuery { element.staticTexts.allElementsBoundByIndex }, isButtonLabel: isButtonLabel) { + return text + } + let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines) + if !label.isEmpty && !isGenericAlertLabel(label) { + return label + } + if let text = firstAlertText( + safeElementsQuery { element.descendants(matching: .any).allElementsBoundByIndex }, + isButtonLabel: isButtonLabel + ) { + return text + } + return "Alert" + } + + private func firstAlertText( + _ elements: [XCUIElement], + isButtonLabel: (String) -> Bool + ) -> String? { + for element in elements { + let text = element.label.trimmingCharacters(in: .whitespacesAndNewlines) + if text.isEmpty || isGenericAlertLabel(text) || isButtonLabel(text) { + continue + } + if element.elementType == .navigationBar || actionableTypes.contains(element.elementType) { continue } return text } - let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines) - return label.isEmpty || isGenericAlertLabel(label) ? "Alert" : label + return nil } private func isGenericAlertLabel(_ label: String) -> Bool { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AlertTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AlertTests.swift index 00419f9847..6f4b102237 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AlertTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AlertTests.swift @@ -65,6 +65,25 @@ extension RunnerTests { XCTAssertEqual(button.identifier, "agent-device-close-popover") } + @MainActor + func testAlertTitleIsTheAlertsOwnTextNotLabelledChromeInsideTheRoot() throws { + // The resolution root of an older-iOS confirmation carries host chrome whose accessibility + // label is not copy (the Home Screen page readout). The title is the alert's own static text. + app.launchArguments = [ + "--agent-device-dismiss-popup-window", + "--agent-device-alert-title-page-chrome", + ] + app.launch() + defer { terminateCrowdedScreen() } + + let alert = try XCTUnwrap( + resolveAlert(app: app, deadline: Date().addingTimeInterval(RunnerTests.defaultAlertCommandTimeout)) + ) + let inspection = handleAlert(alert, action: "get", deadline: Date().addingTimeInterval(RunnerTests.defaultAlertCommandTimeout)) + XCTAssertTrue(inspection.ok) + XCTAssertEqual(inspection.data?.message, "Relaunch to continue") + } + private func launchCrowdedScreen(extraArguments: [String]) { app.launchArguments = ["--agent-device-crowded-screen"] + extraArguments app.launch() diff --git a/packages/contracts/src/application-lifecycle-runtime.ts b/packages/contracts/src/application-lifecycle-runtime.ts index 8db2fb2807..30631b4726 100644 --- a/packages/contracts/src/application-lifecycle-runtime.ts +++ b/packages/contracts/src/application-lifecycle-runtime.ts @@ -169,18 +169,47 @@ export type PostOpenObservationFailure = | Readonly<{ source: 'circuit' }>; /** - * A system confirmation the launch itself raised and the open answered. `accepted`: iOS held a - * launch URL behind `Open in ""?` naming the session app, and the open accepted it. + * An alert seen in place of the launch, and what the open reported about it. `accepted`: iOS held + * a launch URL behind `Open in ""?` naming the session app, and the open accepted it. + * `unanswered`: the open saw an alert it did not answer, and the required reason names which leg + * outran it; the prompt stays on screen for `alert accept`. */ -export type LaunchConfirmation = 'accepted'; +export type LaunchConfirmation = 'accepted' | 'unanswered'; -export type OpenApplicationOutcome = Readonly<{ +/** Which leg left a seen launch confirmation unanswered. Keyed, never inferred from text. */ +export type LaunchConfirmationUnansweredReason = 'alert-unrecognized' | 'url-owner-unresolved'; + +/** + * A launch-confirmation probe step that failed or whose result could not be verified, so whether a + * prompt is up is unknown. Not an outcome of the confirmation itself: the open reports no + * confirmation for it and the daemon warns instead, keeping probe failure separate from what a + * completed probe saw. + */ +export type LaunchConfirmationProbeFailure = 'alert-read' | 'alert-accept' | 'runner'; + +/** What the open reports about a launch confirmation it saw, with its typed outcome paired. */ +export type LaunchConfirmationOutcome = + | Readonly<{ launchConfirmation: 'accepted'; launchConfirmationProbeFailure?: undefined }> + | Readonly<{ + launchConfirmation: 'unanswered'; + launchConfirmationReason: LaunchConfirmationUnansweredReason; + launchConfirmationProbeFailure?: undefined; + }>; + +/** No confirmation reported: either none was seen or the probe could not read one. */ +export type LaunchConfirmationAbsent = Readonly<{ + launchConfirmation?: undefined; + launchConfirmationProbeFailure?: LaunchConfirmationProbeFailure; +}>; + +type OpenApplicationOutcomeBase = Readonly<{ appBundleId?: string; timing: OpenApplicationTiming; - /** Present only when the open answered a launch confirmation. */ - launchConfirmation?: LaunchConfirmation; }>; +export type OpenApplicationOutcome = OpenApplicationOutcomeBase & + (LaunchConfirmationOutcome | LaunchConfirmationAbsent); + /** Applies or clears a platform's native representation of neutral runtime hints. */ export type RuntimeHintsApplicationInput = Readonly<{ appId?: string; diff --git a/packages/contracts/src/client-app.ts b/packages/contracts/src/client-app.ts index 765785d57e..fc5abe6a9e 100644 --- a/packages/contracts/src/client-app.ts +++ b/packages/contracts/src/client-app.ts @@ -1,7 +1,10 @@ // The public API vocabulary for app install, deploy, open, close and inventory. import type { AppsFilter } from './app-inventory.ts'; -import type { LaunchConfirmation } from './application-lifecycle-runtime.ts'; +import type { + LaunchConfirmationAbsent, + LaunchConfirmationOutcome, +} from './application-lifecycle-runtime.ts'; import type { JsonObject } from './json.ts'; import type { SessionSurface } from './session-surface.ts'; import type { TargetShutdownResult } from './target-shutdown-contract.ts'; @@ -96,7 +99,12 @@ export type AppOpenOptions = AgentDeviceRequestOverrides & runtime?: SessionRuntimeHints; }; -export type AppOpenResult = { +export type AppOpenResult = AppOpenResultBase & + // The same discriminated report the platform outcome carries: a seen confirmation pairs its + // reason, and only the absent arm can name a probe step that could not read one. + (LaunchConfirmationOutcome | LaunchConfirmationAbsent); + +type AppOpenResultBase = { session: string; warnings?: string[]; sessionStateDir?: string; @@ -110,11 +118,6 @@ export type AppOpenResult = { runtime?: SessionRuntimeHints; selection?: DeviceSelectionMetadata; device?: AgentDeviceSessionDevice; - /** - * `accepted` when iOS held the launch URL behind an `Open in ""?` confirmation naming the - * session app and the open answered it. - */ - launchConfirmation?: LaunchConfirmation; /** * Initial interactive snapshot captured immediately after an open that * requested `foreground`, composed from the same snapshot-runtime dispatch diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts index 190d27cd11..773d08ae12 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts @@ -432,10 +432,14 @@ test('open reports the launch confirmation its platform answered', async () => { ...binding, operations: { ...binding.operations, - openApplication: async (input: OpenApplicationInput) => ({ - ...(await operations.openApplication(input)), - launchConfirmation: 'accepted' as const, - }), + openApplication: async (input: OpenApplicationInput) => { + const base = await operations.openApplication(input); + return { + appBundleId: base.appBundleId, + timing: base.timing, + launchConfirmation: 'accepted' as const, + }; + }, }, }; }); diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-surface.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-surface.test.ts index 1bce8ee814..d8bdb4deb7 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-surface.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-surface.test.ts @@ -71,8 +71,19 @@ test('buildOpenResult reports an answered launch confirmation and omits one that }; assert.equal( - buildOpenResult({ ...base, launchConfirmation: 'accepted' }).launchConfirmation, + buildOpenResult({ ...base, launchConfirmation: { launchConfirmation: 'accepted' } }) + .launchConfirmation, 'accepted', ); + const unanswered = buildOpenResult({ + ...base, + launchConfirmation: { + launchConfirmation: 'unanswered', + launchConfirmationReason: 'alert-unrecognized', + }, + }); + assert.equal(unanswered.launchConfirmation, 'unanswered'); + assert.equal(unanswered.launchConfirmationReason, 'alert-unrecognized'); assert.equal('launchConfirmation' in buildOpenResult(base), false); + assert.equal('launchConfirmationReason' in buildOpenResult(base), false); }); diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/session-open-execution-relaunch.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/session-open-execution-relaunch.test.ts index fafbdc4e8f..b157c0daf0 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/session-open-execution-relaunch.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/session-open-execution-relaunch.test.ts @@ -2,7 +2,11 @@ import path from 'node:path'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import type { OpenApplicationInput } from '@agent-device/contracts/application-lifecycle-runtime'; +import type { + LaunchConfirmationAbsent, + LaunchConfirmationOutcome, + OpenApplicationInput, +} from '@agent-device/contracts/application-lifecycle-runtime'; import { mkdtempForTestSync } from '@agent-device/test-kit'; import { SessionStore } from '../../../core/session-store.ts'; import type { DaemonRequest } from '../../../core/daemon-request.ts'; @@ -93,6 +97,7 @@ async function openRelaunch(params: { positionals?: string[]; flags?: Record; onOpenApplication?: (input: OpenApplicationInput) => Promise | void; + openOutcome?: LaunchConfirmationOutcome | LaunchConfirmationAbsent; }) { const opened: OpenApplicationInput[] = []; const boundDevices: DeviceInfo[] = []; @@ -109,7 +114,7 @@ async function openRelaunch(params: { openApplication: async (input: OpenApplicationInput) => { opened.push(input); await params.onOpenApplication?.(input); - return { appBundleId: input.appBundleId, timing: {} }; + return { appBundleId: input.appBundleId, timing: {}, ...params.openOutcome }; }, }, }; @@ -235,6 +240,61 @@ test('open --relaunch hands the lifecycle both positionals', async ( ]); }); +test.each([ + { + reason: 'alert-unrecognized' as const, + warning: /did not recognize \(alert-unrecognized\)[\s\S]*alert get[\s\S]*alert accept/, + }, + { + reason: 'url-owner-unresolved' as const, + warning: /could not be resolved[\s\S]*alert accept/, + }, +])( + 'open reports an unanswered $reason launch confirmation and appends its warning', + async ({ reason, warning }) => { + const sessionStore = makeSessionStore(); + publishSession(sessionStore, 'ios-confirmation-session', IOS_SIMULATOR); + mockResolveTargetDevice.mockResolvedValue(IOS_SIMULATOR); + + const { response } = await openRelaunch({ + sessionStore, + sessionName: 'ios-confirmation-session', + openOutcome: { + launchConfirmation: 'unanswered', + launchConfirmationReason: reason, + }, + }); + + expect(response?.ok).toBe(true); + if (response?.ok !== true) throw new Error('open must answer an unanswered confirmation'); + const data = response.data ?? {}; + expect(data.launchConfirmation).toBe('unanswered'); + expect(data.launchConfirmationReason).toBe(reason); + expect(data.warnings).toEqual([expect.stringMatching(warning)]); + }, +); + +test('open appends a probe-failure warning when the confirmation could not be read', async () => { + const sessionStore = makeSessionStore(); + publishSession(sessionStore, 'ios-probe-session', IOS_SIMULATOR); + mockResolveTargetDevice.mockResolvedValue(IOS_SIMULATOR); + + const { response } = await openRelaunch({ + sessionStore, + sessionName: 'ios-probe-session', + openOutcome: { launchConfirmationProbeFailure: 'alert-read' }, + }); + + expect(response?.ok).toBe(true); + if (response?.ok !== true) throw new Error('open must survive an unreadable probe'); + const data = response.data ?? {}; + expect('launchConfirmation' in data).toBe(false); + expect(data.launchConfirmationProbeFailure).toBe('alert-read'); + expect(data.warnings).toEqual([ + expect.stringMatching(/could not read whether iOS is asking[\s\S]*alert get/), + ]); +}); + test('open --relaunch --clear-app-state hands the lifecycle the clear-state execution', async () => { const sessionStore = makeSessionStore(); publishSession(sessionStore, 'ios-clear-state-session', IOS_SIMULATOR); diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/session-open-execution.ts b/packages/daemon/src/sessions/session-lifecycle/internal/session-open-execution.ts index 5cd67bf7ef..60eab455fa 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/session-open-execution.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/session-open-execution.ts @@ -13,7 +13,13 @@ import { } from '@agent-device/device-selection/device-selection-resolver'; import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtime'; import type { SessionSurface } from '@agent-device/contracts/session'; -import type { OpenApplicationTiming } from '@agent-device/contracts/application-lifecycle-runtime'; +import type { + LaunchConfirmationAbsent, + LaunchConfirmationOutcome, + LaunchConfirmationUnansweredReason, + OpenApplicationOutcome, + OpenApplicationTiming, +} from '@agent-device/contracts/application-lifecycle-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { DaemonPlatformServices } from '../../../core/platform-services.ts'; import type { DaemonRequest } from '../../../core/daemon-request.ts'; @@ -114,6 +120,51 @@ function deviceClaimTakeoverWarning(tookOver: TakenOverDeviceClaim): string { ); } +/** What the caller's `open` output says about the launch-confirmation probe on this outcome. */ +function launchConfirmationReport( + outcome: OpenApplicationOutcome, +): LaunchConfirmationOutcome | LaunchConfirmationAbsent { + switch (outcome.launchConfirmation) { + case 'accepted': + return { launchConfirmation: 'accepted' }; + case 'unanswered': + return { + launchConfirmation: 'unanswered', + launchConfirmationReason: outcome.launchConfirmationReason, + }; + default: + // Keeps the typed probe-failure step on the response beside its warning, so a machine + // reader tells "no prompt" from "prompt state unreadable" without parsing prose. + return outcome.launchConfirmationProbeFailure + ? { launchConfirmationProbeFailure: outcome.launchConfirmationProbeFailure } + : {}; + } +} + +/** What the caller's `open` output says when a confirmation probe step failed unverified. */ +function launchConfirmationProbeFailureWarning(): string { + return ( + 'agent-device could not read whether iOS is asking to confirm the launch URL, so the launch ' + + 'URL may still be held by a prompt. Check with `alert get` before retrying.' + ); +} + +/** What the caller's `open` output says when the launch confirmation it saw stayed unanswered. */ +function launchConfirmationUnansweredWarning(reason: LaunchConfirmationUnansweredReason): string { + if (reason === 'url-owner-unresolved') { + return ( + 'iOS is still holding the launch confirmation and the installed app that owns the launch ' + + "URL's scheme could not be resolved, so this launch URL has not reached the app. Answer it " + + 'with `alert accept`.' + ); + } + return ( + 'iOS shows an alert this open did not recognize (alert-unrecognized), so it left the launch ' + + 'URL unconfirmed. Inspect the current alert with `alert get` before answering it with ' + + '`alert accept`.' + ); +} + // Default-on for emulators, opt-in via --test-ime on real devices; --no-test-ime forces off. function shouldActivateAndroidTestIme(device: DeviceInfo, req: DaemonRequest): boolean { if (device.platform !== 'android') return false; @@ -298,11 +349,20 @@ export async function completeOpenCommand(params: { runtimeHintCount: countConfiguredRuntimeHints, sessionReused: existingSession !== undefined, selection: preparedSelection, - launchConfirmation: outcome.launchConfirmation, + launchConfirmation: launchConfirmationReport(outcome), }); if (tookOverDeviceClaim) { appendResponseWarning(openResult, deviceClaimTakeoverWarning(tookOverDeviceClaim)); } + if (outcome.launchConfirmation === 'unanswered') { + appendResponseWarning( + openResult, + launchConfirmationUnansweredWarning(outcome.launchConfirmationReason), + ); + } + if (outcome.launchConfirmation === undefined && outcome.launchConfirmationProbeFailure) { + appendResponseWarning(openResult, launchConfirmationProbeFailureWarning()); + } const nextRef = publishOpenSession({ req, sessionStore, diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/session-open-surface.ts b/packages/daemon/src/sessions/session-lifecycle/internal/session-open-surface.ts index b2e47f090a..b3212eef7d 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/session-open-surface.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/session-open-surface.ts @@ -1,4 +1,7 @@ -import type { LaunchConfirmation } from '@agent-device/contracts/application-lifecycle-runtime'; +import type { + LaunchConfirmationAbsent, + LaunchConfirmationOutcome, +} from '@agent-device/contracts/application-lifecycle-runtime'; import type { SessionSurface } from '@agent-device/contracts/session'; import { isIosFamily, @@ -27,7 +30,7 @@ export function buildOpenResult(params: { runtimeHintCount: (runtime: SessionRuntimeHints) => number; sessionReused: boolean; selection?: DeviceSelectionResult; - launchConfirmation?: LaunchConfirmation; + launchConfirmation?: LaunchConfirmationOutcome | LaunchConfirmationAbsent; }): Record { const { sessionName, @@ -61,7 +64,7 @@ export function buildOpenResult(params: { if (appBundleId) result.appBundleId = appBundleId; if (startup) result.startup = startup; if (timing) result.timing = timing; - if (launchConfirmation) result.launchConfirmation = launchConfirmation; + Object.assign(result, launchConfirmation ?? {}); if (runtime && runtimeHintCount(runtime) > 0) result.runtime = runtime; if (device) { result.platform = publicPlatformString(device); diff --git a/packages/platform-apple/src/lifecycle-launch-confirmation.test.ts b/packages/platform-apple/src/lifecycle-launch-confirmation.test.ts new file mode 100644 index 0000000000..f2f9c08b73 --- /dev/null +++ b/packages/platform-apple/src/lifecycle-launch-confirmation.test.ts @@ -0,0 +1,492 @@ +import { beforeEach, expect, test, vi } from 'vitest'; +import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; +import { AppError } from '@agent-device/kernel/errors'; + +import { bindAppleApplicationLifecycle } from './lifecycle.ts'; +import { platformRuntimeHostFixture } from './runtime.fixtures.ts'; +import type { LaunchObservation } from './snapshot-observability.ts'; +import { alertNotFound, CONFIRMATION } from './launch-confirmation.fixtures.ts'; +import { + COMING_UP, + LAUNCH_URL, + OBSERVABLE, + SPAWN_TIMEOUT, + UNOBSERVABLE, + device, + launchUrlInput, + launchUrlSimulator, + resetUrlOwner, + urlOwner, +} from './lifecycle.fixtures.ts'; + +/** + * Launch-URL open and its SpringBoard confirmation: reading, recognizing, accepting, reporting, + * and failing it. The host-AX settle around it lives here too because every confirmation case is + * reached through an unobservable launch verdict. + */ + +vi.mock('./core/app-resolution.ts', async () => { + const fixtures = await import('./lifecycle.fixtures.ts'); + return { + resolveIosSimulatorDeepLinkBundleId: async () => await fixtures.urlOwner.resolve(), + }; +}); + +beforeEach(() => { + resetUrlOwner(); +}); +test('a launch URL the bridge sees land in the app never reaches the runner', async () => { + const { lifecycle, events, interactor, releaseSpeculativeRunner, prewarmRunnerSession } = + launchUrlSimulator(async () => { + throw alertNotFound(); + }); + + const outcome = await lifecycle.openApplication(launchUrlInput()); + + expect(outcome.launchConfirmation).toBeUndefined(); + expect(outcome.timing.runnerDemand).toBe('none'); + expect(releaseSpeculativeRunner).toHaveBeenCalledOnce(); + expect(prewarmRunnerSession).not.toHaveBeenCalled(); + expect(interactor.awaitAlert).not.toHaveBeenCalled(); + expect(events).toEqual(['release', `open ${LAUNCH_URL}`, 'observe observable']); +}); + +/** Verdicts that prove nothing about whether the app came up, so the sheet is still read. */ +const DISCOVERY_DEADLINE_TIMEOUT: LaunchObservation = { + observation: 'probe-failed', + failure: { source: 'target', code: 'COMMAND_FAILED' }, +}; +const TARGET_PROBE_FAILED: LaunchObservation = { + observation: 'probe-failed', + failure: { + source: 'target', + code: 'COMMAND_FAILED', + reason: 'simulator-target-probe-failed', + }, +}; +const BRIDGE_CIRCUIT: LaunchObservation = { + observation: 'probe-failed', + failure: { source: 'circuit' }, +}; + +test.each([ + ['the bridge circuit is open', BRIDGE_CIRCUIT, BRIDGE_CIRCUIT], + ['target discovery fails on its deadline', DISCOVERY_DEADLINE_TIMEOUT, OBSERVABLE], + ['the target probe fails outright', TARGET_PROBE_FAILED, OBSERVABLE], +])( + 'a confirmable launch whose bridge verdict is %s still reads and answers the sheet', + async (_name, beforeAnswer, afterAnswer) => { + const { lifecycle, events, interactor } = launchUrlSimulator( + async () => CONFIRMATION, + [beforeAnswer, afterAnswer], + ); + + const outcome = await lifecycle.openApplication(launchUrlInput()); + + expect(outcome.launchConfirmation).toBe('accepted'); + expect(outcome.timing.runnerDemand).toBe('required'); + expect(interactor.awaitAlert).toHaveBeenCalledOnce(); + expect(outcome.timing.postOpenObservation).toBe(afterAnswer.observation); + // A verdict the bridge could not produce keeps its typed failure, so the open still says why + // the app came back unreadable. + expect(outcome.timing.postOpenObservationFailure).toEqual( + afterAnswer.observation === 'probe-failed' ? afterAnswer.failure : undefined, + ); + expect(events).toEqual([ + 'release', + `open ${LAUNCH_URL}`, + 'observe probe-failed', + 'alert wait', + 'alert accept', + `observe ${afterAnswer.observation}`, + ]); + }, +); + +test('a launch URL held behind a confirmation for the session app is accepted and reported', async () => { + const { lifecycle, events, resolutions } = launchUrlSimulator( + async () => CONFIRMATION, + [UNOBSERVABLE, OBSERVABLE], + ); + + const outcome = await lifecycle.openApplication(launchUrlInput()); + + expect(outcome.launchConfirmation).toBe('accepted'); + // One resolution dispatches the open; one more serves both the alert read and the accept. + expect(resolutions()).toBe(2); + expect(outcome.timing.runnerDemand).toBe('required'); + expect(outcome.timing.postOpenObservation).toBe('observable'); + expect(events).toEqual([ + 'release', + `open ${LAUNCH_URL}`, + 'observe unobservable', + 'alert wait', + 'alert accept', + 'observe observable', + ]); +}); + +test('the reported settle spans both observations and the answer between them', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + try { + const { lifecycle } = launchUrlSimulator( + async () => { + vi.advanceTimersByTime(100); + return CONFIRMATION; + }, + [UNOBSERVABLE, OBSERVABLE], + { + owner: async () => { + vi.advanceTimersByTime(100); + return 'com.example.app'; + }, + }, + ); + + const outcome = await lifecycle.openApplication(launchUrlInput()); + + expect(outcome.launchConfirmation).toBe('accepted'); + expect(outcome.timing.postOpenSettleDurationMs).toBe(200); + } finally { + vi.useRealTimers(); + } +}); + +test.each([ + ['finds no alert', alertNotFound(), undefined], + [ + 'cannot reach the runner', + new AppError('COMMAND_FAILED', 'runner unavailable', { reason: 'runner-start-failed' }), + 'alert-read' as const, + ], +])( + 'an unobservable launch whose alert read %s returns the open as it was', + async (_case, failure, probeFailure) => { + const { lifecycle, events } = launchUrlSimulator(async () => { + throw failure; + }, [UNOBSERVABLE]); + + const outcome = await lifecycle.openApplication(launchUrlInput()); + + expect(outcome.launchConfirmation).toBeUndefined(); + if (probeFailure) { + expect(outcome).toMatchObject({ launchConfirmationProbeFailure: probeFailure }); + } else { + expect('launchConfirmationProbeFailure' in outcome).toBe(false); + } + expect(outcome.timing.runnerDemand).toBe('required'); + expect(outcome.timing.postOpenObservation).toBe('unobservable'); + expect(events).toEqual(['release', `open ${LAUNCH_URL}`, 'observe unobservable', 'alert wait']); + }, +); + +test('an unobservable launch whose runner interactor cannot be resolved reports the runner step', async () => { + const { lifecycle, events } = launchUrlSimulator(async () => CONFIRMATION, [UNOBSERVABLE], { + resolveInteractor: async () => { + throw SPAWN_TIMEOUT; + }, + }); + + const outcome = await lifecycle.openApplication(launchUrlInput()); + + expect(outcome.launchConfirmation).toBeUndefined(); + expect(outcome).toMatchObject({ launchConfirmationProbeFailure: 'runner' }); + expect(outcome.timing.postOpenObservation).toBe('unobservable'); + expect(events).toEqual(['release', `open ${LAUNCH_URL}`, 'observe unobservable']); +}); + +test('a prompt whose URL owner lookup fails reports the unanswered confirmation', async () => { + const { lifecycle, interactor } = launchUrlSimulator(async () => CONFIRMATION, [UNOBSERVABLE], { + owner: async () => { + throw SPAWN_TIMEOUT; + }, + }); + + const outcome = await lifecycle.openApplication(launchUrlInput()); + + expect(outcome).toMatchObject({ + launchConfirmation: 'unanswered', + launchConfirmationReason: 'url-owner-unresolved', + }); + expect(interactor.acceptAlert).not.toHaveBeenCalled(); +}); + +test('a prompt open read but did not recognize reports the unanswered confirmation', async () => { + const { lifecycle, interactor } = launchUrlSimulator( + async () => ({ message: '0 pages', items: ['Cancel', 'Open'] }), + [UNOBSERVABLE], + ); + + const outcome = await lifecycle.openApplication(launchUrlInput()); + + expect(outcome).toMatchObject({ + launchConfirmation: 'unanswered', + launchConfirmationReason: 'alert-unrecognized', + }); + expect(interactor.acceptAlert).not.toHaveBeenCalled(); +}); + +test('a prompt whose URL owner stays unresolved reports the unanswered confirmation', async () => { + const { lifecycle, interactor } = launchUrlSimulator(async () => CONFIRMATION, [UNOBSERVABLE], { + owner: async () => undefined, + }); + + const outcome = await lifecycle.openApplication(launchUrlInput()); + + expect(outcome).toMatchObject({ + launchConfirmation: 'unanswered', + launchConfirmationReason: 'url-owner-unresolved', + }); + expect(interactor.acceptAlert).not.toHaveBeenCalled(); +}); + +test('an accept that dies with the runner session hands the URL over again and reads once more', async () => { + let accepts = 0; + const readAlert = vi + .fn<() => Promise>>() + .mockResolvedValueOnce(CONFIRMATION) + .mockRejectedValueOnce(SPAWN_TIMEOUT) + .mockResolvedValue(CONFIRMATION); + const { lifecycle, events } = launchUrlSimulator( + readAlert, + [UNOBSERVABLE, UNOBSERVABLE, OBSERVABLE], + { + acceptAlert: async () => { + accepts += 1; + if (accepts === 1) throw SPAWN_TIMEOUT; + return {}; + }, + }, + ); + + const outcome = await lifecycle.openApplication(launchUrlInput()); + + expect(outcome.launchConfirmation).toBe('accepted'); + expect(outcome.timing.postOpenObservation).toBe('observable'); + // The re-dispatch hands the URL as its own open target, the way the follow-up open does. + expect(events).toEqual([ + 'release', + `open ${LAUNCH_URL}`, + 'observe unobservable', + 'alert wait', + 'alert accept', + 'alert get', + 'observe unobservable', + 'open', + 'alert wait', + 'alert accept', + 'observe observable', + ]); +}); + +test('an accept whose read-back also fails reports the unknown prompt state', async () => { + const readAlert = vi + .fn<() => Promise>>() + .mockResolvedValueOnce(CONFIRMATION) + .mockRejectedValue(SPAWN_TIMEOUT); + const { lifecycle } = launchUrlSimulator(readAlert, [UNOBSERVABLE, COMING_UP], { + acceptAlert: async () => { + throw SPAWN_TIMEOUT; + }, + }); + + const outcome = await lifecycle.openApplication(launchUrlInput()); + + expect(outcome.launchConfirmation).toBeUndefined(); + expect(outcome).toMatchObject({ launchConfirmationProbeFailure: 'alert-accept' }); +}); + +test('a redispatch whose second probe fails reports the unknown state, not the earlier accept', async () => { + // Round 1 accepts but the app stays proven not running, so the URL is handed over again; round 2 + // cannot even read the prompt. The earlier accepted must not hide the unknown final state. + const readAlert = vi + .fn<() => Promise>>() + .mockResolvedValueOnce(CONFIRMATION) + .mockRejectedValue(SPAWN_TIMEOUT); + const { lifecycle } = launchUrlSimulator(readAlert, [UNOBSERVABLE, UNOBSERVABLE, COMING_UP]); + + const outcome = await lifecycle.openApplication(launchUrlInput()); + + expect(outcome.launchConfirmation).toBeUndefined(); + expect(outcome).toMatchObject({ launchConfirmationProbeFailure: 'alert-read' }); +}); + +test.each([ + ['still coming up', COMING_UP], + ['observable', OBSERVABLE], + ['not running', UNOBSERVABLE], +])( + 'a failed accept rejects a persistent launch prompt while the app is %s', + async (_name, afterAnswer) => { + const { lifecycle, interactor, events } = launchUrlSimulator( + async () => CONFIRMATION, + [BRIDGE_CIRCUIT, afterAnswer], + { + acceptAlert: async () => { + throw new AppError('COMMAND_FAILED', 'alert accept exhausted its deadline', { + runnerErrorCode: 'ALERT_DEADLINE_EXCEEDED', + }); + }, + }, + ); + + await expect(lifecycle.openApplication(launchUrlInput())).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'launch_confirmation_unanswered', appBundleId: 'com.example.app' }, + }); + expect(interactor.acceptAlert).toHaveBeenCalledOnce(); + expect(interactor.awaitAlert).toHaveBeenCalledOnce(); + expect(interactor.readAlert).toHaveBeenCalledOnce(); + expect(events.filter((event) => event === 'open' || event === `open ${LAUNCH_URL}`)).toEqual([ + `open ${LAUNCH_URL}`, + ]); + }, +); + +test.each([ + { + name: 'absent', + readAfterFailure: async () => { + throw alertNotFound(); + }, + }, + { + name: 'unreadable', + readAfterFailure: async () => { + throw SPAWN_TIMEOUT; + }, + }, + { + name: 'replaced by the same title with unrelated buttons', + readAfterFailure: async () => ({ message: CONFIRMATION.message, items: ['Allow', 'Deny'] }), + }, + { + name: 'replaced by another launch confirmation', + readAfterFailure: async () => ({ ...CONFIRMATION, message: 'Open in “Other App”?' }), + }, +])( + 'a failed accept with its prompt $name preserves launch-transition policy', + async ({ readAfterFailure }) => { + const readAlert = vi.fn(readAfterFailure).mockResolvedValueOnce(CONFIRMATION); + const { lifecycle, interactor, events } = launchUrlSimulator( + readAlert, + [BRIDGE_CIRCUIT, COMING_UP], + { + acceptAlert: async () => { + throw SPAWN_TIMEOUT; + }, + }, + ); + + const outcome = await lifecycle.openApplication(launchUrlInput()); + + expect(outcome.launchConfirmation).toBeUndefined(); + expect(outcome.timing.postOpenObservation).toBe('unobservable'); + expect(interactor.acceptAlert).toHaveBeenCalledOnce(); + expect(interactor.awaitAlert).toHaveBeenCalledOnce(); + expect(interactor.readAlert).toHaveBeenCalledOnce(); + expect(events.filter((event) => event === 'open' || event === `open ${LAUNCH_URL}`)).toEqual([ + `open ${LAUNCH_URL}`, + ]); + }, +); + +test('an accept leaving a launch-transition window open stays green and re-hands nothing', async () => { + const { lifecycle, events } = launchUrlSimulator( + async () => CONFIRMATION, + [UNOBSERVABLE, COMING_UP], + ); + + const outcome = await lifecycle.openApplication(launchUrlInput()); + + // The window proves only that the app was still coming up, which no second hand-off can fix. + expect(outcome.launchConfirmation).toBe('accepted'); + expect(outcome.timing.postOpenObservation).toBe('unobservable'); + expect(events).toEqual([ + 'release', + `open ${LAUNCH_URL}`, + 'observe unobservable', + 'alert wait', + 'alert accept', + 'observe unobservable', + ]); +}); + +test('a launch URL handed over twice that still leaves no process fails the open', async () => { + const readAlert = vi + .fn<() => Promise>>() + .mockResolvedValueOnce(CONFIRMATION) + .mockRejectedValueOnce(SPAWN_TIMEOUT) + .mockResolvedValueOnce(CONFIRMATION) + .mockRejectedValueOnce(SPAWN_TIMEOUT); + const { lifecycle, events } = launchUrlSimulator( + readAlert, + Array.from({ length: 3 }, () => UNOBSERVABLE), + { + acceptAlert: async () => { + throw SPAWN_TIMEOUT; + }, + }, + ); + + const failure = await lifecycle + .openApplication(launchUrlInput()) + .catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(AppError); + expect((failure as AppError).details).toMatchObject({ + reason: 'launch_confirmation_unanswered', + appBundleId: 'com.example.app', + }); + expect(events.filter((event) => event === 'open' || event === `open ${LAUNCH_URL}`)).toEqual([ + `open ${LAUNCH_URL}`, + 'open', + ]); +}); + +test('a launch URL whose scheme another app owns fails the open without accepting it', async () => { + urlOwner.resolve = async () => 'com.example.other'; + const { lifecycle, interactor } = launchUrlSimulator(async () => CONFIRMATION, [UNOBSERVABLE]); + + const failure = await lifecycle + .openApplication(launchUrlInput()) + .catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(AppError); + expect((failure as AppError).details).toMatchObject({ + reason: 'launch_confirmation_foreign_app', + foreignAppBundleId: 'com.example.other', + sessionAppBundleId: 'com.example.app', + }); + expect(interactor.acceptAlert).not.toHaveBeenCalled(); +}); + +test.each([ + ['no launch URL', undefined], + ['a web launch URL', 'https://example.com/automation'], +])('a Simulator open with %s never reads an alert', async (_name, runtimeLaunchUrl) => { + const { lifecycle, interactor } = launchUrlSimulator(async () => ({}), [UNOBSERVABLE]); + + const outcome = await lifecycle.openApplication({ ...launchUrlInput(), runtimeLaunchUrl }); + + expect(outcome.launchConfirmation).toBeUndefined(); + expect(outcome.timing.runnerDemand).toBe('none'); + expect(interactor.awaitAlert).not.toHaveBeenCalled(); +}); + +test('a physical iOS launch URL never reads an alert', async () => { + const { interactor } = launchUrlSimulator(async () => ({}), [UNOBSERVABLE]); + const lifecycle = bindAppleApplicationLifecycle({ + host: { + ...platformRuntimeHostFixture(), + localInteractors: { resolve: async () => interactor }, + } as unknown as PlatformRuntimeHost, + device, + signal: new AbortController().signal, + }); + + const outcome = await lifecycle.openApplication({ ...launchUrlInput(), relaunch: false }); + + expect(outcome.launchConfirmation).toBeUndefined(); + expect(interactor.awaitAlert).not.toHaveBeenCalled(); +}); diff --git a/packages/platform-apple/src/lifecycle.test.ts b/packages/platform-apple/src/lifecycle.test.ts index 057ad27840..7943816204 100644 --- a/packages/platform-apple/src/lifecycle.test.ts +++ b/packages/platform-apple/src/lifecycle.test.ts @@ -1,41 +1,18 @@ -import { beforeEach, expect, test, vi } from 'vitest'; +import { expect, test, vi } from 'vitest'; import type { Interactor } from '@agent-device/contracts/interactor-types'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { AppError } from '@agent-device/kernel/errors'; import { bindAppleApplicationLifecycle } from './lifecycle.ts'; import { platformRuntimeHostFixture } from './runtime.fixtures.ts'; -import type { LaunchObservation } from './snapshot-observability.ts'; -import { alertNotFound, CONFIRMATION } from './launch-confirmation.fixtures.ts'; import { - COMING_UP, - LAUNCH_URL, - OBSERVABLE, - SPAWN_TIMEOUT, - UNOBSERVABLE, coldSimulatorLifecycleHost, device, - launchUrlInput, - launchUrlSimulator, openInput, - resetUrlOwner, simulator, simulatorHost, - urlOwner, } from './lifecycle.fixtures.ts'; -vi.mock('./core/app-resolution.ts', async () => { - const fixtures = await import('./lifecycle.fixtures.ts'); - return { - resolveIosSimulatorDeepLinkBundleId: async () => await fixtures.urlOwner.resolve(), - }; -}); - -beforeEach(() => { - resetUrlOwner(); -}); - test.each(['coredevice', 'xctest'] as const)( 'retains a physical iOS runner through relaunch and resets its target with the %s backend', async (iosPhysicalDeviceBackend) => { @@ -581,390 +558,3 @@ test('a tvOS Simulator relaunch keeps the awaited prewarm and asks for no observ expect(prewarmRunnerSession).toHaveBeenCalledOnce(); expect(notifyRunnerAppRelaunched).not.toHaveBeenCalled(); }); - -test('a launch URL the bridge sees land in the app never reaches the runner', async () => { - const { lifecycle, events, interactor, releaseSpeculativeRunner, prewarmRunnerSession } = - launchUrlSimulator(async () => { - throw alertNotFound(); - }); - - const outcome = await lifecycle.openApplication(launchUrlInput()); - - expect(outcome.launchConfirmation).toBeUndefined(); - expect(outcome.timing.runnerDemand).toBe('none'); - expect(releaseSpeculativeRunner).toHaveBeenCalledOnce(); - expect(prewarmRunnerSession).not.toHaveBeenCalled(); - expect(interactor.awaitAlert).not.toHaveBeenCalled(); - expect(events).toEqual(['release', `open ${LAUNCH_URL}`, 'observe observable']); -}); - -/** Verdicts that prove nothing about whether the app came up, so the sheet is still read. */ -const DISCOVERY_DEADLINE_TIMEOUT: LaunchObservation = { - observation: 'probe-failed', - failure: { source: 'target', code: 'COMMAND_FAILED' }, -}; -const TARGET_PROBE_FAILED: LaunchObservation = { - observation: 'probe-failed', - failure: { - source: 'target', - code: 'COMMAND_FAILED', - reason: 'simulator-target-probe-failed', - }, -}; -const BRIDGE_CIRCUIT: LaunchObservation = { - observation: 'probe-failed', - failure: { source: 'circuit' }, -}; - -test.each([ - ['the bridge circuit is open', BRIDGE_CIRCUIT, BRIDGE_CIRCUIT], - ['target discovery fails on its deadline', DISCOVERY_DEADLINE_TIMEOUT, OBSERVABLE], - ['the target probe fails outright', TARGET_PROBE_FAILED, OBSERVABLE], -])( - 'a confirmable launch whose bridge verdict is %s still reads and answers the sheet', - async (_name, beforeAnswer, afterAnswer) => { - const { lifecycle, events, interactor } = launchUrlSimulator( - async () => CONFIRMATION, - [beforeAnswer, afterAnswer], - ); - - const outcome = await lifecycle.openApplication(launchUrlInput()); - - expect(outcome.launchConfirmation).toBe('accepted'); - expect(outcome.timing.runnerDemand).toBe('required'); - expect(interactor.awaitAlert).toHaveBeenCalledOnce(); - expect(outcome.timing.postOpenObservation).toBe(afterAnswer.observation); - // A verdict the bridge could not produce keeps its typed failure, so the open still says why - // the app came back unreadable. - expect(outcome.timing.postOpenObservationFailure).toEqual( - afterAnswer.observation === 'probe-failed' ? afterAnswer.failure : undefined, - ); - expect(events).toEqual([ - 'release', - `open ${LAUNCH_URL}`, - 'observe probe-failed', - 'alert wait', - 'alert accept', - `observe ${afterAnswer.observation}`, - ]); - }, -); - -test('a launch URL held behind a confirmation for the session app is accepted and reported', async () => { - const { lifecycle, events, resolutions } = launchUrlSimulator( - async () => CONFIRMATION, - [UNOBSERVABLE, OBSERVABLE], - ); - - const outcome = await lifecycle.openApplication(launchUrlInput()); - - expect(outcome.launchConfirmation).toBe('accepted'); - // One resolution dispatches the open; one more serves both the alert read and the accept. - expect(resolutions()).toBe(2); - expect(outcome.timing.runnerDemand).toBe('required'); - expect(outcome.timing.postOpenObservation).toBe('observable'); - expect(events).toEqual([ - 'release', - `open ${LAUNCH_URL}`, - 'observe unobservable', - 'alert wait', - 'alert accept', - 'observe observable', - ]); -}); - -test('the reported settle spans both observations and the answer between them', async () => { - vi.useFakeTimers({ toFake: ['Date'] }); - try { - const { lifecycle } = launchUrlSimulator( - async () => { - vi.advanceTimersByTime(100); - return CONFIRMATION; - }, - [UNOBSERVABLE, OBSERVABLE], - { - owner: async () => { - vi.advanceTimersByTime(100); - return 'com.example.app'; - }, - }, - ); - - const outcome = await lifecycle.openApplication(launchUrlInput()); - - expect(outcome.launchConfirmation).toBe('accepted'); - expect(outcome.timing.postOpenSettleDurationMs).toBe(200); - } finally { - vi.useRealTimers(); - } -}); - -test.each([ - ['finds no alert', alertNotFound()], - [ - 'cannot reach the runner', - new AppError('COMMAND_FAILED', 'runner unavailable', { reason: 'runner-start-failed' }), - ], -])( - 'an unobservable launch whose alert read %s returns the open as it was', - async (_case, failure) => { - const { lifecycle, events } = launchUrlSimulator(async () => { - throw failure; - }, [UNOBSERVABLE]); - - const outcome = await lifecycle.openApplication(launchUrlInput()); - - expect(outcome.launchConfirmation).toBeUndefined(); - expect(outcome.timing.runnerDemand).toBe('required'); - expect(outcome.timing.postOpenObservation).toBe('unobservable'); - expect(events).toEqual(['release', `open ${LAUNCH_URL}`, 'observe unobservable', 'alert wait']); - }, -); - -test.each([ - [ - 'the runner interactor cannot be resolved', - { - resolveInteractor: async () => { - throw SPAWN_TIMEOUT; - }, - }, - ['release', `open ${LAUNCH_URL}`, 'observe unobservable'], - ], - [ - 'the URL owner lookup rejects', - { - owner: async () => { - throw SPAWN_TIMEOUT; - }, - }, - ['release', `open ${LAUNCH_URL}`, 'observe unobservable', 'alert wait'], - ], -])('an unobservable launch where %s returns the open as it was', async (_case, legs, expected) => { - const { lifecycle, events } = launchUrlSimulator(async () => CONFIRMATION, [UNOBSERVABLE], legs); - - const outcome = await lifecycle.openApplication(launchUrlInput()); - - expect(outcome.launchConfirmation).toBeUndefined(); - expect(outcome.timing.postOpenObservation).toBe('unobservable'); - expect(events).toEqual(expected); -}); - -test('an accept that dies with the runner session hands the URL over again and reads once more', async () => { - let accepts = 0; - const readAlert = vi - .fn<() => Promise>>() - .mockResolvedValueOnce(CONFIRMATION) - .mockRejectedValueOnce(SPAWN_TIMEOUT) - .mockResolvedValue(CONFIRMATION); - const { lifecycle, events } = launchUrlSimulator( - readAlert, - [UNOBSERVABLE, UNOBSERVABLE, OBSERVABLE], - { - acceptAlert: async () => { - accepts += 1; - if (accepts === 1) throw SPAWN_TIMEOUT; - return {}; - }, - }, - ); - - const outcome = await lifecycle.openApplication(launchUrlInput()); - - expect(outcome.launchConfirmation).toBe('accepted'); - expect(outcome.timing.postOpenObservation).toBe('observable'); - // The re-dispatch hands the URL as its own open target, the way the follow-up open does. - expect(events).toEqual([ - 'release', - `open ${LAUNCH_URL}`, - 'observe unobservable', - 'alert wait', - 'alert accept', - 'alert get', - 'observe unobservable', - 'open', - 'alert wait', - 'alert accept', - 'observe observable', - ]); -}); - -test.each([ - ['still coming up', COMING_UP], - ['observable', OBSERVABLE], - ['not running', UNOBSERVABLE], -])( - 'a failed accept rejects a persistent launch prompt while the app is %s', - async (_name, afterAnswer) => { - const { lifecycle, interactor, events } = launchUrlSimulator( - async () => CONFIRMATION, - [BRIDGE_CIRCUIT, afterAnswer], - { - acceptAlert: async () => { - throw new AppError('COMMAND_FAILED', 'alert accept exhausted its deadline', { - runnerErrorCode: 'ALERT_DEADLINE_EXCEEDED', - }); - }, - }, - ); - - await expect(lifecycle.openApplication(launchUrlInput())).rejects.toMatchObject({ - code: 'COMMAND_FAILED', - details: { reason: 'launch_confirmation_unanswered', appBundleId: 'com.example.app' }, - }); - expect(interactor.acceptAlert).toHaveBeenCalledOnce(); - expect(interactor.awaitAlert).toHaveBeenCalledOnce(); - expect(interactor.readAlert).toHaveBeenCalledOnce(); - expect(events.filter((event) => event === 'open' || event === `open ${LAUNCH_URL}`)).toEqual([ - `open ${LAUNCH_URL}`, - ]); - }, -); - -test.each([ - { - name: 'absent', - readAfterFailure: async () => { - throw alertNotFound(); - }, - }, - { - name: 'unreadable', - readAfterFailure: async () => { - throw SPAWN_TIMEOUT; - }, - }, - { - name: 'replaced by the same title with unrelated buttons', - readAfterFailure: async () => ({ message: CONFIRMATION.message, items: ['Allow', 'Deny'] }), - }, - { - name: 'replaced by another launch confirmation', - readAfterFailure: async () => ({ ...CONFIRMATION, message: 'Open in “Other App”?' }), - }, -])( - 'a failed accept with its prompt $name preserves launch-transition policy', - async ({ readAfterFailure }) => { - const readAlert = vi.fn(readAfterFailure).mockResolvedValueOnce(CONFIRMATION); - const { lifecycle, interactor, events } = launchUrlSimulator( - readAlert, - [BRIDGE_CIRCUIT, COMING_UP], - { - acceptAlert: async () => { - throw SPAWN_TIMEOUT; - }, - }, - ); - - const outcome = await lifecycle.openApplication(launchUrlInput()); - - expect(outcome.launchConfirmation).toBeUndefined(); - expect(outcome.timing.postOpenObservation).toBe('unobservable'); - expect(interactor.acceptAlert).toHaveBeenCalledOnce(); - expect(interactor.awaitAlert).toHaveBeenCalledOnce(); - expect(interactor.readAlert).toHaveBeenCalledOnce(); - expect(events.filter((event) => event === 'open' || event === `open ${LAUNCH_URL}`)).toEqual([ - `open ${LAUNCH_URL}`, - ]); - }, -); - -test('an accept leaving a launch-transition window open stays green and re-hands nothing', async () => { - const { lifecycle, events } = launchUrlSimulator( - async () => CONFIRMATION, - [UNOBSERVABLE, COMING_UP], - ); - - const outcome = await lifecycle.openApplication(launchUrlInput()); - - // The window proves only that the app was still coming up, which no second hand-off can fix. - expect(outcome.launchConfirmation).toBe('accepted'); - expect(outcome.timing.postOpenObservation).toBe('unobservable'); - expect(events).toEqual([ - 'release', - `open ${LAUNCH_URL}`, - 'observe unobservable', - 'alert wait', - 'alert accept', - 'observe unobservable', - ]); -}); - -test('a launch URL handed over twice that still leaves no process fails the open', async () => { - const readAlert = vi - .fn<() => Promise>>() - .mockResolvedValueOnce(CONFIRMATION) - .mockRejectedValueOnce(SPAWN_TIMEOUT) - .mockResolvedValueOnce(CONFIRMATION) - .mockRejectedValueOnce(SPAWN_TIMEOUT); - const { lifecycle, events } = launchUrlSimulator( - readAlert, - Array.from({ length: 3 }, () => UNOBSERVABLE), - { - acceptAlert: async () => { - throw SPAWN_TIMEOUT; - }, - }, - ); - - const failure = await lifecycle - .openApplication(launchUrlInput()) - .catch((error: unknown) => error); - - expect(failure).toBeInstanceOf(AppError); - expect((failure as AppError).details).toMatchObject({ - reason: 'launch_confirmation_unanswered', - appBundleId: 'com.example.app', - }); - expect(events.filter((event) => event === 'open' || event === `open ${LAUNCH_URL}`)).toEqual([ - `open ${LAUNCH_URL}`, - 'open', - ]); -}); - -test('a launch URL whose scheme another app owns fails the open without accepting it', async () => { - urlOwner.resolve = async () => 'com.example.other'; - const { lifecycle, interactor } = launchUrlSimulator(async () => CONFIRMATION, [UNOBSERVABLE]); - - const failure = await lifecycle - .openApplication(launchUrlInput()) - .catch((error: unknown) => error); - - expect(failure).toBeInstanceOf(AppError); - expect((failure as AppError).details).toMatchObject({ - reason: 'launch_confirmation_foreign_app', - foreignAppBundleId: 'com.example.other', - sessionAppBundleId: 'com.example.app', - }); - expect(interactor.acceptAlert).not.toHaveBeenCalled(); -}); - -test.each([ - ['no launch URL', undefined], - ['a web launch URL', 'https://example.com/automation'], -])('a Simulator open with %s never reads an alert', async (_name, runtimeLaunchUrl) => { - const { lifecycle, interactor } = launchUrlSimulator(async () => ({}), [UNOBSERVABLE]); - - const outcome = await lifecycle.openApplication({ ...launchUrlInput(), runtimeLaunchUrl }); - - expect(outcome.launchConfirmation).toBeUndefined(); - expect(outcome.timing.runnerDemand).toBe('none'); - expect(interactor.awaitAlert).not.toHaveBeenCalled(); -}); - -test('a physical iOS launch URL never reads an alert', async () => { - const { interactor } = launchUrlSimulator(async () => ({}), [UNOBSERVABLE]); - const lifecycle = bindAppleApplicationLifecycle({ - host: { - ...platformRuntimeHostFixture(), - localInteractors: { resolve: async () => interactor }, - } as unknown as PlatformRuntimeHost, - device, - signal: new AbortController().signal, - }); - - const outcome = await lifecycle.openApplication({ ...launchUrlInput(), relaunch: false }); - - expect(outcome.launchConfirmation).toBeUndefined(); - expect(interactor.awaitAlert).not.toHaveBeenCalled(); -}); diff --git a/packages/platform-apple/src/lifecycle.ts b/packages/platform-apple/src/lifecycle.ts index 5b1896bea1..79c4fb7177 100644 --- a/packages/platform-apple/src/lifecycle.ts +++ b/packages/platform-apple/src/lifecycle.ts @@ -165,7 +165,7 @@ async function openAppleApplication( runnerTargetPredatesOpen, retainRunnerForRelaunch, ); - const launchConfirmation = await settleAppleOpen( + const launchConfirmationReport = await settleAppleOpen( host, binding, input, @@ -180,7 +180,7 @@ async function openAppleApplication( return { appBundleId: input.appBundleId, timing, - ...(launchConfirmation ? { launchConfirmation } : {}), + ...(launchConfirmationReport ?? {}), }; } catch (error) { if (retainRunnerForRelaunch) { diff --git a/packages/platform-apple/src/open-policy.test.ts b/packages/platform-apple/src/open-policy.test.ts new file mode 100644 index 0000000000..73f4cf8429 --- /dev/null +++ b/packages/platform-apple/src/open-policy.test.ts @@ -0,0 +1,112 @@ +import { expect, test } from 'vitest'; +import type { + LaunchConfirmationOutcome, + LaunchConfirmationProbeFailure, +} from '@agent-device/contracts/application-lifecycle-runtime'; +import type { LaunchConfirmationAttempt } from './launch-confirmation.ts'; +import { applyAttemptReport } from './open-policy.ts'; + +type Report = Readonly<{ + report?: LaunchConfirmationOutcome; + probeFailure?: LaunchConfirmationProbeFailure; +}>; + +/** + * The mapping's cases at both ends: every attempt kind against a fresh settle, and attempts that + * follow a settle already holding a report or an unknown state. `applyAttemptReport` owns the + * rule and its url-owner exception; each expected row here restates only its own case. + */ +const freshCases: ReadonlyArray> = [ + ['accepted', { outcome: 'accepted' }, { report: { launchConfirmation: 'accepted' } }], + ['absent', { outcome: 'absent' }, {}], + [ + 'an unrecognized prompt', + { outcome: 'unanswered', reason: 'alert-unrecognized' }, + { + report: { + launchConfirmation: 'unanswered', + launchConfirmationReason: 'alert-unrecognized', + }, + }, + ], + [ + 'an unresolved URL owner', + { outcome: 'unanswered', reason: 'url-owner-unresolved' }, + { + report: { + launchConfirmation: 'unanswered', + launchConfirmationReason: 'url-owner-unresolved', + }, + }, + ], + [ + 'a persistent prompt after a failed accept', + { outcome: 'unanswered', reason: 'alert-still-present' }, + // The failed-accept path fails the open outright; nothing to report alongside a throw. + {}, + ], + [ + 'a failed alert read', + { outcome: 'unreadable', step: 'alert-read' }, + { probeFailure: 'alert-read' }, + ], + [ + 'a failed accept', + { outcome: 'unreadable', step: 'alert-accept' }, + { probeFailure: 'alert-accept' }, + ], + ['an unresolvable runner', { outcome: 'unreadable', step: 'runner' }, { probeFailure: 'runner' }], + [ + 'a failed URL-owner lookup', + { outcome: 'unreadable', step: 'url-owner' }, + { + report: { + launchConfirmation: 'unanswered', + launchConfirmationReason: 'url-owner-unresolved', + }, + }, + ], +]; + +test.each(freshCases)( + 'a fresh settle where the attempt %s reports %j', + (_name, attempt, expected) => { + expect(applyAttemptReport({}, attempt)).toEqual(expected); + }, +); + +const heldCases: ReadonlyArray> = [ + [ + 'an accepted confirmation', + { report: { launchConfirmation: 'accepted' } }, + { outcome: 'unreadable', step: 'alert-read' }, + // The latest probe state wins: the accepted must not hide an unknown final state. + { probeFailure: 'alert-read' }, + ], + [ + 'an accepted confirmation', + { report: { launchConfirmation: 'accepted' } }, + { outcome: 'absent' }, + { report: { launchConfirmation: 'accepted' } }, + ], + [ + 'a probe failure', + { probeFailure: 'alert-read' }, + { outcome: 'absent' }, + // A completed probe that finds nothing resolves the unknown state. + {}, + ], + [ + 'a probe failure', + { probeFailure: 'alert-read' }, + { outcome: 'unreadable', step: 'runner' }, + { probeFailure: 'runner' }, + ], +]; + +test.each(heldCases)( + 'a settle holding %s where the next attempt is %j reports %j', + (_held, previous, attempt, expected) => { + expect(applyAttemptReport(previous, attempt)).toEqual(expected); + }, +); diff --git a/packages/platform-apple/src/open-policy.ts b/packages/platform-apple/src/open-policy.ts index df48ceef3c..575b46aacb 100644 --- a/packages/platform-apple/src/open-policy.ts +++ b/packages/platform-apple/src/open-policy.ts @@ -1,5 +1,7 @@ import type { - LaunchConfirmation, + LaunchConfirmationAbsent, + LaunchConfirmationOutcome, + LaunchConfirmationProbeFailure, OpenApplicationInput, OpenApplicationOutcome, OpenApplicationRunnerDemand, @@ -31,14 +33,17 @@ const LAUNCH_CONFIRMATION_ROUNDS = 2; type HeldLaunchOutcome = Readonly<{ observed: LaunchObservation | undefined; - launchConfirmation?: LaunchConfirmation; + report?: LaunchConfirmationOutcome; + /** The probe step that failed or went unverified when no report exists; the state is unknown. */ + probeFailure?: LaunchConfirmationProbeFailure; /** The launch has no process, or its recognized confirmation remains visible. */ unanswered: boolean; }>; type HeldLaunchRound = Readonly<{ observed: LaunchObservation | undefined; - launchConfirmation?: LaunchConfirmation; + report?: LaunchConfirmationOutcome; + probeFailure?: LaunchConfirmationProbeFailure; /** Whether an accept has run or been attempted; only an accept can change what the bridge sees. */ acceptAttempted: boolean; confirmationStillPresent: boolean; @@ -119,9 +124,13 @@ export function releaseSpeculativeRunner( * held URL, and the URL is handed over again and read once more. A launch still proven not running * afterwards fails the open with `launch_confirmation_unanswered` rather than returning green to a * session whose every later command then fails `app is not running`. A recognized prompt still - * visible after a failed accept also fails the open, even if the app is running. A missing or - * unrecognized prompt, or an unresolved URL owner, leaves the launch unchanged. An unresolved owner - * spends both the 3-second presentation window and the bounded URL-owner lookup. + * visible after a failed accept also fails the open, even if the app is running. A missing prompt + * leaves the launch unchanged; an unrecognized prompt or an unresolved URL owner leaves it + * unchanged and reports `launchConfirmation: 'unanswered'` with its typed reason, because the + * prompt is on the screen the caller still has to answer. A step that failed or went unverified + * settles to the unknown prompt state, which the daemon turns into a check-with-`alert get` + * warning; `applyAttemptReport` owns that reporting rule and its one lookup exception. An + * unresolved owner spends both the 3-second presentation window and the bounded URL-owner lookup. */ export async function settleAppleOpen( host: Pick, @@ -134,12 +143,13 @@ export async function settleAppleOpen( redispatchLaunchUrl?: () => Promise; }>, timing: MutableOpenTiming, -): Promise { +): Promise { const startedAtMs = Date.now(); const observe = async () => await observeLaunchedApp(binding, input, localIosSimulator, launch.observation); let observed = await observe(); - let launchConfirmation: LaunchConfirmation | undefined; + let report: LaunchConfirmationOutcome | undefined; + let probeFailure: LaunchConfirmationProbeFailure | undefined; const answer = launch.answerConfirmation; const held = answer && observed?.observation !== 'observable' @@ -147,7 +157,8 @@ export async function settleAppleOpen( : undefined; if (held) { timing.runnerDemand = 'required'; - launchConfirmation = held.launchConfirmation; + report = held.report; + probeFailure = held.probeFailure; observed = held.observed; if (held.unanswered) { throw new AppError( @@ -171,7 +182,10 @@ export async function settleAppleOpen( } } timing.postOpenSettleDurationMs = Math.max(0, Date.now() - startedAtMs); - return launchConfirmation; + // `applyAttemptReport` keeps the two arms exclusive, so this picks the final state directly. + if (report) return report; + if (probeFailure) return { launchConfirmationProbeFailure: probeFailure }; + return undefined; } /** @@ -212,7 +226,8 @@ async function answerHeldLaunch( } return { observed: round.observed, - launchConfirmation: round.launchConfirmation, + report: round.report, + probeFailure: round.probeFailure, unanswered: acceptLeftTheUrlInFlight(round) || round.confirmationStillPresent, }; } @@ -226,26 +241,88 @@ async function answerConfirmationOnce( const confirmationStillPresent = attempt.outcome === 'unanswered' && attempt.reason === 'alert-still-present'; const acceptAttempted = round.acceptAttempted || acceptWasAttempted(attempt); - const launchConfirmation = - attempt.outcome === 'accepted' ? ('accepted' as const) : round.launchConfirmation; + const reported = applyAttemptReport(round, attempt); if (!acceptAttempted) { - // Only an accept changes the device, so a read that found no prompt, an unrecognized prompt or - // an unresolved URL owner leaves the launch as the verdict before it already described it. + // Only an accept changes the device, so a read that found no prompt, an unrecognized prompt, an + // unresolved URL owner or a failed URL-owner lookup leaves the launch as the verdict before it + // already described it. return { observed: round.observed, - launchConfirmation, + ...reported, acceptAttempted, confirmationStillPresent, }; } return { observed: await observe(), - launchConfirmation, + ...reported, acceptAttempted, confirmationStillPresent, }; } +/** + * What one attempt itself shows the settle: a confirmation it saw, the step whose failure left the + * prompt state unknown, or nothing. A failed URL-owner lookup shows `url-owner-unresolved`: the + * owner was never resolved, which is what the caller has to know. + */ +type AttemptSettlement = + | Readonly<{ seen: LaunchConfirmationOutcome }> + | Readonly<{ probeFailure: LaunchConfirmationProbeFailure }> + | Readonly>; + +function settleAttempt(attempt: LaunchConfirmationAttempt): AttemptSettlement { + switch (attempt.outcome) { + case 'accepted': + return { seen: { launchConfirmation: 'accepted' } }; + case 'absent': + return {}; + case 'unreadable': + if (attempt.step === 'url-owner') { + return { + seen: { + launchConfirmation: 'unanswered', + launchConfirmationReason: 'url-owner-unresolved', + }, + }; + } + return { probeFailure: attempt.step }; + case 'unanswered': + if (attempt.reason === 'alert-still-present') return {}; + return { + seen: { launchConfirmation: 'unanswered', launchConfirmationReason: attempt.reason }, + }; + } +} + +/** + * The one rule mapping an answer attempt to what the settle reports. Every attempt states the + * latest the probe knows: a seen confirmation replaces the report, a step that failed or went + * unverified (`alert-read`, `alert-accept` or `runner`) replaces it with the unknown state, and a + * completed probe that saw nothing resolves the unknown and keeps any confirmation already seen — + * an accepted is a completed action the caller owns. The one exception to the failed-step rule is + * `url-owner`: that lookup ran and answered nothing usable, so it reports the seen outcome + * `url-owner-unresolved`. An `alert-still-present` attempt belongs to the failed-accept path, + * which fails the open outright. + */ +export function applyAttemptReport( + previous: Readonly<{ + report?: LaunchConfirmationOutcome; + probeFailure?: LaunchConfirmationProbeFailure; + }>, + attempt: LaunchConfirmationAttempt, +): Readonly<{ + report?: LaunchConfirmationOutcome; + probeFailure?: LaunchConfirmationProbeFailure; +}> { + const settled = settleAttempt(attempt); + if ('seen' in settled) return { report: settled.seen, probeFailure: undefined }; + if ('probeFailure' in settled) { + return { report: undefined, probeFailure: settled.probeFailure }; + } + return { report: previous.report, probeFailure: undefined }; +} + /** * An app proven to have no process after an accept is a launch this open still owns the URL for: * the hand-off or the answer did not land, so handing it over again is what is left. diff --git a/src/__tests__/agent-device-client-apps.test.ts b/src/__tests__/agent-device-client-apps.test.ts index 54646e87d5..151b02821c 100644 --- a/src/__tests__/agent-device-client-apps.test.ts +++ b/src/__tests__/agent-device-client-apps.test.ts @@ -92,11 +92,17 @@ test('apps.open resolves an open response that names no host device or paths', a test('apps.open reports an answered launch confirmation only when the daemon answered one', async () => { let launchConfirmation: unknown = 'accepted'; + let strayReason: unknown = undefined; const setup = createTransport(async (req) => { if (req.command === 'open') { return { ok: true, - data: { session: 'qa', appBundleId: 'com.example.app', launchConfirmation }, + data: { + session: 'qa', + appBundleId: 'com.example.app', + launchConfirmation, + ...(strayReason === undefined ? {} : { launchConfirmationReason: strayReason }), + }, }; } throw new Error(`Unexpected command: ${req.command}`); @@ -104,11 +110,110 @@ test('apps.open reports an answered launch confirmation only when the daemon ans const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); const answered = await client.apps.open({ app: 'com.example.app', platform: 'ios' }); + strayReason = 'url-owner-unresolved'; + const acceptedWithStrayReason = await client.apps.open({ + app: 'com.example.app', + platform: 'ios', + }); launchConfirmation = undefined; - const unanswered = await client.apps.open({ app: 'com.example.app', platform: 'ios' }); + strayReason = undefined; + const noConfirmation = await client.apps.open({ app: 'com.example.app', platform: 'ios' }); assert.equal(answered.launchConfirmation, 'accepted'); - assert.equal('launchConfirmation' in unanswered, false); + assert.equal('launchConfirmationReason' in acceptedWithStrayReason, false); + assert.equal('launchConfirmation' in noConfirmation, false); +}); + +test('apps.open reports an unanswered launch confirmation with its typed reason', async () => { + const cases = [ + { + reason: 'alert-unrecognized', + warning: 'iOS shows an alert this open did not recognize (alert-unrecognized)', + }, + { + reason: 'url-owner-unresolved', + warning: 'the installed app that owns the launch URL\u2019s scheme could not be resolved', + }, + ] as const; + for (const { reason, warning } of cases) { + const setup = createTransport(async (req) => { + if (req.command === 'open') { + return { + ok: true, + data: { + session: 'qa', + appBundleId: 'com.example.app', + warnings: [warning], + launchConfirmation: 'unanswered', + launchConfirmationReason: reason, + }, + }; + } + throw new Error(`Unexpected command: ${req.command}`); + }); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + const result = await client.apps.open({ app: 'com.example.app', platform: 'ios' }); + + assert.equal(result.launchConfirmation, 'unanswered'); + assert.equal(result.launchConfirmationReason, reason); + assert.deepEqual(result.warnings, [warning]); + } +}); + +test('apps.open drops an unrecognized launch confirmation outcome instead of guessing', async () => { + const setup = createTransport(async (req) => { + if (req.command === 'open') { + return { + ok: true, + data: { + session: 'qa', + appBundleId: 'com.example.app', + launchConfirmation: 'maybe', + launchConfirmationReason: 'alert-unrecognized', + }, + }; + } + throw new Error(`Unexpected command: ${req.command}`); + }); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + const result = await client.apps.open({ app: 'com.example.app', platform: 'ios' }); + + assert.equal('launchConfirmation' in result, false); + assert.equal('launchConfirmationReason' in result, false); +}); + +test('apps.open reports a typed probe-failure step and drops an unrecognized one', async () => { + const responses = [ + { step: 'alert-accept', reported: true }, + { step: 'teleporter', reported: false }, + ] as const; + for (const { step, reported } of responses) { + const setup = createTransport(async (req) => { + if (req.command === 'open') { + return { + ok: true, + data: { + session: 'qa', + appBundleId: 'com.example.app', + launchConfirmationProbeFailure: step, + }, + }; + } + throw new Error(`Unexpected command: ${req.command}`); + }); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + const result = await client.apps.open({ app: 'com.example.app', platform: 'ios' }); + + assert.equal( + 'launchConfirmationProbeFailure' in result, + reported, + `${step} must ${reported ? '' : 'not '}reach the caller`, + ); + assert.equal('launchConfirmation' in result, false); + } }); test('apps.open preserves the full initialSnapshotError shape through client normalization', async () => { diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index 421b6ddc9f..c2bba2a284 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -39,6 +39,7 @@ import { normalizeDeviceSelection, normalizeOpenForegroundComposition, normalizeInstallFromSourceResult, + normalizeLaunchConfirmation, normalizeMaterializationReleaseResult, normalizeOpenDevice, normalizeRuntimeHints, @@ -307,9 +308,7 @@ export function createAgentDeviceClient( startup: normalizeStartupSample(data.startup), runtime: normalizeRuntimeHints(data.runtime), device, - ...(data.launchConfirmation === 'accepted' - ? { launchConfirmation: 'accepted' as const } - : {}), + ...normalizeLaunchConfirmation(data), ...normalizeOpenForegroundComposition(data), identifiers: { session, diff --git a/src/client/client-normalizers.ts b/src/client/client-normalizers.ts index 08f067b561..13ce0cf798 100644 --- a/src/client/client-normalizers.ts +++ b/src/client/client-normalizers.ts @@ -1,4 +1,11 @@ import type { DaemonWireRequest, InternalRequestOptions } from '@agent-device/contracts/command'; +import type { + LaunchConfirmation, + LaunchConfirmationAbsent, + LaunchConfirmationOutcome, + LaunchConfirmationProbeFailure, + LaunchConfirmationUnansweredReason, +} from '@agent-device/contracts/application-lifecycle-runtime'; import type { AgentDeviceDevice, AgentDeviceSession, @@ -379,6 +386,60 @@ export function normalizeOpenForegroundComposition(data: Record }; } +/** + * The launch confirmation an open response reports, keyed on the typed outcome. The daemon pairs + * an `unanswered` outcome with its reason, so an outcome or reason it does not recognize is dropped + * rather than guessed, as is a probe step outside the typed set; warnings travel separately and + * still reach the caller. With no outcome, the only report left is the typed probe-failure step. + */ +export function normalizeLaunchConfirmation( + data: Record, +): LaunchConfirmationOutcome | LaunchConfirmationAbsent { + const outcome = readLaunchConfirmationOutcome(data.launchConfirmation); + if (outcome === 'accepted') return { launchConfirmation: 'accepted' }; + if (outcome === 'unanswered') { + const reason = readLaunchConfirmationReason(data.launchConfirmationReason); + return reason ? { launchConfirmation: 'unanswered', launchConfirmationReason: reason } : {}; + } + const probeFailure = readLaunchConfirmationProbeFailure(data.launchConfirmationProbeFailure); + return probeFailure ? { launchConfirmationProbeFailure: probeFailure } : {}; +} + +function readLaunchConfirmationOutcome(value: unknown): LaunchConfirmation | undefined { + switch (value) { + case 'accepted': + case 'unanswered': + return value; + default: + return undefined; + } +} + +function readLaunchConfirmationReason( + value: unknown, +): LaunchConfirmationUnansweredReason | undefined { + switch (value) { + case 'alert-unrecognized': + case 'url-owner-unresolved': + return value; + default: + return undefined; + } +} + +function readLaunchConfirmationProbeFailure( + value: unknown, +): LaunchConfirmationProbeFailure | undefined { + switch (value) { + case 'alert-read': + case 'alert-accept': + case 'runner': + return value; + default: + return undefined; + } +} + export function readSnapshotNodes(value: unknown): SnapshotNode[] { // Snapshot nodes are produced by the daemon snapshot pipeline and treated as trusted here. return Array.isArray(value) ? (value as SnapshotNode[]) : []; diff --git a/src/commands/management/app.ts b/src/commands/management/app.ts index 17f4e9fbce..f08abd7a3d 100644 --- a/src/commands/management/app.ts +++ b/src/commands/management/app.ts @@ -197,7 +197,7 @@ export const openCommandFacet = defineCommandFacet({ text: { summary: 'Open an app, deep link or URL, save replays', cliDetail: - 'Use --platform to bind URL/deep-link opens to the target platform. For iOS simulator initial stdout/stderr, put --launch-console on this open command, for example agent-device open "Agent Device Tester" --platform ios --launch-console artifacts/launch-console.log. Expo Go/dev-client shells accept host + URL, for example agent-device open "Expo Go" exp://127.0.0.1:8081 --platform ios. macOS also supports --surface app|frontmost-app|desktop|menubar. --metro-host/--metro-port/--bundle-url choose the app\'s Metro server and save that address for later metro reload calls in the same session. A fresh open without Metro hints clears an older same-name binding; close also clears it. --launch-url supplies the app\'s launch URL; it does not select the reload server. On an iOS simulator without --launch-url, open connects a detected Expo development client through its expo-development-client URL. The app must declare a single exp+ scheme that no other installed app owns. If detection is inconclusive, the app opens normally. Bare React Native apps receive their server settings before launch. On an iOS simulator, when --launch-url raises an "Open in ?" prompt that keeps open from seeing the app, open accepts it if the URL scheme belongs to the session app (data.launchConfirmation: "accepted"); if the scheme belongs to another installed app, the prompt stays on screen and open fails with details.reason launch_confirmation_foreign_app; if no single installed app owns the scheme, the prompt stays on screen and open returns as usual. The prompt is read whenever open cannot see the app afterwards, including on a slow host where the app\'s target discovery fails on its own deadline. When an accept leaves the app provably not running, open hands the same URL over once more and reads again; still not running, it fails with details.reason launch_confirmation_unanswered. A recognized prompt still visible after a failed accept also fails with this reason, even if the app is running behind it; answer it with alert accept or alert dismiss. A simulator openurl that CoreSimulator never answers fails within 20s with details.reason ios-simulator-openurl-timeout. Only the English prompt title with Cancel and Open buttons is recognized, so on a Simulator set to another language the prompt stays on screen. A prompt that appears after open has already seen the app is not answered; use alert accept. A device another session is holding refuses at once; add --wait to block up to that budget for it and only then fail with DEVICE_IN_USE naming the owning session.', + 'Use --platform to bind URL/deep-link opens to the target platform. For iOS simulator initial stdout/stderr, put --launch-console on this open command, for example agent-device open "Agent Device Tester" --platform ios --launch-console artifacts/launch-console.log. Expo Go/dev-client shells accept host + URL, for example agent-device open "Expo Go" exp://127.0.0.1:8081 --platform ios. macOS also supports --surface app|frontmost-app|desktop|menubar. --metro-host/--metro-port/--bundle-url choose the app\'s Metro server and save that address for later metro reload calls in the same session. A fresh open without Metro hints clears an older same-name binding; close also clears it. --launch-url supplies the app\'s launch URL; it does not select the reload server. On an iOS simulator without --launch-url, open connects a detected Expo development client through its expo-development-client URL. The app must declare a single exp+ scheme that no other installed app owns. If detection is inconclusive, the app opens normally. Bare React Native apps receive their server settings before launch. On an iOS simulator, when --launch-url raises an "Open in ?" prompt that keeps open from seeing the app, open accepts it if the URL scheme belongs to the session app (data.launchConfirmation: "accepted"); if the scheme belongs to another installed app, the prompt stays on screen and open fails with details.reason launch_confirmation_foreign_app; if the scheme owner cannot be resolved because no single installed app owns it or the lookup failed, the prompt stays on screen and open returns with data.launchConfirmation: "unanswered", launchConfirmationReason url-owner-unresolved, and a warning; a prompt that is seen but not recognized is reported the same way with launchConfirmationReason alert-unrecognized and its own warning; in both cases inspect the warning and answer with alert get then alert accept; when agent-device cannot read whether a prompt is up at all (the alert read, the accept, or the runner itself failed, including after a re-hand of the URL), open stays green, reports no launchConfirmation plus data.launchConfirmationProbeFailure naming the failed step (alert-read, alert-accept, or runner), and appends a warning that says to check with alert get. The prompt is read whenever open cannot see the app afterwards, including on a slow host where the app\'s target discovery fails on its own deadline. When an accept leaves the app provably not running, open hands the same URL over once more and reads again; still not running, it fails with details.reason launch_confirmation_unanswered. A recognized prompt still visible after a failed accept also fails with this reason, even if the app is running behind it; answer it with alert accept or alert dismiss. A simulator openurl that CoreSimulator never answers fails within 20s with details.reason ios-simulator-openurl-timeout. Only the English prompt title with Cancel and Open buttons is recognized, so on a Simulator set to another language the prompt stays on screen. A prompt that appears after open has already seen the app is not answered; use alert accept. A device another session is holding refuses at once; add --wait to block up to that budget for it and only then fail with DEVICE_IN_USE naming the owning session.', mcpDetail: "metroHost, metroPort, and bundleUrl choose the app's Metro server and save its address for later reloads. launchUrl selects the app's launch URL and does not select the reload server. A fresh open without Metro hints clears an older same-name binding; close also clears it.", }, diff --git a/src/commands/output/result-serialization.test.ts b/src/commands/output/result-serialization.test.ts index 911a07310d..cac3bc46bf 100644 --- a/src/commands/output/result-serialization.test.ts +++ b/src/commands/output/result-serialization.test.ts @@ -91,6 +91,32 @@ test('serializeOpenResult carries an answered launch confirmation', () => { assert.equal(data.launchConfirmation, 'accepted'); }); +test('serializeOpenResult carries an unanswered launch confirmation with its reason', () => { + const data = serializeOpenResult({ + session: 'qa', + appBundleId: 'com.example.app', + launchConfirmation: 'unanswered', + launchConfirmationReason: 'alert-unrecognized', + identifiers: { session: 'qa', appId: 'com.example.app', appBundleId: 'com.example.app' }, + }); + + assert.equal(data.launchConfirmation, 'unanswered'); + assert.equal(data.launchConfirmationReason, 'alert-unrecognized'); +}); + +test('serializeOpenResult carries a probe-failure step without any confirmation outcome', () => { + const data = serializeOpenResult({ + session: 'qa', + appBundleId: 'com.example.app', + launchConfirmationProbeFailure: 'runner', + identifiers: { session: 'qa', appId: 'com.example.app', appBundleId: 'com.example.app' }, + }); + + assert.equal(data.launchConfirmationProbeFailure, 'runner'); + assert.equal('launchConfirmation' in data, false); + assert.equal('launchConfirmationReason' in data, false); +}); + test('serializeInstallFromSourceResult uses install-family package naming', () => { const data = serializeInstallFromSourceResult({ launchTarget: 'com.example.demo', diff --git a/src/commands/output/result-serialization.ts b/src/commands/output/result-serialization.ts index 27c7e2316e..1f388411e6 100644 --- a/src/commands/output/result-serialization.ts +++ b/src/commands/output/result-serialization.ts @@ -144,6 +144,12 @@ export function serializeOpenResult(result: AppOpenResult): Record --launch-url ` raises an `Open in ""?` prompt and the URL's scheme belongs to the session app, `open` taps **Open** and returns `launchConfirmation: "accepted"`. If the app still never starts, `open` hands over the URL once more. +- When that prompt stays unanswered, `open` returns `launchConfirmation: "unanswered"` with `launchConfirmationReason`: `alert-unrecognized` for a prompt it did not recognize (such as a non-English Simulator), or `url-owner-unresolved` when no single installed app owns the URL's scheme or that lookup failed. The prompt stays on screen; `warnings` name the next step. Inspect with `alert get`, then answer with `alert accept`. +- When `open` could not read whether a prompt is up at all (the alert read, the accept, or the runner itself failed), `open` succeeds with `launchConfirmationProbeFailure` set to `alert-read`, `alert-accept`, or `runner` and no `launchConfirmation`, plus a warning to check with `alert get` before retrying. - `details.reason: launch_confirmation_unanswered` means the app never started after the prompt was accepted, or the prompt stayed visible after a failed tap. Answer it with `alert accept` or `alert dismiss` before continuing. - `details.reason: launch_confirmation_foreign_app` means another installed app owns the scheme (`details.foreignAppBundleId`). Close the prompt with `alert dismiss` and pass a URL your app handles. -- `open` leaves the prompt on screen and returns as if none appeared when the Simulator isn't set to English (run with `--debug` to see the title), when the prompt appears after `open` has seen your app, or when no single installed app owns the scheme (none does, a debug and a release build both do, or the app list can't be read). Answer it with `alert accept` or `alert dismiss`. +- A prompt that appears after `open` has already seen the app is not reported and not answered; use `alert accept`. Back, orientation, and hardware buttons: