Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- On an iOS simulator, `open <app> --launch-url <url>` now reports every launch-confirmation state instead of only the answered one. When the runner reads the `Open in "<App>"?` 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 <app>` 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
41 changes: 35 additions & 6 deletions packages/contracts/src/application-lifecycle-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<App>"?` 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 "<App>"?` 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';
Comment thread
thymikee marked this conversation as resolved.

/** 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;
Comment thread
thymikee marked this conversation as resolved.
}>;

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;
Expand Down
17 changes: 10 additions & 7 deletions packages/contracts/src/client-app.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -110,11 +118,6 @@ export type AppOpenResult = {
runtime?: SessionRuntimeHints;
selection?: DeviceSelectionMetadata;
device?: AgentDeviceSessionDevice;
/**
* `accepted` when iOS held the launch URL behind an `Open in "<App>"?` 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
},
},
};
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -93,6 +97,7 @@ async function openRelaunch(params: {
positionals?: string[];
flags?: Record<string, unknown>;
onOpenApplication?: (input: OpenApplicationInput) => Promise<void> | void;
openOutcome?: LaunchConfirmationOutcome | LaunchConfirmationAbsent;
}) {
const opened: OpenApplicationInput[] = [];
const boundDevices: DeviceInfo[] = [];
Expand All @@ -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 };
},
},
};
Expand Down Expand Up @@ -235,6 +240,61 @@ test('open <app> <url> --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);
Expand Down
Loading
Loading