diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index f4deb5579..905bcc805 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -214,6 +214,7 @@ jobs: -only-testing:AgentDeviceRunnerUITests/RunnerTests/testPrivateAXInteractiveFiltersLoginLikeHiddenDrawer \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testDecodedPreferredBackendReachesOptionsAndApplicablePlan \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testSparsePayloadReasonMatrix \ + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testStampedPayloadTruncationTracksCompletenessNotRecoveryProvenance \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testRunnerScreenshotStabilitySettledNeedsEnoughSamples \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testRunnerScreenshotStabilitySettledTrueWhenWindowMatches \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testRunnerScreenshotStabilitySettledFalseOnMidWindowMismatch \ diff --git a/CHANGELOG.md b/CHANGELOG.md index b545aafb5..22b1f9514 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +- Fixed: iOS snapshots no longer report `truncated: true` merely because a later backend produced + them. The runner stamped every recovered capture as truncated — including a complete private-AX + tree taken while the XCTest channel was penalized as slow — so a strict `is absent` / `wait absent` + refused it with "capture was truncated" on loaded CI hosts. `truncated` now tracks completeness + only: payload truncation, a depth-limited capture, or a sparse terminal payload. +- Fixed: Android `alert accept` / `alert dismiss` return only once the dialog has left the + accessibility tree (a different alert taking its place counts as dismissed), matching the iOS + runner's re-check. Previously they returned right after the button press, so the next read could + still see only the dialog window. A dialog that stays visible past the action budget now fails with + `alert did not dismiss the visible alert`. - Added strict `wait absent [timeoutMs]` polling for zero selector matches. Incomplete, sparse, truncated, scoped, depth-limited, and Android unreadable captures cannot prove absence; deadline diagnostics retain typed capture evidence and stable first-match details (#2236). diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift index 05a9a3c2c..7797b76bb 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift @@ -645,7 +645,11 @@ extension RunnerTests { // Legacy human text for older daemons that read message instead of snapshotQuality. message: Self.legacyQualityMessage(quality) ?? payload.message, nodes: payload.nodes, - truncated: payload.truncated == true || state != "healthy" || capture.effectiveDepth != nil, + // Completeness, never provenance: a whole tree that a later backend produced (state + // "recovered") stays untruncated, so strict absence reads can trust it. Only a real cap + // (payload truncation, a depth-limited private AX capture) or a sparse terminal payload + // is truncated. + truncated: payload.truncated == true || state == "sparse" || capture.effectiveDepth != nil, qualityPayload: capture.qualityPayload.flatMap { quality in guard let nodes = quality.nodes else { return nil } return SnapshotQualityPayload(nodes: nodes, truncated: quality.truncated == true) @@ -886,6 +890,46 @@ extension RunnerTests { XCTAssertEqual(payload.nodes?.count, 1) } + func testStampedPayloadTruncationTracksCompletenessNotRecoveryProvenance() { + let complete = SnapshotBackendCapture( + payload: DataPayload( + nodes: [ + planTestNode(index: 0, type: "Application", label: "App"), + planTestNode(index: 1, type: "Button", label: "Open", parentIndex: 0), + ], + truncated: false + ), + effectiveDepth: nil + ) + let deferred: (reason: String, code: String) = ( + "XCTest-backed snapshot tiers were deferred after recent slow accessibility work", "deferred" + ) + + // The CI signature behind `is absent ... capture was truncated`: a complete private AX + // tree selected while the XCTest channel is penalized is whole, and must say so. + let recovered = stampedSnapshotPayload( + complete, backend: .privateAX, state: "recovered", reason: deferred) + XCTAssertEqual(recovered.snapshotQuality?.state, "recovered") + XCTAssertEqual(recovered.truncated, false) + + let depthLimited = stampedSnapshotPayload( + SnapshotBackendCapture(payload: complete.payload, effectiveDepth: 56), + backend: .privateAX, state: "recovered", reason: deferred) + XCTAssertEqual(depthLimited.truncated, true) + + let cappedPayload = stampedSnapshotPayload( + SnapshotBackendCapture( + payload: DataPayload(nodes: complete.payload.nodes ?? [], truncated: true), + effectiveDepth: nil), + backend: .recursiveTree, state: "healthy", reason: nil) + XCTAssertEqual(cappedPayload.truncated, true) + + let sparse = stampedSnapshotPayload( + complete, backend: .querySweep, state: "sparse", + reason: ("snapshot returned no semantic controls or content", "sparse-tree")) + XCTAssertEqual(sparse.truncated, true) + } + func testSnapshotQualityCarriesUnscopedQualityPayload() { let quality = DataPayload( nodes: [planTestNode(index: 0, type: "Application", label: "App")], diff --git a/packages/platform-android/src/__tests__/alert.test.ts b/packages/platform-android/src/__tests__/alert.test.ts index 2b7af0672..3c38f315b 100644 --- a/packages/platform-android/src/__tests__/alert.test.ts +++ b/packages/platform-android/src/__tests__/alert.test.ts @@ -9,9 +9,22 @@ const runAndroidAdb = vi.fn(async (_device: DeviceInfo, _args: string[]) => ({ stderr: '', })); vi.mock('../adb.ts', () => ({ runAndroidAdb })); +// The dismissal re-check polls at the contract interval; the clock is the assertion, not the wait. +vi.mock('@agent-device/host-kit/retry', () => ({ sleep: async () => {} })); const { handleAndroidAlert } = await import('../alert.ts'); +const dialog = [ + node(0, 'android.app.AlertDialog'), + text(1, 'Automation confirmation', 'android:id/alertTitle'), + button(2, 'Cancel', 'android:id/button2', { x: 210, y: 612 }), +]; + +/** The dialog is in the tree until the button press lands, then gone — the real timeline. */ +function dialogUntilPressed(nodes = dialog) { + return async () => (runAndroidAdb.mock.calls.length === 0 ? nodes : []); +} + const device: DeviceInfo = { platform: 'android', id: 'emulator-5554', @@ -23,11 +36,7 @@ const device: DeviceInfo = { test('dismissing a button alert records the tapped button and its coordinates', async () => { runAndroidAdb.mockClear(); const result = await handleAndroidAlert(device, 'dismiss', { - captureNodes: async () => [ - node(0, 'android.app.AlertDialog'), - text(1, 'Automation confirmation', 'android:id/alertTitle'), - button(2, 'Cancel', 'android:id/button2', { x: 210, y: 612 }), - ], + captureNodes: dialogUntilPressed(), }); assert.deepEqual(result, { @@ -52,11 +61,11 @@ test('dismissing a button alert records the tapped button and its coordinates', test('accepting a button alert records the tapped button and its coordinates', async () => { runAndroidAdb.mockClear(); const result = await handleAndroidAlert(device, 'accept', { - captureNodes: async () => [ + captureNodes: dialogUntilPressed([ node(0, 'android.app.AlertDialog'), text(1, 'Automation confirmation', 'android:id/alertTitle'), button(2, 'OK', 'android:id/button1', { x: 52, y: 612 }), - ], + ]), }); assert.equal(result.kind, 'alertHandled'); @@ -66,10 +75,10 @@ test('accepting a button alert records the tapped button and its coordinates', a test('a fallback Back dismissal (no matching button) carries no coordinates', async () => { runAndroidAdb.mockClear(); const result = await handleAndroidAlert(device, 'dismiss', { - captureNodes: async () => [ + captureNodes: dialogUntilPressed([ node(0, 'android.app.AlertDialog'), text(1, 'Automation confirmation', 'android:id/alertTitle'), - ], + ]), }); assert.equal(result.kind, 'alertHandled'); @@ -77,3 +86,63 @@ test('a fallback Back dismissal (no matching button) carries no coordinates', as assert.equal(result.kind === 'alertHandled' ? result.button : undefined, 'Back'); assert.deepEqual(runAndroidAdb.mock.calls[0]?.[1], ['shell', 'input', 'keyevent', '4']); }); + +test('dismiss returns only after the dialog has left the tree', async () => { + runAndroidAdb.mockClear(); + // Pre-press lookup, then two captures that still show the closing dialog, then the app. + const captures = [dialog, dialog, dialog, []]; + let reads = 0; + const result = await handleAndroidAlert(device, 'dismiss', { + captureNodes: async () => captures[Math.min(reads++, captures.length - 1)] ?? [], + }); + + assert.equal(result.kind, 'alertHandled'); + assert.equal(reads, 4); + assert.equal(runAndroidAdb.mock.calls.length, 1); +}); + +test('a different alert replacing the pressed one counts as dismissed', async () => { + runAndroidAdb.mockClear(); + const followUp = [ + node(0, 'android.app.AlertDialog'), + text(1, 'Discard changes?', 'android:id/alertTitle'), + button(2, 'Keep', 'android:id/button2', { x: 210, y: 612 }), + ]; + const result = await handleAndroidAlert(device, 'dismiss', { + captureNodes: async () => (runAndroidAdb.mock.calls.length === 0 ? dialog : followUp), + }); + + assert.equal(result.kind, 'alertHandled'); + assert.equal( + result.kind === 'alertHandled' ? result.alert.title : undefined, + 'Automation confirmation', + ); +}); + +test('dismiss fails when the dialog is still visible after the action budget', async () => { + runAndroidAdb.mockClear(); + vi.useFakeTimers({ now: 0, toFake: ['Date'] }); + try { + let reads = 0; + await assert.rejects( + handleAndroidAlert(device, 'dismiss', { + captureNodes: async () => { + // Every post-press capture costs wall clock; the dialog never leaves. + if (reads++ > 0) vi.setSystemTime(Date.now() + 700); + return dialog; + }, + }), + (error: unknown) => + error instanceof Error && + error.message === 'alert dismiss did not dismiss the visible alert' && + (error as { code?: string }).code === 'COMMAND_FAILED', + ); + assert.equal(runAndroidAdb.mock.calls.length, 1); + assert.ok( + reads >= 4, + `expected the re-check to poll until the budget expired, got ${reads} reads`, + ); + } finally { + vi.useRealTimers(); + } +}); diff --git a/packages/platform-android/src/alert.ts b/packages/platform-android/src/alert.ts index 27eea88d4..f9a662977 100644 --- a/packages/platform-android/src/alert.ts +++ b/packages/platform-android/src/alert.ts @@ -103,6 +103,7 @@ async function handleAndroidAlertAction( const button = chooseAndroidAlertButton(candidate.buttons, action); if (button) { await pressAndroid(device, button.x, button.y); + await confirmAndroidAlertDismissed(candidate.alert, action, captureNodes); return buildAndroidAlertHandledResponse(action, candidate.alert, button.label, { x: button.x, y: button.y, @@ -111,6 +112,7 @@ async function handleAndroidAlertAction( if (action === 'dismiss') { await backAndroid(device); + await confirmAndroidAlertDismissed(candidate.alert, action, captureNodes); return buildAndroidAlertHandledResponse(action, candidate.alert, 'Back'); } @@ -120,6 +122,35 @@ async function handleAndroidAlertAction( }); } +/** + * `alert accept|dismiss` means the dialog is gone, not that a button was pressed: the next + * command reads the app, and a capture that lands while the dialog window is still up sees + * only the dialog. A different alert taking its place counts as dismissed. Bounded by the + * same budget the pre-press lookup uses; iOS's runner applies the same re-check. + */ +async function confirmAndroidAlertDismissed( + dismissed: AndroidAlertInfo, + action: 'accept' | 'dismiss', + captureNodes: AndroidAlertOptions['captureNodes'], +): Promise { + const start = Date.now(); + for (;;) { + const current = await readAndroidAlertCandidate(captureNodes); + if (!current || !sameAndroidAlert(current.alert, dismissed)) return; + if (Date.now() - start >= ALERT_ACTION_RETRY_MS) { + throw new AppError('COMMAND_FAILED', `alert ${action} did not dismiss the visible alert`, { + alert: dismissed, + hint: 'The alert button was pressed but the dialog is still visible. Inspect alert get --json, then press the visible button by label/ref or retry.', + }); + } + await sleep(ALERT_POLL_INTERVAL_MS); + } +} + +function sameAndroidAlert(left: AndroidAlertInfo, right: AndroidAlertInfo): boolean { + return left.title === right.title && left.buttons.join('\u0000') === right.buttons.join('\u0000'); +} + async function pollAndroidAlertCandidate( captureNodes: AndroidAlertOptions['captureNodes'], timeoutMs: number, diff --git a/test/integration/android-emulator-e2e/live-automation-scenario.ts b/test/integration/android-emulator-e2e/live-automation-scenario.ts index 242c36c11..3bf45b69f 100644 --- a/test/integration/android-emulator-e2e/live-automation-scenario.ts +++ b/test/integration/android-emulator-e2e/live-automation-scenario.ts @@ -164,9 +164,13 @@ export async function assertAutomationSystem(context: LiveContext): Promise= fixture.minimumNodeCount, diff --git a/test/integration/live-device-e2e/runtime.ts b/test/integration/live-device-e2e/runtime.ts index 2764b3077..ff1976929 100644 --- a/test/integration/live-device-e2e/runtime.ts +++ b/test/integration/live-device-e2e/runtime.ts @@ -169,13 +169,13 @@ export function createLiveDeviceHarness< const unexpectedFailure = result.status !== 0 && !failedAsExpected && stepOptions.allowFailure !== true; if (unexpectedFailure) { - const screenshotPath = - fullArgs[0] === 'wait' ? await captureWaitTimeoutScreenshot(context) : undefined; + const evidence = await captureFailedStepEvidence(context); const message = [ formatResultDebug(step, fullArgs, result), `scenario: ${context.currentScenario}`, `artifacts: ${context.artifactDir}`, - `screenshot: ${screenshotPath ?? '(capture failed or not applicable)'}`, + `screenshot: ${evidence.screenshotPath ?? '(capture failed)'}`, + `snapshot: ${evidence.snapshotPath ?? '(capture failed)'}`, ].join('\n'); fs.writeFileSync(path.join(context.artifactDir, 'failed-step.txt'), message); assert.fail(message); @@ -185,21 +185,37 @@ export function createLiveDeviceHarness< } } - /** Best-effort: never throws, returns undefined on a failed capture. */ - async function captureWaitTimeoutScreenshot(context: Context): Promise { - const screenshotPath = path.join( - context.artifactDir, - `wait-timeout-${context.stepHistory.length}.png`, - ); + /** + * What the device showed when a step failed: the pixels and the accessibility tree the + * next capture would have read. Best-effort, never throws; a failed capture yields undefined. + */ + async function captureFailedStepEvidence( + context: Context, + ): Promise<{ screenshotPath?: string; snapshotPath?: string }> { + const stem = path.join(context.artifactDir, `failed-step-${context.stepHistory.length}`); + const screenshotPath = `${stem}.png`; + const snapshotPath = `${stem}-snapshot.json`; + const runCli = options.runCli ?? runBuiltCliJson; + const evidence: { screenshotPath?: string; snapshotPath?: string } = {}; try { - const capture = await (options.runCli ?? runBuiltCliJson)( + const screenshot = await runCli( options.commonFlags(context, ['screenshot', screenshotPath]), context.env, ); - return capture.status === 0 ? screenshotPath : undefined; + if (screenshot.status === 0) evidence.screenshotPath = screenshotPath; + } catch { + // evidence only + } + try { + const snapshot = await runCli(options.commonFlags(context, ['snapshot']), context.env); + if (snapshot.status === 0 && snapshot.json !== undefined) { + fs.writeFileSync(snapshotPath, JSON.stringify(snapshot.json, null, 2)); + evidence.snapshotPath = snapshotPath; + } } catch { - return undefined; + // evidence only } + return evidence; } function updateSessionState(context: Context, command: string | undefined, status: number): void { diff --git a/test/integration/provider-scenarios/android-alert.test.ts b/test/integration/provider-scenarios/android-alert.test.ts new file mode 100644 index 000000000..cde8b4e2f --- /dev/null +++ b/test/integration/provider-scenarios/android-alert.test.ts @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { assertCommandCall } from './assertions.ts'; +import { createAndroidSettingsWorld } from './android-world.ts'; +import { + androidAppOwnedSheetXml, + androidButtonlessAlertXml, + androidNativeAlertXml, + androidRuntimePermissionXml, + androidSystemDialogXml, + dismissibleDialog, +} from './android-dialog-fixtures.ts'; +import { withProviderScenarioResource } from './harness.ts'; + +test('Provider-backed integration Android alert handles runtime permission dialog', async () => { + const dialog = dismissibleDialog(androidRuntimePermissionXml); + await withProviderScenarioResource( + async () => await createAndroidSettingsWorld(dialog), + async (world) => { + const client = world.daemon.client(); + await client.apps.open({ app: 'com.example.demo', ...world.selection }); + + const alertGet = await client.command.alert({ action: 'get', ...world.selection }); + assert.equal(alertGet.kind, 'alertStatus'); + assert.deepEqual(alertGet.alert, { + title: 'Allow Demo to send you notifications?', + buttons: ['Don’t allow', 'Allow'], + platform: 'android', + source: 'permission', + packageName: 'com.google.android.permissioncontroller', + }); + + const alertAccept = await client.command.alert({ action: 'accept', ...world.selection }); + assert.equal(alertAccept.kind, 'alertHandled'); + assert.equal(alertAccept.button, 'Allow'); + assert.deepEqual( + world.adbCalls.filter((call) => call.join(' ') === 'shell input tap 274 638'), + [['shell', 'input', 'tap', '274', '638']], + ); + + dialog.show(); + const alertDismiss = await client.command.alert({ action: 'dismiss', ...world.selection }); + assert.equal(alertDismiss.kind, 'alertHandled'); + assert.equal(alertDismiss.button, 'Don’t allow'); + assert.deepEqual( + world.adbCalls.filter((call) => call.join(' ') === 'shell input tap 116 638'), + [['shell', 'input', 'tap', '116', '638']], + ); + }, + ); +}); + +test('Provider-backed integration Android alert handles native AlertDialog actions', async () => { + const dialog = dismissibleDialog(androidNativeAlertXml); + await withProviderScenarioResource( + async () => await createAndroidSettingsWorld(dialog), + async (world) => { + const client = world.daemon.client(); + await client.apps.open({ app: 'com.example.demo', ...world.selection }); + + const alertGet = await client.command.alert({ action: 'get', ...world.selection }); + assert.deepEqual(alertGet.alert, { + title: 'Unsaved changes', + message: 'Leave without saving?', + buttons: ['Cancel', 'Discard'], + platform: 'android', + source: 'native-dialog', + packageName: 'com.example.demo', + }); + + const alertAccept = await client.command.alert({ action: 'accept', ...world.selection }); + assert.equal(alertAccept.button, 'Discard'); + dialog.show(); + const alertDismiss = await client.command.alert({ action: 'dismiss', ...world.selection }); + assert.equal(alertDismiss.button, 'Cancel'); + assert.deepEqual( + world.adbCalls.filter((call) => + ['shell input tap 274 638', 'shell input tap 116 638'].includes(call.join(' ')), + ), + [ + ['shell', 'input', 'tap', '274', '638'], + ['shell', 'input', 'tap', '116', '638'], + ], + ); + }, + ); +}); + +test('Provider-backed integration Android alert handles system dialogs', async () => { + await withProviderScenarioResource( + async () => await createAndroidSettingsWorld(dismissibleDialog(androidSystemDialogXml)), + async (world) => { + const client = world.daemon.client(); + await client.apps.open({ app: 'com.example.demo', ...world.selection }); + + const alertGet = await client.command.alert({ action: 'get', ...world.selection }); + assert.deepEqual(alertGet.alert, { + title: "Demo isn't responding", + message: 'Do you want to close it?', + buttons: ['Close app', 'Wait'], + platform: 'android', + source: 'system-dialog', + packageName: 'com.android.systemui', + }); + + const alertDismiss = await client.command.alert({ action: 'dismiss', ...world.selection }); + assert.equal(alertDismiss.button, 'Close app'); + assertCommandCall(world.adbCalls, ['shell', 'input', 'tap', '116', '638']); + }, + ); +}); + +test('Provider-backed integration Android alert dismiss falls back to Back without a dismiss button', async () => { + await withProviderScenarioResource( + async () => await createAndroidSettingsWorld(dismissibleDialog(androidButtonlessAlertXml)), + async (world) => { + const client = world.daemon.client(); + await client.apps.open({ app: 'com.example.demo', ...world.selection }); + + const alertDismiss = await client.command.alert({ action: 'dismiss', ...world.selection }); + assert.equal(alertDismiss.kind, 'alertHandled'); + assert.equal(alertDismiss.button, 'Back'); + assertCommandCall(world.adbCalls, ['shell', 'input', 'keyevent', '4']); + }, + ); +}); + +test('Provider-backed integration Android alert accept fails when the dialog stays visible', async () => { + await withProviderScenarioResource( + async () => await createAndroidSettingsWorld({ snapshotXml: androidNativeAlertXml }), + async (world) => { + const client = world.daemon.client(); + await client.apps.open({ app: 'com.example.demo', ...world.selection }); + + await assert.rejects( + client.command.alert({ action: 'accept', ...world.selection }), + (error: unknown) => + error instanceof Error && + error.message === 'alert accept did not dismiss the visible alert', + ); + assertCommandCall(world.adbCalls, ['shell', 'input', 'tap', '274', '638']); + }, + ); +}); + +test('Provider-backed integration Android alert wait polls until a dialog appears', async () => { + let snapshotCount = 0; + await withProviderScenarioResource( + async () => + await createAndroidSettingsWorld({ + snapshotXml: () => { + snapshotCount += 1; + return snapshotCount === 1 ? androidAppOwnedSheetXml() : androidRuntimePermissionXml(); + }, + }), + async (world) => { + const client = world.daemon.client(); + await client.apps.open({ app: 'com.example.demo', ...world.selection }); + + const alertWait = await client.command.alert({ + action: 'wait', + timeoutMs: 1000, + ...world.selection, + }); + assert.equal(alertWait.kind, 'alertWait'); + // alert now returns the untyped CommandRequestResult bag (its iOS path is a + // dynamic runner Record, so the public type is no longer a closed shape). + const alertInfo = alertWait.alert as { source?: string } | null | undefined; + assert.equal(alertInfo?.source, 'permission'); + assert.ok(snapshotCount >= 2); + }, + ); +}); + +test('Provider-backed integration Android alert ignores app-owned sheets', async () => { + await withProviderScenarioResource( + async () => await createAndroidSettingsWorld({ snapshotXml: androidAppOwnedSheetXml }), + async (world) => { + const client = world.daemon.client(); + await client.apps.open({ app: 'com.example.demo', ...world.selection }); + + const alertGet = await client.command.alert({ action: 'get', ...world.selection }); + assert.equal(alertGet.kind, 'alertStatus'); + assert.equal(alertGet.alert, null); + }, + ); +}); diff --git a/test/integration/provider-scenarios/android-dialog-fixtures.ts b/test/integration/provider-scenarios/android-dialog-fixtures.ts new file mode 100644 index 000000000..31d1b2373 --- /dev/null +++ b/test/integration/provider-scenarios/android-dialog-fixtures.ts @@ -0,0 +1,169 @@ +/** + * Scripted Android dialog surfaces for the provider-backed scenarios: runtime permission, + * native AlertDialog, system (ANR) dialog, a buttonless dialog, and the app-owned sheet that + * sits underneath them. + */ + +export function androidRuntimePermissionXml(): string { + const packageName = 'com.google.android.permissioncontroller'; + return androidXml([ + rootNode(packageName), + textNode( + 1, + 'Allow Demo to send you notifications?', + 'com.android.permissioncontroller:id/permission_message', + packageName, + '[24,300][366,352]', + ), + buttonNode( + 2, + 'Don’t allow', + 'com.android.permissioncontroller:id/permission_deny_button', + '[52,612][180,664]', + packageName, + ), + buttonNode( + 3, + 'Allow', + 'com.android.permissioncontroller:id/permission_allow_button', + '[210,612][338,664]', + packageName, + ), + ' ', + ]); +} + +export function androidNativeAlertXml(): string { + return androidDialogXml([ + textNode(2, 'Unsaved changes', 'android:id/alertTitle'), + textNode(3, 'Leave without saving?', 'android:id/message'), + buttonNode(4, 'Cancel', 'android:id/button2', '[52,612][180,664]'), + buttonNode(5, 'Discard', 'android:id/button1', '[210,612][338,664]'), + ]); +} + +export function androidSystemDialogXml(): string { + const packageName = 'com.android.systemui'; + return androidXml([ + rootNode(packageName), + textNode(1, 'Demo isn't responding', 'android:id/alertTitle', packageName), + textNode(2, 'Do you want to close it?', 'android:id/message', packageName), + buttonNode(3, 'Close app', 'android:id/button2', '[52,612][180,664]', packageName), + buttonNode(4, 'Wait', 'android:id/button1', '[210,612][338,664]', packageName), + ' ', + ]); +} + +export function androidButtonlessAlertXml(): string { + return androidDialogXml([ + textNode(2, 'Unsaved changes', 'android:id/alertTitle'), + textNode(3, 'Leave without saving?', 'android:id/message'), + ]); +} + +/** + * A dialog the way a device shows one: in the tree until its button is tapped (or Back is + * sent), then gone, with the app-owned surface underneath. `show()` brings it back for a + * second action on the same fixture. + */ +export function dismissibleDialog(dialogXml: () => string) { + let visible = true; + return { + show: () => { + visible = true; + }, + snapshotXml: () => (visible ? dialogXml() : androidAppOwnedSheetXml()), + onAdbExec: (args: string[]) => { + if (args[0] !== 'shell' || args[1] !== 'input') return; + if (args[2] === 'tap' || (args[2] === 'keyevent' && args[3] === '4')) visible = false; + }, + }; +} + +export function androidAppOwnedSheetXml(): string { + return androidXml([ + rootNode('com.example.demo', 'com.example.demo:id/root'), + textNode(1, 'Choose an option', 'com.example.demo:id/title'), + buttonNode(2, 'Allow', 'com.example.demo:id/allow_button', '[210,612][338,664]'), + ' ', + ]); +} + +function androidDialogXml(children: string[]): string { + return androidXml([ + rootNode(), + androidNode({ + index: 1, + id: 'android:id/parentPanel', + type: 'android.app.AlertDialog', + bounds: '[24,240][366,680]', + selfClosing: false, + }), + ...children, + ' ', + ' ', + ]); +} + +function androidXml(body: string[]): string { + return [ + '', + '', + ...body, + '', + ].join('\n'); +} + +function rootNode(packageName = 'com.example.demo', id = 'android:id/content'): string { + return androidNode({ index: 0, id, type: 'FrameLayout', packageName, selfClosing: false }); +} + +function textNode( + index: number, + text: string, + id: string, + packageName = 'com.example.demo', + bounds?: string, +): string { + return androidNode({ index, text, id, packageName, ...(bounds ? { bounds } : {}) }); +} + +function buttonNode( + index: number, + text: string, + id: string, + bounds: string, + packageName = 'com.example.demo', +): string { + return androidNode({ index, text, id, type: 'Button', packageName, bounds, clickable: true }); +} + +function androidNode(options: { + index: number; + id: string; + text?: string; + type?: string; + packageName?: string; + bounds?: string; + clickable?: boolean; + selfClosing?: boolean; +}): string { + const type = options.type ?? 'TextView'; + const className = type.includes('.') ? type : `android.widget.${type}`; + const tagEnd = options.selfClosing === false ? '>' : ' />'; + return [ + ` { ); }); -test('Provider-backed integration Android alert handles runtime permission dialog', async () => { - await withProviderScenarioResource( - async () => await createAndroidSettingsWorld({ snapshotXml: androidRuntimePermissionXml }), - async (world) => { - const client = world.daemon.client(); - await client.apps.open({ app: 'com.example.demo', ...world.selection }); - - const alertGet = await client.command.alert({ action: 'get', ...world.selection }); - assert.equal(alertGet.kind, 'alertStatus'); - assert.deepEqual(alertGet.alert, { - title: 'Allow Demo to send you notifications?', - buttons: ['Don’t allow', 'Allow'], - platform: 'android', - source: 'permission', - packageName: 'com.google.android.permissioncontroller', - }); - - const alertAccept = await client.command.alert({ action: 'accept', ...world.selection }); - assert.equal(alertAccept.kind, 'alertHandled'); - assert.equal(alertAccept.button, 'Allow'); - assert.deepEqual( - world.adbCalls.filter((call) => call.join(' ') === 'shell input tap 274 638'), - [['shell', 'input', 'tap', '274', '638']], - ); - - const alertDismiss = await client.command.alert({ action: 'dismiss', ...world.selection }); - assert.equal(alertDismiss.kind, 'alertHandled'); - assert.equal(alertDismiss.button, 'Don’t allow'); - assert.deepEqual( - world.adbCalls.filter((call) => call.join(' ') === 'shell input tap 116 638'), - [['shell', 'input', 'tap', '116', '638']], - ); - }, - ); -}); - -test('Provider-backed integration Android alert handles native AlertDialog actions', async () => { - await withProviderScenarioResource( - async () => await createAndroidSettingsWorld({ snapshotXml: androidNativeAlertXml }), - async (world) => { - const client = world.daemon.client(); - await client.apps.open({ app: 'com.example.demo', ...world.selection }); - - const alertGet = await client.command.alert({ action: 'get', ...world.selection }); - assert.deepEqual(alertGet.alert, { - title: 'Unsaved changes', - message: 'Leave without saving?', - buttons: ['Cancel', 'Discard'], - platform: 'android', - source: 'native-dialog', - packageName: 'com.example.demo', - }); - - const alertAccept = await client.command.alert({ action: 'accept', ...world.selection }); - assert.equal(alertAccept.button, 'Discard'); - const alertDismiss = await client.command.alert({ action: 'dismiss', ...world.selection }); - assert.equal(alertDismiss.button, 'Cancel'); - assert.deepEqual( - world.adbCalls.filter((call) => - ['shell input tap 274 638', 'shell input tap 116 638'].includes(call.join(' ')), - ), - [ - ['shell', 'input', 'tap', '274', '638'], - ['shell', 'input', 'tap', '116', '638'], - ], - ); - }, - ); -}); - -test('Provider-backed integration Android alert handles system dialogs', async () => { - await withProviderScenarioResource( - async () => await createAndroidSettingsWorld({ snapshotXml: androidSystemDialogXml }), - async (world) => { - const client = world.daemon.client(); - await client.apps.open({ app: 'com.example.demo', ...world.selection }); - - const alertGet = await client.command.alert({ action: 'get', ...world.selection }); - assert.deepEqual(alertGet.alert, { - title: "Demo isn't responding", - message: 'Do you want to close it?', - buttons: ['Close app', 'Wait'], - platform: 'android', - source: 'system-dialog', - packageName: 'com.android.systemui', - }); - - const alertDismiss = await client.command.alert({ action: 'dismiss', ...world.selection }); - assert.equal(alertDismiss.button, 'Close app'); - assertCommandCall(world.adbCalls, ['shell', 'input', 'tap', '116', '638']); - }, - ); -}); - test('Provider-backed integration Android app-owned ANR recovers before action commands', async () => { let anrFocused = true; await withProviderScenarioResource( @@ -406,64 +313,6 @@ test('Provider-backed integration Android external ANR fails with actionable con ); }); -test('Provider-backed integration Android alert dismiss falls back to Back without a dismiss button', async () => { - await withProviderScenarioResource( - async () => await createAndroidSettingsWorld({ snapshotXml: androidButtonlessAlertXml }), - async (world) => { - const client = world.daemon.client(); - await client.apps.open({ app: 'com.example.demo', ...world.selection }); - - const alertDismiss = await client.command.alert({ action: 'dismiss', ...world.selection }); - assert.equal(alertDismiss.kind, 'alertHandled'); - assert.equal(alertDismiss.button, 'Back'); - assertCommandCall(world.adbCalls, ['shell', 'input', 'keyevent', '4']); - }, - ); -}); - -test('Provider-backed integration Android alert wait polls until a dialog appears', async () => { - let snapshotCount = 0; - await withProviderScenarioResource( - async () => - await createAndroidSettingsWorld({ - snapshotXml: () => { - snapshotCount += 1; - return snapshotCount === 1 ? androidAppOwnedSheetXml() : androidRuntimePermissionXml(); - }, - }), - async (world) => { - const client = world.daemon.client(); - await client.apps.open({ app: 'com.example.demo', ...world.selection }); - - const alertWait = await client.command.alert({ - action: 'wait', - timeoutMs: 1000, - ...world.selection, - }); - assert.equal(alertWait.kind, 'alertWait'); - // alert now returns the untyped CommandRequestResult bag (its iOS path is a - // dynamic runner Record, so the public type is no longer a closed shape). - const alertInfo = alertWait.alert as { source?: string } | null | undefined; - assert.equal(alertInfo?.source, 'permission'); - assert.ok(snapshotCount >= 2); - }, - ); -}); - -test('Provider-backed integration Android alert ignores app-owned sheets', async () => { - await withProviderScenarioResource( - async () => await createAndroidSettingsWorld({ snapshotXml: androidAppOwnedSheetXml }), - async (world) => { - const client = world.daemon.client(); - await client.apps.open({ app: 'com.example.demo', ...world.selection }); - - const alertGet = await client.command.alert({ action: 'get', ...world.selection }); - assert.equal(alertGet.kind, 'alertStatus'); - assert.equal(alertGet.alert, null); - }, - ); -}); - async function runAndroidSetupAndInstallWorkflow( world: AndroidSettingsWorld, client: AgentDeviceClient, @@ -647,151 +496,6 @@ async function runAndroidSetupAndInstallWorkflow( assert.equal(keyboard.visible, false); } -function androidRuntimePermissionXml(): string { - const packageName = 'com.google.android.permissioncontroller'; - return androidXml([ - rootNode(packageName), - textNode( - 1, - 'Allow Demo to send you notifications?', - 'com.android.permissioncontroller:id/permission_message', - packageName, - '[24,300][366,352]', - ), - buttonNode( - 2, - 'Don’t allow', - 'com.android.permissioncontroller:id/permission_deny_button', - '[52,612][180,664]', - packageName, - ), - buttonNode( - 3, - 'Allow', - 'com.android.permissioncontroller:id/permission_allow_button', - '[210,612][338,664]', - packageName, - ), - ' ', - ]); -} - -function androidNativeAlertXml(): string { - return androidDialogXml([ - textNode(2, 'Unsaved changes', 'android:id/alertTitle'), - textNode(3, 'Leave without saving?', 'android:id/message'), - buttonNode(4, 'Cancel', 'android:id/button2', '[52,612][180,664]'), - buttonNode(5, 'Discard', 'android:id/button1', '[210,612][338,664]'), - ]); -} - -function androidSystemDialogXml(): string { - const packageName = 'com.android.systemui'; - return androidXml([ - rootNode(packageName), - textNode(1, 'Demo isn't responding', 'android:id/alertTitle', packageName), - textNode(2, 'Do you want to close it?', 'android:id/message', packageName), - buttonNode(3, 'Close app', 'android:id/button2', '[52,612][180,664]', packageName), - buttonNode(4, 'Wait', 'android:id/button1', '[210,612][338,664]', packageName), - ' ', - ]); -} - -function androidButtonlessAlertXml(): string { - return androidDialogXml([ - textNode(2, 'Unsaved changes', 'android:id/alertTitle'), - textNode(3, 'Leave without saving?', 'android:id/message'), - ]); -} - -function androidAppOwnedSheetXml(): string { - return androidXml([ - rootNode('com.example.demo', 'com.example.demo:id/root'), - textNode(1, 'Choose an option', 'com.example.demo:id/title'), - buttonNode(2, 'Allow', 'com.example.demo:id/allow_button', '[210,612][338,664]'), - ' ', - ]); -} - -function androidDialogXml(children: string[]): string { - return androidXml([ - rootNode(), - androidNode({ - index: 1, - id: 'android:id/parentPanel', - type: 'android.app.AlertDialog', - bounds: '[24,240][366,680]', - selfClosing: false, - }), - ...children, - ' ', - ' ', - ]); -} - -function androidXml(body: string[]): string { - return [ - '', - '', - ...body, - '', - ].join('\n'); -} - -function rootNode(packageName = 'com.example.demo', id = 'android:id/content'): string { - return androidNode({ index: 0, id, type: 'FrameLayout', packageName, selfClosing: false }); -} - -function textNode( - index: number, - text: string, - id: string, - packageName = 'com.example.demo', - bounds?: string, -): string { - return androidNode({ index, text, id, packageName, ...(bounds ? { bounds } : {}) }); -} - -function buttonNode( - index: number, - text: string, - id: string, - bounds: string, - packageName = 'com.example.demo', -): string { - return androidNode({ index, text, id, type: 'Button', packageName, bounds, clickable: true }); -} - -function androidNode(options: { - index: number; - id: string; - text?: string; - type?: string; - packageName?: string; - bounds?: string; - clickable?: boolean; - selfClosing?: boolean; -}): string { - const type = options.type ?? 'TextView'; - const className = type.includes('.') ? type : `android.widget.${type}`; - const tagEnd = options.selfClosing === false ? '>' : ' />'; - return [ - `