From aa8f5a7a5e3994aa30a81c365d2658831f6d13bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 5 Sep 2026 22:38:37 +0200 Subject: [PATCH 1/4] fix: stop stamping recovered iOS captures truncated; confirm Android alert dismissal Two CI flake families on main and PRs since 2026-09-03. iOS Smoke, `is absent ... capture was truncated` (7 of 13 failures): the runner's stampedSnapshotPayload set `truncated: true` on every non-healthy capture, so a complete private-AX tree taken while the XCTest channel was penalized as slow (the normal state on a loaded CI host) was reported as truncated. Nothing consumed that until the strict absence assertion (#2245) refused truncated captures. `truncated` now tracks completeness only: payload truncation, a depth-limited capture, or a sparse terminal payload. The E2E conformance helper asserted the old conflation and now asserts `truncated === false`; a runner unit test pins the new contract and joins the targeted list in ios.yml. Android Smoke, `get text id="automation-alert-result"` selector miss (5 of 5 failures): #2260 replaced a polling wait with a one-shot read right after `alert dismiss`, and Android's `alert accept|dismiss` returned as soon as the button was pressed, while the dialog window was still the only thing in the accessibility tree. They now poll until the same dialog is gone (a different alert taking its place counts as dismissed), bounded by the existing action budget, else fail with "did not dismiss the visible alert" like the iOS runner already does. --- .github/workflows/ios.yml | 1 + CHANGELOG.md | 10 +++ .../RunnerTests+SnapshotCapturePlan.swift | 46 +++++++++- .../src/__tests__/alert.test.ts | 87 +++++++++++++++++-- packages/platform-android/src/alert.ts | 31 +++++++ .../snapshot-backend-conformance.ts | 11 +-- ...e-ios-snapshot-backend-conformance.test.ts | 4 +- 7 files changed, 173 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index f4deb55796..905bcc8053 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 b545aafb59..22b1f9514b 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 05a9a3c2cf..7797b76bbe 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 2b7af06720..3c38f315bb 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 27eea88d48..f9a662977b 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/ios-simulator-e2e/snapshot-backend-conformance.ts b/test/integration/ios-simulator-e2e/snapshot-backend-conformance.ts index 964145a20f..ef2a9c2d21 100644 --- a/test/integration/ios-simulator-e2e/snapshot-backend-conformance.ts +++ b/test/integration/ios-simulator-e2e/snapshot-backend-conformance.ts @@ -82,13 +82,14 @@ export function assertSnapshotBackendConformance( quality?.state === 'healthy' || quality?.state === 'recovered', `${backend} capture must have a non-sparse quality verdict: ${JSON.stringify(quality)}`, ); - // The existing wire contract marks any recovered capture as truncated, including a complete - // private-AX payload selected after the XCTest channel was deferred. Assert that relationship - // instead of conflating recovery provenance with missing fixture controls. + // `truncated` is a completeness fact, never recovery provenance: a complete capture stays + // untruncated even when a later backend produced it after the XCTest channel was deferred + // (state "recovered"). The fixture screen fits every backend's budget, so truncation here is + // a wire regression, not missing fixture controls. assert.equal( snapshot.truncated, - quality.state !== 'healthy', - `${backend} quality/truncation flags disagree: ${JSON.stringify(quality)}`, + false, + `${backend} reported a truncated capture of the fixture screen: ${JSON.stringify(quality)}`, ); assert.ok( snapshot.nodes.length >= fixture.minimumNodeCount, diff --git a/test/integration/smoke-ios-snapshot-backend-conformance.test.ts b/test/integration/smoke-ios-snapshot-backend-conformance.test.ts index 17a654a783..534a367bed 100644 --- a/test/integration/smoke-ios-snapshot-backend-conformance.test.ts +++ b/test/integration/smoke-ios-snapshot-backend-conformance.test.ts @@ -45,9 +45,9 @@ test('snapshot backend conformance rejects every promised control invariant', () /must have a non-sparse quality verdict/, ); expectFailure( - 'recovered/truncated mismatch', + 'truncated fixture capture', { ...base, truncated: true }, - /quality\/truncation/, + /reported a truncated capture/, ); expectFailure('minimum node count', { ...base, nodes: base.nodes.slice(0, 2) }, /too few nodes/); expectFailure( From d4a4f833a8267b060f615f230ea1b3e54ced0672 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 5 Sep 2026 22:44:03 +0200 Subject: [PATCH 2/4] test(provider): model Android dialogs that leave the tree after the alert action The scripted Android alert scenarios served the same dialog to every capture, which encoded the old return-after-press behavior; alert accept/dismiss now confirm the dialog is gone, so a dialog that never leaves is the failure it should be (covered by a new scenario). The fixtures now hide the dialog once its button is tapped or Back is sent, the way the ANR recovery scenario already did. --- .../android-lifecycle.test.ts | 49 +++++++++++++++++-- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/test/integration/provider-scenarios/android-lifecycle.test.ts b/test/integration/provider-scenarios/android-lifecycle.test.ts index b396665239..a21776ecea 100644 --- a/test/integration/provider-scenarios/android-lifecycle.test.ts +++ b/test/integration/provider-scenarios/android-lifecycle.test.ts @@ -239,8 +239,9 @@ test(ANDROID_TOUCH_CONTRACT_EVIDENCE.testName, async () => { }); test('Provider-backed integration Android alert handles runtime permission dialog', async () => { + const dialog = dismissibleDialog(androidRuntimePermissionXml); await withProviderScenarioResource( - async () => await createAndroidSettingsWorld({ snapshotXml: androidRuntimePermissionXml }), + async () => await createAndroidSettingsWorld(dialog), async (world) => { const client = world.daemon.client(); await client.apps.open({ app: 'com.example.demo', ...world.selection }); @@ -263,6 +264,7 @@ test('Provider-backed integration Android alert handles runtime permission dialo [['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'); @@ -275,8 +277,9 @@ test('Provider-backed integration Android alert handles runtime permission dialo }); test('Provider-backed integration Android alert handles native AlertDialog actions', async () => { + const dialog = dismissibleDialog(androidNativeAlertXml); await withProviderScenarioResource( - async () => await createAndroidSettingsWorld({ snapshotXml: androidNativeAlertXml }), + async () => await createAndroidSettingsWorld(dialog), async (world) => { const client = world.daemon.client(); await client.apps.open({ app: 'com.example.demo', ...world.selection }); @@ -293,6 +296,7 @@ test('Provider-backed integration Android alert handles native AlertDialog actio 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( @@ -310,7 +314,7 @@ test('Provider-backed integration Android alert handles native AlertDialog actio test('Provider-backed integration Android alert handles system dialogs', async () => { await withProviderScenarioResource( - async () => await createAndroidSettingsWorld({ snapshotXml: androidSystemDialogXml }), + async () => await createAndroidSettingsWorld(dismissibleDialog(androidSystemDialogXml)), async (world) => { const client = world.daemon.client(); await client.apps.open({ app: 'com.example.demo', ...world.selection }); @@ -408,7 +412,7 @@ 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 () => await createAndroidSettingsWorld(dismissibleDialog(androidButtonlessAlertXml)), async (world) => { const client = world.daemon.client(); await client.apps.open({ app: 'com.example.demo', ...world.selection }); @@ -421,6 +425,24 @@ test('Provider-backed integration Android alert dismiss falls back to Back witho ); }); +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( @@ -704,6 +726,25 @@ function androidButtonlessAlertXml(): string { ]); } +/** + * 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. + */ +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; + }, + }; +} + function androidAppOwnedSheetXml(): string { return androidXml([ rootNode('com.example.demo', 'com.example.demo:id/root'), From d04da1e9d4435bb2803d8ed0fb37739a9eb0b7fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 5 Sep 2026 22:55:43 +0200 Subject: [PATCH 3/4] test(e2e): wait for the alert outcome before reading it; dump evidence for any failed step The Android smoke still missed `id="automation-alert-result"` on CI right after a confirmed dismissal: the daemon opened a fresh helper session for that read and its 2s capture had no such node, while the same one-shot read passes locally in 150ms. The fixture's re-render after the button callback is app timing, so the scenario waits for the outcome text (the polling landmark #2260 removed) and then pins it to the canary element. The harness kept only a screenshot, and only for wait timeouts, so the tree that produced a selector miss was never in the artifacts. Every unexpected step failure now writes failed-step-N.png and failed-step-N-snapshot.json next to failed-step.txt. --- .../live-automation-scenario.ts | 4 ++ test/integration/live-device-e2e/runtime.ts | 40 +++++++++++++------ 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/test/integration/android-emulator-e2e/live-automation-scenario.ts b/test/integration/android-emulator-e2e/live-automation-scenario.ts index 242c36c118..3bf45b69f4 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 { - 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 { From b783ab388c7cf511e08b212dbb321508b78ec595 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 5 Sep 2026 22:58:21 +0200 Subject: [PATCH 4/4] test(provider): move the Android alert scenarios and dialog fixtures out of android-lifecycle The test-file size ratchet rejects growth in android-lifecycle.test.ts (1,597 lines at the merge-base), and the dialog re-check work added a scenario there. The alert scenarios now live in android-alert.test.ts and the scripted dialog surfaces they share with the ANR scenarios in android-dialog-fixtures.ts; the lifecycle file drops to 1,260 lines. --- .../provider-scenarios/android-alert.test.ts | 187 ++++++++++ .../android-dialog-fixtures.ts | 169 +++++++++ .../android-lifecycle.test.ts | 339 +----------------- 3 files changed, 357 insertions(+), 338 deletions(-) create mode 100644 test/integration/provider-scenarios/android-alert.test.ts create mode 100644 test/integration/provider-scenarios/android-dialog-fixtures.ts 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 0000000000..cde8b4e2f0 --- /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 0000000000..31d1b2373f --- /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 () => { - 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 app-owned ANR recovers before action commands', async () => { let anrFocused = true; await withProviderScenarioResource( @@ -410,82 +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(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); - }, - ); -}); - async function runAndroidSetupAndInstallWorkflow( world: AndroidSettingsWorld, client: AgentDeviceClient, @@ -669,170 +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'), - ]); -} - -/** - * 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. - */ -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; - }, - }; -} - -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 [ - `