diff --git a/packages/apple-runner/src/index.ts b/packages/apple-runner/src/index.ts index d860b6e345..cf27fe016d 100644 --- a/packages/apple-runner/src/index.ts +++ b/packages/apple-runner/src/index.ts @@ -3,11 +3,21 @@ * * The package entry names its whole surface. Host-bound operations evaluate the implementation, so * they reach their host capabilities through the `AppleRunnerHost` port, which `platform-apple` - * binds once through `bindAppleRunnerHost` before any operation runs. The one operation outside - * this closure, `runApplePressSeries`, and the legacy device-set restore stay behind function-scoped - * imports so the entry evaluates only what a host-bound operation needs. + * binds once through `bindAppleRunnerHost` before any operation runs. `runApplePressSeries`, + * `createLaunchedAppleRunnerProvider`, and the legacy device-set restore stay behind + * function-scoped imports so the entry evaluates only what a host-bound operation needs. */ +export { RUNNER_STARTUP_TIMEOUT_MS } from './runner-startup-transport.ts'; +export async function createLaunchedAppleRunnerProvider( + ...args: Parameters< + typeof import('./runner-launcher-provider.ts').createLaunchedAppleRunnerProvider + > +) { + const { createLaunchedAppleRunnerProvider } = await import('./runner-launcher-provider.ts'); + return createLaunchedAppleRunnerProvider(...args); +} + export { bindAppleRunnerHost, type AppleRunnerHost, @@ -22,6 +32,7 @@ export type { IosPhysicalDeviceRunnerControl, } from './runner-physical-device.ts'; export { + mintRunnerToken, RUNNER_READY_PREFLIGHT_TIMEOUT_MS, SCROLL_KEYBOARD_OCCLUDES_SURFACE_RUNNER_CODE, buildRunnerSequenceCommand, @@ -371,3 +382,7 @@ export async function restoreLegacyXctestDeviceSetRedirect( await import('./legacy-xctest-device-set.ts'); await restore(...args); } + +export { runnerListenerEnv } from './runner-contract.ts'; + +export { RUNNER_XCTESTRUN_CAPTURE_OPTIONS } from './runner-artifact-env.ts'; diff --git a/packages/apple-runner/src/runner-artifact-env.ts b/packages/apple-runner/src/runner-artifact-env.ts index 0795dfc629..6b345ca236 100644 --- a/packages/apple-runner/src/runner-artifact-env.ts +++ b/packages/apple-runner/src/runner-artifact-env.ts @@ -211,7 +211,7 @@ function toXctestrunTarget( return target; } -const RUNNER_XCTESTRUN_CAPTURE_OPTIONS = { +export const RUNNER_XCTESTRUN_CAPTURE_OPTIONS = { PreferredScreenCaptureFormat: 'screenshots', SystemAttachmentLifetime: 'keepNever', UserAttachmentLifetime: 'keepNever', diff --git a/packages/capture-kit/src/ios-ax-tree.ts b/packages/capture-kit/src/ios-ax-tree.ts index dcdee8a4a6..b8d55e4557 100644 --- a/packages/capture-kit/src/ios-ax-tree.ts +++ b/packages/capture-kit/src/ios-ax-tree.ts @@ -119,7 +119,7 @@ function readIosNodeIdentifier(node: IosTreeNode): string | undefined { * The node's screen-space rect, or undefined when the provider omitted a complete one. A component that * is not a finite number (an AX sentinel such as an infinite origin) makes the frame unusable. */ -export function readIosNodeRect(node: IosTreeNode): RawSnapshotNode['rect'] { +function readIosNodeRect(node: IosTreeNode): RawSnapshotNode['rect'] { const rect = node.rect ?? node.frame; if ( !rect || @@ -137,7 +137,7 @@ function isFiniteNumber(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value); } -export function readIosNodeChildren(node: IosTreeNode): IosTreeNode[] { +function readIosNodeChildren(node: IosTreeNode): IosTreeNode[] { return node.children ?? node.nodes ?? node.elements ?? []; } diff --git a/packages/capture-kit/src/png-worker-client.test.ts b/packages/capture-kit/src/png-worker-client.test.ts index 0720b2eb40..9938fe2ce3 100644 --- a/packages/capture-kit/src/png-worker-client.test.ts +++ b/packages/capture-kit/src/png-worker-client.test.ts @@ -11,7 +11,6 @@ import { encodePngAsync, retainPngWorker, terminatePngWorker, - transcodeScreenshotToPngAsync, } from './png-worker-client.ts'; import { cropPngBytes } from './png-crop-bytes.ts'; import { computeScreenshotDiffPixels } from './screenshot-diff-pixels.ts'; @@ -148,39 +147,6 @@ test('decodePngAsync rejects invalid PNG data with the canonical decode AppError ); }); -test('transcodeScreenshotToPngAsync matches the synchronous transcoder byte for byte', async () => { - const { encode } = await import('jpeg-js'); - const { transcodeScreenshotToPng } = await import('./screenshot-image.ts'); - const rgba = Buffer.alloc(9 * 7 * 4, 0x66); - const jpeg = encode({ width: 9, height: 7, data: rgba }, 90).data; - - const fromWorker = await runOnWorkerThread('jpeg-to-png', () => - transcodeScreenshotToPngAsync(jpeg, 'fixture'), - ); - - assert.deepEqual(fromWorker, transcodeScreenshotToPng(jpeg, 'fixture')); - assert.equal(PNG.sync.read(fromWorker).width, 9); -}); - -test('transcodeScreenshotToPngAsync returns a PNG untouched without a worker round trip', async () => { - const png = PNG.sync.write(new PNG({ width: 2, height: 2 })); - - assert.equal(await transcodeScreenshotToPngAsync(png, 'fixture'), png); -}); - -test('transcodeScreenshotToPngAsync rejects a corrupt JPEG with the canonical decode AppError', async () => { - const corrupt = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(16, 0)]); - - await assert.rejects(transcodeScreenshotToPngAsync(corrupt, 'fixture'), (error) => { - assert.equal(error instanceof AppError, true); - assert.equal((error as AppError).code, 'COMMAND_FAILED'); - assert.match((error as AppError).message, /Failed to decode fixture as JPEG/); - assert.equal((error as AppError).details?.label, 'fixture'); - assert.ok(String((error as AppError).details?.reason).length > 0); - return true; - }); -}); - test('decodeScreenshotImageAsync answers the synchronous decoder for both containers', async () => { const { encode } = await import('jpeg-js'); const { decodeScreenshotImage } = await import('./screenshot-image.ts'); diff --git a/packages/capture-kit/src/png-worker-client.ts b/packages/capture-kit/src/png-worker-client.ts index c22d601593..09be3c8c1d 100644 --- a/packages/capture-kit/src/png-worker-client.ts +++ b/packages/capture-kit/src/png-worker-client.ts @@ -2,7 +2,7 @@ import { Worker } from 'node:worker_threads'; import { AppError, toAppErrorCode } from '@agent-device/kernel/errors'; import type { Rect } from '@agent-device/kernel/snapshot'; import { resolveInternalEntryModulePath } from './internal-entry.ts'; -import { decodePng, hasPngSignature, PNG } from './png.ts'; +import { decodePng, PNG } from './png.ts'; import { computeScreenshotDiffPixels, type ScreenshotDiffPixelsJob, @@ -315,19 +315,3 @@ export async function computeScreenshotDiffPixelsAsync( })); return { ...result, diffData: toBuffer(result.diffData) }; } - -/** - * PNG bytes for a provider screenshot in whatever container it arrived in. A PNG returns as is - * without a worker round trip; a JPEG is decoded and re-encoded on the worker so a full-resolution - * capture never blocks the daemon event loop. Decode failures carry the canonical `AppError`. - */ -export async function transcodeScreenshotToPngAsync(bytes: Buffer, label: string): Promise { - if (hasPngSignature(bytes)) return bytes; - const result = await runPngJob({ kind: 'jpeg-to-png', image: bytes, label }, async () => { - // Read on demand so the JPEG decoder stays out of the import closure of every entry that only - // needs the worker's other jobs. - const { transcodeScreenshotToPng } = await import('./screenshot-image.ts'); - return { kind: 'jpeg-to-png', png: transcodeScreenshotToPng(bytes, label) }; - }); - return toBuffer(result.png); -} diff --git a/packages/capture-kit/src/png-worker-contract.ts b/packages/capture-kit/src/png-worker-contract.ts index 0782195b8a..bfae54345a 100644 --- a/packages/capture-kit/src/png-worker-contract.ts +++ b/packages/capture-kit/src/png-worker-contract.ts @@ -9,7 +9,7 @@ import type { PngRgbDifferenceResult } from './png-rgb-difference.ts'; /** * Message contract between the daemon-side PNG worker client * (`png-worker-client.ts`) and the worker thread entry (`png-worker.ts`). - * One message = one decode, encode, crop, diff, or transcode job. Binary payloads cross the + * One message = one decode, encode, crop, or diff job. Binary payloads cross the * thread boundary via structured clone (or transfer), so `Buffer` fields * arrive as plain `Uint8Array` views on the receiving side. */ @@ -21,9 +21,7 @@ export type PngWorkerJob = | { kind: 'encode'; width: number; height: number; data: Uint8Array } | { kind: 'crop'; png: Uint8Array; label: string; box: Rect } | { kind: 'rgb-difference'; firstPng: Uint8Array; secondPng: Uint8Array; label: string } - | ({ kind: 'diff-pixels' } & ScreenshotDiffPixelsJob) - // A provider screenshot in whatever container it arrived in; answers PNG bytes. - | { kind: 'jpeg-to-png'; image: Uint8Array; label: string }; + | ({ kind: 'diff-pixels' } & ScreenshotDiffPixelsJob); export type PngWorkerJobResult = | { kind: 'decode'; width: number; height: number; data: Uint8Array } @@ -32,8 +30,7 @@ export type PngWorkerJobResult = // A crop answers `null` when the box already covers the image, so the caller keeps the file. | { kind: 'crop'; png: Uint8Array | null } | ({ kind: 'rgb-difference' } & PngRgbDifferenceResult) - | ({ kind: 'diff-pixels' } & ScreenshotDiffPixelsResult) - | { kind: 'jpeg-to-png'; png: Uint8Array }; + | ({ kind: 'diff-pixels' } & ScreenshotDiffPixelsResult); export type PngWorkerJobKind = PngWorkerJob['kind']; diff --git a/packages/capture-kit/src/png-worker.ts b/packages/capture-kit/src/png-worker.ts index da551690c9..8e625a6bc1 100644 --- a/packages/capture-kit/src/png-worker.ts +++ b/packages/capture-kit/src/png-worker.ts @@ -55,13 +55,6 @@ async function runJob(request: PngWorkerRequest): Promise { case 'diff-pixels': { return { kind: 'diff-pixels', ...computeScreenshotDiffPixels(request) }; } - case 'jpeg-to-png': { - const { transcodeScreenshotToPng } = await import('./screenshot-image.ts'); - return { - kind: 'jpeg-to-png', - png: transcodeScreenshotToPng(toBuffer(request.image), request.label), - }; - } } } @@ -102,8 +95,6 @@ function resultBufferViews(result: PngWorkerJobResult): Uint8Array[] { return []; case 'diff-pixels': return [result.diffData, result.diffMask]; - case 'jpeg-to-png': - return [result.png]; } } diff --git a/packages/capture-kit/src/screenshot-image.test.ts b/packages/capture-kit/src/screenshot-image.test.ts index 0f8be1acc8..cad4e7c0af 100644 --- a/packages/capture-kit/src/screenshot-image.test.ts +++ b/packages/capture-kit/src/screenshot-image.test.ts @@ -2,11 +2,7 @@ import { expect, test } from 'vitest'; import fc from 'fast-check'; import { encode as encodeJpeg } from 'jpeg-js'; import { PNG } from './png.ts'; -import { - decodeScreenshotImage, - detectScreenshotImageFormat, - transcodeScreenshotToPng, -} from './screenshot-image.ts'; +import { decodeScreenshotImage, detectScreenshotImageFormat } from './screenshot-image.ts'; function solidRgba(width: number, height: number, rgba: readonly [number, number, number, number]) { const data = Buffer.alloc(width * height * 4); @@ -24,32 +20,9 @@ function thrownBy(run: () => unknown): unknown { throw new Error('expected the call to throw'); } -test('a JPEG screenshot becomes a PNG of the same size with the same picture', () => { - const jpeg = encodeJpeg({ width: 6, height: 4, data: solidRgba(6, 4, [200, 30, 30, 255]) }, 100); - expect(detectScreenshotImageFormat(jpeg.data)).toBe('jpeg'); - - const png = transcodeScreenshotToPng(jpeg.data, 'test screenshot'); - - expect(detectScreenshotImageFormat(png)).toBe('png'); - const decoded = PNG.sync.read(png); - expect([decoded.width, decoded.height]).toEqual([6, 4]); - const [r = -1, g = -1, b = -1, a = -1] = decoded.data.subarray(0, 4); - // JPEG is lossy; a flat field survives within a few levels per channel. - expect(Math.abs(r - 200)).toBeLessThanOrEqual(4); - expect(Math.abs(g - 30)).toBeLessThanOrEqual(4); - expect(Math.abs(b - 30)).toBeLessThanOrEqual(4); - expect(a).toBe(255); -}); - -test('a PNG screenshot passes through byte for byte', () => { - const png = PNG.sync.write(new PNG({ width: 3, height: 2 })); - - expect(transcodeScreenshotToPng(png, 'test screenshot')).toBe(png); -}); - test('bytes that are neither container are refused with a typed error carrying the label', () => { expect( - thrownBy(() => transcodeScreenshotToPng(Buffer.from('GIF89a'), 'Limrun iOS screenshot')), + thrownBy(() => decodeScreenshotImage(Buffer.from('GIF89a'), 'Limrun iOS screenshot')), ).toMatchObject({ code: 'COMMAND_FAILED', message: 'Limrun iOS screenshot is neither PNG nor JPEG', @@ -61,7 +34,7 @@ test('a valid SOI marker followed by garbage is refused with the typed decode er const garbage = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(32, 0x41)]); expect(detectScreenshotImageFormat(garbage)).toBe('jpeg'); - expect(thrownBy(() => transcodeScreenshotToPng(garbage, 'Limrun iOS screenshot'))).toMatchObject({ + expect(thrownBy(() => decodeScreenshotImage(garbage, 'Limrun iOS screenshot'))).toMatchObject({ code: 'COMMAND_FAILED', message: 'Failed to decode Limrun iOS screenshot as JPEG', details: { label: 'Limrun iOS screenshot', reason: expect.any(String) }, @@ -72,9 +45,7 @@ test('a truncated JPEG body is refused with the same typed decode error', () => const jpeg = encodeJpeg({ width: 6, height: 4, data: solidRgba(6, 4, [10, 200, 30, 255]) }, 100); const truncated = jpeg.data.subarray(0, 24); - expect( - thrownBy(() => transcodeScreenshotToPng(truncated, 'Limrun iOS screenshot')), - ).toMatchObject({ + expect(thrownBy(() => decodeScreenshotImage(truncated, 'Limrun iOS screenshot'))).toMatchObject({ code: 'COMMAND_FAILED', message: 'Failed to decode Limrun iOS screenshot as JPEG', }); diff --git a/packages/capture-kit/src/screenshot-image.ts b/packages/capture-kit/src/screenshot-image.ts index 0a3cf9e3cd..9a64e4cb6b 100644 --- a/packages/capture-kit/src/screenshot-image.ts +++ b/packages/capture-kit/src/screenshot-image.ts @@ -30,22 +30,6 @@ export function decodeScreenshotImage(bytes: Buffer, label: string, maxPixels?: throw unsupportedContainer(label, bytes); } -/** - * Returns PNG bytes for a screenshot a provider handed back in whatever container it prefers. PNG - * passes through untouched; JPEG is decoded and re-encoded losslessly from the decoded pixels, so - * every PNG-only reader downstream (size, crop, overlay) sees the format the path promises. Those - * readers rewrite the file in place, which is why a JPEG cannot be handed to them undecoded. - * - * Synchronous and CPU-bound: daemon request paths reach it through the PNG worker - * (`transcodeScreenshotToPngAsync` in `png-worker-client.ts`), like every other codec job. - */ -export function transcodeScreenshotToPng(bytes: Buffer, label: string): Buffer { - const format = detectScreenshotImageFormat(bytes); - if (format === 'png') return bytes; - if (format === 'jpeg') return PNG.sync.write(decodeJpegImage(bytes, label)); - throw unsupportedContainer(label, bytes); -} - /** A JPEG signature does not prove a decodable body; a failure keeps the label and the decoder's reason. */ function decodeJpegImage(bytes: Buffer, label: string, maxPixels?: number): PNG { try { diff --git a/packages/command-registry/src/flag-definitions-target.ts b/packages/command-registry/src/flag-definitions-target.ts index 744cb6fb12..04808b414e 100644 --- a/packages/command-registry/src/flag-definitions-target.ts +++ b/packages/command-registry/src/flag-definitions-target.ts @@ -423,7 +423,7 @@ export const TARGET_FLAG_DEFINITIONS: readonly FlagDefinition[] = [ type: 'string', usageLabel: '--launch-console ', usageDescription: - 'open: capture the initial local iOS simulator launch console window to a file', + 'open: capture the initial local or Limrun iOS simulator launch console window to a file', projectConfig: false, recorded: false, }, diff --git a/packages/command-surface/src/capture/alert.ts b/packages/command-surface/src/capture/alert.ts index ef69de3bfd..62b96e6160 100644 --- a/packages/command-surface/src/capture/alert.ts +++ b/packages/command-surface/src/capture/alert.ts @@ -17,7 +17,7 @@ import { AppError } from '@agent-device/kernel/errors'; const ALERT_COMMAND_NAME = 'alert'; const alertCommandDescription = - "Inspect, wait for, accept, or dismiss a platform alert. Use get before acting when the alert content matters; accept and dismiss change the active alert state. Each iOS XCTest execution activates once, then only observes. Local Apple XCTest and Limrun iOS choose recognized English labels by preference (Allow While Using App before Allow Once); on iOS simulators, local XCTest also recognizes the device's preferred languages' wording of those labels from the OS permission-prompt strings, on any alert, while Limrun iOS stays English; equal-ranked choices and unknown labels refuse, including a lone unknown button. Open, Done, and Confirm… accept; Keep Browsing, Deny, and Skip dismiss. On the Apple XCUITest runner, dismiss taps only a recognized decline label (Cancel, Don't Allow, Ask App Not to Track, and similar) or the close element of a popover whose accessibility marker reads exactly \"dismiss popup\"; when no button fits the action, accept and dismiss answer ALERT_BUTTON_NOT_FOUND having tapped nothing — read labels with alert get, then press the button by label. Inspect again after an unconfirmed action; never assume a timeout means nothing happened."; + "Inspect, wait for, accept, or dismiss a platform alert. Use get before acting when the alert content matters; accept and dismiss change the active alert state. Each iOS XCTest execution activates once, then only observes. Apple XCTest and Limrun iOS choose recognized English labels by preference (Allow While Using App before Allow Once); on iOS simulators, the runner also recognizes the device's preferred languages' wording of those labels from the OS permission-prompt strings, on any alert; equal-ranked choices and unknown labels refuse, including a lone unknown button. Open, Done, and Confirm… accept; Keep Browsing, Deny, and Skip dismiss. On the Apple XCUITest runner, dismiss taps only a recognized decline label (Cancel, Don't Allow, Ask App Not to Track, and similar) or the close element of a popover whose accessibility marker reads exactly \"dismiss popup\"; when no button fits the action, accept and dismiss answer ALERT_BUTTON_NOT_FOUND having tapped nothing — read labels with alert get, then press the button by label. Inspect again after an unconfirmed action; never assume a timeout means nothing happened."; const alertCommandMetadata = defineFieldCommandMetadata( ALERT_COMMAND_NAME, diff --git a/packages/command-surface/src/management/app.ts b/packages/command-surface/src/management/app.ts index c5245bf81b..89df4a15a6 100644 --- a/packages/command-surface/src/management/app.ts +++ b/packages/command-surface/src/management/app.ts @@ -53,7 +53,7 @@ const openCommandMetadata = defineFieldCommandMetadata( ), activity: stringField('Android activity name.'), launchConsole: stringField( - 'File path on the daemon host for the stdout and stderr of the initial app launch. Local iOS simulator direct app launches only; not for URL opens.', + 'File path on the daemon host for the stdout and stderr of the initial app launch. Local or Limrun iOS simulator direct app launches only; not for URL opens.', ), launchArgs: stringArrayField( 'Launch arguments forwarded verbatim to the platform launch command.', @@ -209,7 +209,7 @@ export const openCommandFacet = defineCommandFacet({ text: { summary: 'Open an app, deep link or URL, save replays', cliDetail: - 'Use --platform to bind URL/deep-link opens to the target platform. For local iOS simulator initial stdout/stderr, put --launch-console on this open command, for example agent-device open "Agent Device Tester" --platform ios --launch-console artifacts/launch-console.log. Expo Go/dev-client shells accept host + URL, for example agent-device open "Expo Go" exp://127.0.0.1:8081 --platform ios. macOS also supports --surface app|frontmost-app|desktop|menubar. --metro-host/--metro-port/--bundle-url choose the app\'s Metro server and save that address for later metro reload calls in the same session. A fresh open without Metro hints clears an older same-name binding; close also clears it. --launch-url supplies the app\'s launch URL; it does not select the reload server. On an iOS simulator without --launch-url, open connects a detected Expo development client through its expo-development-client URL. The app must declare a single exp+ scheme that no other installed app owns. If detection is inconclusive, the app opens normally. Bare React Native apps receive their server settings before launch. On an iOS simulator, when --launch-url raises an "Open in ?" prompt that keeps open from seeing the app, open accepts it if the URL scheme belongs to the session app (data.launchConfirmation: "accepted"); if the scheme belongs to another installed app, the prompt stays on screen and open fails with details.reason launch_confirmation_foreign_app; if the scheme owner cannot be resolved because no single installed app owns it or the lookup failed, the prompt stays on screen and open returns with data.launchConfirmation: "unanswered", launchConfirmationReason url-owner-unresolved, and a warning; a prompt that is seen but not recognized is reported the same way with launchConfirmationReason alert-unrecognized and its own warning; for url-owner-unresolved, answer the prompt with alert accept; for alert-unrecognized, read the alert with alert get first, because it can be the app\'s own alert (such as a permission prompt) rather than the launch confirmation, and answer with alert accept only when it is the launch confirmation; when agent-device cannot read whether a prompt is up at all (the alert read, the accept, or the runner itself failed, including after a re-hand of the URL), open stays green, reports no launchConfirmation plus data.launchConfirmationProbeFailure naming the failed step (alert-read, alert-accept, or runner), and appends a warning that says to check with alert get. The prompt is read whenever open cannot see the app afterwards, including on a slow host where the app\'s target discovery fails on its own deadline. When an accept leaves the app provably not running, open hands the same URL over once more and reads again; still not running, it fails with details.reason launch_confirmation_unanswered. When the runner reports its main-thread timeout for the accept and the prompt is still up, open presses Open again, but only when the time the open request has left covers a whole press and the read that checks it. A recognized prompt still visible after a failed accept, or once that time is gone, also fails with this reason, even if the app is running behind it; answer it with alert accept or alert dismiss. A simulator openurl that CoreSimulator never answers fails within 20s with details.reason ios-simulator-openurl-timeout. The prompt is recognized by the OS\'s own wording of its title and buttons in the Simulator\'s preferred languages; when the runner cannot read that wording, the prompt stays on screen as alert-unrecognized. A prompt that appears after open has already seen the app is not answered; use alert accept. A device another session is holding refuses at once; add --wait to block up to that budget for it and only then fail with DEVICE_IN_USE naming the owning session.', + 'Use --platform to bind URL/deep-link opens to the target platform. For local or Limrun iOS simulator initial stdout/stderr, put --launch-console on this open command, for example agent-device open "Agent Device Tester" --platform ios --launch-console artifacts/launch-console.log. Expo Go/dev-client shells accept host + URL, for example agent-device open "Expo Go" exp://127.0.0.1:8081 --platform ios. macOS also supports --surface app|frontmost-app|desktop|menubar. --metro-host/--metro-port/--bundle-url choose the app\'s Metro server and save that address for later metro reload calls in the same session. A fresh open without Metro hints clears an older same-name binding; close also clears it. --launch-url supplies the app\'s launch URL; it does not select the reload server. On an iOS simulator without --launch-url, open connects a detected Expo development client through its expo-development-client URL. The app must declare a single exp+ scheme that no other installed app owns. If detection is inconclusive, the app opens normally. Bare React Native apps receive their server settings before launch. On an iOS simulator, when --launch-url raises an "Open in ?" prompt that keeps open from seeing the app, open accepts it if the URL scheme belongs to the session app (data.launchConfirmation: "accepted"); if the scheme belongs to another installed app, the prompt stays on screen and open fails with details.reason launch_confirmation_foreign_app; if the scheme owner cannot be resolved because no single installed app owns it or the lookup failed, the prompt stays on screen and open returns with data.launchConfirmation: "unanswered", launchConfirmationReason url-owner-unresolved, and a warning; a prompt that is seen but not recognized is reported the same way with launchConfirmationReason alert-unrecognized and its own warning; for url-owner-unresolved, answer the prompt with alert accept; for alert-unrecognized, read the alert with alert get first, because it can be the app\'s own alert (such as a permission prompt) rather than the launch confirmation, and answer with alert accept only when it is the launch confirmation; when agent-device cannot read whether a prompt is up at all (the alert read, the accept, or the runner itself failed, including after a re-hand of the URL), open stays green, reports no launchConfirmation plus data.launchConfirmationProbeFailure naming the failed step (alert-read, alert-accept, or runner), and appends a warning that says to check with alert get. The prompt is read whenever open cannot see the app afterwards, including on a slow host where the app\'s target discovery fails on its own deadline. When an accept leaves the app provably not running, open hands the same URL over once more and reads again; still not running, it fails with details.reason launch_confirmation_unanswered. When the runner reports its main-thread timeout for the accept and the prompt is still up, open presses Open again, but only when the time the open request has left covers a whole press and the read that checks it. A recognized prompt still visible after a failed accept, or once that time is gone, also fails with this reason, even if the app is running behind it; answer it with alert accept or alert dismiss. A simulator openurl that CoreSimulator never answers fails within 20s with details.reason ios-simulator-openurl-timeout. The prompt is recognized by the OS\'s own wording of its title and buttons in the Simulator\'s preferred languages; when the runner cannot read that wording, the prompt stays on screen as alert-unrecognized. A prompt that appears after open has already seen the app is not answered; use alert accept. A device another session is holding refuses at once; add --wait to block up to that budget for it and only then fail with DEVICE_IN_USE naming the owning session.', mcpDetail: "metroHost, metroPort, and bundleUrl choose the app's Metro server and save its address for later reloads. launchUrl selects the app's launch URL and does not select the reload server. A fresh open without Metro hints clears an older same-name binding; close also clears it.", }, diff --git a/packages/host-kit/src/internal/request-cancel.test.ts b/packages/host-kit/src/internal/request-cancel.test.ts index 2f4d38b031..22bead1e33 100644 --- a/packages/host-kit/src/internal/request-cancel.test.ts +++ b/packages/host-kit/src/internal/request-cancel.test.ts @@ -1,12 +1,14 @@ import { test, vi } from 'vitest'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import assert from 'node:assert/strict'; +import { getEventListeners } from 'node:events'; import { AppError, createRequestCanceledError, isRequestCanceledError, } from '@agent-device/kernel/errors'; import { + awaitWithSignal, clearRequestAbortRegistration, clearRequestCanceled, markRequestCanceled, @@ -17,6 +19,28 @@ import { withRequestDeadline, } from './request-cancel.ts'; +test.each(['before', 'after'] as const)( + 'a throwing abort callback rejects when cancellation occurs %s listener registration', + async (timing) => { + const controller = new AbortController(); + const failure = new Error('operation stop failed'); + if (timing === 'before') controller.abort(); + const pending = awaitWithSignal(new Promise(() => {}), controller.signal, () => { + throw failure; + }); + const rejected = assert.rejects(pending, (error) => error === failure); + if (timing === 'after') controller.abort(); + await rejected; + }, +); + +test('a pre-aborted wait does not retain an abort listener on an unsettled operation', async () => { + const controller = new AbortController(); + controller.abort(new Error('canceled')); + await assert.rejects(awaitWithSignal(new Promise(() => {}), controller.signal)); + assert.equal(getEventListeners(controller.signal, 'abort').length, 0); +}); + test('an early live request remains cancelable after more than 50,000 registrations', () => { const requestId = 'early-live-request'; const registration = registerRequestAbort(requestId)!; diff --git a/packages/host-kit/src/internal/request-cancel.ts b/packages/host-kit/src/internal/request-cancel.ts index 4b4b98d866..5f87d4bffa 100644 --- a/packages/host-kit/src/internal/request-cancel.ts +++ b/packages/host-kit/src/internal/request-cancel.ts @@ -247,3 +247,34 @@ export async function withRequestDeadline( clearRequestAbortRegistration(ownedRegistration); } } + +/** Cancels the wait; the caller supplies any operation-specific stop and error handling. */ +export async function awaitWithSignal( + pending: Promise, + signal?: AbortSignal, + onAbort: () => unknown = () => signal?.reason, +): Promise { + if (!signal) return await pending; + return await new Promise((resolve, reject) => { + const aborted = () => { + signal.removeEventListener('abort', aborted); + try { + reject(onAbort()); + } catch (error) { + reject(error); + } + }; + signal.addEventListener('abort', aborted, { once: true }); + if (signal.aborted) aborted(); + void pending.then( + (value) => { + signal.removeEventListener('abort', aborted); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener('abort', aborted); + reject(error); + }, + ); + }); +} diff --git a/packages/host-kit/src/request.ts b/packages/host-kit/src/request.ts index a0bc4cebc9..ba11a9a4e6 100644 --- a/packages/host-kit/src/request.ts +++ b/packages/host-kit/src/request.ts @@ -1,4 +1,5 @@ export { + awaitWithSignal, cancelAllRequests, clearRequestAbortRegistration, clearRequestCanceled, diff --git a/packages/platform-apple/package.json b/packages/platform-apple/package.json index 3c1c72d7d7..47a3a8a2e5 100644 --- a/packages/platform-apple/package.json +++ b/packages/platform-apple/package.json @@ -73,10 +73,6 @@ "types": "./src/simctl-facade.ts", "default": "./src/simctl-facade.ts" }, - "./simctl-settings": { - "types": "./src/simctl-settings-facade.ts", - "default": "./src/simctl-settings-facade.ts" - }, "./simulator": { "types": "./src/simulator-facade.ts", "default": "./src/simulator-facade.ts" @@ -91,6 +87,7 @@ } }, "devDependencies": { - "@agent-device/test-kit": "workspace:*" + "@agent-device/test-kit": "workspace:*", + "plist": "5.0.0" } } diff --git a/packages/platform-apple/src/core/plist.ts b/packages/platform-apple/src/core/plist.ts index 97c40e0cb2..14a69520db 100644 --- a/packages/platform-apple/src/core/plist.ts +++ b/packages/platform-apple/src/core/plist.ts @@ -62,3 +62,13 @@ function readXmlPlistString(plist: string, key: string): string | undefined { }); return result; } + +/** Parses downloaded app metadata on hosts without Apple's plist tools. */ +export async function parseApplePlist(bytes: Uint8Array): Promise | null> { + const { parse } = await import('plist'); + const binary = new TextDecoder().decode(bytes.subarray(0, 8)) === 'bplist00'; + const value = parse(binary ? bytes : new TextDecoder().decode(bytes)); + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} diff --git a/packages/platform-apple/src/core/runner-client.ts b/packages/platform-apple/src/core/runner-client.ts index 6b4859b5e6..9dd55eefb1 100644 --- a/packages/platform-apple/src/core/runner-client.ts +++ b/packages/platform-apple/src/core/runner-client.ts @@ -41,6 +41,11 @@ export async function runAppleRunnerCommand( } export { + RUNNER_STARTUP_TIMEOUT_MS, + createLaunchedAppleRunnerProvider, + mintRunnerToken, + runnerListenerEnv, + RUNNER_XCTESTRUN_CAPTURE_OPTIONS, applyXctestRunnerAppIconFromDerivedPath, cleanupRunnerLeasesForOwner, detachIosRunnerSessionsForShutdown, diff --git a/packages/platform-apple/src/runner-operations-facade.ts b/packages/platform-apple/src/runner-operations-facade.ts index 2dfc83c912..4295aa7f00 100644 --- a/packages/platform-apple/src/runner-operations-facade.ts +++ b/packages/platform-apple/src/runner-operations-facade.ts @@ -1,4 +1,9 @@ export { + RUNNER_STARTUP_TIMEOUT_MS, + createLaunchedAppleRunnerProvider, + mintRunnerToken, + runnerListenerEnv, + RUNNER_XCTESTRUN_CAPTURE_OPTIONS, applyXctestRunnerAppIconFromDerivedPath, detachIosRunnerSessionsForShutdown, findRunnerXctestrun, diff --git a/packages/platform-apple/src/simctl-settings-facade.ts b/packages/platform-apple/src/simctl-settings-facade.ts deleted file mode 100644 index 44ae720307..0000000000 --- a/packages/platform-apple/src/simctl-settings-facade.ts +++ /dev/null @@ -1 +0,0 @@ -export { applySimctlSetting } from './core/simctl-settings.ts'; diff --git a/packages/platform-apple/src/tool-provider-facade.ts b/packages/platform-apple/src/tool-provider-facade.ts index 6db3885c6c..9842629ef0 100644 --- a/packages/platform-apple/src/tool-provider-facade.ts +++ b/packages/platform-apple/src/tool-provider-facade.ts @@ -11,9 +11,15 @@ export type { AppleMacOsHostProvider, ApplePlistProvider, AppleToolProvider, + AppleToolExecOptions, AppleToolSubcommandExecutor, } from './core/tool-provider.ts'; +export async function parseApplePlist(bytes: Uint8Array): Promise | null> { + const plist = await import('./core/plist.ts'); + return await plist.parseApplePlist(bytes); +} + export async function readSimctlContainerPath(stdout: string): Promise { const simctl = await import('./core/simctl.ts'); return simctl.readSimctlContainerPath(stdout); diff --git a/packages/provider-limrun/package.json b/packages/provider-limrun/package.json index 6bd2406c11..4dfeb6fc97 100644 --- a/packages/provider-limrun/package.json +++ b/packages/provider-limrun/package.json @@ -17,6 +17,7 @@ } }, "devDependencies": { + "@agent-device/platform-apple": "workspace:*", "@limrun/api": "^0.68.1" } } diff --git a/packages/provider-limrun/src/alert-detection.fixtures.ts b/packages/provider-limrun/src/alert-detection.fixtures.ts deleted file mode 100644 index 9b3af0f8eb..0000000000 --- a/packages/provider-limrun/src/alert-detection.fixtures.ts +++ /dev/null @@ -1,354 +0,0 @@ -import type { IosTreeNode } from '@agent-device/capture-kit/ios-ax-tree'; - -/** - * Trees shaped from live Limrun iOS instances (issue #3349 evidence). The shapes are what the - * detection rules are actually tested against, so each keeps the provider's real fields: - * `role`/`type` pairs, `AXLabel`, per-node `pid`, and a `frame` dictionary. - * - * The captured discriminator: a system alert arrives as its own `AXApplication` root whose - * `AXLabel` is blank and whose `pid` (and every child's `pid`) is the hosting process, not the - * app's. The app's own `UIAlertController` arrives inside the app's root with the app's `pid`. - */ - -/** The app under test, as the app's own root reports it. */ -export const LIMRUN_APP_PID = 59_012; -/** The process hosting system alerts (SpringBoard's alert agent on the instance). */ -export const LIMRUN_ALERT_HOST_PID = 24_261; -/** SpringBoard, which contributes the status-bar nodes that ride along on app screens. */ -export const LIMRUN_SPRINGBOARD_PID = 33_887; - -export const LIMRUN_ALERT_SCREEN: Readonly<{ width: number; height: number }> = Object.freeze({ - width: 402, - height: 874, -}); - -function alertRoot(children: IosTreeNode[]): IosTreeNode { - return { - type: 'Application', - role: 'AXApplication', - AXLabel: ' ', - pid: LIMRUN_ALERT_HOST_PID, - frame: { x: 0, y: 0, ...LIMRUN_ALERT_SCREEN }, - traits: ['None'], - children, - }; -} - -/** Positive control: the live ATT prompt tree — its own root, host pid, two texts, two buttons. */ -export function limrunAttAlertTree(): IosTreeNode { - return alertRoot([ - { - type: 'StaticText', - role: 'AXStaticText', - AXLabel: 'Allow “ATTProbe” to track your activity across other companies’ apps and websites?', - pid: LIMRUN_ALERT_HOST_PID, - frame: { x: 71, y: 381.33, width: 260, height: 64.33 }, - traits: ['Scrollable', 'StaticText'], - }, - { - type: 'StaticText', - role: 'AXStaticText', - AXLabel: 'This build asks for tracking authorization once, for automation testing.', - pid: LIMRUN_ALERT_HOST_PID, - frame: { x: 71, y: 453.33, width: 260, height: 38 }, - traits: ['Scrollable', 'StaticText'], - }, - { - type: 'Button', - role: 'AXButton', - AXLabel: 'Ask App Not to Track', - pid: LIMRUN_ALERT_HOST_PID, - enabled: true, - frame: { x: 57, y: 511.67, width: 288, height: 48 }, - traits: ['Button', 'Scrollable'], - }, - { - type: 'Button', - role: 'AXButton', - AXLabel: 'Allow', - pid: LIMRUN_ALERT_HOST_PID, - enabled: true, - frame: { x: 57, y: 567.67, width: 288, height: 48 }, - traits: ['Button', 'Scrollable'], - }, - ]); -} - -/** The same alert with its buttons removed: a text sheet is not an actionable alert. */ -export function limrunAlertTreeWithoutButtons(): IosTreeNode { - const root = limrunAttAlertTree(); - return { ...root, children: (root.children ?? []).filter((n) => n.role !== 'AXButton') }; -} - -/** - * The standard iOS location prompt shape: three choices, two of which grant ("Allow Once" and - * "Allow While Using App"). The capture itself never marks a preferred grant; the shared - * preference table does, so `alert accept` presses the lasting grant rather than refusing. - */ -export function limrunLocationAlertTree(): IosTreeNode { - return alertRoot([ - { - type: 'StaticText', - role: 'AXStaticText', - AXLabel: '“Probe” Would Like to Use Your Current Location', - pid: LIMRUN_ALERT_HOST_PID, - frame: { x: 71, y: 381.33, width: 260, height: 64.33 }, - traits: ['Scrollable', 'StaticText'], - }, - { - type: 'Button', - role: 'AXButton', - AXLabel: "Don't Allow", - pid: LIMRUN_ALERT_HOST_PID, - enabled: true, - frame: { x: 57, y: 511.67, width: 288, height: 48 }, - traits: ['Button', 'Scrollable'], - }, - { - type: 'Button', - role: 'AXButton', - AXLabel: 'Allow Once', - pid: LIMRUN_ALERT_HOST_PID, - enabled: true, - frame: { x: 57, y: 567.67, width: 288, height: 48 }, - traits: ['Button', 'Scrollable'], - }, - { - type: 'Button', - role: 'AXButton', - AXLabel: 'Allow While Using App', - pid: LIMRUN_ALERT_HOST_PID, - enabled: true, - frame: { x: 57, y: 623.67, width: 288, height: 48 }, - traits: ['Button', 'Scrollable'], - }, - ]); -} - -/** - * The live ATT prompt with every choice degraded out of action: one disabled, one explicitly - * unhittable, one label-less and frameless. This is what a capture looks like when the tap was - * ignored or the sheet is mid-transition — the dialog is still visibly up, yet nothing on it - * can be pressed. - */ -export function limrunAlertTreeWithUnpressableButtons(): IosTreeNode { - const root = limrunAttAlertTree(); - return { - ...root, - children: [ - ...(root.children ?? []).slice(0, 2), - { - type: 'Button', - role: 'AXButton', - AXLabel: 'Allow', - pid: root.pid, - enabled: false, - frame: { x: 57, y: 511.67, width: 288, height: 48 }, - traits: ['Button'], - }, - { - type: 'Button', - role: 'AXButton', - AXLabel: 'Ask App Not to Track', - pid: root.pid, - enabled: true, - hittable: false, - frame: { x: 57, y: 567.67, width: 288, height: 48 }, - traits: ['Button'], - }, - { - type: 'Button', - role: 'AXButton', - AXLabel: 'Later', - pid: root.pid, - enabled: true, - traits: ['Button'], - }, - ], - }; -} - -/** An alert whose only choices are ambiguous: no label classifies as accept or dismiss. */ -export function limrunAlertAmbiguousButtonsTree(): IosTreeNode { - const root = limrunAttAlertTree(); - return { - ...root, - children: [ - ...(root.children ?? []).slice(0, 2), - { - type: 'Button', - role: 'AXButton', - AXLabel: 'Maybe Later', - pid: LIMRUN_ALERT_HOST_PID, - enabled: true, - frame: { x: 57, y: 511.67, width: 288, height: 48 }, - traits: ['Button', 'Scrollable'], - }, - { - type: 'Button', - role: 'AXButton', - AXLabel: 'More Info', - pid: LIMRUN_ALERT_HOST_PID, - enabled: true, - frame: { x: 57, y: 567.67, width: 288, height: 48 }, - traits: ['Button', 'Scrollable'], - }, - ], - }; -} - -/** The live app home screen: app root with the app pid, plus SpringBoard status-bar nodes. */ -export function limrunAppScreenTree(): IosTreeNode { - return { - type: 'Application', - role: 'AXApplication', - AXLabel: 'ATTProbe', - pid: LIMRUN_APP_PID, - frame: { x: 0, y: 0, ...LIMRUN_ALERT_SCREEN }, - traits: ['None'], - children: [ - { - type: 'StaticText', - role: 'AXStaticText', - AXLabel: 'ATT Probe', - pid: LIMRUN_APP_PID, - frame: { x: 161, y: 418.5, width: 80, height: 20.33 }, - }, - { - type: 'Button', - role: 'AXButton', - AXLabel: 'Request tracking', - AXUniqueId: 'request-tracking', - pid: LIMRUN_APP_PID, - enabled: true, - frame: { x: 136.67, y: 441, width: 129, height: 20.33 }, - }, - { - type: 'Button', - role: 'AXButton', - AXLabel: 'Present app alert', - AXUniqueId: 'present-app-alert', - pid: LIMRUN_APP_PID, - enabled: true, - frame: { x: 136, y: 463.33, width: 130.33, height: 20.33 }, - }, - { - type: 'StaticText', - role: 'AXStaticText', - AXLabel: '21:15', - pid: LIMRUN_SPRINGBOARD_PID, - frame: { x: 53, y: 22.33, width: 42, height: 20.33 }, - }, - { - type: 'GenericElement', - role: 'AXGenericElement', - AXLabel: 'Cellular', - pid: LIMRUN_SPRINGBOARD_PID, - frame: { x: 288.33, y: 26.33, width: 19.33, height: 12.33 }, - }, - { - type: 'GenericElement', - role: 'AXGenericElement', - AXLabel: '100% battery power', - pid: LIMRUN_SPRINGBOARD_PID, - frame: { x: 339.33, y: 26, width: 27.33, height: 13 }, - }, - ], - }; -} - -/** - * Negative control: the live app-owned `UIAlertController` ("Unsaved changes" / Cancel / - * Discard). It shares the flat text+button shape of a system alert but rides the app's root - * with the app's pid, so the alert legs must refuse to treat it as one. - */ -export function limrunAppAlertDialogTree(): IosTreeNode { - const home = limrunAppScreenTree(); - return { - ...home, - children: [ - { - type: 'StaticText', - role: 'AXStaticText', - AXLabel: 'Unsaved changes', - pid: LIMRUN_APP_PID, - frame: { x: 82, y: 408.67, width: 238, height: 20.33 }, - }, - { - type: 'StaticText', - role: 'AXStaticText', - AXLabel: 'Leave without saving?', - pid: LIMRUN_APP_PID, - frame: { x: 82, y: 432.67, width: 238, height: 15.67 }, - }, - { - type: 'Button', - role: 'AXButton', - AXLabel: 'Cancel', - pid: LIMRUN_APP_PID, - enabled: true, - frame: { x: 66, y: 469.33, width: 135, height: 44 }, - traits: ['Button', 'Scrollable'], - }, - { - type: 'Button', - role: 'AXButton', - AXLabel: 'Discard', - pid: LIMRUN_APP_PID, - enabled: true, - frame: { x: 201.33, y: 469.33, width: 134.67, height: 44 }, - traits: ['Button', 'Scrollable'], - }, - ...(home.children ?? []).slice(3), - ], - }; -} - -/** SpringBoard's home screen: a blank-label root, but full of launch icons rather than a dialog. - * Root and icons share SpringBoard's pid, as the live home capture reports it. */ -export function limrunHomeScreenTree(): IosTreeNode { - return { - ...alertRoot([ - { - type: 'Button', - role: 'AXButton', - AXLabel: 'Maps', - pid: LIMRUN_SPRINGBOARD_PID, - enabled: true, - frame: { x: 24.33, y: 88, width: 168.33, height: 191 }, - traits: ['Scrollable', 'UpdatesFrequently', 'LaunchIcon'], - }, - { - type: 'Button', - role: 'AXButton', - AXLabel: 'Calendar', - pid: LIMRUN_SPRINGBOARD_PID, - enabled: true, - frame: { x: 209.67, y: 88, width: 168.33, height: 191 }, - traits: ['Scrollable', 'UpdatesFrequently', 'LaunchIcon'], - }, - ]), - pid: LIMRUN_SPRINGBOARD_PID, - }; -} - -/** - * The app screen and the ATT alert as sibling roots — what a capture with a live system alert - * over an app looks like. The alert is the actionable one here; the app screen is background. - */ -export function limrunAlertOverlayOnAppScreenTree(): IosTreeNode[] { - return [limrunAppScreenTree(), limrunAttAlertTree()]; -} - -/** - * Lookalike (not a live capture): the live app dialog of {@link limrunAppAlertDialogTree} with - * its root's label blanked, content pids untouched. The captures reject it through process - * exclusivity — every live app capture carries foreign-pid status-bar nodes on the app root, - * while the system alert root is process-exclusive. The residual the detector honestly cannot - * separate is an app dialog under a blanked root in a capture with no foreign-pid node at all; - * that is what the label-strict acting legs backstop. - */ -export function limrunBlankRootAppDialogLookalikeTree(): IosTreeNode { - const dialog = limrunAppAlertDialogTree(); - return { ...dialog, AXLabel: ' ' }; -} diff --git a/packages/provider-limrun/src/alert-detection.test.ts b/packages/provider-limrun/src/alert-detection.test.ts deleted file mode 100644 index c778438dab..0000000000 --- a/packages/provider-limrun/src/alert-detection.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - chooseLimrunAlertButton, - isLimrunAlertHostRoot, - readLimrunAlertCapture, -} from './alert-detection.ts'; -import { - limrunAlertAmbiguousButtonsTree, - limrunAlertOverlayOnAppScreenTree, - limrunAlertTreeWithUnpressableButtons, - limrunAlertTreeWithoutButtons, - limrunAppAlertDialogTree, - limrunAppScreenTree, - limrunAttAlertTree, - limrunBlankRootAppDialogLookalikeTree, - limrunHomeScreenTree, -} from './alert-detection.fixtures.ts'; - -function alertTreeWithButtons(...labels: string[]) { - const root = limrunAttAlertTree(); - return { - ...root, - children: [ - ...(root.children ?? []).slice(0, 2), - ...labels.map((label, index) => ({ - type: 'Button', - role: 'AXButton', - AXLabel: label, - pid: root.pid, - enabled: true, - frame: { x: 57, y: 511.67 + index * 56, width: 288, height: 48 }, - traits: ['Button', 'Scrollable'], - })), - ], - }; -} - -describe('isLimrunAlertHostRoot', () => { - it('names the blank-label application root a system alert arrives on', () => { - expect(isLimrunAlertHostRoot(limrunAttAlertTree())).toBe(true); - }); - - it('refuses an app-owned root: the app names its own application', () => { - expect(isLimrunAlertHostRoot(limrunAppScreenTree())).toBe(false); - expect(isLimrunAlertHostRoot(limrunAppAlertDialogTree())).toBe(false); - }); - - it('refuses roots that are not applications', () => { - expect(isLimrunAlertHostRoot({ role: 'AXGroup', AXLabel: ' ' })).toBe(false); - expect(isLimrunAlertHostRoot({ role: 'AXButton', AXLabel: ' ' })).toBe(false); - }); -}); - -describe('readLimrunAlertCapture', () => { - it('reads the live ATT alert: title, message, and both buttons in tree order', () => { - const capture = readLimrunAlertCapture(limrunAttAlertTree()); - expect(capture.kind).toBe('alert'); - if (capture.kind !== 'alert') return; - expect(capture.candidate.alert.title).toBe( - 'Allow “ATTProbe” to track your activity across other companies’ apps and websites?', - ); - expect(capture.candidate.alert.message).toBe( - 'This build asks for tracking authorization once, for automation testing.', - ); - expect(capture.candidate.alert.buttons).toEqual(['Ask App Not to Track', 'Allow']); - expect(capture.candidate.alert.platform).toBe('ios'); - expect(capture.candidate.alert.source).toBe('permission'); - expect(capture.candidate.buttons.map((button) => button.role)).toEqual(['dismiss', 'accept']); - // The tap point is the live button frame's center, rounded the way `centerOfRect` rounds. - expect(capture.candidate.buttons[1]).toEqual({ - label: 'Allow', - x: 201, - y: 592, - role: 'accept', - }); - }); - - it('finds the alert when the app screen is the first root of the capture', () => { - const capture = readLimrunAlertCapture(limrunAlertOverlayOnAppScreenTree()); - expect(capture.kind === 'alert' && capture.candidate.alert.buttons).toEqual([ - 'Ask App Not to Track', - 'Allow', - ]); - }); - - it('refuses the live app-owned UIAlertController: a named root is app UI, not a host sheet', () => { - expect(readLimrunAlertCapture(limrunAppAlertDialogTree()).kind).toBe('none'); - }); - - it('refuses a plain app screen', () => { - expect(readLimrunAlertCapture(limrunAppScreenTree()).kind).toBe('none'); - }); - - it("refuses SpringBoard's home screen: launch icons are not a dialog", () => { - expect(readLimrunAlertCapture(limrunHomeScreenTree()).kind).toBe('none'); - }); - - it('refuses a widget-bearing home screen: LaunchIcon buttons never qualify', () => { - const home = limrunHomeScreenTree(); - const withWidgets = { - ...home, - children: [ - { - type: 'StaticText', - role: 'AXStaticText', - AXLabel: 'Mostly Sunny', - pid: home.pid, - frame: { x: 24, y: 300, width: 160, height: 20 }, - }, - ...(home.children ?? []), - ], - }; - expect(readLimrunAlertCapture(withWidgets).kind).toBe('none'); - }); - - it('reports a text-only sheet on a blank root as undecided, not absence', () => { - // Buttons can drop out between captures mid-transition; a message on a host-shaped root - // is alert-shaped content the reader cannot resolve, and absence is a stronger claim. - expect(readLimrunAlertCapture(limrunAlertTreeWithoutButtons()).kind).toBe('undecided'); - }); - - it('reports a one-choice sheet as undecided: alert-shaped, not resolvable to an action', () => { - expect(readLimrunAlertCapture(alertTreeWithButtons('Allow')).kind).toBe('undecided'); - expect(readLimrunAlertCapture(alertTreeWithButtons('OK')).kind).toBe('undecided'); - }); - - it('reports a capture mixing an app screen and a text-only sheet as undecided', () => { - expect( - readLimrunAlertCapture([limrunAppScreenTree(), limrunAlertTreeWithoutButtons()]).kind, - ).toBe('undecided'); - }); - - it('reports a capture whose host sheets disagree as undecided, not as no alert', () => { - const second = { - ...limrunAttAlertTree(), - pid: 99, - children: (limrunAttAlertTree().children ?? []).map((child) => ({ ...child, pid: 99 })), - }; - expect(readLimrunAlertCapture([limrunAttAlertTree(), second]).kind).toBe('undecided'); - }); - - it('reports a blank-label root that reports no pid as undecided: unverifiable, not absent', () => { - const root = limrunAttAlertTree(); - expect(readLimrunAlertCapture({ ...root, pid: undefined }).kind).toBe('undecided'); - }); - - it('reports a blank-label root whose content belongs to another process as undecided', () => { - const root = limrunAttAlertTree(); - const foreignContent = { - ...root, - children: (root.children ?? []).map((child) => ({ ...child, pid: 4242 })), - }; - expect(readLimrunAlertCapture(foreignContent).kind).toBe('undecided'); - }); - - it('reports the blank-root app-dialog lookalike as undecided, never an actionable alert', () => { - // The live app captures ride SpringBoard status nodes (a foreign pid) on the app root; the - // system alert root is process-exclusive. Blanking the app dialog's label keeps that - // discriminator intact: the capture is alert-shaped yet never resolves to a candidate some - // action could press into. - expect(readLimrunAlertCapture(limrunBlankRootAppDialogLookalikeTree()).kind).toBe('undecided'); - }); - - it('reports a blank-label sheet sharing its root with another process as undecided', () => { - const root = limrunAttAlertTree(); - const shared = { - ...root, - children: [ - ...(root.children ?? []), - { - type: 'GenericElement', - role: 'AXGenericElement', - AXLabel: 'Cellular', - pid: 4242, - frame: { x: 288, y: 26, width: 19, height: 12 }, - }, - ], - }; - expect(readLimrunAlertCapture(shared).kind).toBe('undecided'); - }); - - it('reports a sheet whose every button is unpressable as undecided, not absence', () => { - // Disabled, explicitly unhittable, and frameless buttons press nothing; the dialog they - // belong to is still visibly up, so the capture must not claim absence. - expect(readLimrunAlertCapture(limrunAlertTreeWithUnpressableButtons()).kind).toBe('undecided'); - }); - - it('reports a permission alert from its button vocabulary and a plain one as a system dialog', () => { - const att = readLimrunAlertCapture(limrunAttAlertTree()); - expect(att.kind === 'alert' && att.candidate.alert.source).toBe('permission'); - const plain = readLimrunAlertCapture(alertTreeWithButtons('No', 'Yes')); - expect(plain.kind === 'alert' && plain.candidate.alert.source).toBe('system-dialog'); - }); -}); - -describe('chooseLimrunAlertButton', () => { - function buttonsOf(tree: Parameters[0]) { - const capture = readLimrunAlertCapture(tree); - if (capture.kind !== 'alert') throw new Error('expected an alert capture'); - return capture.candidate.buttons; - } - - it('accepts the affirmative and dismisses the denying choice of the ATT prompt', () => { - const buttons = buttonsOf(limrunAttAlertTree()); - expect(chooseLimrunAlertButton(buttons, 'accept')?.label).toBe('Allow'); - expect(chooseLimrunAlertButton(buttons, 'dismiss')?.label).toBe('Ask App Not to Track'); - }); - - it('fails an ambiguous accept closed when no button classifies as affirmative', () => { - const buttons = buttonsOf(limrunAlertAmbiguousButtonsTree()); - expect(chooseLimrunAlertButton(buttons, 'accept')).toBeNull(); - }); - - it('fails an ambiguous dismiss closed when no button classifies as denying', () => { - const buttons = buttonsOf(limrunAlertAmbiguousButtonsTree()); - expect(chooseLimrunAlertButton(buttons, 'dismiss')).toBeNull(); - }); - - it('never picks a choice of the wrong role, whatever else the alert offers', () => { - const allowOnly = buttonsOf(alertTreeWithButtons('Deny', 'Allow')); - expect(chooseLimrunAlertButton(allowOnly, 'accept')?.label).toBe('Allow'); - expect(chooseLimrunAlertButton(allowOnly, 'dismiss')?.label).toBe('Deny'); - const noAccept = buttonsOf(alertTreeWithButtons('Cancel', 'Maybe Later')); - expect(chooseLimrunAlertButton(noAccept, 'accept')).toBeNull(); - const noDismiss = buttonsOf(alertTreeWithButtons('Open Settings', 'Continue')); - expect(chooseLimrunAlertButton(noDismiss, 'dismiss')).toBeNull(); - }); - - it('classifies the classic iOS pair, the typographic apostrophe, and iOS variant grants', () => { - const classic = buttonsOf(alertTreeWithButtons("Don't Allow", 'OK')); - expect(chooseLimrunAlertButton(classic, 'accept')?.label).toBe('OK'); - expect(chooseLimrunAlertButton(classic, 'dismiss')?.label).toBe("Don't Allow"); - const curly = buttonsOf(alertTreeWithButtons('Don’t Allow', 'Allow')); - expect(chooseLimrunAlertButton(curly, 'dismiss')?.label).toBe('Don’t Allow'); - const settings = buttonsOf(alertTreeWithButtons('Not Now', 'Open Settings')); - expect(chooseLimrunAlertButton(settings, 'accept')?.label).toBe('Open Settings'); - expect(chooseLimrunAlertButton(settings, 'dismiss')?.label).toBe('Not Now'); - for (const label of ['Allow Once', 'Allow All the Time', 'Allow While Using App']) { - const granted = buttonsOf(alertTreeWithButtons('Ask App Not to Track', label)); - expect(chooseLimrunAlertButton(granted, 'accept')?.label).toBe(label); - } - }); - - it('refuses a choice two physical buttons share: pressing one of them would guess', () => { - const doubled = buttonsOf(alertTreeWithButtons('Allow', 'Deny', 'Allow')); - expect(chooseLimrunAlertButton(doubled, 'accept')).toBeNull(); - expect(chooseLimrunAlertButton(doubled, 'dismiss')?.label).toBe('Deny'); - }); -}); diff --git a/packages/provider-limrun/src/alert-detection.ts b/packages/provider-limrun/src/alert-detection.ts deleted file mode 100644 index b59fc2c60c..0000000000 --- a/packages/provider-limrun/src/alert-detection.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { - type AlertButtonRole, - classifyAlertButtonRole, - type AlertInfo, - type AlertSource, - selectRankedAlertButtonByRole, -} from '@agent-device/contracts/alert-contract'; -import { centerOfRect, type Rect } from '@agent-device/kernel/snapshot'; -import { isPositiveFiniteRect } from '@agent-device/kernel/rect'; -import type { TreeAlertObservation } from '@agent-device/capture-kit/alert-tree-driver'; -import { - readIosNodeChildren, - readIosNodeRect, - type IosTreeNode, -} from '@agent-device/capture-kit/ios-ax-tree'; - -/** - * System-alert detection over a Limrun iOS element tree (issue #3349). A Limrun iOS direct - * session has no XCUITest runner, but its element tree carries the system alert: on a live - * instance the ATT prompt arrived as its own `AXApplication` root with a blank `AXLabel` and one - * `pid` shared by its whole subtree — never the app's. The app's own `UIAlertController` rode - * inside the app's named root under the app's pid. Nothing in a capture names an owner, so the - * host rule is a stated heuristic: a host sheet qualifies only on a blank-label root reporting a - * `pid` whose entire subtree reports that same one. The named residual these captures cannot - * exclude: an app blanking its own root label while showing an unshared dialog — the acting legs - * press only label-unambiguous buttons as the backstop. Button eligibility never asserts - * hitability: captures omit `hittable` entirely, so an explicit `false` refuses and an omission - * carries no signal; enabled, label, and complete-rect rules admit a button. - */ - -/** Only the two host-sheet sources exist on this surface: no node names a package to attribute. */ -type LimrunAlertSource = Extract; - -export type LimrunAlertButton = Readonly<{ - label: string; - x: number; - y: number; - role: AlertButtonRole; -}>; - -export type LimrunAlertInfo = AlertInfo & { - buttons: string[]; - platform: 'ios'; - source: LimrunAlertSource; -}; - -export type LimrunAlertCandidate = Readonly<{ - alert: LimrunAlertInfo; - buttons: LimrunAlertButton[]; -}>; - -// The button roles come from the shared tree-alert label vocabulary in contracts/alert-contract: -// the same label states the same role on Android and here, so `alert dismiss` cannot act -// differently on the two platforms for one label. English-only, matching the untranslated -// device locale these instances run; a localized alert resolves as a candidate whose buttons -// all classify neutral, and the acting legs refuse on the button-undecided reason. -/** The subset that states a grant/deny choice; a plain Yes/No dialog is not a permission prompt. */ -const PERMISSION_LABEL_PATTERN = - /^(?:allow.*|deny|don.?t allow|ask app not to track|turn on|open settings)$/i; -/** SpringBoard marks its home-screen icons with this trait; an alert never carries it. */ -const LAUNCH_ICON_TRAIT = 'LaunchIcon'; - -function nodeLabel(node: IosTreeNode): string { - const label = node.AXLabel ?? node.label; - return typeof label === 'string' ? label.trim() : ''; -} - -/** - * The application root a system alert arrives on: the blank-label `AXApplication`. An app names - * its own root, so a labeled application is the app's scene — including when it hosts an - * in-app `UIAlertController` — and never a system host. SpringBoard also reports a blank label; - * its screens are refused downstream because they carry no dialog-shaped content. - */ -export function isLimrunAlertHostRoot(node: IosTreeNode): boolean { - if (node.role !== 'AXApplication' && node.type !== 'Application') return false; - return nodeLabel(node).length === 0; -} - -/** - * The one capture reader: what this element tree says about system alerts, in the shared - * tree-alert observation the driver confirms against. `none` states the capture carries no - * alert-shaped content on any host root; `undecided` states it carries alert-shaped content this - * reader cannot resolve into one actionable alert — more than one alert-shaped host sheet, or a - * sheet whose choices are visible yet unactionable (a degraded or shared root). Only `alert` and - * `none` carry information; `undecided` is not absence, and confirming a dismissal may accept - * `none` but never `undecided`. - */ -export type LimrunAlertCapture = TreeAlertObservation; - -/** - * The actionable alert on this capture, or why the capture answers nothing. A host sheet is - * actionable only on a blank-label, process-exclusive root (it reports a `pid` and its whole - * subtree reports that same one) offering at least two pressable choices (enabled, not reported - * unhittable, with a complete rect and a label) under at least one piece of text, with no - * SpringBoard launch icons. A root that shows the same dialog-shaped content but fails one of - * those tests — a button dropping to unhittable, disabled, frameless, or label-less between - * captures, a root sharing its subtree with another pid — still shows a dialog, so the capture - * is undecided, not absent. A named root is app UI, a home screen offers launch icons, and a - * blank root with neither a message nor a pair of button-shaped nodes carries nothing a dialog - * could have degraded from: those are absence. What these captures cannot exclude is an app - * dialog under a blanked root no other process rides; the label-strict acting legs are the - * backstop named above. - */ -export function readLimrunAlertCapture(tree: IosTreeNode | IosTreeNode[]): LimrunAlertCapture { - let candidate: LimrunAlertCandidate | null = null; - let alertShapedRoots = 0; - for (const root of Array.isArray(tree) ? tree : [tree]) { - if (!isLimrunAlertHostRoot(root)) continue; - const observed = readAlertHostRoot(root); - if (observed === 'absent') continue; - alertShapedRoots += 1; - if (observed !== 'undecided') candidate = observed; - } - // One actionable sheet among nothing else alert-shaped is the answer. Two sheets, or one that - // is alert-shaped yet unactionable, is content this reader must not resolve by guessing — - // pressing into it would pick a side the observation does not support. - if (alertShapedRoots === 0) return { kind: 'none' }; - return alertShapedRoots === 1 && candidate ? { kind: 'alert', candidate } : { kind: 'undecided' }; -} - -type LimrunHostRootReading = LimrunAlertCandidate | 'undecided' | 'absent'; - -function readAlertHostRoot(root: IosTreeNode): LimrunHostRootReading { - const descendants = collectDescendants(root); - // A home screen is a home screen: SpringBoard marks its icons, and widgets can carry text, - // so the trait answers absence before any dialog question is asked. - if (descendants.some(isLaunchIcon)) return 'absent'; - const texts = descendants - .filter(isStaticTextNode) - .map(nodeLabel) - .filter((text) => text.length > 0); - const choices = descendants.filter((node) => isButtonNode(node) && !isLaunchIcon(node)); - // Absence means nothing a dialog could have degraded from: no message and no pair of - // button-shaped nodes. - if (texts.length === 0 && choices.length < 2) return 'absent'; - // Process exclusivity is the capture-side host heuristic named above: the live system alert - // root was process-exclusive (one pid on every node) while the app root rode SpringBoard's - // status nodes under a second pid. It settles actionability, never absence. - const exclusive = - typeof root.pid === 'number' && descendants.every((node) => node.pid === root.pid); - if (!exclusive) return 'undecided'; - const buttons = findAlertButtons(descendants); - // Dialog-shaped content that is not actionable — a sheet rendered mid-transition, one whose - // buttons just dropped to disabled, unhittable, or frameless, a message-less button pair — - // still shows something alert-shaped. That is undecided, never absence, so no caller can - // report it as a confirmed no-alert. - if (texts.length === 0 || buttons.length < 2) return 'undecided'; - const [title, ...rest] = texts; - const alert: LimrunAlertInfo = { - ...(title ? { title } : {}), - ...(rest.length > 0 ? { message: [...new Set(rest)].join('\n') } : {}), - buttons: buttons.map((button) => button.label), - platform: 'ios', - source: classifyAlertSource(buttons), - }; - return { alert, buttons }; -} - -function collectDescendants(root: IosTreeNode): IosTreeNode[] { - const nodes: IosTreeNode[] = []; - const visit = (node: IosTreeNode) => { - for (const child of readIosNodeChildren(node)) { - nodes.push(child); - visit(child); - } - }; - visit(root); - return nodes; -} - -function isButtonNode(node: IosTreeNode): boolean { - return node.role === 'AXButton' || node.type === 'Button'; -} - -function isLaunchIcon(node: IosTreeNode): boolean { - return node.traits?.includes(LAUNCH_ICON_TRAIT) === true; -} - -function isStaticTextNode(node: IosTreeNode): boolean { - return node.role === 'AXStaticText' || node.type === 'StaticText'; -} - -/** - * Pressable alert choices only: a disabled button is not a choice the alert offers right now, a - * button without a complete rect has no point the touch seam could aim at, and a button the - * provider explicitly reports as `hittable: false` cannot be pressed; an omitted `hittable` - * carries no signal either way. Process membership was settled by the caller, so every physical - * node stays in the list: two buttons sharing a label are two points a tap could land on, and - * the chooser refuses rather than taking the first. - */ -function findAlertButtons(nodes: IosTreeNode[]): LimrunAlertButton[] { - const buttons: LimrunAlertButton[] = []; - for (const node of nodes) { - if (!isButtonNode(node) || node.enabled === false || node.hittable === false) continue; - const label = nodeLabel(node); - if (!label) continue; - const rect = readIosNodeRect(node); - if (!isPressableRect(rect)) continue; - const point = centerOfRect(rect); - buttons.push({ label, x: point.x, y: point.y, role: classifyAlertButtonRole(label) }); - } - return buttons; -} - -function isPressableRect(rect: Rect | undefined): rect is Rect { - return isPositiveFiniteRect(rect); -} - -/** A button vocabulary naming a grant/deny choice makes this a permission prompt. */ -function classifyAlertSource(buttons: readonly LimrunAlertButton[]): LimrunAlertSource { - return buttons.some((button) => PERMISSION_LABEL_PATTERN.test(button.label.trim())) - ? 'permission' - : 'system-dialog'; -} - -/** - * The button one action may press, or null when pressing any visible choice would guess. - * Deliberately stricter than Android's: with no `android:id/button1`-style identity to fall back - * on, only a button whose own label states the asked-for choice may be pressed, resolved through - * the shared preference table — a multi-grant sheet ("Allow Once" / "Allow While Using App" / - * "Don't Allow") resolves to the preferred grant, while equal-rank labels or a vocabulary-free - * multiplicity refuse. An accept must never press a denial, and an undecided action is refused - * rather than resolved by tree position. - */ -export function chooseLimrunAlertButton( - buttons: readonly LimrunAlertButton[], - action: 'accept' | 'dismiss', -): LimrunAlertButton | null { - return selectRankedAlertButtonByRole(buttons, action); -} diff --git a/packages/provider-limrun/src/alert.test.ts b/packages/provider-limrun/src/alert.test.ts deleted file mode 100644 index 417dbcd748..0000000000 --- a/packages/provider-limrun/src/alert.test.ts +++ /dev/null @@ -1,505 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { - ALERT_BUTTON_UNDECIDED_REASON, - ALERT_CAPTURE_UNDECIDED_REASON, -} from '@agent-device/contracts/alert-contract'; -import { AppError } from '@agent-device/kernel/errors'; -import { - limrunAlertAmbiguousButtonsTree, - limrunAlertTreeWithUnpressableButtons, - limrunAppScreenTree, - limrunAttAlertTree, - limrunLocationAlertTree, -} from './alert-detection.fixtures.ts'; -import { handleLimrunIosAlert, type LimrunIosAlertClient } from './alert.ts'; -import type { IosTreeNode } from '@agent-device/capture-kit/ios-ax-tree'; - -/** - * The legs share the tree-alert driver, whose polls sleep between captures on the shared - * deadline primitive; the clock is the assertion, not the wait (`docs/agents/testing.md`). - */ -async function underFakeTimers(run: () => Promise): Promise { - vi.useFakeTimers(); - try { - const outcome = run(); - outcome.catch(() => {}); - await vi.runAllTimersAsync(); - return await outcome; - } finally { - vi.useRealTimers(); - } -} - -function clientWithTrees(trees: Array): LimrunIosAlertClient { - const elementTree = vi.fn(async () => { - const next = trees.length > 1 ? trees.shift() : trees[0]; - // The real client answers an array for a multi-root capture and a single node otherwise. - return (Array.isArray(next) ? next : [next]) as unknown as ReturnType< - LimrunIosAlertClient['elementTree'] - >; - }); - return { elementTree, tap: vi.fn(async () => {}) }; -} - -afterEach(() => { - vi.useRealTimers(); -}); - -describe('wait budget', () => { - it('spends exactly the caller window and reports the timeout', async () => { - const client = clientWithTrees([limrunAppScreenTree()]); - - await expect( - underFakeTimers(() => handleLimrunIosAlert(client, 'wait', { timeoutMs: 5_000 })), - ).rejects.toThrow('alert wait timed out'); - // One poll per interval over the window, plus the final sample the polling primitive - // lands exactly on the deadline: a probe that starts at the boundary is a started probe, - // and the budget rule lets it answer — the deadline ends the loop, it does not unstart it. - expect(client.elementTree).toHaveBeenCalledTimes(Math.ceil(5_000 / 300) + 1); - }); - - it('treats a malformed window as the bounded default rather than an unbound loop', async () => { - const client = clientWithTrees([limrunAppScreenTree()]); - - await expect( - underFakeTimers(() => handleLimrunIosAlert(client, 'wait', { timeoutMs: Number.NaN })), - ).rejects.toThrow('alert wait timed out'); - // The fallback is the contract default, spent on the driven clock — this returned, so the - // loop had a bound, which a NaN comparison would not have given it. - expect(client.elementTree).toHaveBeenCalledTimes(Math.ceil(10_000 / 300) + 1); - }); - - it('reads a finite budget past the timer ceiling as the bounded default, never a misfire', async () => { - // 2^32 reaches `AbortSignal.timeout` as a RangeError and 2^31 as a 1 ms timer; both must - // arrive here as the contract default, spent on the driven clock. - for (const oversized of [2 ** 31, 2 ** 32]) { - const client = clientWithTrees([limrunAppScreenTree()]); - await expect( - underFakeTimers(() => handleLimrunIosAlert(client, 'wait', { timeoutMs: oversized })), - ).rejects.toThrow('alert wait timed out'); - expect(client.elementTree).toHaveBeenCalledTimes(Math.ceil(10_000 / 300) + 1); - } - }); - - it('consumes the boundary sample: an alert answering exactly at the deadline is still reported', async () => { - // The budget rule applied to `wait`: the poll that the sleep lands on the deadline is a - // started probe — it finishes and its answer counts. The old contract refused it as a late - // verdict; under the new one a sheet visible as the budget closes is exactly what `wait` - // is for. No probe ever starts once the budget has passed: the boundary sample is the last. - const client = { - elementTree: vi - .fn() - .mockResolvedValueOnce([limrunAppScreenTree()]) - .mockResolvedValueOnce([limrunAttAlertTree()]), - tap: vi.fn(async () => {}), - } satisfies LimrunIosAlertClient; - - const result = await underFakeTimers(() => - handleLimrunIosAlert(client, 'wait', { timeoutMs: 300 }), - ); - expect(result.kind).toBe('alertWait'); - expect(client.elementTree).toHaveBeenCalledTimes(2); - }); - - it('presses a candidate whose started lookup probe answered after the deadline', async () => { - // The maintainer's whole-rule pin on the acting leg: a bounded lookup probe stalling past - // its 2s budget still finishes, and the alert it names is the alert on screen — the press - // acts on it rather than refusing. The old code threw `alert-window-expired, dispatched: - // no` here even though the sheet was plainly visible to the probe it had started. - const client = { - elementTree: vi - .fn() - .mockResolvedValueOnce([limrunAppScreenTree()]) - .mockImplementationOnce( - async () => - new Promise((resolve) => { - setTimeout(() => resolve([limrunAttAlertTree()]), 3_000); - }), - ) - // The post-press confirm read sees the app screen: the press did land. - .mockResolvedValue([limrunAppScreenTree()]), - tap: vi.fn(async () => {}), - } satisfies LimrunIosAlertClient; - - const handled = await underFakeTimers(() => handleLimrunIosAlert(client, 'accept')); - expect(handled.kind).toBe('alertHandled'); - expect(client.tap).toHaveBeenCalledTimes(1); - }); - - it('surfaces a failed capture as itself, never as a timeout', async () => { - const elementTree = vi - .fn() - .mockRejectedValue( - new Error('websocket closed'), - ) as unknown as LimrunIosAlertClient['elementTree']; - const client = { elementTree, tap: vi.fn(async () => {}) } satisfies LimrunIosAlertClient; - - await expect(handleLimrunIosAlert(client, 'wait')).rejects.toThrow('websocket closed'); - }); - - it('ends the leg on cancellation only when the capture transport dies with it', async () => { - // The accepted capability limit, tested rather than assumed. The Limrun client exposes no - // abort on `elementTree`, so a caller cancel cannot unstart or cut a capture already in - // flight: awaiting it keeps the leg open (the caller's own request ends at the daemon; this - // promise is detached work no seam may fake-abort). What does end the leg is the transport - // route the SDK does have — an instance teardown that fails the pending call — and the - // driver reports that as the shared cancellation, never as a broken backend. - const caller = new AbortController(); - let settled = 'pending'; - let rejectCapture: ((error: unknown) => void) | undefined; - let markCaptureStarted!: () => void; - const captureStarted = new Promise((resolve) => { - markCaptureStarted = resolve; - }); - const client = { - elementTree: vi.fn(() => { - markCaptureStarted(); - return new Promise((_resolve, reject) => { - rejectCapture = reject; - }); - }) as unknown as LimrunIosAlertClient['elementTree'], - tap: vi.fn(async () => {}), - } satisfies LimrunIosAlertClient; - const leg = handleLimrunIosAlert(client, 'get', { signal: caller.signal }); - leg.then( - () => { - settled = 'resolved'; - }, - () => { - settled = 'rejected'; - }, - ); - await captureStarted; - // Canceled, and the in-flight capture with no abort still holds the leg open. - caller.abort(); - await Promise.resolve(); - expect(settled).toBe('pending'); - - // The instance/session teardown route: the pending call fails, the leg ends, and it ends as - // the caller's cancellation — not as a transport failure. It is a pre-press exit (acting - // legs never press before their lookup returns; get has no press), so it carries the clean - // dispatched: no like the other pre-press refusal. - rejectCapture?.(new Error('websocket closed')); - await expect(leg).rejects.toMatchObject({ - details: { reason: 'request_canceled', dispatched: 'no' }, - }); - }); - - it('reports a press-cancelled acting leg as dispatched: unknown even when the tap answers after the cancel', async () => { - // The tap transport has no abort either: the cancel can arrive while `tap()` is pending and - // the promise simply RESOLVE anyway. A resolved press after a cancel still says "the device - // may have the tap" — the leg must answer `dispatched: unknown`, never a confirmation or a - // clean cancellation. - let resolveTap: (() => void) | undefined; - let markTapStarted!: () => void; - const tapStarted = new Promise((resolve) => { - markTapStarted = resolve; - }); - const trees: Array> = [limrunAttAlertTree()]; - const client = { - elementTree: vi.fn(async () => { - // Lookup resolves the alert; a confirm read would see it gone — the cancel must end - // the leg before that read is ever started. - const next = - trees.length > 0 ? (trees.shift() ?? limrunAppScreenTree()) : limrunAppScreenTree(); - return [next] as unknown as ReturnType; - }), - tap: vi.fn(() => { - markTapStarted(); - return new Promise((resolve) => { - resolveTap = resolve; - }); - }), - } satisfies LimrunIosAlertClient; - - const caller = new AbortController(); - const leg = handleLimrunIosAlert(client, 'accept', { signal: caller.signal }); - leg.catch(() => {}); - await tapStarted; - caller.abort(); - resolveTap?.(); - - await expect(leg).rejects.toMatchObject({ - details: { reason: 'request_canceled', dispatched: 'unknown' }, - }); - // Exactly one dispatch attempt, never a replay: the leg died with the cancel, not with a - // second press or a confirm that contradicts what the caller already withdrew. - expect(client.tap).toHaveBeenCalledTimes(1); - // And the closest witness that the leg ended BEFORE any confirm read: the only capture is - // the lookup probe that resolved the alert — the confirm loop's top-of-loop cancellation - // check stops it before it starts a probe. - expect(client.elementTree).toHaveBeenCalledTimes(1); - }); - - it('honors a cancel that arrives while a straddling lookup probe is in flight: no press starts', async () => { - // The probe the cancel rode finished anyway (the budget rule) — but its alert answer is NOT - // acted on once the caller withdrew the action: the leg refuses at the pre-press boundary - // with the clean cancellation and dispatched: no, and no tap is ever attempted. - let resolveCapture: ((value: unknown) => void) | undefined; - let markCaptureStarted!: () => void; - const captureStarted = new Promise((resolve) => { - markCaptureStarted = resolve; - }); - const held = { - elementTree: vi.fn(() => { - markCaptureStarted(); - return new Promise((resolve) => { - resolveCapture = resolve; - }); - }) as unknown as LimrunIosAlertClient['elementTree'], - tap: vi.fn(async () => {}), - } satisfies LimrunIosAlertClient; - - const caller = new AbortController(); - const leg = handleLimrunIosAlert(held, 'accept', { signal: caller.signal }); - leg.catch(() => {}); - await captureStarted; - caller.abort(); - resolveCapture?.([limrunAttAlertTree()]); - - await expect(leg).rejects.toMatchObject({ - details: { reason: 'request_canceled', dispatched: 'no' }, - }); - expect(held.tap).toHaveBeenCalledTimes(0); - }); - - it('consumes a confirm capture that answers past its window deadline', async () => { - // The same rule after the press: the first confirm probe started inside the confirm budget, - // so it finishes and its answer decides. The Limrun WebSocket client cannot abort an - // in-flight `elementTree` — under the old contract the leg refused the late answer and - // reported the press unconfirmed; under the budget rule the capture that saw the app screen - // IS the confirmation, and the leg handled. The cost is the documented one-call overrun: - // the leg waits for the started transport instead of answering at 2 s. - const client = { - elementTree: vi - .fn() - .mockResolvedValueOnce([limrunAttAlertTree()]) // lookup probe resolves the alert - // The confirm probe stalls 3s on the driven clock — past its 2s budget — and answers - // "gone": under the rule, that answer counts. - .mockImplementationOnce( - async () => - new Promise((resolve) => { - setTimeout(() => resolve([limrunAppScreenTree()]), 3_000); - }), - ), - tap: vi.fn(async () => {}), - } satisfies LimrunIosAlertClient; - - const handled = await underFakeTimers(() => handleLimrunIosAlert(client, 'accept')); - expect(client.tap).toHaveBeenCalledTimes(1); - expect(handled.kind).toBe('alertHandled'); - }); -}); - -describe('get on an undecided capture', () => { - it('says the capture cannot decide, rather than claiming no alert', async () => { - const client = clientWithTrees([[limrunAttAlertTree(), limrunAttAlertTree()]]); - - const result = await handleLimrunIosAlert(client, 'get'); - - expect(result.kind === 'alertStatus' && result.alert).toBeNull(); - // The states are typed on the result, not just in the message: `undecided` and `none` - // arrive with the same null alert and must stay switchable apart. - expect(result.kind === 'alertStatus' && result.status).toBe('undecided'); - expect(result.kind === 'alertStatus' && result.message).toBe( - 'Cannot decide the alert: the capture is alert-shaped but unresolvable', - ); - }); -}); - -describe('acting budgets', () => { - it('spends the confirm budget and reports a stuck alert', async () => { - const client = clientWithTrees([limrunAttAlertTree(), limrunAttAlertTree()]); - - const thrown = await underFakeTimers(() => handleLimrunIosAlert(client, 'dismiss')).then( - () => undefined, - (error: unknown) => error, - ); - expect(thrown).toBeInstanceOf(AppError); - const failure = thrown as AppError; - expect(failure.message).toBe('alert dismiss did not dismiss the visible alert'); - // Every spent-budget exit of an acting leg branches on this reason, per commands.md. - expect(failure.details?.reason).toBe('alert-window-expired'); - expect(client.tap).toHaveBeenCalledTimes(1); - }); - - it('reports an unconfirmed dismissal when an undecided re-read overrules the last match', async () => { - // Confirm probe 1 re-names the pressed alert; probe 2 is alert-shaped yet unresolvable and - // stays that way to the end of the budget. The undecided read is the fresher fact, so the - // leg may not report the dialog as standing on the older match — it reports the press as - // unconfirmed, which is all a spent budget with no settling read may say. - const client = clientWithTrees([ - limrunAttAlertTree(), - limrunAttAlertTree(), - [limrunAttAlertTree(), limrunAttAlertTree()], - ]); - - const thrown = await underFakeTimers(() => handleLimrunIosAlert(client, 'dismiss')).then( - () => undefined, - (error: unknown) => error, - ); - expect(thrown).toBeInstanceOf(AppError); - const failure = thrown as AppError; - expect(failure.message).toBe( - 'alert dismiss pressed the button but the dismissal could not be confirmed', - ); - expect(failure.details?.reason).toBe('alert-window-expired'); - }); - - it('confirms on the capture that answers after a straddle, even one that first re-named the alert', async () => { - // Budget rule over the older sighting rule: confirm probe 1 re-named the pressed alert, - // probe 2 started inside the budget and answers past its deadline that the screen is now - // the app. Both are started-probe answers, so both count, and the freshest one settles the - // dismissal — the dialog the latest capture that answered saw is gone. The old code kept - // the earlier sighting as a still-visible failure precisely because this straddling capture - // was refused; the maintainer's whole-rule supersedes that. - const client = { - elementTree: vi - .fn() - .mockResolvedValueOnce(limrunAttAlertTree()) - .mockResolvedValueOnce(limrunAttAlertTree()) - .mockImplementationOnce( - async () => - new Promise((resolve) => { - setTimeout(() => resolve([limrunAppScreenTree()]), 3_000); - }), - ), - tap: vi.fn(async () => {}), - } satisfies LimrunIosAlertClient; - - const handled = await underFakeTimers(() => handleLimrunIosAlert(client, 'accept')); - expect(handled.kind).toBe('alertHandled'); - expect(client.tap).toHaveBeenCalledTimes(1); - }); - - it('refuses an ambiguous alert without tapping, on a typed reason', async () => { - const client = clientWithTrees([limrunAlertAmbiguousButtonsTree()]); - const thrown = await handleLimrunIosAlert(client, 'accept').then( - () => undefined, - (error: unknown) => error, - ); - expect(thrown).toBeInstanceOf(AppError); - const refusal = thrown as AppError; - expect(refusal.code).toBe('COMMAND_FAILED'); - expect(refusal.details?.reason).toBe(ALERT_BUTTON_UNDECIDED_REASON); - expect(refusal.details?.dispatched).toBe('no'); - expect(client.tap).not.toHaveBeenCalled(); - }); - - it('reports an unconfirmed dismissal when the post-tap capture stays unresolvable to the end', async () => { - // The first probe resolves the single alert; every post-press probe carries two disagreeing - // alert-shaped sheets. An unresolvable capture proves absence for nothing, so the confirm - // loop keeps its budget and fails — and, having never named this alert on screen again, it - // reports the press as unconfirmed rather than claiming a dialog it never re-observed. - const client = clientWithTrees([ - limrunAttAlertTree(), - [limrunAttAlertTree(), limrunAttAlertTree()], - ]); - - await expect(underFakeTimers(() => handleLimrunIosAlert(client, 'dismiss'))).rejects.toThrow( - 'alert dismiss pressed the button but the dismissal could not be confirmed', - ); - }); - - it('reports an unconfirmed dismissal when the same root survives with degraded buttons', async () => { - // The tap was ignored: the post-press capture is the same alert root with its choices dropped - // to disabled, explicitly unhittable, and frameless — an unresolvable shape, not a re-named - // alert. Degraded-but-present is not absence; the leg must fail, not report handled, and it - // may not claim "still visible" beyond what the undecided capture proved. - const client = clientWithTrees([limrunAttAlertTree(), limrunAlertTreeWithUnpressableButtons()]); - - await expect(underFakeTimers(() => handleLimrunIosAlert(client, 'accept'))).rejects.toThrow( - 'alert accept pressed the button but the dismissal could not be confirmed', - ); - expect(client.tap).toHaveBeenCalledTimes(1); - }); - - it('confirms handled when the post-tap capture shows the app screen instead', async () => { - // The closest genuine no-alert negative to the case above: the same seam and budgets, and - // the app's own named root after the tap — observed absence, which does confirm. - const client = clientWithTrees([limrunAttAlertTree(), limrunAppScreenTree()]); - const result = await handleLimrunIosAlert(client, 'accept'); - expect(result.kind === 'alertHandled' && result.button).toBe('Allow'); - }); - - it('taps exactly once on accept, and the next get reports no alert', async () => { - // The maintainer's live-validated timeline at the unit seam: one tap for the action, the - // post-tap confirm observes the app screen, and a following read sees that same absence. - const client = clientWithTrees([limrunAttAlertTree(), limrunAppScreenTree()]); - - const handled = await handleLimrunIosAlert(client, 'accept'); - expect(handled.kind).toBe('alertHandled'); - expect(client.tap).toHaveBeenCalledTimes(1); - - const status = await handleLimrunIosAlert(client, 'get'); - expect(status.kind === 'alertStatus' && status.alert).toBeNull(); - expect(status.kind === 'alertStatus' && status.message).toBe('No alert visible'); - }); - - it('accepts the three-button location sheet on the preferred grant, once', async () => { - // The multi-grant regression at the action seam: "Allow Once" and "Allow While Using App" - // both carry the accept role, and the shared preference table must land one tap on the - // lasting grant rather than refusing the sheet. - const client = clientWithTrees([limrunLocationAlertTree(), limrunAppScreenTree()]); - - const handled = await handleLimrunIosAlert(client, 'accept'); - expect(handled.kind).toBe('alertHandled'); - expect(handled.kind === 'alertHandled' && handled.button).toBe('Allow While Using App'); - expect(client.tap).toHaveBeenCalledTimes(1); - // Grant center: x 57 + 288/2, y 623.67 + 48/2, rounded by the press seam to whole points. - expect(client.tap).toHaveBeenCalledWith(201, 648); - }); - - it('refuses to act on an alert-shaped but unresolvable capture, without tapping', async () => { - const client = clientWithTrees([limrunAlertTreeWithUnpressableButtons()]); - - const thrown = await underFakeTimers(() => handleLimrunIosAlert(client, 'accept')).then( - () => undefined, - (error: unknown) => error, - ); - expect(thrown).toBeInstanceOf(AppError); - const refusal = thrown as AppError; - expect(refusal.code).toBe('COMMAND_FAILED'); - expect(refusal.details?.reason).toBe(ALERT_CAPTURE_UNDECIDED_REASON); - expect(refusal.details?.dispatched).toBe('no'); - expect(client.tap).not.toHaveBeenCalled(); - }); -}); - -describe('button vocabulary', () => { - function alertWith(left: string, right: string): IosTreeNode { - const root = limrunAttAlertTree(); - return { - ...root, - children: [ - ...(root.children ?? []).slice(0, 2), - ...[left, right].map((label, index) => ({ - type: 'Button', - role: 'AXButton', - AXLabel: label, - pid: root.pid, - enabled: true, - frame: { x: 57, y: 511.67 + index * 56, width: 288, height: 48 }, - traits: ['Button', 'Scrollable'], - })), - ], - }; - } - - it('accepts the iOS variant grants, not just the bare Allow', async () => { - for (const label of ['Allow While Using App', 'Allow Once', 'Allow All the Time']) { - const client = clientWithTrees([ - alertWith('Ask App Not to Track', label), - limrunAppScreenTree(), - ]); - const result = await handleLimrunIosAlert(client, 'accept'); - expect(result.kind === 'alertHandled' && result.button).toBe(label); - } - }); - - it('dismisses Close App, the ANRB denial', async () => { - const client = clientWithTrees([alertWith('Close App', 'Wait'), limrunAppScreenTree()]); - const result = await handleLimrunIosAlert(client, 'dismiss'); - expect(result.kind === 'alertHandled' && result.button).toBe('Close App'); - }); -}); diff --git a/packages/provider-limrun/src/alert.ts b/packages/provider-limrun/src/alert.ts deleted file mode 100644 index 4c88c89259..0000000000 --- a/packages/provider-limrun/src/alert.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { - TreeAlertDriver, - TreeAlertObservation, - TreeAlertPress, - TreeAlertResult, -} from '@agent-device/capture-kit/alert-tree-driver'; -import { runTreeAlertAction } from '@agent-device/capture-kit/alert-tree-driver'; -import type { InstanceClient as LimrunIosClient } from '@limrun/api/ios-client'; -import { - chooseLimrunAlertButton, - type LimrunAlertCandidate, - type LimrunAlertInfo, - readLimrunAlertCapture, -} from './alert-detection.ts'; - -/** - * The four alert legs on a Limrun iOS direct session (issue #3349). There is no XCUITest runner - * to ask, so this backend supplies the shared tree-alert driver (`capture-kit`'s - * `runTreeAlertAction`, the one poll/confirm/same-alert/response owner every tree-based backend - * rides) with its own observation and press seams only: observation through the session's own - * element-tree capture and its host-root reader, action through the same point-touch seam every - * other touch operation rides. - */ - -/** The client methods the legs use; declared here so the legs are testable without a live instance. */ -export type LimrunIosAlertClient = Readonly<{ - elementTree: LimrunIosClient['elementTree']; - tap: LimrunIosClient['tap']; -}>; - -export type LimrunIosAlertResult = TreeAlertResult; - -export type LimrunIosAlertAction = 'get' | 'wait' | 'accept' | 'dismiss'; - -export type LimrunIosAlertOptions = Readonly<{ - timeoutMs?: number; - /** The daemon request's cancellation, refused at (never canceled inside) the transport. */ - signal?: AbortSignal; -}>; - -export async function handleLimrunIosAlert( - client: LimrunIosAlertClient, - action: LimrunIosAlertAction, - options?: LimrunIosAlertOptions, -): Promise { - const driver: TreeAlertDriver< - Awaited>, - LimrunAlertInfo, - LimrunAlertCandidate['buttons'][number], - LimrunAlertCandidate, - 'ios' - > = { - platform: 'ios', - timeoutMs: options?.timeoutMs, - ...(options?.signal ? { signal: options.signal } : {}), - // The Limrun WebSocket client exposes no abort on `elementTree`/`tap`, so the seam signal - // cannot cancel its transport: a started call always runs to its answer, and the leg waits - // for it (the documented one-call overrun) rather than dropping the answer. - capture: () => client.elementTree(), - findCandidate: (tree): TreeAlertObservation => - readLimrunAlertCapture(tree as Parameters[0]), - // Stricter than Android's by design: only the button whose own label states the asked-for - // choice may be pressed; there is no platform fallback key to invent on iOS. The shared - // driver names the refusal when this returns null. - choose: (candidate, act): TreeAlertPress | null => { - const button = chooseLimrunAlertButton(candidate.buttons, act); - return button ? { kind: 'press', button } : null; - }, - press: async (choice) => { - // `dismiss` never authorizes tracking: the chooser above presses only the one button the - // action's own label names, and a tap that falls back to a guess is never dispatched. - if (choice.kind === 'press') await client.tap(choice.button.x, choice.button.y); - }, - }; - return await runTreeAlertAction(action, driver); -} diff --git a/packages/provider-limrun/src/app-log-reconnect.test.ts b/packages/provider-limrun/src/app-log-reconnect.test.ts index 8871777aa0..df08262aa8 100644 --- a/packages/provider-limrun/src/app-log-reconnect.test.ts +++ b/packages/provider-limrun/src/app-log-reconnect.test.ts @@ -39,6 +39,17 @@ import type { LimrunRuntimeDependencies, } from './runtime-dependencies.ts'; +const iosDependencies = { + ios: { + createAppLogReader: ({ + client, + }: Parameters[0]) => ({ + readLogs: async (app: string, limit: number) => client.appLogTail(app, limit), + [Symbol.asyncDispose]: async () => {}, + }), + }, +} as unknown as LimrunRuntimeDependencies; + test('reattaches an owned Limrun instance without persisting credentials', async () => { const get = vi.fn(async () => ({ metadata: { labels: { provider: 'limrun', leaseId: 'lease-a' } }, @@ -60,7 +71,7 @@ test('reattaches an owned Limrun instance without persisting credentials', async appBundleId: 'com.example.app', outputPath: '/sessions/one/app.log', }, - dependencies: {} as LimrunRuntimeDependencies, + dependencies: iosDependencies, signal, }); expect(get).toHaveBeenCalledWith('instance-a', { timeout: 5_000, maxRetries: 0, signal }); @@ -92,7 +103,7 @@ test('fails closed when the instance labels do not match the descriptor lease', appBundleId: 'com.example.app', outputPath: '/sessions/one/app.log', }, - dependencies: {} as LimrunRuntimeDependencies, + dependencies: iosDependencies, }); expect(outcome).toEqual({ status: 'ownership-lost' }); }); @@ -117,7 +128,7 @@ test('reports an owned instance whose status carries no control token as missing appBundleId: 'com.example.app', outputPath: '/sessions/one/app.log', }, - dependencies: {} as LimrunRuntimeDependencies, + dependencies: iosDependencies, }); expect(outcome).toEqual({ status: 'missing' }); expect(createIosInstanceClient).not.toHaveBeenCalled(); @@ -149,7 +160,7 @@ test('disconnects the Android instance client when tunnel acquisition fails', as appBundleId: 'com.example.app', outputPath: '/sessions/one/app.log', }, - dependencies: {} as LimrunRuntimeDependencies, + dependencies: iosDependencies, }), ).rejects.toThrow('tunnel failed'); expect(androidClient.disconnect).toHaveBeenCalledOnce(); @@ -265,7 +276,7 @@ test('reattaches app logs to the attached instance the descriptor names', async const outcome = await reconnectLimrunAppLogReader({ instances: { ios: ATTACHED_ACCESS }, descriptor: attachedDescriptor(), - dependencies: {} as LimrunRuntimeDependencies, + dependencies: iosDependencies, }); expect(vi.mocked(createIosInstanceClient)).toHaveBeenLastCalledWith({ ...ATTACHED_ACCESS, @@ -276,7 +287,7 @@ test('reattaches app logs to the attached instance the descriptor names', async const replaced = await reconnectLimrunAppLogReader({ instances: { ios: { ...ATTACHED_ACCESS, apiUrl: 'https://another-instance.example/api' } }, descriptor: attachedDescriptor(), - dependencies: {} as LimrunRuntimeDependencies, + dependencies: iosDependencies, }); expect(replaced).toEqual({ status: 'ownership-lost' }); }); @@ -290,7 +301,7 @@ test('reattaches a created instance through the organization API while another i limrun: { iosInstances: { get } } as never, instances: { ios: ATTACHED_ACCESS }, descriptor: attachedDescriptor('ios_created_instance', 'created'), - dependencies: {} as LimrunRuntimeDependencies, + dependencies: iosDependencies, }); expect(get).toHaveBeenCalledOnce(); expect(outcome.status).toBe('opened'); @@ -304,7 +315,7 @@ test('does not connect once the reconnect request is aborted', async () => { reconnectLimrunAppLogReader({ instances: { ios: ATTACHED_ACCESS }, descriptor: attachedDescriptor(), - dependencies: {} as LimrunRuntimeDependencies, + dependencies: iosDependencies, signal: controller.signal, }), ).rejects.toThrow(); diff --git a/packages/provider-limrun/src/app-log-reconnect.ts b/packages/provider-limrun/src/app-log-reconnect.ts index fd133fb87d..2d78488f56 100644 --- a/packages/provider-limrun/src/app-log-reconnect.ts +++ b/packages/provider-limrun/src/app-log-reconnect.ts @@ -47,7 +47,7 @@ async function reconnectAttached( ): Promise { if (descriptor.platform === 'ios') { return isDescribedInstance(instances.ios, descriptor) - ? await openIosReader(instances.ios, descriptor) + ? await openIosReader(instances.ios, descriptor, dependencies) : { status: 'ownership-lost' }; } return isDescribedInstance(instances.android, descriptor) @@ -65,6 +65,7 @@ function isDescribedInstance( async function reconnectIos(options: { limrun: Limrun; descriptor: LimrunAppLogDescriptor; + dependencies: LimrunRuntimeDependencies; signal?: AbortSignal; }): Promise { const selected = await selectOwnedActiveInstance( @@ -80,25 +81,34 @@ async function reconnectIos(options: { return await openIosReader( { apiUrl: selected.apiUrl, token: selected.token }, options.descriptor, + options.dependencies, ); } async function openIosReader( access: LimrunIosInstanceAccess, descriptor: LimrunAppLogDescriptor, + dependencies: LimrunRuntimeDependencies, ): Promise { const client = await createIosInstanceClient({ apiUrl: access.apiUrl, token: access.token, logLevel: 'warn', }); + const logs = dependencies.ios.createAppLogReader({ ...access, client }); const reader: LimrunAppLogReader = { platform: 'ios', leaseId: descriptor.leaseId, instanceId: descriptor.instanceId, ownership: descriptor.ownership, - readLogs: async (appBundleId, lineLimit) => await client.appLogTail(appBundleId, lineLimit), - [Symbol.asyncDispose]: async () => client.disconnect(), + readLogs: logs.readLogs, + [Symbol.asyncDispose]: async () => { + try { + await logs[Symbol.asyncDispose](); + } finally { + client.disconnect(); + } + }, }; return { status: 'opened', reader }; } diff --git a/packages/provider-limrun/src/app-log-runtime.fixtures.ts b/packages/provider-limrun/src/app-log-runtime.fixtures.ts index ff0c49d22e..848a8ebdf3 100644 --- a/packages/provider-limrun/src/app-log-runtime.fixtures.ts +++ b/packages/provider-limrun/src/app-log-runtime.fixtures.ts @@ -1,3 +1,4 @@ +import { testIosRunner } from './ios-runner.fixtures.ts'; import type { PlatformRequestScope } from '@agent-device/contracts/platform-runtime-host'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -31,6 +32,7 @@ export function limrunOwnerOptions( runtimeInstance: 'default', ownsDevice: () => true, hasLiveSession: () => true, + getIosRunner: (device) => (device.platform === 'apple' ? testIosRunner : undefined), getInteractor: () => undefined, getDeviceSession: () => undefined, openCurrent: async () => undefined, @@ -46,6 +48,7 @@ export function unusedLimrunHost(): PlatformRuntimeHost { // These scenarios exercise validation and app-log ports only; every other host port is // unreachable, so a narrow record beats restating the full platform-runtime host surface. return { + clock: { now: () => Date.now(), sleep: async () => {} }, artifacts: { resolveSession: (sessionId: string) => ({ outputPath: `/sessions/${sessionId}/app.log`, diff --git a/packages/provider-limrun/src/app-log-runtime.test.ts b/packages/provider-limrun/src/app-log-runtime.test.ts index dff989a911..48cf5cf51f 100644 --- a/packages/provider-limrun/src/app-log-runtime.test.ts +++ b/packages/provider-limrun/src/app-log-runtime.test.ts @@ -258,8 +258,6 @@ test('serves provider-owned network from the canonical session app log without l expect(commandRun).not.toHaveBeenCalled(); }); -const ANDROID_APP_STATE = { package: 'com.example.app', activity: '.MainActivity' }; - test.each([ ['iOS simulator', device], [ @@ -277,7 +275,6 @@ test.each([ const owner = createLimrunPlatformRuntimeOwner( limrunOwnerOptions({ getInteractor: () => ({}) as never, - getAppState: async () => (runtimeDevice.platform === 'android' ? ANDROID_APP_STATE : {}), }), ); const binding = await owner.bind({ device: runtimeDevice, intent: { kind: 'ordinary' }, scope }); @@ -292,7 +289,9 @@ test.each([ expect(binding.facts.operations.captureSnapshotWithCustomActions.available).toBe( runtimeDevice.platform === 'apple', ); - expect(binding.facts.operations.captureSnapshotWithoutActiveApp).toEqual({ available: true }); + expect(binding.facts.operations.captureSnapshotWithoutActiveApp.available).toBe( + runtimeDevice.platform === 'android', + ); expect(binding.facts.operations.setViewport).toMatchObject({ available: false }); expect(binding.operations.setViewport).toBeUndefined(); expect(binding.facts.operations.captureScreenshot).toEqual({ available: true }); @@ -303,13 +302,12 @@ test.each([ expect(binding.operations.focusPoint).toBeTypeOf('function'); expect(binding.operations.typeText).toBeTypeOf('function'); expect(binding.operations.captureSnapshot).toBeTypeOf('function'); - // Limrun owns the device remotely and exposes no local point-read tool, so the live read is - // unavailable and `get` answers from the captured tree — never by borrowing the local runtime. - expect(binding.facts.operations.readTextAtPoint).toMatchObject({ - available: false, - reason: 'unsupported-provider-mode', - }); - expect(binding.operations.readTextAtPoint).toBeUndefined(); + expect(binding.facts.operations.readTextAtPoint.available).toBe( + runtimeDevice.platform === 'apple', + ); + expect(binding.operations.readTextAtPoint).toBeTypeOf( + runtimeDevice.platform === 'apple' ? 'function' : 'undefined', + ); expectLimrunNavigationAndKeyboardFacts(binding, runtimeDevice); await expect( binding.operations.listApps?.({ device: runtimeDevice, filter: 'all' }), @@ -322,11 +320,46 @@ test.each([ id: runtimeDevice.id, booted: true, }); - await expect(binding.operations.appState?.()).resolves.toEqual( - runtimeDevice.platform === 'android' ? ANDROID_APP_STATE : {}, + if (runtimeDevice.platform === 'android') { + await expect(binding.operations.appState?.()).resolves.toEqual({ + package: 'com.example.app', + activity: '.MainActivity', + }); + } else { + expect(binding.operations.appState).toBeTypeOf('function'); + } +}); + +test('checks the instance before reading iOS app state through the runner', async () => { + const getAppState = vi.fn(async () => ({})); + const appState = vi.fn(async () => ({ applicationState: 'runningForeground' as const })); + const owner = createLimrunPlatformRuntimeOwner( + limrunOwnerOptions({ + getAppState, + getInteractor: () => ({ appState }) as never, + }), + ); + const binding = await owner.bind({ device, intent: { kind: 'ordinary' }, scope }); + await expect(binding.operations.appState?.({ appBundleId: 'com.example.app' })).resolves.toEqual({ + applicationState: 'runningForeground', + }); + expect(getAppState).toHaveBeenCalledOnce(); + expect(appState).toHaveBeenCalledOnce(); + expect(getAppState.mock.invocationCallOrder[0]).toBeLessThan( + appState.mock.invocationCallOrder[0]!, ); }); +test('checks the iOS instance even when the session has no bundle id', async () => { + const failure = new Error('instance unreachable'); + const getAppState = vi.fn(async () => { + throw failure; + }); + const owner = createLimrunPlatformRuntimeOwner(limrunOwnerOptions({ getAppState })); + const binding = await owner.bind({ device, intent: { kind: 'ordinary' }, scope }); + await expect(binding.operations.appState?.({})).rejects.toBe(failure); +}); + test('fails closed for a stale Android identity before exposing facts or binding operations', async () => { const getAppState = vi.fn(async () => ({ package: 'com.example.app', @@ -383,11 +416,6 @@ test('fails closed for a stale Android identity before exposing facts or binding expect(getAppState).not.toHaveBeenCalled(); }); -/** - * back/orientation/home are admitted on both direct-session platforms; tv-remote differs by - * platform (the Android leg reuses the local family's interactor factory, the iOS leg refuses - * it explicitly); keyboard status/dismiss/enter reuse that same Android-only interactor. - */ function expectLimrunNavigationAndKeyboardFacts( binding: DeviceBinding, runtimeDevice: DeviceInfo, @@ -405,23 +433,21 @@ function expectLimrunNavigationAndKeyboardFacts( expect(binding.facts.operations.tvRemote).toMatchObject({ hint: isAndroid ? 'tv-remote is supported only on Android TV targets.' - : 'Limrun iOS direct sessions do not expose tv remote control.', + : 'tv-remote is supported only on tvOS devices.', }); + if (!isAndroid) { + expect(binding.facts.operations.keyboardStatus).toMatchObject({ + reason: 'unsupported-platform-leaf', + hint: 'keyboard status/get is currently supported only on Android; use keyboard dismiss or enter on iOS', + }); + } for (const operation of ['keyboardStatus', 'keyboardDismiss', 'keyboardEnter'] as const) { - expect(binding.facts.operations[operation].available).toBe(isAndroid); - expect(binding.operations[operation]).toBeTypeOf(isAndroid ? 'function' : 'undefined'); - if (!isAndroid) { - expect(binding.facts.operations[operation]).toMatchObject({ - hint: 'Limrun iOS direct sessions do not expose keyboard actions.', - }); - } + const available = isAndroid || operation !== 'keyboardStatus'; + expect(binding.facts.operations[operation].available).toBe(available); + expect(binding.operations[operation]).toBeTypeOf(available ? 'function' : 'undefined'); } } -// R52/R53: Limrun's two session kinds run different interactors. The Android emulator session -// runs the ordinary Android interactor (every tier); the iOS direct session's own -// `performGesture` refuses, so stating that as a fact refuses at admission instead of -// mid-execution — with the interactor's wording preserved. const limrunAndroid = { platform: 'android' as const, id: 'limrun:android:lease-a', @@ -453,7 +479,7 @@ test('admits every gesture tier on a Limrun Android session', async () => { } }); -test('refuses Limrun iOS gestures at admission while keeping its scroll', async () => { +test('admits the shared Apple runner gestures on Limrun iOS', async () => { const owner = createLimrunPlatformRuntimeOwner( limrunOwnerOptions({ getInteractor: () => ({}) as never }), ); @@ -465,14 +491,9 @@ test('refuses Limrun iOS gestures at admission while keeping its scroll', async 'performTargetAuthoredDrag', 'gestureViewport', ] as const) { - expect(binding.facts.operations[operation]).toEqual({ - available: false, - reason: 'unsupported-provider-mode', - hint: 'Limrun iOS direct sessions do not expose portable gesture execution yet.', - }); - expect(binding.operations[operation]).toBeUndefined(); + expect(binding.facts.operations[operation]).toEqual({ available: true }); + expect(binding.operations[operation]).toBeTypeOf('function'); } - // The iOS direct session exposes scrolling directly — no gesture synthesis involved. expect(binding.facts.operations.scrollDirection).toEqual({ available: true }); expect(binding.operations.scrollDirection).toBeTypeOf('function'); }); diff --git a/packages/provider-limrun/src/app-log-runtime.ts b/packages/provider-limrun/src/app-log-runtime.ts index 1881119ff8..d9d4cf3c9f 100644 --- a/packages/provider-limrun/src/app-log-runtime.ts +++ b/packages/provider-limrun/src/app-log-runtime.ts @@ -1,11 +1,8 @@ +import type { LimrunIosRunner } from './runtime-dependencies.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { AppsFilter, ProviderPortReverseOptions } from '@agent-device/contracts/device'; import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; -import { - LIMRUN_FOLD_UNAVAILABLE, - LIMRUN_TELEPHONY_UNAVAILABLE, - bindLimrunInteractionOperations, -} from './interaction-operations.ts'; +import { bindLimrunInteractionOperations } from './interaction-operations.ts'; import { bindAdmittedProviderInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; import { AppError } from '@agent-device/kernel/errors'; import { isSupportedLimrunAppLogDevice, parseLimrunDeviceId } from './device.ts'; @@ -26,7 +23,6 @@ import { type ProviderAppLogReconnectOutcome, } from '@agent-device/capture-kit/provider-app-log'; import { availableApplicationLifecycleOperations } from '@agent-device/contracts/application-lifecycle-runtime'; -import { createUnavailablePlatformRuntimeFacts } from '@agent-device/contracts/platform-runtime-unavailable'; import { limrunAppLogDescriptorCodec, type LimrunAppLogDescriptor } from './app-log-descriptor.ts'; import type { LimrunInstanceOwnership } from './instance-access.ts'; import { bindLimrunApplicationLifecycle } from './lifecycle.ts'; @@ -43,8 +39,7 @@ import { deploymentOptions, limrunAppLogFacts, limrunAppLogRecoveryFacts, - limrunLifecycleFacts, - liveSessionUnavailable, + limrunUnavailableFacts, } from './facts-runtime.ts'; export type LimrunAppLogReader = ProviderAppLogReader & @@ -65,6 +60,7 @@ export type LimrunPlatformRuntimeOwnerOptions = Omit< host: PlatformRuntimeHost; runtimeInstance: string; ownsDevice(device: DeviceInfo): boolean; + getIosRunner(device: DeviceInfo): LimrunIosRunner | undefined; getInteractor(device: DeviceInfo, runner?: RunnerContext): Interactor | undefined; getDeviceSession(device: DeviceInfo): LimrunScreenRecordingSession | undefined; resolveAppReference?(device: DeviceInfo, app: string): string; @@ -99,40 +95,7 @@ export function createLimrunPlatformRuntimeOwner( inspectFacts: async (device) => hasLiveSession(device) ? limrunAppLogFacts(options, device) - : createUnavailablePlatformRuntimeFacts(device, owner, { - appLog: liveSessionUnavailable, - appState: liveSessionUnavailable, - appDeployment: liveSessionUnavailable, - network: liveSessionUnavailable, - screenshot: liveSessionUnavailable, - viewport: liveSessionUnavailable, - focus: liveSessionUnavailable, - gesture: liveSessionUnavailable, - scroll: liveSessionUnavailable, - typeText: liveSessionUnavailable, - touch: liveSessionUnavailable, - elementText: liveSessionUnavailable, - back: liveSessionUnavailable, - orientation: liveSessionUnavailable, - tvRemote: liveSessionUnavailable, - keyboard: liveSessionUnavailable, - clipboard: liveSessionUnavailable, - systemButton: liveSessionUnavailable, - fold: LIMRUN_FOLD_UNAVAILABLE, - telephony: LIMRUN_TELEPHONY_UNAVAILABLE, - triggerAppEvent: liveSessionUnavailable, - settings: liveSessionUnavailable, - readAlert: liveSessionUnavailable, - awaitAlert: liveSessionUnavailable, - acceptAlert: liveSessionUnavailable, - dismissAlert: liveSessionUnavailable, - audioProbeCapture: liveSessionUnavailable, - audioProbeQuery: liveSessionUnavailable, - perf: liveSessionUnavailable, - readiness: liveSessionUnavailable, - shutdown: liveSessionUnavailable, - lifecycle: limrunLifecycleFacts(device, false), - }), + : limrunUnavailableFacts(options, device), bind: async (request) => { if (request.intent.kind === 'exact-owner' && !sameRuntimeOwner(request.intent.owner, owner)) { throw new AppError('UNSUPPORTED_OPERATION', 'Limrun app-log owner identity does not match'); @@ -198,20 +161,39 @@ function bindLimrunAppLogs( reconnect: async (descriptor, reconnectSignal) => await options.reconnect(descriptor, reconnectSignal), }); + const runnerOperations = options.getIosRunner(device)?.bindRunnerOperations({ + signal, + resolveInteractor: async (target, context) => { + const interactor = options.getInteractor(target, context); + if (!interactor) throw new AppError('DEVICE_NOT_FOUND', 'Limrun session is unavailable'); + return interactor; + }, + }); const operations = { ...appLogs, ensureReady: async () => ({ ...device, booted: true }), bootTarget: async () => ({ ...device, booted: true }), listApps: async (input) => await options.listApps(input.device, input.filter, signal), - appState: async () => await options.getAppState(device, signal), ...availableApplicationLifecycleOperations( - bindLimrunApplicationLifecycle({ - device, - signal, - getInteractor: options.getInteractor, - resolveAppReference: (app) => options.resolveAppReference?.(device, app) ?? app, - configurePortReverse: options.configurePortReverse, - }), + bindLimrunApplicationLifecycle( + { + device, + signal, + getInteractor: options.getInteractor, + resolveAppReference: (app) => options.resolveAppReference?.(device, app) ?? app, + configurePortReverse: options.configurePortReverse, + }, + options.getIosRunner(device)?.bindApplicationLifecycle({ + signal, + clock: options.host.clock, + resolveInteractor: async (target, context) => { + const interactor = options.getInteractor(target, context); + if (!interactor) + throw new AppError('DEVICE_NOT_FOUND', 'Limrun session is unavailable'); + return interactor; + }, + }), + ), runtimeFacts.operations, ), ...bindLimrunInteractionOperations({ @@ -219,7 +201,13 @@ function bindLimrunAppLogs( signal, getInteractor: options.getInteractor, presentIosAcquisition: options.host.snapshot.presentIosAcquisition, + facts: runtimeFacts.operations, }), + ...runnerOperations, + appState: async (input) => { + const state = await options.getAppState(device, signal); + return runnerOperations ? await runnerOperations.appState(input) : state; + }, ...bindAdmittedProviderInteractorOperations({ device, signal, diff --git a/packages/provider-limrun/src/deployment-runtime.test.ts b/packages/provider-limrun/src/deployment-runtime.test.ts index 8a9a574223..327b83febb 100644 --- a/packages/provider-limrun/src/deployment-runtime.test.ts +++ b/packages/provider-limrun/src/deployment-runtime.test.ts @@ -31,7 +31,9 @@ test.each([ expect(operation).toMatchObject({ available: false }); } expect(facts.deployApp).toMatchObject({ reason: 'owner-capability-missing' }); - expect(facts.sendPushNotification).toMatchObject({ reason: 'unsupported-provider-mode' }); + expect(facts.sendPushNotification).toMatchObject({ + reason: device.platform === 'apple' ? 'owner-capability-missing' : 'unsupported-provider-mode', + }); }); test.each([ @@ -59,15 +61,23 @@ test.each([ cleanup: async () => {}, })); const base = unusedLimrunRuntimeHost(); + const hostMaterializeApple = vi.fn(base.appleDeployment.prepareArtifact); + const hostMaterializeAndroid = vi.fn(base.androidDeployment.prepareArtifact); + const push = vi.fn(async () => ({})); const options = { host: { ...base, - appleDeployment: { ...base.appleDeployment, prepareArtifact: materializeApple }, - androidDeployment: { ...base.androidDeployment, prepareArtifact: materializeAndroid }, + appleDeployment: { ...base.appleDeployment, prepareArtifact: hostMaterializeApple }, + androidDeployment: { ...base.androidDeployment, prepareArtifact: hostMaterializeAndroid }, }, ownsDevice: () => true, - deployApp, - deployMaterializedApp, + bindDeployment: async () => ({ + deployApp, + deployMaterializedApp, + materializeAppSource: device.platform === 'apple' ? materializeApple : materializeAndroid, + uninstallApp: async () => ({ wasInstalled: false as const }), + ...(device.platform === 'apple' ? { sendPushNotification: push } : {}), + }), }; const facts = limrunAppDeploymentFacts(options, device); const operations = createLimrunAppDeploymentOperations( @@ -79,7 +89,11 @@ test.each([ for (const fact of [facts.deployApp, facts.materializeAppSource, facts.deployMaterializedApp]) { expect(fact).toEqual({ available: true }); } - expect(facts.sendPushNotification).toMatchObject({ available: false }); + expect(facts.sendPushNotification).toMatchObject({ available: device.platform === 'apple' }); + if (device.platform === 'apple') { + await operations.sendPushNotification!({ appId: 'com.example.app', payload: {} }); + expect(push).toHaveBeenCalledOnce(); + } await operations.deployApp?.({ app: 'com.example.app', appPath: '/tmp/app', @@ -93,6 +107,8 @@ test.each([ expect(deployMaterializedApp).toHaveBeenCalledOnce(); expect(materializeApple).toHaveBeenCalledTimes(device.platform === 'apple' ? 1 : 0); expect(materializeAndroid).toHaveBeenCalledTimes(device.platform === 'android' ? 1 : 0); + expect(hostMaterializeApple).not.toHaveBeenCalled(); + expect(hostMaterializeAndroid).not.toHaveBeenCalled(); }, ); @@ -130,8 +146,15 @@ test.each([ { host: unusedLimrunRuntimeHost(), ownsDevice: () => true, - deployApp, - deployMaterializedApp, + bindDeployment: async (_device, signal) => ({ + uninstallApp: async () => ({ wasInstalled: false as const }), + materializeAppSource: async () => ({ + installablePath: '/tmp/app', + cleanup: async () => {}, + }), + deployApp: (input) => deployApp(_device, input, signal), + deployMaterializedApp: (input) => deployMaterializedApp(_device, input, signal), + }), }, limrunIosDevice, controller.signal, diff --git a/packages/provider-limrun/src/deployment-runtime.ts b/packages/provider-limrun/src/deployment-runtime.ts index c7e7d1fda8..6445754a2c 100644 --- a/packages/provider-limrun/src/deployment-runtime.ts +++ b/packages/provider-limrun/src/deployment-runtime.ts @@ -1,19 +1,8 @@ -import type { - AppDeploymentInput, - AppDeploymentResult, - DeployMaterializedAppInput, - MaterializeAppSourceInput, - MaterializedAppSource, -} from '@agent-device/contracts/app-deployment-runtime'; import type { PlatformRuntimeHost, PlatformRuntimeOperations, } from '@agent-device/contracts/platform-runtime-operations'; -import { - type RuntimeOperationFact, - unavailableFact, -} from '@agent-device/contracts/platform-runtime'; -import type { ProviderDeviceInstallResult } from '@agent-device/contracts/device'; +import { unavailableFact } from '@agent-device/contracts/platform-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import type { LimrunRequestOperationDrain } from './request-cancellation.ts'; @@ -28,51 +17,37 @@ const pushUnavailable = unavailableFact( 'unsupported-provider-mode', 'Push notifications are unavailable for Limrun provider-owned devices.', ); -const uninstallUnavailable = unavailableFact( - 'unsupported-provider-mode', - 'App uninstall is unavailable for Limrun provider-owned devices.', -); - export type LimrunAppDeploymentRuntimeOptions = Readonly<{ host: PlatformRuntimeHost; ownsDevice(device: DeviceInfo): boolean; - /** A currently live provider session, distinct from the provider-owned device namespace. */ isSessionActive?(device: DeviceInfo): boolean; - deployApp?( + bindDeployment?( device: DeviceInfo, - input: AppDeploymentInput, signal: AbortSignal, operationDrain?: LimrunRequestOperationDrain, - ): Promise; - deployMaterializedApp?( - device: DeviceInfo, - input: DeployMaterializedAppInput, - signal: AbortSignal, - operationDrain?: LimrunRequestOperationDrain, - ): Promise; + ): Promise>; }>; export function limrunAppDeploymentFacts( options: LimrunAppDeploymentRuntimeOptions, device: DeviceInfo, -): Readonly<{ - deployApp: RuntimeOperationFact; - materializeAppSource: RuntimeOperationFact; - deployMaterializedApp: RuntimeOperationFact; - sendPushNotification: RuntimeOperationFact; - uninstallApp: RuntimeOperationFact; -}> { - const deployment = limrunDeploymentFact(options, device); +) { + const sessionActive = options.isSessionActive?.(device) ?? options.ownsDevice(device); + const deployment = + isSupportedLimrunRuntimeDevice(device) && sessionActive && options.bindDeployment + ? available + : deploymentUnavailable; return Object.freeze({ deployApp: deployment, materializeAppSource: deployment, deployMaterializedApp: deployment, - sendPushNotification: isActiveLimrunRuntimeSession(options, device) - ? pushUnavailable - : deploymentUnavailable, - uninstallApp: isActiveLimrunRuntimeSession(options, device) - ? uninstallUnavailable - : deploymentUnavailable, + uninstallApp: deployment, + sendPushNotification: + device.platform === 'apple' + ? deployment + : sessionActive + ? pushUnavailable + : deploymentUnavailable, }); } @@ -82,61 +57,47 @@ export function createLimrunAppDeploymentOperations( signal: AbortSignal, operationDrain?: LimrunRequestOperationDrain, ): Partial { - const deployApp = options.deployApp; - const deployMaterializedApp = options.deployMaterializedApp; - if ( - !limrunAppDeploymentFacts(options, device).deployApp.available || - !deployApp || - !deployMaterializedApp - ) { - return Object.freeze({}); - } - return Object.freeze({ - deployApp: async (input: AppDeploymentInput) => - limrunDeploymentResult(await deployApp(device, input, signal, operationDrain)), - materializeAppSource: async (input: MaterializeAppSourceInput) => - await materializeLimrunSource(options.host, device, input, signal), - deployMaterializedApp: async (input: DeployMaterializedAppInput) => - limrunDeploymentResult(await deployMaterializedApp(device, input, signal, operationDrain)), - }); -} - -function limrunDeploymentFact( - options: LimrunAppDeploymentRuntimeOptions, - device: DeviceInfo, -): RuntimeOperationFact { - return isSupportedLimrunRuntimeDevice(device) && - isActiveLimrunRuntimeSession(options, device) && - options.deployApp && - options.deployMaterializedApp - ? available - : deploymentUnavailable; -} - -/** Admission liveness is synchronous metadata from the provider runtime, never a bind-time probe. */ -function isActiveLimrunRuntimeSession( - options: Pick, - device: DeviceInfo, -): boolean { - return options.isSessionActive?.(device) ?? options.ownsDevice(device); -} - -async function materializeLimrunSource( - host: PlatformRuntimeHost, - device: DeviceInfo, - input: MaterializeAppSourceInput, - signal: AbortSignal, -): Promise { - const deploymentHost = - device.platform === 'apple' ? host.appleDeployment : host.androidDeployment; - return await deploymentHost.prepareArtifact(input, { signal }); -} - -function limrunDeploymentResult( - result: ProviderDeviceInstallResult | undefined, -): AppDeploymentResult { - if (!result) { - throw new AppError('UNSUPPORTED_OPERATION', 'The Limrun provider session is no longer active.'); - } - return result; + if (!limrunAppDeploymentFacts(options, device).deployApp.available) return {}; + const bind = options.bindDeployment!; + const load = async () => { + signal.throwIfAborted(); + const { + deployApp, + materializeAppSource, + deployMaterializedApp, + uninstallApp, + sendPushNotification, + } = await bind(device, signal, operationDrain); + if (!deployApp || !materializeAppSource || !deployMaterializedApp || !uninstallApp) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'The Limrun provider session is no longer active.', + ); + } + return { + deployApp, + materializeAppSource, + deployMaterializedApp, + uninstallApp, + sendPushNotification, + }; + }; + return { + ...(device.platform === 'apple' + ? { + sendPushNotification: async ( + input: import('@agent-device/contracts/app-deployment-runtime').PushNotificationInput, + ) => { + const operation = (await load()).sendPushNotification; + if (!operation) + throw new AppError('UNSUPPORTED_OPERATION', 'Apple push operation is unavailable'); + return await operation(input); + }, + } + : {}), + deployApp: async (input) => (await load()).deployApp(input), + materializeAppSource: async (input) => (await load()).materializeAppSource(input), + deployMaterializedApp: async (input) => (await load()).deployMaterializedApp(input), + uninstallApp: async (input) => (await load()).uninstallApp(input), + }; } diff --git a/packages/provider-limrun/src/device-session.test.ts b/packages/provider-limrun/src/device-session.test.ts index 48c61da4cc..4b828f96aa 100644 --- a/packages/provider-limrun/src/device-session.test.ts +++ b/packages/provider-limrun/src/device-session.test.ts @@ -1,3 +1,4 @@ +import { testIosRunner } from './ios-runner.fixtures.ts'; import assert from 'node:assert/strict'; import { test, vi } from 'vitest'; import type { AppsFilter, DeviceLease } from '@agent-device/contracts/device'; @@ -29,6 +30,9 @@ const downloadFile = vi.fn(async (_options: LimrunFileDownload) => undefined); const TEST_DEPENDENCIES = { clientVersion: 'test-version', android: { + bindDeployment: async () => { + throw new Error('Unexpected deployment binding'); + }, createInteractor: () => ({}) as Interactor, createPortReverse: async () => ({ ensure: async () => undefined, @@ -64,7 +68,13 @@ const TEST_DEPENDENCIES = { downloadFile, }, ios: { - applySimctlSetting: vi.fn(async () => undefined), + createAppLogReader: ({ client }) => ({ + readLogs: async (app, limit) => client.appLogTail(app, limit), + [Symbol.asyncDispose]: async () => {}, + }), + createRunner: async () => { + return testIosRunner; + }, resolveAppAlias: async (app: string) => app, readBundleAppName: async () => undefined, }, @@ -135,7 +145,7 @@ test('iOS device session exposes reusable capabilities without its raw client', assert.equal('client' in session, false); }); -test('iOS remote install waits for eventually consistent app inventory', async () => { +test('iOS remote install bypasses stale digest caching and waits for app inventory', async () => { const staleApps = IOS_APPS.slice(0, 1); const installedApps = [...staleApps, IOS_APPS[1]]; const listApps = vi @@ -163,7 +173,7 @@ test('iOS remote install waits for eventually consistent app inventory', async ( assert.equal(listApps.mock.calls.length, 3); assert.deepEqual(installApp.mock.calls[0], [ 'https://assets.example/example.zip', - { md5: 'example-md5', launchMode: 'RelaunchIfRunning' }, + { launchMode: 'RelaunchIfRunning' }, ]); }); @@ -231,6 +241,7 @@ test('Android device session exposes semantic ADB capabilities without its raw c function iosSession(client: Record): LimrunIosSession { return { platform: 'ios', + runner: testIosRunner, lease: lease('ios-instance'), instanceId: 'ios-instance', device: device('ios'), diff --git a/packages/provider-limrun/src/facts-runtime.ts b/packages/provider-limrun/src/facts-runtime.ts index e3ad60f212..c4cc6bf239 100644 --- a/packages/provider-limrun/src/facts-runtime.ts +++ b/packages/provider-limrun/src/facts-runtime.ts @@ -1,8 +1,14 @@ +import { createUnavailablePlatformRuntimeFacts } from '@agent-device/contracts/platform-runtime-unavailable'; +import { + providerRuntimeOwner, + type RuntimeFacts, + unavailableFact, +} from '@agent-device/contracts/platform-runtime'; +import { invalidRuntimeContract } from '@agent-device/contracts/runtime-contract-error'; import { deviceShape, isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { applicationLifecycleOperationFacts } from '@agent-device/contracts/application-lifecycle-runtime'; import { elementTextRuntimeOperationFacts } from '@agent-device/contracts/element-text-runtime'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; -import { type RuntimeFacts, unavailableFact } from '@agent-device/contracts/platform-runtime'; import { screenshotRuntimeOperationFacts } from '@agent-device/contracts/screenshot-runtime'; import { selectorObservationRuntimeOperationFacts } from '@agent-device/contracts/selector-observation-runtime'; import { snapshotRuntimeOperationFacts } from '@agent-device/contracts/snapshot-runtime'; @@ -16,16 +22,11 @@ import { type LimrunAppDeploymentRuntimeOptions, } from './deployment-runtime.ts'; import { - limrunInteractionOperationFacts, - limrunKeyboardOperationFacts, - limrunAppEventOperationFacts, - limrunSettingsOperationFacts, - limrunAlertOperationFacts, - limrunSystemButtonOperationFacts, - limrunFoldOperationFacts, - limrunTelephonyOperationFacts, - limrunClipboardOperationFacts, - limrunNavigationOperationFacts, + limrunAndroidInteractionFacts, + limrunAndroidSettingsFacts, + limrunAppleSettingsFacts, + LIMRUN_FOLD_UNAVAILABLE, + LIMRUN_TELEPHONY_UNAVAILABLE, } from './interaction-operations.ts'; const available = Object.freeze({ available: true } as const); @@ -37,14 +38,9 @@ const viewportUnavailable = unavailableFact( 'unsupported-provider-mode', 'Limrun does not expose viewport resizing.', ); -/** - * A point read needs a local tool (adb uiautomator, the XCUITest runner). Limrun's transport - * carries none of them, so the owner reports no live read and `get` answers from the captured - * tree; provider ownership never borrows the local family read. - */ const elementTextUnavailable = unavailableFact( 'unsupported-provider-mode', - 'Limrun-owned devices read element text from the captured tree only.', + 'Read element text from the captured snapshot.', ); const headlessUnavailable = unavailableFact( 'unsupported-provider-mode', @@ -99,11 +95,14 @@ export function limrunLifecycleFacts(device: DeviceInfo, live: boolean) { resolveOpenTarget: openTarget, prepareApplicationOpen: openTarget, openApplication: openTarget, - applyRuntimeHints: runtimeHintsUnavailable, - clearRuntimeHints: runtimeHintsUnavailable, + applyRuntimeHints: + isIosFamily(device) && openTarget.available ? available : runtimeHintsUnavailable, + clearRuntimeHints: + isIosFamily(device) && openTarget.available ? available : runtimeHintsUnavailable, closeApplication: closeTarget, finalizeApplicationClose: closeTarget, - prepareAppleRunner: prepareUnavailable, + prepareAppleRunner: + isIosFamily(device) && openTarget.available ? available : prepareUnavailable, configureProviderPortReverse: portReverse, }); } @@ -134,49 +133,46 @@ export function limrunAppLogFacts( device: DeviceInfo, ): RuntimeFacts { const deployment = limrunAppDeploymentFacts(deploymentOptions(options), device); - const customSnapshotFact = - isIosFamily(device) && device.kind === 'simulator' ? available : customSnapshotUnavailable; + const isAndroid = device.platform === 'android'; + const runner = options.getIosRunner(device); + if (!isAndroid && !runner) throw invalidRuntimeContract('Limrun iOS session has no Apple runner'); + const interactionFacts = isAndroid ? limrunAndroidInteractionFacts(device) : runner!.facts; return Object.freeze({ device: { ...deviceShape(device), providerMode: 'provider-runtime' }, operations: { + ...limrunUnavailableFacts(options, device).operations, appLogInspect: available, appLogDoctor: available, appLogStart: available, appLogReattach: available, appLogCleanup: available, ...deployment, - appState: available, + ...(isAndroid ? { appState: available } : {}), networkDump: available, screenRecordingStart: available, screenRecordingReattach: available, screenRecordingCleanup: available, - ...snapshotRuntimeOperationFacts({ - capture: available, - customActions: customSnapshotFact, - withoutActiveApp: available, - }), + ...(isAndroid + ? { + ...snapshotRuntimeOperationFacts({ + capture: available, + customActions: customSnapshotUnavailable, + withoutActiveApp: available, + }), + ...selectorObservationRuntimeOperationFacts({ + findText: customSnapshotUnavailable, + querySelector: customSnapshotUnavailable, + }), + ...elementTextRuntimeOperationFacts({ readTextAtPoint: elementTextUnavailable }), + } + : {}), ...screenshotRuntimeOperationFacts({ capture: available }), - // Provider ownership is authoritative: no native text reading is exposed, so text waits - // on a Limrun-owned device poll the canonical tree rather than borrowing Apple's. The same - // ownership answer covers the live selector read: no rescue hook on a provider device. - ...selectorObservationRuntimeOperationFacts({ - findText: customSnapshotUnavailable, - querySelector: customSnapshotUnavailable, - }), ...viewportRuntimeOperationFacts({ setViewport: viewportUnavailable }), - // Focus rides the same provider interactor the captures do, and a live-session Limrun - // device always has one, so it is available wherever a capture is. - ...limrunInteractionOperationFacts(device), - ...elementTextRuntimeOperationFacts({ readTextAtPoint: elementTextUnavailable }), - ...limrunNavigationOperationFacts(device), - ...limrunKeyboardOperationFacts(device), - ...limrunClipboardOperationFacts(), - ...limrunSystemButtonOperationFacts(device), - ...limrunFoldOperationFacts(), - ...limrunTelephonyOperationFacts(), - ...limrunAppEventOperationFacts(device), - ...limrunSettingsOperationFacts(device), - ...limrunAlertOperationFacts(device), + ...limrunLifecycleFacts(device, true), + ...interactionFacts, + setFoldPose: LIMRUN_FOLD_UNAVAILABLE, + sendTelephonyEvent: LIMRUN_TELEPHONY_UNAVAILABLE, + ...(isAndroid ? limrunAndroidSettingsFacts() : limrunAppleSettingsFacts(interactionFacts)), ...audioProbeRuntimeOperationFacts({ capture: audioProbeUnavailable, query: audioProbeUnavailable, @@ -196,7 +192,6 @@ export function limrunAppLogFacts( 'unsupported-provider-mode', 'Limrun owns the target lifecycle for provider-owned devices.', ), - ...limrunLifecycleFacts(device, true), }, }); } @@ -205,53 +200,50 @@ export function limrunAppLogRecoveryFacts( options: LimrunPlatformRuntimeOwnerOptions, device: DeviceInfo, ): RuntimeFacts { - const normalFacts = limrunAppLogFacts(options, device); + const facts = limrunUnavailableFacts(options, device); return Object.freeze({ - device: normalFacts.device, - operations: { - ...normalFacts.operations, - appLogInspect: liveSessionUnavailable, - appLogDoctor: liveSessionUnavailable, - appLogStart: liveSessionUnavailable, - appLogReattach: available, - appLogCleanup: available, - appState: liveSessionUnavailable, - networkDump: liveSessionUnavailable, - screenRecordingStart: liveSessionUnavailable, - screenRecordingReattach: liveSessionUnavailable, - screenRecordingCleanup: liveSessionUnavailable, - ...snapshotRuntimeOperationFacts({ - capture: liveSessionUnavailable, - customActions: liveSessionUnavailable, - withoutActiveApp: liveSessionUnavailable, - }), - ...screenshotRuntimeOperationFacts({ capture: liveSessionUnavailable }), - ...selectorObservationRuntimeOperationFacts({ - findText: liveSessionUnavailable, - querySelector: liveSessionUnavailable, - }), - ...viewportRuntimeOperationFacts({ setViewport: liveSessionUnavailable }), - ...limrunInteractionOperationFacts(device, liveSessionUnavailable), - ...limrunNavigationOperationFacts(device, liveSessionUnavailable), - ...limrunKeyboardOperationFacts(device, liveSessionUnavailable), - ...limrunClipboardOperationFacts(liveSessionUnavailable), - ...limrunSystemButtonOperationFacts(device, liveSessionUnavailable), - ...limrunFoldOperationFacts(), - ...limrunTelephonyOperationFacts(), - ...limrunAppEventOperationFacts(device, liveSessionUnavailable), - ...limrunSettingsOperationFacts(device, liveSessionUnavailable), - ...limrunAlertOperationFacts(device, liveSessionUnavailable), - ensureReady: liveSessionUnavailable, - bootTarget: liveSessionUnavailable, - bootTargetHeadless: liveSessionUnavailable, - listApps: liveSessionUnavailable, - deployApp: liveSessionUnavailable, - materializeAppSource: liveSessionUnavailable, - deployMaterializedApp: liveSessionUnavailable, - sendPushNotification: liveSessionUnavailable, - uninstallApp: liveSessionUnavailable, - shutdownTarget: liveSessionUnavailable, - ...limrunLifecycleFacts(device, false), - }, + device: facts.device, + operations: { ...facts.operations, appLogReattach: available, appLogCleanup: available }, + }); +} + +export function limrunUnavailableFacts( + options: LimrunPlatformRuntimeOwnerOptions, + device: DeviceInfo, +) { + const owner = providerRuntimeOwner('limrun', options.runtimeInstance); + return createUnavailablePlatformRuntimeFacts(device, owner, { + appLog: liveSessionUnavailable, + appState: liveSessionUnavailable, + appDeployment: liveSessionUnavailable, + network: liveSessionUnavailable, + screenshot: liveSessionUnavailable, + viewport: liveSessionUnavailable, + focus: liveSessionUnavailable, + gesture: liveSessionUnavailable, + scroll: liveSessionUnavailable, + typeText: liveSessionUnavailable, + touch: liveSessionUnavailable, + elementText: liveSessionUnavailable, + back: liveSessionUnavailable, + orientation: liveSessionUnavailable, + tvRemote: liveSessionUnavailable, + keyboard: liveSessionUnavailable, + clipboard: liveSessionUnavailable, + systemButton: liveSessionUnavailable, + fold: LIMRUN_FOLD_UNAVAILABLE, + telephony: LIMRUN_TELEPHONY_UNAVAILABLE, + triggerAppEvent: liveSessionUnavailable, + settings: liveSessionUnavailable, + readAlert: liveSessionUnavailable, + awaitAlert: liveSessionUnavailable, + acceptAlert: liveSessionUnavailable, + dismissAlert: liveSessionUnavailable, + audioProbeCapture: liveSessionUnavailable, + audioProbeQuery: liveSessionUnavailable, + perf: liveSessionUnavailable, + readiness: liveSessionUnavailable, + shutdown: liveSessionUnavailable, + lifecycle: limrunLifecycleFacts(device, false), }); } diff --git a/packages/provider-limrun/src/interaction-operations.test.ts b/packages/provider-limrun/src/interaction-operations.test.ts index 3d8917f710..d3238b28fb 100644 --- a/packages/provider-limrun/src/interaction-operations.test.ts +++ b/packages/provider-limrun/src/interaction-operations.test.ts @@ -1,3 +1,4 @@ +import { testIosRunner } from './ios-runner.fixtures.ts'; import { runtimeSubjectFact } from '@agent-device/contracts/platform-runtime'; import { HARDWARE_BUTTON_NAMES, @@ -7,15 +8,18 @@ import type { Interactor, RunnerContext } from '@agent-device/contracts/interact import { bindAdmittedProviderInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { expect, test } from 'vitest'; +import { limrunAndroidSettingsFacts, limrunAppleSettingsFacts } from './interaction-operations.ts'; import { - limrunAppEventOperationFacts, - limrunAlertOperationFacts, - limrunSettingsOperationFacts, - limrunSystemButtonOperationFacts, - limrunClipboardOperationFacts, - limrunInteractionOperationFacts, - limrunNavigationOperationFacts, -} from './interaction-operations.ts'; + limrunAppLogFacts, + limrunAppLogRecoveryFacts, + liveSessionUnavailable, +} from './facts-runtime.ts'; +import { limrunOwnerOptions } from './app-log-runtime.fixtures.ts'; + +function factsFor(device: DeviceInfo = iosDevice, unavailable?: unknown) { + return (unavailable ? limrunAppLogRecoveryFacts : limrunAppLogFacts)(limrunOwnerOptions(), device) + .operations; +} const iosDevice: DeviceInfo = { platform: 'apple', @@ -40,14 +44,8 @@ const androidTvDevice: DeviceInfo = { target: 'tv', }; -const liveSessionUnavailable = Object.freeze({ - available: false, - reason: 'unsupported-provider-mode', - hint: 'The Limrun provider session is no longer active for this device.', -} as const); - test('the Android leg admits back/orientation and gates tv-remote on a real TV target', () => { - const mobile = limrunNavigationOperationFacts(androidMobileDevice); + const mobile = factsFor(androidMobileDevice); expect(mobile.back).toEqual({ available: true }); expect(mobile.setOrientation).toEqual({ available: true }); expect(mobile.unlockOrientation).toEqual({ available: true }); @@ -57,22 +55,24 @@ test('the Android leg admits back/orientation and gates tv-remote on a real TV t hint: 'tv-remote is supported only on Android TV targets.', }); - const tv = limrunNavigationOperationFacts(androidTvDevice); + const tv = factsFor(androidTvDevice); expect(tv.tvRemote).toEqual({ available: true }); }); test('a live session reads and writes the clipboard on both legs', () => { - const live = limrunClipboardOperationFacts(); - expect(live.readClipboard).toEqual({ available: true }); - expect(live.writeClipboard).toEqual({ available: true }); + for (const device of [iosDevice, androidMobileDevice]) { + const live = factsFor(device); + expect(live.readClipboard).toEqual({ available: true }); + expect(live.writeClipboard).toEqual({ available: true }); + } - const stale = limrunClipboardOperationFacts(liveSessionUnavailable); + const stale = factsFor(iosDevice, liveSessionUnavailable); expect(stale.readClipboard).toEqual(liveSessionUnavailable); expect(stale.writeClipboard).toEqual(liveSessionUnavailable); }); -test('home is admitted on both legs, app-switcher only on Android, the action button on neither', () => { - const android = limrunSystemButtonOperationFacts(androidMobileDevice); +test('home and app-switcher are admitted on both legs, with the Apple action button', () => { + const android = factsFor(androidMobileDevice); expect(android.home).toEqual({ available: true }); expect(android.appSwitcher).toEqual({ available: true }); expect(android.actionButton).toMatchObject({ @@ -80,16 +80,12 @@ test('home is admitted on both legs, app-switcher only on Android, the action bu reason: 'unsupported-provider-mode', }); - const ios = limrunSystemButtonOperationFacts(iosDevice); + const ios = factsFor(iosDevice); expect(ios.home).toEqual({ available: true }); - expect(ios.appSwitcher).toEqual({ - available: false, - reason: 'unsupported-provider-mode', - hint: 'Limrun iOS direct sessions do not expose app switcher yet.', - }); - expect(ios.actionButton).toMatchObject({ available: false, reason: 'unsupported-provider-mode' }); + expect(ios.appSwitcher).toEqual({ available: true }); + expect(ios.actionButton).toEqual({ available: true }); - const stale = limrunSystemButtonOperationFacts(androidMobileDevice, liveSessionUnavailable); + const stale = factsFor(androidMobileDevice, liveSessionUnavailable); expect(stale.home).toEqual(liveSessionUnavailable); expect(stale.appSwitcher).toEqual(liveSessionUnavailable); expect(stale.actionButton).toEqual(liveSessionUnavailable); @@ -100,12 +96,12 @@ test('home is admitted on both legs, app-switcher only on Android, the action bu // and TV rule; the iOS leg has no such member and falls to the family denial (#3848). test('the Android leg admits the hardware-button presses and gates them on the local rule', () => { const cells = HARDWARE_BUTTON_NAMES.map((name) => parseHardwareButtonPositionals([name])); - const android = limrunSystemButtonOperationFacts(androidMobileDevice); + const android = factsFor(androidMobileDevice); for (const cell of cells) { expect(android[cell]).toEqual({ available: true }); } - const tv = limrunSystemButtonOperationFacts(androidTvDevice); + const tv = factsFor(androidTvDevice); for (const cell of cells) { expect(tv[cell]).toEqual({ available: false, @@ -114,15 +110,17 @@ test('the Android leg admits the hardware-button presses and gates them on the l }); } - const ios = limrunSystemButtonOperationFacts(iosDevice); + const ios = factsFor(iosDevice); expect(ios.volumeUp).toMatchObject({ available: false, - reason: 'unsupported-provider-mode', - hint: 'No Limrun session exposes this system button.', + reason: 'unsupported-device-kind', + }); + expect(ios.mediaPlayPause).toMatchObject({ + available: false, + reason: 'unsupported-platform-leaf', }); - expect(ios.mediaPlayPause).toEqual(ios.volumeUp); - const stale = limrunSystemButtonOperationFacts(androidMobileDevice, liveSessionUnavailable); + const stale = factsFor(androidMobileDevice, liveSessionUnavailable); for (const cell of cells) { expect(stale[cell]).toEqual(liveSessionUnavailable); } @@ -131,36 +129,45 @@ test('the Android leg admits the hardware-button presses and gates them on the l // R57: app-event delivery is the one system leaf BOTH direct-session legs serve, because each // implements `open` and a deep link is exactly what that method routes. test('app-event delivery is admitted on both direct-session legs', () => { - expect(limrunAppEventOperationFacts(androidMobileDevice).triggerAppEvent).toEqual({ + expect(factsFor(androidMobileDevice).triggerAppEvent).toEqual({ available: true, }); - expect(limrunAppEventOperationFacts(iosDevice).triggerAppEvent).toEqual({ available: true }); - expect(limrunAppEventOperationFacts(iosDevice, liveSessionUnavailable).triggerAppEvent).toEqual( + expect(factsFor(iosDevice).triggerAppEvent).toEqual({ available: true }); + expect(factsFor(iosDevice, liveSessionUnavailable).triggerAppEvent).toEqual( liveSessionUnavailable, ); }); -test('the iOS leg serves the simctl settings only and reads nothing back', () => { - const android = limrunSettingsOperationFacts(androidMobileDevice); +test('iOS inherits shared settings except unavailable keychain reset', () => { + const android = limrunAndroidSettingsFacts(); expect(runtimeSubjectFact(android.setSetting, 'animations').available).toBe(true); - expect(runtimeSubjectFact(android.readSetting, 'text-size').available).toBe(true); - const ios = limrunSettingsOperationFacts(iosDevice).setSetting; - expect(runtimeSubjectFact(ios, 'appearance').available).toBe(true); - expect(runtimeSubjectFact(ios, 'wifi')).toMatchObject({ - available: false, - reason: 'unsupported-provider-mode', - }); - expect(limrunSettingsOperationFacts(iosDevice).readSetting).toEqual({ - available: false, - reason: 'unsupported-provider-mode', - hint: 'Limrun iOS direct sessions cannot read settings back.', - }); - expect( - limrunSettingsOperationFacts(androidMobileDevice, liveSessionUnavailable).setSetting, - ).toEqual(liveSessionUnavailable); - expect( - limrunSettingsOperationFacts(androidMobileDevice, liveSessionUnavailable).readSetting, - ).toEqual(liveSessionUnavailable); + const ios = limrunAppleSettingsFacts(testIosRunner.facts); + for (const setting of [ + 'clear-app-state', + 'appearance', + 'wifi', + 'airplane', + 'permission', + 'location', + 'text-size', + 'faceid', + 'touchid', + ]) { + expect(runtimeSubjectFact(ios.setSetting, setting).available).toBe(true); + } + expect(runtimeSubjectFact(ios.setSetting, 'reset-keychain').available).toBe(false); + expect(runtimeSubjectFact(ios.readSetting, 'text-size').available).toBe(true); + expect(limrunAndroidSettingsFacts(liveSessionUnavailable).setSetting).toEqual( + liveSessionUnavailable, + ); + expect(limrunAndroidSettingsFacts(liveSessionUnavailable).readSetting).toEqual( + liveSessionUnavailable, + ); +}); + +test('recovery facts cannot be changed after publication', () => { + const facts = limrunAppLogRecoveryFacts(limrunOwnerOptions(), iosDevice); + expect(Object.isFrozen(facts)).toBe(true); }); // R59 split, then #3349 closed the iOS half: the Android leg reuses the local family's @@ -169,31 +176,29 @@ test('the iOS leg serves the simctl settings only and reads nothing back', () => // platforms. test('alert legs are available on both legs of a live session and refused without one', () => { for (const leg of ['readAlert', 'awaitAlert', 'acceptAlert', 'dismissAlert'] as const) { - expect(limrunAlertOperationFacts(androidMobileDevice)[leg]).toEqual({ available: true }); - expect(limrunAlertOperationFacts(iosDevice)[leg]).toEqual({ available: true }); - expect(limrunAlertOperationFacts(androidMobileDevice, liveSessionUnavailable)[leg]).toEqual( - liveSessionUnavailable, - ); - expect(limrunAlertOperationFacts(iosDevice, liveSessionUnavailable)[leg]).toEqual( + expect(factsFor(androidMobileDevice)[leg]).toEqual({ available: true }); + expect(factsFor(iosDevice)[leg]).toEqual({ available: true }); + expect(factsFor(androidMobileDevice, liveSessionUnavailable)[leg]).toEqual( liveSessionUnavailable, ); + expect(factsFor(iosDevice, liveSessionUnavailable)[leg]).toEqual(liveSessionUnavailable); } }); test('the iOS leg admits back/orientation but explicitly refuses tv-remote', () => { - const facts = limrunNavigationOperationFacts(iosDevice); + const facts = factsFor(iosDevice); expect(facts.back).toEqual({ available: true }); expect(facts.setOrientation).toEqual({ available: true }); expect(facts.unlockOrientation.available).toBe(false); expect(facts.tvRemote).toEqual({ available: false, - reason: 'unsupported-provider-mode', - hint: 'Limrun iOS direct sessions do not expose tv remote control.', + reason: 'unsupported-platform-leaf', + hint: 'tv-remote is supported only on tvOS devices.', }); }); test('a dead session closes all three navigation cells with the same reason regardless of platform', () => { - const facts = limrunNavigationOperationFacts(androidTvDevice, liveSessionUnavailable); + const facts = factsFor(androidTvDevice, liveSessionUnavailable); expect(facts.back).toEqual(liveSessionUnavailable); expect(facts.setOrientation).toEqual(liveSessionUnavailable); expect(facts.unlockOrientation).toEqual(liveSessionUnavailable); @@ -219,8 +224,8 @@ test('binds only the operations the facts admitted, driving the resolved interac } as unknown as Interactor; const getInteractor = (_device: DeviceInfo, _runner?: RunnerContext) => interactor; const facts = { - ...limrunNavigationOperationFacts(androidTvDevice), - ...limrunSystemButtonOperationFacts(androidTvDevice), + ...factsFor(androidTvDevice), + ...factsFor(androidTvDevice), }; const operations = bindAdmittedProviderInteractorOperations({ @@ -245,8 +250,8 @@ test('binds only the operations the facts admitted, driving the resolved interac test('binding omits every operation an unavailable fact refused', () => { const facts = { - ...limrunNavigationOperationFacts(iosDevice), - ...limrunSystemButtonOperationFacts(iosDevice), + ...factsFor(iosDevice), + ...factsFor(iosDevice), }; const operations = bindAdmittedProviderInteractorOperations({ device: iosDevice, @@ -258,16 +263,16 @@ test('binding omits every operation an unavailable fact refused', () => { expect(operations.back).toBeTypeOf('function'); expect(operations.setOrientation).toBeTypeOf('function'); expect(operations.home).toBeTypeOf('function'); - expect(operations.appSwitcher).toBeUndefined(); + expect(operations.appSwitcher).toBeTypeOf('function'); expect(operations.tvRemote).toBeUndefined(); }); test('long press is admitted on both direct-session legs and closed with a dead session', () => { - expect(limrunInteractionOperationFacts(iosDevice).longPressPoint).toEqual({ available: true }); - expect(limrunInteractionOperationFacts(androidMobileDevice).longPressPoint).toEqual({ + expect(factsFor(iosDevice).longPressPoint).toEqual({ available: true }); + expect(factsFor(androidMobileDevice).longPressPoint).toEqual({ available: true, }); - expect(limrunInteractionOperationFacts(iosDevice, liveSessionUnavailable).longPressPoint).toEqual( + expect(factsFor(iosDevice, liveSessionUnavailable).longPressPoint).toEqual( liveSessionUnavailable, ); }); diff --git a/packages/provider-limrun/src/interaction-operations.ts b/packages/provider-limrun/src/interaction-operations.ts index 77c4a5c88b..0922f8d53c 100644 --- a/packages/provider-limrun/src/interaction-operations.ts +++ b/packages/provider-limrun/src/interaction-operations.ts @@ -1,3 +1,10 @@ +import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; +import { + type RuntimeFacts, + type RuntimeOperationFact, + type RuntimeOperationUnavailability, + unavailableFact, +} from '@agent-device/contracts/platform-runtime'; import { backRuntimeOperationFacts } from '@agent-device/contracts/back-runtime'; import { bindProviderFocusInteractor, @@ -10,7 +17,6 @@ import { import { bindProviderGestureInteractor, gestureRuntimeOperationFacts, - type GestureRuntimeOperationFacts, } from '@agent-device/contracts/gesture-runtime'; import { bindProviderScrollInteractor, @@ -19,7 +25,6 @@ import { import { appEventRuntimeOperationFacts } from '@agent-device/contracts/app-event-runtime'; import { settingsRuntimeOperationFacts } from '@agent-device/contracts/settings-runtime'; import type { AndroidSetting } from '@agent-device/platform-android/mechanics'; -import type { LimrunIosSetting } from './ios-settings.ts'; import { alertRuntimeOperationFacts } from '@agent-device/contracts/alert-runtime'; import { androidHardwareButtonFacts, @@ -43,148 +48,88 @@ import { touchRuntimeOperationFacts, } from '@agent-device/contracts/touch-runtime'; import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; -import { - type RuntimeOperationFact, - type RuntimeOperationUnavailability, - unavailableFact, -} from '@agent-device/contracts/platform-runtime'; -import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; +import { type DeviceInfo } from '@agent-device/kernel/device'; import { setTimeout as sleep } from 'node:timers/promises'; const available = Object.freeze({ available: true } as const); -/** - * Limrun's iOS direct session drives text and touch but exposes no portable gesture execution. - * Stating it as a fact refuses at admission instead of mid-execution (ADR 0019 §6), and it is - * the only refusal the caller sees: the leg's interactor carries no gesture member at all. - */ -const iosGestureUnavailable = unavailableFact( - 'unsupported-provider-mode', - 'Limrun iOS direct sessions do not expose portable gesture execution yet.', -); -/** What an unnamed gesture tier reports on a live Limrun session. */ -const gestureUnsupportedProviderMode = unavailableFact('unsupported-provider-mode'); -/** What an unnamed clipboard half reports on a live Limrun session. */ -const clipboardUnsupportedProviderMode = unavailableFact('unsupported-provider-mode'); -const androidTvMultiTouchUnavailable = unavailableFact( - 'unsupported-platform-leaf', - ANDROID_TV_MULTI_TOUCH_UNSUPPORTED_HINT, -); -const androidTvDragUnavailable = unavailableFact( - 'unsupported-platform-leaf', - TARGET_AUTHORED_DRAG_UNSUPPORTED_HINT, -); - -/** - * Gesture cells split by the interactor behind the session: an Android emulator session runs the - * ordinary Android interactor (every tier, minus the TV gates it always carried), while the iOS - * direct session has no gesture execution at all. - */ -function limrunGestureFacts( - device: DeviceInfo, - cell: RuntimeOperationUnavailability | typeof available, -): GestureRuntimeOperationFacts { - if (!cell.available) { - return gestureRuntimeOperationFacts({ unsupported: cell }); - } - if (device.platform !== 'android') { - return gestureRuntimeOperationFacts({ unsupported: iosGestureUnavailable }); - } - const tv = device.target === 'tv'; - return gestureRuntimeOperationFacts({ - unsupported: gestureUnsupportedProviderMode, - plan: available, - directionalFling: available, - multiTouch: tv ? androidTvMultiTouchUnavailable : available, - targetAuthoredDrag: tv ? androidTvDragUnavailable : available, - viewport: available, - }); -} -const tvRemoteUnavailableIos = unavailableFact( - 'unsupported-provider-mode', - 'Limrun iOS direct sessions do not expose tv remote control.', -); -const tvRemoteUnavailableAndroid = unavailableFact( - 'unsupported-device-kind', - 'tv-remote is supported only on Android TV targets.', -); -/** - * The retired leaf never routed `keyboard` through provider resolution at all — it dispatched - * directly by device platform, bypassing the interactor/provider seam entirely. The Android leg - * genuinely carries it (the same `createAndroidInteractor` factory the local family binds); the - * iOS leg has no such reuse, so it stays honestly unavailable rather than guessing at untested - * provider behavior. - */ -const keyboardUnavailableIos = unavailableFact( - 'unsupported-provider-mode', - 'Limrun iOS direct sessions do not expose keyboard actions.', -); -const settingUnavailableIos = unavailableFact( - 'unsupported-provider-mode', - 'Limrun iOS direct sessions support settings appearance, permission, location, and clear-app-state.', -); +const unsupported = unavailableFact('unsupported-provider-mode'); const settingUnavailableAndroid = unavailableFact( 'unsupported-platform-leaf', 'Run `agent-device settings --help` for the settings each target serves.', ); -const settingsReadUnavailableIos = unavailableFact( - 'unsupported-provider-mode', - 'Limrun iOS direct sessions cannot read settings back.', -); -const appSwitcherUnavailableIos = unavailableFact( +export const LIMRUN_FOLD_UNAVAILABLE = unavailableFact( 'unsupported-provider-mode', - 'Limrun iOS direct sessions do not expose app switcher yet.', + 'Limrun exposes the commanded hinge angle, but fold requires an independently observed angle to verify the pose.', ); -/** No Limrun session exposes the action button; only `appSwitcher` still splits by leg. */ -const systemButtonUnavailable = unavailableFact( +export const LIMRUN_TELEPHONY_UNAVAILABLE = unavailableFact( 'unsupported-provider-mode', - 'No Limrun session exposes this system button.', + "telephony sends calls and SMS to an Android emulator's console through local adb; agent-device does not route it through a Limrun session.", ); -// The hardware-button cells use the shared Android admission rule, so a Limrun-managed phone -// and a local adb phone state the same verdict. On a leased phone or tablet the Android leg's -// `createAndroidInteractor` carries these presses. -/** - * The interactor-backed interaction cells a live Limrun session serves: everything here rides - * one provider interactor, and a live session always has one, so the cells are available - * together. Extracted from the app-log owner because text/point interaction is not app-log - * behavior — the owner module composes this, it does not define it. - */ -export function limrunInteractionOperationFacts( - device: DeviceInfo, - liveSessionUnavailable?: RuntimeOperationUnavailability, -) { - const cell = liveSessionUnavailable ?? available; - const unsupportedTouch = unavailableFact('unsupported-provider-mode'); - return Object.freeze({ - ...focusRuntimeOperationFacts({ focus: cell }), - ...typeTextRuntimeOperationFacts({ type: cell }), +export function limrunAndroidInteractionFacts(device: DeviceInfo) { + const tv = device.target === 'tv'; + return { + ...focusRuntimeOperationFacts({ focus: available }), + ...typeTextRuntimeOperationFacts({ type: available }), ...touchRuntimeOperationFacts({ - unsupported: unsupportedTouch, - tap: cell, - longPress: cell, - // A dead session refuses hover for its own reason; the family builder adds the redirection - // to longpress either way. - hover: liveSessionUnavailable ?? unsupportedTouch, - fill: cell, - tapElementSelector: liveSessionUnavailable ?? (isIosFamily(device) ? cell : unsupportedTouch), + unsupported, + tap: available, + longPress: available, + fill: available, }), - ...limrunGestureFacts(device, cell), - // `scroll` needs no gesture synthesis: both session kinds expose it directly. - ...scrollRuntimeOperationFacts({ scroll: cell }), - }); + ...gestureRuntimeOperationFacts({ + unsupported, + plan: available, + directionalFling: available, + viewport: available, + multiTouch: tv + ? unavailableFact('unsupported-platform-leaf', ANDROID_TV_MULTI_TOUCH_UNSUPPORTED_HINT) + : available, + targetAuthoredDrag: tv + ? unavailableFact('unsupported-platform-leaf', TARGET_AUTHORED_DRAG_UNSUPPORTED_HINT) + : available, + }), + ...scrollRuntimeOperationFacts({ scroll: available }), + ...backRuntimeOperationFacts({ back: available }), + ...orientationRuntimeOperationFacts({ orientation: available, unlock: available }), + ...tvRemoteRuntimeOperationFacts({ + tvRemote: tv + ? available + : unavailableFact( + 'unsupported-device-kind', + 'tv-remote is supported only on Android TV targets.', + ), + }), + ...keyboardRuntimeOperationFacts({ + unsupported, + status: available, + dismiss: available, + enter: available, + }), + ...clipboardRuntimeOperationFacts({ unsupported, read: available, write: available }), + ...systemButtonRuntimeOperationFacts({ + unsupported, + home: available, + appSwitcher: available, + ...androidHardwareButtonFacts(device), + }), + ...alertRuntimeOperationFacts({ + read: available, + wait: available, + accept: available, + dismiss: available, + }), + ...appEventRuntimeOperationFacts({ triggerAppEvent: available }), + }; } -/** - * Binds the interactor-backed operations (snapshot, screenshot, focus, type, gestures, scroll) for - * one session. Only a live session reaches here, so the gesture tiers are gated by the same device - * split their facts use rather than by liveness. - */ export function bindLimrunInteractionOperations( params: Readonly<{ device: DeviceInfo; signal: AbortSignal; getInteractor(device: DeviceInfo, runner?: RunnerContext): Interactor | undefined; presentIosAcquisition: SnapshotRuntimePresenter; + facts: RuntimeFacts['operations']; }>, ) { const { device, signal } = params; @@ -202,225 +147,60 @@ export function bindLimrunInteractionOperations( device, signal, resolveInteractor, - facts: limrunInteractionOperationFacts(device), + facts: params.facts, pause: async (milliseconds) => await sleep(milliseconds, undefined, { signal }), }), ...bindProviderScreenshotInteractor({ device, signal, resolveInteractor }), ...bindProviderGestureInteractor({ device, signal, - facts: limrunGestureFacts(device, available), + facts: params.facts, resolveInteractor, }), ...bindProviderScrollInteractor({ device, signal, resolveInteractor }), }); } -/** - * `back`/`orientation`/`tvRemote` differ by direct-session platform, unlike focus/type: - * the Android leg rides `session.dependencies.android.createInteractor` (`android.ts`) — the - * SAME factory the local Android family binds, so it carries the identical cell table (parity - * with the local owner, including the `device.target === 'tv'` gate for `tvRemote`). The iOS - * direct session is a phone/tablet surface with no remote receiver, so its fact refuses - * `tvRemote` and its interactor carries no such member. - */ -export function limrunNavigationOperationFacts( - device: DeviceInfo, +export function limrunAndroidSettingsFacts( liveSessionUnavailable?: RuntimeOperationUnavailability, ) { - if (liveSessionUnavailable) { - return Object.freeze({ - ...backRuntimeOperationFacts({ back: liveSessionUnavailable }), - ...orientationRuntimeOperationFacts({ - orientation: liveSessionUnavailable, - unlock: liveSessionUnavailable, - }), - ...tvRemoteRuntimeOperationFacts({ tvRemote: liveSessionUnavailable }), - }); - } - if (device.platform === 'android') { - return Object.freeze({ - ...backRuntimeOperationFacts({ back: available }), - ...orientationRuntimeOperationFacts({ orientation: available, unlock: available }), - ...tvRemoteRuntimeOperationFacts({ - tvRemote: device.target === 'tv' ? available : tvRemoteUnavailableAndroid, - }), - }); - } - return Object.freeze({ - ...backRuntimeOperationFacts({ back: available }), - ...orientationRuntimeOperationFacts({ orientation: available }), - ...tvRemoteRuntimeOperationFacts({ tvRemote: tvRemoteUnavailableIos }), + return settingsRuntimeOperationFacts({ + target: liveSessionUnavailable ?? available, + unsupported: settingUnavailableAndroid, + write: { + wifi: available, + bluetooth: available, + airplane: available, + 'mobile-data': available, + location: available, + animations: available, + appearance: available, + 'text-size': available, + fingerprint: available, + 'clear-app-state': available, + permission: available, + } satisfies Record, + read: { 'text-size': available }, }); } -/** - * The Android leg rides `session.dependencies.android.createInteractor`, the factory the local - * Android family binds, so `cmd clipboard get/set text` reaches the device as it does locally. - * The iOS direct session reads and writes the pasteboard through Limrun's `simctl pbpaste` and - * `pbcopy`. - */ -export function limrunClipboardOperationFacts( - liveSessionUnavailable?: RuntimeOperationUnavailability, +export function limrunAppleSettingsFacts( + facts: Partial['operations']>, ) { - if (liveSessionUnavailable) { - return Object.freeze({ - ...clipboardRuntimeOperationFacts({ unsupported: liveSessionUnavailable }), - }); - } - return Object.freeze({ - ...clipboardRuntimeOperationFacts({ - unsupported: clipboardUnsupportedProviderMode, - read: available, - write: available, - }), - }); -} - -/** - * `alert` splits like every other interaction leaf: the Android leg rides the local family's own - * interactor factory, which reads the same accessibility dump it always did; the iOS direct - * session reads system alerts out of the same element-tree capture its snapshot rides and presses - * their buttons through the same touch seam (issue #3349), so all four legs are available on - * both legs of a live session. - */ -export function limrunAlertOperationFacts( - _device: DeviceInfo, - liveSessionUnavailable?: RuntimeOperationUnavailability, -) { - const cell = liveSessionUnavailable ?? available; - return Object.freeze({ - ...alertRuntimeOperationFacts({ read: cell, wait: cell, accept: cell, dismiss: cell }), - }); -} - -/** - * The Android leg rides the local family's own interactor factory for `home`, `appSwitcher` and - * the hardware-button presses. The iOS direct session presses `home` through Limrun's button - * actions and refuses `appSwitcher`. No Limrun session exposes the action button. - */ -export function limrunSystemButtonOperationFacts( - device: DeviceInfo, - liveSessionUnavailable?: RuntimeOperationUnavailability, -) { - if (liveSessionUnavailable) { - return systemButtonRuntimeOperationFacts({ unsupported: liveSessionUnavailable }); - } - if (device.platform === 'android') { - return systemButtonRuntimeOperationFacts({ - unsupported: systemButtonUnavailable, - home: available, - appSwitcher: available, - ...androidHardwareButtonFacts(device), - }); - } - return systemButtonRuntimeOperationFacts({ - unsupported: systemButtonUnavailable, - home: available, - appSwitcher: appSwitcherUnavailableIos, - }); -} - -/** - * `fold` sends a HID hinge event through a helper the daemon host spawns inside one of its own iOS - * simulators with `simctl spawn`; a Limrun session's device is not a host simulator. - */ -export const LIMRUN_FOLD_UNAVAILABLE = unavailableFact( - 'unsupported-provider-mode', - 'fold runs a HID helper through simctl spawn inside a foldable iPhone simulator on the daemon host; a Limrun session has no such simulator.', -); - -export const LIMRUN_TELEPHONY_UNAVAILABLE = unavailableFact( - 'unsupported-provider-mode', - "telephony sends calls and SMS to an Android emulator's console through local adb; agent-device does not route it through a Limrun session.", -); - -/** The fold refusal both Limrun legs share. */ -export function limrunFoldOperationFacts() { - return Object.freeze({ setFoldPose: LIMRUN_FOLD_UNAVAILABLE }); -} - -/** The telephony refusal both Limrun legs share. */ -export function limrunTelephonyOperationFacts() { - return Object.freeze({ sendTelephonyEvent: LIMRUN_TELEPHONY_UNAVAILABLE }); -} - -/** - * `trigger-app-event` is the one system leaf both direct-session legs genuinely serve: each - * implements `open`, and a deep link is exactly what that method routes (`openUrl` on iOS, the - * local Android interactor's `am start` on Android). - */ -export function limrunAppEventOperationFacts( - device: DeviceInfo, - liveSessionUnavailable?: RuntimeOperationUnavailability, -) { - void device; - return Object.freeze({ - ...appEventRuntimeOperationFacts({ triggerAppEvent: liveSessionUnavailable ?? available }), - }); -} - -/** The Android leg runs the local Android interactor; the iOS leg runs the simctl plan. */ -export function limrunSettingsOperationFacts( - device: DeviceInfo, - liveSessionUnavailable?: RuntimeOperationUnavailability, -) { - const target = liveSessionUnavailable ?? available; - return Object.freeze( - settingsRuntimeOperationFacts( - device.platform === 'android' - ? { - target, - unsupported: settingUnavailableAndroid, - write: { - wifi: available, - bluetooth: available, - airplane: available, - 'mobile-data': available, - location: available, - animations: available, - appearance: available, - 'text-size': available, - fingerprint: available, - 'clear-app-state': available, - permission: available, - } satisfies Record, - read: { 'text-size': available }, - } - : { - target, - unsupported: settingUnavailableIos, - write: { - appearance: available, - permission: available, - location: available, - 'clear-app-state': available, - } satisfies Record, - read: { 'text-size': settingsReadUnavailableIos }, + const setting = facts.setSetting; + return { + readSetting: facts.readSetting ?? unsupported, + setSetting: setting?.available + ? { + ...setting, + subjects: { + ...setting.subjects, + 'reset-keychain': unavailableFact( + 'unsupported-provider-mode', + 'Limrun does not expose keychain reset.', + ), }, - ), - ); -} - -/** - * `keyboard` (status/dismiss/enter) shares one cell per session: the Android leg rides the same - * interactor factory `limrunNavigationOperationFacts` above describes; the iOS leg has no tested - * provider keyboard behavior, so it stays unavailable. - */ -export function limrunKeyboardOperationFacts( - device: DeviceInfo, - liveSessionUnavailable?: RuntimeOperationUnavailability, -) { - // One denial covers the iOS leg and any session that is no longer live: neither serves a - // keyboard operation. - const unsupported = liveSessionUnavailable ?? keyboardUnavailableIos; - const androidLegServesKeyboard = - liveSessionUnavailable === undefined && device.platform === 'android'; - return Object.freeze({ - ...keyboardRuntimeOperationFacts( - androidLegServesKeyboard - ? { unsupported, status: available, dismiss: available, enter: available } - : { unsupported }, - ), - }); + } + : (setting ?? unsupported), + }; } diff --git a/packages/provider-limrun/src/ios-collapsed-tab-bar.test.ts b/packages/provider-limrun/src/ios-collapsed-tab-bar.test.ts deleted file mode 100644 index 6e20911139..0000000000 --- a/packages/provider-limrun/src/ios-collapsed-tab-bar.test.ts +++ /dev/null @@ -1,337 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { presentIosSnapshotAcquisition } from '@agent-device/capture-kit/ios-snapshot-runtime'; -import type { SnapshotRuntimeAcquiredResult } from '@agent-device/contracts/interactor-types'; -import { afterEach, expect, test, vi } from 'vitest'; -import { captureLimrunIosSnapshot } from './ios-snapshot-adapter.ts'; -import { - IPHONE_TAB_BAR_FRAME, - LIMRUN_TAB_BUTTONS, - createLimrunHitTestSession, - limrunCollapsedTabBarTree, - limrunEmptyGroup, - limrunScreenTree, - limrunTabButton, -} from './ios-snapshot-adapter.fixtures.ts'; -import type { IosTreeNode } from '@agent-device/capture-kit/ios-ax-tree'; - -afterEach(() => { - vi.useRealTimers(); -}); - -function tabsOf(result: SnapshotRuntimeAcquiredResult, barLabel = 'Tab Bar') { - const { nodes } = result.acquisition; - const bar = nodes.find((node) => node.label === barLabel)!; - return nodes.filter((node) => node.parentIndex === bar.index); -} - -test('Limrun publishes the tabs its hit-tests find inside a collapsed iOS tab bar', async () => { - const { session, probes } = createLimrunHitTestSession( - limrunCollapsedTabBarTree(), - LIMRUN_TAB_BUTTONS, - ); - const result = await captureLimrunIosSnapshot(session); - const bar = result.acquisition.nodes.find((node) => node.label === 'Tab Bar')!; - const tabs = tabsOf(result); - - expect(tabs.map((tab) => tab.label)).toEqual(['Home', 'Catalog', 'Form', 'Audio', 'Settings']); - expect(tabs[0]).toMatchObject({ - type: 'RadioButton', - rect: { x: 25, y: 795, width: 74, height: 54 }, - selected: true, - depth: (bar.depth ?? 0) + 1, - }); - expect(tabs.filter((tab) => tab.selected).map((tab) => tab.label)).toEqual(['Home']); - expect(probes.length).toBeLessThanOrEqual(24); - expect(result.acquisition.residue).not.toContainEqual( - expect.objectContaining({ kind: 'collapsed-tab-bar-probe-failed' }), - ); -}); - -const FIVE_TABS = ['Home', 'Catalog', 'Form', 'Audio', 'Settings']; -const LANDSCAPE = { width: 874, height: 402 }; - -function landscapeTabs(y: number, height: number) { - return FIVE_TABS.map((label, index) => - limrunTabButton(label, { x: 187 + index * 100, y, width: 100, height }, index === 0), - ); -} - -test('Limrun probes the tab bar before a home indicator strip that precedes it', async () => { - const { session, probes } = createLimrunHitTestSession( - limrunScreenTree([ - limrunEmptyGroup({ x: 0, y: 854, width: 402, height: 20 }, 'Home Indicator Area'), - limrunEmptyGroup(IPHONE_TAB_BAR_FRAME, 'Tab Bar'), - ]), - LIMRUN_TAB_BUTTONS, - ); - const result = await captureLimrunIosSnapshot(session); - - expect(tabsOf(result).map((tab) => tab.label)).toEqual(FIVE_TABS); - expect(tabsOf(result, 'Home Indicator Area')).toEqual([]); - expect(new Set(probes.map((point) => point.y))).toEqual(new Set([832.5])); -}); - -test('Limrun moves past a lower bar-sized group whose hit-tests find no tabs', async () => { - const { session, probes } = createLimrunHitTestSession( - limrunScreenTree([ - limrunEmptyGroup({ x: 0, y: 830, width: 402, height: 44 }, 'Toolbar'), - limrunEmptyGroup(IPHONE_TAB_BAR_FRAME, 'Tab Bar'), - ]), - LIMRUN_TAB_BUTTONS, - ); - const result = await captureLimrunIosSnapshot(session); - - expect(tabsOf(result).map((tab) => tab.label)).toEqual(FIVE_TABS); - expect(tabsOf(result, 'Toolbar')).toEqual([]); - // Both candidates were hit-tested along their own midlines, inside one budget. - expect(new Set(probes.map((point) => point.y))).toEqual(new Set([852, 832.5])); - expect(probes.length).toBeLessThanOrEqual(24); -}); - -test('Limrun finds the tabs of a landscape iPhone tab bar', async () => { - const { session, probes } = createLimrunHitTestSession( - limrunScreenTree( - [limrunEmptyGroup({ x: 0, y: 350, width: 874, height: 52 }, 'Tab Bar')], - LANDSCAPE, - ), - landscapeTabs(352, 44), - { screen: LANDSCAPE }, - ); - const result = await captureLimrunIosSnapshot(session); - - expect(tabsOf(result).map((tab) => tab.label)).toEqual(FIVE_TABS); - expect(probes.length).toBeLessThanOrEqual(24); -}); - -test('Limrun finds a landscape tab bar behind a full-width decoy that precedes it', async () => { - const { session, probes } = createLimrunHitTestSession( - limrunScreenTree( - [ - limrunEmptyGroup({ x: 0, y: 252, width: 874, height: 150 }, 'Backdrop'), - limrunEmptyGroup({ x: 0, y: 350, width: 874, height: 52 }, 'Tab Bar'), - ], - LANDSCAPE, - ), - landscapeTabs(352, 44), - { screen: LANDSCAPE }, - ); - const result = await captureLimrunIosSnapshot(session); - - expect(tabsOf(result).map((tab) => tab.label)).toEqual(FIVE_TABS); - expect(probes.length).toBeLessThanOrEqual(24); -}); - -test('Limrun reports a tab bar the probe budget could not reach', async () => { - // Both groups are bar-sized; the lower one is probed first, finds nothing, and leaves too few - // probes to cover the 874-point bar above it. - const { session, probes } = createLimrunHitTestSession( - limrunScreenTree( - [ - limrunEmptyGroup({ x: 0, y: 330, width: 874, height: 72 }, 'Tab Bar'), - limrunEmptyGroup({ x: 0, y: 356, width: 874, height: 46 }, 'Toolbar'), - ], - LANDSCAPE, - ), - landscapeTabs(334, 40), - { screen: LANDSCAPE }, - ); - const result = await captureLimrunIosSnapshot(session); - - expect(tabsOf(result)).toEqual([]); - expect(probes.length).toBeLessThan(24); - expect(result.acquisition.residue).toContainEqual({ - kind: 'collapsed-tab-bar-probe-failed', - reason: 'budget-exhausted', - }); -}); - -test('Limrun reports no miss for a strip too wide to probe without a tab bar height', async () => { - const screen = { width: 1194, height: 834 }; - const { session, probes } = createLimrunHitTestSession( - limrunScreenTree([limrunEmptyGroup({ x: 0, y: 814, width: 1194, height: 20 })], screen), - [], - { screen }, - ); - const result = await captureLimrunIosSnapshot(session); - - expect(probes).toEqual([]); - expect(result.acquisition.residue).not.toContainEqual( - expect.objectContaining({ kind: 'collapsed-tab-bar-probe-failed' }), - ); -}); - -test('Limrun reports a bar with a tab bar height that is too wide to probe', async () => { - const screen = { width: 1194, height: 834 }; - const { session, probes } = createLimrunHitTestSession( - limrunScreenTree([limrunEmptyGroup({ x: 0, y: 784, width: 1194, height: 50 })], screen), - [], - { screen }, - ); - const result = await captureLimrunIosSnapshot(session); - - expect(probes).toEqual([]); - expect(result.acquisition.residue).toContainEqual({ - kind: 'collapsed-tab-bar-probe-failed', - reason: 'budget-exhausted', - }); -}); - -test('Limrun probes no childless group that is not docked along the bottom edge', async () => { - const { session, probes } = createLimrunHitTestSession( - limrunCollapsedTabBarTree({ x: 0, y: 62, width: 402, height: 83 }), - LIMRUN_TAB_BUTTONS.map((tab) => ({ ...tab, frame: { ...tab.frame!, y: 66 } })), - ); - - expect(tabsOf(await captureLimrunIosSnapshot(session))).toEqual([]); - expect(probes).toEqual([]); -}); - -test('Limrun publishes no hit-test result that is not a tab button', async () => { - const { session } = createLimrunHitTestSession( - limrunCollapsedTabBarTree(), - LIMRUN_TAB_BUTTONS.map((tab) => ({ ...tab, type: 'Button', traits: ['Button'] })), - ); - - expect(tabsOf(await captureLimrunIosSnapshot(session))).toEqual([]); -}); - -test('Limrun keeps a tab bar unexpanded and reports it when one hit-test fails', async () => { - let calls = 0; - const { session } = createLimrunHitTestSession(limrunCollapsedTabBarTree(), LIMRUN_TAB_BUTTONS, { - hitTest: async (_point, hit) => { - calls += 1; - if (calls === 3) throw new Error('socket closed'); - return hit; - }, - }); - const result = await captureLimrunIosSnapshot(session); - - expect(tabsOf(result)).toEqual([]); - expect(result.acquisition.residue).toContainEqual({ - kind: 'collapsed-tab-bar-probe-failed', - reason: 'rejected', - }); - const expanded = createLimrunHitTestSession(limrunCollapsedTabBarTree(), LIMRUN_TAB_BUTTONS); - const baseline = presentIosSnapshotAcquisition(await captureLimrunIosSnapshot(expanded.session)); - expect(presentIosSnapshotAcquisition(result).warnings?.length).toBe( - (baseline.warnings?.length ?? 0) + 1, - ); -}); - -test('Limrun stops waiting for a hung hit-test well before the SDK request timeout', async () => { - vi.useFakeTimers(); - let calls = 0; - const { session } = createLimrunHitTestSession(limrunCollapsedTabBarTree(), LIMRUN_TAB_BUTTONS, { - hitTest: (_point, hit) => { - calls += 1; - return calls === 2 ? new Promise(() => {}) : Promise.resolve(hit); - }, - }); - let result: SnapshotRuntimeAcquiredResult | undefined; - void captureLimrunIosSnapshot(session).then((value) => { - result = value; - }); - await vi.advanceTimersByTimeAsync(2_000); - - expect(result).toBeDefined(); - expect(tabsOf(result!)).toEqual([]); - expect(result!.acquisition.residue).toContainEqual({ - kind: 'collapsed-tab-bar-probe-failed', - reason: 'timed-out', - }); -}); - -type GoldenRect = { x: number; y: number; width: number; height: number }; -const geometry = JSON.parse( - readFileSync( - new URL('../../../contracts/fixtures/ios-collapsed-tab-geometry.json', import.meta.url), - 'utf8', - ), -) as { - containers: { name: string; frame: GoldenRect; expected: boolean }[]; - candidates: { name: string; container: GoldenRect; frame: GoldenRect; expected: boolean }[]; - rows: { name: string; container: GoldenRect; midpoints: number[]; expected: boolean }[]; -}; - -/** A screen whose viewport ends at `container`, so the container is docked along its bottom edge. */ -function dockedScreen(container: GoldenRect) { - return { x: container.x, width: container.width, height: container.y + container.height }; -} - -function dockedSession( - container: GoldenRect, - hit: (point: { x: number; y: number }) => IosTreeNode[], -) { - const screen = dockedScreen(container); - const tree = limrunScreenTree([limrunEmptyGroup(container, 'Tab Bar')], screen); - tree[0]!.frame = { x: screen.x, y: 0, width: screen.width, height: screen.height }; - return createLimrunHitTestSession(tree, [], { - screen, - hitTest: async (point) => hit(point), - }); -} - -for (const row of geometry.containers) { - test(`collapsed tab golden container: ${row.name}`, async () => { - const { session, probes } = dockedSession(row.frame, () => []); - await captureLimrunIosSnapshot(session); - expect(probes.length > 0).toBe(row.expected); - }); -} - -for (const row of geometry.candidates) { - test(`collapsed tab golden candidate: ${row.name}`, async () => { - // Two distinct tabs with the same frame, so only the candidate rule decides. - const { session } = dockedSession(row.container, () => [ - limrunTabButton('Left', row.frame), - limrunTabButton('Twin', row.frame), - ]); - const tabs = tabsOf(await captureLimrunIosSnapshot(session)); - expect(tabs.length === 2).toBe(row.expected); - }); -} - -for (const row of geometry.rows) { - test(`collapsed tab golden row: ${row.name}`, async () => { - const items = row.midpoints.map((midpoint, index) => - limrunTabButton(`Tab ${index}`, { - x: row.container.x + 20 + index * 70, - y: midpoint - 5, - width: 60, - height: 10, - }), - ); - const { session } = dockedSession(row.container, () => items); - const tabs = tabsOf(await captureLimrunIosSnapshot(session)); - expect(tabs.length === items.length).toBe(row.expected); - }); -} - -const traitClassification = JSON.parse( - readFileSync( - new URL('../../../contracts/fixtures/ios-trait-classification.json', import.meta.url), - 'utf8', - ), -) as { - cases: Array<{ - name: string; - limrun?: { - fact: string; - seam: string; - input: IosTreeNode; - expected: { tabCandidate: boolean }; - }; - }>; -}; -for (const row of traitClassification.cases.filter((entry) => entry.limrun?.seam === 'tab')) { - test(`Limrun trait tab golden: ${row.limrun!.fact}`, async () => { - const frame = { x: 25, y: 795, width: 74, height: 54 }; - const { session } = dockedSession({ x: 0, y: 791, width: 402, height: 83 }, () => [ - { ...row.limrun!.input, label: 'Left', frame }, - { ...row.limrun!.input, label: 'Right', frame }, - ]); - expect(tabsOf(await captureLimrunIosSnapshot(session)).length === 2).toBe( - row.limrun!.expected.tabCandidate, - ); - }); -} diff --git a/packages/provider-limrun/src/ios-collapsed-tab-bar.ts b/packages/provider-limrun/src/ios-collapsed-tab-bar.ts deleted file mode 100644 index a93e8d9e86..0000000000 --- a/packages/provider-limrun/src/ios-collapsed-tab-bar.ts +++ /dev/null @@ -1,213 +0,0 @@ -import type { IosCollapsedTabBarProbeFailure } from '@agent-device/contracts/ios-snapshot'; -import type { Rect } from '@agent-device/kernel/snapshot'; -import { - readIosNodeChildren, - readIosNodeRect, - type IosTreeNode, -} from '@agent-device/capture-kit/ios-ax-tree'; - -type Point = { x: number; y: number }; - -/** One Limrun hit-test: the element tree at `point`, as `client.elementTree(point)` returns it. */ -type LimrunIosHitTest = (point: Point) => Promise; - -/** Preferred spacing of the hit-tests across a bar. */ -const TAB_PROBE_SPACING = 32; -/** Widest spacing that still hits every tab: the 44 pt minimum tap target. */ -const MAX_TAB_PROBE_SPACING = 44; -/** The most hit-tests one snapshot sends, shared out over every candidate bar. */ -const MAX_TAB_PROBES = 24; -/** Heights a docked iOS tab bar takes, with or without the home indicator inset below it. */ -const TAB_BAR_HEIGHT = { min: 44, max: 100 }; -/** - * How long a candidate's parallel hit-tests may take. A live probe answers in about 150 ms; the - * SDK's own request timeout is 30 s, which would stall the snapshot. - */ -const TAB_PROBE_TIMEOUT_MS = 1_500; - -/** - * The tree with a collapsed tab bar's tabs published as its children. A live Limrun iPhone - * reports the iOS tab bar as one childless `Group` along the viewport's bottom edge, with no tab - * trait, while hit-tests inside its frame return the tab buttons. This applies the Apple runner's - * collapsed tab container rule (`isCollapsedTabContainer`, `collapsedTabFallbackNodes`) to those - * hit-tests; Limrun marks a tab with the `TabButton` trait, which stands in for the runner's - * candidate types. `contracts/fixtures/ios-collapsed-tab-geometry.json` holds the shared geometry. - * - * Candidates are probed most tab-bar-like first (a tab bar's height, then the lowest top edge) - * until one yields tabs, each with an even share of what is left of the budget but never fewer - * probes than reach every tab. Expansion is all or nothing. The tree keeps its reported shape, and - * `probeFailure` says why, when a candidate's hit-test fails (`rejected`), when it does not answer - * within the timeout (`timed-out`), or when the budget runs out before a candidate with a tab - * bar's height is checked (`budget-exhausted`). A partial tab set would look complete. - */ -export async function expandLimrunCollapsedTabBar( - tree: IosTreeNode | IosTreeNode[], - viewport: Rect | null, - hitTest: LimrunIosHitTest, -): Promise<{ tree: IosTreeNode[]; probeFailure?: IosCollapsedTabBarProbeFailure }> { - const roots = Array.isArray(tree) ? tree : [tree]; - if (!viewport) return { tree: roots }; - const bars = collapsedTabBars(roots, viewport).sort(byTabBarLikeness); - let remaining = MAX_TAB_PROBES; - for (const [index, bar] of bars.entries()) { - const points = tabProbePoints(bar.frame, remaining, bars.length - index); - if (!points) { - const missed = bars.slice(index).some(({ frame }) => hasTabBarHeight(frame)); - return missed ? { tree: roots, probeFailure: 'budget-exhausted' } : { tree: roots }; - } - remaining -= points.length; - const outcome = await probe(points, hitTest); - if ('failure' in outcome) return { tree: roots, probeFailure: outcome.failure }; - const tabs = collapsedTabBarTabs(bar.frame, outcome.hits); - if (tabs.length > 0) { - return { tree: roots.map((root) => withTabs(root, bar.node, tabs)) }; - } - } - return { tree: roots }; -} - -function withTabs(node: IosTreeNode, bar: IosTreeNode, tabs: IosTreeNode[]): IosTreeNode { - if (node === bar) return { ...node, children: tabs }; - const children = readIosNodeChildren(node); - return children.length === 0 - ? node - : { ...node, children: children.map((child) => withTabs(child, bar, tabs)) }; -} - -function collapsedTabBars(roots: IosTreeNode[], viewport: Rect) { - const bars: { node: IosTreeNode; frame: Rect }[] = []; - const visit = (node: IosTreeNode) => { - const children = readIosNodeChildren(node); - const frame = readIosNodeRect(node); - if (children.length === 0 && frame && isCollapsedTabBar(node, frame, viewport)) { - bars.push({ node, frame }); - } - children.forEach(visit); - }; - roots.forEach(visit); - return bars; -} - -function hasTabBarHeight(frame: Rect): boolean { - return frame.height >= TAB_BAR_HEIGHT.min && frame.height <= TAB_BAR_HEIGHT.max; -} - -function byTabBarLikeness(left: { frame: Rect }, right: { frame: Rect }): number { - const rank = (frame: Rect) => (hasTabBarHeight(frame) ? 0 : 1); - return rank(left.frame) - rank(right.frame) || right.frame.y - left.frame.y; -} - -/** The runner's wide childless container, in the shape Limrun reports an iPhone tab bar. */ -function isCollapsedTabBar(node: IosTreeNode, frame: Rect, viewport: Rect): boolean { - if ((node.elementType ?? node.type) !== 'Group') return false; - if (frame.width <= 0 || frame.height <= 0) return false; - if (frame.width < Math.max(160, frame.height * 1.75)) return false; - return ( - Math.abs(frame.x - viewport.x) <= 1 && - Math.abs(frame.x + frame.width - (viewport.x + viewport.width)) <= 1 && - Math.abs(frame.y + frame.height - (viewport.y + viewport.height)) <= 1 - ); -} - -/** - * Points across the bar's middle: this candidate's even share of the probes left, raised to the - * count that reaches every tab, or undefined when the probes left cannot. - */ -function tabProbePoints( - frame: Rect, - remaining: number, - candidatesLeft: number, -): Point[] | undefined { - const needed = Math.ceil(frame.width / MAX_TAB_PROBE_SPACING); - if (remaining < needed) return undefined; - const share = Math.floor(remaining / candidatesLeft); - const preferred = Math.ceil(frame.width / TAB_PROBE_SPACING); - const count = Math.min(remaining, Math.max(needed, Math.min(preferred, share))); - const y = frame.y + frame.height / 2; - return Array.from({ length: count }, (_, index) => ({ - x: frame.x + (frame.width * (index + 0.5)) / count, - y, - })); -} - -async function probe( - points: Point[], - hitTest: LimrunIosHitTest, -): Promise<{ hits: IosTreeNode[] } | { failure: IosCollapsedTabBarProbeFailure }> { - let timer: ReturnType | undefined; - const timedOut = new Promise<{ failure: IosCollapsedTabBarProbeFailure }>((resolve) => { - timer = setTimeout(() => resolve({ failure: 'timed-out' }), TAB_PROBE_TIMEOUT_MS); - }); - const answered = Promise.all(points.map(hitTest)).then( - (hits) => ({ hits: hits.flatMap(flattenHit) }), - () => ({ failure: 'rejected' as const }), - ); - try { - return await Promise.race([answered, timedOut]); - } finally { - clearTimeout(timer); - } -} - -function flattenHit(hit: IosTreeNode | IosTreeNode[]): IosTreeNode[] { - const nodes: IosTreeNode[] = []; - const visit = (node: IosTreeNode) => { - nodes.push(node); - readIosNodeChildren(node).forEach(visit); - }; - (Array.isArray(hit) ? hit : [hit]).forEach(visit); - return nodes; -} - -/** `collapsedTabFallbackNodes`: at least two distinct tabs inside the bar, in one row. */ -function collapsedTabBarTabs(barFrame: Rect, hits: IosTreeNode[]): IosTreeNode[] { - const seen = new Set(); - const tabs: { node: IosTreeNode; frame: Rect }[] = []; - for (const node of hits) { - const frame = readIosNodeRect(node); - if (!frame || !isTabButton(node) || !isTabInside(frame, barFrame)) continue; - const key = JSON.stringify([ - node.elementType ?? node.type, - node.label ?? node.AXLabel, - node.identifier ?? node.AXUniqueId, - node.value ?? node.AXValue, - frame, - ]); - if (seen.has(key)) continue; - seen.add(key); - tabs.push({ node: { ...node, children: [] }, frame }); - } - if (tabs.length < 2) return []; - const midpoints = tabs.map(({ frame }) => frame.y + frame.height / 2); - const rowSpread = Math.max(...midpoints) - Math.min(...midpoints); - if (rowSpread > Math.max(24, barFrame.height * 0.6)) return []; - return tabs - .sort((left, right) => left.frame.x - right.frame.x || left.frame.y - right.frame.y) - .map(({ node }) => node); -} - -function isTabButton(node: IosTreeNode): boolean { - if (node.traits?.includes('TabButton') !== true) return false; - return [ - node.label, - node.AXLabel, - node.identifier, - node.AXUniqueId, - node.value, - node.AXValue, - ].some((text) => text !== undefined && text !== null && String(text).trim() !== ''); -} - -/** `isCollapsedTabCandidate`: smaller than the bar and centered inside it. */ -function isTabInside(frame: Rect, barFrame: Rect): boolean { - if (frame.width <= 0 || frame.height <= 0) return false; - const centerX = frame.x + frame.width / 2; - const centerY = frame.y + frame.height / 2; - return ( - frame.width * frame.height < barFrame.width * barFrame.height * 0.9 && - centerX >= barFrame.x && - centerX <= barFrame.x + barFrame.width && - centerY >= barFrame.y && - centerY <= barFrame.y + barFrame.height - ); -} diff --git a/packages/provider-limrun/src/ios-interactor-alert.test.ts b/packages/provider-limrun/src/ios-interactor-alert.test.ts deleted file mode 100644 index 752e756813..0000000000 --- a/packages/provider-limrun/src/ios-interactor-alert.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { ALERT_BUTTON_UNDECIDED_REASON } from '@agent-device/contracts/alert-contract'; -import { AppError } from '@agent-device/kernel/errors'; -import { - limrunAlertTreeWithoutButtons, - limrunAppAlertDialogTree, - limrunAppScreenTree, - limrunAttAlertTree, -} from './alert-detection.fixtures.ts'; -import { createLimrunIosInteractor, type LimrunIosSession } from './ios.ts'; -import type { IosTreeNode } from '@agent-device/capture-kit/ios-ax-tree'; - -// The legs load `./alert.ts` per call (ADR-0019 laziness inside the provider), and that module -// carries the shared tree-alert driver from `@agent-device/capture-kit/alert-tree-driver`. Warm -// it at collection, like `platform-android`'s own alert test does, so the first timed leg -// measures the legs, not the module graph's first transform. -await import('./alert.ts'); - -/** - * The four legs as the interactor exposes them: observation is one `elementTree()` read per - * probe, action is a `tap()` at the chosen button's center. Trees are the live Limrun captures. - * These cases resolve on the first probe each; budget loops are pinned with driven clocks in - * `alert.test.ts`, so nothing here waits production time. - */ -function sessionWithTrees(trees: Array) { - const elementTree = vi.fn(async () => { - const next = trees.length > 1 ? trees.shift() : trees[0]; - return [next]; - }); - const client = { elementTree, tap: vi.fn(async () => {}) }; - const session = { - platform: 'ios', - instanceId: 'limrun-alert-instance', - client, - } as unknown as LimrunIosSession; - return { interactor: createLimrunIosInteractor(session), client }; -} - -describe('readAlert', () => { - it('reports the live ATT alert with its buttons', async () => { - const { interactor } = sessionWithTrees([limrunAttAlertTree()]); - - const result = await interactor.readAlert!(); - - expect(result.kind).toBe('alertStatus'); - expect(result.status).toBe('visible'); - expect(result.alert).toMatchObject({ - platform: 'ios', - source: 'permission', - buttons: ['Ask App Not to Track', 'Allow'], - }); - }); - - it('reports no alert for the live app-owned dialog rather than claiming it', async () => { - const { interactor } = sessionWithTrees([limrunAppAlertDialogTree()]); - - const result = await interactor.readAlert!(); - - expect(result.kind).toBe('alertStatus'); - expect(result.alert).toBeNull(); - expect(result.status).toBe('none'); - }); -}); - -describe('awaitAlert', () => { - it('returns as soon as a capture shows the alert', async () => { - // The fixture serves the same alert on every read, so the read count is the assertion that - // proves this leg returns on the FIRST capture rather than polling past it. - const { interactor, client } = sessionWithTrees([limrunAttAlertTree(), limrunAttAlertTree()]); - - const result = await interactor.awaitAlert!({ timeoutMs: 5_000 }); - - expect(result.kind).toBe('alertWait'); - expect(result.alert).toMatchObject({ buttons: ['Ask App Not to Track', 'Allow'] }); - expect(client.elementTree).toHaveBeenCalledTimes(1); - }); -}); - -describe('acceptAlert', () => { - it('taps the affirmative choice and reports it, then re-reads to confirm', async () => { - const { interactor, client } = sessionWithTrees([ - limrunAttAlertTree(), - limrunAppScreenTree(), - limrunAppScreenTree(), - ]); - - const result = await interactor.acceptAlert!(); - - expect(result).toMatchObject({ - kind: 'alertHandled', - action: 'accept', - handled: true, - button: 'Allow', - // `centerOfRect` rounds: the live Allow frame centers at (201, 592). - coordinates: { x: 201, y: 592 }, - }); - expect(client.tap).toHaveBeenCalledTimes(1); - expect(client.tap).toHaveBeenCalledWith(201, 592); - }); - - it('refuses an alert whose buttons name no affirmative choice and taps nothing', async () => { - const { interactor, client } = sessionWithTrees([ - { - ...limrunAlertTreeWithoutButtons(), - children: [ - ...(limrunAlertTreeWithoutButtons().children ?? []), - { - type: 'Button', - role: 'AXButton', - AXLabel: 'Maybe Later', - pid: limrunAlertTreeWithoutButtons().pid, - enabled: true, - frame: { x: 57, y: 511.67, width: 288, height: 48 }, - }, - { - type: 'Button', - role: 'AXButton', - AXLabel: 'More Info', - pid: limrunAlertTreeWithoutButtons().pid, - enabled: true, - frame: { x: 57, y: 567.67, width: 288, height: 48 }, - }, - ], - }, - ]); - - const error = await interactor.acceptAlert!().then( - () => undefined, - (error: unknown) => error, - ); - expect(error).toBeInstanceOf(AppError); - const refusal = error as AppError; - expect(refusal.code).toBe('COMMAND_FAILED'); - expect(refusal.details?.reason).toBe(ALERT_BUTTON_UNDECIDED_REASON); - expect(refusal.details?.dispatched).toBe('no'); - expect(client.tap).not.toHaveBeenCalled(); - }); -}); - -describe('dismissAlert', () => { - it('taps the denying choice, never the affirmative one', async () => { - const { interactor, client } = sessionWithTrees([ - limrunAttAlertTree(), - limrunAppScreenTree(), - limrunAppScreenTree(), - ]); - - const result = await interactor.dismissAlert!(); - - expect(result).toMatchObject({ - kind: 'alertHandled', - action: 'dismiss', - button: 'Ask App Not to Track', - }); - // Exactly one tap, at the denying button: proving the affirmative one was never pressed. - expect(client.tap).toHaveBeenCalledTimes(1); - expect(client.tap).toHaveBeenCalledWith(201, 536); - }); - - it('accepts a replacement alert as a dismissal of the first', async () => { - const replacement = { - ...limrunAttAlertTree(), - children: (limrunAttAlertTree().children ?? []).map((child, index) => - index >= 2 ? { ...child, AXLabel: index === 2 ? 'No' : 'Yes' } : child, - ), - }; - const { interactor } = sessionWithTrees([limrunAttAlertTree(), replacement, replacement]); - - await expect(interactor.dismissAlert!()).resolves.toMatchObject({ - kind: 'alertHandled', - button: 'Ask App Not to Track', - }); - }); -}); diff --git a/packages/provider-limrun/src/ios-interactor-clipboard.test.ts b/packages/provider-limrun/src/ios-interactor-clipboard.test.ts deleted file mode 100644 index 147d2fcc5b..0000000000 --- a/packages/provider-limrun/src/ios-interactor-clipboard.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { expect, test, vi } from 'vitest'; -import { AppError } from '@agent-device/kernel/errors'; -import { createLimrunIosInteractor, type LimrunIosSession } from './ios.ts'; - -function sessionWithSimctl(result: { code: number; stdout?: string; stderr?: string }) { - const simctl = vi.fn(() => ({ - wait: async () => ({ stdout: '', stderr: '', ...result }), - })); - const session = { - platform: 'ios', - instanceId: 'limrun-clipboard-instance', - client: { simctl }, - } as unknown as LimrunIosSession; - return { interactor: createLimrunIosInteractor(session), simctl }; -} - -test('reads the pasteboard through simctl pbpaste, dropping the trailing newline', async () => { - const { interactor, simctl } = sessionWithSimctl({ code: 0, stdout: 'first\r\nsecond\n' }); - - await expect(interactor.readClipboard!()).resolves.toBe('first\nsecond'); - expect(simctl).toHaveBeenCalledWith(['pbpaste', 'booted'], undefined); -}); - -test('writes the pasteboard by sending the text to simctl pbcopy on stdin', async () => { - const { interactor, simctl } = sessionWithSimctl({ code: 0 }); - - await interactor.writeClipboard!('hello ✓'); - - expect(simctl).toHaveBeenCalledWith(['pbcopy', 'booted'], { stdin: 'hello ✓' }); -}); - -test('a non-zero simctl exit fails with the command, exit code, and stderr', async () => { - const { interactor } = sessionWithSimctl({ code: 1, stderr: 'pbcopy: refused\n' }); - - const failure = await interactor.writeClipboard!('text').catch((error: unknown) => error); - - expect(failure).toBeInstanceOf(AppError); - expect(failure).toMatchObject({ - code: 'COMMAND_FAILED', - details: { command: 'pbcopy', exitCode: 1, stderr: 'pbcopy: refused' }, - }); -}); diff --git a/packages/provider-limrun/src/ios-interactor-double-tap.test.ts b/packages/provider-limrun/src/ios-interactor-double-tap.test.ts deleted file mode 100644 index cf6dfcb251..0000000000 --- a/packages/provider-limrun/src/ios-interactor-double-tap.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { expect, test, vi } from 'vitest'; -import { createLimrunIosInteractor, type LimrunIosSession } from './ios.ts'; - -function sessionWithClient() { - const client = { - tap: vi.fn(async () => {}), - performActions: vi.fn(async () => ({ results: [] })), - }; - const session = { - platform: 'ios', - instanceId: 'limrun-double-tap-instance', - client, - } as unknown as LimrunIosSession; - return { interactor: createLimrunIosInteractor(session), client }; -} - -test('double tap sends both taps in one batch with the pause enforced on the device', async () => { - const { interactor, client } = sessionWithClient(); - - await interactor.doubleTap!(120, 340); - - expect(client.performActions).toHaveBeenCalledTimes(1); - expect(client.performActions).toHaveBeenCalledWith([ - { type: 'tap', x: 120, y: 340 }, - { type: 'wait', durationMs: 80 }, - { type: 'tap', x: 120, y: 340 }, - ]); - expect(client.tap).not.toHaveBeenCalled(); -}); - -test('a batch the device rejects surfaces as the double tap failure', async () => { - const { interactor, client } = sessionWithClient(); - client.performActions.mockRejectedValueOnce(new Error('tap: no foreground application')); - - await expect(interactor.doubleTap!(120, 340)).rejects.toThrow('tap: no foreground application'); -}); diff --git a/packages/provider-limrun/src/ios-interactor-home.test.ts b/packages/provider-limrun/src/ios-interactor-home.test.ts deleted file mode 100644 index 0ca18e0c6d..0000000000 --- a/packages/provider-limrun/src/ios-interactor-home.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { expect, test, vi } from 'vitest'; -import { createLimrunIosInteractor, type LimrunIosSession } from './ios.ts'; - -function sessionWithClient() { - const client = { - pressKey: vi.fn(async () => {}), - performActions: vi.fn(async () => ({ results: [] })), - }; - const session = { - platform: 'ios', - instanceId: 'limrun-home-instance', - client, - } as unknown as LimrunIosSession; - return { interactor: createLimrunIosInteractor(session), client }; -} - -test('home presses and releases the hardware home button in one batch', async () => { - const { interactor, client } = sessionWithClient(); - - await interactor.home!(); - - expect(client.performActions).toHaveBeenCalledTimes(1); - expect(client.performActions).toHaveBeenCalledWith([ - { type: 'buttonDown', button: 'home' }, - { type: 'buttonUp', button: 'home' }, - ]); - expect(client.pressKey).not.toHaveBeenCalled(); -}); - -test('a batch the device rejects surfaces as the home failure', async () => { - const { interactor, client } = sessionWithClient(); - client.performActions.mockRejectedValueOnce(new Error('buttonDown: unsupported button')); - - await expect(interactor.home!()).rejects.toThrow('buttonDown: unsupported button'); -}); diff --git a/packages/provider-limrun/src/ios-interactor-long-press.test.ts b/packages/provider-limrun/src/ios-interactor-long-press.test.ts deleted file mode 100644 index e0032f51a0..0000000000 --- a/packages/provider-limrun/src/ios-interactor-long-press.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { expect, test, vi } from 'vitest'; -import { createLimrunIosInteractor, type LimrunIosSession } from './ios.ts'; - -function sessionWithClient() { - const client = { - tap: vi.fn(async () => {}), - performActions: vi.fn(async () => ({ results: [] })), - }; - const session = { - platform: 'ios', - instanceId: 'limrun-long-press-instance', - client, - } as unknown as LimrunIosSession; - return { interactor: createLimrunIosInteractor(session), client }; -} - -test('long press holds one touch on the device for the requested duration', async () => { - const { interactor, client } = sessionWithClient(); - - await interactor.longPress(120, 340, 1200); - - expect(client.performActions).toHaveBeenCalledTimes(1); - expect(client.performActions).toHaveBeenCalledWith([ - { type: 'touchDown', x: 120, y: 340 }, - { type: 'wait', durationMs: 1200 }, - { type: 'touchUp', x: 120, y: 340 }, - ]); - expect(client.tap).not.toHaveBeenCalled(); -}); - -test('an omitted duration holds for the 800 ms the other interactors default to', async () => { - const { interactor, client } = sessionWithClient(); - - await interactor.longPress(40, 60); - - expect(client.performActions).toHaveBeenCalledWith([ - { type: 'touchDown', x: 40, y: 60 }, - { type: 'wait', durationMs: 800 }, - { type: 'touchUp', x: 40, y: 60 }, - ]); -}); - -test('a batch the device rejects surfaces as the long press failure', async () => { - const { interactor, client } = sessionWithClient(); - client.performActions.mockRejectedValueOnce(new Error('touchDown: no foreground application')); - - await expect(interactor.longPress(120, 340, 500)).rejects.toThrow( - 'touchDown: no foreground application', - ); -}); diff --git a/packages/provider-limrun/src/ios-interactor-open.test.ts b/packages/provider-limrun/src/ios-interactor-open.test.ts deleted file mode 100644 index b30a17864d..0000000000 --- a/packages/provider-limrun/src/ios-interactor-open.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { expect, test, vi } from 'vitest'; -import { AppError } from '@agent-device/kernel/errors'; -import { createLimrunIosInteractor, type LimrunIosSession } from './ios.ts'; - -function sessionWithClient(result = { code: 0, stdout: '', stderr: '' }) { - const wait = async () => result; - const client = { - launchApp: vi.fn(async () => {}), - openUrl: vi.fn(async () => {}), - simctl: vi.fn((_argv: string[]) => ({ wait })), - }; - const session = { - platform: 'ios', - instanceId: 'limrun-open-instance', - client, - dependencies: { ios: { resolveAppAlias: async (app: string) => app } }, - } as unknown as LimrunIosSession; - return { interactor: createLimrunIosInteractor(session), client }; -} - -test('launch arguments relaunch the app through simctl instead of launchApp', async () => { - const { interactor, client } = sessionWithClient(); - - await interactor.open('com.example.app', { launchArgs: ['--flag', 'value'] }); - - expect(client.simctl).toHaveBeenCalledTimes(1); - expect(client.simctl.mock.calls[0]?.[0]).toEqual([ - 'launch', - '--terminate-running-process', - 'booted', - 'com.example.app', - '--flag', - 'value', - ]); - expect(client.launchApp).not.toHaveBeenCalled(); -}); - -test('without launch arguments the app launches through launchApp only', async () => { - const { interactor, client } = sessionWithClient(); - - await interactor.open('com.example.app'); - - expect(client.launchApp).toHaveBeenCalledTimes(1); - expect(client.launchApp).toHaveBeenCalledWith('com.example.app'); - expect(client.simctl).not.toHaveBeenCalled(); -}); - -test('a url open launches with the arguments, then opens the url', async () => { - const { interactor, client } = sessionWithClient(); - const order: string[] = []; - client.simctl.mockImplementation(() => { - order.push('simctl'); - return { wait: async () => ({ code: 0, stdout: '', stderr: '' }) }; - }); - client.openUrl.mockImplementation(async () => { - order.push('openUrl'); - }); - - await interactor.open('com.example.app', { - url: 'myapp://home', - launchArgs: ['--flag', 'value'], - }); - - expect(order).toEqual(['simctl', 'openUrl']); - expect(client.openUrl).toHaveBeenCalledWith('myapp://home'); - expect(client.launchApp).not.toHaveBeenCalled(); -}); - -test('a failing simctl launch rejects with typed exit code and stderr', async () => { - const { interactor, client } = sessionWithClient({ - code: 1, - stdout: '', - stderr: 'launch failed\n', - }); - - const rejection = expect(interactor.open('com.example.app', { launchArgs: ['--flag'] })).rejects; - await rejection.toBeInstanceOf(AppError); - await rejection.toMatchObject({ - code: 'COMMAND_FAILED', - details: { exitCode: 1, stderr: 'launch failed' }, - }); - expect(client.openUrl).not.toHaveBeenCalled(); -}); diff --git a/packages/provider-limrun/src/ios-interactor-screenshot.test.ts b/packages/provider-limrun/src/ios-interactor-screenshot.test.ts deleted file mode 100644 index 5f9fb9db0f..0000000000 --- a/packages/provider-limrun/src/ios-interactor-screenshot.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { expect, test, vi } from 'vitest'; -import { readPngSize } from '@agent-device/capture-kit/png-size'; -import { createLimrunIosInteractor, type LimrunIosSession } from './ios.ts'; -import { mkdtempForTestSync } from './tmp-dir.fixtures.ts'; - -/** An 8x6 mid-gray JPEG at quality 90: the container Limrun serves its captures in. */ -const JPEG_8X6_BASE64 = [ - '/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4R', - 'DgsLEBYQERMUFRUVDA8XGBYUGBIUFRQBAwQEBQQFCQUFCRQNCw0UFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQU', - 'FBQUFBQUFBQUFBQUFBQUFBQUFBQUFP/AABEIAAYACAMBEQACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQID', - 'BAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYX', - 'GBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqi', - 'o6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEB', - 'AQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS', - '8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeI', - 'iYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/', - '2gAMAwEAAhEDEQA/ACgD/9k=', -].join(''); - -function sessionWithScreenshot(base64: string) { - const client = { - screenshot: vi.fn(async () => ({ base64, width: 402, height: 874 })), - }; - const session = { - platform: 'ios', - instanceId: 'limrun-screenshot-instance', - client, - } as unknown as LimrunIosSession; - return { interactor: createLimrunIosInteractor(session), client }; -} - -test('the JPEG Limrun serves is written to the PNG path as a PNG of the same size', async () => { - const outPath = path.join(mkdtempForTestSync('agent-device-limrun-screenshot-'), 'shot.png'); - const { interactor } = sessionWithScreenshot(JPEG_8X6_BASE64); - - await interactor.screenshot(outPath); - - const bytes = fs.readFileSync(outPath); - expect(bytes.subarray(0, 8)).toEqual( - Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), - ); - await expect(readPngSize(outPath)).resolves.toEqual({ width: 8, height: 6 }); -}); - -test('a capture that is neither PNG nor JPEG is refused instead of written', async () => { - const outPath = path.join(mkdtempForTestSync('agent-device-limrun-screenshot-'), 'shot.png'); - const { interactor } = sessionWithScreenshot(Buffer.from('not an image').toString('base64')); - - await expect(interactor.screenshot(outPath)).rejects.toMatchObject({ - code: 'COMMAND_FAILED', - message: 'Limrun iOS screenshot is neither PNG nor JPEG', - }); - expect(fs.existsSync(outPath)).toBe(false); -}); - -test('a JPEG header over a body that does not decode is refused with the typed decode error', async () => { - const outPath = path.join(mkdtempForTestSync('agent-device-limrun-screenshot-'), 'shot.png'); - const garbage = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(32, 0x41)]); - const { interactor } = sessionWithScreenshot(garbage.toString('base64')); - - await expect(interactor.screenshot(outPath)).rejects.toMatchObject({ - code: 'COMMAND_FAILED', - message: 'Failed to decode Limrun iOS screenshot as JPEG', - }); - expect(fs.existsSync(outPath)).toBe(false); -}); diff --git a/packages/provider-limrun/src/ios-interactor-snapshot.test.ts b/packages/provider-limrun/src/ios-interactor-snapshot.test.ts index 3ffa59e845..1318b12921 100644 --- a/packages/provider-limrun/src/ios-interactor-snapshot.test.ts +++ b/packages/provider-limrun/src/ios-interactor-snapshot.test.ts @@ -1,23 +1,66 @@ -import { expect, test } from 'vitest'; +import { expect, test, vi } from 'vitest'; import { createLimrunIosInteractor, type LimrunIosSession } from './ios.ts'; +import { createTestIosRunner, iosRunnerDevice } from './ios-runner.fixtures.ts'; -test('limrun iOS snapshot stamps the xctest channel with its own producer', async () => { +test('an opened app remains the shared runner target across requests and home does not change it', async () => { + const commands: Record[] = []; + const runner = await createTestIosRunner((command) => { + commands.push(command); + return { + nodes: [ + { + index: 0, + type: 'Application', + label: 'Settings', + rect: { x: 0, y: 0, width: 390, height: 844 }, + }, + ], + }; + }); + const createInteractor = runner.createInteractor; + runner.createInteractor = (context) => ({ + ...createInteractor(context), + open: async () => {}, + close: async () => {}, + }); + vi.spyOn(runner.applicationTools, 'resolveOpenTarget').mockImplementation( + async (_device, input) => ({ + appBundleId: input.target === 'settings' ? 'com.apple.Preferences' : input.target, + }), + ); + const elementTree = vi.fn(() => { + throw new Error('AX capture must not run'); + }); const session = { - platform: 'ios', - client: { - elementTree: async () => ({ - elementType: 'Application', - frame: { x: 0, y: 0, width: 320, height: 240 }, - children: [{ elementType: 'Button', label: 'Continue', enabled: true }], - }), - deviceInfo: { screenWidth: 320, screenHeight: 240 }, - }, + device: iosRunnerDevice, + runner, + client: { elementTree, launchApp: async () => {} }, + dependencies: { ios: { resolveAppAlias: async () => 'com.apple.Preferences' } }, } as unknown as LimrunIosSession; - - const result = await createLimrunIosInteractor(session).snapshot(); - - if (!('stage' in result)) throw new Error('Limrun iOS snapshot must carry acquired facts'); - expect(result.stage).toBe('acquired'); - expect(result.acquisition.producer).toBe('limrun-ios-tree'); - expect(result.acquisition.nodes.map((node) => node.label)).toEqual([undefined, 'Continue']); + const interactor = createLimrunIosInteractor(session); + await interactor.open('settings'); + await interactor.snapshot({ depth: 3, interactiveOnly: true }); + expect(commands.at(-1)).toMatchObject({ + command: 'snapshot', + appBundleId: 'com.apple.Preferences', + depth: 3, + interactiveOnly: true, + }); + await interactor.home!(); + expect(commands.at(-1)).toMatchObject({ command: 'home', appBundleId: 'com.apple.Preferences' }); + await createLimrunIosInteractor(session).snapshot(); + expect(commands.at(-1)).toMatchObject({ + command: 'snapshot', + appBundleId: 'com.apple.Preferences', + }); + await createLimrunIosInteractor(session).snapshot({ appBundleId: 'com.example.other' }); + expect(commands.at(-1)?.appBundleId).toBe('com.example.other'); + await interactor.close('com.example.other'); + await createLimrunIosInteractor(session).snapshot(); + expect(commands.at(-1)?.appBundleId).toBe('com.apple.Preferences'); + await interactor.close('settings'); + await expect(createLimrunIosInteractor(session).snapshot()).rejects.toMatchObject({ + code: 'INVALID_ARGS', + }); + expect(elementTree).not.toHaveBeenCalled(); }); diff --git a/packages/provider-limrun/src/ios-interactor-text-entry.test.ts b/packages/provider-limrun/src/ios-interactor-text-entry.test.ts deleted file mode 100644 index 31a802accd..0000000000 --- a/packages/provider-limrun/src/ios-interactor-text-entry.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { afterEach, expect, test, vi } from 'vitest'; -import { createLimrunIosInteractor, type LimrunIosSession } from './ios.ts'; -import type { IosTreeNode } from '@agent-device/capture-kit/ios-ax-tree'; - -const FIELD_RECT = Object.freeze({ x: 24, y: 142, width: 354, height: 56 }); - -function emailField(editing: boolean): IosTreeNode { - return { - type: 'TextField', - AXLabel: 'Email', - pid: 4242, - frame: FIELD_RECT, - traits: editing - ? ['TextEntry', 'TextOperationsAvailable', 'IsEditing'] - : ['TextEntry', 'TextOperationsAvailable'], - }; -} - -afterEach(() => { - vi.restoreAllMocks(); -}); - -function sessionWithTrees(trees: IosTreeNode[]) { - const calls: string[] = []; - let reads = 0; - const client = { - elementTree: vi.fn(async () => { - calls.push('elementTree'); - const tree = trees[Math.min(reads, trees.length - 1)]; - reads += 1; - return tree; - }), - tap: vi.fn(async () => { - calls.push('tap'); - }), - pressKey: vi.fn(async (key: string, modifiers?: string[]) => { - calls.push(`pressKey:${key}${modifiers?.length ? `+${modifiers.join('+')}` : ''}`); - }), - typeText: vi.fn(async () => { - calls.push('typeText'); - }), - setElementValue: vi.fn(async () => { - calls.push('setElementValue'); - }), - }; - const session = { - platform: 'ios', - instanceId: 'limrun-text-entry-instance', - client, - } as unknown as LimrunIosSession; - return { interactor: createLimrunIosInteractor(session), client, calls }; -} - -test('type enters the focused field without the provider focus scan', async () => { - const { interactor, client } = sessionWithTrees([]); - - await interactor.type('qa@example.com'); - - expect(client.typeText).toHaveBeenCalledWith('qa@example.com', false, { requireFocus: false }); -}); - -test('a delayed type sends one focused-blind key press per character', async () => { - const { interactor, client } = sessionWithTrees([]); - - await interactor.type('abc', 1); - - expect(client.typeText.mock.calls).toEqual([ - ['a', false, { requireFocus: false }], - ['b', false, { requireFocus: false }], - ['c', false, { requireFocus: false }], - ]); -}); - -test('fill waits for text-entry focus to arrive and only then sets the value', async () => { - const { interactor, client, calls } = sessionWithTrees([ - emailField(false), - emailField(false), - emailField(true), - ]); - - const result = await interactor.fill(200, 170, 'qa@example.com'); - - expect(calls).toEqual(['elementTree', 'tap', 'elementTree', 'elementTree', 'setElementValue']); - expect(result).toEqual({ textEntryReadiness: 'focused-element' }); - expect(client.setElementValue.mock.calls).toEqual([['qa@example.com']]); - expect(client.pressKey).not.toHaveBeenCalled(); - expect(client.typeText).not.toHaveBeenCalled(); -}); - -test.each([ - ['你好', undefined], - ['qa@例子.com', undefined], - ['👋', undefined], - ['replacement', undefined], - ['', undefined], - ['你好a', 1], - ['', 1], -])('fill writes %s once with delay %s', async (text, delayMs) => { - const { interactor, client } = sessionWithTrees([emailField(false), emailField(true)]); - - const result = await interactor.fill(200, 170, text, delayMs); - - expect(result).toEqual({ textEntryReadiness: 'focused-element' }); - expect(client.setElementValue.mock.calls).toEqual([[text]]); - expect(client.pressKey).not.toHaveBeenCalled(); - expect(client.typeText).not.toHaveBeenCalled(); -}); - -test('a session that cannot answer the focus read fails before the device is touched', async () => { - const { interactor, client, calls } = sessionWithTrees([emailField(false)]); - client.elementTree.mockImplementationOnce(async () => { - calls.push('elementTree'); - throw new Error('instance connection lost'); - }); - - await expect(interactor.fill(200, 170, 'qa@example.com')).rejects.toThrow( - 'instance connection lost', - ); - expect(calls).toEqual(['elementTree']); - expect(client.tap).not.toHaveBeenCalled(); - expect(client.setElementValue).not.toHaveBeenCalled(); - expect(client.pressKey).not.toHaveBeenCalled(); - expect(client.typeText).not.toHaveBeenCalled(); -}); - -test('fill writes no value when text-entry focus never arrives', async () => { - const { interactor, client } = sessionWithTrees([emailField(false)]); - // The focus budget is wall-clock time. Step the clock by that budget on every - // read so the first miss expires it, and the refusal is not a three-second wait. - let now = 0; - vi.spyOn(Date, 'now').mockImplementation(() => { - const current = now; - now += 3_000; - return current; - }); - - await expect(interactor.fill(200, 170, '你好')).rejects.toMatchObject({ - code: 'COMMAND_FAILED', - details: { reason: 'text_entry_focus_not_observed' }, - }); - expect(client.tap).toHaveBeenCalledTimes(1); - expect(client.setElementValue).not.toHaveBeenCalled(); - expect(client.pressKey).not.toHaveBeenCalled(); - expect(client.typeText).not.toHaveBeenCalled(); -}); - -test('a rejected value write fails the fill with that failure', async () => { - const { interactor, client } = sessionWithTrees([emailField(false), emailField(true)]); - const failure = new Error('element value refused'); - client.setElementValue.mockRejectedValueOnce(failure); - - await expect(interactor.fill(200, 170, '你好')).rejects.toMatchObject({ - code: 'COMMAND_FAILED', - details: { reason: 'text_entry_value_write_failed', dispatched: 'unknown' }, - cause: failure, - }); - expect(client.setElementValue.mock.calls).toEqual([['你好']]); - expect(client.pressKey).not.toHaveBeenCalled(); - expect(client.typeText).not.toHaveBeenCalled(); -}); diff --git a/packages/provider-limrun/src/ios-runner-lifecycle.test.ts b/packages/provider-limrun/src/ios-runner-lifecycle.test.ts new file mode 100644 index 0000000000..692886e2d2 --- /dev/null +++ b/packages/provider-limrun/src/ios-runner-lifecycle.test.ts @@ -0,0 +1,123 @@ +import { expect, test, vi } from 'vitest'; +import type { DeviceLease } from '@agent-device/contracts/device'; +import { createLimrunRuntime } from './runtime.ts'; +import type { LimrunRuntimeDependencies } from './runtime-dependencies.ts'; +import { stubLimrunRuntimeDependencies } from './runtime-dependencies.fixtures.ts'; +import { testIosRunner } from './ios-runner.fixtures.ts'; + +const clients = vi.hoisted(() => ({ disconnect: vi.fn(), deleteInstance: vi.fn(async () => {}) })); +vi.mock('@limrun/api', () => ({ + default: class { + iosInstances = { + create: async () => ({ + metadata: { id: 'ios_test' }, + status: { apiUrl: 'https://sim.example/api', token: 'instance-token' }, + }), + delete: clients.deleteInstance, + }; + }, +})); +vi.mock('@limrun/api/ios-client', () => ({ + createInstanceClient: async () => ({ disconnect: clients.disconnect }), +})); + +for (const attached of [false, true]) { + test(`releases an ${attached ? 'attached' : 'owned'} simulator lease when runner creation fails`, async () => { + clients.disconnect.mockClear(); + clients.deleteInstance.mockClear(); + const runtime = createLimrunRuntime( + { + apiKey: 'api-key', + ...(attached + ? { instances: { ios: { apiUrl: 'https://sim.example/api', token: 'instance-token' } } } + : {}), + }, + { + clientVersion: 'test', + android: {}, + host: {}, + ios: { + createRunner: async () => { + throw new Error('runner unavailable'); + }, + }, + } as unknown as LimrunRuntimeDependencies, + ); + const lease: DeviceLease = { + leaseId: 'lease-ios', + tenantId: 'test', + runId: 'run', + backend: 'ios-instance', + leaseProvider: 'limrun', + createdAt: 1, + heartbeatAt: 1, + expiresAt: 60001, + }; + try { + await expect(runtime.leaseLifecycle.allocate!(lease)).rejects.toThrow('runner unavailable'); + expect(clients.disconnect).toHaveBeenCalledTimes(1); + expect(clients.deleteInstance).toHaveBeenCalledTimes(attached ? 0 : 1); + } finally { + await runtime.shutdown(); + } + }); +} + +for (const attached of [false, true]) { + test(`one teardown stops the ${attached ? 'attached' : 'owned'} simulator runner before disconnect`, async () => { + clients.disconnect.mockClear(); + clients.deleteInstance.mockClear(); + const dependencies = stubLimrunRuntimeDependencies(); + let reportStarted!: () => void; + const started = new Promise((resolve) => { + reportStarted = resolve; + }); + let reportStopped!: () => void; + const stopped = new Promise((resolve) => { + reportStopped = resolve; + }); + const dispose = vi.fn(async () => { + reportStarted(); + await stopped; + }); + const runtime = createLimrunRuntime( + { + apiKey: 'api-key', + ...(attached + ? { instances: { ios: { apiUrl: 'https://sim.example/api', token: 'instance-token' } } } + : {}), + }, + { + ...dependencies, + ios: { ...dependencies.ios, createRunner: async () => ({ ...testIosRunner, dispose }) }, + }, + ); + const lease: DeviceLease = { + leaseId: 'lease-ios-dispose', + tenantId: 'test', + runId: 'run', + backend: 'ios-instance', + leaseProvider: 'limrun', + createdAt: 1, + heartbeatAt: 1, + expiresAt: 60001, + }; + try { + await runtime.leaseLifecycle.allocate!(lease); + const release = runtime.leaseLifecycle.release!(lease); + const shutdown = runtime.shutdown(); + await started; + expect(dispose).toHaveBeenCalledTimes(1); + expect(clients.disconnect).not.toHaveBeenCalled(); + if (attached) expect(clients.deleteInstance).not.toHaveBeenCalled(); + else await vi.waitFor(() => expect(clients.deleteInstance).toHaveBeenCalledOnce()); + reportStopped(); + await Promise.all([release, shutdown]); + expect(dispose).toHaveBeenCalledTimes(1); + expect(clients.disconnect).toHaveBeenCalledTimes(1); + } finally { + reportStopped(); + await runtime.shutdown(); + } + }); +} diff --git a/packages/provider-limrun/src/ios-runner.fixtures.ts b/packages/provider-limrun/src/ios-runner.fixtures.ts new file mode 100644 index 0000000000..be83eea0d4 --- /dev/null +++ b/packages/provider-limrun/src/ios-runner.fixtures.ts @@ -0,0 +1,62 @@ +import type { AppleApplicationTools } from '@agent-device/contracts/application-lifecycle-runtime'; +import { createAppleRunnerRuntime } from '@agent-device/platform-apple/runner/operations'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { LimrunIosRunner } from './runtime-dependencies.ts'; + +export const iosRunnerDevice: DeviceInfo = { + platform: 'apple', + appleOs: 'ios', + id: 'limrun:ios:lease-a', + name: 'Limrun iOS', + kind: 'simulator', + target: 'mobile', + booted: true, +}; + +export async function createTestIosRunner( + respond: (command: Record) => Record = () => ({}), + device = iosRunnerDevice, +): Promise { + const runner = await createAppleRunnerRuntime({ + device, + host: { env: {}, cwd: process.cwd() }, + provider: { + hasLiveSession: () => true, + runCommand: async (_device, command) => respond(command), + }, + }); + const applicationTools: AppleApplicationTools = { + resolveOpenTarget: async (_device, input) => ({ + appBundleId: input.target ?? input.currentAppBundleId, + appName: input.target, + }), + prewarmRunnerCache: async () => {}, + prewarmRunnerSession: async () => {}, + notifyRunnerAppRelaunched: async () => {}, + stopRunnerSession: async () => {}, + hasLiveRunnerSession: async () => true, + releaseSpeculativeRunner: async () => false, + releaseRunnerOnClose: async () => {}, + prepareRunner: (_device, input, signal) => runner.prepare(input, signal), + applyRuntimeHints: async () => {}, + clearRuntimeHints: async () => {}, + dismissCloseAlerts: async () => {}, + detachRunnerSessionsForShutdown: async () => [], + finalizeRunnerSessionsForShutdown: async () => [], + }; + return { + ...runner, + applicationTools, + bindApplicationLifecycle: (request) => { + const lifecycle = runner.bindApplicationLifecycle({ + ...request, + appleApplications: applicationTools, + ensureReady: async (target) => target, + }); + return lifecycle; + }, + dispose: async () => {}, + }; +} + +export const testIosRunner = await createTestIosRunner(); diff --git a/packages/provider-limrun/src/ios-settings.test.ts b/packages/provider-limrun/src/ios-settings.test.ts deleted file mode 100644 index b9db8d73f8..0000000000 --- a/packages/provider-limrun/src/ios-settings.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { expect, test, vi } from 'vitest'; -import type { SimctlSettingRequest } from '@agent-device/contracts/settings'; -import type { LimrunIosSession } from './ios.ts'; -import { setLimrunIosSetting } from './ios-settings.ts'; - -function sessionWithClient(results: Array<{ code: number; stdout: string; stderr: string }> = []) { - const queue = [...results]; - const client = { - simctl: vi.fn((_argv: string[]) => ({ - wait: async () => queue.shift() ?? { code: 0, stdout: '', stderr: '' }, - })), - softReset: vi.fn(async (_bundleId: string, _options?: unknown) => ({})), - terminateApp: vi.fn(async (_bundleId: string) => {}), - }; - const applySimctlSetting = vi.fn(async (request: SimctlSettingRequest) => { - const output = await request.runSimctl(['ui', request.udid, 'appearance']); - return { output }; - }); - const session = { - platform: 'ios', - device: { id: 'limrun:ios:settings', platform: 'ios' }, - client, - dependencies: { - ios: { applySimctlSetting, resolveAppAlias: async (app: string) => `resolved.${app}` }, - }, - } as unknown as LimrunIosSession; - return { session, client, applySimctlSetting }; -} - -test('simctl settings run the Apple plan on the booted simulator, reporting the session device', async () => { - const { session, client, applySimctlSetting } = sessionWithClient([ - { code: 0, stdout: 'dark\n', stderr: '' }, - ]); - const options = { permissionTarget: 'photos', permissionMode: 'limited' }; - - const result = await setLimrunIosSetting(session, 'Permission', 'grant', 'camera-app', options); - - expect(applySimctlSetting).toHaveBeenCalledWith({ - runSimctl: expect.any(Function), - udid: 'booted', - deviceId: 'limrun:ios:settings', - setting: 'permission', - state: 'grant', - appBundleId: 'camera-app', - resolveApp: expect.any(Function), - options, - }); - await expect(applySimctlSetting.mock.calls[0]?.[0].resolveApp('camera-app')).resolves.toBe( - 'resolved.camera-app', - ); - expect(client.simctl.mock.calls).toEqual([[['ui', 'booted', 'appearance']]]); - expect(result).toEqual({ output: { code: 0, stdout: 'dark\n', stderr: '' } }); -}); - -test('an appless simctl setting reaches the Apple plan without an app', async () => { - const { session, applySimctlSetting } = sessionWithClient(); - - await setLimrunIosSetting(session, 'appearance', 'toggle', undefined, undefined); - - expect(applySimctlSetting.mock.calls[0]?.[0].appBundleId).toBeUndefined(); -}); - -test('a failing Limrun simctl rejects with its exit code and stderr', async () => { - const { session } = sessionWithClient([{ code: 3, stdout: 'out', stderr: 'denied\n' }]); - - await expect( - setLimrunIosSetting(session, 'appearance', 'dark', undefined, undefined), - ).rejects.toMatchObject({ - code: 'COMMAND_FAILED', - details: { args: ['ui', 'booted', 'appearance'], exitCode: 3, stderr: 'denied\n' }, - }); -}); - -test('clear-app-state soft resets the app data through Limrun, then stops the app', async () => { - const { session, client, applySimctlSetting } = sessionWithClient(); - - const result = await setLimrunIosSetting(session, 'clear-app-state', 'clear', 'app', undefined); - - expect(client.softReset).toHaveBeenCalledWith('resolved.app', { strategy: 'data' }); - expect(client.terminateApp).toHaveBeenCalledWith('resolved.app'); - expect(client.softReset.mock.invocationCallOrder[0]).toBeLessThan( - client.terminateApp.mock.invocationCallOrder[0]!, - ); - expect(applySimctlSetting).not.toHaveBeenCalled(); - expect(result).toEqual({ bundleId: 'resolved.app', cleared: true }); -}); - -test('a plain SDK failure while clearing app state becomes a typed COMMAND_FAILED', async () => { - const reset = sessionWithClient(); - const cause = new Error('HTTP 500'); - reset.client.softReset.mockRejectedValueOnce(cause); - const stop = sessionWithClient(); - stop.client.terminateApp.mockRejectedValueOnce(cause); - - for (const { session } of [reset, stop]) { - await expect( - setLimrunIosSetting(session, 'clear-app-state', 'clear', 'app', undefined), - ).rejects.toMatchObject({ - code: 'COMMAND_FAILED', - message: 'Limrun iOS could not clear app state.', - details: { setting: 'clear-app-state', bundleId: 'resolved.app' }, - cause, - }); - } -}); - -test('clear-app-state refuses an appless session and any state but clear', async () => { - const { session, client } = sessionWithClient(); - - await expect( - setLimrunIosSetting(session, 'clear-app-state', 'clear', undefined, undefined), - ).rejects.toMatchObject({ - code: 'INVALID_ARGS', - message: 'settings clear-app-state requires an app id or an active app session.', - details: { reason: 'session_app_required' }, - }); - await expect( - setLimrunIosSetting(session, 'clear-app-state', 'wipe', 'app', undefined), - ).rejects.toMatchObject({ code: 'INVALID_ARGS' }); - expect(client.softReset).not.toHaveBeenCalled(); -}); - -test('settings Limrun cannot serve are refused with the supported list', async () => { - const { session, client, applySimctlSetting } = sessionWithClient(); - - for (const [setting, state] of [ - ['wifi', 'off'], - ['reset-keychain', 'clear'], - ['text-size', 'large'], - ] as const) { - await expect( - setLimrunIosSetting(session, setting, state, 'app', undefined), - ).rejects.toMatchObject({ - code: 'UNSUPPORTED_OPERATION', - message: expect.stringContaining('appearance, permission, location, and clear-app-state'), - }); - } - expect(applySimctlSetting).not.toHaveBeenCalled(); - expect(client.simctl).not.toHaveBeenCalled(); -}); diff --git a/packages/provider-limrun/src/ios-settings.ts b/packages/provider-limrun/src/ios-settings.ts deleted file mode 100644 index 903ae1e9d5..0000000000 --- a/packages/provider-limrun/src/ios-settings.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { SettingOptions } from '@agent-device/contracts/settings'; -import type { SettingName } from '@agent-device/contracts/settings-catalog'; -import { AppError, sessionAppRequiredDetails } from '@agent-device/kernel/errors'; -import type { LimrunIosSession } from './ios.ts'; - -const LIMRUN_IOS_SETTINGS = [ - 'appearance', - 'permission', - 'location', - 'clear-app-state', -] as const satisfies readonly SettingName[]; - -export type LimrunIosSetting = (typeof LIMRUN_IOS_SETTINGS)[number]; - -/** - * Changes one setting on a Limrun iOS instance. `appearance`, `permission`, and `location` run the - * Apple simctl plan on the instance's one simulator; `clear-app-state` uses Limrun's data reset, - * because the local route edits the data container on the host. - */ -export async function setLimrunIosSetting( - session: LimrunIosSession, - setting: string, - state: string, - appId: string | undefined, - options: SettingOptions | undefined, -): Promise | void> { - const normalized = LIMRUN_IOS_SETTINGS.find((name) => name === setting.toLowerCase()); - if (normalized === undefined) { - throw new AppError( - 'UNSUPPORTED_OPERATION', - `Limrun iOS direct sessions support appearance, permission, location, and clear-app-state settings, not ${setting}.`, - { command: 'settings' }, - ); - } - switch (normalized) { - case 'appearance': - case 'permission': - case 'location': - return await session.dependencies.ios.applySimctlSetting({ - runSimctl: (args) => runLimrunSimctl(session, args), - udid: 'booted', - deviceId: session.device.id, - setting: normalized, - state, - appBundleId: appId, - resolveApp: (app) => session.dependencies.ios.resolveAppAlias(app), - options, - }); - case 'clear-app-state': - return await clearAppState(session, state, appId); - default: - return normalized satisfies never; - } -} - -async function runLimrunSimctl( - session: LimrunIosSession, - args: string[], -): Promise<{ stdout: string; stderr: string }> { - const result = await session.client.simctl(args).wait(); - if (result.code !== 0) { - throw new AppError('COMMAND_FAILED', `Limrun iOS simctl exited with code ${result.code}`, { - args, - exitCode: result.code, - stdout: result.stdout, - stderr: result.stderr, - }); - } - return result; -} - -async function clearAppState( - session: LimrunIosSession, - state: string, - appId: string | undefined, -): Promise> { - if (state.toLowerCase() !== 'clear') { - throw new AppError('INVALID_ARGS', 'settings clear-app-state only supports clear.'); - } - if (!appId) { - throw new AppError( - 'INVALID_ARGS', - 'settings clear-app-state requires an app id or an active app session.', - sessionAppRequiredDetails(), - ); - } - const bundleId = await session.dependencies.ios.resolveAppAlias(appId); - try { - await session.client.softReset(bundleId, { strategy: 'data' }); - // Limrun's reset relaunches the app. A local clear leaves it stopped, so stop it here too. - await session.client.terminateApp(bundleId); - } catch (error) { - if (error instanceof AppError) throw error; - throw new AppError( - 'COMMAND_FAILED', - 'Limrun iOS could not clear app state.', - { setting: 'clear-app-state', bundleId }, - error, - ); - } - return { bundleId, cleared: true }; -} diff --git a/packages/provider-limrun/src/ios-snapshot-adapter.fixtures.ts b/packages/provider-limrun/src/ios-snapshot-adapter.fixtures.ts deleted file mode 100644 index 23a50392b6..0000000000 --- a/packages/provider-limrun/src/ios-snapshot-adapter.fixtures.ts +++ /dev/null @@ -1,193 +0,0 @@ -import type { LimrunIosSession } from './ios.ts'; -import type { IosTreeNode } from '@agent-device/capture-kit/ios-ax-tree'; - -export const LIMRUN_SNAPSHOT_SCREEN: Readonly<{ width: number; height: number }> = Object.freeze({ - width: 320, - height: 240, -}); - -export function limrunSnapshotTree(): IosTreeNode { - return { - elementType: 'Application', - label: 'App', - frame: { - x: 0, - y: 0, - width: LIMRUN_SNAPSHOT_SCREEN.width, - height: LIMRUN_SNAPSHOT_SCREEN.height, - }, - children: [ - { - elementType: 'Table', - label: 'Settings', - frame: { - x: 0, - y: 0, - width: LIMRUN_SNAPSHOT_SCREEN.width, - height: LIMRUN_SNAPSHOT_SCREEN.height, - }, - children: [ - { - elementType: 'Cell', - label: 'Target', - frame: { x: 16, y: 40, width: 288, height: 52 }, - children: [ - { - elementType: 'Button', - label: 'Save', - frame: { x: 32, y: 48, width: 100, height: 36 }, - enabled: true, - hittable: true, - }, - { - elementType: 'StaticText', - label: 'Save', - frame: { x: 32, y: 48, width: 100, height: 36 }, - }, - ], - }, - ], - }, - ], - }; -} - -export function createLimrunSnapshotSession( - tree: IosTreeNode | IosTreeNode[] = limrunSnapshotTree(), - screen = LIMRUN_SNAPSHOT_SCREEN, -): Pick { - return { - instanceId: 'limrun-snapshot-test-instance', - client: { - elementTree: async () => tree, - deviceInfo: { screenWidth: screen.width, screenHeight: screen.height }, - }, - } as unknown as Pick; -} - -type Point = { x: number; y: number }; -type Screen = { width: number; height: number }; -type Frame = NonNullable; - -export const IPHONE_SCREEN: Readonly = Object.freeze({ width: 402, height: 874 }); -export const IPHONE_TAB_BAR_FRAME: Readonly = Object.freeze({ - x: 0, - y: 791, - width: 402, - height: 83, -}); - -/** An app screen whose root holds `children` above one line of text. */ -export function limrunScreenTree( - children: IosTreeNode[], - screen: Screen = IPHONE_SCREEN, -): IosTreeNode[] { - return [ - { - type: 'Application', - AXLabel: 'Agent Device Tester', - frame: { x: 0, y: 0, ...screen }, - children: [ - { - type: 'StaticText', - AXLabel: 'Welcome', - frame: { x: 16, y: 120, width: 300, height: 30 }, - }, - ...children, - ], - }, - ]; -} - -/** A childless `Group`, the way a live Limrun iPhone reports the iOS 26 tab bar. */ -export function limrunEmptyGroup(frame: Frame, label?: string): IosTreeNode { - return { - type: 'Group', - ...(label ? { AXLabel: label } : {}), - role: 'AXGroup', - traits: ['Scrollable'], - frame, - }; -} - -export function limrunCollapsedTabBarTree(barFrame: Frame = IPHONE_TAB_BAR_FRAME): IosTreeNode[] { - return limrunScreenTree([limrunEmptyGroup(barFrame, 'Tab Bar')]); -} - -/** - * The five tab buttons a live Limrun iPhone 16 Pro (iOS 26.4) returned from `elementTree(point)` - * inside the test app's tab bar, with Home selected. - */ -export const LIMRUN_TAB_BUTTONS: readonly IosTreeNode[] = [ - limrunTabButton('Home', { x: 25, y: 795, width: 74, height: 54 }, true), - limrunTabButton('Catalog', { - x: 93.41666666666666, - y: 795, - width: 74.33333333333334, - height: 54, - }), - limrunTabButton('Form', { x: 162.16666666666666, y: 795, width: 74, height: 54 }), - limrunTabButton('Audio', { x: 230.58333333333331, y: 795, width: 74, height: 54 }), - limrunTabButton('Settings', { x: 299, y: 795, width: 78, height: 54 }), -]; - -export function limrunTabButton(label: string, frame: Frame, selected = false): IosTreeNode { - return { - type: 'RadioButton', - role: 'AXRadioButton', - subrole: 'AXTabButton', - role_description: 'tab', - AXLabel: label, - AXValue: selected ? 1 : 0, - traits: selected ? ['Selected', 'Button', 'TabButton'] : ['Button', 'TabButton'], - enabled: true, - frame, - children: [], - }; -} - -/** - * A Limrun session whose `elementTree()` returns `tree` and whose `elementTree(point)` hit-tests - * `hits`: the first whose frame holds the point, else the deepest tree node that does, as one node. - * `hitTest` replaces that answer for a point, for example with a rejection. - */ -export function createLimrunHitTestSession( - tree: IosTreeNode[], - hits: readonly IosTreeNode[], - options: { - screen?: Screen; - hitTest?: (point: Point, hit: IosTreeNode) => Promise; - } = {}, -) { - const probes: Point[] = []; - const deepestHit = (nodes: readonly IosTreeNode[], point: Point) => { - let hit: IosTreeNode | undefined; - for (const node of nodes) { - if (!frameHolds(node.frame, point)) continue; - hit = deepestHit(node.children ?? [], point) ?? node; - } - return hit; - }; - const session = createLimrunSnapshotSession(tree, options.screen ?? IPHONE_SCREEN); - session.client.elementTree = (async (point?: Point) => { - if (!point) return tree; - probes.push(point); - const found = hits.find((node) => frameHolds(node.frame, point)) ?? deepestHit(tree, point); - const hit = { ...found, children: [] }; - return options.hitTest ? options.hitTest(point, hit) : hit; - }) as unknown as typeof session.client.elementTree; - return { session, probes }; -} - -function frameHolds(frame: IosTreeNode['frame'], point: Point): boolean { - return ( - frame?.x !== undefined && - frame.y !== undefined && - frame.width !== undefined && - frame.height !== undefined && - point.x >= frame.x && - point.x < frame.x + frame.width && - point.y >= frame.y && - point.y < frame.y + frame.height - ); -} diff --git a/packages/provider-limrun/src/ios-snapshot-adapter.test.ts b/packages/provider-limrun/src/ios-snapshot-adapter.test.ts deleted file mode 100644 index 55fa1ddc97..0000000000 --- a/packages/provider-limrun/src/ios-snapshot-adapter.test.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { readFileSync } from 'node:fs'; -import type { IosTreeNode } from '@agent-device/capture-kit/ios-ax-tree'; -import { expect, test, vi } from 'vitest'; -import { captureLimrunIosSnapshot } from './ios-snapshot-adapter.ts'; -import { - LIMRUN_SNAPSHOT_SCREEN, - createLimrunSnapshotSession, - limrunSnapshotTree, -} from './ios-snapshot-adapter.fixtures.ts'; - -test('Limrun adapter returns the tree and shared viewport facts without presenting them', async () => { - const session = createLimrunSnapshotSession(limrunSnapshotTree(), { width: 240, height: 320 }); - const elementTree = vi.fn(session.client.elementTree); - const result = await captureLimrunIosSnapshot({ - ...session, - client: { ...session.client, elementTree }, - }); - - expect(elementTree).toHaveBeenCalledWith(); - expect(result.stage).toBe('acquired'); - expect(result.acquisition.producer).toBe('limrun-ios-tree'); - expect(result.acquisition.nodes.map((node) => node.label)).toEqual([ - 'App', - 'Settings', - 'Target', - 'Save', - 'Save', - ]); - expect(result.acquisition.nodes.find((node) => node.label === 'Save')).toHaveProperty( - 'hittable', - true, - ); - expect(result.acquisition.viewport).toEqual({ - kind: 'derived', - rect: { x: 0, y: 0, width: 320, height: 240 }, - }); - expect(result.acquisition.residue).toEqual([ - { kind: 'unavailable-fact', fact: 'hittability' }, - { kind: 'unavailable-fact', fact: 'acquisition-depth' }, - { kind: 'unavailable-fact', fact: 'truncation' }, - ]); -}); - -test('Limrun adapter preserves provider-reported node facts for host presentation', async () => { - const tree = limrunSnapshotTree(); - tree.children![0]!.children!.push({ - elementType: 'Button', - label: 'Enabled but unverified', - frame: { x: 120, y: 120, width: 80, height: 40 }, - enabled: true, - hittable: true, - }); - - const result = await captureLimrunIosSnapshot(createLimrunSnapshotSession(tree)); - const target = result.acquisition.nodes.find((node) => node.label === 'Enabled but unverified'); - - expect(target).toMatchObject({ enabled: true, rect: { x: 120, y: 120, width: 80, height: 40 } }); - expect(target).toHaveProperty('hittable', true); -}); - -test('Limrun adapter uses valid deviceInfo when the tree has no viewport root frame', async () => { - const tree = limrunSnapshotTree(); - tree.frame = undefined; - - const result = await captureLimrunIosSnapshot( - createLimrunSnapshotSession(tree, { width: 240, height: 320 }), - ); - - expect(result.acquisition.viewport).toEqual({ - kind: 'reported', - rect: { x: 0, y: 0, width: 240, height: 320 }, - }); -}); - -test('Limrun adapter carries invalid viewport evidence to the host presenter', async () => { - const tree = limrunSnapshotTree(); - tree.frame = undefined; - const result = await captureLimrunIosSnapshot( - createLimrunSnapshotSession(tree, { width: 0, height: 240 }), - ); - - expect(result.acquisition.viewport).toEqual({ kind: 'missing', reason: 'invalid' }); - expect(result.acquisition.residue).toContainEqual({ - kind: 'missing-viewport', - reason: 'invalid', - }); -}); - -test('the Limrun acquired result keeps provider provenance and SDK screen dimensions', async () => { - const result = await captureLimrunIosSnapshot(createLimrunSnapshotSession()); - - expect(result.acquisition).toMatchObject({ - producer: 'limrun-ios-tree', - lineage: { targetId: 'limrun-snapshot-test-instance' }, - }); - expect(result.acquisition.nodes[0]?.rect).toEqual({ - x: 0, - y: 0, - width: LIMRUN_SNAPSHOT_SCREEN.width, - height: LIMRUN_SNAPSHOT_SCREEN.height, - }); -}); - -const nativeStates = JSON.parse( - readFileSync( - new URL('../../../contracts/fixtures/native-snapshot-states.json', import.meta.url), - 'utf8', - ), -) as { - cases: { name: string; input: IosTreeNode; expected: Record }[]; -}; -for (const row of nativeStates.cases.filter( - ({ input }) => !['checked', 'password', 'editable'].some((key) => key in input), -)) { - test(`limrun acquires native states: ${row.name}`, async () => { - const result = await captureLimrunIosSnapshot(createLimrunSnapshotSession(row.input)); - const node = result.acquisition.nodes[0]!; - const states = Object.fromEntries( - ['checked', 'password', 'editable'].flatMap((key) => { - const value = node[key as 'checked' | 'password' | 'editable']; - return value === undefined ? [] : [[key, value]]; - }), - ); - expect(states).toEqual(row.expected); - }); -} - -test('Limrun secure TextField subrole survives acquisition without changing the native type', async () => { - const result = await captureLimrunIosSnapshot( - createLimrunSnapshotSession({ - type: 'TextField', - role: 'AXTextField', - subrole: 'AXSecureTextField', - traits: ['TextOperationsAvailable', 'Scrollable', 'TextEntry', 'SecureTextField'], - role_description: 'secure text field', - AXUniqueId: 'native-secure', - AXValue: 'Native input', - enabled: true, - }), - ); - expect(result.acquisition.nodes[0]).toMatchObject({ - type: 'TextField', - identifier: 'native-secure', - password: true, - editable: true, - roleDescription: 'secure text field', - }); -}); - -test('Limrun carries docked tree evidence and never infers absence from missing nodes', async () => { - const tree = limrunSnapshotTree(); - expect( - (await captureLimrunIosSnapshot(createLimrunSnapshotSession(tree))).acquisition.keyboard, - ).toEqual({ kind: 'unmeasurable', reason: 'keyboard-not-observed' }); - tree.children!.push({ - elementType: 'Keyboard', - frame: { x: 0, y: 140, width: 320, height: 100 }, - }); - expect( - (await captureLimrunIosSnapshot(createLimrunSnapshotSession(tree))).acquisition.keyboard, - ).toEqual({ - kind: 'derived', - derivation: 'docked-tree', - frame: { x: 0, y: 140, width: 320, height: 100 }, - treeBound: true, - }); - tree.children!.at(-1)!.frame = { x: 100, y: 60, width: 140, height: 80 }; - expect( - (await captureLimrunIosSnapshot(createLimrunSnapshotSession(tree))).acquisition.keyboard, - ).toEqual({ kind: 'tree-rejected', derivation: 'docked-tree' }); -}); - -test('Limrun acquires a system photo picker whose tabs report numeric values', async () => { - // PHPickerViewController reports AXValue 1/0 as numbers, and marks a tapped photo with the - // Selected trait. Both are variant JSON the raw tree hands over untyped. - const result = await captureLimrunIosSnapshot( - createLimrunSnapshotSession({ - elementType: 'Other', - children: [ - { elementType: 'RadioButton', AXLabel: 'Photos', AXValue: 1 }, - { elementType: 'RadioButton', AXLabel: 'Collections', AXValue: 0 }, - { - elementType: 'Image', - AXLabel: 'Photo', - AXUniqueId: 12, - traits: ['Scrollable', 'Selected', 'Image'], - }, - ], - }), - ); - const [photos, collections, photo] = result.acquisition.nodes.slice(1); - expect(photos).toMatchObject({ value: '1', checked: true }); - expect(collections).toMatchObject({ value: '0', checked: false }); - expect(photo).toMatchObject({ selected: true, label: 'Photo', identifier: '12' }); -}); - -test('limrun acquisition shares RN interpretation and preserves native values', async () => { - const result = await captureLimrunIosSnapshot( - createLimrunSnapshotSession({ - elementType: 'Other', - value: 'checkbox, unchecked', - children: [{ elementType: 'TextField', value: 'checkbox, checked' }], - }), - ); - const [checkbox, field] = result.acquisition.nodes; - expect(checkbox).toMatchObject({ - type: 'Other', - value: 'checkbox, unchecked', - checked: false, - iosValueSemantics: { role: 'checkbox', checked: false }, - }); - expect(field).toMatchObject({ type: 'TextField', value: 'checkbox, checked' }); - expect(field).not.toHaveProperty('iosValueSemantics'); - expect(field).not.toHaveProperty('checked'); -}); - -const traitClassification = JSON.parse( - readFileSync( - new URL('../../../contracts/fixtures/ios-trait-classification.json', import.meta.url), - 'utf8', - ), -) as { - cases: Array<{ - name: string; - limrun?: { fact: string; seam: string; input: IosTreeNode; expected: { selected?: boolean } }; - }>; -}; -for (const row of traitClassification.cases.filter((entry) => entry.limrun?.seam === 'selection')) { - test(`Limrun trait selection golden: ${row.limrun!.fact}`, async () => { - const result = await captureLimrunIosSnapshot(createLimrunSnapshotSession(row.limrun!.input)); - expect(result.acquisition.nodes[0]?.selected).toBe(row.limrun!.expected.selected); - }); -} - -test('Limrun adapter publishes the AX subrole the instance reports', async () => { - const tree = limrunSnapshotTree(); - tree.children![0]!.children!.push({ - elementType: 'TextField', - label: 'Password', - subrole: 'AXSecureTextField', - frame: { x: 10, y: 10, width: 100, height: 30 }, - }); - const result = await captureLimrunIosSnapshot(createLimrunSnapshotSession(tree)); - const field = result.acquisition.nodes.find((node) => node.label === 'Password'); - expect(field).toMatchObject({ subrole: 'AXSecureTextField', password: true }); -}); diff --git a/packages/provider-limrun/src/ios-snapshot-adapter.ts b/packages/provider-limrun/src/ios-snapshot-adapter.ts deleted file mode 100644 index f9a07d0827..0000000000 --- a/packages/provider-limrun/src/ios-snapshot-adapter.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { interpretIosNode } from '@agent-device/capture-kit/ios-node-interpretation'; -import { createIosSnapshotAcquisition } from '@agent-device/capture-kit/ios-snapshot-acquisition'; -import type { SnapshotRuntimeAcquiredResult } from '@agent-device/contracts/interactor-types'; -import type { LimrunIosSession } from './ios.ts'; -import { acquireProviderKeyboard } from '@agent-device/contracts/tap-keyboard-occlusion'; -import { expandLimrunCollapsedTabBar } from './ios-collapsed-tab-bar.ts'; -import { - flattenIosTree, - resolveIosAxTreeViewport, - type IosTreeNode, -} from '@agent-device/capture-kit/ios-ax-tree'; - -export async function captureLimrunIosSnapshot( - session: Pick, -): Promise { - const reported = (await session.client.elementTree()) as IosTreeNode | IosTreeNode[]; - const deviceInfo = session.client.deviceInfo; - const viewport = resolveIosAxTreeViewport( - reported, - deviceInfo && { width: deviceInfo.screenWidth, height: deviceInfo.screenHeight }, - ); - const { tree, probeFailure } = await expandLimrunCollapsedTabBar( - reported, - viewport.kind === 'missing' ? null : viewport.rect, - async (point) => (await session.client.elementTree(point)) as IosTreeNode | IosTreeNode[], - ); - const nodes = flattenIosTree(tree).map((node) => interpretIosNode(node)); - return createIosSnapshotAcquisition({ - producer: 'limrun-ios-tree', - ...acquireProviderKeyboard({ - nodes, - viewport: viewport.kind === 'missing' ? null : viewport.rect, - }), - viewport, - lineage: { targetId: session.instanceId }, - ...(probeFailure ? { collapsedTabBarProbeFailure: probeFailure } : {}), - }); -} diff --git a/packages/provider-limrun/src/ios.ts b/packages/provider-limrun/src/ios.ts index ce79dc2c3c..5bcebd6eb7 100644 --- a/packages/provider-limrun/src/ios.ts +++ b/packages/provider-limrun/src/ios.ts @@ -1,14 +1,12 @@ -import { isDeepLinkTarget } from '@agent-device/contracts/command'; import type { SettingOptions } from '@agent-device/contracts/settings'; import type { DeviceLease, - DeviceRotation, ProviderDeviceInstallOptions, ProviderDeviceInstallResult, } from '@agent-device/contracts/device'; -import type { FillBackendResult, Interactor } from '@agent-device/contracts/interactor-types'; +import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { AppError, discloseDispatchAfterSteps } from '@agent-device/kernel/errors'; +import { AppError } from '@agent-device/kernel/errors'; import type Limrun from '@limrun/api'; import { createInstanceClient as createIosInstanceClient, @@ -18,19 +16,21 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { setTimeout as sleep } from 'node:timers/promises'; -import { toIosSelector } from './snapshot.ts'; import { normalizeOptionalString } from './strings.ts'; import { awaitLimrunDeploymentOperation, type LimrunRequestOperationDrain, } from './request-cancellation.ts'; import type { LimrunInstanceOwnership } from './instance-access.ts'; -import type { LimrunRuntimeDependencies } from './runtime-dependencies.ts'; +import type { LimrunRuntimeDependencies, LimrunIosRunner } from './runtime-dependencies.ts'; export type LimrunIosSession = { platform: 'ios'; lease: DeviceLease; instanceId: string; + readonly apiUrl: string; + runner: LimrunIosRunner; + appBundleId?: string; readonly ownership: LimrunInstanceOwnership; device: DeviceInfo; client: LimrunIosClient; @@ -67,16 +67,13 @@ export async function createLimrunIosSession( token: options.token, logLevel: 'warn', }); - return { - platform: 'ios', - lease: options.lease, - instanceId: options.instanceId, - ownership: options.ownership, - device: options.device, - client, - token: options.token, - dependencies, - }; + try { + const runner = await dependencies.ios.createRunner({ ...options, client }); + return { ...options, platform: 'ios', client, runner, dependencies }; + } catch (error) { + client.disconnect(); + throw error; + } } export async function installLimrunIosApp( @@ -142,8 +139,8 @@ export async function installLimrunIosRemoteApp( const result = await awaitLimrunDeploymentOperation( operationDrain, session.client.installApp(url, { - md5: options?.md5, - launchMode: options?.relaunch ? 'RelaunchIfRunning' : 'ForegroundIfRunning', + // The server's digest cache survives uninstall, so every requested install must run. + launchMode: options?.relaunch ? 'RelaunchIfRunning' : undefined, }), signal, ); @@ -175,234 +172,68 @@ export async function installLimrunIosRemoteApp( }); } -export function createLimrunIosInteractor(session: LimrunIosSession): Interactor { - return new LimrunIosInteractor(session); +export function createLimrunIosInteractor( + session: LimrunIosSession, + runner?: RunnerContext, +): Interactor { + const context = { + ...runner, + get appBundleId() { + return runner?.appBundleId ?? session.appBundleId; + }, + }; + const shared = session.runner.createInteractor(context); + const host = new LimrunIosHostInteractor(session, shared, runner?.signal); + return { + ...shared, + open: host.open.bind(host), + openDevice: host.openDevice.bind(host), + close: host.close.bind(host), + setSetting: host.setSetting.bind(host), + }; } -class LimrunIosInteractor implements Interactor { +class LimrunIosHostInteractor { private readonly session: LimrunIosSession; - constructor(session: LimrunIosSession) { + private readonly signal?: AbortSignal; + private readonly shared: Interactor; + + constructor(session: LimrunIosSession, shared: Interactor, signal?: AbortSignal) { this.session = session; + this.shared = shared; + this.signal = signal; } - async open(app: string, options?: { url?: string; launchArgs?: string[] }): Promise { - if (options?.url) { - await this.launch(app, options.launchArgs); - await this.session.client.openUrl(options.url); - return; - } - if (isDeepLinkTarget(app)) { - await this.session.client.openUrl(app); - return; - } - await this.launch(app, options?.launchArgs); - } - - private async launch(app: string, launchArgs: string[] = []): Promise { - const bundleId = await this.session.dependencies.ios.resolveAppAlias(app); - if (launchArgs.length === 0) { - await this.session.client.launchApp(bundleId); - return; - } - // Limrun's launchApp takes no arguments, and an app reads them only at process start. - const result = await this.session.client - .simctl(['launch', '--terminate-running-process', 'booted', bundleId, ...launchArgs]) - .wait(); - if (result.code !== 0) { - throw new AppError( - 'COMMAND_FAILED', - `Limrun iOS could not launch ${bundleId} with arguments.`, - { - bundleId, - exitCode: result.code, - stderr: result.stderr.trim(), - }, - ); - } + async open(app: string, options?: Parameters[1]): Promise { + const target = + options?.appBundleId ?? + ( + await this.session.runner.applicationTools.resolveOpenTarget(this.session.device, { + target: app, + currentAppBundleId: this.session.appBundleId, + surface: 'app', + }) + ).appBundleId; + await this.shared.open(app, { ...options, appBundleId: target }); + this.session.appBundleId = target; } async openDevice(): Promise {} async close(app: string): Promise { - if (app) { - await this.session.client - .terminateApp(await this.session.dependencies.ios.resolveAppAlias(app)) - .catch(() => {}); - } - } - - async tap(x: number, y: number): Promise { - await this.session.client.tap(x, y); - } - - async tapElementSelector(selector: { - key: 'id' | 'label' | 'text' | 'value'; - value: string; - }): Promise | void> { - await this.session.client.tapElement(toIosSelector(selector)); - } - - /** - * Both taps travel in one `performActions` batch so the inter-tap gap is enforced on the - * device. Two separate `tap` requests put a network round trip between the taps, which - * exceeds the double-tap recognition window and registers as two slow single taps. - */ - async doubleTap(x: number, y: number): Promise { - await this.session.client.performActions([ - { type: 'tap', x, y }, - { type: 'wait', durationMs: DOUBLE_TAP_INTERVAL_MS }, - { type: 'tap', x, y }, - ]); - } - - /** One held touch as HID primitives; the hold runs on the device, not across the wire. */ - async longPress( - x: number, - y: number, - durationMs = DEFAULT_LONG_PRESS_DURATION_MS, - ): Promise { - await this.session.client.performActions([ - { type: 'touchDown', x, y }, - { type: 'wait', durationMs }, - { type: 'touchUp', x, y }, - ]); - } - - async focus(x: number, y: number): Promise { - await this.tap(x, y); - } - - async type(text: string, delayMs?: number): Promise { - await this.enterText(text, delayMs); - } - - async fill(x: number, y: number, text: string, _delayMs?: number): Promise { - // Loaded on the fill path to keep this provider's declared import-time closure budget. - const { awaitLimrunTextEntryFocus, readLimrunUnambiguousTapTargets, readLimrunTextEntryFocus } = - await import('./text-entry-focus.ts'); - // Read before the tap: the witness needs to know what was under this point. - const targetsAtPoint = readLimrunUnambiguousTapTargets( - await this.session.client.elementTree(), - x, - y, - ); - await this.tap(x, y); - const textEntryReadiness = await awaitLimrunTextEntryFocus({ - targetsAtPoint, - readFocus: async () => readLimrunTextEntryFocus(await this.session.client.elementTree()), - sleep: (milliseconds) => sleep(milliseconds), - x, - y, - }); - // Omitting the selector lets the server resolve focus. - try { - await this.session.client.setElementValue(text); - } catch (error) { - if (error instanceof AppError) throw error; - throw new AppError( - 'COMMAND_FAILED', - 'Limrun could not write the focused field value.', - { - reason: 'text_entry_value_write_failed', - dispatched: 'unknown', - hint: 'Inspect and assert the field or resulting screen before continuing. If the value needs correction, retry fill with the full desired value. To append with key events, focus the field with press, then use type.', - }, - error, - ); - } - return { textEntryReadiness }; - } - - /** - * Types into whatever holds text-entry focus, character by character when a delay is - * asked for. Focus targeting belongs to the caller, so the provider's own scan for a - * globally focused element is skipped: an app can expose fields that take keys without - * ever reporting one. - */ - private async enterText(text: string, delayMs?: number): Promise { - if (delayMs && delayMs > 0) { - let dispatchedChars = 0; - try { - for (const char of Array.from(text)) { - await this.session.client.typeText(char, false, { requireFocus: false }); - dispatchedChars += 1; - await sleep(delayMs); - } - } catch (error) { - throw discloseDispatchAfterSteps(error, dispatchedChars); - } - return; - } - await this.session.client.typeText(text, false, { requireFocus: false }); - } - - async scroll(direction: 'up' | 'down' | 'left' | 'right', options?: { pixels?: number }) { - await this.session.client.scroll(direction, options?.pixels ?? 300); - } - - /** Limrun serves its capture as JPEG; the PNG-only readers behind `outPath` get a PNG. */ - async screenshot(outPath: string): Promise { - // Loaded on the screenshot path to keep this provider's declared import-time closure budget. - const { transcodeScreenshotToPngAsync } = - await import('@agent-device/capture-kit/png-worker-client'); - const screenshot = await this.session.client.screenshot(); - const png = await transcodeScreenshotToPngAsync( - Buffer.from(screenshot.base64, 'base64'), - 'Limrun iOS screenshot', + const { appBundleId } = await this.session.runner.applicationTools.resolveOpenTarget( + this.session.device, + { + target: app, + surface: 'app', + }, ); - fs.mkdirSync(path.dirname(outPath), { recursive: true }); - await fs.promises.writeFile(outPath, png); - } - - async snapshot() { - const { captureLimrunIosSnapshot } = await import('./ios-snapshot-adapter.ts'); - return await captureLimrunIosSnapshot(this.session); - } - - async back(): Promise { - await this.session.client.pressKey('escape'); - } - - /** One press and release of the hardware home button, sent as a single action batch. */ - async home(): Promise { - await this.session.client.performActions([ - { type: 'buttonDown', button: 'home' }, - { type: 'buttonUp', button: 'home' }, - ]); - } - - async readClipboard(): Promise { - const result = await this.pasteboard(['pbpaste', 'booted']); - return result.stdout.replaceAll('\r\n', '\n').replace(/\n$/, ''); - } - - async writeClipboard(text: string): Promise { - await this.pasteboard(['pbcopy', 'booted'], text); - } - - private async pasteboard(argv: string[], stdin?: string) { - const result = await this.session.client - .simctl(argv, stdin === undefined ? undefined : { stdin }) - .wait(); - if (result.code !== 0) { - throw new AppError('COMMAND_FAILED', 'Limrun iOS could not access the clipboard.', { - command: argv[0], - exitCode: result.code, - stderr: result.stderr.trim(), - }); - } - return result; - } - - async setOrientation(orientation: DeviceRotation): Promise { - if (orientation === 'portrait-upside-down') { - throw unsupported( - 'orientation', - 'Limrun iOS direct sessions support portrait and landscape orientation, not portrait upside-down.', - ); + await this.shared.close(appBundleId ?? app); + if (appBundleId !== undefined && this.session.appBundleId === appBundleId) { + await this.session.runner.resetTarget(this.signal); + this.session.appBundleId = undefined; } - await this.session.client.setOrientation(orientation === 'portrait' ? 'Portrait' : 'Landscape'); } async setSetting( @@ -411,37 +242,10 @@ class LimrunIosInteractor implements Interactor { appId?: string, options?: SettingOptions, ): Promise | void> { - const { setLimrunIosSetting } = await import('./ios-settings.ts'); - return await setLimrunIosSetting(this.session, setting, state, appId, options); - } - - /** - * The four alert legs observe through the same element-tree capture the snapshot rides and act - * through the same point tap every touch operation rides; `alert.ts` holds the host-root rule - * that separates a system alert from the app's own dialog. Loaded on the alert path like the - * other seam modules to keep this provider's declared import-time closure budget. - */ - async readAlert(options?: { signal?: AbortSignal }): Promise> { - const { handleLimrunIosAlert } = await import('./alert.ts'); - return await handleLimrunIosAlert(this.session.client, 'get', options); - } - - async awaitAlert(options?: { - timeoutMs?: number; - signal?: AbortSignal; - }): Promise> { - const { handleLimrunIosAlert } = await import('./alert.ts'); - return await handleLimrunIosAlert(this.session.client, 'wait', options); - } - - async acceptAlert(options?: { signal?: AbortSignal }): Promise> { - const { handleLimrunIosAlert } = await import('./alert.ts'); - return await handleLimrunIosAlert(this.session.client, 'accept', options); - } - - async dismissAlert(options?: { signal?: AbortSignal }): Promise> { - const { handleLimrunIosAlert } = await import('./alert.ts'); - return await handleLimrunIosAlert(this.session.client, 'dismiss', options); + if (setting.toLowerCase() === 'reset-keychain') { + throw new AppError('UNSUPPORTED_OPERATION', 'Limrun does not expose keychain reset.'); + } + return await this.shared.setSetting(setting, state, appId, options); } } @@ -495,11 +299,6 @@ function inferAppNameFromPath(appPath: string): string | undefined { const IOS_APP_INVENTORY_RETRY_DELAYS_MS = [0, 250] as const; -/** Hold applied when the caller names no duration; matches the Android and Linux interactors. */ -const DEFAULT_LONG_PRESS_DURATION_MS = 800; -/** On-device pause between the two taps of a double tap; well inside the recognizer's window. */ -const DOUBLE_TAP_INTERVAL_MS = 80; - function resolveInstalledIosAppId(params: { resultBundleId?: string; requestedBundleId?: string; @@ -535,7 +334,3 @@ export function isUserInstalledIosApp(app: LimrunIosApp): boolean { !app.bundleId.startsWith('com.apple.') && !app.installType.toLowerCase().includes('system') ); } - -function unsupported(command: string, message: string): never { - throw new AppError('UNSUPPORTED_OPERATION', message, { command }); -} diff --git a/packages/provider-limrun/src/lease-release.ts b/packages/provider-limrun/src/lease-release.ts index bb621d5468..f8802d5679 100644 --- a/packages/provider-limrun/src/lease-release.ts +++ b/packages/provider-limrun/src/lease-release.ts @@ -306,14 +306,17 @@ async function releaseLocalResources(owner: LimrunLeaseOwner): Promise { const session = owner.session ?? (await sessionTheCreateBuilt(owner)); if (session === undefined) return; const { awaitLimrunWithinBudget } = await import('./lifecycle-budget.ts'); - const tunnel = - session.platform === 'android' ? cleanupLimrunAndroidAdbTunnel(session) : Promise.resolve(); - // Disconnect closes the ADB endpoint: reverse cleanup gets its window, but cannot postpone disconnect - // past that window. Both promises are observed before either can settle. - const disconnect = awaitLimrunWithinBudget(tunnel, LOCAL_TEARDOWN_BUDGET_MS).then(() => { - session.client.disconnect(); - }); - const results = await Promise.allSettled([tunnel, disconnect]); + const platformResources = + session.platform === 'android' + ? cleanupLimrunAndroidAdbTunnel(session) + : session.runner.dispose(); + // Runner and reverse cleanup use the connection, but cannot postpone disconnect beyond their budget. + const disconnect = awaitLimrunWithinBudget(platformResources, LOCAL_TEARDOWN_BUDGET_MS).then( + () => { + session.client.disconnect(); + }, + ); + const results = await Promise.allSettled([platformResources, disconnect]); const refused = results.find((result) => result.status === 'rejected'); if (refused?.status === 'rejected') throw refused.reason; } diff --git a/packages/provider-limrun/src/lifecycle.test.ts b/packages/provider-limrun/src/lifecycle.test.ts index 47bb093c79..bd9b849ac3 100644 --- a/packages/provider-limrun/src/lifecycle.test.ts +++ b/packages/provider-limrun/src/lifecycle.test.ts @@ -11,7 +11,7 @@ import { } from './app-log-runtime.fixtures.ts'; import { createLimrunPlatformRuntimeOwner } from './app-log-runtime.ts'; -type LegacyLifecycleCell = Readonly<{ +type LifecycleCell = Readonly<{ openTarget: boolean; prepareAppleRunner: boolean; closeTarget: boolean; @@ -19,21 +19,21 @@ type LegacyLifecycleCell = Readonly<{ portReverse: boolean; }>; -const LIMRUN_SUPPORTED_IOS_LIFECYCLE_CELL: LegacyLifecycleCell = { +const LIMRUN_SUPPORTED_IOS_LIFECYCLE_CELL: LifecycleCell = { openTarget: true, - prepareAppleRunner: false, + prepareAppleRunner: true, closeTarget: true, - runtimeHints: false, + runtimeHints: true, portReverse: false, }; -const LIMRUN_SUPPORTED_ANDROID_LIFECYCLE_CELL: LegacyLifecycleCell = { +const LIMRUN_SUPPORTED_ANDROID_LIFECYCLE_CELL: LifecycleCell = { openTarget: true, prepareAppleRunner: false, closeTarget: true, runtimeHints: false, portReverse: true, }; -const LIMRUN_UNSUPPORTED_LIFECYCLE_CELL: LegacyLifecycleCell = { +const LIMRUN_UNSUPPORTED_LIFECYCLE_CELL: LifecycleCell = { openTarget: false, prepareAppleRunner: false, closeTarget: false, @@ -70,7 +70,7 @@ const LIMRUN_LIFECYCLE_DENOMINATOR = [ target: 'mobile' as const, booted: true, }, - legacy: + expected: kind === 'emulator' ? LIMRUN_SUPPORTED_ANDROID_LIFECYCLE_CELL : LIMRUN_UNSUPPORTED_LIFECYCLE_CELL, @@ -87,7 +87,7 @@ const LIMRUN_LIFECYCLE_DENOMINATOR = [ target, booted: true, }, - legacy: + expected: appleOs === 'ios' && kind === 'simulator' && target === 'mobile' ? LIMRUN_SUPPORTED_IOS_LIFECYCLE_CELL : LIMRUN_UNSUPPORTED_LIFECYCLE_CELL, @@ -104,7 +104,7 @@ const LIMRUN_LIFECYCLE_DENOMINATOR = [ target, booted: true, }, - legacy: LIMRUN_UNSUPPORTED_LIFECYCLE_CELL, + expected: LIMRUN_UNSUPPORTED_LIFECYCLE_CELL, })), ), { @@ -117,7 +117,7 @@ const LIMRUN_LIFECYCLE_DENOMINATOR = [ target: 'tv' as const, booted: true, }, - legacy: LIMRUN_UNSUPPORTED_LIFECYCLE_CELL, + expected: LIMRUN_UNSUPPORTED_LIFECYCLE_CELL, }, { name: 'iOS simulator TV target', @@ -126,11 +126,9 @@ const LIMRUN_LIFECYCLE_DENOMINATOR = [ id: 'limrun:ios:tv', target: 'tv' as const, }, - legacy: LIMRUN_UNSUPPORTED_LIFECYCLE_CELL, + expected: LIMRUN_UNSUPPORTED_LIFECYCLE_CELL, }, -] satisfies ReadonlyArray< - Readonly<{ name: string; device: DeviceInfo; legacy: LegacyLifecycleCell }> ->; +] satisfies ReadonlyArray>; test.each([ { @@ -175,15 +173,15 @@ test.each([ test.each(LIMRUN_LIFECYCLE_DENOMINATOR)( 'classifies the direct Limrun $name provider descriptor/dispatch cell', - async ({ device: runtimeDevice, legacy }) => { + async ({ device: runtimeDevice, expected }) => { const owner = createLimrunPlatformRuntimeOwner( limrunOwnerOptions({ getInteractor: () => ({}) as Interactor, }), ); const facts = await owner.inspectFacts(runtimeDevice); - expectLifecycleFactAvailability(facts, legacy); - if (!legacy.openTarget) { + expectLifecycleFactAvailability(facts, expected); + if (!expected.openTarget) { await expect( owner.bind({ device: runtimeDevice, intent: { kind: 'ordinary' }, scope }), ).rejects.toMatchObject({ @@ -201,7 +199,7 @@ test.each(LIMRUN_LIFECYCLE_DENOMINATOR)( expect(binding.facts.operations.ensureReady).toEqual({ available: true }); expect(binding.facts.operations.bootTarget).toEqual({ available: true }); expect(binding.facts.operations.bootTargetHeadless).toMatchObject({ available: false }); - expectLifecycleFacts(binding, legacy); + expectLifecycleFacts(binding, expected); }, ); @@ -287,10 +285,10 @@ test('a live Limrun lifecycle binding relaunches with its selected provider inte runtimeHints: {}, execution: {}, }); - expect(providerClose).toHaveBeenCalledWith('com.example.app'); + expect(providerClose).not.toHaveBeenCalled(); expect(providerOpen).toHaveBeenCalledWith( 'com.example.app', - expect.objectContaining({ appBundleId: 'com.example.app' }), + expect.objectContaining({ appBundleId: 'com.example.app', terminateRunningApp: true }), ); await binding.operations.closeApplication?.({ positionals: ['Example.app.zip'], @@ -304,11 +302,9 @@ test('a live Limrun lifecycle binding relaunches with its selected provider inte function expectLifecycleFacts( binding: DeviceBinding, - // Independent legacy Limrun dispatch cell: only its exact iOS-simulator/Android-emulator - // sessions had open/close behavior; provider-owned hints and Apple runner setup were absent. - legacy: LegacyLifecycleCell = LIMRUN_SUPPORTED_IOS_LIFECYCLE_CELL, + expected: LifecycleCell = LIMRUN_SUPPORTED_IOS_LIFECYCLE_CELL, ): void { - expectLifecycleFactAvailability(binding.facts, legacy); + expectLifecycleFactAvailability(binding.facts, expected); const operations = [ ['openTarget', ['resolveOpenTarget', 'prepareApplicationOpen', 'openApplication']], ['prepareAppleRunner', ['prepareAppleRunner']], @@ -318,7 +314,7 @@ function expectLifecycleFacts( ] as const; for (const [facet, names] of operations) { for (const name of names) { - if (legacy[facet]) expect(binding.operations[name]).toBeTypeOf('function'); + if (expected[facet]) expect(binding.operations[name]).toBeTypeOf('function'); else expect(binding.operations[name]).toBeUndefined(); } } @@ -326,7 +322,7 @@ function expectLifecycleFacts( function expectLifecycleFactAvailability( facts: RuntimeFacts, - legacy: LegacyLifecycleCell, + expected: LifecycleCell, ): void { const operations = [ ['openTarget', ['resolveOpenTarget', 'prepareApplicationOpen', 'openApplication']], @@ -337,7 +333,7 @@ function expectLifecycleFactAvailability( ] as const; for (const [facet, names] of operations) { for (const name of names) { - expect(facts.operations[name].available).toBe(legacy[facet]); + expect(facts.operations[name].available).toBe(expected[facet]); } } } diff --git a/packages/provider-limrun/src/lifecycle.ts b/packages/provider-limrun/src/lifecycle.ts index f90ed73a22..1890cabd2d 100644 --- a/packages/provider-limrun/src/lifecycle.ts +++ b/packages/provider-limrun/src/lifecycle.ts @@ -25,18 +25,21 @@ type LimrunLifecycleParams = Readonly<{ /** Limrun owns its live-session lifecycle, relaunch, and exact port-reverse mechanics. */ export function bindLimrunApplicationLifecycle( params: LimrunLifecycleParams, + appleLifecycle?: ApplicationLifecycleRuntimeOperations, ): ApplicationLifecycleRuntimeOperations { - const lifecycle = bindDirectApplicationLifecycle({ - owner: 'Limrun', - openTargetIdentity: 'bundle-id', - closeBeforeRelaunch: true, - configureProviderPortReverse: async (input) => await params.configurePortReverse(input), - binding: bindProviderApplicationLifecycleInteractor({ - device: params.device, - signal: params.signal, - resolveInteractor: (runner) => params.getInteractor(params.device, runner), - }), - }); + const lifecycle = + appleLifecycle ?? + bindDirectApplicationLifecycle({ + owner: 'Limrun', + openTargetIdentity: 'bundle-id', + closeBeforeRelaunch: true, + configureProviderPortReverse: async (input) => await params.configurePortReverse(input), + binding: bindProviderApplicationLifecycleInteractor({ + device: params.device, + signal: params.signal, + resolveInteractor: (runner) => params.getInteractor(params.device, runner), + }), + }); const resolve = params.resolveAppReference; if (!resolve) return lifecycle; return Object.freeze({ diff --git a/packages/provider-limrun/src/runtime-dependencies.fixtures.ts b/packages/provider-limrun/src/runtime-dependencies.fixtures.ts index 82323635f6..47eeb023cc 100644 --- a/packages/provider-limrun/src/runtime-dependencies.fixtures.ts +++ b/packages/provider-limrun/src/runtime-dependencies.fixtures.ts @@ -1,3 +1,4 @@ +import { testIosRunner } from './ios-runner.fixtures.ts'; import type { AppsFilter } from '@agent-device/contracts/device'; import type { Interactor } from '@agent-device/contracts/interactor-types'; import { AppError } from '@agent-device/kernel/errors'; @@ -25,6 +26,9 @@ export function stubLimrunRuntimeDependencies(): LimrunRuntimeDependencies { return { clientVersion: 'test-version', android: { + bindDeployment: async () => { + throw new Error('Unexpected deployment binding'); + }, createInteractor: () => ({}) as Interactor, createPortReverse: async () => ({ ensure: async () => {}, @@ -55,7 +59,11 @@ export function stubLimrunRuntimeDependencies(): LimrunRuntimeDependencies { downloadFile: async () => undefined, }, ios: { - applySimctlSetting: async () => undefined, + createAppLogReader: ({ client }) => ({ + readLogs: async (app, limit) => client.appLogTail(app, limit), + [Symbol.asyncDispose]: async () => {}, + }), + createRunner: async () => testIosRunner, resolveAppAlias: async (app) => app, readBundleAppName: async () => undefined, }, diff --git a/packages/provider-limrun/src/runtime-dependencies.test.ts b/packages/provider-limrun/src/runtime-dependencies.test.ts index 4e7279294d..375fd8c120 100644 --- a/packages/provider-limrun/src/runtime-dependencies.test.ts +++ b/packages/provider-limrun/src/runtime-dependencies.test.ts @@ -1,3 +1,4 @@ +import { testIosRunner } from './ios-runner.fixtures.ts'; import assert from 'node:assert/strict'; import { test, vi } from 'vitest'; import type { AppsFilter, DeviceLease } from '@agent-device/contracts/device'; @@ -323,6 +324,9 @@ function createContractFixture() { const dependencies = { clientVersion: 'test-version', android: { + bindDeployment: async () => { + throw new Error('Unexpected deployment binding'); + }, createInteractor, createPortReverse: async (adb: LimrunAdbExecutor) => createInMemoryPortReverse(adb, activeReverseMappings), @@ -354,7 +358,13 @@ function createContractFixture() { downloadFile: async () => undefined, }, ios: { - applySimctlSetting: vi.fn(async () => undefined), + createAppLogReader: () => ({ + readLogs: async () => 'log line\n', + [Symbol.asyncDispose]: async () => {}, + }), + createRunner: async () => { + return testIosRunner; + }, resolveAppAlias: async (app: string) => app, readBundleAppName: async () => undefined, }, diff --git a/packages/provider-limrun/src/runtime-dependencies.ts b/packages/provider-limrun/src/runtime-dependencies.ts index 41772952fc..1d511a3a5c 100644 --- a/packages/provider-limrun/src/runtime-dependencies.ts +++ b/packages/provider-limrun/src/runtime-dependencies.ts @@ -1,13 +1,26 @@ +import type { + AppDeploymentInstaller, + AppDeploymentRuntimeOperations, +} from '@agent-device/contracts/app-deployment-runtime'; +import type { InstanceClient as LimrunIosClient } from '@limrun/api/ios-client'; import type { AndroidAdbInvocation, AndroidTextInjectionRequest, } from '@agent-device/platform-android/mechanics'; +import type { + AppleApplicationTools, + ApplicationLifecycleRuntimeOperations, +} from '@agent-device/contracts/application-lifecycle-runtime'; import type { AppsFilter } from '@agent-device/contracts/device'; -import type { Interactor } from '@agent-device/contracts/interactor-types'; +import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; import type { AndroidInputOwner } from '@agent-device/contracts/android-input-ownership'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { AppError } from '@agent-device/kernel/errors'; -import type { SimctlSettingRequest } from '@agent-device/contracts/settings'; +import type { + PlatformRuntimeHost, + PlatformRuntimeOperations, +} from '@agent-device/contracts/platform-runtime-operations'; +import type { RuntimeFacts } from '@agent-device/contracts/platform-runtime'; export type LimrunAdbCommandOptions = { allowFailure?: boolean; @@ -71,7 +84,18 @@ export type LimrunAndroidKeyboardDismissResult = LimrunAndroidKeyboardState & { dismissed: boolean; }; +export type LimrunDeploymentRequest = { + host: PlatformRuntimeHost; + signal: AbortSignal; + install: AppDeploymentInstaller; +}; + export type LimrunAndroidRuntimeAdapter = { + bindDeployment( + device: DeviceInfo, + adb: LimrunAdbProvider, + request: LimrunDeploymentRequest, + ): Promise>; // Interactors need provider-scoped capabilities; command helpers below need only ADB execution. createInteractor(device: DeviceInfo, adb: LimrunAdbProvider): Interactor; /** @@ -124,6 +148,7 @@ export type LimrunFileDownload = Readonly<{ headers: Readonly>; destinationPath: string; timeoutMs: number; + signal?: AbortSignal; }>; export type LimrunHostAdapter = { @@ -139,9 +164,39 @@ export type LimrunHostAdapter = { downloadFile(options: LimrunFileDownload): Promise; }; +export type LimrunIosRunner = { + bindDeployment(request: LimrunDeploymentRequest): Partial; + readonly applicationTools: AppleApplicationTools; + readonly facts: Partial['operations']>; + createInteractor(context?: RunnerContext): Interactor; + resetTarget(signal?: AbortSignal): Promise; + bindApplicationLifecycle(request: { + signal: AbortSignal; + clock: PlatformRuntimeHost['clock']; + resolveInteractor(device: DeviceInfo, context: RunnerContext): Promise; + }): ApplicationLifecycleRuntimeOperations; + bindRunnerOperations(request: { + signal: AbortSignal; + resolveInteractor(device: DeviceInfo, context: RunnerContext): Promise; + }): Pick< + PlatformRuntimeOperations, + 'appState' | 'findText' | 'querySelector' | 'readTextAtPoint' + >; + dispose(): Promise; +}; + export type LimrunIosRuntimeAdapter = { - /** Runs the Apple simctl plan, supplied by the composition root so this provider imports no platform package. */ - applySimctlSetting(request: SimctlSettingRequest): Promise | void>; + createAppLogReader(input: { + client: LimrunIosClient; + apiUrl: string; + token: string; + }): import('@agent-device/capture-kit/provider-app-log').ProviderAppLogReader; + createRunner(session: { + client: Pick; + apiUrl: string; + token: string; + device: DeviceInfo; + }): Promise; resolveAppAlias(app: string): Promise; readBundleAppName(appPath: string): Promise; }; diff --git a/packages/provider-limrun/src/runtime.ts b/packages/provider-limrun/src/runtime.ts index 467c32c0f2..4bef8417cd 100644 --- a/packages/provider-limrun/src/runtime.ts +++ b/packages/provider-limrun/src/runtime.ts @@ -213,11 +213,16 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { return this.getSessionForDevice(device) !== undefined; } - getInteractor(device: DeviceInfo, _runner?: RunnerContext): Interactor | undefined { + getIosRunner(device: DeviceInfo) { + const session = this.getSessionForDevice(device); + return session?.platform === 'ios' ? session.runner : undefined; + } + + getInteractor(device: DeviceInfo, runner?: RunnerContext): Interactor | undefined { const session = this.getSessionForDevice(device); if (!session) return undefined; return session.platform === 'ios' - ? createLimrunIosInteractor(session) + ? createLimrunIosInteractor(session, runner) : createLimrunAndroidInteractor(session); } @@ -226,6 +231,44 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { return session ? createLimrunDeviceSession(session) : undefined; } + async bindDeployment( + host: PlatformRuntimeHost, + device: DeviceInfo, + signal: AbortSignal, + operationDrain?: LimrunRequestOperationDrain, + ) { + const session = this.getSessionForDevice(device); + if (!session) return {}; + const request = { + host, + signal, + install: async ( + artifact: import('@agent-device/contracts/app-deployment-runtime').MaterializedAppSource, + installSignal: AbortSignal, + ) => { + const result = await this.installInstallablePath( + device, + artifact.installablePath, + { + appIdentifierHint: artifact.bundleId, + packageNameHint: artifact.packageName, + }, + installSignal, + operationDrain, + ); + if (!result) + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'The Limrun provider session is no longer active.', + ); + return result; + }, + }; + return session.platform === 'ios' + ? session.runner.bindDeployment(request) + : await this.dependencies.android.bindDeployment(device, session.adbProvider, request); + } + async installApp( device: DeviceInfo, app: string, @@ -759,16 +802,20 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { const session = this.getSessionForDevice(device); if (!session) return undefined; const publicSession = createLimrunDeviceSession(session); + const logs = + session.platform === 'ios' ? this.dependencies.ios.createAppLogReader(session) : undefined; return { platform: session.platform, leaseId: session.lease.leaseId, instanceId: session.instanceId, ownership: session.ownership, - readLogs: async (appBundleId, lineLimit) => - publicSession.platform === 'ios' - ? await publicSession.readLogs(appBundleId, lineLimit) - : await publicSession.readLogs(lineLimit), - [Symbol.asyncDispose]: async () => undefined, + readLogs: async (appBundleId, lineLimit, signal) => { + if (publicSession.platform === 'android') return await publicSession.readLogs(lineLimit); + return await logs!.readLogs(appBundleId, lineLimit, signal); + }, + [Symbol.asyncDispose]: async () => { + await logs?.[Symbol.asyncDispose](); + }, }; } @@ -805,6 +852,7 @@ async function loadLimrunPlatformRuntime( ownsDevice: (device) => runtime.ownsDevice(device), hasLiveSession: (device) => runtime.hasLiveSession(device), getInteractor: (device, runner) => runtime.getInteractor(device, runner), + getIosRunner: (device) => runtime.getIosRunner(device), getDeviceSession: (device) => runtime.getDeviceSession(device), resolveAppReference: (device, app) => runtime.resolveAppReference(device, app), openCurrent: async (device) => runtime.currentAppLogReader(device), @@ -827,30 +875,8 @@ async function loadLimrunPlatformRuntime( const { readLimrunAppState } = await import('./app-state-read.ts'); return await readLimrunAppState(runtime.getDeviceSession(device), signal); }, - deployApp: async (device, input, signal, operationDrain) => - await runtime.installApp( - device, - input.app, - input.appPath, - { - relaunch: input.replaceExisting, - appIdentifierHint: input.app, - packageNameHint: input.app, - }, - signal, - operationDrain, - ), - deployMaterializedApp: async (device, input, signal, operationDrain) => - await runtime.installInstallablePath( - device, - input.artifact.installablePath, - { - appIdentifierHint: input.artifact.bundleId, - packageNameHint: input.artifact.packageName, - }, - signal, - operationDrain, - ), + bindDeployment: (device, signal, operationDrain) => + runtime.bindDeployment(host, device, signal, operationDrain), configurePortReverse: async (options) => await runtime.configurePortReverse(options), }); } diff --git a/packages/provider-limrun/src/snapshot.test.ts b/packages/provider-limrun/src/snapshot.test.ts deleted file mode 100644 index 46d508a9bf..0000000000 --- a/packages/provider-limrun/src/snapshot.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { expect, test } from 'vitest'; -import { toIosSelector } from './snapshot.ts'; - -test('agent-device selector keys become the accessibility keys the iOS instance matches', () => { - expect(toIosSelector({ key: 'id', value: 'email-field' })).toEqual({ - AXUniqueId: 'email-field', - }); - expect(toIosSelector({ key: 'value', value: 'qa@example.com' })).toEqual({ - AXValue: 'qa@example.com', - }); - expect(toIosSelector({ key: 'label', value: 'Email' })).toEqual({ AXLabel: 'Email' }); -}); - -test('visible text targets the provider label field, which is where the tree exposes it', () => { - expect(toIosSelector({ key: 'text', value: 'Sign In' })).toEqual({ AXLabel: 'Sign In' }); -}); diff --git a/packages/provider-limrun/src/snapshot.ts b/packages/provider-limrun/src/snapshot.ts deleted file mode 100644 index 31795c95f6..0000000000 --- a/packages/provider-limrun/src/snapshot.ts +++ /dev/null @@ -1,9 +0,0 @@ -type LimrunSelector = { key: 'id' | 'label' | 'text' | 'value'; value: string }; - -export function toIosSelector(selector: LimrunSelector) { - if (selector.key === 'id') return { AXUniqueId: selector.value }; - if (selector.key === 'value') return { AXValue: selector.value }; - // The Limrun iOS tree exposes visible text through AXLabel, so both - // agent-device label and text selectors target the provider's label field. - return { AXLabel: selector.value }; -} diff --git a/packages/provider-limrun/src/text-entry-focus.test.ts b/packages/provider-limrun/src/text-entry-focus.test.ts deleted file mode 100644 index 6a1cee01bd..0000000000 --- a/packages/provider-limrun/src/text-entry-focus.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { expect, test, vi } from 'vitest'; -import { - awaitLimrunTextEntryFocus, - readLimrunTextEntryFocus, - readLimrunUnambiguousTapTargets, -} from './text-entry-focus.ts'; -import type { IosTreeNode } from '@agent-device/capture-kit/ios-ax-tree'; - -type TreeRect = Readonly<{ x: number; y: number; width: number; height: number }>; - -const FIELD_RECT: TreeRect = Object.freeze({ x: 24, y: 142, width: 354, height: 56 }); -const ON_THE_FIELD = Object.freeze({ x: 200, y: 170 }); -const OFF_THE_FIELD = Object.freeze({ x: 200, y: 810 }); - -const EDITING_TRAITS = Object.freeze([ - 'TextEntry', - 'TextOperationsAvailable', - 'Scrollable', - 'IsEditing', -]); - -function textField( - label: string, - options: { editing?: boolean; rect?: TreeRect | null; traits?: string[] } = {}, -): IosTreeNode { - return { - type: 'TextField', - AXLabel: label, - pid: 4242, - ...(options.rect === null ? {} : { frame: options.rect ?? FIELD_RECT }), - traits: - options.traits ?? - (options.editing - ? [...EDITING_TRAITS] - : ['TextEntry', 'TextOperationsAvailable', 'Scrollable']), - }; -} - -function tree(...children: IosTreeNode[]): IosTreeNode { - return { type: 'Application', AXLabel: 'App', children }; -} - -function targetsAt(point: Readonly<{ x: number; y: number }>, ...before: IosTreeNode[]) { - return readLimrunUnambiguousTapTargets(tree(...before), point.x, point.y); -} - -const noSleep = async () => {}; - -test('the editing element is the one carrying the provider text-entry trait', () => { - const focused = textField('Email', { editing: true }); - const focus = readLimrunTextEntryFocus(tree(textField('Password'), focused)); - expect(focus).toEqual({ identity: expect.stringContaining('Email'), rect: FIELD_RECT }); - expect(readLimrunTextEntryFocus(tree(textField('Password')))).toBeNull(); -}); - -test('the smallest editing element wins when a field and its container both report editing', () => { - const container = textField('Email', { - editing: true, - rect: Object.freeze({ x: 0, y: 100, width: 402, height: 200 }), - }); - const field = textField('Email', { editing: true }); - const focus = readLimrunTextEntryFocus(tree({ ...container, children: [field] })); - expect(focus?.rect).toEqual(FIELD_RECT); -}); - -// An element without a frame cannot be shown to be the one we tapped. -test('an editing element with no usable frame is not offered as evidence', () => { - expect(readLimrunTextEntryFocus(tree(textField('Email', { editing: true, rect: null })))).toBe( - null, - ); - expect( - readLimrunTextEntryFocus( - tree(textField('Email', { editing: true, rect: { x: 0, y: 0, width: 0, height: 0 } })), - ), - ).toBeNull(); -}); - -test('the elements under the tapped point are the ones a fill can vouch for', () => { - const field = textField('Email'); - const elsewhere = textField('Address', { rect: { x: 24, y: 600, width: 354, height: 56 } }); - const identity = readLimrunTextEntryFocus( - tree({ ...field, traits: [...EDITING_TRAITS] }), - )?.identity; - expect(identity).toEqual(expect.any(String)); - expect(targetsAt(ON_THE_FIELD, field, elsewhere).has(identity!)).toBe(true); - expect(targetsAt(OFF_THE_FIELD, field, elsewhere).has(identity!)).toBe(false); -}); - -test('re-filling the field that already holds focus is witnessed by geometry', async () => { - const readiness = await awaitLimrunTextEntryFocus({ - targetsAtPoint: targetsAt(ON_THE_FIELD, textField('Email', { editing: true })), - readFocus: async () => readLimrunTextEntryFocus(tree(textField('Email', { editing: true }))), - sleep: noSleep, - ...ON_THE_FIELD, - }); - expect(readiness).toBe('focused-element'); -}); - -test('a field that keeps focus while the keyboard scrolls it still counts as witnessed', async () => { - const readiness = await awaitLimrunTextEntryFocus({ - targetsAtPoint: targetsAt(ON_THE_FIELD, textField('Email')), - readFocus: async () => - readLimrunTextEntryFocus( - tree(textField('Email', { editing: true, rect: { x: 24, y: 40, width: 354, height: 56 } })), - ), - sleep: noSleep, - ...ON_THE_FIELD, - }); - expect(readiness).toBe('focused-element'); -}); - -test('a tap that nothing answers is refused without typing, after the budget is spent', async () => { - const readFocus = vi.fn(async () => readLimrunTextEntryFocus(tree(textField('Email')))); - const sleep = vi.fn(noSleep); - await expect( - awaitLimrunTextEntryFocus({ - targetsAtPoint: targetsAt(ON_THE_FIELD, textField('Email')), - readFocus, - sleep, - ...ON_THE_FIELD, - timeoutMs: 0, - }), - ).rejects.toMatchObject({ - code: 'COMMAND_FAILED', - details: { - reason: 'text_entry_focus_not_observed', - x: ON_THE_FIELD.x, - y: ON_THE_FIELD.y, - samples: 1, - editingElementObserved: false, - }, - }); - expect(readFocus).toHaveBeenCalledTimes(1); - expect(sleep).not.toHaveBeenCalled(); -}); - -// An editing element that was never under this point is the wrong field. -test('an editing element that was never under the tapped point is refused', async () => { - await expect( - awaitLimrunTextEntryFocus({ - targetsAtPoint: targetsAt(OFF_THE_FIELD, textField('Email')), - readFocus: async () => readLimrunTextEntryFocus(tree(textField('Email', { editing: true }))), - sleep: noSleep, - ...OFF_THE_FIELD, - timeoutMs: 0, - }), - ).rejects.toMatchObject({ - details: { reason: 'text_entry_focus_not_observed', editingElementObserved: true }, - }); -}); - -// Fields with no identifier or label share one identity, so only geometry tells them apart. -const TWIN_A_RECT: TreeRect = Object.freeze({ x: 24, y: 142, width: 354, height: 56 }); -const TWIN_B_RECT: TreeRect = Object.freeze({ x: 24, y: 262, width: 354, height: 56 }); - -test('a shared identity does not witness the twin that was not tapped', async () => { - await expect( - awaitLimrunTextEntryFocus({ - targetsAtPoint: targetsAt( - { x: 200, y: 170 }, - textField('', { rect: TWIN_A_RECT }), - textField('', { rect: TWIN_B_RECT }), - ), - readFocus: async () => - readLimrunTextEntryFocus( - tree( - textField('', { rect: TWIN_A_RECT }), - textField('', { rect: TWIN_B_RECT, editing: true }), - ), - ), - sleep: noSleep, - x: 200, - y: 170, - timeoutMs: 0, - }), - ).rejects.toMatchObject({ - details: { reason: 'text_entry_focus_not_observed', editingElementObserved: true }, - }); -}); - -// One unlabeled field before the tap, two after: the newcomer can share the identity of -// the field that was under the finger, so only geometry clears it. -test('a field that appears after the tap does not borrow the identity it now shares', async () => { - await expect( - awaitLimrunTextEntryFocus({ - targetsAtPoint: targetsAt({ x: 200, y: 170 }, textField('', { rect: TWIN_A_RECT })), - readFocus: async () => - readLimrunTextEntryFocus( - tree( - textField('', { rect: TWIN_A_RECT }), - textField('', { rect: TWIN_B_RECT, editing: true }), - ), - ), - sleep: noSleep, - x: 200, - y: 170, - timeoutMs: 0, - }), - ).rejects.toMatchObject({ - details: { reason: 'text_entry_focus_not_observed', editingElementObserved: true }, - }); -}); - -test('geometry still witnesses the twin that was tapped', async () => { - const readiness = await awaitLimrunTextEntryFocus({ - targetsAtPoint: targetsAt( - { x: 200, y: 170 }, - textField('', { rect: TWIN_A_RECT }), - textField('', { rect: TWIN_B_RECT }), - ), - readFocus: async () => - readLimrunTextEntryFocus( - tree( - textField('', { rect: TWIN_A_RECT, editing: true }), - textField('', { rect: TWIN_B_RECT }), - ), - ), - sleep: noSleep, - x: 200, - y: 170, - }); - expect(readiness).toBe('focused-element'); -}); - -test('focus that arrives late is waited for instead of typed past', async () => { - let reads = 0; - const readiness = await awaitLimrunTextEntryFocus({ - targetsAtPoint: targetsAt(ON_THE_FIELD, textField('Email')), - readFocus: async () => { - reads += 1; - return readLimrunTextEntryFocus( - tree(reads < 3 ? textField('Email') : textField('Email', { editing: true })), - ); - }, - sleep: noSleep, - ...ON_THE_FIELD, - }); - expect(readiness).toBe('focused-element'); - expect(reads).toBe(3); -}); - -const traitClassification = JSON.parse( - readFileSync( - new URL('../../../contracts/fixtures/ios-trait-classification.json', import.meta.url), - 'utf8', - ), -) as { - cases: Array<{ - name: string; - limrun?: { - fact: string; - seam: string; - input: IosTreeNode; - expected: { focusWitness: boolean }; - }; - }>; -}; -for (const row of traitClassification.cases.filter((entry) => entry.limrun?.seam === 'focus')) { - test(`Limrun trait focus golden: ${row.limrun!.fact}`, () => { - const input = { ...row.limrun!.input, frame: FIELD_RECT }; - expect(readLimrunTextEntryFocus(input) !== null).toBe(row.limrun!.expected.focusWitness); - }); -} diff --git a/packages/provider-limrun/src/text-entry-focus.ts b/packages/provider-limrun/src/text-entry-focus.ts deleted file mode 100644 index 67721a8955..0000000000 --- a/packages/provider-limrun/src/text-entry-focus.ts +++ /dev/null @@ -1,188 +0,0 @@ -import type { CloudTextEntryReadiness } from '@agent-device/contracts/interactor-types'; -import { AppError } from '@agent-device/kernel/errors'; -import { type Rect } from '@agent-device/kernel/snapshot'; -import { containsPoint, isPositiveFiniteRect, rectArea } from '@agent-device/kernel/rect'; -import { - readIosNodeChildren, - readIosNodeRect, - type IosTreeNode, -} from '@agent-device/capture-kit/ios-ax-tree'; - -/** The trait a Limrun iOS instance puts on the element that owns text entry. */ -const LIMRUN_IOS_TEXT_ENTRY_TRAIT = 'IsEditing'; - -/** Each sample is a round trip to the instance, so this budget is longer than a local read's. */ -const TEXT_ENTRY_FOCUS_TIMEOUT_MS = 3_000; -const TEXT_ENTRY_FOCUS_POLL_INTERVAL_MS = 150; - -/** - * The element that owns text entry, as one tree read saw it. A rect is required: an - * element without a usable frame cannot be shown to be the one that was tapped. The - * identity is offered only when that read reports it once, because an identity two - * elements share cannot say which of them holds focus. - */ -export type LimrunTextEntryFocus = Readonly<{ - identity?: string; - rect: Rect; -}>; - -/** - * Nothing took text-entry focus within the budget, so typing would go to whatever - * else holds first responder, or nowhere. `editingElementObserved` separates a - * target that never exposes text entry from one that exposed it away from the - * tapped point, which are different next moves for the caller. - */ -function textEntryFocusNotObservedError( - x: number, - y: number, - detail: string, - evidence: Readonly<{ samples: number; editingElementObserved: boolean }>, -): AppError { - return new AppError('COMMAND_FAILED', `fill tapped (${x}, ${y}) but ${detail}`, { - reason: 'text_entry_focus_not_observed', - x, - y, - ...evidence, - hint: 'The tap most likely missed the field. Re-check the target with snapshot -i and fill the element it reports, rather than retrying the same coordinates.', - }); -} - -/** A node with a rect a point can be tested against, which is what a witness needs. */ -type LimrunFramedNode = Readonly<{ node: IosTreeNode; identity: string; rect: Rect }>; - -function readFramedNodes(tree: IosTreeNode | IosTreeNode[]): LimrunFramedNode[] { - const framed: LimrunFramedNode[] = []; - for (const node of flattenIosNodes(tree)) { - const rect = readIosNodeRect(node); - if (isPositiveFiniteRect(rect)) { - framed.push({ node, identity: limrunTextEntryIdentity(node), rect }); - } - } - return framed; -} - -/** How many nodes of one read an identity names. One means the identity picks out a single element. */ -function countIdentitySightings(framed: readonly LimrunFramedNode[]): Map { - const sightings = new Map(); - for (const entry of framed) { - sightings.set(entry.identity, (sightings.get(entry.identity) ?? 0) + 1); - } - return sightings; -} - -/** - * The editing element of one tree read. A field and its container can both carry the - * trait, so the smallest rect wins. - */ -export function readLimrunTextEntryFocus( - tree: IosTreeNode | IosTreeNode[], -): LimrunTextEntryFocus | null { - const framed = readFramedNodes(tree); - const sightings = countIdentitySightings(framed); - let focus: LimrunTextEntryFocus | null = null; - let focusArea = Number.POSITIVE_INFINITY; - for (const entry of framed) { - if (!entry.node.traits?.includes(LIMRUN_IOS_TEXT_ENTRY_TRAIT)) continue; - const area = rectArea(entry.rect); - if (area >= focusArea) continue; - focus = - sightings.get(entry.identity) === 1 - ? { identity: entry.identity, rect: entry.rect } - : { rect: entry.rect }; - focusArea = area; - } - return focus; -} - -/** - * The elements under the point this fill aims at, read before the tap could move - * anything, so a field that focusing re-laid out is still recognized. An identity - * this read reports more than once is left out, and the post-tap read repeats the - * same check: an element that appears after the tap can share an identity with the - * one that was under the finger. - */ -export function readLimrunUnambiguousTapTargets( - tree: IosTreeNode | IosTreeNode[], - x: number, - y: number, -): ReadonlySet { - const framed = readFramedNodes(tree); - const sightings = countIdentitySightings(framed); - const targets = new Set(); - for (const entry of framed) { - if (sightings.get(entry.identity) === 1 && containsPoint(entry.rect, x, y)) { - targets.add(entry.identity); - } - } - return targets; -} - -/** - * Wait for the tap to move text-entry focus onto the aimed-at point, and hand back - * the readiness this fill earned. An editing element elsewhere on the screen says - * nothing about this tap, so it is refused. Limrun reports no keyboard visibility, - * so a field that never takes the editing trait is indistinguishable from a missed - * tap. - */ -export async function awaitLimrunTextEntryFocus( - params: Readonly<{ - targetsAtPoint: ReadonlySet; - readFocus: () => Promise; - sleep: (milliseconds: number) => Promise; - x: number; - y: number; - timeoutMs?: number; - }>, -): Promise { - const deadline = Date.now() + (params.timeoutMs ?? TEXT_ENTRY_FOCUS_TIMEOUT_MS); - let samples = 0; - let editingElementObserved = false; - for (;;) { - const focus = await params.readFocus(); - samples += 1; - if (focus) { - editingElementObserved = true; - if (witnessesThisTap(focus, params.targetsAtPoint, params.x, params.y)) { - return 'focused-element'; - } - } - if (Date.now() >= deadline) break; - await params.sleep(TEXT_ENTRY_FOCUS_POLL_INTERVAL_MS); - } - throw textEntryFocusNotObservedError( - params.x, - params.y, - 'nothing there took text-entry focus, so the text was not sent', - { samples, editingElementObserved }, - ); -} - -function witnessesThisTap( - focus: LimrunTextEntryFocus, - targetsAtPoint: ReadonlySet, - x: number, - y: number, -): boolean { - if (containsPoint(focus.rect, x, y)) return true; - return focus.identity !== undefined && targetsAtPoint.has(focus.identity); -} - -function flattenIosNodes(tree: IosTreeNode | IosTreeNode[]): IosTreeNode[] { - const nodes: IosTreeNode[] = []; - const visit = (node: IosTreeNode) => { - nodes.push(node); - for (const child of readIosNodeChildren(node)) visit(child); - }; - for (const root of Array.isArray(tree) ? tree : [tree]) visit(root); - return nodes; -} - -/** What two reads of the same element agree on. The provider hands out no element handle. */ -function limrunTextEntryIdentity(node: IosTreeNode): string { - return [ - node.pid ?? '', - node.type ?? node.elementType ?? '', - node.AXUniqueId ?? node.identifier ?? '', - node.AXLabel ?? node.label ?? '', - ].join('\u0000'); -} diff --git a/packages/provider-limrun/tsconfig.json b/packages/provider-limrun/tsconfig.json index 4592bbb24b..b9fa33399b 100644 --- a/packages/provider-limrun/tsconfig.json +++ b/packages/provider-limrun/tsconfig.json @@ -14,6 +14,7 @@ { "path": "../capture-kit" }, { "path": "../contracts" }, { "path": "../kernel" }, - { "path": "../platform-android" } + { "path": "../platform-android" }, + { "path": "../platform-apple" } ] } diff --git a/packages/provider-revyl/src/interactor.test.ts b/packages/provider-revyl/src/interactor.test.ts index 56c22abe8b..53c5158090 100644 --- a/packages/provider-revyl/src/interactor.test.ts +++ b/packages/provider-revyl/src/interactor.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { afterEach, test } from 'vitest'; +import { afterEach, test, vi } from 'vitest'; import { mkdtempForTest } from '@agent-device/test-kit'; import { PNG } from '@agent-device/capture-kit/png'; import { readPngSize } from '@agent-device/capture-kit/png-size'; @@ -592,11 +592,7 @@ test('two captures of one path each keep their own temporary file', async () => ); }); -test('a screenshot canceled after its bytes arrive is not published', async () => { - // The worker has answered, so the cancellation cannot reach the request: it lands while the local write - // runs. The caller that hung up is owed a failure, not a file it stopped asking for, and the bytes it - // canceled have to be taken back off the disk. The abort is issued the moment the temporary file appears, - // and the body is large enough that the write is still in flight across the next turn of the loop. +test('a screenshot canceled during its local write removes the partial file', async () => { const controller = new AbortController(); const { interactor } = await liveInteractor( 'android', @@ -605,52 +601,79 @@ test('a screenshot canceled after its bytes arrive is not published', async () = 'GET /api/v1/execution/device-proxy/run-1/screenshot': { status: 200, contentType: 'image/png', - body: 'P'.repeat(8 * 1024 * 1024), + body: 'PNG', }, }, }, { signal: controller.signal }, ); - const directory = await mkdtempForTest('agent-device-revyl-shot-after-'); + const directory = await mkdtempForTest('agent-device-revyl-shot-mid-write-'); const outPath = path.join(directory, 'shot.png'); - - const capturing = interactor.screenshot(outPath); - await waitForWriteToStart(directory); - controller.abort(); - - await assert.rejects( - async () => await capturing, - (error: unknown) => error instanceof AppError && error.details?.reason === 'request_canceled', - ); - assert.equal( - await fs.stat(outPath).then( - () => true, - () => false, - ), - false, - 'the canceled capture never reaches the requested path', - ); - assert.deepEqual( - await fs.readdir(directory), - [], - 'the bytes it wrote are taken back off the disk', - ); + let started!: () => void; + const writing = new Promise((resolve) => { + started = resolve; + }); + const original = fs.writeFile.bind(fs); + const write = vi.spyOn(fs, 'writeFile').mockImplementation(async (file, data, options) => { + await original(file, data); + const signal = typeof options === 'object' && options !== null ? options.signal : undefined; + started(); + await new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')), { + once: true, + }); + }); + }); + try { + const capture = interactor.screenshot(outPath); + const outcome = assert.rejects( + capture, + (error: unknown) => error instanceof AppError && error.details?.reason === 'request_canceled', + ); + await writing; + controller.abort(); + await outcome; + assert.deepEqual(await fs.readdir(directory), []); + } finally { + write.mockRestore(); + } }); -/** - * Waits until one capture's temporary sibling shows up in the directory, which is the moment its write is - * in flight and the abort has somewhere to land. - */ -async function waitForWriteToStart(directory: string): Promise { - for (let attempt = 0; attempt < 2_000; attempt += 1) { - const entries = await fs.readdir(directory); - if (entries.some((entry) => entry.endsWith('.tmp'))) return; - await new Promise((resolve) => setTimeout(resolve, 0.2)); - } - throw new Error( - 'The screenshot never reached its write, so the cancellation had nothing to interrupt.', +test('a screenshot canceled after its bytes arrive is not published', async () => { + const controller = new AbortController(); + const { interactor } = await liveInteractor( + 'android', + { + respond: { + 'GET /api/v1/execution/device-proxy/run-1/screenshot': { + status: 200, + contentType: 'image/png', + body: 'P'.repeat(1024), + }, + }, + }, + { signal: controller.signal }, ); -} + const directory = await mkdtempForTest('agent-device-revyl-shot-after-'); + const outPath = path.join(directory, 'shot.png'); + const writeFile = fs.writeFile.bind(fs); + // Directory polling can observe the temporary name after rename has already begun. + // Abort at write completion so publication has not started, independent of disk speed. + const write = vi.spyOn(fs, 'writeFile').mockImplementation(async (...args) => { + await writeFile(...args); + if (typeof args[0] === 'string' && path.dirname(args[0]) === directory) controller.abort(); + }); + try { + await assert.rejects( + () => interactor.screenshot(outPath), + (error: unknown) => error instanceof AppError && error.details?.reason === 'request_canceled', + ); + assert.equal(controller.signal.aborted, true, 'the capture reached the completed write'); + assert.deepEqual(await fs.readdir(directory), [], 'the canceled capture leaves no file behind'); + } finally { + write.mockRestore(); + } +}); test('a canceled screenshot leaves the file unwritten', async () => { // The worker is still composing the PNG when the caller hangs up. The request has to end there, and diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d38ac0838..818959ceb8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -757,6 +757,9 @@ importers: '@agent-device/test-kit': specifier: workspace:* version: link:../test-kit + plist: + specifier: 5.0.0 + version: 5.0.0 packages/platform-harmonyos: dependencies: @@ -867,6 +870,9 @@ importers: specifier: workspace:* version: link:../platform-android devDependencies: + '@agent-device/platform-apple': + specifier: workspace:* + version: link:../platform-apple '@limrun/api': specifier: ^0.68.1 version: 0.68.1(supports-color@7.2.0) @@ -3198,6 +3204,10 @@ packages: '@workflow/serde@4.1.0': resolution: {integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==} + '@xmldom/xmldom@0.9.12': + resolution: {integrity: sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==} + engines: {node: '>=14.6'} + '@yuku-codegen/binding-android-arm64@0.8.7': resolution: {integrity: sha512-C/0zV5IhgVdYhGJTwrY0v8dknxlhiKwtVJkMUaexu9/QvRmzlV4vfU3hZlUSgqc2BxQHntL1mCVbDq8j0FRFDw==} cpu: [arm64] @@ -4850,6 +4860,10 @@ packages: engines: {node: '>=20'} hasBin: true + plist@5.0.0: + resolution: {integrity: sha512-20N+g1DvMm/DFRbsvER7tT4wDryq0WunK7VMkDaiJcKNapAnUMkTsAnacFYf8n420F4Hf6/hefgmJRkMb1M0fg==} + engines: {node: '>=18'} + pngjs@7.0.0: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} @@ -5709,6 +5723,10 @@ packages: utf-8-validate: optional: true + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + xxhash-wasm@1.1.0: resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} @@ -7523,6 +7541,8 @@ snapshots: '@workflow/serde@4.1.0': {} + '@xmldom/xmldom@0.9.12': {} + '@yuku-codegen/binding-android-arm64@0.8.7': optional: true @@ -9327,6 +9347,11 @@ snapshots: playwright-core@1.64.1: {} + plist@5.0.0: + dependencies: + '@xmldom/xmldom': 0.9.12 + xmlbuilder: 15.1.1 + pngjs@7.0.0: {} postcss-nested@7.0.2(postcss@8.5.23): @@ -10226,6 +10251,8 @@ snapshots: ws@8.21.3: {} + xmlbuilder@15.1.1: {} + xxhash-wasm@1.1.0: {} y18n@5.0.8: {} diff --git a/scripts/capability-matrix/annotations.ts b/scripts/capability-matrix/annotations.ts index 4b8ddaff1f..e590ce9eb4 100644 --- a/scripts/capability-matrix/annotations.ts +++ b/scripts/capability-matrix/annotations.ts @@ -164,6 +164,11 @@ export const MATRIX_SECTIONS: readonly MatrixSection[] = [ commands: ['keyboard'], link: docs('commands', 'keyboard'), cells: { + 'limrun-ios': { + text: 'Dismiss and enter; status unsupported', + admits: ['keyboardDismiss', 'keyboardEnter'], + refuses: ['keyboardStatus'], + }, 'ios-sim': { text: 'Dismiss and enter; status unsupported', admits: ['keyboardDismiss', 'keyboardEnter'], @@ -256,11 +261,6 @@ export const MATRIX_SECTIONS: readonly MatrixSection[] = [ label: 'Foreground app state', commands: ['appstate'], link: docs('commands', 'app-state-and-app-lists'), - cells: { - 'limrun-ios': { - text: 'Last opened app, once the instance answers; no foreground read', - }, - }, note: 'On iOS and macOS a bound session that owns an app identity or surface answers this from its own state, even where the sessionless probe cannot see a foreground app.', }, { @@ -457,6 +457,9 @@ export const MATRIX_SECTIONS: readonly MatrixSection[] = [ 'ios-device': { text: 'Models with an Action Button only', }, + 'limrun-ios': { + text: 'Models with an Action Button only', + }, }, }, { @@ -509,11 +512,6 @@ export const MATRIX_SECTIONS: readonly MatrixSection[] = [ admits: ['setSetting'], refuses: ['readSetting'], }, - 'limrun-ios': { - text: 'Set only; no reads', - admits: ['setSetting'], - refuses: ['readSetting'], - }, }, }, { diff --git a/scripts/capability-matrix/collect.ts b/scripts/capability-matrix/collect.ts index c021c841d9..2752911eef 100644 --- a/scripts/capability-matrix/collect.ts +++ b/scripts/capability-matrix/collect.ts @@ -222,6 +222,9 @@ async function limrunSupport( // The hooks must be in place before the provider's own `@limrun/api` import resolves. stubLimrunSdk(); const { createLimrunRuntime } = await import('@agent-device/provider-limrun'); + const { createLimrunIosRunner } = await import('../../src/core/interactors/limrun-ios-runner.ts'); + const { createAppleApplicationTools } = + await import('../../src/platform-runtime-apple-application-tools.ts'); const runtime = createLimrunRuntime( { instances: { @@ -237,7 +240,14 @@ async function limrunSupport( clientVersion: '0.0.0', android: { createPortReverse: async () => ({}) }, host: {}, - ios: {}, + ios: { + createRunner: (session: Parameters[0]) => + createLimrunIosRunner( + session, + { env: {}, cwd: process.cwd() }, + createAppleApplicationTools({}), + ), + }, } as never, { includePlatformModule: true }, ); diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index f61564d87a..6d929fe16c 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -645,7 +645,6 @@ test('the real tree parses, declares, and passes R11', () => { '@agent-device/platform-apple/runner/operations', '@agent-device/platform-apple/session-observation', '@agent-device/platform-apple/simctl', - '@agent-device/platform-apple/simctl-settings', '@agent-device/platform-apple/simulator', '@agent-device/platform-apple/simulator-boot', '@agent-device/platform-apple/snapshot-source', diff --git a/scripts/layering/platform-package-policy.test.ts b/scripts/layering/platform-package-policy.test.ts index eb8c554f50..388696057f 100644 --- a/scripts/layering/platform-package-policy.test.ts +++ b/scripts/layering/platform-package-policy.test.ts @@ -39,7 +39,6 @@ function declarations(): PlatformPackageDeclaration[] { '@agent-device/platform-apple/runner-owner', '@agent-device/platform-apple/session-observation', '@agent-device/platform-apple/simctl', - '@agent-device/platform-apple/simctl-settings', '@agent-device/platform-apple/snapshot-source', '@agent-device/platform-apple/simulator', '@agent-device/platform-apple/simulator-boot', diff --git a/scripts/layering/platform-package-policy.ts b/scripts/layering/platform-package-policy.ts index e6ad20741a..c3dfcd0dfd 100644 --- a/scripts/layering/platform-package-policy.ts +++ b/scripts/layering/platform-package-policy.ts @@ -100,7 +100,6 @@ const MECHANICS_FACET_SUBPATHS: Readonly&2 exit 1 fi +# Swift Testing loads overlays at runtime; keep them from the same toolchain as its bundled framework. +if [ -d "$STAGED_APP_PATH/Frameworks/Testing.framework" ]; then + for FRAMEWORK in "$PLATFORM_PATH"/Developer/Library/Frameworks/_Testing_*.framework; do + if [ -d "$FRAMEWORK" ]; then + ditto "$FRAMEWORK" "$STAGED_APP_PATH/Frameworks/$(basename "$FRAMEWORK")" + fi + done +fi rm -f "$ARCHIVE_PATH" ( cd "$STAGE_DIR" diff --git a/src/__tests__/limrun-runtime.test.ts b/src/__tests__/limrun-runtime.test.ts index ed9f85793f..d82497f23a 100644 --- a/src/__tests__/limrun-runtime.test.ts +++ b/src/__tests__/limrun-runtime.test.ts @@ -29,9 +29,9 @@ const limrunMockState = vi.hoisted(() => { bundleId: 'com.example.ios', url: 'https://assets.example/app', })), + iosSimctl: vi.fn(), iosLaunchApp: vi.fn(async () => undefined), iosOpenUrl: vi.fn(async () => undefined), - iosSetOrientation: vi.fn(async () => undefined), iosListApps: vi.fn(async () => [ { bundleId: 'com.apple.Preferences', name: 'Settings', installType: 'System' }, { bundleId: 'com.example.ios', name: 'Example', installType: 'User' }, @@ -94,22 +94,36 @@ vi.mock('@limrun/api', () => ({ }, })); -vi.mock('@limrun/api/ios-client', () => ({ - createInstanceClient: vi.fn(async () => ({ - disconnect: vi.fn(), - installApp: limrunMockState.iosInstallApp, - launchApp: limrunMockState.iosLaunchApp, - openUrl: limrunMockState.iosOpenUrl, - setOrientation: limrunMockState.iosSetOrientation, - listApps: limrunMockState.iosListApps, - deviceInfo: { - udid: 'ios-device', - screenWidth: 402, - screenHeight: 874, - model: 'iPhone', - }, - })), -})); +vi.mock('@limrun/api/ios-client', async (importOriginal) => { + const actual = await importOriginal(); + limrunMockState.iosSimctl.mockImplementation((args: string[]) => { + const execution = new actual.SimctlExecution(() => {}); + if (args[0] === 'list') + execution._handleStdout( + Buffer.from( + JSON.stringify({ devices: { ios: [{ udid: 'ios-device', state: 'Booted' }] } }), + ), + ); + execution._handleExit(0); + return execution; + }); + return { + createInstanceClient: vi.fn(async () => ({ + disconnect: vi.fn(), + installApp: limrunMockState.iosInstallApp, + launchApp: limrunMockState.iosLaunchApp, + openUrl: limrunMockState.iosOpenUrl, + listApps: limrunMockState.iosListApps, + simctl: limrunMockState.iosSimctl, + deviceInfo: { + udid: 'ios-device', + screenWidth: 402, + screenHeight: 874, + model: 'iPhone', + }, + })), + }; +}); vi.mock('@limrun/api/instance-client', () => ({ createInstanceClient: vi.fn(async () => ({ @@ -212,6 +226,18 @@ test('Limrun refuses a refused field on a repeat allocation of its live lease', test('Limrun iOS uses shared deep-link classification', async () => { const runtime = new LimrunRuntime({ apiKey: 'lim_test_key' }); + const metadata = new Map([ + ['com.apple.Preferences', ''], + [ + 'com.example.ios', + 'CFBundleURLTypesCFBundleURLSchemesexample', + ], + ]); + const fetchMetadata = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const bundleId = new URL(String(input)).searchParams.get('bundleId'); + const plist = metadata.get(bundleId ?? ''); + return new Response(plist ?? '', { status: plist ? 200 : 404 }); + }); try { const device = await allocateLimrunDevice(runtime, { @@ -222,13 +248,25 @@ test('Limrun iOS uses shared deep-link classification', async () => { const interactor = runtime.getInteractor(device); if (!interactor) throw new Error('Limrun runtime must return an interactor'); - await interactor.open('http:malformed'); + await assert.rejects(interactor.open('http:malformed'), { code: 'APP_NOT_INSTALLED' }); await interactor.open('example://screen'); - assert.deepEqual(limrunMockState.iosLaunchApp.mock.calls, [['http:malformed']]); - assert.deepEqual(limrunMockState.iosOpenUrl.mock.calls, [['example://screen']]); + assert.deepEqual(fetchMetadata.mock.calls.map(([input]) => String(input)).sort(), [ + 'https://ios.example/files?name=Info.plist&bundleId=com.apple.Preferences&containerType=app', + 'https://ios.example/files?name=Info.plist&bundleId=com.example.ios&containerType=app', + ]); + assert.deepEqual(limrunMockState.iosLaunchApp.mock.calls, []); + assert.deepEqual(limrunMockState.iosOpenUrl.mock.calls, []); + assert.deepEqual(limrunMockState.iosSimctl.mock.calls, [ + [['list', 'devices', '-j'], undefined], + [['openurl', 'booted', 'example://screen'], undefined], + ]); } finally { - await runtime.shutdown(); + try { + await runtime.shutdown(); + } finally { + fetchMetadata.mockRestore(); + } } }); @@ -523,7 +561,7 @@ test('Limrun iOS installs direct local artifacts through Limrun assets', async ( }); assert.deepEqual(limrunMockState.iosInstallApp.mock.calls[0], [ 'https://assets.example/app', - { md5: 'asset-md5', launchMode: 'RelaunchIfRunning' }, + { launchMode: 'RelaunchIfRunning' }, ]); assert.deepEqual(result, { bundleId: 'com.example.ios', @@ -570,32 +608,6 @@ test('Limrun iOS reads the bundle display name before uploading an app bundle', } }); -test('Limrun iOS maps supported orientation and rejects unsupported upside-down orientation', async () => { - const runtime = new LimrunRuntime({ - apiKey: 'lim_test_key', - }); - - try { - const device = await allocateLimrunDevice(runtime, { - ...androidLease(), - leaseId: 'lease-ios-orientation', - backend: 'ios-instance', - }); - const interactor = runtime.getInteractor(device); - if (!interactor) throw new Error('Limrun runtime must return an interactor'); - - await interactor.setOrientation('landscape-left'); - await assert.rejects( - () => interactor.setOrientation('portrait-upside-down'), - /not portrait upside-down/, - ); - - assert.deepEqual(limrunMockState.iosSetOrientation.mock.calls, [['Landscape']]); - } finally { - await runtime.shutdown(); - } -}); - test('Limrun Android configures an explicit port reverse', async () => { const runtime = new LimrunRuntime({ apiKey: 'lim_test_key', diff --git a/src/commands/schema/cli-help-first-screen.test.ts b/src/commands/schema/cli-help-first-screen.test.ts index c28c43bcb8..d757b3e408 100644 --- a/src/commands/schema/cli-help-first-screen.test.ts +++ b/src/commands/schema/cli-help-first-screen.test.ts @@ -65,7 +65,7 @@ const FIRST_SCREEN_RULES: Record = { debugging: [ 'Do not cat a full stale log into agent context', 'logs clear --restart is the compact command to clear old logs and start a fresh capture; do not split it into logs stop, logs clear, logs start', - '--launch-console is only for local iOS simulator app launches, not URL opens', + '--launch-console is only for local or Limrun iOS simulator app launches, not URL opens', 'network log is a supported alias, but network dump --include headers is the clearest plan form. Do not write network log headers', ], 'react-devtools': [ diff --git a/src/commands/schema/cli-help-workflows.test.ts b/src/commands/schema/cli-help-workflows.test.ts index d40247f20c..926a25e8cf 100644 --- a/src/commands/schema/cli-help-workflows.test.ts +++ b/src/commands/schema/cli-help-workflows.test.ts @@ -16,7 +16,7 @@ test('usageForCommand resolves debugging help topic', async () => { assert.match(help, /agent-device alert wait 3000/); assert.match(help, /iOS support is runner-derived/); assert.match(help, /resolved app executable/); - assert.match(help, /--launch-console is only for local iOS simulator app launches/); + assert.match(help, /--launch-console is only for local or Limrun iOS simulator app launches/); assert.match(help, /runnerLogPath and requestLogPath/); assert.match( help, diff --git a/src/commands/schema/cli-help-workflows.ts b/src/commands/schema/cli-help-workflows.ts index 469eceea12..5d7b7fa7ce 100644 --- a/src/commands/schema/cli-help-workflows.ts +++ b/src/commands/schema/cli-help-workflows.ts @@ -80,7 +80,7 @@ Logs: On iOS physical devices, logs clear --restart relaunches the session app through devicectl process launch --console so stdout/stderr can be captured. For iOS simulator launch-time stdout/stderr, use --launch-console on the direct app launch: agent-device open MyApp --platform ios --relaunch --launch-console ./artifacts/app.console.log - --launch-console is only for local iOS simulator app launches, not URL opens. + --launch-console is only for local or Limrun iOS simulator app launches, not URL opens. Events: Use events for a compact session timeline without dumping full app logs. diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index cb178f6527..2fd5b21220 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -103,7 +103,7 @@ Selectors: Text entry: fill replaces; type appends to an already-focused field: fill 'id="field-email"' "qa@example.com"; type "Handle with care" --delay-ms 80 fill "" clears the field (replace with nothing); the empty argument must be present -- fill alone is a missing argument. - For dropped iOS text, retry with --delay-ms before clipboard paste. Limrun iOS: fill ignores --delay-ms; type supports delayed key events. + For dropped iOS text, retry with --delay-ms before clipboard paste. This also applies to Limrun iOS, which uses the same Apple runner. Element presses behind the keyboard are refused with tap_keyboard_occludes_target. keyboard dismiss taps its own dismiss key when one exists, else UNSUPPORTED_OPERATION. Android: try dismiss before back. iOS: when both fail, do not tap a static text/heading hoping it is safe; prefer type "\\n" to submit. iOS paste-prompt limits and Android IME/handwriting capture quirks: help debugging. diff --git a/src/core/interactors/limrun-ios-app-logs.test.ts b/src/core/interactors/limrun-ios-app-logs.test.ts new file mode 100644 index 0000000000..068385489b --- /dev/null +++ b/src/core/interactors/limrun-ios-app-logs.test.ts @@ -0,0 +1,136 @@ +import { EventEmitter } from 'node:events'; +import { expect, test, vi } from 'vitest'; +import type { InstanceClient, LogStream } from '@limrun/api/ios-client'; +import { fixture } from './limrun-ios-runner.fixtures.ts'; +import { createLimrunRuntimeDependencies } from '../../sdk/limrun-runtime-dependencies.ts'; + +const plist = Buffer.from( + 'CFBundleExecutableExample', +); +const record = (process: string, subsystem: string, text: string) => + `2026-11-02 12:00:00.000001 Info ${process}[42:0x123] (${subsystem}) [test] ${text}`; +async function logFixture(options: { holdFileDownload?: boolean; failFileStatus?: number } = {}) { + const f = await fixture({ + containers: { 'com.example.app': { app: { 'Info.plist': plist } } }, + ...options, + ...(options.failFileStatus ? { failFileMethod: 'GET' } : {}), + }); + const streams: LogStream[] = []; + const stream = () => { + const emitter = new EventEmitter(); + const value = Object.assign(emitter, { + stop: vi.fn(() => emitter.emit('close')), + }) as unknown as LogStream; + streams.push(value); + return value; + }; + const client = { streamAppLog: stream, streamSyslog: stream } as unknown as InstanceClient; + const dependencies = createLimrunRuntimeDependencies({ env: {}, cwd: f.directory }); + const reader = dependencies.ios.createAppLogReader({ ...f.session, client }); + const read = () => reader.readLogs('com.example.app', 100, new AbortController().signal); + return { reader, read, streams, storage: f.storage }; +} + +test('app logs retain repeated console lines and matching syslog without matching message text', async () => { + const f = await logFixture(); + try { + await f.read(); + f.streams[0]!.emit('line', 'same stdout'); + f.streams[0]!.emit('line', 'same stdout'); + const processLine = record('Example', 'framework', 'process output'); + const subsystemLine = record('other', 'com.example.app.service', 'subsystem output'); + f.streams[1]!.emit('line', processLine); + f.streams[1]!.emit('line', subsystemLine); + f.streams[1]!.emit('line', record('other', 'unrelated', 'Example com.example.app')); + f.streams[1]!.emit('line', record('other', 'com.example.application', 'wrong prefix')); + f.streams[1]!.emit('line', record('other', 'org.com.example.app', 'wrong namespace')); + await vi.waitFor(async () => + expect(await f.read()).toBe( + ['same stdout', 'same stdout', processLine, subsystemLine].join('\n'), + ), + ); + } finally { + await f.reader[Symbol.asyncDispose](); + } + expect(f.streams.every((stream) => vi.mocked(stream.stop).mock.calls.length === 1)).toBe(true); +}); + +test('broken streams report failure, close both sockets and reopen on the following poll', async () => { + const f = await logFixture(); + try { + await f.read(); + const error = new Error('transport lost'); + f.streams[1]!.emit('error', error); + await expect(f.read()).rejects.toBe(error); + expect(f.streams.every((stream) => vi.mocked(stream.stop).mock.calls.length === 1)).toBe(true); + await f.read(); + expect(f.streams).toHaveLength(4); + f.streams[2]!.emit('line', 'reconnected'); + expect(await f.read()).toBe('reconnected'); + } finally { + await f.reader[Symbol.asyncDispose](); + } +}); + +test('the log tail bounds retained bytes and disposal refuses further reads', async () => { + const f = await logFixture(); + await f.read(); + f.streams[0]!.emit('line', 'a'.repeat(1024 * 1024)); + f.streams[0]!.emit('line', 'tail'); + expect(await f.read()).toBe('tail'); + await f.reader[Symbol.asyncDispose](); + await expect(f.read()).rejects.toMatchObject({ name: 'AbortError' }); + expect(f.streams.every((stream) => vi.mocked(stream.stop).mock.calls.length === 1)).toBe(true); +}); + +test('slow app metadata does not delay polls and survives completion of an individual poll', async () => { + const f = await logFixture({ holdFileDownload: true }); + const poll = new AbortController(); + let completed = false; + const firstRead = f.reader.readLogs('com.example.app', 100, poll.signal).then(() => { + completed = true; + }); + try { + await vi.waitFor(() => + expect(completed, 'the poll must finish while the metadata response is held').toBe(true), + ); + await firstRead; + poll.abort(); + f.streams[0]!.emit('line', 'console while metadata loads'); + const systemLine = record('Example', 'framework', 'buffered process output'); + f.streams[1]!.emit('line', systemLine); + expect(await f.read()).toBe('console while metadata loads'); + await vi.waitFor(() => + expect(f.storage.calls.some((call) => call.method === 'GET')).toBe(true), + ); + f.storage.finishDownloads(); + await vi.waitFor(async () => + expect(await f.read()).toBe(['console while metadata loads', systemLine].join('\n')), + ); + } finally { + poll.abort(); + f.storage.finishDownloads(); + await firstRead.catch(() => {}); + await f.reader[Symbol.asyncDispose](); + } +}); + +test.each([401, 500])( + 'log metadata HTTP %s remains visible to the shared recovery policy', + async (status) => { + const f = await logFixture({ failFileStatus: status }); + try { + await vi.waitFor(async () => { + await expect(f.read()).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { statusCode: status }, + }); + }); + expect(f.streams.every((stream) => vi.mocked(stream.stop).mock.calls.length === 1)).toBe( + true, + ); + } finally { + await f.reader[Symbol.asyncDispose](); + } + }, +); diff --git a/src/core/interactors/limrun-ios-app-logs.ts b/src/core/interactors/limrun-ios-app-logs.ts new file mode 100644 index 0000000000..c3cb3183c9 --- /dev/null +++ b/src/core/interactors/limrun-ios-app-logs.ts @@ -0,0 +1,137 @@ +import type { LimrunRuntimeDependencies } from '@agent-device/provider-limrun'; +import type { ProviderAppLogReader } from '@agent-device/capture-kit/provider-app-log'; +import type { LogStream } from '@limrun/api/ios-client'; + +const MAX_LOG_BYTES = 1024 * 1024; + +/** Keeps app console and filtered unified logs in one bounded tail for the shared log collector. */ +export function createLimrunIosAppLogReader( + input: Parameters[0], +): ProviderAppLogReader { + const lifetime = new AbortController(); + let active: { app: string; controller: AbortController; streams: LogStream[] } | undefined; + const metadataReads = new Set>(); + let activeApp: string | undefined; + let failure: Error | undefined; + const lines: string[] = []; + let bytes = 0; + const append = (line: string) => { + lines.push(line); + bytes += Buffer.byteLength(line) + 1; + while (bytes > MAX_LOG_BYTES && lines.length) bytes -= Buffer.byteLength(lines.shift()!) + 1; + }; + const stop = () => { + const owned = active; + active = undefined; + owned?.controller.abort(); + for (const stream of owned?.streams ?? []) stream.stop(); + }; + const start = (app: string) => { + const owned = { app, controller: new AbortController(), streams: [] as LogStream[] }; + active = owned; + const signal = AbortSignal.any([lifetime.signal, owned.controller.signal]); + const consoleStream = input.client.streamAppLog(app); + owned.streams.push(consoleStream); + const systemStream = input.client.streamSyslog(); + owned.streams.push(systemStream); + let executable: string | undefined; + let metadataReady = false; + const pendingLines: string[] = []; + let pendingBytes = 0; + consoleStream.on('line', (line) => { + if (active === owned) append(line); + }); + systemStream.on('line', (line) => { + if (active !== owned) return; + if (metadataReady) { + if (matchesAppleLogTarget(line, app, executable)) append(line); + } else { + pendingLines.push(line); + pendingBytes += Buffer.byteLength(line) + 1; + while (pendingBytes > MAX_LOG_BYTES && pendingLines.length) + pendingBytes -= Buffer.byteLength(pendingLines.shift()!) + 1; + } + }); + for (const stream of owned.streams) { + stream.on('error', (error) => { + if (active === owned) failure = error; + }); + stream.on('close', () => { + if (!lifetime.signal.aborted && active === owned) + failure = new Error('Limrun log stream closed'); + }); + } + // Metadata can outlive a short poll, but never the app's streams or the reader. + const pending = (async () => { + try { + const { readLimrunAppInfo } = await import('./limrun-ios-files.ts'); + const info = await readLimrunAppInfo(input, app, signal); + if (active !== owned) return; + executable = + typeof info?.CFBundleExecutable === 'string' ? info.CFBundleExecutable : undefined; + metadataReady = true; + for (const line of pendingLines) + if (matchesAppleLogTarget(line, app, executable)) append(line); + } catch (error) { + if (!signal.aborted && active === owned) + failure = error instanceof Error ? error : new Error(String(error)); + } finally { + pendingLines.length = 0; + } + })(); + metadataReads.add(pending); + void pending.finally(() => metadataReads.delete(pending)); + }; + return { + readLogs: async (app, limit, signal) => { + const bounded = AbortSignal.any([lifetime.signal, signal]); + bounded.throwIfAborted(); + if (failure) { + const error = failure; + failure = undefined; + stop(); + throw error; + } + if (active?.app !== app) { + stop(); + if (activeApp !== app) { + lines.length = 0; + bytes = 0; + activeApp = app; + } + try { + start(app); + } catch (error) { + stop(); + throw error; + } + } + return lines.slice(-limit).join('\n'); + }, + [Symbol.asyncDispose]: async () => { + lifetime.abort(); + stop(); + await Promise.all(metadataReads); + }, + }; +} + +/** Matches the fields retained by a formatted simulator log stream. */ +function matchesAppleLogTarget( + line: string, + appBundleId: string, + executableName?: string, +): boolean { + const record = + /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+ \S+ (.+?)\[\d+(?::[^\]]+)?\](?: \(([^)]*)\))?/.exec( + line, + ); + if (!record) return false; + const [, process, subsystem] = record; + return ( + subsystem === appBundleId || + subsystem?.startsWith(`${appBundleId}.`) === true || + process === executableName || + process?.toLowerCase() === appBundleId.toLowerCase() + ); +} diff --git a/src/core/interactors/limrun-ios-application-tools.ts b/src/core/interactors/limrun-ios-application-tools.ts new file mode 100644 index 0000000000..1646047ebd --- /dev/null +++ b/src/core/interactors/limrun-ios-application-tools.ts @@ -0,0 +1,107 @@ +import type { AppleApplicationTools } from '@agent-device/contracts/application-lifecycle-runtime'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; + +type RunnerLifecycle = { + prepareCache(signal?: AbortSignal): Promise; + prewarm( + execution: Parameters[1], + signal: AbortSignal, + options?: Parameters[4], + ): Promise; + release(retain: boolean): Promise; + prepare: AppleApplicationTools['prepareRunner']; + resetTarget(signal?: AbortSignal): Promise; + isReady(): boolean; + isStarted(): boolean; + stop(): Promise; + releaseSpeculative(): Promise; +}; + +/** Adapts the lease-owned XCTest run to Apple's shared lifecycle ordering. */ +export function createLimrunAppleApplicationTools( + device: DeviceInfo, + runner: RunnerLifecycle, + hostTools: Pick< + AppleApplicationTools, + 'resolveOpenTarget' | 'applyRuntimeHints' | 'clearRuntimeHints' + >, +): AppleApplicationTools { + const requireDevice = (target: DeviceInfo | string) => { + if ((typeof target === 'string' ? target : target.id) !== device.id) + throw new AppError( + 'DEVICE_NOT_FOUND', + 'Apple lifecycle tools belong to a different Limrun lease', + ); + }; + return { + resolveOpenTarget: async (target, input) => { + requireDevice(target); + return await hostTools.resolveOpenTarget(target, input); + }, + prewarmRunnerCache: async (target, _execution, signal) => { + requireDevice(target); + await runner.prepareCache(signal); + }, + prewarmRunnerSession: async (target, execution, signal, propagateError, options) => { + requireDevice(target); + try { + await runner.prewarm(execution, signal, options); + } catch (error) { + if (propagateError) throw error; + emitDiagnostic({ + level: 'warn', + phase: 'limrun.runner.prewarm_failed', + data: { error: String(error) }, + }); + } + }, + notifyRunnerAppRelaunched: async (target, _execution, signal) => { + requireDevice(target); + await runner.resetTarget(signal); + }, + stopRunnerSession: async (id) => { + requireDevice(id); + await runner.stop(); + }, + hasLiveRunnerSession: async (target) => { + requireDevice(target); + return runner.isReady(); + }, + releaseSpeculativeRunner: async (target) => { + requireDevice(target); + return await runner.releaseSpeculative(); + }, + releaseRunnerOnClose: async (id, options) => { + requireDevice(id); + await runner.release(options.retain); + }, + prepareRunner: async (target, input, signal) => { + requireDevice(target); + return await runner.prepare(target, input, signal); + }, + applyRuntimeHints: async (target, input) => { + requireDevice(target); + await hostTools.applyRuntimeHints(target, input); + }, + clearRuntimeHints: async (target, input) => { + requireDevice(target); + await hostTools.clearRuntimeHints(target, input); + }, + // Local Apple close-alert cleanup applies only to macOS, never iOS simulators. + dismissCloseAlerts: async (target) => { + requireDevice(target); + }, + detachRunnerSessionsForShutdown: async () => { + // The managed XCTest stream cannot be adopted by another daemon. + await runner.stop(); + return []; + }, + finalizeRunnerSessionsForShutdown: async () => { + const started = runner.isStarted(); + await runner.stop(); + return started ? [device.id] : []; + }, + }; +} diff --git a/src/core/interactors/limrun-ios-files.fixtures.ts b/src/core/interactors/limrun-ios-files.fixtures.ts new file mode 100644 index 0000000000..cf70ffe1f0 --- /dev/null +++ b/src/core/interactors/limrun-ios-files.fixtures.ts @@ -0,0 +1,92 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; + +export type FileStoreOptions = { + files?: Record; + containers?: Record>>; + failFileMethod?: string; + failFileStatus?: number; + holdFileDownload?: boolean; + onFileDownload?: () => void; +}; + +export function fileStore(options: FileStoreOptions) { + const files = new Map(); + for (const [name, body] of Object.entries(options.files ?? {})) + files.set(`staging/${name}`, body); + for (const [bundleId, types] of Object.entries(options.containers ?? {})) + for (const [type, entries] of Object.entries(types)) + for (const [name, body] of Object.entries(entries)) + files.set(`${bundleId}/${type}/${name}`, body); + const directories = new Set(); + const calls: Array<{ + method: string; + name: string; + bundleId: string | null; + containerType: string | null; + body: Buffer; + }> = []; + const put = (root: string, name: string, body: Buffer) => { + files.set(root + name, body); + for ( + let parent = name.includes('/') ? name.slice(0, name.lastIndexOf('/')) : ''; + parent; + parent = parent.includes('/') ? parent.slice(0, parent.lastIndexOf('/')) : '' + ) + directories.add(root + parent); + }; + const remove = (name: string) => { + for (const key of files.keys()) + if (key === name || key.startsWith(`${name}/`)) files.delete(key); + for (const key of directories) + if (key === name || key.startsWith(`${name}/`)) directories.delete(key); + }; + const downloads: Array<() => void> = []; + const serve = (request: IncomingMessage, response: ServerResponse, body: Buffer): boolean => { + const url = new URL(request.url!, 'http://fixture'); + if (!['/v1/ios_test/api/files', '/v1/ios_test/api/files/list'].includes(url.pathname)) + return false; + const name = url.searchParams.get('name') ?? ''; + const bundleId = url.searchParams.get('bundleId'); + const containerType = url.searchParams.get('containerType'); + const root = bundleId ? `${bundleId}/${containerType}/` : 'staging/'; + const method = request.method!; + calls.push({ method, name, bundleId, containerType, body }); + if (method === options.failFileMethod) { + response.writeHead(options.failFileStatus ?? 500).end('file operation failed'); + return true; + } + if (url.pathname.endsWith('/list')) { + const names = new Set( + [...files.keys(), ...directories] + .filter((key) => key.startsWith(root)) + .map((key) => key.slice(root.length).split('/')[0]!), + ); + response.end(JSON.stringify({ entries: [...names].map((name) => ({ name, path: name })) })); + } else if (method === 'PUT') { + put(root, name, body); + response.end(JSON.stringify({ path: `/staging/${name}` })); + } else if (method === 'DELETE') { + remove(root + name); + response.end(JSON.stringify({ ok: true })); + } else { + const bytes = files.get(root + name); + if (!bytes) { + response.writeHead(404).end('File not found'); + return true; + } + response.writeHead(200, { 'Content-Type': 'application/octet-stream' }); + response.write(bytes); + options.onFileDownload?.(); + if (options.holdFileDownload) downloads.push(() => response.end()); + else response.end(); + } + return true; + }; + return { + files, + directories, + calls, + serve, + finishDownloads: () => downloads.splice(0).forEach((finish) => finish()), + }; +} diff --git a/src/core/interactors/limrun-ios-files.test.ts b/src/core/interactors/limrun-ios-files.test.ts new file mode 100644 index 0000000000..e22ccd0389 --- /dev/null +++ b/src/core/interactors/limrun-ios-files.test.ts @@ -0,0 +1,359 @@ +import { expect, test, vi } from 'vitest'; +import { fixture } from './limrun-ios-runner.fixtures.ts'; +import { createLimrunAppleToolProvider } from './limrun-ios-tools.ts'; +import { withAppleToolProvider } from '@agent-device/platform-apple/tool-provider'; +import { resolveIosSimulatorDeepLinkBundleId } from '@agent-device/platform-apple/app-resolution'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; + +const plist = (scheme: string) => + Buffer.from( + `CFBundleExecutableExampleCFBundleURLTypesCFBundleURLSchemes${scheme}`, + ); + +test('cached app metadata rejects an already canceled caller', async () => { + const f = await fixture({ + containers: { 'com.example.app': { app: { 'Info.plist': plist('example') } } }, + }); + const tools = createLimrunAppleToolProvider(f.session, new AbortController().signal); + const input = { device: f.device, bundleId: 'com.example.app', timeoutMs: 1000 }; + await tools.simulatorApps.readInfoPlist(input); + const controller = new AbortController(); + const reason = new Error('canceled'); + controller.abort(reason); + await expect( + tools.simulatorApps.readInfoPlist({ ...input, signal: controller.signal }), + ).rejects.toBe(reason); +}); + +test('remote app metadata resolves Expo and deep-link ownership and rejects duplicate claims', async () => { + const f = await fixture({ + containers: { + 'com.example.app': { app: { 'Info.plist': plist('exp+example') } }, + 'com.example.other': { app: { 'Info.plist': plist('exp+example') } }, + }, + }); + const listApps = vi.fn(async () => [ + { bundleId: 'com.example.app', name: 'Example', installType: 'User' }, + ]); + f.session.client.listApps = listApps; + const provider = () => createLimrunAppleToolProvider(f.session, new AbortController().signal); + await withAppleToolProvider(provider(), async () => { + expect(await resolveIosSimulatorDeepLinkBundleId(f.device, 'exp+example://route')).toBe( + 'com.example.app', + ); + }); + listApps.mockResolvedValue([ + { bundleId: 'com.example.app', name: 'Example', installType: 'User' }, + { bundleId: 'com.example.other', name: 'Other', installType: 'User' }, + ]); + await withAppleToolProvider(provider(), async () => { + expect( + await resolveIosSimulatorDeepLinkBundleId(f.device, 'exp+example://route'), + ).toBeUndefined(); + }); + expect(f.storage.calls.every((call) => call.containerType === 'app')).toBe(true); +}); + +test('clear state uses the shared reset and preserves container identity without launching', async () => { + const metadata = '.com.apple.mobile_container_manager.metadata.plist'; + const f = await fixture({ + containers: { + 'com.example.app': { + data: { + [metadata]: Buffer.from('identity'), + 'Documents/state': Buffer.from('old'), + 'Library/Preferences/state': Buffer.from('old'), + }, + }, + 'com.example.other': { data: { 'Documents/state': Buffer.from('other app') } }, + }, + }); + f.session.client.listApps = async () => [ + { bundleId: 'com.example.app', name: 'Example', installType: 'User' }, + ]; + await withAppleToolProvider( + createLimrunAppleToolProvider(f.session, new AbortController().signal), + async () => { + await f.runner.createInteractor().setSetting('clear-app-state', 'clear', 'com.example.app'); + }, + ); + expect([...f.storage.files.entries()]).toEqual([ + [`com.example.app/data/${metadata}`, Buffer.from('identity')], + ['com.example.other/data/Documents/state', Buffer.from('other app')], + ]); + for (const name of ['Documents', 'Library/Caches', 'Library/Preferences', 'SystemData', 'tmp']) + expect(f.storage.directories.has(`com.example.app/data/${name}`)).toBe(true); + const calls = vi.mocked(f.session.client.simctl).mock.calls.map((call) => call[0]); + expect(calls.some((args) => args[0] === 'terminate')).toBe(true); + expect(calls.some((args) => args[0] === 'launch')).toBe(false); + expect( + f.storage.calls.every( + (call) => call.bundleId === 'com.example.app' && call.containerType === 'data', + ), + ).toBe(true); +}); + +test.each([false, true])( + 'push stages the original payload and cleans up, tool failure=%s', + async (fail) => { + const f = await fixture({ + simctlResult: { code: fail ? 1 : 0, stdout: '', stderr: fail ? 'push rejected' : '' }, + }); + const payloadPath = path.join(f.directory, 'payload.apns'); + const payload = { aps: { alert: 'hello' }, custom: 'value' }; + const { createAppleToolHost } = await import('../../platform-runtime-apple-tool-host.ts'); + const host = { + deviceReadiness: { + appleAutomation: { + wasRecentlyObservedBooted: async () => true, + keepHot: () => {}, + markBooted: () => {}, + }, + }, + temporaryFiles: { + create: async () => ({ + path: payloadPath, + writeText: (text: string) => fs.writeFile(payloadPath, text), + [Symbol.asyncDispose]: async () => fs.rm(payloadPath), + }), + }, + appleTools: createAppleToolHost(), + } as unknown as PlatformRuntimeHost; + // The real runner binding scopes the host tools and the staged file to the same instance. + const operations = f.runner.bindDeployment({ + host, + signal: new AbortController().signal, + install: async () => ({}), + }); + const send = operations.sendPushNotification!; + const sent = send({ appId: 'com.example.app', payload }); + if (fail) await expect(sent).rejects.toMatchObject({ code: 'COMMAND_FAILED' }); + else await sent; + const upload = f.storage.calls.find((call) => call.method === 'PUT')!; + expect(JSON.parse(upload.body.toString())).toEqual(payload); + expect(vi.mocked(f.session.client.simctl).mock.calls[0]?.[0]).toEqual([ + 'push', + 'booted', + 'com.example.app', + `/staging/${upload.name}`, + ]); + expect(f.storage.files.has(`staging/${upload.name}`)).toBe(false); + expect( + f.storage.calls.some((call) => call.method === 'DELETE' && call.name === upload.name), + ).toBe(true); + }, +); + +test('binary installed metadata uses shared Expo launch hints and explicit URL precedence', async () => { + const f = await fixture({ + containers: { + 'com.example.app': { + app: { + 'Info.plist': Buffer.from( + 'YnBsaXN0MDDSAQIDBF8QEkNGQnVuZGxlRXhlY3V0YWJsZV8QEENGQnVuZGxlVVJMVHlwZXNXRXhhbXBsZaEF0QYHXxASQ0ZCdW5kbGVVUkxTY2hlbWVzoQhbZXhwK2V4YW1wbGUIDSI1PT9CV1kAAAAAAAABAQAAAAAAAAAJAAAAAAAAAAAAAAAAAAAAZQ==', + 'base64', + ), + }, + }, + }, + }); + f.session.client.listApps = async () => [ + { bundleId: 'com.example.app', name: 'Example', installType: 'User' }, + ]; + const lifecycle = f.runner.bindApplicationLifecycle({ + signal: new AbortController().signal, + clock: { now: Date.now, sleep: async () => {} }, + resolveInteractor: async (_device, context) => f.runner.createInteractor(context), + }); + const input = { + target: 'com.example.app', + positionals: ['com.example.app'], + appBundleId: 'com.example.app', + surface: 'app' as const, + hasExistingSession: true, + relaunch: false, + prewarmRunnerBeforeOpen: false, + enableTestIme: false, + stateDir: f.directory, + runtimeHints: { metroHost: 'localhost', metroPort: '8081' }, + execution: {}, + applyRuntimeHints: async () => {}, + }; + expect(await lifecycle.openApplication(input)).toMatchObject({ appBundleId: 'com.example.app' }); + expect(vi.mocked(f.session.client.simctl).mock.calls.map(([args]) => args)).toContainEqual([ + 'openurl', + 'booted', + 'exp+example://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081', + ]); + await lifecycle.openApplication({ ...input, runtimeLaunchUrl: 'custom://explicit' }); + const urls = vi + .mocked(f.session.client.simctl) + .mock.calls.map(([args]) => args) + .filter((args) => args[0] === 'openurl'); + expect(urls.at(-1)).toEqual(['openurl', 'booted', 'custom://explicit']); +}); + +test.each([ + { state: 'clear', app: undefined }, + { state: 'wipe', app: 'com.example.app' }, +])('clear state rejects invalid input before modifying files: %j', async ({ state, app }) => { + const f = await fixture(); + await withAppleToolProvider( + createLimrunAppleToolProvider(f.session, new AbortController().signal), + async () => { + await expect( + f.runner.createInteractor().setSetting('clear-app-state', state, app), + ).rejects.toMatchObject({ code: 'INVALID_ARGS' }); + }, + ); + expect(f.storage.calls).toEqual([]); +}); + +test('staged payload cleanup survives cancellation of its command', async () => { + const f = await fixture(); + const cancellation = new AbortController(); + const provider = createLimrunAppleToolProvider( + f.session, + new AbortController().signal, + cancellation.signal, + ); + const payload = path.join(f.directory, 'canceled.apns'); + await fs.writeFile(payload, '{}'); + const staged = await provider.simulatorFiles!.stageFile!(payload, cancellation.signal); + cancellation.abort(new Error('cancel')); + await staged[Symbol.asyncDispose](); + expect(f.storage.files.size).toBe(0); +}); + +test('clear state reports file deletion failure without relaunching or recreating directories', async () => { + const f = await fixture({ + containers: { 'com.example.app': { data: { 'Documents/state': Buffer.from('old') } } }, + failFileMethod: 'DELETE', + }); + f.session.client.listApps = async () => [ + { bundleId: 'com.example.app', name: 'Example', installType: 'User' }, + ]; + await withAppleToolProvider( + createLimrunAppleToolProvider(f.session, new AbortController().signal), + async () => { + await expect( + f.runner.createInteractor().setSetting('clear-app-state', 'clear', 'com.example.app'), + ).rejects.toMatchObject({ code: 'COMMAND_FAILED', details: { status: 500 } }); + }, + ); + expect(f.storage.calls.some((call) => call.method === 'PUT')).toBe(false); + expect( + vi.mocked(f.session.client.simctl).mock.calls.some((call) => call[0][0] === 'launch'), + ).toBe(false); +}); + +test('clear state reports the shared no-data-container result without touching files', async () => { + const f = await fixture({ simctlResult: { code: 0, stdout: '(null)\n', stderr: '' } }); + await withAppleToolProvider( + createLimrunAppleToolProvider(f.session, new AbortController().signal), + async () => { + await expect( + f.runner.createInteractor().setSetting('clear-app-state', 'clear', 'com.apple.Preferences'), + ).rejects.toMatchObject({ + code: 'UNSUPPORTED_OPERATION', + details: { reason: 'app-no-data-container', appBundleId: 'com.apple.Preferences' }, + }); + }, + ); + expect(f.storage.calls).toEqual([]); +}); + +test('deep-link ownership ignores missing metadata for another installed app', async () => { + const f = await fixture({ + containers: { 'com.example.app': { app: { 'Info.plist': plist('exp+example') } } }, + }); + f.session.client.listApps = async () => [ + { bundleId: 'com.example.absent', name: 'Missing metadata', installType: 'User' }, + { bundleId: 'com.example.app', name: 'Example', installType: 'User' }, + ]; + await withAppleToolProvider( + createLimrunAppleToolProvider(f.session, new AbortController().signal), + async () => { + expect(await resolveIosSimulatorDeepLinkBundleId(f.device, 'exp+example://route')).toBe( + 'com.example.app', + ); + }, + ); +}); + +test.each([401, 500])('app metadata HTTP %s is not treated as an absent plist', async (status) => { + const f = await fixture({ failFileMethod: 'GET', failFileStatus: status }); + const tools = createLimrunAppleToolProvider(f.session, new AbortController().signal); + await expect( + tools.simulatorApps.readInfoPlist({ + device: f.device, + bundleId: 'com.example.app', + timeoutMs: 1000, + }), + ).rejects.toMatchObject({ code: 'COMMAND_FAILED', details: { statusCode: status } }); +}); + +test('container operations receive fresh deadlines after earlier operations have completed', async () => { + const f = await fixture({ + containers: { 'com.example.app': { data: { 'Documents/state': Buffer.from('old') } } }, + }); + const deadlines: AbortController[] = []; + const timeout = vi.spyOn(AbortSignal, 'timeout').mockImplementation(() => { + const deadline = new AbortController(); + deadlines.push(deadline); + return deadline.signal; + }); + const expireCompletedOperations = () => { + for (const deadline of deadlines) + deadline.abort(new DOMException('deadline expired', 'TimeoutError')); + }; + try { + const tools = createLimrunAppleToolProvider(f.session, new AbortController().signal); + const container = await tools.simulatorFiles.openContainer({ + device: f.device, + bundleId: 'com.example.app', + containerType: 'data', + timeoutMs: 1000, + }); + expect(container).not.toBeNull(); + expireCompletedOperations(); + expect(await container!.list()).toEqual(['Documents']); + expireCompletedOperations(); + await container!.remove('Documents'); + expireCompletedOperations(); + await container!.ensureDirectory('Library/Caches'); + expect(f.storage.files.size).toBe(0); + expect(f.storage.directories.has('com.example.app/data/Library/Caches')).toBe(true); + } finally { + timeout.mockRestore(); + } +}); + +test('a container download deadline remains a typed timeout and removes the partial artifact', async () => { + const deadline = new AbortController(); + const f = await fixture({ + containers: { 'com.example.app': { data: { 'tmp/large.bin': Buffer.from('partial') } } }, + holdFileDownload: true, + onFileDownload: () => deadline.abort(new DOMException('deadline expired', 'TimeoutError')), + }); + const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(deadline.signal); + const destinationPath = path.join(f.directory, 'timed-out.bin'); + try { + const tools = createLimrunAppleToolProvider(f.session, new AbortController().signal); + await expect( + tools.simulatorFiles.copyToHost({ + device: f.device, + bundleId: 'com.example.app', + containerType: 'data', + sourcePath: 'tmp/large.bin', + destinationPath, + timeoutMs: 1000, + }), + ).rejects.toMatchObject({ code: 'COMMAND_FAILED', details: { timeoutMs: 1000 } }); + await expect(fs.stat(destinationPath)).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + timeout.mockRestore(); + } +}); diff --git a/src/core/interactors/limrun-ios-files.ts b/src/core/interactors/limrun-ios-files.ts new file mode 100644 index 0000000000..5cb70f13c9 --- /dev/null +++ b/src/core/interactors/limrun-ios-files.ts @@ -0,0 +1,209 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { + readSimctlContainerPath, + type AppleToolProvider, + type AppleToolExecOptions, + type AppleToolSubcommandExecutor, +} from '@agent-device/platform-apple/tool-provider'; +import type { LimrunRuntimeDependencies } from '@agent-device/provider-limrun'; +import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors'; +import { downloadLimrunFile } from '../limrun-download-file.ts'; + +type Session = Parameters[0]; +type Files = AppleToolProvider['simulatorFiles']; +type Container = Parameters[0]; + +export function createLimrunSimulatorFiles( + session: Session, + lifetime: AbortSignal, + runSimctl: AppleToolSubcommandExecutor, + requestSignal?: AbortSignal, +): { simulatorFiles: Files; readInfoPlist: AppleToolProvider['simulatorApps']['readInfoPlist'] } { + const signalFor = (signal?: AbortSignal) => + AbortSignal.any( + [lifetime, requestSignal, signal].filter((value): value is AbortSignal => Boolean(value)), + ); + const operation = async ( + options: AppleToolExecOptions, + run: (signal: AbortSignal) => Promise, + ): Promise => { + const timeoutMs = options.timeoutMs ?? 15_000; + const deadline = AbortSignal.timeout(timeoutMs); + const signal = AbortSignal.any( + [ + lifetime, + options.requestCancellation === 'ignore' ? undefined : requestSignal, + options.signal, + deadline, + ].filter((value): value is AbortSignal => Boolean(value)), + ); + try { + signal.throwIfAborted(); + return await run(signal); + } catch (error) { + if (signal.aborted) { + if (signal.reason === deadline.reason && deadline.aborted) + throw new AppError('COMMAND_FAILED', 'Remote simulator file operation timed out', { + timeoutMs, + }); + if (signal.reason instanceof AppError) throw signal.reason; + throw createRequestCanceledError(undefined, signal.reason); + } + throw error; + } + }; + const urlFor = (route: string, name: string, container?: Container) => { + const url = new URL(`${session.apiUrl.replace(/\/$/, '')}/${route}`); + url.searchParams.set(route === 'files/list' ? 'path' : 'name', name); + if (container) { + url.searchParams.set('bundleId', container.bundleId); + url.searchParams.set('containerType', container.containerType); + } + return url; + }; + const request = async (method: string, url: URL, signal: AbortSignal, body?: Uint8Array) => { + signal.throwIfAborted(); + const response = await fetch(url, { + method, + headers: { + Authorization: `Bearer ${session.token}`, + ...(body ? { 'Content-Type': 'application/octet-stream' } : {}), + }, + body: body as BodyInit | undefined, + signal, + redirect: 'error', + }); + if (!response.ok) { + await response.body?.cancel(); + throw new AppError('COMMAND_FAILED', `Limrun file ${method} failed (${response.status})`, { + status: response.status, + }); + } + return response; + }; + const files: Files = { + copyToHost: async (input) => { + await downloadLimrunFile({ + url: urlFor('files', input.sourcePath, input).href, + headers: { Authorization: `Bearer ${session.token}` }, + destinationPath: input.destinationPath, + timeoutMs: input.timeoutMs, + signal: signalFor(input.signal), + }); + }, + openContainer: async (container) => { + const result = await runSimctl( + ['get_app_container', session.device.id, container.bundleId, container.containerType], + { signal: container.signal, timeoutMs: container.timeoutMs }, + ); + if (!(await readSimctlContainerPath(result.stdout))) return null; + return { + list: () => + operation(container, async (signal) => { + const response = await request('GET', urlFor('files/list', '', container), signal); + const result = (await response.json()) as { entries: Array<{ name: string }> }; + return result.entries.map((entry) => entry.name); + }), + remove: (name) => + operation(container, async (signal) => { + const response = await request('DELETE', urlFor('files', name, container), signal); + await response.body?.cancel(); + }), + ensureDirectory: (directory) => + operation(container, async (signal) => { + // Upload creates parents; removing the unique marker leaves the requested directory. + const name = `${directory}/.agent-device-${randomUUID()}`; + const response = await request( + 'PUT', + urlFor('files', name, container), + signal, + new Uint8Array(), + ); + await response.body?.cancel(); + const removed = await request('DELETE', urlFor('files', name, container), signal); + await removed.body?.cancel(); + }), + }; + }, + stageFile: async (localPath, inputSignal) => { + const url = urlFor('files', `agent-device-${randomUUID()}${path.extname(localPath)}`); + const result = await operation({ signal: inputSignal }, async (signal) => { + const response = await request( + 'PUT', + url, + signal, + await fs.readFile(localPath, { signal }), + ); + return (await response.json()) as { path: string }; + }); + return { + path: result.path, + [Symbol.asyncDispose]: () => + operation({ requestCancellation: 'ignore', timeoutMs: 15_000 }, async (signal) => { + const response = await request('DELETE', url, signal); + await response.body?.cancel(); + }), + }; + }, + }; + const metadata = new Map | null>>(); + + return { + simulatorFiles: files, + readInfoPlist: async (input) => { + signalFor(input.signal).throwIfAborted(); + const key = input.bundleId; + let pending = metadata.get(key); + if (!pending) { + pending = readLimrunAppInfo( + session, + input.bundleId, + signalFor(input.signal), + input.timeoutMs, + ); + metadata.set(key, pending); + } + try { + return await pending; + } catch (error) { + metadata.delete(key); + throw error; + } + }, + }; +} + +export async function readLimrunAppInfo( + access: { apiUrl: string; token: string }, + bundleId: string, + signal: AbortSignal, + timeoutMs = 15_000, +): Promise | null> { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-app-plist-')); + try { + const destinationPath = path.join(directory, 'Info.plist'); + const url = new URL(`${access.apiUrl.replace(/\/$/, '')}/files`); + url.search = new URLSearchParams({ + name: 'Info.plist', + bundleId, + containerType: 'app', + }).toString(); + await downloadLimrunFile({ + url: url.href, + headers: { Authorization: `Bearer ${access.token}` }, + destinationPath, + timeoutMs, + signal, + }); + const { parseApplePlist } = await import('@agent-device/platform-apple/tool-provider'); + return await parseApplePlist(await fs.readFile(destinationPath, { signal })); + } catch (error) { + if (error instanceof AppError && error.details?.statusCode === 404) return null; + throw error; + } finally { + await fs.rm(directory, { recursive: true, force: true }); + } +} diff --git a/src/core/interactors/limrun-ios-runner-parity.test.ts b/src/core/interactors/limrun-ios-runner-parity.test.ts new file mode 100644 index 0000000000..688224788e --- /dev/null +++ b/src/core/interactors/limrun-ios-runner-parity.test.ts @@ -0,0 +1,181 @@ +import { expect, test, vi } from 'vitest'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { PNG } from '@agent-device/capture-kit/png'; +import { readPngSize } from '@agent-device/capture-kit/png-size'; +import { fixture } from './limrun-ios-runner.fixtures.ts'; + +test('forwarded screenshots preserve PNG pixels and share density and status bar normalization', async () => { + const png = new PNG({ width: 12, height: 18 }); + for (let i = 0; i < png.data.length; i++) png.data[i] = i % 251; + const bytes = PNG.sync.write(png); + const f = await fixture({ + containers: { + 'com.callstack.agentdevice.runner.uitests.xctrunner': { + data: { 'tmp/screenshot.png': bytes }, + }, + }, + screenshot: { + message: 'tmp/screenshot.png', + screenshotMetadata: { + displayID: 1, + pixelWidth: 12, + pixelHeight: 18, + pixelsPerPoint: 3, + }, + }, + }); + const interactor = f.runner.createInteractor({ appBundleId: 'com.apple.Preferences' }); + const original = path.join(f.directory, 'original.png'); + await interactor.screenshot(original, { pixelDensity: 3 }); + expect(await fs.readFile(original)).toEqual(bytes); + const scaled = path.join(f.directory, 'scaled.png'); + await interactor.screenshot(scaled, { pixelDensity: 1, normalizeStatusBar: true }); + expect(await readPngSize(scaled)).toEqual({ width: 4, height: 6 }); + expect(f.commands.filter((command) => command.command === 'screenshot')).toEqual([ + expect.objectContaining({ inlineScreenshot: false, fullscreen: true }), + expect.objectContaining({ inlineScreenshot: false, fullscreen: true }), + ]); + const downloads = f.requests + .filter((url) => url.includes('/files?')) + .map((url) => new URL(url, 'http://fixture')); + expect(downloads).toHaveLength(2); + for (const url of downloads) { + expect(url.searchParams.get('name')).toBe('tmp/screenshot.png'); + expect(url.searchParams.get('containerType')).toBe('data'); + expect(url.searchParams.get('bundleId')).toContain('xctrunner'); + } + const calls = vi.mocked(f.session.client.simctl).mock.calls.map(([args]) => args); + expect(calls.some((args) => args[0] === 'status_bar' && args.includes('9:41'))).toBe(true); + expect(calls.some((args) => args[0] === 'io' || args[0] === 'get_app_container')).toBe(false); + expect(calls.at(-1)).toEqual(['status_bar', 'booted', 'clear']); +}); + +test('a runner screenshot without a file path fails without writing a file', async () => { + const f = await fixture(); + const outPath = path.join(f.directory, 'missing.png'); + await expect(f.runner.createInteractor().screenshot(outPath)).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + message: 'Failed to capture iOS screenshot: runner returned no file path', + }); + await expect(fs.stat(outPath)).rejects.toMatchObject({ code: 'ENOENT' }); +}); + +test('canceling a normalized screenshot restores the remote status bar before returning', async () => { + const controller = new AbortController(); + const f = await fixture({ + containers: { + 'com.callstack.agentdevice.runner.uitests.xctrunner': { + data: { 'tmp/screenshot.png': PNG.sync.write(new PNG({ width: 2, height: 2 })) }, + }, + }, + screenshot: { message: 'tmp/screenshot.png' }, + holdFileDownload: true, + onFileDownload: () => controller.abort(new Error('capture canceled')), + }); + const outPath = path.join(f.directory, 'canceled.png'); + const interactor = f.runner.createInteractor({ + appBundleId: 'com.apple.Preferences', + signal: controller.signal, + }); + await expect(interactor.screenshot(outPath, { normalizeStatusBar: true })).rejects.toThrow( + 'capture canceled', + ); + const calls = vi.mocked(f.session.client.simctl).mock.calls.map(([args]) => args); + expect(calls.some((args) => args[0] === 'status_bar' && args.includes('9:41'))).toBe(true); + expect(calls.at(-1)).toEqual(['status_bar', 'booted', 'clear']); + await expect(fs.stat(outPath)).rejects.toMatchObject({ code: 'ENOENT' }); +}); + +test('remote tools execute the shared text-size, status bar and biometric settings', async () => { + const f = await fixture({ simctlResult: { code: 0, stdout: 'large', stderr: '' } }); + const interactor = f.runner.createInteractor(); + await interactor.setSetting('text-size', 'large'); + expect(await interactor.readSetting!('text-size')).toMatchObject({ category: 'large' }); + await interactor.setSetting('wifi', 'off'); + await interactor.setSetting('airplane', 'on'); + await interactor.setSetting('faceid', 'match'); + await interactor.setSetting('touchid', 'nonmatch'); + const calls = vi.mocked(f.session.client.simctl).mock.calls.map(([args]) => args); + expect(calls).toContainEqual(['ui', 'booted', 'content_size', 'large']); + expect(calls).toContainEqual(['ui', 'booted', 'content_size']); + expect(calls).toContainEqual(['status_bar', 'booted', 'override', '--wifiMode', 'failed']); + expect(calls).toContainEqual([ + 'spawn', + 'booted', + 'notifyutil', + '-p', + 'com.apple.BiometricKit_Sim.pearl.match', + ]); + expect(calls).toContainEqual([ + 'spawn', + 'booted', + 'notifyutil', + '-p', + 'com.apple.BiometricKit_Sim.fingerTouch.nomatch', + ]); +}); + +test('shared deployment resolves remote app names and uninstalls before uploading a replacement', async () => { + const f = await fixture(); + const { createAppleToolHost } = await import('../../platform-runtime-apple-tool-host.ts'); + const { createAppleAppDeploymentExecutor } = + await import('../../platform-runtime-apple-deployment-executor.ts'); + const order: string[] = []; + const originalSimctl = f.session.client.simctl; + f.session.client.simctl = vi.fn((args, options) => { + if (args[0] === 'uninstall') order.push(`uninstall:${args[2]}`); + return originalSimctl(args, options); + }); + const host = { + appleTools: createAppleToolHost(), + appleDeployment: { + ...createAppleAppDeploymentExecutor({}), + prepareArtifact: async () => { + order.push('materialize'); + return { + installablePath: '/tmp/New.app', + bundleId: 'com.apple.Preferences', + cleanup: async () => { + order.push('cleanup'); + }, + }; + }, + }, + deviceReadiness: { + appleAutomation: { + wasRecentlyObservedBooted: async () => true, + keepHot: () => {}, + markBooted: () => {}, + }, + }, + } as unknown as import('@agent-device/contracts/platform-runtime-operations').PlatformRuntimeHost; + const operations = f.runner.bindDeployment({ + host, + signal: new AbortController().signal, + install: async () => { + order.push('upload-and-install'); + return { bundleId: 'com.apple.Preferences' }; + }, + }); + expect( + await operations.deployApp!({ + app: 'Settings', + appPath: '/tmp/New.app', + replaceExisting: true, + }), + ).toMatchObject({ bundleId: 'com.apple.Preferences' }); + expect(order).toEqual([ + 'uninstall:com.apple.Preferences', + 'materialize', + 'upload-and-install', + 'cleanup', + ]); + expect(await operations.uninstallApp!({ app: 'Settings' })).toEqual({ + wasInstalled: true, + bundleId: 'com.apple.Preferences', + }); + expect(await operations.uninstallApp!({ app: 'com.example.absent' })).toEqual({ + wasInstalled: false, + }); +}); diff --git a/src/core/interactors/limrun-ios-runner-products.test.ts b/src/core/interactors/limrun-ios-runner-products.test.ts new file mode 100644 index 0000000000..ded7443762 --- /dev/null +++ b/src/core/interactors/limrun-ios-runner-products.test.ts @@ -0,0 +1,169 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { afterEach, expect, test, vi } from 'vitest'; +import { mkdtempForTest } from '@agent-device/test-kit'; +import { + prepareLimrunRunnerProducts, + uploadLimrunRunnerProducts, +} from './limrun-ios-runner-products.ts'; + +afterEach(() => vi.unstubAllGlobals()); +const endpoint = { port: 8100, token: 'a'.repeat(32) }; + +test('a normal app without a test bundle reports the invalid runner path', async () => { + const directory = await mkdtempForTest('runner-products-'); + const appPath = path.join(directory, 'Ordinary.app'); + await fs.mkdir(appPath); + const fetch = vi.fn(); + vi.stubGlobal('fetch', fetch); + await expect( + prepareLimrunRunnerProducts({ + directory, + appPath, + version: '0.1.0', + bindAddress: '10.244.1.2', + endpoint, + signal: new AbortController().signal, + }), + ).rejects.toMatchObject({ + code: 'INVALID_ARGS', + message: 'The app does not contain AgentDeviceRunnerUITests.xctest', + }); + expect(fetch).not.toHaveBeenCalled(); +}); + +test('an HTML upload failure preserves the HTTP status', async () => { + const directory = await mkdtempForTest('runner-products-'); + await fs.writeFile(path.join(directory, 'Runner.xctestrun'), ''); + vi.stubGlobal('fetch', async () => new Response('Bad Gateway', { status: 502 })); + await expect( + uploadLimrunRunnerProducts( + directory, + 'https://instance.invalid', + 'token', + new AbortController().signal, + ), + ).rejects.toMatchObject({ code: 'COMMAND_FAILED', details: { status: 502 } }); +}); + +for (const bindAddress of ['0.0.0.0', 'localhost', '', '::1']) { + test(`refuses ${JSON.stringify(bindAddress)} as a pod binding`, async () => { + await expect( + prepareLimrunRunnerProducts({ + directory: await mkdtempForTest('runner-products-'), + version: '0.1.0', + bindAddress, + endpoint, + signal: new AbortController().signal, + }), + ).rejects.toMatchObject({ code: 'COMMAND_FAILED' }); + }); +} + +test('refuses release bytes with a mismatched checksum before extracting or deploying them', async () => { + const urls: string[] = []; + vi.stubGlobal('fetch', async (url: string) => { + urls.push(url); + return new Response( + url.endsWith('.json') + ? JSON.stringify({ + version: '0.1.0', + platform_target: 'iphonesimulator', + sha256: '0'.repeat(64), + }) + : 'untrusted archive', + ); + }); + const directory = await mkdtempForTest('runner-products-'); + await expect( + prepareLimrunRunnerProducts({ + directory, + version: '0.1.0', + bindAddress: '10.244.1.2', + endpoint, + signal: new AbortController().signal, + }), + ).rejects.toThrow('iOS runner checksum mismatch'); + expect(urls).toEqual([ + 'https://github.com/callstack/agent-device/releases/download/v0.1.0/agent-device-ios-runner-0.1.0.manifest.json', + 'https://github.com/callstack/agent-device/releases/download/v0.1.0/agent-device-ios-runner-0.1.0.app.tar.gz', + ]); + await expect(fs.stat(path.join(directory, 'extracted'))).rejects.toMatchObject({ + code: 'ENOENT', + }); +}); + +test('nightly builds require a packaged runner instead of requesting unpublished release assets', async () => { + const fetch = vi.fn(); + vi.stubGlobal('fetch', fetch); + await expect( + prepareLimrunRunnerProducts({ + directory: await mkdtempForTest('runner-products-'), + version: '0.1.0-nightly.20261030.123', + bindAddress: '10.244.1.2', + endpoint, + signal: new AbortController().signal, + }), + ).rejects.toMatchObject({ + code: 'INVALID_ARGS', + message: 'Nightly and development builds require AGENT_DEVICE_LIMRUN_IOS_RUNNER_APP', + }); + expect(fetch).not.toHaveBeenCalled(); +}); + +for (const recover of [true, false]) { + test(`a missing remote products tree ${recover ? 'recovers with full payloads' : 'fails after one full retry'}`, async () => { + const { createServer } = await import('node:http'); + const { gunzipSync } = await import('node:zlib'); + const directory = await mkdtempForTest('runner-products-'); + await fs.writeFile(path.join(directory, 'Runner.xctestrun'), ''); + await fs.mkdir(path.join(directory, 'Runner.app')); + await fs.writeFile(path.join(directory, 'Runner.app/Runner'), 'runner executable'); + const uploads: Array<{ payloads: Array<{ path: string; length: number }> }> = []; + const bodies: Buffer[] = []; + const server = createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const body = gunzipSync(Buffer.concat(chunks)); + const size = body.readUInt32BE(0); + uploads.push(JSON.parse(body.subarray(4, 4 + size).toString())); + bodies.push(body.subarray(4 + size)); + response.setHeader('Content-Type', 'application/json'); + response.end( + JSON.stringify( + recover && uploads.length === 2 + ? { ok: true } + : { ok: false, needFull: ['Runner.app/Runner'] }, + ), + ); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Missing server address'); + try { + const upload = uploadLimrunRunnerProducts( + directory, + `http://127.0.0.1:${address.port}`, + 'token', + new AbortController().signal, + ['Runner.xctestrun'], + ); + if (recover) await upload; + else + await expect(upload).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { needFull: ['Runner.app/Runner'] }, + }); + expect(uploads).toHaveLength(2); + expect(uploads[0]!.payloads.map((file) => file.path)).toEqual(['Runner.xctestrun']); + expect(uploads[1]!.payloads.map((file) => file.path).sort()).toEqual([ + 'Runner.app/Runner', + 'Runner.xctestrun', + ]); + expect(bodies[1]!.toString()).toContain('runner executable'); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } + }); +} diff --git a/src/core/interactors/limrun-ios-runner-products.ts b/src/core/interactors/limrun-ios-runner-products.ts new file mode 100644 index 0000000000..52f770d702 --- /dev/null +++ b/src/core/interactors/limrun-ios-runner-products.ts @@ -0,0 +1,219 @@ +import { + runnerListenerEnv, + RUNNER_XCTESTRUN_CAPTURE_OPTIONS, +} from '@agent-device/platform-apple/runner/operations'; +import { createHash, randomUUID } from 'node:crypto'; +import { createReadStream, promises as fs } from 'node:fs'; +import { isIP } from 'node:net'; +import path from 'node:path'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { createGzip } from 'node:zlib'; +import { AppError } from '@agent-device/kernel/errors'; +import { extractArchiveSafely } from '@agent-device/host-kit/archive'; +import { escapeXmlTextAndAttribute } from '@agent-device/xml'; +import { downloadLimrunFile } from '../limrun-download-file.ts'; + +/** Stages the version-matched release runner, or an explicitly supplied development build. */ +export async function prepareLimrunRunnerProducts(input: { + directory: string; + version: string; + appPath?: string; + bindAddress: string; + endpoint: { port: number; token: string }; + signal: AbortSignal; +}): Promise { + if (isIP(input.bindAddress) !== 4 || input.bindAddress === '0.0.0.0') { + throw new AppError('COMMAND_FAILED', 'Limrun USE_IP must be a concrete IPv4 address'); + } + const products = path.join(input.directory, `runner-${randomUUID()}`); + await fs.mkdir(products); + let appPath = input.appPath; + if (!appPath) { + if (!/^\d+\.\d+\.\d+$/.test(input.version)) { + throw new AppError( + 'INVALID_ARGS', + 'Nightly and development builds require AGENT_DEVICE_LIMRUN_IOS_RUNNER_APP', + { + hint: 'Point it at the simulator runner .app built from this agent-device checkout.', + }, + ); + } + const name = `agent-device-ios-runner-${input.version}`; + const release = `https://github.com/callstack/agent-device/releases/download/v${input.version}`; + const manifestPath = path.join(input.directory, 'manifest.json'); + await downloadLimrunFile({ + url: `${release}/${name}.manifest.json`, + headers: {}, + destinationPath: manifestPath, + timeoutMs: 120_000, + signal: input.signal, + }); + const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Record; + if ( + manifest.version !== input.version || + manifest.platform_target !== 'iphonesimulator' || + typeof manifest.sha256 !== 'string' || + !/^[a-f0-9]{64}$/.test(manifest.sha256) + ) { + throw new AppError('COMMAND_FAILED', 'Invalid iOS runner release manifest'); + } + const archivePath = path.join(input.directory, 'runner.tar.gz'); + await downloadLimrunFile({ + url: `${release}/${name}.app.tar.gz`, + headers: {}, + destinationPath: archivePath, + timeoutMs: 120_000, + signal: input.signal, + }); + input.signal.throwIfAborted(); + const digest = createHash('sha256') + .update(await fs.readFile(archivePath)) + .digest('hex'); + if (digest !== manifest.sha256) + throw new AppError('COMMAND_FAILED', 'iOS runner checksum mismatch'); + const extracted = path.join(input.directory, 'extracted'); + await extractArchiveSafely({ + archivePath, + outputRoot: extracted, + type: 'tgz', + signal: input.signal, + }); + appPath = path.join(extracted, `${name}.app`); + } + input.signal.throwIfAborted(); + if (!appPath.endsWith('.app') || !(await fs.stat(appPath)).isDirectory()) { + throw new AppError('INVALID_ARGS', 'The Limrun XCTest runner must be an iOS simulator .app'); + } + const plugins = await fs + .readdir(path.join(appPath, 'PlugIns')) + .catch((error: unknown): string[] => { + if (['ENOENT', 'ENOTDIR'].includes((error as NodeJS.ErrnoException).code ?? '')) return []; + throw error; + }); + if (!plugins.includes('AgentDeviceRunnerUITests.xctest')) { + throw new AppError('INVALID_ARGS', 'The app does not contain AgentDeviceRunnerUITests.xctest'); + } + await fs.cp(appPath, path.join(products, 'Runner.app'), { recursive: true }); + await writeLimrunRunnerConfiguration(products, input.bindAddress, input.endpoint); + return products; +} + +export async function writeLimrunRunnerConfiguration( + directory: string, + bindAddress: string, + endpoint: { port: number; token: string }, +): Promise { + const string = (value: string) => `${escapeXmlTextAndAttribute(value)}`; + const environment = runnerListenerEnv(endpoint, bindAddress); + const xml = ` + +AgentDeviceRunnerUITests +BlueprintNameAgentDeviceRunnerUITests +ProductModuleNameAgentDeviceRunnerUITests +TestHostPath__TESTROOT__/Runner.app +TestBundlePath__TESTHOST__/PlugIns/AgentDeviceRunnerUITests.xctest +IsUITestBundle +UITargetAppPath__TESTROOT__/Runner.app +${Object.entries(RUNNER_XCTESTRUN_CAPTURE_OPTIONS) + .map(([key, value]) => `${key}${string(value)}`) + .join('')} +OnlyTestIdentifiersRunnerTests/testCommand +EnvironmentVariables${Object.entries(environment) + .map(([key, value]) => `${key}${string(value)}`) + .join('')} +__xctestrun_metadata__FormatVersion1`; + await fs.writeFile(path.join(directory, 'Runner.xctestrun'), xml, { mode: 0o600 }); +} + +/** The SDK syncFolder cannot accept cancellation and may load the omitted xdelta WASM even with gzip. */ +export async function uploadLimrunRunnerProducts( + directory: string, + apiUrl: string, + token: string, + signal: AbortSignal, + payloadPaths?: readonly string[], +): Promise { + const files: Array<{ path: string; size: number; sha256: string; mode: number }> = []; + const visit = async (relative: string) => { + for (const entry of await fs.readdir(path.join(directory, relative), { withFileTypes: true })) { + signal.throwIfAborted(); + const name = path.posix.join(relative, entry.name); + if (entry.isDirectory()) { + await visit(name); + continue; + } + if (!entry.isFile()) + throw new AppError('INVALID_ARGS', 'Runner products must contain regular files'); + const file = path.join(directory, name); + const stat = await fs.stat(file); + const sha256 = createHash('sha256') + .update(await fs.readFile(file, { signal })) + .digest('hex'); + files.push({ path: name, size: stat.size, mode: stat.mode & 0o777, sha256 }); + } + }; + await visit(''); + const selected = payloadPaths ? files.filter((file) => payloadPaths.includes(file.path)) : files; + for (let attempt = 0; attempt < 2; attempt++) { + signal.throwIfAborted(); + const result = await upload(attempt === 0 ? selected : files); + if (result.ok === true && !result.needFull?.length) return; + if (!result.needFull?.length || attempt === 1) { + throw new AppError('COMMAND_FAILED', 'Limrun runner products could not be synchronized', { + needFull: result.needFull, + }); + } + } + + async function upload(payloads: typeof files): Promise<{ ok?: boolean; needFull?: string[] }> { + const metadata = Buffer.from( + JSON.stringify({ + id: randomUUID(), + rootName: path.basename(directory), + install: false, + files, + payloads: payloads.map((file) => ({ + kind: 'full', + path: file.path, + targetSha256: file.sha256, + length: file.size, + })), + }), + ); + const size = Buffer.alloc(4); + size.writeUInt32BE(metadata.length); + async function* body() { + yield size; + yield metadata; + for (const file of payloads) + yield* createReadStream(path.join(directory, file.path), { signal }); + } + const stream = createGzip(); + const compression = pipeline(Readable.from(body()), stream, { signal }).catch(() => {}); + try { + const response = await fetch(`${apiUrl}/sync`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/octet-stream', + 'Content-Encoding': 'gzip', + }, + body: Readable.toWeb(stream) as ReadableStream, + duplex: 'half', + signal, + redirect: 'error', + } as RequestInit & { duplex: 'half' }); + if (!response.ok) { + await response.body?.cancel(); + throw new AppError('COMMAND_FAILED', `Limrun runner upload failed (${response.status})`, { + status: response.status, + }); + } + return (await response.json()) as { ok?: boolean; needFull?: string[] }; + } finally { + stream.destroy(); + await compression; + } + } +} diff --git a/src/core/interactors/limrun-ios-runner.fixtures.ts b/src/core/interactors/limrun-ios-runner.fixtures.ts new file mode 100644 index 0000000000..e42b0ea066 --- /dev/null +++ b/src/core/interactors/limrun-ios-runner.fixtures.ts @@ -0,0 +1,329 @@ +import { fileStore, type FileStoreOptions } from './limrun-ios-files.fixtures.ts'; +import { createAppleApplicationTools } from '../../platform-runtime-apple-application-tools.ts'; +import { EventEmitter } from 'node:events'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { gunzipSync } from 'node:zlib'; +import { afterEach, vi, type Mock } from 'vitest'; +import { mkdtempForTest } from '@agent-device/test-kit'; +import type { LimrunRuntimeDependencies } from '@agent-device/provider-limrun'; +import { createLimrunIosRunner } from './limrun-ios-runner.ts'; +const cleanups: Array<() => Promise> = []; +afterEach(async () => { + for (const cleanup of cleanups.reverse()) await cleanup(); + cleanups.length = 0; +}); + +type Session = Parameters[0]; +type Manifest = { + files: Array<{ path: string; mode: number }>; + payloads: Array<{ path: string }>; +}; +type RunnerFixture = { + runner: Awaited>; + storage: ReturnType; + session: Session; + directory: string; + admit(): void; + readonly admissionClosed: boolean | undefined; + commands: Record[]; + uploads: Manifest[]; + apiUrl: string; + device: Session['device']; + stopReadiness: Mock<() => void>; + requests: string[]; + readonly manifest: Manifest | undefined; + readonly environment: Record; + readonly command: Record | undefined; + readonly testRun: ServerResponse | undefined; +}; + +export async function fixture( + options: { + launchStatus?: number; + holdLaunchErrorBody?: boolean; + syncStatus?: number; + holdReadiness?: boolean; + holdGetenv?: boolean; + holdAdmission?: boolean; + canceledRunConflicts?: number; + proof?: boolean; + holdSnapshot?: boolean; + holdShutdown?: boolean; + failReset?: boolean; + proxyHealthFailures?: number; + idleMs?: number; + simctlResult?: { code: number; stdout: string; stderr: string }; + loseTapReply?: 'completed' | 'started'; + busyTap?: boolean; + screenshot?: Record; + } & FileStoreOptions = {}, +): Promise { + const directory = await mkdtempForTest('limrun-runner-'); + const app = path.join(directory, 'AgentDeviceRunnerUITests-Runner.app'); + await fs.mkdir(path.join(app, 'PlugIns', 'AgentDeviceRunnerUITests.xctest'), { recursive: true }); + await fs.writeFile(path.join(app, 'Runner'), 'simulator runner', { mode: 0o755 }); + const requests: string[] = []; + const commands: Record[] = []; + let manifest: Manifest | undefined; + const uploads: Manifest[] = []; + const environment: Record = {}; + let command: Record | undefined; + let testRun: ServerResponse | undefined; + let admitted: ServerResponse | undefined; + let canceledRunConflicts = 0; + const serveRunnerFault = (payload: Record, response: ServerResponse) => { + if (payload.command === 'uptime' && options.proxyHealthFailures) { + options.proxyHealthFailures--; + response.writeHead(502).end('upstream not listening'); + return true; + } + if (payload.command === 'tap' && options.loseTapReply) { + response.destroy(); + return true; + } + if (payload.command === 'tap' && options.busyTap) { + response.setHeader('X-Agent-Device-Runner-Auth', 'required'); + response.end(JSON.stringify({ ok: false, error: { code: 'RUNNER_BUSY', message: 'busy' } })); + return true; + } + if (payload.command === 'status' && options.loseTapReply) { + response.setHeader('X-Agent-Device-Runner-Auth', 'required'); + response.end( + JSON.stringify({ + ok: true, + data: { + lifecycleState: options.loseTapReply, + lifecycleResponseJson: JSON.stringify({ ok: true, data: { recoveredTap: true } }), + }, + }), + ); + return true; + } + if (payload.command === 'targetReset' && options.failReset) { + response.setHeader('X-Agent-Device-Runner-Auth', 'required'); + response.end( + JSON.stringify({ ok: false, error: { code: 'RESET_FAILED', message: 'reset failed' } }), + ); + return true; + } + return false; + }; + const commandResults: Record = { + snapshot: { + nodes: [ + { + index: 0, + type: 'Application', + label: 'Settings', + rect: { x: 0, y: 0, width: 390, height: 844 }, + }, + { + index: 1, + parentIndex: 0, + type: 'Button', + label: 'Continue', + enabled: true, + hittable: true, + rect: { x: 20, y: 100, width: 150, height: 44 }, + }, + ], + truncated: false, + keyboard: { kind: 'absent' }, + }, + screenshot: options.screenshot ?? {}, + }; + const serveRunner = (request: IncomingMessage, response: ServerResponse, body: Buffer) => { + if (!testRun) { + response.writeHead(503).end(); + return; + } + if ( + request.url !== `/v1/ios_test/targetHttpPort${environment.PORT}/command` || + request.headers['x-agent-device-runner-token'] !== environment.TOKEN + ) { + response.writeHead(403).end(); + return; + } + const payload = JSON.parse(body.toString()); + commands.push(payload); + if (serveRunnerFault(payload, response)) return; + if (options.proof !== false || payload.command !== 'snapshot') + response.setHeader('X-Agent-Device-Runner-Auth', 'required'); + if (payload.command === 'snapshot') { + command = payload; + if (options.holdSnapshot) return; + } + if (payload.command === 'shutdown') { + if (options.holdShutdown) return; + testRun.end(); + testRun = undefined; + } + response.end( + JSON.stringify({ ok: true, data: commandResults[payload.command] ?? { uptime: 1 } }), + ); + }; + const serveLaunch = (response: ServerResponse) => { + if (canceledRunConflicts > 0) { + canceledRunConflicts--; + response.writeHead(409).end('A test run is already in progress'); + return; + } + if (options.launchStatus) { + response.writeHead(options.launchStatus); + response.flushHeaders(); + if (!options.holdLaunchErrorBody) response.end('test run unavailable'); + return; + } + testRun = response; + admitted = response; + response.once('close', () => { + if (testRun === response) testRun = undefined; + if (!response.writableEnded) canceledRunConflicts = options.canceledRunConflicts ?? 0; + }); + if (options.holdAdmission) return; + response.writeHead(200, { 'Content-Type': 'application/x-ndjson' }); + response.flushHeaders(); + }; + const storage = fileStore(options); + const server = createServer(async (request, response) => { + requests.push(request.url!); + if (request.headers.authorization !== 'Bearer instance-token') { + response.writeHead(401).end(); + return; + } + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + let body = Buffer.concat(chunks); + if (request.headers['content-encoding'] === 'gzip') body = gunzipSync(body); + if (storage.serve(request, response, body)) return; + if (request.url === '/v1/ios_test/api/sync') { + if (options.syncStatus) { + response.writeHead(options.syncStatus).end('upload unavailable'); + return; + } + const size = body.readUInt32BE(); + manifest = JSON.parse(body.subarray(4, 4 + size).toString()); + uploads.push(manifest!); + const xml = body.subarray(4 + size).toString(); + for (const key of ['PORT', 'TOKEN', 'BIND_IP']) { + environment[key] = xml.match( + new RegExp(`AGENT_DEVICE_RUNNER_${key}(.*?)`), + )![1]!; + } + response.end(JSON.stringify({ ok: true })); + return; + } + if (request.url === '/v1/ios_test/api/xctest/run') { + serveLaunch(response); + return; + } + serveRunner(request, response, body); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Missing fixture address'); + cleanups.push(async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + }); + const stopReadiness: Mock<() => void> = vi.fn(); + const heldCommands = new Set([ + ...(options.holdReadiness ? ['bootstatus'] : []), + ...(options.holdGetenv ? ['getenv'] : []), + ]); + const session = { + apiUrl: `http://127.0.0.1:${address.port}/v1/ios_test/api`, + token: 'instance-token', + device: { + id: 'limrun:ios:test', + platform: 'apple', + appleOs: 'ios', + kind: 'simulator', + name: 'Limrun iOS', + booted: true, + }, + client: { + deviceInfo: { udid: 'remote-udid' }, + listApps: async () => [ + { bundleId: 'com.apple.Preferences', name: 'Settings', installType: 'System' }, + ], + simctl: vi.fn((args: string[]) => + Object.assign(new EventEmitter(), { + wait: () => + heldCommands.has(args[0]!) + ? new Promise(() => {}) + : Promise.resolve( + args[0] === 'getenv' + ? { + code: 0, + stdout: args[2] === 'SIMULATOR_MAINSCREEN_SCALE' ? '3' : '10.244.1.2\n', + stderr: '', + } + : args[0] === 'list' + ? { + code: 0, + stdout: JSON.stringify({ + devices: { ios: [{ udid: 'remote-udid', state: 'Booted' }] }, + }), + stderr: '', + } + : (options.simctlResult ?? { + code: 0, + stdout: + args[0] === 'get_app_container' + ? `/containers/${args[2]}/${args[3]}` + : '', + stderr: '', + }), + ), + stop: stopReadiness, + }), + ), + }, + } as unknown as Session; + const host = { + cwd: directory, + env: { + AGENT_DEVICE_LIMRUN_IOS_RUNNER_APP: app, + ...(options.idleMs === undefined + ? {} + : { AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS: String(options.idleMs) }), + }, + }; + const runner: Awaited> = + await createLimrunIosRunner(session, host, createAppleApplicationTools(host.env)); + cleanups.push(() => runner.dispose()); + return { + runner, + storage, + session, + directory, + admit: () => { + admitted?.writeHead(200, { 'Content-Type': 'application/x-ndjson' }); + admitted?.flushHeaders(); + }, + get admissionClosed() { + return admitted?.destroyed; + }, + commands, + uploads, + apiUrl: session.apiUrl, + device: session.device, + stopReadiness, + requests, + get manifest() { + return manifest; + }, + get environment() { + return environment; + }, + get command() { + return command; + }, + get testRun() { + return testRun; + }, + }; +} diff --git a/src/core/interactors/limrun-ios-runner.test.ts b/src/core/interactors/limrun-ios-runner.test.ts new file mode 100644 index 0000000000..3e9af001a3 --- /dev/null +++ b/src/core/interactors/limrun-ios-runner.test.ts @@ -0,0 +1,627 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { + registerRequestAbort, + markRequestCanceled, + clearRequestCanceled, + clearRequestAbortRegistration, +} from '@agent-device/host-kit/request'; +import { expect, test, vi } from 'vitest'; +import { fixture } from './limrun-ios-runner.fixtures.ts'; +test('stops a stalled remote getenv when product preparation times out', async () => { + const deadline = new AbortController(); + const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(deadline.signal); + try { + const f = await fixture({ holdGetenv: true }); + const pending = f.runner.applicationTools.prewarmRunnerSession( + f.device, + {}, + new AbortController().signal, + true, + { healthCheck: false }, + ); + const rejected = expect(pending).rejects.toThrow(); + await vi.waitFor(() => + expect(f.session.client.simctl).toHaveBeenCalledWith(['getenv', 'booted', 'USE_IP']), + ); + deadline.abort(new Error('deadline')); + await rejected; + expect(f.stopReadiness).toHaveBeenCalledOnce(); + } finally { + timeout.mockRestore(); + } +}); +test('stages a token-authenticated runner once and presents forwarded XCTest snapshots', async () => { + const f = await fixture(); + const snapshot = await f.runner.createInteractor().snapshot({ + appBundleId: 'com.apple.Preferences', + interactiveOnly: true, + }); + expect(snapshot).toMatchObject({ + producer: 'apple-runner', + backend: 'xctest', + truncated: false, + keyboard: { kind: 'absent' }, + }); + if (!('nodes' in snapshot)) throw new Error('Expected a presented snapshot'); + expect(snapshot.nodes?.map((n) => n.label)).toContain('Continue'); + expect(f.command).toMatchObject({ + command: 'snapshot', + appBundleId: 'com.apple.Preferences', + interactiveOnly: true, + }); + expect(f.environment.BIND_IP).toBe('10.244.1.2'); + expect(f.environment.TOKEN?.length).toBeGreaterThanOrEqual(32); + expect((f.manifest?.files.find((f) => f.path === 'Runner.app/Runner')?.mode ?? 0) & 0o111).toBe( + 0o111, + ); + await f.runner.createInteractor().snapshot({ appBundleId: 'com.apple.Preferences' }); + expect( + f.uploads.filter((m) => m.payloads.some((p) => p.path === 'Runner.app/Runner')), + ).toHaveLength(1); + expect(f.requests.filter((p) => p.endsWith('/xctest/run'))).toHaveLength(1); + await f.runner.dispose(); + await expect( + f.runner.createInteractor().snapshot({ appBundleId: 'com.apple.Preferences' }), + ).rejects.toThrow(); +}); + +test('reports an occupied XCTest slot instead of falling back to AX', async () => { + const f = await fixture({ launchStatus: 409 }); + await expect( + f.runner.createInteractor().snapshot({ appBundleId: 'com.apple.Preferences' }), + ).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + message: 'Limrun XCTest launch failed (409)', + }); + expect(f.requests.filter((p) => p.endsWith('/xctest/run'))).toHaveLength(1); +}); + +test('rejects a forwarded response that does not prove runner token enforcement', async () => { + const f = await fixture({ proof: false }); + await expect( + f.runner.createInteractor().snapshot({ appBundleId: 'com.apple.Preferences' }), + ).rejects.toMatchObject({ + details: { reason: 'runner_token_unsupported' }, + }); +}); + +test('an admitted XCTest stream survives its launch deadline', async () => { + const timeout = new AbortController(); + const realTimeout = AbortSignal.timeout.bind(AbortSignal); + const timer = vi + .spyOn(AbortSignal, 'timeout') + .mockImplementation((ms) => (ms === 90_000 ? timeout.signal : realTimeout(ms))); + try { + const f = await fixture(); + const logPath = path.join(f.directory, 'runner.log'); + await f.runner.applicationTools.prepareRunner( + f.device, + { execution: { logPath }, timeoutMs: 5_000 }, + new AbortController().signal, + ); + timeout.abort(new DOMException('Admission expired', 'TimeoutError')); + f.testRun!.write('"still-alive"\n'); + await vi.waitFor(async () => + expect(await fs.readFile(logPath, 'utf8')).toContain('still-alive'), + ); + expect(await f.runner.applicationTools.hasLiveRunnerSession(f.device, {})).toBe(true); + expect( + await f.runner.createInteractor({ appBundleId: 'com.apple.Preferences' }).snapshot(), + ).toMatchObject({ backend: 'xctest' }); + expect(f.requests.filter((p) => p.endsWith('/xctest/run'))).toHaveLength(1); + } finally { + timer.mockRestore(); + } +}); + +test('a stalled rejection body respects the launch deadline and does not claim the occupied slot', async () => { + const timeout = new AbortController(); + let markReadStarted!: () => void; + const readStarted = new Promise((resolve) => { + markReadStarted = resolve; + }); + const readText = Response.prototype.text; + const responseBody = vi + .spyOn(Response.prototype, 'text') + .mockImplementation(function (this: Response) { + if (this.status === 409) markReadStarted(); + return readText.call(this); + }); + const realTimeout = AbortSignal.timeout.bind(AbortSignal); + const timer = vi + .spyOn(AbortSignal, 'timeout') + .mockImplementation((ms) => (ms === 90_000 ? timeout.signal : realTimeout(ms))); + try { + const options = { launchStatus: 409, holdLaunchErrorBody: true }; + const f = await fixture(options); + const prepare = () => + f.runner.applicationTools.prewarmRunnerSession( + f.device, + {}, + new AbortController().signal, + true, + { healthCheck: false }, + ); + const failed = expect(prepare()).rejects.toMatchObject({ details: { status: 409 } }); + await readStarted; + timeout.abort(new DOMException('Admission expired', 'TimeoutError')); + await failed; + timer.mockRestore(); + options.holdLaunchErrorBody = false; + await expect(prepare()).rejects.toMatchObject({ details: { status: 409 } }); + expect(f.requests.filter((p) => p.endsWith('/xctest/run'))).toHaveLength(2); + } finally { + timer.mockRestore(); + responseBody.mockRestore(); + } +}); + +test('an admission timeout waits for its uncertain XCTest slot before restarting', async () => { + const timeout = new AbortController(); + const realTimeout = AbortSignal.timeout.bind(AbortSignal); + const timer = vi + .spyOn(AbortSignal, 'timeout') + .mockImplementation((ms) => (ms === 90_000 ? timeout.signal : realTimeout(ms))); + try { + const options = { holdAdmission: true, canceledRunConflicts: 1 }; + const f = await fixture(options); + const pending = f.runner.applicationTools.prewarmRunnerSession( + f.device, + {}, + new AbortController().signal, + true, + { healthCheck: false }, + ); + const failed = expect(pending).rejects.toThrow(); + await vi.waitFor(() => expect(f.requests.some((p) => p.endsWith('/xctest/run'))).toBe(true)); + timeout.abort(new DOMException('Admission expired', 'TimeoutError')); + await failed; + await vi.waitFor(() => expect(f.admissionClosed).toBe(true)); + timer.mockRestore(); + options.holdAdmission = false; + vi.useFakeTimers({ toFake: ['setTimeout'] }); + const restarted = f.runner + .createInteractor({ appBundleId: 'com.apple.Preferences' }) + .snapshot(); + await vi.waitFor(() => + expect(f.requests.filter((p) => p.endsWith('/xctest/run'))).toHaveLength(3), + ); + expect(await restarted).toMatchObject({ producer: 'apple-runner', backend: 'xctest' }); + } finally { + vi.useRealTimers(); + timer.mockRestore(); + } +}); + +test('cancels an in-flight snapshot while keeping the runner available for lease cleanup', async () => { + const options = { holdSnapshot: false }; + const f = await fixture(options); + const controller = new AbortController(); + const interactor = f.runner.createInteractor({ + signal: controller.signal, + appBundleId: 'com.apple.Preferences', + }); + await interactor.snapshot(); + options.holdSnapshot = true; + const snapshot = interactor.snapshot(); + const outcome = expect(snapshot).rejects.toMatchObject({ + details: { reason: 'request_canceled' }, + }); + await vi.waitFor(() => + expect(f.commands.filter((command) => command.command === 'snapshot')).toHaveLength(2), + ); + controller.abort(new Error('capture canceled')); + await outcome; + expect(await f.runner.applicationTools.hasLiveRunnerSession(f.device, {})).toBe(true); + options.holdSnapshot = false; + await f.runner.createInteractor({ appBundleId: 'com.apple.Preferences' }).snapshot(); + await f.runner.dispose(); + expect(f.requests.filter((path) => path.endsWith('/xctest/run'))).toHaveLength(1); +}); + +test('an already canceled snapshot option does not start a cold runner', async () => { + const f = await fixture(); + const signal = AbortSignal.abort(new Error('capture canceled')); + await expect( + f.runner.createInteractor().snapshot({ signal, appBundleId: 'com.apple.Preferences' }), + ).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'request_canceled' }, + }); + expect(f.requests).toHaveLength(0); +}); + +test('uses the request app instead of the XCTest target and refuses an unbound capture', async () => { + const f = await fixture(); + expect(f.runner.facts.captureSnapshotWithoutActiveApp?.available).toBe(false); + await expect(f.runner.createInteractor().snapshot()).rejects.toMatchObject({ + code: 'INVALID_ARGS', + }); + expect(f.requests).toHaveLength(0); + await f.runner.createInteractor({ appBundleId: 'com.apple.Preferences' }).snapshot(); + expect(f.command?.appBundleId).toBe('com.apple.Preferences'); +}); + +test('a failed target reset restarts the runner without re-uploading its products', async () => { + const f = await fixture({ failReset: true }); + await f.runner.resetTarget(); + expect(f.requests).toHaveLength(0); + await f.runner.createInteractor({ appBundleId: 'com.apple.Preferences' }).snapshot(); + await expect(f.runner.resetTarget()).rejects.toMatchObject({ + details: { runnerErrorCode: 'RESET_FAILED' }, + }); + await f.runner.createInteractor({ appBundleId: 'com.apple.Preferences' }).snapshot(); + expect(f.requests.filter((path) => path.endsWith('/xctest/run'))).toHaveLength(2); + expect( + f.uploads.filter((m) => m.payloads.some((p) => p.path === 'Runner.app/Runner')), + ).toHaveLength(1); +}); + +test('cache prewarm uploads without starting XCTest, and retained close reuses the same run', async () => { + const f = await fixture(); + const tools = f.runner.applicationTools; + const signal = new AbortController().signal; + await tools.prewarmRunnerCache(f.device, {}, signal); + expect( + f.uploads.filter((m) => m.payloads.some((p) => p.path === 'Runner.app/Runner')), + ).toHaveLength(1); + expect(f.requests.filter((p) => p.endsWith('/xctest/run'))).toHaveLength(0); + await tools.prewarmRunnerSession(f.device, {}, signal, true); + expect(await tools.hasLiveRunnerSession(f.device, {})).toBe(true); + expect(await tools.releaseSpeculativeRunner(f.device, {})).toBe(true); + expect(await tools.hasLiveRunnerSession(f.device, {})).toBe(false); + await f.runner.createInteractor({ appBundleId: 'com.apple.Preferences' }).snapshot(); + expect(await tools.releaseSpeculativeRunner(f.device, {})).toBe(false); + await tools.releaseRunnerOnClose(f.device.id, { retain: true }); + expect(await tools.hasLiveRunnerSession(f.device, {})).toBe(true); + await f.runner.createInteractor({ appBundleId: 'com.apple.Preferences' }).snapshot(); + expect(f.requests.filter((p) => p.endsWith('/xctest/run'))).toHaveLength(2); + expect( + f.uploads.filter((m) => m.payloads.some((p) => p.path === 'Runner.app/Runner')), + ).toHaveLength(1); + expect(await tools.detachRunnerSessionsForShutdown()).toEqual([]); + expect(await tools.hasLiveRunnerSession(f.device, {})).toBe(false); +}); + +test.each(['launchStatus', 'syncStatus'] as const)( + 'a failed %s attempt can be retried on the same lease', + async (failure) => { + const options = { [failure]: 503 }; + const f = await fixture(options); + await expect( + f.runner.applicationTools.prewarmRunnerSession( + f.device, + {}, + new AbortController().signal, + true, + ), + ).rejects.toThrow(); + options[failure] = 0; + const result = await f.runner + .createInteractor({ appBundleId: 'com.apple.Preferences' }) + .snapshot(); + expect(result).toMatchObject({ producer: 'apple-runner' }); + expect( + f.requests.filter((p) => p.endsWith(failure === 'syncStatus' ? '/sync' : '/xctest/run')), + ).toHaveLength(failure === 'syncStatus' ? 3 : 2); + }, +); + +test('canceling shared lifecycle readiness stops the remote simctl execution', async () => { + const f = await fixture({ holdReadiness: true }); + const controller = new AbortController(); + const lifecycle = f.runner.bindApplicationLifecycle({ + signal: controller.signal, + clock: { now: Date.now, sleep: async () => {} }, + resolveInteractor: async () => f.runner.createInteractor(), + }); + const pending = lifecycle.prepareApplicationOpen({ + execution: {}, + hasExistingSession: false, + surface: 'app', + prewarmRunnerOnColdBoot: false, + }); + const outcome = expect(pending).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'request_canceled' }, + }); + controller.abort(new Error('readiness canceled')); + await outcome; + expect(f.stopReadiness).toHaveBeenCalledOnce(); + expect(f.requests).toHaveLength(0); +}); + +test('the shared connect loop waits through a forwarding gateway failure without replacing the run', async () => { + const f = await fixture({ proxyHealthFailures: 1 }); + await f.runner.createInteractor({ appBundleId: 'com.apple.Preferences' }).tap(10, 20); + expect(f.commands.filter((c) => c.command === 'uptime').length).toBeGreaterThanOrEqual(2); + expect(f.requests.filter((p) => p.endsWith('/xctest/run'))).toHaveLength(1); +}); + +test('a lost mutation response is recovered from the runner journal without a second mutation', async () => { + const f = await fixture({ loseTapReply: 'completed' }); + await expect( + f.runner.createInteractor({ appBundleId: 'com.apple.Preferences' }).tap(10, 20), + ).resolves.toMatchObject({ recoveredTap: true }); + const tap = f.commands.filter((c) => c.command === 'tap'); + expect(tap).toHaveLength(1); + expect(f.commands.find((c) => c.command === 'status')?.statusCommandId).toBe(tap[0]?.commandId); + expect(f.requests.filter((p) => p.endsWith('/xctest/run'))).toHaveLength(1); +}); + +test('a lost mutation still executing is reported without replaying or replacing its runner', async () => { + const f = await fixture({ loseTapReply: 'started' }); + await expect( + f.runner.createInteractor({ appBundleId: 'com.apple.Preferences' }).tap(10, 20), + ).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'runner_reply_lost', lifecycleState: 'started' }, + }); + expect(f.commands.filter((c) => c.command === 'tap')).toHaveLength(1); + expect(await f.runner.applicationTools.hasLiveRunnerSession(f.device, {})).toBe(true); +}); + +test('a busy mutation is neither replayed nor subjected to lost-response recovery', async () => { + const f = await fixture({ busyTap: true }); + await expect( + f.runner.createInteractor({ appBundleId: 'com.apple.Preferences' }).tap(10, 20), + ).rejects.toMatchObject({ + details: { runnerErrorCode: 'RUNNER_BUSY' }, + }); + expect(f.commands.filter((c) => c.command === 'tap')).toHaveLength(1); + expect(f.commands.filter((c) => c.command === 'status')).toHaveLength(0); +}); + +test('a restarted generation rejects delayed requests carrying the previous token', async () => { + const f = await fixture(); + const interactor = f.runner.createInteractor({ appBundleId: 'com.apple.Preferences' }); + await interactor.snapshot(); + const previousToken = f.environment.TOKEN; + await f.runner.applicationTools.stopRunnerSession(f.device.id); + await interactor.snapshot(); + expect(f.environment.TOKEN).not.toBe(previousToken); + const response = await fetch( + new URL(`../targetHttpPort${f.environment.PORT}/command`, `${f.apiUrl}/`), + { + method: 'POST', + headers: { + Authorization: 'Bearer instance-token', + 'X-Agent-Device-Runner-Token': previousToken!, + }, + body: JSON.stringify({ command: 'tap', x: 10, y: 20 }), + }, + ); + expect(response.status).toBe(403); + expect(f.commands.filter((c) => c.command === 'tap')).toHaveLength(0); + expect( + f.uploads.filter((m) => m.payloads.some((p) => p.path.startsWith('Runner.app/'))), + ).toHaveLength(1); + expect( + f.uploads + .slice(1) + .every((m) => m.payloads.length === 1 && m.payloads[0]?.path === 'Runner.xctestrun'), + ).toBe(true); +}); + +test.each(['signal', 'requestId'] as const)( + 'hard cancellation through %s waits for the canceled XCTest slot before restarting', + async (via) => { + const options = { holdAdmission: true, canceledRunConflicts: 1 }; + const f = await fixture(options); + const controller = new AbortController(); + const requestId = 'limrun-cold-cancel'; + const registration = via === 'requestId' ? registerRequestAbort(requestId) : undefined; + try { + const pending = f.runner + .createInteractor({ + appBundleId: 'com.apple.Preferences', + ...(via === 'signal' ? { signal: controller.signal } : { requestId }), + }) + .snapshot(); + const outcome = expect(pending).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'request_canceled' }, + }); + await vi.waitFor(() => expect(f.requests.some((p) => p.endsWith('/xctest/run'))).toBe(true)); + if (via === 'signal') controller.abort(new Error('stop cold launch')); + else markRequestCanceled(requestId); + await outcome; + await vi.waitFor(() => expect(f.admissionClosed).toBe(true)); + const port = f.environment.PORT; + options.holdAdmission = false; + vi.useFakeTimers({ toFake: ['setTimeout'] }); + const restarted = f.runner + .createInteractor({ appBundleId: 'com.apple.Preferences' }) + .snapshot(); + await vi.waitFor(() => + expect(f.requests.filter((p) => p.endsWith('/xctest/run'))).toHaveLength(3), + ); + expect(await restarted).toMatchObject({ producer: 'apple-runner', backend: 'xctest' }); + expect(f.environment.PORT).toBe(port); + } finally { + vi.useRealTimers(); + clearRequestCanceled(requestId); + clearRequestAbortRegistration(registration); + } + }, +); + +test('a caller deadline leaves its pending launch available for the next request', async () => { + const f = await fixture({ holdAdmission: true }); + const controller = new AbortController(); + const pending = f.runner + .createInteractor({ appBundleId: 'com.apple.Preferences', signal: controller.signal }) + .snapshot(); + const outcome = expect(pending).rejects.toThrow(); + await vi.waitFor(() => expect(f.requests.some((p) => p.endsWith('/xctest/run'))).toBe(true)); + controller.abort(new DOMException('deadline', 'TimeoutError')); + await outcome; + expect(f.admissionClosed).toBe(false); + f.admit(); + await f.runner.createInteractor({ appBundleId: 'com.apple.Preferences' }).snapshot(); + expect(f.requests.filter((p) => p.endsWith('/xctest/run'))).toHaveLength(1); +}); + +test('a never-ready generation expires across caller-deadline polls', async () => { + const options = { holdSnapshot: true }; + const f = await fixture(options); + const controller = new AbortController(); + const pending = f.runner + .createInteractor({ appBundleId: 'com.apple.Preferences', signal: controller.signal }) + .snapshot(); + const outcome = expect(pending).rejects.toThrow(); + await vi.waitFor(() => expect(f.command?.command).toBe('snapshot')); + controller.abort(new DOMException('poll deadline', 'TimeoutError')); + await outcome; + const now = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 60_000); + try { + options.holdSnapshot = false; + await f.runner.createInteractor({ appBundleId: 'com.apple.Preferences' }).snapshot(); + expect(f.requests.filter((p) => p.endsWith('/xctest/run'))).toHaveLength(2); + } finally { + now.mockRestore(); + } +}); + +test('an exact-session prepare after retirement cannot start a replacement', async () => { + const f = await fixture(); + await f.runner.createInteractor({ appBundleId: 'com.apple.Preferences' }).snapshot(); + await f.runner.applicationTools.stopRunnerSession(f.device.id); + const execution = { + requestId: 'retired-exact-prepare', + expectedRunnerSessionId: 'retired-generation', + }; + await expect( + f.runner.applicationTools.prepareRunner( + f.device, + { execution, timeoutMs: 1000 }, + new AbortController().signal, + ), + ).rejects.toMatchObject({ + details: { reason: 'runner_session_ownership_changed' }, + }); + expect(f.requests.filter((p) => p.endsWith('/xctest/run'))).toHaveLength(1); +}); + +test('retained runners expire with the shared idle policy and restart from uploaded products', async () => { + const f = await fixture({ idleMs: 1_000 }); + const interactor = f.runner.createInteractor({ appBundleId: 'com.apple.Preferences' }); + await interactor.snapshot(); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); + try { + await f.runner.applicationTools.releaseRunnerOnClose(f.device.id, { retain: true }); + await vi.advanceTimersByTimeAsync(1_001); + } finally { + vi.useRealTimers(); + } + await vi.waitFor(() => + expect(f.commands.some((command) => command.command === 'shutdown')).toBe(true), + ); + await interactor.snapshot(); + expect(f.requests.filter((request) => request.endsWith('/xctest/run'))).toHaveLength(2); + expect( + f.uploads.filter((upload) => + upload.payloads.some((payload) => payload.path === 'Runner.app/Runner'), + ), + ).toHaveLength(1); +}); + +test('prewarming without a health check admits XCTest without sending commands', async () => { + const f = await fixture(); + await f.runner.applicationTools.prewarmRunnerSession( + f.device, + {}, + new AbortController().signal, + true, + { healthCheck: false }, + ); + expect(f.requests.filter((request) => request.endsWith('/xctest/run'))).toHaveLength(1); + expect(f.commands).toHaveLength(0); + expect(await f.runner.applicationTools.releaseSpeculativeRunner(f.device, {})).toBe(true); +}); + +test('an exited test stream saves its result frames and the shared owner replaces the generation', async () => { + const f = await fixture(); + const logPath = path.join(f.directory, 'runner.log'); + await f.runner.applicationTools.prepareRunner( + f.device, + { execution: { logPath }, timeoutMs: 5_000 }, + new AbortController().signal, + ); + await f.runner.applicationTools.releaseRunnerOnClose(f.device.id, { retain: true }); + f.testRun?.end('"test-plan-failed"\n'); + await vi.waitFor(async () => + expect(await f.runner.applicationTools.hasLiveRunnerSession(f.device, {})).toBe(false), + ); + await expect( + f.runner.applicationTools.prepareRunner( + f.device, + { execution: {}, timeoutMs: 5_000 }, + new AbortController().signal, + ), + ).resolves.toMatchObject({ runner: { uptime: 1 } }); + expect(await fs.readFile(logPath, 'utf8')).toContain('test-plan-failed'); + await f.runner.createInteractor({ appBundleId: 'com.apple.Preferences' }).snapshot(); + expect(f.requests.filter((request) => request.endsWith('/xctest/run'))).toHaveLength(2); +}); + +test('a close fences preparation already queued behind runner shutdown', async () => { + const f = await fixture({ holdShutdown: true }); + await f.runner.createInteractor({ appBundleId: 'com.apple.Preferences' }).snapshot(); + const stopped = f.runner.applicationTools.stopRunnerSession(f.device.id); + await vi.waitFor(() => expect(f.commands.at(-1)?.command).toBe('shutdown')); + const preparing = f.runner.applicationTools.prepareRunner( + f.device, + { execution: {}, timeoutMs: 10_000 }, + new AbortController().signal, + ); + const outcome = expect(preparing).rejects.toMatchObject({ + details: { reason: 'request_canceled', runnerStartRetired: true }, + }); + const closed = f.runner.applicationTools.releaseRunnerOnClose(f.device.id, { retain: false }); + f.testRun?.end(); + await Promise.all([stopped, closed, outcome]); + expect(f.requests.filter((request) => request.endsWith('/xctest/run'))).toHaveLength(1); +}); + +test('close cancels a pending launch before waiting for the session lock', async () => { + const f = await fixture({ holdAdmission: true }); + const preparing = f.runner.applicationTools.prepareRunner( + f.device, + { execution: {}, timeoutMs: 10_000 }, + new AbortController().signal, + ); + const outcome = expect(preparing).rejects.toMatchObject({ + details: { reason: 'request_canceled', runnerStartRetired: true }, + }); + await vi.waitFor(() => expect(f.requests.some((p) => p.endsWith('/xctest/run'))).toBe(true)); + await f.runner.applicationTools.releaseRunnerOnClose(f.device.id, { retain: false }); + await outcome; + await vi.waitFor(() => expect(f.admissionClosed).toBe(true)); +}); + +test.each(['stopRunnerSession', 'finalizeRunnerSessionsForShutdown'] as const)( + '%s cancels a pending launch before waiting for the session lock', + async (operation) => { + const options = { holdAdmission: true }; + const f = await fixture(options); + const preparing = f.runner.applicationTools.prepareRunner( + f.device, + { execution: {}, timeoutMs: 10_000 }, + new AbortController().signal, + ); + const outcome = expect(preparing).rejects.toMatchObject({ + details: { reason: 'request_canceled', runnerStartRetired: true }, + }); + await vi.waitFor(() => expect(f.requests.some((p) => p.endsWith('/xctest/run'))).toBe(true)); + if (operation === 'stopRunnerSession') + await f.runner.applicationTools.stopRunnerSession(f.device.id); + else await f.runner.applicationTools.finalizeRunnerSessionsForShutdown(); + await outcome; + await vi.waitFor(() => expect(f.admissionClosed).toBe(true)); + options.holdAdmission = false; + await f.runner.createInteractor({ appBundleId: 'com.apple.Preferences' }).snapshot(); + expect(f.requests.filter((p) => p.endsWith('/xctest/run'))).toHaveLength(2); + }, +); diff --git a/src/core/interactors/limrun-ios-runner.ts b/src/core/interactors/limrun-ios-runner.ts new file mode 100644 index 0000000000..cb9025b6fa --- /dev/null +++ b/src/core/interactors/limrun-ios-runner.ts @@ -0,0 +1,269 @@ +import { awaitWithSignal } from '@agent-device/host-kit/request'; +import { retryWithPolicy } from '@agent-device/host-kit/retry'; +import { createLimrunAppleToolProvider } from './limrun-ios-tools.ts'; +import type { AppleApplicationTools } from '@agent-device/contracts/application-lifecycle-runtime'; +import { withMethodScope } from '@agent-device/kernel/scoped-provider'; +import { createLimrunAppleApplicationTools } from './limrun-ios-application-tools.ts'; +import { randomInt } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { LimrunRuntimeDependencies } from '@agent-device/provider-limrun'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; +import { AppError } from '@agent-device/kernel/errors'; +import { readVersion } from '@agent-device/host-kit/version'; +import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import { + prepareLimrunRunnerProducts, + uploadLimrunRunnerProducts, + writeLimrunRunnerConfiguration, +} from './limrun-ios-runner-products.ts'; + +type LimrunIosRunner = Awaited>; +type Session = Parameters[0]; + +/** Owns the lease’s XCTest runs; closing an attached lease never terminates its simulator. */ +export async function createLimrunIosRunner( + session: Session, + host: HostEnvironment, + appleApplications: AppleApplicationTools, +): Promise { + const { + createAppleRunnerRuntime, + createLaunchedAppleRunnerProvider, + RUNNER_STARTUP_TIMEOUT_MS, + mintRunnerToken, + } = await import('@agent-device/platform-apple/runner/operations'); + const { withAppleToolProvider, hasScopedAppleToolProvider, runXcrun } = + await import('@agent-device/platform-apple/tool-provider'); + const { buildSimctlArgsForDevice } = await import('@agent-device/platform-apple/simctl'); + const lifetime = new AbortController(); + const withTools = (task: () => Promise, signal?: AbortSignal) => + withAppleToolProvider(createLimrunAppleToolProvider(session, lifetime.signal, signal), task); + const hostTools = withMethodScope({ ...appleApplications }, (task) => + hasScopedAppleToolProvider() ? task() : withTools(task), + ); + const port = randomInt(20000, 50000); + const apiUrl = session.apiUrl.replace(/\/$/, ''); + const commandUrl = new URL(`targetHttpPort${port}/command`, `${apiUrl}/../`).href; + const headers = { Authorization: `Bearer ${session.token}` }; + let staging: string | undefined; + let products: Promise<{ directory: string; bindAddress: string }> | undefined; + let canceledRunMayHoldSlot = false; + const endpoint = { port, token: mintRunnerToken(), http: { url: commandUrl, headers } }; + const provider = await createLaunchedAppleRunnerProvider( + session.device, + { endpoint, launch }, + host.env, + ); + const { stop, release, prepare, isStarted, releaseSpeculative } = provider; + const isReady = provider.hasLiveSession; + const transport = await createAppleRunnerRuntime({ device: session.device, host, provider }); + + const prepareProducts = async () => { + emitDiagnostic({ level: 'info', phase: 'limrun.runner.prepare' }); + staging = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-limrun-runner-')); + const execution = session.client.simctl(['getenv', 'booted', 'USE_IP']); + const signal = AbortSignal.any([lifetime.signal, AbortSignal.timeout(15_000)]); + const bind = await awaitWithSignal(execution.wait(), signal, () => { + execution.stop(); + return signal.reason; + }); + if (bind.code !== 0) throw new AppError('COMMAND_FAILED', 'Limrun did not expose USE_IP'); + const directory = await prepareLimrunRunnerProducts({ + directory: staging, + version: readVersion(), + appPath: host.env.AGENT_DEVICE_LIMRUN_IOS_RUNNER_APP, + bindAddress: bind.stdout.trim(), + endpoint, + signal: lifetime.signal, + }); + lifetime.signal.throwIfAborted(); + await uploadLimrunRunnerProducts( + directory, + apiUrl, + session.token, + AbortSignal.any([lifetime.signal, AbortSignal.timeout(300_000)]), + ); + return { directory, bindAddress: bind.stdout.trim() }; + }; + const cachedProducts = () => { + lifetime.signal.throwIfAborted(); + return (products ??= prepareProducts().catch(async (error: unknown) => { + if (staging) await fs.rm(staging, { recursive: true, force: true }); + staging = undefined; + products = undefined; + throw error; + })); + }; + + async function launch(ownedEndpoint: typeof endpoint, startupSignal: AbortSignal) { + const abort = new AbortController(); + const signal = AbortSignal.any([lifetime.signal, abort.signal]); + const cancel = () => abort.abort(startupSignal.reason); + startupSignal.addEventListener('abort', cancel, { once: true }); + let response: Response; + let runRequested = false; + try { + startupSignal.throwIfAborted(); + const { directory, bindAddress } = await awaitWithSignal(cachedProducts(), signal); + await writeLimrunRunnerConfiguration(directory, bindAddress, ownedEndpoint); + await uploadLimrunRunnerProducts(directory, apiUrl, session.token, signal, [ + 'Runner.xctestrun', + ]); + emitDiagnostic({ + level: 'info', + phase: 'limrun.runner.launch', + data: { path: '/xctest/run', port }, + }); + const launchSignal = AbortSignal.any([signal, AbortSignal.timeout(90_000)]); + response = await retryWithPolicy( + async () => { + runRequested = true; + const result = await awaitWithSignal( + fetch(`${apiUrl}/xctest/run`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + root: path.basename(directory), + xctestrun: 'Runner.xctestrun', + }), + signal, + redirect: 'error', + }), + launchSignal, + ); + if (!result.ok) + throw new AppError('COMMAND_FAILED', `Limrun XCTest launch failed (${result.status})`, { + status: result.status, + response: await awaitWithSignal(result.text(), launchSignal).catch(() => undefined), + }); + return result; + }, + { + maxAttempts: 90, + baseDelayMs: 250, + maxDelayMs: 1_000, + shouldRetry: (error) => + canceledRunMayHoldSlot && error instanceof AppError && error.details?.status === 409, + }, + { signal: launchSignal, phase: 'limrun.runner.wait_for_canceled_run' }, + ); + if (!response.body) + throw new AppError('COMMAND_FAILED', 'Limrun XCTest launch returned no stream'); + canceledRunMayHoldSlot = false; + } catch (error) { + // An interrupted HTTP request can reach the server before it returns the run stream. + if (runRequested && !(error instanceof AppError && typeof error.details?.status === 'number')) + canceledRunMayHoldSlot = true; + abort.abort(error); + throw error; + } finally { + startupSignal.removeEventListener('abort', cancel); + } + const reader = response.body!.getReader(); + let settle!: () => void; + const exited = new Promise((resolve) => { + settle = resolve; + }); + async function* output() { + const decoder = new TextDecoder(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + yield decoder.decode(value, { stream: true }); + } + yield decoder.decode(); + } catch (error) { + canceledRunMayHoldSlot = true; + if (!signal.aborted) throw error; + } finally { + settle(); + } + } + return { + exited, + output: output(), + terminate: async () => { + // Limrun releases its XCTest slot after it observes the closed stream. + canceledRunMayHoldSlot = true; + abort.abort(); + await reader.cancel().catch(() => {}); + settle(); + await exited; + }, + }; + } + + const resetTarget = async (signal?: AbortSignal) => { + try { + await transport.resetTarget(signal); + } catch (error) { + await stop(); + throw error; + } + }; + const applicationTools = createLimrunAppleApplicationTools( + session.device, + { + prepareCache: async (signal) => { + await awaitWithSignal(cachedProducts(), signal); + }, + prewarm: async (execution, signal, options) => { + const input = { ...execution, signal, speculative: true }; + if (options?.healthCheck === false) { + await provider.ensure(input); + return; + } + await prepare(session.device, { ...input, healthTimeoutMs: RUNNER_STARTUP_TIMEOUT_MS }); + }, + prepare: (_device, input, signal) => transport.prepare(input, signal), + resetTarget, + isReady, + release, + isStarted, + stop, + releaseSpeculative, + }, + hostTools, + ); + return { + applicationTools, + bindDeployment: (request) => + withMethodScope({ ...transport.bindDeployment(request) }, (task) => + withTools(task, request.signal), + ), + facts: transport.facts, + createInteractor: (context = {}) => + withMethodScope(transport.createInteractor(context), (task) => + withTools(task, context.signal), + ), + bindRunnerOperations: transport.bindRunnerOperations, + bindApplicationLifecycle: (request) => { + const lifecycle = transport.bindApplicationLifecycle({ + ...request, + appleApplications: applicationTools, + ensureReady: async (device, signal, options) => { + await runXcrun(buildSimctlArgsForDevice(device, ['bootstatus', device.id, '-b']), { + signal, + ...(options?.deadlineAtMs === undefined + ? {} + : { + timeoutMs: Math.max(1, options.deadlineAtMs - Date.now()), + }), + }); + return { ...device, booted: true }; + }, + }); + return withMethodScope({ ...lifecycle }, (task) => withTools(task, request.signal)); + }, + resetTarget, + dispose: async () => { + await applicationTools.finalizeRunnerSessionsForShutdown(); + lifetime.abort(); + await products?.catch(() => {}); + if (staging) await fs.rm(staging, { recursive: true, force: true }); + }, + }; +} diff --git a/src/core/interactors/limrun-ios-tools.test.ts b/src/core/interactors/limrun-ios-tools.test.ts new file mode 100644 index 0000000000..b4926c7c3d --- /dev/null +++ b/src/core/interactors/limrun-ios-tools.test.ts @@ -0,0 +1,402 @@ +import { SimctlExecution } from '@limrun/api/ios-client'; +import { buildSimctlArgsForDevice } from '@agent-device/platform-apple/simctl'; +import { runXcrun, withAppleToolProvider } from '@agent-device/platform-apple/tool-provider'; +import { createLimrunAppleToolProvider } from './limrun-ios-tools.ts'; +import { expect, test, vi } from 'vitest'; +import path from 'node:path'; +import { promises as fs } from 'node:fs'; +import { AppError } from '@agent-device/kernel/errors'; +import { fixture } from './limrun-ios-runner.fixtures.ts'; + +async function sessionWithClient(result = { code: 0, stdout: '', stderr: '' }) { + const f = await fixture({ simctlResult: result }); + return { + ...f, + interactor: f.runner.createInteractor(), + simctl: vi.mocked(f.session.client.simctl), + }; +} + +test('malformed successful device inventory preserves the remote command output', async () => { + const f = await sessionWithClient(); + const execution = new SimctlExecution(() => {}); + execution._handleStdout(Buffer.from('not JSON')); + execution._handleStderr(Buffer.from('simulator warning')); + execution._handleExit(0); + f.simctl.mockReturnValue(execution); + const tools = createLimrunAppleToolProvider(f.session, new AbortController().signal); + await expect( + withAppleToolProvider(tools, () => + runXcrun(buildSimctlArgsForDevice(f.device, ['list', 'devices', '--json'])), + ), + ).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { + cmd: 'simctl', + args: ['list', 'devices', '--json'], + stdout: 'not JSON', + stderr: 'simulator warning', + }, + }); +}); + +test('launch arguments and explicit relaunch use the shared simctl launch', async () => { + const { interactor, simctl } = await sessionWithClient(); + await interactor.open('com.example.app', { + launchArgs: ['--flag', 'value'], + terminateRunningApp: true, + }); + expect(simctl).toHaveBeenCalledWith( + ['launch', '--terminate-running-process', 'booted', 'com.example.app', '--flag', 'value'], + undefined, + ); +}); + +test('normal launch uses the same simctl path as local', async () => { + const { interactor, simctl } = await sessionWithClient(); + await interactor.open('com.example.app'); + expect(simctl).toHaveBeenCalledWith(['launch', 'booted', 'com.example.app'], undefined); +}); + +test('a url open launches with arguments before opening the url', async () => { + const { interactor, simctl } = await sessionWithClient(); + await interactor.open('com.example.app', { + url: 'myapp://home', + launchArgs: ['--flag', 'value'], + }); + const calls = simctl.mock.calls.map((call) => call[0]); + expect(calls).toContainEqual(['launch', 'booted', 'com.example.app', '--flag', 'value']); + expect(calls).toContainEqual(['openurl', 'booted', 'myapp://home']); + expect(calls.findIndex((args) => args[0] === 'launch')).toBeLessThan( + calls.findIndex((args) => args[0] === 'openurl'), + ); +}); + +test('a failed launch preserves exit code and stderr through the shared error path', async () => { + const { interactor, simctl } = await sessionWithClient({ + code: 1, + stdout: '', + stderr: 'launch failed\n', + }); + const error = await interactor + .open('com.example.app', { launchArgs: ['--flag'] }) + .catch((error) => error); + expect(error).toBeInstanceOf(AppError); + expect(error).toMatchObject({ + code: 'COMMAND_FAILED', + details: { exitCode: 1, stderr: 'launch failed\n' }, + }); + expect(simctl.mock.calls.some((call) => call[0][0] === 'openurl')).toBe(false); +}); + +test('explicit relaunch uses the resolved bundle ID', async () => { + const { interactor, simctl } = await sessionWithClient(); + await interactor.open('Settings', { + appBundleId: 'com.apple.Preferences', + terminateRunningApp: true, + }); + expect(simctl).toHaveBeenCalledWith( + ['launch', '--terminate-running-process', 'booted', 'com.apple.Preferences'], + undefined, + ); +}); + +test('launch console writes remote process output through the local capture implementation', async () => { + const { interactor, simctl, directory } = await sessionWithClient({ + code: 0, + stdout: 'app says hello\n', + stderr: 'app warning\n', + }); + const logPath = path.join(directory, 'app.log'); + await interactor.open('com.example.app', { launchConsole: logPath }); + expect(simctl).toHaveBeenCalledWith( + ['launch', '--console-pty', 'booted', 'com.example.app'], + undefined, + ); + expect(await fs.readFile(logPath, 'utf8')).toContain('app says hello\napp warning\n'); +}); + +test('shared target resolution matches installed app display names', async () => { + const { runner, device } = await sessionWithClient(); + expect( + await runner.applicationTools.resolveOpenTarget(device, { target: 'Settings', surface: 'app' }), + ).toMatchObject({ appBundleId: 'com.apple.Preferences' }); +}); + +test('runtime hints write and clear the same native defaults as local', async () => { + const { runner, device, simctl } = await sessionWithClient(); + await runner.applicationTools.applyRuntimeHints(device, { + appId: 'com.example.app', + values: { metroHost: 'dev.example', metroPort: '8081' }, + }); + expect(simctl).toHaveBeenCalledWith( + [ + 'spawn', + 'booted', + 'defaults', + 'write', + 'com.example.app', + 'RCT_jsLocation', + '-string', + 'dev.example:8081', + ], + undefined, + ); + expect(simctl).toHaveBeenCalledWith( + [ + 'spawn', + 'booted', + 'defaults', + 'write', + 'com.example.app', + 'RCT_packager_scheme', + '-string', + 'http', + ], + undefined, + ); + await runner.applicationTools.clearRuntimeHints(device, { appId: 'com.example.app', values: {} }); + expect(simctl).toHaveBeenCalledWith( + ['spawn', 'booted', 'defaults', 'delete', 'com.example.app', 'RCT_jsLocation'], + undefined, + ); +}); + +test('reads the pasteboard through shared simctl pbpaste and normalizes line endings', async () => { + const f = await fixture({ simctlResult: { code: 0, stdout: 'first\r\nsecond\n', stderr: '' } }); + await expect(f.runner.createInteractor().readClipboard!()).resolves.toBe('first\nsecond'); + expect(vi.mocked(f.session.client.simctl)).toHaveBeenCalledWith(['pbpaste', 'booted'], undefined); +}); + +test('a failed pasteboard read preserves command failure evidence', async () => { + const f = await fixture({ simctlResult: { code: 1, stdout: '', stderr: 'pbpaste: refused\n' } }); + await expect(f.runner.createInteractor().readClipboard!()).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { exitCode: 1, stderr: 'pbpaste: refused\n' }, + }); +}); + +test('a host timeout stops the remote command and preserves partial console output', async () => { + const f = await fixture(); + const stopped = vi.fn(); + const execution = new SimctlExecution(stopped); + vi.mocked(f.session.client.simctl).mockReturnValue(execution); + const tools = createLimrunAppleToolProvider(f.session, new AbortController().signal); + const deadline = new AbortController(); + const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(deadline.signal); + try { + const pending = tools.runCommand( + 'xcrun', + ['simctl', 'launch', f.device.id, 'com.example.app'], + { timeoutMs: 10 }, + ); + const outcome = expect(pending).rejects.toMatchObject({ + details: { timeoutMs: 10, stdout: 'before timeout\n', stderr: 'warning\n' }, + }); + execution.emit('stdout', Buffer.from('before timeout\n')); + execution.emit('stderr', Buffer.from('warning\n')); + deadline.abort(); + await outcome; + expect(stopped).toHaveBeenCalledOnce(); + expect(execution.listenerCount('stdout')).toBe(0); + } finally { + timeout.mockRestore(); + } +}); + +for (const canceledBy of ['lease', 'deadline'] as const) { + test(`cleanup ignores the canceled request but still stops at its ${canceledBy}`, async () => { + const f = await fixture(); + const stopped = vi.fn(); + vi.mocked(f.session.client.simctl).mockReturnValue(new SimctlExecution(stopped)); + const lease = new AbortController(); + const deadline = new AbortController(); + const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(deadline.signal); + try { + const tools = createLimrunAppleToolProvider(f.session, lease.signal, AbortSignal.abort()); + const pending = tools.runCommand('xcrun', ['simctl', 'status_bar', f.device.id, 'clear'], { + requestCancellation: 'ignore', + timeoutMs: 30_000, + }); + const outcome = expect(pending).rejects.toMatchObject({ + details: canceledBy === 'deadline' ? { timeoutMs: 30_000 } : { reason: 'request_canceled' }, + }); + expect(stopped).not.toHaveBeenCalled(); + (canceledBy === 'lease' ? lease : deadline).abort(); + await outcome; + expect(stopped).toHaveBeenCalledOnce(); + } finally { + timeout.mockRestore(); + } + }); +} + +test('canceling an app catalog request releases the caller without waiting for the server', async () => { + const f = await fixture(); + vi.spyOn(f.session.client, 'listApps').mockImplementation(() => new Promise(() => {})); + const abort = new AbortController(); + const tools = createLimrunAppleToolProvider( + f.session, + new AbortController().signal, + abort.signal, + ); + const pending = tools.runCommand('xcrun', ['simctl', 'listapps', f.device.id]); + const outcome = expect(pending).rejects.toMatchObject({ + details: { reason: 'request_canceled' }, + }); + abort.abort(); + await outcome; +}); + +test('canceling a lifecycle runtime-hint command stops the remote defaults process', async () => { + const f = await fixture(); + const stop = vi.fn(); + vi.mocked(f.session.client.simctl).mockReturnValue(new SimctlExecution(stop)); + const controller = new AbortController(); + const lifecycle = f.runner.bindApplicationLifecycle({ + signal: controller.signal, + clock: { now: Date.now, sleep: async () => {} }, + resolveInteractor: async () => f.runner.createInteractor(), + }); + const pending = lifecycle.applyRuntimeHints({ + appId: 'com.example.app', + values: { metroHost: 'parity.example', metroPort: '8081' }, + }); + const outcome = expect(pending).rejects.toMatchObject({ + details: { reason: 'request_canceled' }, + }); + await vi.waitFor(() => expect(f.session.client.simctl).toHaveBeenCalled()); + controller.abort(); + await outcome; + expect(stop).toHaveBeenCalledOnce(); +}); + +test.each(['app', 'data', 'group.com.example.shared'])( + 'downloads a file from a %s container through the authenticated host transport', + async (containerType) => { + const sourcePath = 'Documents/a file #1.bin'; + const bytes = Buffer.from([0, 1, 128, 255]); + const f = await fixture({ + containers: { 'com.example.app': { [containerType]: { [sourcePath]: bytes } } }, + }); + const tools = createLimrunAppleToolProvider(f.session, new AbortController().signal); + const destinationPath = path.join(f.directory, 'download', 'file.bin'); + await tools.simulatorFiles!.copyToHost({ + device: f.device, + bundleId: 'com.example.app', + containerType, + sourcePath, + destinationPath, + timeoutMs: 1000, + }); + expect(await fs.readFile(destinationPath)).toEqual(bytes); + const url = new URL(f.requests.at(-1)!, 'http://fixture'); + expect(Object.fromEntries(url.searchParams)).toEqual({ + name: sourcePath, + bundleId: 'com.example.app', + containerType, + }); + }, +); + +test('a missing container file reports HTTP failure and leaves no local artifact', async () => { + const f = await fixture(); + const tools = createLimrunAppleToolProvider(f.session, new AbortController().signal); + const destinationPath = path.join(f.directory, 'missing.bin'); + await expect( + tools.simulatorFiles!.copyToHost({ + device: f.device, + bundleId: 'com.example.app', + containerType: 'data', + sourcePath: 'Documents/missing.bin', + destinationPath, + timeoutMs: 1000, + }), + ).rejects.toMatchObject({ code: 'COMMAND_FAILED', details: { statusCode: 404 } }); + await expect(fs.stat(destinationPath)).rejects.toMatchObject({ code: 'ENOENT' }); +}); + +test('canceling a container download stops the transfer and removes the partial artifact', async () => { + let markStarted = () => {}; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const f = await fixture({ + containers: { 'com.example.app': { data: { 'tmp/large.bin': Buffer.from('partial') } } }, + holdFileDownload: true, + onFileDownload: markStarted, + }); + const controller = new AbortController(); + const tools = createLimrunAppleToolProvider( + f.session, + new AbortController().signal, + controller.signal, + ); + const destinationPath = path.join(f.directory, 'canceled.bin'); + const pending = tools.simulatorFiles!.copyToHost({ + device: f.device, + bundleId: 'com.example.app', + containerType: 'data', + sourcePath: 'tmp/large.bin', + destinationPath, + timeoutMs: 1000, + }); + const reason = new Error('request canceled'); + const outcome = expect(pending).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'request_canceled' }, + }); + await started; + controller.abort(reason); + await outcome; + await expect(fs.stat(destinationPath)).rejects.toMatchObject({ code: 'ENOENT' }); +}); + +test.each(['timeout', 'request'] as const)( + 'app discovery distinguishes a %s abort without falling through to a local tool', + async (source) => { + const f = await fixture(); + const deadline = new AbortController(); + const request = new AbortController(); + let started!: () => void; + const listing = new Promise((resolve) => { + started = resolve; + }); + let complete!: (apps: []) => void; + f.session.client.listApps = vi.fn(() => { + started(); + return new Promise<[]>((resolve) => { + complete = resolve; + }); + }); + const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(deadline.signal); + const tools = createLimrunAppleToolProvider( + f.session, + new AbortController().signal, + request.signal, + ); + try { + const { withAppleToolProvider } = await import('@agent-device/platform-apple/tool-provider'); + const { resolveIosSimulatorDeepLinkBundleId } = + await import('@agent-device/platform-apple/app-resolution'); + const pending = withAppleToolProvider(tools, () => + resolveIosSimulatorDeepLinkBundleId(f.device, 'example://route', { timeoutMs: 1000 }), + ); + const outcome = expect(pending).rejects.toSatisfy( + (error: unknown) => + error instanceof AppError && + error.code === 'COMMAND_FAILED' && + (source === 'timeout' + ? error.details?.timeoutMs !== undefined && error.details?.reason !== 'request_canceled' + : error.details?.reason === 'request_canceled'), + ); + await listing; + (source === 'timeout' ? deadline : request).abort(); + await outcome; + expect(f.session.client.simctl).not.toHaveBeenCalled(); + } finally { + complete([]); + timeout.mockRestore(); + } + }, +); diff --git a/src/core/interactors/limrun-ios-tools.ts b/src/core/interactors/limrun-ios-tools.ts new file mode 100644 index 0000000000..a3b1d808d5 --- /dev/null +++ b/src/core/interactors/limrun-ios-tools.ts @@ -0,0 +1,155 @@ +import { awaitWithSignal } from '@agent-device/host-kit/request'; +import { createLimrunSimulatorFiles } from './limrun-ios-files.ts'; +import type { + AppleToolProvider, + AppleToolExecOptions, +} from '@agent-device/platform-apple/tool-provider'; +import { execFailureDetails, type ExecResult } from '@agent-device/host-kit/command'; +import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors'; +import type { LimrunRuntimeDependencies } from '@agent-device/provider-limrun'; + +type Session = Parameters[0]; + +/** Runs the shared Apple host commands against the lease's simulator. */ +export function createLimrunAppleToolProvider( + session: Session, + lifetime: AbortSignal, + requestSignal?: AbortSignal, +): AppleToolProvider { + const unavailable = async (): Promise => { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'This host tool is unavailable on a Limrun simulator', + ); + }; + const operation = (options: AppleToolExecOptions = {}) => { + const deadline = + options.timeoutMs === undefined ? undefined : AbortSignal.timeout(options.timeoutMs); + const signal = AbortSignal.any( + [ + lifetime, + options.requestCancellation === 'ignore' ? undefined : requestSignal, + options.signal, + deadline, + ].filter((s): s is AbortSignal => Boolean(s)), + ); + const abortError = (details: Record = {}) => + deadline?.aborted + ? new AppError('COMMAND_FAILED', 'Remote simulator operation timed out', { + timeoutMs: options.timeoutMs, + ...details, + }) + : createRequestCanceledError(); + if (signal.aborted) throw abortError(); + return { signal, abortError }; + }; + const runSimctl = async ( + input: readonly string[], + options: AppleToolExecOptions = {}, + ): Promise => { + const args = [...input]; + if (args[0] === '--set') args.splice(0, 2); + const target = args.indexOf(session.device.id); + if (target !== -1) args[target] = 'booted'; + const { signal, abortError } = operation(options); + const execution = session.client.simctl( + args, + options.stdin === undefined ? undefined : { stdin: String(options.stdin) }, + ); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + const onStdout = (chunk: Buffer) => stdoutChunks.push(chunk); + const onStderr = (chunk: Buffer) => stderrChunks.push(chunk); + execution.on('stdout', onStdout); + execution.on('stderr', onStderr); + try { + const result = await awaitWithSignal(execution.wait(), signal, () => { + execution.stop(); + return abortError({ + cmd: 'simctl', + args, + stdout: Buffer.concat(stdoutChunks).toString(), + stderr: Buffer.concat(stderrChunks).toString(), + }); + }); + let stdout = result.stdout; + if (args[0] === 'list' && args.includes('devices') && result.code === 0) { + try { + stdout = normalizeDeviceInventory(stdout, session); + } catch (error) { + throw new AppError( + 'COMMAND_FAILED', + 'Invalid simulator device inventory', + { + cmd: 'simctl', + args, + stdout, + stderr: result.stderr, + }, + error, + ); + } + } + if (result.code !== 0 && !options.allowFailure) { + throw new AppError('COMMAND_FAILED', `xcrun exited with code ${result.code}`, { + cmd: 'simctl', + args, + ...execFailureDetails({ exitCode: result.code, stdout, stderr: result.stderr }), + }); + } + return { exitCode: result.code, stdout, stderr: result.stderr }; + } finally { + execution.off('stdout', onStdout); + execution.off('stderr', onStderr); + } + }; + const { simulatorFiles, readInfoPlist } = createLimrunSimulatorFiles( + session, + lifetime, + runSimctl, + requestSignal, + ); + return { + simctl: { run: runSimctl }, + devicectl: { run: unavailable }, + runCommand: async (command, args, options) => { + if (command === 'xcrun' && args[0] === 'simctl') + return await runSimctl(args.slice(1), options); + return await unavailable(); + }, + whichCommand: async (command) => command === 'xcrun', + plist: { readJson: async () => null }, + simulatorScreenshotBackend: 'runner', + simulatorFiles, + simulatorApps: { + readInfoPlist, + list: async (_device, options) => { + const { signal, abortError } = operation(options); + const apps = await awaitWithSignal(session.client.listApps(), signal, () => + abortError({ cmd: 'listapps' }), + ); + if (signal.aborted) throw abortError({ cmd: 'listapps' }); + return { + type: 'listed', + apps: apps.map((app) => ({ + bundleId: app.bundleId, + name: app.name, + applicationType: app.installType, + })), + }; + }, + }, + }; +} + +function normalizeDeviceInventory(stdout: string, session: Session): string { + const inventory = JSON.parse(stdout) as { + devices?: Record>; + }; + for (const devices of Object.values(inventory.devices ?? {})) { + for (const device of devices) { + if (device.udid === session.client.deviceInfo.udid) device.udid = session.device.id; + } + } + return JSON.stringify(inventory); +} diff --git a/src/sdk/limrun-download-file.test.ts b/src/core/limrun-download-file.test.ts similarity index 75% rename from src/sdk/limrun-download-file.test.ts rename to src/core/limrun-download-file.test.ts index 2d6e03ef90..5547c56b61 100644 --- a/src/sdk/limrun-download-file.test.ts +++ b/src/core/limrun-download-file.test.ts @@ -2,8 +2,8 @@ import fs from 'node:fs'; import http from 'node:http'; import path from 'node:path'; import type { AddressInfo } from 'node:net'; -import { afterEach, expect, test } from 'vitest'; -import { AppError } from '@agent-device/kernel/errors'; +import { afterEach, expect, test, vi } from 'vitest'; +import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors'; import { mkdtempForTest } from '@agent-device/test-kit'; import { downloadLimrunFile } from './limrun-download-file.ts'; @@ -152,3 +152,52 @@ test('an error body is read only up to the preview, without waiting for the rest details: { statusCode: 502, body: 'x'.repeat(500) }, }); }); + +test.each(['default', 'typed', 'deadline'] as const)( + 'a %s abort keeps its failure classification and removes partial downloads', + async (kind) => { + let markStarted!: () => void; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const url = await serve((_request, response) => { + response.writeHead(200); + response.write('partial'); + markStarted(); + }); + const destinationPath = path.join(await mkdtempForTest('limrun-download-'), 'clip.mp4'); + const controller = new AbortController(); + const reason = + kind === 'typed' + ? createRequestCanceledError() + : kind === 'deadline' + ? new DOMException('deadline expired', 'TimeoutError') + : undefined; + const pending = downloadLimrunFile({ + url, + headers: {}, + destinationPath, + timeoutMs: 5000, + signal: controller.signal, + }); + const outcome = + kind === 'typed' + ? expect(pending).rejects.toBe(reason) + : kind === 'default' + ? expect(pending).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'request_canceled' }, + }) + : expect(pending).rejects.toSatisfy( + (error: AppError) => + error instanceof AppError && + error.code === 'COMMAND_FAILED' && + error.cause === reason, + ); + await started; + await vi.waitFor(() => expect(fs.existsSync(destinationPath)).toBe(true)); + controller.abort(reason); + await outcome; + expect(fs.existsSync(destinationPath)).toBe(false); + }, +); diff --git a/src/sdk/limrun-download-file.ts b/src/core/limrun-download-file.ts similarity index 81% rename from src/sdk/limrun-download-file.ts rename to src/core/limrun-download-file.ts index 80a8c01a78..f7dc0f2f89 100644 --- a/src/sdk/limrun-download-file.ts +++ b/src/core/limrun-download-file.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import type { ReadableStream as WebReadableStream } from 'node:stream/web'; -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors'; import type { LimrunFileDownload } from '@agent-device/provider-limrun'; const RESPONSE_BODY_PREVIEW_BYTES = 500; @@ -15,12 +15,13 @@ const RESPONSE_BODY_PREVIEW_BYTES = 500; */ export async function downloadLimrunFile(options: LimrunFileDownload): Promise { const timeout = AbortSignal.timeout(options.timeoutMs); + const signal = options.signal ? AbortSignal.any([timeout, options.signal]) : timeout; try { await fs.promises.mkdir(path.dirname(options.destinationPath), { recursive: true }); const response = await fetch(options.url, { method: 'GET', headers: options.headers, - signal: timeout, + signal, }); if (!response.ok) throw await httpFailure(response, options); if (!response.body) { @@ -31,10 +32,23 @@ export async function downloadLimrunFile(options: LimrunFileDownload): Promise), fs.createWriteStream(options.destinationPath), - { signal: timeout }, + { signal }, ); } catch (error) { await fs.promises.rm(options.destinationPath, { force: true }).catch(() => {}); + if (options.signal?.aborted && signal.reason === options.signal.reason) { + const reason: unknown = options.signal.reason; + if (reason instanceof AppError) throw reason; + if (reason instanceof Error && reason.name === 'TimeoutError') { + throw new AppError( + 'COMMAND_FAILED', + 'Limrun download deadline expired', + { url: options.url }, + reason, + ); + } + throw createRequestCanceledError(undefined, reason); + } throw downloadFailure(error, options, timeout); } } diff --git a/src/platform-runtime-gateway.fixtures.ts b/src/platform-runtime-gateway.fixtures.ts index f8ee366265..92ebe225db 100644 --- a/src/platform-runtime-gateway.fixtures.ts +++ b/src/platform-runtime-gateway.fixtures.ts @@ -19,7 +19,6 @@ import type { import type { PlatformRequestScope } from '@agent-device/contracts/platform-runtime-host'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { LimrunRuntimeDependencies } from '@agent-device/provider-limrun'; -import { applySimctlSetting } from '@agent-device/platform-apple/simctl-settings'; import { createUnavailableRuntimeFactsForTest, unavailableDeploymentSnapshotAndShutdownOperationFacts, @@ -210,9 +209,12 @@ function lifecycleOperations(): ApplicationLifecycleRuntimeOperations { }; } -export const limrunTestDependencies = { +export const limrunTestDependencies: LimrunRuntimeDependencies = { clientVersion: 'test', android: { + bindDeployment: async () => { + throw new Error('Unexpected deployment binding'); + }, createInteractor: () => ({}) as Interactor, createPortReverse: async () => ({ ensure: async () => {}, @@ -250,8 +252,66 @@ export const limrunTestDependencies = { archiveDirectory: async () => {}, }, ios: { - applySimctlSetting, + createAppLogReader: ({ client }) => ({ + readLogs: async (app, limit) => client.appLogTail(app, limit), + [Symbol.asyncDispose]: async () => {}, + }), + createRunner: async (session) => createTestLimrunIosRunner(session.device), resolveAppAlias: async (app: string) => app, readBundleAppName: async () => undefined, }, } satisfies LimrunRuntimeDependencies; + +export async function createTestLimrunIosRunner( + device: DeviceInfo, + respond?: (command: Record) => Promise>, +): Promise>> { + const { createAppleRunnerRuntime } = + await import('@agent-device/platform-apple/runner/operations'); + const { createLimrunAppleApplicationTools } = + await import('./core/interactors/limrun-ios-application-tools.ts'); + const runner = await createAppleRunnerRuntime({ + device, + host: { env: {}, cwd: process.cwd() }, + provider: { + hasLiveSession: () => false, + runCommand: async (_device, command) => { + if (!respond) throw new Error('Unexpected XCTest runner command'); + return await respond(command); + }, + }, + }); + const applicationTools = createLimrunAppleApplicationTools( + device, + { + prepareCache: async () => {}, + prewarm: async () => {}, + release: async () => {}, + prepare: (_device, input, signal) => runner.prepare(input, signal), + resetTarget: async () => {}, + isReady: () => false, + isStarted: () => false, + stop: async () => {}, + releaseSpeculative: async () => false, + }, + { + resolveOpenTarget: async (_device, input) => ({ + appBundleId: input.target ?? input.currentAppBundleId, + appName: input.target, + }), + applyRuntimeHints: async () => {}, + clearRuntimeHints: async () => {}, + }, + ); + return { + ...runner, + applicationTools, + bindApplicationLifecycle: (request) => + runner.bindApplicationLifecycle({ + ...request, + appleApplications: applicationTools, + ensureReady: async (target) => target, + }), + dispose: async () => {}, + }; +} diff --git a/src/sdk/limrun-runtime-dependencies.test.ts b/src/sdk/limrun-runtime-dependencies.test.ts index c6510102fa..5cadb9ca74 100644 --- a/src/sdk/limrun-runtime-dependencies.test.ts +++ b/src/sdk/limrun-runtime-dependencies.test.ts @@ -6,7 +6,6 @@ const moduleLoads = vi.hoisted(() => ({ androidLogcat: 0, appleAppResolution: 0, appleInstallArtifact: 0, - appleSimctlSettings: 0, })); vi.mock('@limrun/api', () => ({ @@ -36,11 +35,6 @@ vi.mock('@agent-device/platform-apple/app-resolution', async (importOriginal) => }), })); -vi.mock('@agent-device/platform-apple/simctl-settings', async (importOriginal) => { - moduleLoads.appleSimctlSettings += 1; - return await importOriginal(); -}); - vi.mock('@agent-device/platform-apple/install-artifact', async (importOriginal) => ({ ...(await importOriginal()), readIosBundleInfo: vi.fn(async () => { @@ -58,7 +52,6 @@ test('Limrun construction defers operation and opposite-platform helper modules' androidLogcat: 0, appleAppResolution: 0, appleInstallArtifact: 0, - appleSimctlSettings: 0, }); await runtime.shutdown(); @@ -246,30 +239,3 @@ test('the adb invocation adapters address through the platform builders', async assert.deepEqual(host, androidAdbInvocation(androidAdbHostTarget(), host.command)); assert.deepEqual(serializeAndroidAdbInvocation(host), ['disconnect', '127.0.0.1:62001']); }); - -test('Limrun iOS settings evaluate the Apple simctl plan module on the first setting', async () => { - const { createLimrunRuntimeDependencies } = await import('./limrun-runtime-dependencies.ts'); - const dependencies = createLimrunRuntimeDependencies({ env: process.env, cwd: process.cwd() }); - const runSimctl = vi.fn(async (_args: string[]) => ({ stdout: '', stderr: '' })); - const grantPhotos = { - runSimctl, - udid: 'booted', - deviceId: 'limrun:ios:lease-a', - setting: 'permission', - state: 'grant', - appBundleId: 'com.example.app', - resolveApp: async (app: string) => app, - options: { permissionTarget: 'photos', permissionMode: 'limited' }, - } as const; - - assert.equal(moduleLoads.appleSimctlSettings, 0); - - await dependencies.ios.applySimctlSetting(grantPhotos); - await dependencies.ios.applySimctlSetting(grantPhotos); - - assert.equal(moduleLoads.appleSimctlSettings, 1); - assert.deepEqual(runSimctl.mock.calls, [ - [['privacy', 'booted', 'grant', 'photos-add', 'com.example.app']], - [['privacy', 'booted', 'grant', 'photos-add', 'com.example.app']], - ]); -}); diff --git a/src/sdk/limrun-runtime-dependencies.ts b/src/sdk/limrun-runtime-dependencies.ts index 9ebff17d29..a8920dece1 100644 --- a/src/sdk/limrun-runtime-dependencies.ts +++ b/src/sdk/limrun-runtime-dependencies.ts @@ -1,3 +1,4 @@ +import { createLimrunIosAppLogReader } from '../core/interactors/limrun-ios-app-logs.ts'; import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { withMethodScope } from '@agent-device/kernel/scoped-provider'; import { AppError } from '@agent-device/kernel/errors'; @@ -21,6 +22,14 @@ export function createLimrunRuntimeDependencies(host: HostEnvironment): LimrunRu return { clientVersion: readVersion(), android: { + bindDeployment: async (device, adb, request) => { + const { createAndroidAppDeploymentOperations, withAndroidAdbProvider } = + await import('@agent-device/platform-android/mechanics'); + const operations = await createAndroidAppDeploymentOperations({ ...request, device }); + return withMethodScope({ ...operations }, (task) => + runOnHost(() => withAndroidAdbProvider(adb, { serial: device.id }, task)), + ); + }, createInteractor: (device, adb) => withMethodScope(createAndroidInteractor(device, adb), runOnHost), createPortReverse: async (adb, options) => { @@ -95,7 +104,7 @@ export function createLimrunRuntimeDependencies(host: HostEnvironment): LimrunRu { runAdb: async (invocation, options) => await runAndroidHostAdb(invocation, options), downloadFile: async (options) => { - const { downloadLimrunFile } = await import('./limrun-download-file.ts'); + const { downloadLimrunFile } = await import('../core/limrun-download-file.ts'); await downloadLimrunFile(options); }, archiveDirectory: async ({ sourceDirectory, entryName, archivePath }) => { @@ -114,25 +123,31 @@ export function createLimrunRuntimeDependencies(host: HostEnvironment): LimrunRu }, runOnHost, ), - ios: withMethodScope( - { - applySimctlSetting: async (request) => { - const { applySimctlSetting } = - await import('@agent-device/platform-apple/simctl-settings'); - return await applySimctlSetting(request); - }, - resolveAppAlias: async (app) => { - const { resolveIosAppAlias } = - await import('@agent-device/platform-apple/app-resolution'); - return resolveIosAppAlias(app); + ios: { + // The scoped wrapper returns promises; this factory must return its reader synchronously. + createAppLogReader: createLimrunIosAppLogReader, + ...withMethodScope>( + { + createRunner: async (session) => { + const { createLimrunIosRunner } = + await import('../core/interactors/limrun-ios-runner.ts'); + const { createAppleApplicationTools } = + await import('../platform-runtime-apple-application-tools.ts'); + return createLimrunIosRunner(session, host, createAppleApplicationTools(host.env)); + }, + resolveAppAlias: async (app) => { + const { resolveIosAppAlias } = + await import('@agent-device/platform-apple/app-resolution'); + return resolveIosAppAlias(app); + }, + readBundleAppName: async (appPath) => { + const { readIosBundleInfo } = + await import('@agent-device/platform-apple/install-artifact'); + return (await readIosBundleInfo(appPath)).appName; + }, }, - readBundleAppName: async (appPath) => { - const { readIosBundleInfo } = - await import('@agent-device/platform-apple/install-artifact'); - return (await readIosBundleInfo(appPath)).appName; - }, - }, - runOnHost, - ), + runOnHost, + ), + }, }; } diff --git a/test/integration/provider-scenarios/limrun-deployment-cancellation.fixtures.ts b/test/integration/provider-scenarios/limrun-deployment-cancellation.fixtures.ts index e4238e7f11..bc1783e961 100644 --- a/test/integration/provider-scenarios/limrun-deployment-cancellation.fixtures.ts +++ b/test/integration/provider-scenarios/limrun-deployment-cancellation.fixtures.ts @@ -19,12 +19,37 @@ export const LIMRUN_IOS_APPS = [ { bundleId: 'com.example.ios', name: 'Example', installType: 'User' }, ]; -export async function bindLimrunDeployment(platform: 'android' | 'ios', signal: AbortSignal) { - const registration = createLimrunRuntime({ apiKey: 'limrun-test-key' }, limrunDependencies(), { +export async function bindLimrunDeployment( + platform: 'android' | 'ios', + signal: AbortSignal, + ios: LimrunRuntimeDependencies['ios'] = limrunTestDependencies.ios, +) { + const registration = createLimrunRuntime({ apiKey: 'limrun-test-key' }, limrunDependencies(ios), { includePlatformModule: true, }); const device = await allocatedDevice(registration.runtime, lease(platform)); + const deployment = { + prepareArtifact: async (input: { source: { path: string } }) => ({ + installablePath: input.source.path, + bundleId: 'com.example.ios', + packageName: 'com.example.android', + cleanup: async () => {}, + }), + withInvalidatedAppResolutionCache: async ( + _device: unknown, + operation: () => Promise, + ) => operation(), + }; const owner = await registration.platformModule.loadRuntime({ + appleDeployment: deployment, + androidDeployment: deployment, + deviceReadiness: { + appleAutomation: { + wasRecentlyObservedBooted: async () => true, + keepHot: () => {}, + markBooted: () => {}, + }, + }, snapshot: { captureSurface: async () => ({ backend: 'xctest' as const, @@ -99,10 +124,15 @@ function lease(platform: 'android' | 'ios'): DeviceLease { }; } -function limrunDependencies(): LimrunRuntimeDependencies { +function limrunDependencies(ios: LimrunRuntimeDependencies['ios']): LimrunRuntimeDependencies { return { clientVersion: 'test', android: { + bindDeployment: async (device, _adb, request) => { + const { createAndroidAppDeploymentOperations } = + await import('@agent-device/platform-android/mechanics'); + return createAndroidAppDeploymentOperations({ ...request, device }); + }, createInteractor: () => ({}) as never, createPortReverse: async () => ({ ensure: async () => {}, @@ -137,6 +167,6 @@ function limrunDependencies(): LimrunRuntimeDependencies { downloadFile: async () => undefined, archiveDirectory: async () => {}, }, - ios: limrunTestDependencies.ios, + ios, }; } diff --git a/test/integration/provider-scenarios/limrun-deployment-cancellation.test.ts b/test/integration/provider-scenarios/limrun-deployment-cancellation.test.ts index fa469a76bd..f64f56fcfa 100644 --- a/test/integration/provider-scenarios/limrun-deployment-cancellation.test.ts +++ b/test/integration/provider-scenarios/limrun-deployment-cancellation.test.ts @@ -223,8 +223,7 @@ test('bound Limrun iOS deployment cancels an in-flight installApp call', async ( await expect(pending).rejects.toBe(abortReason); expect(uploadSignal()).toBe(controller.signal); expect(state.iosInstallApp).toHaveBeenCalledWith(LIMRUN_DEPLOYMENT_ASSET.signedDownloadUrl, { - md5: LIMRUN_DEPLOYMENT_ASSET.md5, - launchMode: 'ForegroundIfRunning', + launchMode: undefined, }); } finally { install.resolve({ bundleId: 'com.example.ios' }); diff --git a/test/integration/provider-scenarios/limrun-ios-appstate.test.ts b/test/integration/provider-scenarios/limrun-ios-appstate.test.ts index 428d95e673..62d58fe37b 100644 --- a/test/integration/provider-scenarios/limrun-ios-appstate.test.ts +++ b/test/integration/provider-scenarios/limrun-ios-appstate.test.ts @@ -1,8 +1,14 @@ import assert from 'node:assert/strict'; +import { withMethodScope } from '@agent-device/kernel/scoped-provider'; +import { withAppleToolProvider } from '@agent-device/platform-apple/tool-provider'; +import { createLimrunAppleToolProvider } from '../../../src/core/interactors/limrun-ios-tools.ts'; import { beforeEach, test, vi } from 'vitest'; import type { DeviceLease } from '@agent-device/contracts/device'; import { createLimrunRuntime, type LimrunRuntimeDependencies } from '@agent-device/provider-limrun'; -import { limrunTestDependencies } from '../../../src/platform-runtime-gateway.fixtures.ts'; +import { + limrunTestDependencies, + createTestLimrunIosRunner, +} from '../../../src/platform-runtime-gateway.fixtures.ts'; import { createProviderDeviceRuntimeRequestProviders } from '../../../src/provider-device-runtime.ts'; import { assertRpcError, assertRpcOk } from './assertions.ts'; import { createProviderScenarioHarness } from './harness.ts'; @@ -11,7 +17,7 @@ const BUNDLE_ID = 'com.apple.Preferences'; const limrunApiState = vi.hoisted(() => ({ listApps: vi.fn(), - launchApp: vi.fn(async () => undefined), + simctl: vi.fn(), })); vi.mock('@limrun/api', () => ({ @@ -31,21 +37,36 @@ vi.mock('@limrun/api', () => ({ }, })); -vi.mock('@limrun/api/ios-client', () => ({ - createInstanceClient: vi.fn(async () => ({ - deviceInfo: { screenWidth: 320, screenHeight: 240 }, - disconnect: vi.fn(), - launchApp: limrunApiState.launchApp, - listApps: limrunApiState.listApps, - })), -})); +vi.mock('@limrun/api/ios-client', async (importOriginal) => { + const actual = await importOriginal(); + limrunApiState.simctl.mockImplementation((args: string[]) => { + const execution = new actual.SimctlExecution(() => {}); + if (args[0] === 'list') + execution._handleStdout( + Buffer.from( + JSON.stringify({ devices: { ios: [{ udid: 'remote-udid', state: 'Booted' }] } }), + ), + ); + execution._handleExit(0); + return execution; + }); + return { + createInstanceClient: vi.fn(async () => ({ + deviceInfo: { udid: 'remote-udid', screenWidth: 320, screenHeight: 240 }, + disconnect: vi.fn(), + simctl: limrunApiState.simctl, + listApps: limrunApiState.listApps, + })), + }; +}); beforeEach(() => { limrunApiState.listApps.mockReset(); - limrunApiState.launchApp.mockClear(); + limrunApiState.simctl.mockClear(); }); -test('Limrun iOS appstate stops answering from the session once the instance is gone', async () => { +test('Limrun iOS appstate refuses a gone instance', async () => { + const target = BUNDLE_ID; limrunApiState.listApps.mockResolvedValue([]); const registration = createLimrunRuntime( { apiKey: 'limrun-test-key' }, @@ -81,8 +102,12 @@ test('Limrun iOS appstate stops answering from the session once the instance is deviceKey: 'ios-a', clientId: 'client-a', }; - assertRpcOk(await daemon.callCommand('open', [BUNDLE_ID], flags, { meta })); - assert.equal(limrunApiState.launchApp.mock.calls.length, 1); + assertRpcOk(await daemon.callCommand('open', [target], flags, { meta })); + assert.ok( + limrunApiState.simctl.mock.calls.some( + ([args]) => args[0] === (target === BUNDLE_ID ? 'launch' : 'openurl'), + ), + ); const live = assertRpcOk<{ appBundleId?: string; source?: string }>( await daemon.callCommand('appstate', [], flags, { meta }), @@ -127,6 +152,22 @@ function limrunRuntimeDependencies(): LimrunRuntimeDependencies { clientVersion: 'test-version', android: {} as LimrunRuntimeDependencies['android'], host: {} as LimrunRuntimeDependencies['host'], - ios: limrunTestDependencies.ios, + ios: { + ...limrunTestDependencies.ios, + createRunner: async (session) => { + const runner = await createTestLimrunIosRunner(session.device); + const lifetime = new AbortController().signal; + return { + ...runner, + createInteractor: (context) => + withMethodScope(runner.createInteractor(context), (task) => + withAppleToolProvider( + createLimrunAppleToolProvider(session, lifetime, context?.signal), + task, + ), + ), + }; + }, + }, }; } diff --git a/test/integration/provider-scenarios/limrun-ios-settings.test.ts b/test/integration/provider-scenarios/limrun-ios-settings.test.ts index 4d417dd0dd..ae337c9a41 100644 --- a/test/integration/provider-scenarios/limrun-ios-settings.test.ts +++ b/test/integration/provider-scenarios/limrun-ios-settings.test.ts @@ -1,3 +1,5 @@ +import { EventEmitter } from 'node:events'; +import { createLimrunRuntimeDependencies } from '../../../src/sdk/limrun-runtime-dependencies.ts'; import { afterEach, expect, test, vi } from 'vitest'; import { bindLimrunDeployment } from './limrun-deployment-cancellation.fixtures.ts'; @@ -23,7 +25,27 @@ vi.mock('@limrun/api', () => ({ vi.mock('@limrun/api/ios-client', () => ({ createInstanceClient: vi.fn(async () => ({ disconnect: vi.fn(async () => undefined), - simctl: state.simctl, + simctl: (args: string[]) => + Object.assign( + new EventEmitter(), + args[0] === 'list' + ? { + wait: async () => ({ + code: 0, + stderr: '', + stdout: JSON.stringify({ + devices: { + runtime: [ + { udid: 'ios-device', name: 'iPhone', state: 'Booted', isAvailable: true }, + ], + }, + }), + }), + } + : state.simctl(args), + { stop() {} }, + ), + listApps: async () => [{ bundleId: 'com.example.ios', name: 'Example', installType: 'User' }], deviceInfo: { udid: 'ios-device', screenWidth: 402, screenHeight: 874, model: 'iPhone' }, })), })); @@ -41,7 +63,11 @@ test('a Limrun iOS permission the runtime refuses reports the local unsupported 'An error was encountered processing the command (domain=NSPOSIXErrorDomain, code=1):\nSimulator device failed to complete the requested operation.\nOperation not permitted\nUnderlying error (domain=NSPOSIXErrorDomain, code=1):\n\tFailed to set access\n\tOperation not permitted', }), })); - const { binding, dispose } = await bindLimrunDeployment('ios', new AbortController().signal); + const { binding, dispose } = await bindLimrunDeployment( + 'ios', + new AbortController().signal, + createLimrunRuntimeDependencies({ env: {}, cwd: process.cwd() }).ios, + ); try { const refusal = binding.operations.setSetting!({ setting: 'permission', @@ -68,7 +94,11 @@ test('a Limrun iOS simctl failure that is not a refusal stays a command failure' state.simctl.mockImplementation(() => ({ wait: async () => ({ code: 2, stdout: '', stderr: 'Invalid device: booted' }), })); - const { binding, dispose } = await bindLimrunDeployment('ios', new AbortController().signal); + const { binding, dispose } = await bindLimrunDeployment( + 'ios', + new AbortController().signal, + createLimrunRuntimeDependencies({ env: {}, cwd: process.cwd() }).ios, + ); try { await expect( binding.operations.setSetting!({ diff --git a/test/integration/provider-scenarios/limrun-ios-snapshot-owner.test.ts b/test/integration/provider-scenarios/limrun-ios-snapshot-owner.test.ts index 890f6218e7..00571e098c 100644 --- a/test/integration/provider-scenarios/limrun-ios-snapshot-owner.test.ts +++ b/test/integration/provider-scenarios/limrun-ios-snapshot-owner.test.ts @@ -1,9 +1,15 @@ +import { withMethodScope } from '@agent-device/kernel/scoped-provider'; +import { withAppleToolProvider } from '@agent-device/platform-apple/tool-provider'; +import { createLimrunAppleToolProvider } from '../../../src/core/interactors/limrun-ios-tools.ts'; import assert from 'node:assert/strict'; import { beforeEach, test, vi } from 'vitest'; import type { CaptureSnapshotResult } from '@agent-device/contracts/client'; import type { DeviceLease } from '@agent-device/contracts/device'; import { createLimrunRuntime, type LimrunRuntimeDependencies } from '@agent-device/provider-limrun'; -import { limrunTestDependencies } from '../../../src/platform-runtime-gateway.fixtures.ts'; +import { + limrunTestDependencies, + createTestLimrunIosRunner, +} from '../../../src/platform-runtime-gateway.fixtures.ts'; import { createProviderDeviceRuntimeRequestProviders } from '../../../src/provider-device-runtime.ts'; import { formatCliOutput } from '@agent-device/command-output'; import { assertRpcOk } from './assertions.ts'; @@ -16,19 +22,27 @@ const limrunApiState = vi.hoisted(() => ({ })), deleteInstance: vi.fn(async () => undefined), disconnect: vi.fn(), - elementTree: vi.fn(async () => ({ - type: 'Application', - AXLabel: 'App', - frame: { x: 0, y: 0, width: 320, height: 240 }, - children: [ + simctl: vi.fn(), + runnerCommand: vi.fn(async (_command: Record) => ({ + nodes: [ { + index: 0, + type: 'Application', + label: 'App', + rect: { x: 0, y: 0, width: 320, height: 240 }, + }, + { + index: 1, + parentIndex: 0, type: 'Button', - AXLabel: 'Continue', - frame: { x: 24, y: 96, width: 160, height: 48 }, + label: 'Continue', + rect: { x: 24, y: 96, width: 160, height: 48 }, enabled: true, hittable: true, }, ], + truncated: false, + keyboard: { kind: 'absent' }, })), })); @@ -50,22 +64,37 @@ vi.mock('@limrun/api', () => ({ }, })); -vi.mock('@limrun/api/ios-client', () => ({ - createInstanceClient: vi.fn(async () => ({ - deviceInfo: { screenWidth: 320, screenHeight: 240 }, - disconnect: limrunApiState.disconnect, - elementTree: limrunApiState.elementTree, - })), -})); +vi.mock('@limrun/api/ios-client', async (importOriginal) => { + const actual = await importOriginal(); + limrunApiState.simctl.mockImplementation((args: string[]) => { + const execution = new actual.SimctlExecution(() => {}); + if (args[0] === 'list') + execution._handleStdout( + Buffer.from( + JSON.stringify({ devices: { ios: [{ udid: 'remote-udid', state: 'Booted' }] } }), + ), + ); + execution._handleExit(0); + return execution; + }); + return { + createInstanceClient: vi.fn(async () => ({ + deviceInfo: { udid: 'remote-udid', screenWidth: 320, screenHeight: 240 }, + disconnect: limrunApiState.disconnect, + simctl: limrunApiState.simctl, + })), + }; +}); beforeEach(() => { limrunApiState.createInstance.mockClear(); limrunApiState.deleteInstance.mockClear(); limrunApiState.disconnect.mockClear(); - limrunApiState.elementTree.mockClear(); + limrunApiState.runnerCommand.mockClear(); + limrunApiState.simctl.mockClear(); }); -test('Limrun iOS acquisition reaches one host presenter and the public snapshot output', async () => { +test('Limrun XCTest snapshots reach the daemon and public output with runner metadata', async () => { const registration = createLimrunRuntime( { apiKey: 'limrun-test-key' }, limrunRuntimeDependencies(), @@ -102,10 +131,19 @@ test('Limrun iOS acquisition reaches one host presenter and the public snapshot deviceKey: 'ios-a', clientId: 'client-a', }; + assertRpcOk(await daemon.callCommand('open', ['com.example.app'], flags, { meta })); + assert.deepEqual( + limrunApiState.simctl.mock.calls.filter(([args]) => args[0] === 'launch'), + [[['launch', 'booted', 'com.example.app'], undefined]], + ); const response = await daemon.callCommand('snapshot', [], flags, { meta }); const result = assertRpcOk(response); - assert.equal(limrunApiState.elementTree.mock.calls.length, 1); + assert.equal( + limrunApiState.runnerCommand.mock.calls.filter(([command]) => command.command === 'snapshot') + .length, + 1, + ); assert.deepEqual( result.nodes?.map((node) => [node.type, node.label]), [ @@ -117,14 +155,9 @@ test('Limrun iOS acquisition reaches one host presenter and the public snapshot result.nodes?.every((node) => typeof node.ref === 'string'), true, ); - assert.equal(result.truncated, undefined); - assert.deepEqual(result.keyboard, { kind: 'unmeasurable', reason: 'keyboard-not-observed' }); - assert.deepEqual(result.warnings, [ - 'iOS snapshot acquisition does not provide hittability evidence; regular snapshots omit unverified hittability while raw snapshots preserve supplied facts.', - 'iOS snapshot acquisition does not report hierarchy completeness; provider-side depth or child limits may omit nodes.', - 'iOS snapshot acquisition does not expose truncation metadata; tree completeness is not independently verified.', - ]); - + assert.equal(result.truncated, false); + assert.deepEqual(result.keyboard, { kind: 'absent' }); + assert.equal(result.warnings, undefined); const cliOutput = await formatCliOutput({ name: 'snapshot', env: process.env, @@ -133,7 +166,17 @@ test('Limrun iOS acquisition reaches one host presenter and the public snapshot result, }); assert.ok(cliOutput, 'snapshot renders CLI output'); - assert.equal(JSON.stringify(cliOutput.jsonData).includes('"truncated"'), false); + assert.equal(JSON.stringify(cliOutput.jsonData).includes('"truncated":false'), true); + assertRpcOk(await daemon.callCommand('press', ['label=Continue'], flags, { meta })); + assert.ok( + limrunApiState.runnerCommand.mock.calls.some( + ([command]) => + command.command === 'tap' && + command.appBundleId === 'com.example.app' && + command.x === 104 && + command.y === 120, + ), + ); } finally { await daemon.close(); await runtime.shutdown(); @@ -166,6 +209,24 @@ function limrunRuntimeDependencies(): LimrunRuntimeDependencies { clientVersion: 'test-version', android: {} as LimrunRuntimeDependencies['android'], host: {} as LimrunRuntimeDependencies['host'], - ios: limrunTestDependencies.ios, + ios: { + ...limrunTestDependencies.ios, + createRunner: async (session) => { + const runner = await createTestLimrunIosRunner(session.device, (command) => + limrunApiState.runnerCommand(command), + ); + const lifetime = new AbortController().signal; + return { + ...runner, + createInteractor: (context) => + withMethodScope(runner.createInteractor(context), (task) => + withAppleToolProvider( + createLimrunAppleToolProvider(session, lifetime, context?.signal), + task, + ), + ), + }; + }, + }, }; } diff --git a/test/integration/provider-scenarios/stale-provider-runtime-admission.test.ts b/test/integration/provider-scenarios/stale-provider-runtime-admission.test.ts index 5a15583203..14b3de8448 100644 --- a/test/integration/provider-scenarios/stale-provider-runtime-admission.test.ts +++ b/test/integration/provider-scenarios/stale-provider-runtime-admission.test.ts @@ -302,6 +302,9 @@ function limrunDependencies(): LimrunRuntimeDependencies { return { clientVersion: 'test', android: { + bindDeployment: async () => { + throw new Error('Unexpected deployment binding'); + }, createInteractor: () => ({}) as never, createPortReverse: async () => ({ ensure: async () => {}, diff --git a/test/integration/replays/ios/device/01-physical-lifecycle.ad b/test/integration/replays/ios/device/01-physical-lifecycle.ad index fb6e1c0b90..ed5d057797 100644 --- a/test/integration/replays/ios/device/01-physical-lifecycle.ad +++ b/test/integration/replays/ios/device/01-physical-lifecycle.ad @@ -2,7 +2,9 @@ context platform=ios target=mobile open com.apple.Preferences --relaunch snapshot +scroll down --until "role=cell label=General || role=button label=General" --max-passes 5 click "role=cell label=General || role=button label=General" wait "label=About || label=\"Software Update\"" 5000 back +wait "role=cell label=General || role=button label=General" 5000 is exists "role=cell label=General || role=button label=General" diff --git a/tsdown.config.ts b/tsdown.config.ts index 4310dfdf72..8b12613f80 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -99,6 +99,7 @@ export default defineConfig({ alwaysBundle: [/^@agent-device\//], onlyBundle: [ '@limrun/api', + '@xmldom/xmldom', 'agent-base', 'b4a', 'debug', @@ -113,6 +114,7 @@ export default defineConfig({ 'jpeg-js', 'ms', 'pend', + 'plist', 'pngjs', 'proxy-from-env', 'streamx', @@ -122,6 +124,7 @@ export default defineConfig({ 'undici', 'undici-types', 'ws', + 'xmlbuilder', 'yaml', 'yauzl', ], diff --git a/website/docs/docs/capabilities.md b/website/docs/docs/capabilities.md index 7ee4aa14cc..4ca42abb06 100644 --- a/website/docs/docs/capabilities.md +++ b/website/docs/docs/capabilities.md @@ -114,10 +114,10 @@ What a **Yes** does not claim: | [Type into the focused field](/docs/commands#interactions) | Yes | Yes | Yes | Yes | Yes | Yes | No | No | | [Long press](/docs/commands#interactions) | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | | [Scroll](/docs/commands#scroll) | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| [Gesture plans and swipes](/docs/commands#pointer-and-gestures) | Yes | Yes | Yes | Yes | No | Yes | No | No | +| [Gesture plans and swipes](/docs/commands#pointer-and-gestures) | Yes | Yes | Yes | Yes | Yes | Yes | No | No | | [Hover and viewport size](/docs/commands#web-automation) | No | No | No | No | No | No | No | No | | [Read, accept, and dismiss system alerts](/docs/commands#wait-and-alerts) | No | No | No | No | Yes | Yes | No | No | -| [Keyboard dismiss, enter, and status](/docs/commands#keyboard) | Yes | Dismiss only; status and enter unsupported | Yes | Dismiss only; status and enter unsupported | No | Yes | No | No | +| [Keyboard dismiss, enter, and status](/docs/commands#keyboard) | Yes | Dismiss only; status and enter unsupported | Yes | Dismiss only; status and enter unsupported | Dismiss and enter; status unsupported | Yes | No | No | | [React Native overlay dismissal](/docs/commands#react-native-overlays) | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | What a **Yes** does not claim: @@ -180,14 +180,14 @@ What a **Yes** does not claim: | --- | --- | --- | --- | --- | --- | --- | --- | --- | | [Open and close apps, URLs, and deep links](/docs/commands#navigation) | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | | [Install and reinstall an app](/docs/commands#app-install-in-place) | Yes | Yes | No | No | Yes | Yes | No | No | -| [Uninstall an app](/docs/commands#app-uninstall) | No | No | No | No | No | No | No | No | +| [Uninstall an app](/docs/commands#app-uninstall) | No | No | No | No | Yes | Yes | No | No | | [Install from a source URL](/docs/commands#app-install-from-source-url) | Yes | Yes | No | No | Yes | Yes | No | No | | [Installed app list](/docs/commands#app-state-and-app-lists) | No | No | No | No | Yes | Yes | No | No | -| [Foreground app state](/docs/commands#app-state-and-app-lists) | No | No | No | No | Last opened app, once the instance answers; no foreground read | Yes | No | No | -| [App switcher](/docs/commands#navigation) | Yes | Yes | Yes | Yes | No | Yes | No | No | +| [Foreground app state](/docs/commands#app-state-and-app-lists) | No | No | No | No | Yes | Yes | No | No | +| [App switcher](/docs/commands#navigation) | Yes | Yes | Yes | Yes | Yes | Yes | No | No | | [Get the device ready and boot simulators and emulators](/docs/commands#navigation) | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | | [Shut down a simulator or emulator](/docs/commands#navigation) | No | No | No | No | No | No | No | No | -| [Prepare the Apple runner](/docs/commands#prepare-apple-runner) | No | No | No | No | No | No | No | No | +| [Prepare the Apple runner](/docs/commands#prepare-apple-runner) | No | No | No | No | Yes | No | No | No | What a **Yes** does not claim: @@ -303,12 +303,12 @@ What a **Yes** does not claim: | [Home](/docs/commands#navigation) | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | | [Set screen orientation](/docs/commands#navigation) | Set only; unlock unsupported | Set only; unlock unsupported | Set only; unlock unsupported | Set only; unlock unsupported | Set only; unlock unsupported | Yes | No | No | | [Phone hardware buttons](/docs/commands#navigation) | Yes | Yes | Yes | Yes | No | Yes | No | No | -| [iPhone Action Button](/docs/commands#navigation) | No | No | No | No | No | No | No | No | +| [iPhone Action Button](/docs/commands#navigation) | No | No | No | No | Models with an Action Button only | No | No | No | | [Foldable pose](/docs/commands#navigation) | No | No | No | No | No | No | No | No | | [TV remote keys](/docs/commands#tv-targets) | No | No | No | No | No | No | No | No | -| [System and app settings helpers](/docs/commands#settings-helpers) | No | No | No | No | Set only; no reads | Yes | No | No | +| [System and app settings helpers](/docs/commands#settings-helpers) | No | No | No | No | Yes | Yes | No | No | | [Clipboard read and write](/docs/commands#clipboard) | Yes | Yes | Yes | Yes | Yes | Yes | No | No | -| [Push notification simulation](/docs/commands#push-notification-simulation) | No | No | No | No | No | No | No | No | +| [Push notification simulation](/docs/commands#push-notification-simulation) | No | No | No | No | Yes | No | No | No | | [App event triggers through the app hook](/docs/commands#app-event-triggers-app-hook) | Yes | Yes | Yes | Yes | Yes | Yes | No | No | | [Call and SMS simulation](/docs/commands#navigation) | No | No | No | No | No | No | No | No | diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index f7ee37a8fc..de379680e9 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -64,8 +64,8 @@ This example opens an app, takes a snapshot, interacts, handles typed errors, an `client.devices.capabilities()` returns `{ device, availableCommands }`, using the same capability matrix as the CLI. Use it to check which command names the selected target supports. -For direct local iOS simulator app launches, `client.apps.open({ app, platform: 'ios', launchConsole: './artifacts/app.console.log' })` captures launch-time -stdout/stderr. The option mirrors `open --launch-console` and is not valid for URL opens, provider-owned simulators, or non-simulator targets. +For direct app launches on local or Limrun iOS simulators, `client.apps.open({ app, platform: 'ios', launchConsole: './artifacts/app.console.log' })` captures launch-time +stdout/stderr. The option mirrors `open --launch-console` and is not valid for URL opens, other provider-owned simulators, or non-simulator targets. `client.sessions.stateDir()` mirrors `session state-dir` and returns the resolved daemon state directory without starting or contacting the daemon. Pass `{ stateDir }` to resolve an explicit override the same way the CLI resolves `--state-dir`. diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 3aeed60f00..c6ba175766 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -98,7 +98,7 @@ Open apps and links: - `open [app|url] [url]` boots the selected device when needed and opens the target in a session. `open ` opens deep links on Android and iOS, and `open ` opens a deep link in that app on iOS. See [Desktop targets](#desktop-targets) for macOS activation behavior. - On iOS devices, `http(s)://` URLs open in Safari when no app is active. Custom-scheme URLs need an active app in the session. - `open --wait ` waits up to that budget for a device another session holds, then fails with `DEVICE_IN_USE`. The wait extends the command's timeout, so you don't need a longer `--timeout`. A claim held by another workspace's daemon is not waited for; `open` returns its recovery command at once. Device-mutating commands like `install` and `reinstall` never wait either: a claim held by another session fails them at once, and when the caller sends a workspace, another workspace's claim under the same session name fails too. See [Sessions](/docs/sessions#wait-for-a-slow-or-busy-device). -- `open --launch-console ` captures launch-time stdout/stderr for local iOS simulator app launches. It is not valid for URL opens or other targets. +- `open --launch-console ` captures launch-time stdout/stderr for iOS simulator app launches, including Limrun. It is not valid for URL opens or other targets. - `open --platform macos --surface app|frontmost-app|desktop|menubar` selects the macOS session surface; `app` is the default when you name an app. See [Desktop targets](#desktop-targets). `--save-script` records the surface so `replay` reopens it; with no surface given, replay opens an app session. - Android React Native: `open --relaunch` needs an installed package name. Install or reinstall the APK first; `open --relaunch` is rejected. For Metro-backed JS changes, run `metro reload` before reaching for `open --relaunch`. - Android (adb): an intent target such as `open settings` records the package that opened as `appBundleId`. `open settings --relaunch` stops and restarts that app. If the intent matches more than one activity, `--relaunch` fails. @@ -437,13 +437,13 @@ agent-device alert dismiss - Branch on `readableCaptures`, `waitedMs`, `matches`, and `firstMatch` instead of parsing error text. `firstMatch` carries identity and text only; absence failures make no visibility or rect claim. - Polling wait timeouts, and a replayed wait refused with `wait_landmark_identity_mismatch`, also carry `captures`, `readableCaptures`, and `polls` so you can see where the budget went. Each poll has `startedMs`, `durationMs`, and an `outcome`: `readable`, `unreadable`, `retriable`, `deadline`, `runner-restart`, or `readiness`. Long waits keep the first 5 and last 25 polls. A wait that never saw a readable capture reports the error its polls hit, with that error's code and retry details, instead of a generic timeout; `wait stable` and a never-readable strict absence keep their own diagnostics. `logPath` links the full request log. - `alert` (same as `alert get`) inspects or handles system alerts on iOS simulator, macOS desktop, and Android permission dialogs. On iOS it also covers app-owned modal popups with native blocking markers. On the Apple XCUITest runner it also covers Safari's own page prompts, such as "Open this page in …?". The `[timeout]` argument applies to `alert wait` only, on every backend: `alert wait ` takes a budget up to 2147483647 ms (~24.8 days), larger is refused as `INVALID_ARGS` before anything runs, while `get`/`accept`/`dismiss` run their own fixed per-backend budgets and ignore it. -- On the snapshot-derived backends (Android, Limrun iOS) `alert get --json` answers with a typed `status`: `visible`, `none`, or `undecided` (an alert-shaped capture that resolves to no single alert). Branch on `status`, not on the message. +- On the snapshot-derived Android backends `alert get --json` answers with a typed `status`: `visible`, `none`, or `undecided` (an alert-shaped capture that resolves to no single alert). Branch on `status`, not on the message. - On those backends, `alert accept` presses the lasting grant when a permission sheet offers several: Android orders its grants by the platform's own ids (full-access "Allow all", then foreground-only, then always, then the picker-limited "Select photos and videos", then one-time) whatever the button language — this is what makes the Android 14+ partial-media sheet answer "Allow all" — and elsewhere the English label table decides ("while using the app" over "only this time"). Android acts only on the grant ids it declares with a role and a rank; a sheet carrying an allow- or deny-shaped grant id it does not declare (a vendor fork's own button, or a newer Android grant) is refused for both `accept` and `dismiss` — `dismiss` answers with the Back key, the cancel that grants nothing. A sheet whose choices no rank orders — or that names the same choice at two points — is refused with `alert_button_undecided` and `dispatched: no`, never pressed by position; read the labels with `alert get` and press your choice by label/ref. - On those backends, when `alert accept`/`dismiss` fails before pressing anything, `error.details.reason` names why (a capture or transport failure that is not a driver refusal may instead surface as its own error without a `reason`). A budget never cancels a running capture or press: it only ends the polling loop, so every check that started inside the budget finishes and counts. Before any press, `dispatched: no` and the reason is `alert-not-found` (the action budget ended with no alert on screen), `alert_capture_undecided`, or `alert_button_undecided` (the sheet is too ambiguous to act on). After a press, `dispatched` is absent and the reason is `alert-window-expired`; “did not dismiss the visible alert” means the latest post-press capture that answered named the same dialog again, “could not be confirmed” means the press went out but the latest read was unresolvable or never answered, so the dialog may already be gone. If you cancel the request at any point from the press onward — the tap in flight, or the post-press checks — the cancellation reports `dispatched: unknown`: the device may already have received the tap. The one exception is a cancel whose post-press check nevertheless finished and proved the alert gone; then the command reports the handled alert, because the outcome is known. Run `alert get` before acting again. -- On those backends a slow capture is never lost to its budget: a check that straddles the deadline still answers, which is why a quiet `alert accept`/`dismiss` always reports `alert-not-found` and never a timing-dependent failure (`alert get` answers `status: none` and `alert wait` times out on a quiet screen). The cost is a wait: the command stays open until the capture it started returns. On Android your cancellation stops the capture and the tap (the adb child is killed). On Limrun a request already sent to the instance cannot be stopped: the command ends when that last request answers (the Limrun client gives a request that stops answering its own ~30-second timeout) or the connection drops, and cancelling does not free it early. If a Limrun alert command stalls, restarting the instance session is what clears it. +- On those backends a slow capture is never lost to its budget: a check that straddles the deadline still answers, which is why a quiet `alert accept`/`dismiss` always reports `alert-not-found` and never a timing-dependent failure (`alert get` answers `status: none` and `alert wait` times out on a quiet screen). The cost is a wait: the command stays open until the capture it started returns. On Android your cancellation stops the capture and the tap (the adb child is killed). Limrun iOS uses the Apple runner and its cancellation and deadline behavior. - Use `alert get` for a quick check, and `alert wait ` only when a prompt may appear after async work. Don't use `settings permission` to answer a dialog already on screen; it is for setup before a flow. - `accept` and `dismiss` are sent once on every platform and never replayed, even if the response is lost. On iOS the runner activates the button once, then only observes until the alert disappears, changes, or the deadline expires. A changed alert does not prove a permission was granted; verify the app outcome. -- The Apple XCUITest runner (iOS, tvOS, and visionOS simulators and devices) and Limrun iOS use the same English label preferences after finding an alert: `Allow While Using App` outranks `Allow Once`, and equal-ranked choices refuse. `Open`, `Done`, and `Confirm…` accept; `Keep Browsing`, `Deny`, and `Skip` dismiss. On iOS simulators the local XCUITest runner also recognizes the wording of these labels in each of the device's preferred languages, read from the OS's own notification, privacy (including App Tracking Transparency), location, and `Open in …?` launch-confirmation prompt strings, at the rank of the English label they translate. The translations apply to any alert whose labels match them, not only permission prompts; a translation both roles claim stays unknown. If a notification, privacy, or location table cannot be read, the runner drops the permission translations and keeps the English labels; the `Open in …?` table is read apart, so its labels survive that, and the permission translations survive it being unreadable. Physical iOS devices, tvOS, and visionOS always use the English labels. Limrun iOS recognizes English labels only, because its tree capture cannot read the device's string tables. On local iOS simulators, `alert get` marks the `Open in …?` launch confirmation with `launchConfirmationPrompt: true`; `open` reads that mark to answer the prompt. Unknown labels refuse even for a lone button; read `alert get`, then choose explicitly with `snapshot -i` and `press @ref`. Limrun still needs at least two actionable buttons to recognize an alert. Ordinary Android dialogs also recognize these labels; Android permission-button ids keep priority. +- The Apple XCUITest runner (iOS, tvOS, and visionOS simulators and devices) and Limrun iOS use the same English label preferences after finding an alert: `Allow While Using App` outranks `Allow Once`, and equal-ranked choices refuse. `Open`, `Done`, and `Confirm…` accept; `Keep Browsing`, `Deny`, and `Skip` dismiss. On iOS simulators, including Limrun, the XCUITest runner also recognizes the wording of these labels in each of the device's preferred languages, read from the OS's own notification, privacy (including App Tracking Transparency), location, and `Open in …?` launch-confirmation prompt strings, at the rank of the English label they translate. The translations apply to any alert whose labels match them, not only permission prompts; a translation both roles claim stays unknown. If a notification, privacy, or location table cannot be read, the runner drops the permission translations and keeps the English labels; the `Open in …?` table is read apart, so its labels survive that, and the permission translations survive it being unreadable. Physical iOS devices, tvOS, and visionOS always use the English labels. On iOS simulators, including Limrun, `alert get` marks the `Open in …?` launch confirmation with `launchConfirmationPrompt: true`; `open` reads that mark to answer the prompt. Unknown labels refuse even for a lone button; read `alert get`, then choose explicitly with `snapshot -i` and `press @ref`. Ordinary Android dialogs also recognize these labels; Android permission-button ids keep priority. - On the Apple XCUITest runner, `dismiss` activates only a button its decline list recognizes (`Cancel`, `Don't Allow`, `Ask App Not to Track`, and similar English labels), or the close button of a popover whose native marker reads "dismiss popup". It never taps anything else: unknown, reworded, or localized decline copy outside the preferred languages' prompt strings fails with `error.code: ALERT_BUTTON_NOT_FOUND` and `details.dispatched: no`, tapping nothing, rather than guessing at an affirmative ([#3627](https://github.com/callstack/agent-device/issues/3627)). A button choice that finds no button to name answers with that code and `dispatched: no` for `accept` too. To answer a prompt with unrecognized wording, read the labels with `alert get`, then press the button by label through `snapshot` + `press @ref`. The macOS helper's `dismiss` keeps its own rules (the app's cancel-button attribute, its label list, and a last-button fallback). - An unreadable or ambiguous capture after the action fails with `error.details.runnerErrorCode: ALERT_CONFIRMATION_UNAVAILABLE`; an expired runner deadline during action or confirmation after the alert is resolved fails with `ALERT_DEADLINE_EXCEEDED`. Neither proves the alert is gone or that nothing happened. Run `alert get` before acting again. - iOS runner refusals carry `error.details.reason`: `runner_busy` means the runner ran nothing (`dispatched: no`); `runner_main_thread_timeout` means the action may still land (`dispatched: unknown`); `no_text_input_at_point` and `text_input_probe_unavailable` mean a `fill` refused before dispatching anything (`dispatched: no`) — see Text entry below. @@ -458,7 +458,7 @@ agent-device click @e1 agent-device click @e1 --button secondary # macOS secondary click / context menu agent-device focus @e2 agent-device fill @e2 "text" # Replace the field value -agent-device fill @e2 "search" --delay-ms 100 # No delay on Limrun iOS fill +agent-device fill @e2 "search" --delay-ms 100 printf %s "$PASSWORD" | agent-device fill @e2 --text-stdin # Text from stdin, kept out of argv agent-device type "text" # Type into focused field without clearing agent-device type "query" --delay-ms 100 @@ -495,15 +495,13 @@ A `click` or `press` whose target the daemon resolves to a point (`@ref` or sele - `fill` replaces the field value. `type` appends to the focused field and accepts text only: use `fill @ref "text"` to target a field, or `press @ref` then `type "text"`. - Local iOS `fill` avoids another focus tap when the current command identifies the target input and reads its keyboard focus. For readable focused inputs, clearing tries select-all/delete and reads the result before moving the caret for delete-burst fallback. Newly focused inputs retain caret preparation. -- Use plain `fill` or `type` first. Add `--delay-ms` only when a debounced or search-as-you-type field misses characters, or the app must receive each incremental update; delayed typing enters characters one at a time instead of using a clipboard-style fallback. On Limrun iOS, `--delay-ms` has no effect on `fill`; use `press` to focus the field and `type --delay-ms` for intentional key-event input. +- Use plain `fill` or `type` first. Add `--delay-ms` only when a debounced or search-as-you-type field misses characters, or the app must receive each incremental update; delayed typing enters characters one at a time instead of using a clipboard-style fallback. - `fill --text-stdin` reads the text from stdin so a secret never appears in process arguments. Pipe it with `printf %s`. It reads at most 64 KiB, removes one trailing newline, and refuses a terminal, empty input, invalid UTF-8, or an extra text argument. Output shows the value as `${VAR}` with `--record-as ` and as `[REDACTED]` otherwise, including in error messages and details, and in the `requested`, `before`, and `after` read-back of an unconfirmed fill, which can hold a reformatted form of the value. With `--settle`, the diff line text and tail labels are replaced whole, so text around the value in the field is not shown either; refs and counts stay. While script recording is armed, pass `--record-as ` or `--no-record`; passing neither is an error. Batch steps don't accept it. - The privacy controls for typed input (`--text-stdin` and `--record-as`) keep the literal out of agent-device's own responses, diagnostics, and recorded scripts. They do not rewrite what the platform records: on iOS, XCTest writes its own diagnostic logs and its `.xcresult` bundle, and typed text can appear in both, on a simulator or a physical device. Treat those as sensitive, and use test-only credentials when they may be kept or shared. See [Security and trust](/docs/security-trust). - Android text entry is owned by `agent-device`. Don't fall back to raw `adb`, clipboard, or paste; if non-ASCII is unsupported on the current backend, report the gap. Android `fill` verifies the text and fails instead of retrying when the IME captures input. Text-entry errors and what to do: -- `text_entry_focus_not_observed` (Limrun iOS `fill`): the tap did not establish focus on the target, so no value was written. Run `snapshot -i` and choose the input again. -- `text_entry_value_write_failed` (Limrun iOS `fill`): the write outcome is unknown. Inspect and assert the field or resulting screen before continuing. Retry `fill` with the full value only if it needs correction. See [Limrun iOS text entry](/docs/limrun). - `TEXT_INPUT_NOT_FOCUSED` (`type`): focus a visible text input and retry. If accessibility does not expose the input, focus it by coordinates. - `NO_TEXT_INPUT_AT_POINT` (iOS `fill` under a degraded XCTest channel — coordinate, ref, selector, and replay fills all aim at a point): the runner identified no text input under that point and refused before dispatching anything (`error.details.reason` is `no_text_input_at_point`, `dispatched: no`), so no field changed. A ref or selector fill aims at the centre of the rect it resolved to, so an outer container or a non-input node can land outside the real field: run `snapshot -i`, pick the input node itself, and aim inside its rect. - `TEXT_INPUT_PROBE_UNAVAILABLE` (iOS `fill` under a degraded XCTest channel — coordinate, ref, selector, and replay fills all aim at a point): the runner's input lookup could not answer, so it refused before dispatching anything (`error.details.reason` is `text_input_probe_unavailable`, `dispatched: no`). This does not mean the point has no input. Retry the fill, or run `snapshot -i` once its capture reads back. A ref or selector fill aims at the centre of its resolved rect. @@ -820,7 +818,7 @@ agent-device apps --platform android --all ``` - Android and HarmonyOS `appstate` report the live foreground package and activity. -- iOS and macOS `appstate` need a session and report that session's app: `appName`, `appBundleId`, and a `source`. On iOS, when a live runner answers the read, the answer carries its `state` (`runningForeground`, `runningBackground`, `runningBackgroundSuspended`, `notRunning`, or `unknown`) with `source: runner`. They never start a runner; when none is running they report `source: session` and no `state`, and on macOS the answer is always session-scoped. When the read fails, for example because the runner cannot reply right now, `appstate` fails instead of answering from the session. Limrun iOS has no live read, so it answers with `source: session` only after the instance responds; when the instance cannot be confirmed it fails with reason `limrun_instance_unreachable`. Any provider whose session has ended refuses `appstate` with reason `owner-capability-missing`. On iOS, after `home` the app reports a background state; which app took the screen is not reported. A command that had to bring the app back reports a [`targetActivation` disclosure](#foreground-repairs-on-ios). +- iOS and macOS `appstate` need a session and report that session's app: `appName`, `appBundleId`, and a `source`. On iOS, when a live runner answers the read, the answer carries its `state` (`runningForeground`, `runningBackground`, `runningBackgroundSuspended`, `notRunning`, or `unknown`) with `source: runner`. They never start a runner; when none is running they report `source: session` and no `state`, and on macOS the answer is always session-scoped. When the read fails, for example because the runner cannot reply right now, `appstate` fails instead of answering from the session. Limrun iOS checks that the instance responds before using the runner read or session record; when the instance cannot be confirmed it fails with reason `limrun_instance_unreachable`. Any provider whose session has ended refuses `appstate` with reason `owner-capability-missing`. On iOS, after `home` the app reports a background state; which app took the screen is not reported. A command that had to bring the app back reports a [`targetActivation` disclosure](#foreground-repairs-on-ios). - `apps` shows user-installed apps. Add `--all` to include system and OEM apps. ## Foreground repairs on iOS diff --git a/website/docs/docs/debugging-profiling.md b/website/docs/docs/debugging-profiling.md index a71e5cbab8..616acd338b 100644 --- a/website/docs/docs/debugging-profiling.md +++ b/website/docs/docs/debugging-profiling.md @@ -146,7 +146,7 @@ On iOS simulators, `logs` scope by bundle id and the resolved app executable. Fo agent-device open MyApp --platform ios --relaunch --launch-console ./artifacts/app.console.log ``` -`--launch-console` is only for direct local iOS simulator app launches, not URL opens. +`--launch-console` is only for direct app launches on local or Limrun iOS simulators, not URL opens. ## Crash symbolication diff --git a/website/docs/docs/limrun.md b/website/docs/docs/limrun.md index 9fb9211e1e..114bcfca9d 100644 --- a/website/docs/docs/limrun.md +++ b/website/docs/docs/limrun.md @@ -15,7 +15,7 @@ export AGENT_DEVICE_SESSION=checkout-android agent-device connect limrun --platform android ``` -`--platform android` or `--platform ios` selects the instance type. `connect` checks the credentials for that platform without creating an instance. +`--platform android` or `--platform ios` selects the instance type. `connect` checks the credentials for that platform without creating an instance. Set `AGENT_DEVICE_SESSION` to a name for your agent or CI job. Every command on a Limrun connection needs that session name, either through this variable, `--session`, or your config. Requests agent-device makes with your API key, including instance creation, start their `User-Agent` with `agent-device-cli/` so Limrun can attribute them to agent-device for revenue sharing. @@ -70,15 +70,57 @@ agent-device close agent-device disconnect ``` +For a new iOS simulator, install a simulator build of your app: + +```bash +export LIMRUN_API_KEY=... +export AGENT_DEVICE_SESSION=checkout-ios + +agent-device connect limrun --platform ios +agent-device install com.example.app ./MyApp.app +agent-device open com.example.app +agent-device snapshot -i +agent-device screenshot ./screen.png +agent-device record start ./session.mp4 +agent-device click 'label="Continue"' +agent-device record stop +agent-device close +agent-device disconnect +``` + +Use a selector from your app's snapshot in place of `Continue`. + On Android, agent-device connects to Limrun over an ADB tunnel. Snapshots use the Android snapshot helper, installs upload the app through Limrun asset storage, and port reverse runs over ADB, including the usual reverse setup for a local Metro server. -On iOS, agent-device supports app lifecycle commands, snapshots, screenshots, taps, text input, scrolling, the home button, clipboard read and write, app installation, and system alerts. `alert get/wait/accept/dismiss` read the alert from the same element tree as snapshots and dispatch the selected button; a choice is pressed only when its English label states the asked-for action (`Allow`, `Don't Allow`, `Ask App Not to Track`, and similar), so a localized alert is reported but never pressed. For an alert it recognizes, Limrun uses the same English button preferences as local Apple XCTest: `Allow While Using App` outranks `Allow Once`; `Open`, `Done`, and `Confirm…` accept; `Keep Browsing`, `Deny`, and `Skip` dismiss. Equal-ranked choices and unknown labels refuse. Limrun needs at least two actionable buttons to recognize an alert. App-owned dialogs are not system alerts there, so use `snapshot -i` and then `press @ref` or a label selector for the visible control. `open --launch-args` relaunches the app with those arguments through simctl. iOS has no `port reverse`: that command is Android-only, because it runs over ADB. iOS still reaches a local Metro. The instance client's destination tunnel routes device `localhost` connections back to this machine, so while that tunnel is open the app reaches a Metro listening on the host. With `agent-device connect limrun --provider-network-logs`, the session opens that tunnel when the device is allocated, usually by the first `open`, and keeps it; see [Capture session network traffic](#capture-session-network-traffic). Otherwise give the app a publicly reachable HTTPS Metro endpoint. +## Use an iOS simulator -On an iPhone, Limrun reports a native tab bar as one empty group. `snapshot` then hit-tests points across the bar and lists the tabs it finds under it, with their labels, frames, and selected state, so you can press a tab by label. If a hit-test fails, takes longer than 1.5 seconds, or the 24 hit-tests a snapshot allows run out before the bar is found, `snapshot` lists the bar without tabs and returns a warning. +Use the same snapshot, selector, text-entry, gesture, navigation, clipboard, and alert commands as on a local iOS simulator. Limrun iOS uses the agent-device XCTest runner, so snapshots include the same tab contents, hierarchy, and keyboard observations as local XCTest snapshots. `fill` replaces text and `type` appends it; both support `--delay-ms`. -On iOS, `settings` supports `appearance`, `permission`, `location`, and `clear-app-state`, with the same arguments as a local simulator. `clear-app-state` resets the app's data container through Limrun's soft reset. That reset relaunches the app once before agent-device stops it, so the app may run once after its data is cleared. `reset-keychain`, `wifi`, `airplane`, `mobile-data`, `faceid`, `touchid`, and `text-size` are unsupported, and you cannot read back any iOS setting. On Android, `settings mobile-data on|off` runs over the ADB tunnel like on a local device. +Use a stable agent-device release. It downloads and starts the matching runner automatically. You do not need local Xcode or any runner network configuration. Stop Appium or other XCTest sessions on the simulator before you start, because only one XCTest run can use it at a time. Limrun limits each XCTest run to one hour; close and reopen your session when it reaches that limit. -On iOS, `fill` taps the target, waits for that field to take text-entry focus, and replaces the focused field's value in one write. The write is the complete text, including an empty string that clears the field and characters that have no hardware key. It does not send key events, so key-event handlers may not run. Some apps, including Flutter forms, do not expose a globally focused element to Limrun. In those apps, `fill` can fail with `text_entry_value_write_failed` after focus is observed. The write outcome is unknown. Inspect and assert the field or resulting screen before continuing. If the value needs correction, retry `fill` with the full desired value. To append with key events, use `press` to focus the field, then `type`. If the tap does not focus a field, `fill` fails with `text_entry_focus_not_observed` and writes nothing. `type` sends key events to whichever field already has focus. `--delay-ms` on `type` enters one character at a time; `--delay-ms` has no effect on `fill`. +Open your app before taking a snapshot. Snapshots target the app you opened in that session. In the SDK, you can also pass `appBundleId` to the snapshot call. A snapshot without a target app fails. + +`install` leaves your app stopped. Use `open` to launch it. To change launch arguments on an app that is already running, use `open --relaunch --launch-args`. Screenshots download as lossless PNG files, and recording downloads the finished MP4 when you stop it. Push notifications use the same command and payload format as local simulators. + +If you use a nightly or unpublished agent-device build, obtain a matching packaged iOS runner from the build's maintainer. Set `AGENT_DEVICE_LIMRUN_IOS_RUNNER_APP` to the extracted `.app` path before starting agent-device. If the daemon is already running, stop it with `agent-device daemon stop` using the same `--state-dir`, then reconnect. + +## Connect an iOS app to Metro + +An installed Expo dev client can open with the same automatic launch URL discovery as a local simulator when its `exp+` scheme has one installed owner and Metro runtime information is available. An explicit launch URL takes precedence. If agent-device cannot verify which app owns a custom URL scheme, it leaves the launch confirmation for you to inspect and answer with the alert commands. + +For a local Metro server, connect with `--provider-network-logs` and include `localhost:8081`, or your Metro port, in `--provider-network-hosts`. While the session is open, the app can reach that port on the machine running agent-device. See [Capture session network traffic](#capture-session-network-traffic) for the network changes this option makes. Otherwise, use a publicly reachable HTTPS Metro endpoint. `port reverse` is Android-only. + +## Read iOS app logs + +After opening your app, use `logs start` to collect its stdout, stderr, and matching simulator logs. Use `open --launch-console ` to capture its initial launch output. Runner logs contain the test results Limrun provides, but native XCTest stdout is unavailable. + +Remote simulator logs do not include sender or process image paths. Filters that rely only on those fields cannot match remotely. + +## Change simulator settings + +On iOS, `settings` supports `appearance`, `permission`, `location`, `wifi`, `airplane`, `faceid`, `touchid`, `text-size`, and `clear-app-state`, with the same arguments as a local simulator. You can also read the current text size. + +Wi-Fi and airplane settings change the status-bar appearance; they do not disconnect the simulator. `clear-app-state` stops your app and removes its data. Use `open` when you are ready to launch it again. `reset-keychain` and `mobile-data` are unavailable on Limrun iOS. On Android, `settings mobile-data on|off` works as on a local device. Fold commands are unavailable on Limrun. To drive Limrun only through MCP, see [Use a provider only through MCP](/docs/device-clouds#use-a-provider-only-through-mcp). For Limrun, `artifacts` lists the network capture file only when you connect with `--provider-network-logs`; otherwise there are no provider artifacts to fetch. @@ -138,7 +180,7 @@ const runtime = new LimrunRuntime({ To drive existing instances, pass `instances: { ios: { apiUrl, token } }` (Android also takes `adbUrl`), with or without `apiKey`. The runtime never creates or deletes instances for a platform listed there. -A provider that reaches a harness-owned instance through the Node.js client allocates a Limrun lease in `acquire` and releases that same lease in `release`. Put the returned `lease.leaseId` on the client scope, and pass that same id to `leases.release`. Start the daemon with the instance URL and token (`LIM_IOS_INSTANCE_URL` and `LIM_IOS_INSTANCE_TOKEN`). `leases.allocate` attaches to that instance. `leases.release` returns `{ released, provider }`. Neither call creates or deletes the instance. Allocate with `retainOnClose: true` because the provider releases the lease itself: without it, a session `close` before `release` ends the lease and `leases.release` returns `released: false`. A retained lease still expires after its `ttlMs`, so keep heartbeating it between sessions. +A provider that reaches a harness-owned instance through the Node.js client allocates a Limrun lease in `acquire` and releases that same lease in `release`. Put the returned `lease.leaseId` on the client scope, and pass that same id to `leases.release`. Start the daemon with the instance URL and token (`LIM_IOS_INSTANCE_URL` and `LIM_IOS_INSTANCE_TOKEN`). `leases.allocate` attaches to that instance. `leases.release` returns `{ released, provider }`. Neither call creates or deletes the instance. Allocate with `retainOnClose: true` because the provider releases the lease itself: without it, a session `close` before `release` ends the lease and `leases.release` returns `released: false`. A retained lease still expires after its `ttlMs`, so keep heartbeating it between sessions. On iOS, a retained lease can reuse a healthy runner between sessions in the same daemon. Releasing the lease or stopping the daemon stops the runner. A new daemon cannot adopt it. ```ts import { createAgentDeviceClient } from 'agent-device'; @@ -170,6 +212,16 @@ const provider = { After allocating a lease, call `runtime.getDeviceSession(device)` to get the allocated device's capabilities: app inventory, foreground state where Limrun exposes it, key input, bounded log reads, recording, remote asset installation, and the interactor. On Android it also exposes agent-device's `AndroidAdbProvider` for helpers and reversible port forwarding. On iOS it exposes a typed `simctl` execution handle so your bridge can own runner lifecycle and launch policy. The runtime does not expose raw Limrun clients. +## Record the screen + +Use `record start video.mp4` and `record stop` in an open session. The recording runs on Limrun, +and `record stop` downloads the finished MP4. Each agent-device recording has its own Limrun +recording ID, so stopping it leaves other recordings on that instance running. Concurrent +recordings must use compatible encoder settings, including the same quality. + +`--quality medium|high` is supported. `--fps`, `--hide-touches`, and recording recovery after a +daemon restart are not supported. Every recording scope captures the whole device screen. + ## Get artifacts and troubleshoot For Limrun, `agent-device artifacts` lists only the `--provider-network-logs` HAR file. If a page hangs while the capture runs, read the failure summary that `artifacts` prints; see [Capture session network traffic](#capture-session-network-traffic). If `connect` fails, check `LIMRUN_API_KEY` and the optional `LIMRUN_REGION`. For an existing instance, check its URL and token variables, and that the instance is still running. diff --git a/website/docs/docs/snapshots.md b/website/docs/docs/snapshots.md index 3990f79799..ac8206b756 100644 --- a/website/docs/docs/snapshots.md +++ b/website/docs/docs/snapshots.md @@ -231,7 +231,7 @@ the tree to work out whether a sheet or alert was up. evidence absence needs. Absence is only reachable from a capture that reads UIKit class names, so a screen served by a backend that publishes none answers `"unknown"` even when nothing is presented. That is the private accessibility backend (`snapshotQuality.backend: "private-ax"`, which `--actions` - asks for) and an Appium or Limrun tree. Never treat `"unknown"` as `"absent"`. + asks for) and an Appium tree. Limrun iOS uses the Apple XCTest runner. Never treat `"unknown"` as `"absent"`. - A physical iOS device has one capture backend, and it publishes no class names, so there the answer is `"presented"` or `"unknown"` and never `"absent"`. - The field is absent entirely when the platform publishes no reading at all: Android, web, Linux, @@ -345,8 +345,8 @@ The response-level `keyboard` field tells you what the capture observed: Appium iOS and Android snapshots include a visibility probe. Each capture, including a `wait` poll or the capture before `fill`, adds one sequential probe with a 500 ms timeout. A timeout leaves the -probe unknown; it does not discard the tree. Appium iOS and limrun iOS can also report a derived docked -band during capture. Android Appium can derive the band when a tap resolves the tree's viewport. +probe unknown; it does not discard the tree. Appium iOS can also report a derived docked band during capture. Limrun iOS reports +the same keyboard observations as local iOS XCTest. Android Appium can derive the band when a tap resolves the tree's viewport. On iOS XCTest, a successful scan of the complete pre-projection root snapshot with no keyboard is the explicit observation that establishes `absent`. A failed or timed-out capture does not @@ -386,7 +386,7 @@ Android snapshot nodes and `get attrs` (including the digest response) carry the them. Explicit `false` and `0` are kept; an absent field means the fact was unavailable, not false. `hintShowing` and `placeholder` need Android API 26 or later, `heading` API 28 or later. -On iOS, the Apple runner, Simulator accessibility bridge, Appium, and Limrun publish native +On iOS, the Apple runner, including on Limrun, Simulator accessibility bridge, and Appium publish native `checked`, `password`, and `editable` facts when available. macOS runner snapshots publish the same native facts. @@ -411,8 +411,8 @@ same native facts. password flag. - `heading` is the accessibility heading flag an app sets on a node, the way React Native's `accessibilityRole="header"` does on a plain `View`. The Android helper publishes true for a - heading; Appium preserves an explicit true or false when reported. Limrun reports true when - the node carries the native heading trait. + heading; Appium preserves an explicit true or false when reported. A backend that does not + publish this fact leaves it absent. - `roleDescription` is the localized role description an app sets beside the native class, verbatim (React Native writes `Tab`, `Tab List`, `Radio Group`, `Link`, `Menu`), when the class alone would not say what the control is. The `type` stays the class; a consumer maps the description to a role.