Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,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
54 changes: 48 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,60 @@ 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 carries this step on the response beside a warning, 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;
}>;

/**
* What a client reports about a launch confirmation. The platform always pairs an `unanswered`
* outcome with its reason; a version-skewed daemon may not, so this adds the arm where only the
* outcome survives and the unknown reason is omitted.
*/
export type LaunchConfirmationClientOutcome =
| LaunchConfirmationOutcome
| Readonly<{
launchConfirmation: 'unanswered';
launchConfirmationReason?: undefined;
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
19 changes: 12 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,
LaunchConfirmationClientOutcome,
} 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,14 @@ 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. The extra arm
// covers a daemon whose reason this client does not recognize, which answers `unanswered`
// without one.
(LaunchConfirmationClientOutcome | LaunchConfirmationAbsent);

type AppOpenResultBase = {
session: string;
warnings?: string[];
sessionStateDir?: string;
Expand All @@ -110,11 +120,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 @@ -445,10 +445,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,
bindTestCommandHost,
Expand Down Expand Up @@ -99,6 +103,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 @@ -115,7 +120,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 @@ -242,6 +247,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