From 78761d173412ca428b3d0f2456f7a9985b909c11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 2 Jun 2026 14:45:25 -0700 Subject: [PATCH 1/2] perf: speed up ios swipes and harden runner cache --- .../RunnerSynthesizedGesture.h | 7 + .../RunnerSynthesizedGesture.m | 109 +++++++ .../RunnerTests+CommandExecution.swift | 24 +- .../RunnerTests+Interaction.swift | 36 ++ .../RunnerTests+Models.swift | 1 + .../__tests__/dispatch-interactions.test.ts | 33 ++ src/core/__tests__/dispatch-series.test.ts | 25 -- src/core/dispatch-interactions.ts | 6 +- src/core/dispatch-series.ts | 5 - src/platforms/ios/__tests__/index.test.ts | 145 +++++++++ .../ios/__tests__/runner-client.test.ts | 194 +++++++++++ src/platforms/ios/interactions.ts | 29 +- src/platforms/ios/runner-contract.ts | 1 + src/platforms/ios/runner-xctestrun.ts | 308 ++++++++++++------ 14 files changed, 785 insertions(+), 138 deletions(-) diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.h b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.h index 1e1fb4a9e..1d536f514 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.h +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.h @@ -14,6 +14,13 @@ NS_ASSUME_NONNULL_BEGIN radius:(double)radius durationMs:(double)durationMs; ++ (NSString * _Nullable)synthesizeSwipeWithApplication:(id)application + x:(double)x + y:(double)y + x2:(double)x2 + y2:(double)y2 + durationMs:(double)durationMs; + @end NS_ASSUME_NONNULL_END diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m index 483f70864..d66277076 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m @@ -47,6 +47,12 @@ static id RunnerPointerPath( double durationMs, double side ); +static id RunnerSwipePointerPath( + const RunnerXCTestEventBridge *bridge, + CGPoint start, + CGPoint end, + double durationMs +); static CGPoint RunnerPointerPointAt( double x, double y, @@ -58,6 +64,8 @@ static CGPoint RunnerPointerPointAt( double t, double side ); +static CGPoint RunnerInterpolatedPoint(CGPoint start, CGPoint end, double t); +static double RunnerSmoothStep(double t); @implementation RunnerSynthesizedGesture @@ -87,6 +95,26 @@ + (NSString * _Nullable)synthesizeTransformWithApplication:(id)application } } ++ (NSString * _Nullable)synthesizeSwipeWithApplication:(id)application + x:(double)x + y:(double)y + x2:(double)x2 + y2:(double)y2 + durationMs:(double)durationMs { + @try { + return [self trySynthesizeSwipeWithApplication:application + x:x + y:y + x2:x2 + y2:y2 + durationMs:durationMs]; + } @catch (NSException *exception) { + NSString *name = exception.name ?: @"NSException"; + NSString *reason = exception.reason ?: @"private XCTest event synthesis failed"; + return [NSString stringWithFormat:@"%@: %@", name, reason]; + } +} + + (NSString * _Nullable)trySynthesizeTransformWithApplication:(id)application x:(double)x y:(double)y @@ -151,6 +179,51 @@ + (NSString * _Nullable)trySynthesizeTransformWithApplication:(id)application return nil; } ++ (NSString * _Nullable)trySynthesizeSwipeWithApplication:(id)application + x:(double)x + y:(double)y + x2:(double)x2 + y2:(double)y2 + durationMs:(double)durationMs { + RunnerXCTestEventBridge bridge; + NSString *missing = RunnerResolveXCTestEventBridge(application, &bridge); + if (missing != nil) { + return missing; + } + + NSInteger interfaceOrientation = + ((RunnerMsgSendInteger)objc_msgSend)(application, bridge.interfaceOrientationSelector); + NSInteger targetProcessID = ((RunnerMsgSendInteger)objc_msgSend)(application, bridge.processIDSelector); + if (targetProcessID <= 0) { + return @"private XCTest event synthesis unavailable: could not resolve target process ID"; + } + + id record = ((RunnerMsgSendInitRecord)objc_msgSend)( + [bridge.recordClass alloc], + bridge.initRecordSelector, + @"agent-device-swipe", + interfaceOrientation + ); + if (record == nil) { + return @"private XCTest event synthesis failed: could not create event record"; + } + ((RunnerMsgSendSetInteger)objc_msgSend)(record, bridge.setTargetProcessIDSelector, targetProcessID); + + id path = RunnerSwipePointerPath(&bridge, CGPointMake(x, y), CGPointMake(x2, y2), durationMs); + if (path == nil) { + return @"private XCTest event synthesis failed: could not create pointer path"; + } + ((RunnerMsgSendAddPath)objc_msgSend)(record, bridge.addPathSelector, path); + + NSError *error = nil; + BOOL ok = ((RunnerMsgSendSynthesize)objc_msgSend)(record, bridge.synthesizeSelector, &error); + if (!ok) { + NSString *detail = error.localizedDescription ?: @"synthesizeWithError returned false"; + return [NSString stringWithFormat:@"private XCTest event synthesis failed: %@", detail]; + } + return nil; +} + static NSString * _Nullable RunnerResolveXCTestEventBridge( id application, RunnerXCTestEventBridge *bridge @@ -270,6 +343,31 @@ static id RunnerPointerPath( return path; } +static id RunnerSwipePointerPath( + const RunnerXCTestEventBridge *bridge, + CGPoint start, + CGPoint end, + double durationMs +) { + id path = + ((RunnerMsgSendInitPath)objc_msgSend)([bridge->pathClass alloc], bridge->initPathSelector, start, 0.0); + if (path == nil) { + return nil; + } + + int frameCount = MAX(3, (int)(durationMs / 16.0)); + NSTimeInterval durationSeconds = durationMs / 1000.0; + for (int index = 1; index <= frameCount; index += 1) { + double t = (double)index / (double)frameCount; + CGPoint point = RunnerInterpolatedPoint(start, end, RunnerSmoothStep(t)); + NSTimeInterval offset = durationSeconds * t; + ((RunnerMsgSendPathMove)objc_msgSend)(path, bridge->moveSelector, point, offset); + } + + ((RunnerMsgSendPathOffset)objc_msgSend)(path, bridge->liftSelector, durationSeconds); + return path; +} + static CGPoint RunnerPointerPointAt( double x, double y, @@ -294,4 +392,15 @@ static CGPoint RunnerPointerPointAt( return CGPointMake(centerX + cos(angle) * radius * side, centerY + sin(angle) * radius * side); } +static CGPoint RunnerInterpolatedPoint(CGPoint start, CGPoint end, double t) { + return CGPointMake( + start.x + (end.x - start.x) * t, + start.y + (end.y - start.y) * t + ); +} + +static double RunnerSmoothStep(double t) { + return t * t * (3.0 - 2.0 * t); +} + @end diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index d2fb61e00..d0d2c855e 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -13,6 +13,10 @@ extension RunnerTests { return (gestureStartUptimeMs, currentUptimeMs()) } + private func synthesizedSwipeFallbackHoldDuration(durationMs: Double) -> TimeInterval { + min(max((durationMs / 5.0) / 1000.0, 0.016), 0.120) + } + func unsupportedResponse(for outcome: RunnerInteractionOutcome) -> Response? { switch outcome { case .performed: @@ -495,7 +499,6 @@ extension RunnerTests { guard let x = command.x, let y = command.y, let x2 = command.x2, let y2 = command.y2 else { return Response(ok: false, error: ErrorPayload(message: "drag requires x, y, x2, and y2")) } - let holdDuration = min(max((command.durationMs ?? 60) / 1000.0, 0.016), 10.0) let dragPoints = keyboardAvoidingDragPoints(app: activeApp, x: x, y: y, x2: x2, y2: y2) let dragFrame = resolvedDragVisualizationFrame( app: activeApp, @@ -504,6 +507,25 @@ extension RunnerTests { x2: dragPoints.x2, y2: dragPoints.y2 ) + if command.synthesized == true { + let durationMs = min(max(command.durationMs ?? 250, 16), 10000) + let (timing, outcome) = performGesture(activeApp, idleTimeout: false) { + synthesizedDragAt( + app: activeApp, + x: dragPoints.x, + y: dragPoints.y, + x2: dragPoints.x2, + y2: dragPoints.y2, + durationMs: durationMs + ) + } + if case .performed = outcome { + return gestureResponse(message: "dragged", timing: timing, frame: .drag(dragFrame)) + } + } + let holdDuration = command.synthesized == true + ? synthesizedSwipeFallbackHoldDuration(durationMs: command.durationMs ?? 250) + : min(max((command.durationMs ?? 60) / 1000.0, 0.016), 10.0) let (timing, outcome) = performGesture(activeApp) { dragAt( app: activeApp, diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift index 6fb48aecb..b59a36228 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift @@ -634,6 +634,42 @@ extension RunnerTests { return performCoordinateDrag(app: app, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration) } + func synthesizedDragAt( + app: XCUIApplication, + x: Double, + y: Double, + x2: Double, + y2: Double, + durationMs: Double + ) -> RunnerInteractionOutcome { +#if os(iOS) + if let message = RunnerSynthesizedGesture.synthesizeSwipe( + withApplication: app, + x: x, + y: y, + x2: x2, + y2: y2, + durationMs: durationMs + ) { + return .unsupported( + message: message, + hint: "Falling back to XCTest coordinate drag may be slower; update Xcode if this persists." + ) + } + return .performed +#elseif os(tvOS) + return .unsupported( + message: "coordinate drag is not supported on tvOS", + hint: "tvOS has no coordinate input; use remote-driven swipe/scroll to move focus instead." + ) +#else + return .unsupported( + message: "coordinate drag is not supported on macOS", + hint: "macOS automation has no touchscreen; use mouse-driven interactions instead." + ) +#endif + } + func keyboardAvoidingDragPoints( app: XCUIApplication, x: Double, diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift index 5c3f4b448..57708b8f6 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift @@ -147,6 +147,7 @@ struct Command: Codable { let scope: String? let raw: Bool? let fullscreen: Bool? + let synthesized: Bool? } struct Response: Codable { diff --git a/src/core/__tests__/dispatch-interactions.test.ts b/src/core/__tests__/dispatch-interactions.test.ts index 16417e59d..2cba4ca20 100644 --- a/src/core/__tests__/dispatch-interactions.test.ts +++ b/src/core/__tests__/dispatch-interactions.test.ts @@ -2,6 +2,7 @@ import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; import { handleRotateGestureCommand, + handleSwipeCommand, handleSwipePresetCommand, handleTransformGestureCommand, } from '../dispatch-interactions.ts'; @@ -117,6 +118,38 @@ test('handleSwipePresetCommand resolves Android in-page swipe to content lane', }); }); +test('handleSwipeCommand preserves iOS swipe duration through dispatch', async () => { + const calls: unknown[][] = []; + const interactor = { + ...makeUnusedInteractor(), + swipe: async (...args: unknown[]) => { + calls.push(args); + }, + }; + + const result = await handleSwipeCommand( + IOS_SIMULATOR, + interactor, + ['100', '200', '180', '200', '300'], + undefined, + ); + + assert.deepEqual(calls, [[100, 200, 180, 200, 300]]); + assert.deepEqual(result, { + x1: 100, + y1: 200, + x2: 180, + y2: 200, + durationMs: 300, + effectiveDurationMs: 300, + timingMode: 'direct', + count: 1, + pauseMs: 0, + pattern: 'one-way', + message: 'Swiped', + }); +}); + test('handleRotateGestureCommand routes Android through the interactor', async () => { const calls: unknown[][] = []; const interactor = { diff --git a/src/core/__tests__/dispatch-series.test.ts b/src/core/__tests__/dispatch-series.test.ts index e7630d63f..96adabf44 100644 --- a/src/core/__tests__/dispatch-series.test.ts +++ b/src/core/__tests__/dispatch-series.test.ts @@ -2,7 +2,6 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import { requireIntInRange, - clampIosSwipeDuration, shouldUseIosTapSeries, shouldUseIosDragSeries, } from '../dispatch-series.ts'; @@ -42,30 +41,6 @@ test('requireIntInRange throws for non-finite values', () => { } }); -// --- clampIosSwipeDuration --- - -test('clampIosSwipeDuration returns value within bounds unchanged', () => { - assert.equal(clampIosSwipeDuration(30), 30); -}); - -test('clampIosSwipeDuration clamps below-minimum to 16', () => { - assert.equal(clampIosSwipeDuration(5), 16); -}); - -test('clampIosSwipeDuration clamps above-maximum to 60', () => { - assert.equal(clampIosSwipeDuration(100), 60); -}); - -test('clampIosSwipeDuration returns exact boundary values unchanged', () => { - assert.equal(clampIosSwipeDuration(16), 16); - assert.equal(clampIosSwipeDuration(60), 60); -}); - -test('clampIosSwipeDuration rounds fractional input before clamping', () => { - assert.equal(clampIosSwipeDuration(30.4), 30); - assert.equal(clampIosSwipeDuration(15.6), 16); -}); - // --- shouldUseIosTapSeries --- test('shouldUseIosTapSeries returns true for iOS with count > 1 and no hold or jitter', () => { diff --git a/src/core/dispatch-interactions.ts b/src/core/dispatch-interactions.ts index ff9fa4417..fd6fb63d3 100644 --- a/src/core/dispatch-interactions.ts +++ b/src/core/dispatch-interactions.ts @@ -23,7 +23,6 @@ import { } from '../utils/scroll-edge-state.ts'; import { requireIntInRange, - clampIosSwipeDuration, shouldUseIosTapSeries, shouldUseIosDragSeries, computeDeterministicJitter, @@ -466,8 +465,7 @@ async function runSwipeCoordinates(params: { }): Promise> { const { device, interactor, context, x1, y1, x2, y2, requestedDurationMs, preset } = params; const durationMs = requireIntInRange(requestedDurationMs, 'durationMs', 16, 10_000); - const effectiveDurationMs = - device.platform === 'ios' ? clampIosSwipeDuration(durationMs) : durationMs; + const effectiveDurationMs = durationMs; const count = requireIntInRange(context?.count ?? 1, 'count', 1, 200); const pauseMs = requireIntInRange(context?.pauseMs ?? 0, 'pause-ms', 0, 10_000); const pattern = context?.pattern ?? 'one-way'; @@ -530,7 +528,7 @@ async function runSwipeCoordinates(params: { ...(preset ? { preset } : {}), durationMs, effectiveDurationMs, - timingMode: device.platform === 'ios' ? 'safe-normalized' : 'direct', + timingMode: 'direct', count, pauseMs, pattern, diff --git a/src/core/dispatch-series.ts b/src/core/dispatch-series.ts index e22b61597..f43d431b4 100644 --- a/src/core/dispatch-series.ts +++ b/src/core/dispatch-series.ts @@ -14,11 +14,6 @@ const DETERMINISTIC_JITTER_PATTERN: ReadonlyArray = [ [-1, -1], ]; -export function clampIosSwipeDuration(durationMs: number): number { - // Keep iOS swipes stable while allowing explicit fast durations for scroll-heavy flows. - return Math.min(60, Math.max(16, Math.round(durationMs))); -} - export function shouldUseIosTapSeries( device: DeviceInfo, count: number, diff --git a/src/platforms/ios/__tests__/index.test.ts b/src/platforms/ios/__tests__/index.test.ts index d1329e8ae..3fa6a692a 100644 --- a/src/platforms/ios/__tests__/index.test.ts +++ b/src/platforms/ios/__tests__/index.test.ts @@ -104,6 +104,15 @@ const MACOS_TEST_DEVICE: DeviceInfo = { booted: true, }; +const TVOS_TEST_SIMULATOR: DeviceInfo = { + platform: 'ios', + id: 'tvos-sim-1', + name: 'Apple TV', + kind: 'simulator', + target: 'tv', + booted: true, +}; + const mockRunCmd = vi.mocked(runCmd); const mockRetryWithPolicy = vi.mocked(retryWithPolicy); const mockRunIosRunnerCommand = vi.mocked(runIosRunnerCommand); @@ -160,6 +169,142 @@ test('iosRunnerOverrides gives fling a short default XCUITest drag hold', async }); }); +test('iosRunnerOverrides maps swipe to synthesized iOS drag duration', async () => { + mockRunIosRunnerCommand.mockResolvedValue({}); + + const { overrides } = iosRunnerOverrides(IOS_TEST_SIMULATOR, { + appBundleId: 'com.example.App', + }); + + await overrides.swipe(100, 200, 180, 200, 300); + await overrides.swipe(100, 200, 180, 200, undefined); + await overrides.pan(100, 200, 180, 200, 300); + + assert.deepEqual(mockRunIosRunnerCommand.mock.calls[0]?.[1], { + command: 'drag', + x: 100, + y: 200, + x2: 180, + y2: 200, + durationMs: 300, + synthesized: true, + appBundleId: 'com.example.App', + }); + assert.deepEqual(mockRunIosRunnerCommand.mock.calls[1]?.[1], { + command: 'drag', + x: 100, + y: 200, + x2: 180, + y2: 200, + durationMs: 250, + synthesized: true, + appBundleId: 'com.example.App', + }); + assert.deepEqual(mockRunIosRunnerCommand.mock.calls[2]?.[1], { + command: 'drag', + x: 100, + y: 200, + x2: 180, + y2: 200, + durationMs: 300, + appBundleId: 'com.example.App', + }); +}); + +test('iosRunnerOverrides keeps macOS swipes on the standard drag path', async () => { + mockRunIosRunnerCommand.mockResolvedValue({}); + + const { overrides } = iosRunnerOverrides(MACOS_TEST_DEVICE, { + appBundleId: 'com.example.App', + }); + + await overrides.swipe(100, 200, 180, 200, 300); + + assert.deepEqual(mockRunIosRunnerCommand.mock.calls[0]?.[1], { + command: 'drag', + x: 100, + y: 200, + x2: 180, + y2: 200, + durationMs: 300, + appBundleId: 'com.example.App', + }); +}); + +test('iosRunnerOverrides keeps tvOS swipes on the standard drag path', async () => { + mockRunIosRunnerCommand.mockResolvedValue({}); + + const { overrides } = iosRunnerOverrides(TVOS_TEST_SIMULATOR, { + appBundleId: 'com.example.App', + }); + + await overrides.swipe(100, 200, 180, 200, 300); + + assert.deepEqual(mockRunIosRunnerCommand.mock.calls[0]?.[1], { + command: 'drag', + x: 100, + y: 200, + x2: 180, + y2: 200, + durationMs: 300, + appBundleId: 'com.example.App', + }); +}); + +test('iosRunnerOverrides maps iOS scroll to synthesized drag', async () => { + mockRunIosRunnerCommand + .mockResolvedValueOnce({ + x: 0, + y: 0, + referenceWidth: 400, + referenceHeight: 800, + }) + .mockResolvedValueOnce({}); + + const { overrides } = iosRunnerOverrides(IOS_TEST_SIMULATOR, { + appBundleId: 'com.example.App', + }); + + await overrides.scroll('down'); + + assert.deepEqual(mockRunIosRunnerCommand.mock.calls[1]?.[1], { + command: 'drag', + x: 200, + y: 640, + x2: 200, + y2: 160, + durationMs: 250, + synthesized: true, + appBundleId: 'com.example.App', + }); +}); + +test('iosRunnerOverrides keeps macOS scroll on the standard drag path', async () => { + mockRunIosRunnerCommand + .mockResolvedValueOnce({ + x: 0, + y: 0, + referenceWidth: 400, + referenceHeight: 800, + }) + .mockResolvedValueOnce({}); + + const { overrides } = iosRunnerOverrides(MACOS_TEST_DEVICE, { + appBundleId: 'com.example.App', + }); + + await overrides.scroll('down'); + + assert.deepEqual(mockRunIosRunnerCommand.mock.calls[1]?.[1], { + command: 'drag', + x: 200, + y: 640, + x2: 200, + y2: 160, + appBundleId: 'com.example.App', + }); +}); + test('AGENT_DEVICE_MACOS_HELPER_BIN rejects relative override paths', async () => { const previousHelperPath = process.env.AGENT_DEVICE_MACOS_HELPER_BIN; process.env.AGENT_DEVICE_MACOS_HELPER_BIN = './agent-device-macos-helper'; diff --git a/src/platforms/ios/__tests__/runner-client.test.ts b/src/platforms/ios/__tests__/runner-client.test.ts index 79b732e01..436690bc0 100644 --- a/src/platforms/ios/__tests__/runner-client.test.ts +++ b/src/platforms/ios/__tests__/runner-client.test.ts @@ -46,8 +46,10 @@ import { shouldRetryRunnerConnectError, } from '../runner-client.ts'; import { + acquireRunnerXctestrunCacheLock, ensureXctestrun, resolveExpectedRunnerCacheMetadata, + resolveRunnerDerivedPath, resolveRunnerCacheMetadataPath, resolveRunnerPerformanceBuildSettings, shouldDeleteRunnerDerivedRootEntry, @@ -185,6 +187,10 @@ async function makeProjectTmpDir(): Promise { return tmpDir; } +async function waitMs(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + function writeXctestrunFixture( xctestrunPath: string, options: { projectRoot: string; productRelativePaths: string[] }, @@ -267,6 +273,14 @@ function withRunnerDerivedPathEnv(derivedPath: string): void { }); } +function withoutRunnerDerivedPathEnv(): void { + const previousDerivedPath = process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH; + delete process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH; + onTestFinished(() => { + restoreEnvVar('AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH', previousDerivedPath); + }); +} + function restoreEnvVar(name: string, value: string | undefined): void { if (value === undefined) { delete process.env[name]; @@ -294,9 +308,11 @@ function writeRunnerCacheMetadataWithArtifacts(params: { artifacts: { xctestrunPath: params.xctestrunPath, xctestrunMtimeMs: Math.trunc(fs.statSync(params.xctestrunPath).mtimeMs), + xctestrunSize: fs.statSync(params.xctestrunPath).size, productPaths: params.productPaths.map((productPath) => ({ path: productPath, mtimeMs: Math.trunc(fs.statSync(productPath).mtimeMs), + size: fs.statSync(productPath).size, })), }, }, @@ -687,6 +703,184 @@ test('xctestrunReferencesExistingProducts accepts xctestruns when referenced pro assert.equal(await xctestrunReferencesExistingProducts(xctestrunPath), true); }); +test('resolveRunnerDerivedPath keys default cache by runner metadata', () => { + withoutRunnerDerivedPathEnv(); + const metadata = resolveExpectedRunnerCacheMetadata(iosSimulator, repoRoot); + const iosPath = resolveRunnerDerivedPath(iosSimulator, metadata); + const tvPath = resolveRunnerDerivedPath(tvOsSimulator, { + ...metadata, + platformName: 'tvOS', + target: 'tv', + buildDestinationFamily: 'appletvsimulator', + }); + const staleVersionPath = resolveRunnerDerivedPath(iosSimulator, { + ...metadata, + packageVersion: '0.0.0-stale', + }); + + assert.match(iosPath, /\/ios-runner\/derived\/ios-simulator\/cache-[a-f0-9]{16}$/); + assert.match(tvPath, /\/ios-runner\/derived\/tvos-simulator\/cache-[a-f0-9]{16}$/); + assert.notEqual(iosPath, staleVersionPath); +}); + +test('resolveRunnerDerivedPath reuses cache path for identical runner source fingerprints', async () => { + withoutRunnerDerivedPathEnv(); + const tmpDir = await makeTmpDir(); + const firstRoot = path.join(tmpDir, 'first'); + const secondRoot = path.join(tmpDir, 'second'); + const runnerRelativePath = path.join( + 'ios-runner', + 'AgentDeviceRunner', + 'AgentDeviceRunnerUITests', + 'RunnerTests.swift', + ); + await fs.promises.mkdir(path.dirname(path.join(firstRoot, runnerRelativePath)), { + recursive: true, + }); + await fs.promises.mkdir(path.dirname(path.join(secondRoot, runnerRelativePath)), { + recursive: true, + }); + await fs.promises.writeFile(path.join(firstRoot, runnerRelativePath), 'final class RunnerTests {}\n'); + await fs.promises.writeFile(path.join(secondRoot, runnerRelativePath), 'final class RunnerTests {}\n'); + + const firstPath = resolveRunnerDerivedPath( + iosSimulator, + resolveExpectedRunnerCacheMetadata(iosSimulator, firstRoot), + ); + const secondPath = resolveRunnerDerivedPath( + iosSimulator, + resolveExpectedRunnerCacheMetadata(iosSimulator, secondRoot), + ); + await fs.promises.writeFile( + path.join(secondRoot, runnerRelativePath), + 'final class RunnerTests { let changed = true }\n', + ); + const changedPath = resolveRunnerDerivedPath( + iosSimulator, + resolveExpectedRunnerCacheMetadata(iosSimulator, secondRoot), + ); + + assert.equal(firstPath, secondPath); + assert.notEqual(firstPath, changedPath); +}); + +test('acquireRunnerXctestrunCacheLock serializes cache access across acquirers', async () => { + const tmpDir = await makeTmpDir(); + const derivedPath = path.join(tmpDir, 'derived'); + const releaseFirst = await acquireRunnerXctestrunCacheLock(derivedPath); + let secondAcquired = false; + const second = acquireRunnerXctestrunCacheLock(derivedPath).then(async (releaseSecond) => { + secondAcquired = true; + await releaseSecond(); + }); + + await waitMs(50); + assert.equal(secondAcquired, false); + await releaseFirst(); + await second; + assert.equal(secondAcquired, true); +}); + +test('ensureXctestrun reuses matching manifest artifacts from another project root', async () => { + const tmpDir = await makeTmpDir(); + const derivedPath = path.join(tmpDir, 'custom-derived'); + const productPath = path.join(derivedPath, 'Runner.app'); + const xctestrunPath = path.join(derivedPath, 'manifest.xctestrun'); + await fs.promises.mkdir(productPath, { recursive: true }); + writeXctestrunFixture(xctestrunPath, { + projectRoot: '/tmp/other-agent-device-worktree', + productRelativePaths: ['Runner.app'], + }); + writeRunnerCacheMetadataWithArtifacts({ + derivedPath, + device: macOsDevice, + xctestrunPath, + productPaths: [productPath], + }); + withRunnerDerivedPathEnv(derivedPath); + + const result = await ensureXctestrun(macOsDevice, {}); + + assert.equal(result, xctestrunPath); + assert.equal(mockRunCmdStreaming.mock.calls.length, 0); + assert.deepEqual(mockRepairMacOsRunnerProductsIfNeeded.mock.calls[0]?.[1], [productPath]); +}); + +test('ensureXctestrun rebuilds foreign artifacts when metadata does not match', async () => { + const projectRoot = repoRoot; + const tmpDir = await makeProjectTmpDir(); + const derivedPath = path.join(tmpDir, 'custom-derived'); + const productPath = path.join(derivedPath, 'Runner.app'); + const foreignXctestrunPath = path.join(derivedPath, 'foreign.xctestrun'); + const rebuiltXctestrunPath = path.join(derivedPath, 'rebuilt', 'rebuilt.xctestrun'); + await fs.promises.mkdir(productPath, { recursive: true }); + writeXctestrunFixture(foreignXctestrunPath, { + projectRoot: '/tmp/other-agent-device-worktree', + productRelativePaths: ['Runner.app'], + }); + writeRunnerCacheMetadataWithArtifacts({ + derivedPath, + device: macOsDevice, + xctestrunPath: foreignXctestrunPath, + productPaths: [productPath], + }); + const metadataPath = resolveRunnerCacheMetadataPath(derivedPath); + const staleMetadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); + staleMetadata.packageVersion = '0.0.0-stale'; + fs.writeFileSync(metadataPath, JSON.stringify(staleMetadata, null, 2)); + withRunnerDerivedPathEnv(derivedPath); + + mockRunCmdStreaming.mockImplementation(async () => { + await fs.promises.mkdir(path.join(derivedPath, 'rebuilt', 'Runner.app'), { recursive: true }); + writeXctestrunFixture(rebuiltXctestrunPath, { + projectRoot, + productRelativePaths: ['Runner.app'], + }); + }); + + const result = await ensureXctestrun(macOsDevice, {}); + + assert.equal(result, rebuiltXctestrunPath); + assert.equal(mockRunCmdStreaming.mock.calls.length, 1); + assert.equal(fs.existsSync(foreignXctestrunPath), false); +}); + +test('ensureXctestrun ignores manifest artifacts outside the cache root', async () => { + const projectRoot = repoRoot; + const tmpDir = await makeProjectTmpDir(); + const derivedPath = path.join(tmpDir, 'custom-derived'); + const externalDir = path.join(tmpDir, 'external'); + const externalProductPath = path.join(externalDir, 'Runner.app'); + const externalXctestrunPath = path.join(externalDir, 'external.xctestrun'); + const rebuiltXctestrunPath = path.join(derivedPath, 'rebuilt', 'rebuilt.xctestrun'); + await fs.promises.mkdir(externalProductPath, { recursive: true }); + writeXctestrunFixture(externalXctestrunPath, { + projectRoot, + productRelativePaths: ['Runner.app'], + }); + await fs.promises.mkdir(derivedPath, { recursive: true }); + writeRunnerCacheMetadataWithArtifacts({ + derivedPath, + device: macOsDevice, + xctestrunPath: externalXctestrunPath, + productPaths: [externalProductPath], + }); + withRunnerDerivedPathEnv(derivedPath); + + mockRunCmdStreaming.mockImplementation(async () => { + await fs.promises.mkdir(path.join(derivedPath, 'rebuilt', 'Runner.app'), { recursive: true }); + writeXctestrunFixture(rebuiltXctestrunPath, { + projectRoot, + productRelativePaths: ['Runner.app'], + }); + }); + + const result = await ensureXctestrun(macOsDevice, {}); + + assert.equal(result, rebuiltXctestrunPath); + assert.equal(mockRunCmdStreaming.mock.calls.length, 1); +}); + test('xctestrunReferencesExistingProducts parses nested plist fallback values from XML', async () => { const { debugDir, xctestrunPath } = await makeXctestrunProductsFixture(); await fs.promises.mkdir(path.join(debugDir, 'AgentDeviceRunner.app'), { recursive: true }); diff --git a/src/platforms/ios/interactions.ts b/src/platforms/ios/interactions.ts index 4ec397caf..d387a4d72 100644 --- a/src/platforms/ios/interactions.ts +++ b/src/platforms/ios/interactions.ts @@ -22,6 +22,10 @@ type InteractionFrame = { referenceHeight: number; }; +const IOS_SWIPE_DEFAULT_DURATION_MS = 250; +const IOS_SWIPE_MIN_DURATION_MS = 16; +const IOS_SWIPE_MAX_DURATION_MS = 10_000; + type NormalizedScrollOptions = { amount?: number; pixels?: number; @@ -104,9 +108,19 @@ export function iosRunnerOverrides( ); }, swipe: async (x1, y1, x2, y2, durationMs) => { + const useSynthesizedSwipe = shouldUseSynthesizedSwipe(device); return await runIosRunnerCommand( device, - { command: 'drag', x: x1, y: y1, x2, y2, durationMs, appBundleId: ctx.appBundleId }, + { + command: 'drag', + x: x1, + y: y1, + x2, + y2, + durationMs: useSynthesizedSwipe ? iosSwipeDurationMs(durationMs) : durationMs, + ...(useSynthesizedSwipe ? { synthesized: true } : {}), + appBundleId: ctx.appBundleId, + }, runnerOpts, ); }, @@ -256,6 +270,16 @@ export function iosRunnerOverrides( }; } +function iosSwipeDurationMs(durationMs: number | undefined): number { + if (durationMs === undefined) return IOS_SWIPE_DEFAULT_DURATION_MS; + + return Math.min(IOS_SWIPE_MAX_DURATION_MS, Math.max(IOS_SWIPE_MIN_DURATION_MS, Math.round(durationMs))); +} + +function shouldUseSynthesizedSwipe(device: DeviceInfo): boolean { + return device.platform === 'ios' && device.target !== 'tv'; +} + export function appleRemotePressCommand( remoteButton: AppleRemoteButton, appBundleId?: string, @@ -305,6 +329,9 @@ async function runAppleScroll( y: frame.originY + plan.y1, x2: frame.originX + plan.x2, y2: frame.originY + plan.y2, + ...(shouldUseSynthesizedSwipe(device) + ? { durationMs: IOS_SWIPE_DEFAULT_DURATION_MS, synthesized: true } + : {}), appBundleId: ctx.appBundleId, }, runnerOpts, diff --git a/src/platforms/ios/runner-contract.ts b/src/platforms/ios/runner-contract.ts index e6af965f3..c2168446b 100644 --- a/src/platforms/ios/runner-contract.ts +++ b/src/platforms/ios/runner-contract.ts @@ -81,6 +81,7 @@ export type RunnerCommand = { scope?: string; raw?: boolean; fullscreen?: boolean; + synthesized?: boolean; /** * @deprecated Use textEntryMode: 'replace'. Kept for compatibility with older local runner clients. */ diff --git a/src/platforms/ios/runner-xctestrun.ts b/src/platforms/ios/runner-xctestrun.ts index f4486c47a..4f48ef6df 100644 --- a/src/platforms/ios/runner-xctestrun.ts +++ b/src/platforms/ios/runner-xctestrun.ts @@ -33,6 +33,7 @@ const RUNNER_CACHE_SCHEMA_VERSION = 1; const XCTEST_DEVICE_SET_LOCK_TIMEOUT_MS = 30_000; const XCTEST_DEVICE_SET_LOCK_POLL_MS = 100; const XCTEST_DEVICE_SET_LOCK_OWNER_GRACE_MS = 5_000; +const RUNNER_XCTESTRUN_CACHE_LOCK_TIMEOUT_MS = 10 * 60_000; const RUNNER_XCTESTRUN_CAPTURE_OPTIONS = { PreferredScreenCaptureFormat: 'screenshots', SystemAttachmentLifetime: 'keepNever', @@ -99,12 +100,14 @@ export type RunnerXctestrunCacheMetadata = { type RunnerXctestrunCacheArtifacts = { xctestrunPath: string; xctestrunMtimeMs: number; + xctestrunSize: number; productPaths: RunnerXctestrunCacheProductArtifact[]; }; type RunnerXctestrunCacheProductArtifact = { path: string; mtimeMs: number; + size: number; }; function normalizeBundleId(value: string | undefined): string { @@ -357,10 +360,15 @@ function sameResolvedPath(left: string, right: string): boolean { async function acquireXcodebuildSimulatorSetLock(params: { lockDirPath: string; owner: XcodebuildSimulatorSetLockOwner; + timeoutMs?: number; + pollMs?: number; + description?: string; }): Promise<() => Promise> { const { lockDirPath, owner } = params; const ownerFilePath = path.join(lockDirPath, 'owner.json'); - const deadline = Date.now() + XCTEST_DEVICE_SET_LOCK_TIMEOUT_MS; + const deadline = Date.now() + (params.timeoutMs ?? XCTEST_DEVICE_SET_LOCK_TIMEOUT_MS); + const pollMs = params.pollMs ?? XCTEST_DEVICE_SET_LOCK_POLL_MS; + const description = params.description ?? 'XCTest device set lock'; fs.mkdirSync(path.dirname(lockDirPath), { recursive: true }); @@ -384,15 +392,34 @@ async function acquireXcodebuildSimulatorSetLock(params: { if (clearStaleXcodebuildSimulatorSetLock(lockDirPath, ownerFilePath)) { continue; } - await sleep(XCTEST_DEVICE_SET_LOCK_POLL_MS); + await sleep(pollMs); } } - throw new AppError('COMMAND_FAILED', 'Timed out waiting for XCTest device set lock', { + throw new AppError('COMMAND_FAILED', `Timed out waiting for ${description}`, { lockDirPath, }); } +export async function acquireRunnerXctestrunCacheLock( + derived: string, +): Promise<() => Promise> { + return await acquireXcodebuildSimulatorSetLock({ + lockDirPath: resolveRunnerXctestrunCacheLockPath(derived), + owner: { + pid: process.pid, + startTime: readProcessStartTime(process.pid), + acquiredAtMs: Date.now(), + }, + timeoutMs: RUNNER_XCTESTRUN_CACHE_LOCK_TIMEOUT_MS, + description: 'iOS runner cache lock', + }); +} + +function resolveRunnerXctestrunCacheLockPath(derived: string): string { + return path.join(path.dirname(derived), `${path.basename(derived)}.lock`); +} + function writeXcodebuildSimulatorSetLockOwner( ownerFilePath: string, owner: XcodebuildSimulatorSetLockOwner, @@ -452,102 +479,120 @@ export async function ensureXctestrun( device: DeviceInfo, options: { verbose?: boolean; logPath?: string; traceLogPath?: string }, ): Promise { - const derived = resolveRunnerDerivedPath(device); const projectRoot = findProjectRoot(); - // fallow-ignore-next-line complexity + const expectedCacheMetadata = resolveExpectedRunnerCacheMetadata(device, projectRoot); + const derived = resolveRunnerDerivedPath(device, expectedCacheMetadata); return await withKeyedLock(runnerXctestrunBuildLocks, derived, async () => { - const expectedCacheMetadata = resolveExpectedRunnerCacheMetadata(device, projectRoot); - if (shouldCleanDerived()) { - emitRunnerXctestrunDecision('clean', 'forced_clean', { derived }); - assertSafeDerivedCleanup(derived); - cleanRunnerDerivedArtifacts(derived); + const releaseCacheLock = await acquireRunnerXctestrunCacheLock(derived); + try { + return await ensureXctestrunUnderCacheLock({ + device, + options, + projectRoot, + expectedCacheMetadata, + derived, + }); + } finally { + await releaseCacheLock(); } - const existing = await evaluateExistingXctestrun({ + }); +} + +// fallow-ignore-next-line complexity +async function ensureXctestrunUnderCacheLock(params: { + device: DeviceInfo; + options: { verbose?: boolean; logPath?: string; traceLogPath?: string }; + projectRoot: string; + expectedCacheMetadata: RunnerXctestrunCacheMetadata; + derived: string; +}): Promise { + const { device, options, projectRoot, expectedCacheMetadata, derived } = params; + if (shouldCleanDerived()) { + emitRunnerXctestrunDecision('clean', 'forced_clean', { derived }); + assertSafeDerivedCleanup(derived); + cleanRunnerDerivedArtifacts(derived); + } + const existing = await evaluateExistingXctestrun({ + derived, + projectRoot, + expectedCacheMetadata, + findXctestrun: (root) => findXctestrun(root, device), + xctestrunReferencesProjectRoot, + resolveExistingXctestrunProductPaths, + }); + if (existing.reason !== 'reuse_ready') { + emitRunnerXctestrunDecision('rebuild', existing.reason, { derived, - projectRoot, - expectedCacheMetadata, - findXctestrun: (root) => findXctestrun(root, device), - xctestrunReferencesProjectRoot, - resolveExistingXctestrunProductPaths, + xctestrunPath: existing.xctestrunPath, }); - if (existing.reason !== 'reuse_ready') { - emitRunnerXctestrunDecision('rebuild', existing.reason, { + } + if (existing.reason === 'reuse_ready') { + try { + await repairMacOsRunnerProductsIfNeeded(device, existing.productPaths, existing.xctestrunPath); + emitRunnerXctestrunDecision('reuse', 'reuse_ready', { derived, xctestrunPath: existing.xctestrunPath, }); - } - if (existing.reason === 'reuse_ready') { - try { - await repairMacOsRunnerProductsIfNeeded( - device, - existing.productPaths, + writeRunnerCacheMetadata( + derived, + withRunnerCacheArtifacts( + expectedCacheMetadata, existing.xctestrunPath, - ); - emitRunnerXctestrunDecision('reuse', 'reuse_ready', { - derived, - xctestrunPath: existing.xctestrunPath, - }); - writeRunnerCacheMetadata( - derived, - withRunnerCacheArtifacts( - expectedCacheMetadata, - existing.xctestrunPath, - existing.productPaths, - ), - ); - return existing.xctestrunPath; - } catch (error) { - if (!isExpectedRunnerRepairFailure(error)) { - throw error; - } - emitRunnerXctestrunDecision('rebuild', 'repair_failed', { - derived, - xctestrunPath: existing.xctestrunPath, - }); - // Fall through and rebuild from a clean derived state. + existing.productPaths, + ), + ); + return existing.xctestrunPath; + } catch (error) { + if (!isExpectedRunnerRepairFailure(error)) { + throw error; } + emitRunnerXctestrunDecision('rebuild', 'repair_failed', { + derived, + xctestrunPath: existing.xctestrunPath, + }); + // Fall through and rebuild from a clean derived state. } - if (existing.xctestrunPath) { - assertSafeDerivedCleanup(derived); - cleanRunnerDerivedArtifacts(derived); - } - const projectPath = path.join( - projectRoot, - 'ios-runner', - 'AgentDeviceRunner', - 'AgentDeviceRunner.xcodeproj', - ); + } + if (existing.xctestrunPath) { + assertSafeDerivedCleanup(derived); + cleanRunnerDerivedArtifacts(derived); + } + const projectPath = path.join( + projectRoot, + 'ios-runner', + 'AgentDeviceRunner', + 'AgentDeviceRunner.xcodeproj', + ); - if (!fs.existsSync(projectPath)) { - throw new AppError('COMMAND_FAILED', 'iOS runner project not found', { projectPath }); - } + if (!fs.existsSync(projectPath)) { + throw new AppError('COMMAND_FAILED', 'iOS runner project not found', { projectPath }); + } - await buildRunnerXctestrun(device, projectPath, derived, options); + await buildRunnerXctestrun(device, projectPath, derived, options); - const built = findXctestrun(derived, device); - if (!built) { - throw new AppError('COMMAND_FAILED', 'Failed to locate .xctestrun after build'); - } - const builtProductPaths = await resolveExistingXctestrunProductPaths(built); - if (!builtProductPaths) { - throw new AppError('COMMAND_FAILED', 'Runner build is missing expected products', { - xctestrunPath: built, - }); - } - await repairMacOsRunnerProductsIfNeeded(device, builtProductPaths, built); - // Release/dev script builds patch the synthesized XCTest runner app in scripts/. - // This covers direct local xcodebuilds triggered by ensureXctestrun on cache miss. - await applyXctestRunnerAppIcon(builtProductPaths); - writeRunnerCacheMetadata( - derived, - withRunnerCacheArtifacts(expectedCacheMetadata, built, builtProductPaths), - ); - emitRunnerXctestrunDecision('build', 'built_new', { - derived, + const built = findXctestrun(derived, device); + if (!built) { + throw new AppError('COMMAND_FAILED', 'Failed to locate .xctestrun after build'); + } + const builtProductPaths = await resolveExistingXctestrunProductPaths(built); + if (!builtProductPaths) { + throw new AppError('COMMAND_FAILED', 'Runner build is missing expected products', { xctestrunPath: built, }); - return built; + } + await repairMacOsRunnerProductsIfNeeded(device, builtProductPaths, built); + // Release/dev script builds patch the synthesized XCTest runner app in scripts/. + // This covers direct local xcodebuilds triggered by ensureXctestrun on cache miss. + await applyXctestRunnerAppIcon(builtProductPaths); + writeRunnerCacheMetadata( + derived, + withRunnerCacheArtifacts(expectedCacheMetadata, built, builtProductPaths), + ); + emitRunnerXctestrunDecision('build', 'built_new', { + derived, + xctestrunPath: built, }); + return built; } function cleanRunnerDerivedArtifacts(derived: string): void { @@ -658,6 +703,14 @@ function comparableRunnerCacheMetadata( return comparable; } +function resolveRunnerDerivedCacheKey(metadata: RunnerXctestrunCacheMetadata): string { + const hash = crypto + .createHash('sha256') + .update(JSON.stringify(comparableRunnerCacheMetadata(metadata))) + .digest('hex'); + return `cache-${hash.slice(0, 16)}`; +} + function withRunnerCacheArtifacts( metadata: RunnerXctestrunCacheMetadata, xctestrunPath: string, @@ -671,38 +724,51 @@ function buildRunnerCacheArtifacts( xctestrunPath: string, productPaths: readonly string[], ): RunnerXctestrunCacheArtifacts | null { - const xctestrunMtimeMs = readFileMtimeMs(xctestrunPath); - if (xctestrunMtimeMs === null || productPaths.length === 0) { + const xctestrunStats = readPathSignature(xctestrunPath); + if (xctestrunStats === null || productPaths.length === 0) { return null; } const productArtifacts: RunnerXctestrunCacheProductArtifact[] = []; for (const productPath of productPaths) { - const mtimeMs = readFileMtimeMs(productPath); - if (mtimeMs === null) { + const stats = readPathSignature(productPath); + if (stats === null) { return null; } - productArtifacts.push({ path: productPath, mtimeMs }); + productArtifacts.push({ path: productPath, ...stats }); } return { xctestrunPath, - xctestrunMtimeMs, + xctestrunMtimeMs: xctestrunStats.mtimeMs, + xctestrunSize: xctestrunStats.size, productPaths: productArtifacts, }; } function readValidatedRunnerCacheArtifacts( + derived: string, metadata: RunnerXctestrunCacheMetadata | null, ): { xctestrunPath: string; productPaths: string[] } | null { const artifacts = metadata?.artifacts; if (!isRunnerCacheArtifacts(artifacts)) { return null; } - if (readFileMtimeMs(artifacts.xctestrunPath) !== artifacts.xctestrunMtimeMs) { + if (!isPathInsideDirectory(artifacts.xctestrunPath, derived)) { + return null; + } + if ( + !pathSignatureMatches(artifacts.xctestrunPath, { + mtimeMs: artifacts.xctestrunMtimeMs, + size: artifacts.xctestrunSize, + }) + ) { return null; } const productPaths: string[] = []; for (const product of artifacts.productPaths) { - if (readFileMtimeMs(product.path) !== product.mtimeMs) { + if (!isPathInsideDirectory(product.path, derived)) { + return null; + } + if (!pathSignatureMatches(product.path, product)) { return null; } productPaths.push(product.path); @@ -718,6 +784,7 @@ function isRunnerCacheArtifacts(value: unknown): value is RunnerXctestrunCacheAr return ( typeof artifacts.xctestrunPath === 'string' && Number.isInteger(artifacts.xctestrunMtimeMs) && + Number.isInteger(artifacts.xctestrunSize) && Array.isArray(artifacts.productPaths) && artifacts.productPaths.length > 0 && artifacts.productPaths.every(isRunnerCacheProductArtifact) @@ -731,17 +798,35 @@ function isRunnerCacheProductArtifact( return false; } const product = value as Partial; - return typeof product.path === 'string' && Number.isInteger(product.mtimeMs); + return ( + typeof product.path === 'string' && + Number.isInteger(product.mtimeMs) && + Number.isInteger(product.size) + ); } -function readFileMtimeMs(filePath: string): number | null { +function readPathSignature(filePath: string): { mtimeMs: number; size: number } | null { try { - return Math.trunc(fs.statSync(filePath).mtimeMs); + const stat = fs.statSync(filePath); + return { mtimeMs: Math.trunc(stat.mtimeMs), size: stat.size }; } catch { return null; } } +function pathSignatureMatches( + filePath: string, + expected: { mtimeMs: number; size: number }, +): boolean { + const actual = readPathSignature(filePath); + return actual?.mtimeMs === expected.mtimeMs && actual.size === expected.size; +} + +function isPathInsideDirectory(targetPath: string, directoryPath: string): boolean { + const relativePath = path.relative(path.resolve(directoryPath), path.resolve(targetPath)); + return relativePath !== '' && !relativePath.startsWith('..') && !path.isAbsolute(relativePath); +} + type RunnerSourceFingerprintCacheEntry = { fileStatsFingerprint: string; sourceFingerprint: string; @@ -1158,18 +1243,34 @@ async function buildRunnerXctestrun( } } -function resolveRunnerDerivedPath(device: DeviceInfo): string { +export function resolveRunnerDerivedPath( + device: DeviceInfo, + metadata: RunnerXctestrunCacheMetadata, +): string { const override = process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim(); if (override) { return path.resolve(override); } + const cacheKey = resolveRunnerDerivedCacheKey(metadata); + const base = resolveRunnerDerivedBasePath(device); + return path.join(base, cacheKey); +} + +function resolveRunnerDerivedBasePath(device: DeviceInfo): string { if (device.platform === 'macos') { return path.join(RUNNER_DERIVED_ROOT, 'derived', 'macos'); } + if (device.target === 'tv') { + return path.join( + RUNNER_DERIVED_ROOT, + 'derived', + device.kind === 'simulator' ? 'tvos-simulator' : 'tvos-device', + ); + } if (device.kind === 'simulator') { - return path.join(RUNNER_DERIVED_ROOT, 'derived'); + return path.join(RUNNER_DERIVED_ROOT, 'derived', 'ios-simulator'); } - return path.join(RUNNER_DERIVED_ROOT, 'derived', device.kind); + return path.join(RUNNER_DERIVED_ROOT, 'derived', 'ios-device'); } export function resolveRunnerDestination(device: DeviceInfo): string { @@ -1332,20 +1433,23 @@ async function evaluateExistingXctestrun(options: { }): Promise { const cacheMetadata = evaluateRunnerCacheMetadata(options.derived, options.expectedCacheMetadata); const manifest = cacheMetadata.ok - ? readValidatedRunnerCacheArtifacts(cacheMetadata.metadata) + ? readValidatedRunnerCacheArtifacts(options.derived, cacheMetadata.metadata) : null; const xctestrunPath = manifest?.xctestrunPath ?? options.findXctestrun(options.derived); if (!xctestrunPath) { return { reason: 'missing_xctestrun', xctestrunPath: null }; } - const productPaths = - manifest?.xctestrunPath === xctestrunPath - ? manifest.productPaths - : await options.resolveExistingXctestrunProductPaths(xctestrunPath); + const hasValidatedManifest = manifest?.xctestrunPath === xctestrunPath; + const productPaths = hasValidatedManifest + ? manifest.productPaths + : await options.resolveExistingXctestrunProductPaths(xctestrunPath); if (!productPaths) { return { reason: 'missing_products', xctestrunPath, productPaths: [] }; } - if (!options.xctestrunReferencesProjectRoot(xctestrunPath, options.projectRoot)) { + if ( + !options.xctestrunReferencesProjectRoot(xctestrunPath, options.projectRoot) && + !hasValidatedManifest + ) { return { reason: 'project_root_mismatch', xctestrunPath, productPaths }; } if (!cacheMetadata.ok) { From 341289850f249825ddaebbb54205cd128900e906 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 2 Jun 2026 20:23:44 -0700 Subject: [PATCH 2/2] fix: harden maestro replay smoke tests --- .github/workflows/ios.yml | 6 +- .github/workflows/perf-nightly.yml | 6 +- .github/workflows/replays-nightly.yml | 5 + .../RunnerTests+CommandExecution.swift | 131 +++++++-- .../RunnerTests+Models.swift | 11 +- src/__tests__/cli-network.test.ts | 108 +++++++ src/cli-test.ts | 38 ++- src/commands/interaction-targeting.ts | 15 +- .../__tests__/runtime-assertions.test.ts | 79 ++++++ .../maestro/__tests__/runtime-flow.test.ts | 58 ++++ .../__tests__/runtime-geometry.test.ts | 20 ++ .../__tests__/runtime-interactions.test.ts | 74 ++++- .../maestro/__tests__/runtime-targets.test.ts | 264 +++++++++++++++++- src/compat/maestro/runtime-assertions.ts | 37 ++- src/compat/maestro/runtime-flow.ts | 36 ++- src/compat/maestro/runtime-geometry.ts | 34 ++- src/compat/maestro/runtime-interactions.ts | 75 ++++- src/compat/maestro/runtime-support.ts | 52 +++- src/compat/maestro/runtime-targets.ts | 39 ++- .../__tests__/dispatch-interactions.test.ts | 79 +++++- src/core/__tests__/dispatch-series.test.ts | 18 ++ src/core/dispatch-interactions.ts | 5 +- src/core/dispatch-series.ts | 4 + src/daemon/request-progress-protocol.ts | 8 +- src/platforms/android/__tests__/index.test.ts | 16 ++ src/platforms/android/ui-hierarchy.ts | 17 +- src/platforms/ios/__tests__/index.test.ts | 134 ++++----- .../ios/__tests__/runner-client.test.ts | 61 +++- src/platforms/ios/interactions.ts | 89 +++--- src/platforms/ios/runner-session.ts | 54 ++-- src/platforms/ios/runner-xctestrun.ts | 112 +++++--- src/utils/duration-format.ts | 6 + .../provider-scenarios/ios-world.ts | 1 + 33 files changed, 1373 insertions(+), 319 deletions(-) create mode 100644 src/utils/duration-format.ts diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index dcaf27c01..daad3f9bc 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -54,9 +54,13 @@ jobs: runtime-version: ${{ env.IOS_RUNTIME_VERSION }} preferred-device-name: iPhone 17 Pro - - name: Run iOS simulator smoke replay + - name: Prepare iOS runner run: | pnpm clean:daemon + node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout 240000 + + - name: Run iOS simulator smoke replay + run: | node --experimental-strip-types src/bin.ts test test/integration/replays/ios/simulator/01-settings.ad --retries 2 --report-junit test/artifacts/replays-ios-simulator-smoke.junit.xml - name: Run iOS physical device smoke replay diff --git a/.github/workflows/perf-nightly.yml b/.github/workflows/perf-nightly.yml index d317d33be..f4a6da107 100644 --- a/.github/workflows/perf-nightly.yml +++ b/.github/workflows/perf-nightly.yml @@ -60,9 +60,13 @@ jobs: runtime-version: ${{ env.IOS_RUNTIME_VERSION }} preferred-device-name: iPhone 17 Pro - - name: Run iOS command perf benchmark + - name: Prepare iOS runner run: | pnpm clean:daemon + node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout 240000 + + - name: Run iOS command perf benchmark + run: | node --experimental-strip-types scripts/perf/run.ts \ --platform ios \ --device "iPhone 17 Pro" \ diff --git a/.github/workflows/replays-nightly.yml b/.github/workflows/replays-nightly.yml index 166d810b6..fe2c89f0d 100644 --- a/.github/workflows/replays-nightly.yml +++ b/.github/workflows/replays-nightly.yml @@ -77,6 +77,11 @@ jobs: runtime-version: ${{ env.IOS_RUNTIME_VERSION }} preferred-device-name: iPhone 17 Pro + - name: Prepare iOS runner + run: | + pnpm clean:daemon + node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout 240000 + - name: Run iOS simulator replay suite run: node --experimental-strip-types src/bin.ts test test/integration/replays/ios/simulator --retries 2 --report-junit test/artifacts/replays-ios-simulator.junit.xml diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index d0d2c855e..8f2e9d3f6 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -36,6 +36,43 @@ extension RunnerTests { case drag(DragVisualizationFrame) } + struct GestureFallback { + let strategy: String + let message: String + let hint: String? + } + + private func gestureFallback(strategy: String, from outcome: RunnerInteractionOutcome) -> GestureFallback? { + switch outcome { + case .performed: + return nil + case .unsupported(let message, let hint): + return GestureFallback(strategy: strategy, message: message, hint: hint) + } + } + + private func performDragSeries( + count: Int, + pauseMs: Double, + pattern: String, + points: DragPoints, + _ drag: (_ x: Double, _ y: Double, _ x2: Double, _ y2: Double) -> RunnerInteractionOutcome + ) -> RunnerInteractionOutcome { + var outcome = RunnerInteractionOutcome.performed + runSeries(count: count, pauseMs: pauseMs) { idx in + guard case .performed = outcome else { + return + } + let reverse = pattern == "ping-pong" && (idx % 2 == 1) + let startX = reverse ? points.x2 : points.x + let startY = reverse ? points.y2 : points.y + let endX = reverse ? points.x : points.x2 + let endY = reverse ? points.y : points.y2 + outcome = drag(startX, startY, endX, endY) + } + return outcome + } + /// Runs a gesture action with uniform timing capture. Touch gestures pass `idleTimeout: true` /// (the default) to run inside the scroll idle-timeout + quiescence-skip wrapper; synthesis /// gestures (pinch/rotate/transform) pass `false` because RunnerSynthesizedGesture governs its @@ -64,7 +101,8 @@ extension RunnerTests { private func gestureResponse( message: String, timing: (gestureStartUptimeMs: Double, gestureEndUptimeMs: Double), - frame: GestureFrame = .none + frame: GestureFrame = .none, + fallback: GestureFallback? = nil ) -> Response { let data: DataPayload switch frame { @@ -72,7 +110,10 @@ extension RunnerTests { data = DataPayload( message: message, gestureStartUptimeMs: timing.gestureStartUptimeMs, - gestureEndUptimeMs: timing.gestureEndUptimeMs + gestureEndUptimeMs: timing.gestureEndUptimeMs, + gestureFallback: fallback?.strategy, + gestureFallbackMessage: fallback?.message, + gestureFallbackHint: fallback?.hint ) case .touch(let f): data = DataPayload( @@ -82,7 +123,10 @@ extension RunnerTests { x: f?.x, y: f?.y, referenceWidth: f?.referenceWidth, - referenceHeight: f?.referenceHeight + referenceHeight: f?.referenceHeight, + gestureFallback: fallback?.strategy, + gestureFallbackMessage: fallback?.message, + gestureFallbackHint: fallback?.hint ) case .drag(let f): data = DataPayload( @@ -94,7 +138,10 @@ extension RunnerTests { x2: f.x2, y2: f.y2, referenceWidth: f.referenceWidth, - referenceHeight: f.referenceHeight + referenceHeight: f.referenceHeight, + gestureFallback: fallback?.strategy, + gestureFallbackMessage: fallback?.message, + gestureFallbackHint: fallback?.hint ) } return Response(ok: true, data: data) @@ -507,6 +554,7 @@ extension RunnerTests { x2: dragPoints.x2, y2: dragPoints.y2 ) + var fallback: GestureFallback? if command.synthesized == true { let durationMs = min(max(command.durationMs ?? 250, 16), 10000) let (timing, outcome) = performGesture(activeApp, idleTimeout: false) { @@ -522,6 +570,7 @@ extension RunnerTests { if case .performed = outcome { return gestureResponse(message: "dragged", timing: timing, frame: .drag(dragFrame)) } + fallback = gestureFallback(strategy: "xctest-coordinate-drag", from: outcome) } let holdDuration = command.synthesized == true ? synthesizedSwipeFallbackHoldDuration(durationMs: command.durationMs ?? 250) @@ -539,7 +588,12 @@ extension RunnerTests { if let response = unsupportedResponse(for: outcome) { return response } - return gestureResponse(message: "dragged", timing: timing, frame: .drag(dragFrame)) + return gestureResponse( + message: "dragged", + timing: timing, + frame: .drag(dragFrame), + fallback: fallback + ) case .dragSeries: guard let x = command.x, let y = command.y, let x2 = command.x2, let y2 = command.y2 else { return Response(ok: false, error: ErrorPayload(message: "dragSeries requires x, y, x2, and y2")) @@ -550,41 +604,56 @@ extension RunnerTests { if pattern != "one-way" && pattern != "ping-pong" { return Response(ok: false, error: ErrorPayload(message: "dragSeries pattern must be one-way or ping-pong")) } - let holdDuration = min(max((command.durationMs ?? 60) / 1000.0, 0.016), 10.0) let dragPoints = keyboardAvoidingDragPoints(app: activeApp, x: x, y: y, x2: x2, y2: y2) - let (timing, outcome) = performGesture(activeApp) { - var outcome = RunnerInteractionOutcome.performed - runSeries(count: count, pauseMs: pauseMs) { idx in - guard case .performed = outcome else { - return - } - let reverse = pattern == "ping-pong" && (idx % 2 == 1) - if reverse { - outcome = dragAt( - app: activeApp, - x: dragPoints.x2, - y: dragPoints.y2, - x2: dragPoints.x, - y2: dragPoints.y, - holdDuration: holdDuration - ) - } else { - outcome = dragAt( + var fallback: GestureFallback? + if command.synthesized == true { + let durationMs = min(max(command.durationMs ?? 250, 16), 10000) + let (timing, outcome) = performGesture(activeApp, idleTimeout: false) { + performDragSeries( + count: count, + pauseMs: pauseMs, + pattern: pattern, + points: dragPoints + ) { startX, startY, endX, endY in + synthesizedDragAt( app: activeApp, - x: dragPoints.x, - y: dragPoints.y, - x2: dragPoints.x2, - y2: dragPoints.y2, - holdDuration: holdDuration + x: startX, + y: startY, + x2: endX, + y2: endY, + durationMs: durationMs ) } } - return outcome + if case .performed = outcome { + return gestureResponse(message: "drag series", timing: timing) + } + fallback = gestureFallback(strategy: "xctest-coordinate-drag-series", from: outcome) + } + let holdDuration = command.synthesized == true + ? synthesizedSwipeFallbackHoldDuration(durationMs: command.durationMs ?? 250) + : min(max((command.durationMs ?? 60) / 1000.0, 0.016), 10.0) + let (timing, outcome) = performGesture(activeApp) { + performDragSeries( + count: count, + pauseMs: pauseMs, + pattern: pattern, + points: dragPoints + ) { startX, startY, endX, endY in + dragAt( + app: activeApp, + x: startX, + y: startY, + x2: endX, + y2: endY, + holdDuration: holdDuration + ) + } } if let response = unsupportedResponse(for: outcome) { return response } - return gestureResponse(message: "drag series", timing: timing) + return gestureResponse(message: "drag series", timing: timing, fallback: fallback) case .remotePress: guard let button = tvRemoteButton(from: command.remoteButton) else { return Response(ok: false, error: ErrorPayload(message: "remotePress requires remoteButton")) diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift index 57708b8f6..b835b853a 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift @@ -190,6 +190,9 @@ struct DataPayload: Codable { let wasVisible: Bool? let dismissed: Bool? let orientation: String? + let gestureFallback: String? + let gestureFallbackMessage: String? + let gestureFallbackHint: String? init( message: String? = nil, @@ -218,7 +221,10 @@ struct DataPayload: Codable { visible: Bool? = nil, wasVisible: Bool? = nil, dismissed: Bool? = nil, - orientation: String? = nil + orientation: String? = nil, + gestureFallback: String? = nil, + gestureFallbackMessage: String? = nil, + gestureFallbackHint: String? = nil ) { self.message = message self.text = text @@ -247,6 +253,9 @@ struct DataPayload: Codable { self.wasVisible = wasVisible self.dismissed = dismissed self.orientation = orientation + self.gestureFallback = gestureFallback + self.gestureFallbackMessage = gestureFallbackMessage + self.gestureFallbackHint = gestureFallbackHint } } diff --git a/src/__tests__/cli-network.test.ts b/src/__tests__/cli-network.test.ts index d98a47dff..2b7fafa25 100644 --- a/src/__tests__/cli-network.test.ts +++ b/src/__tests__/cli-network.test.ts @@ -212,6 +212,114 @@ test('test command --verbose prints step telemetry for passing tests without deb } }); +test('test command --verbose keeps nested retry and open step telemetry distinct', async () => { + const tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'agent-device-cli-test-verbose-retry-'), + ); + const artifactsDir = path.join(tmpDir, 'material-top-tabs'); + const attemptDir = path.join(artifactsDir, 'attempt-1'); + await fs.mkdir(attemptDir, { recursive: true }); + await fs.writeFile( + path.join(attemptDir, 'replay-timing.ndjson'), + [ + { + type: 'replay_action_start', + step: 2, + line: 4, + command: 'retry', + positionals: ['3'], + }, + { + type: 'replay_action_start', + step: 2, + line: 4, + command: 'open', + positionals: ['org.reactnavigation.playground', 'rne://material-top-tabs-basic'], + }, + { + type: 'replay_action_stop', + step: 2, + line: 4, + command: 'open', + ok: true, + durationMs: 727, + }, + { + type: 'replay_action_start', + step: 2.001, + line: 4, + command: '__maestroAssertVisible', + positionals: ['label="Chat" || text="Chat" || id="Chat"', '60000'], + }, + { + type: 'replay_action_stop', + step: 2.001, + line: 4, + command: '__maestroAssertVisible', + ok: true, + durationMs: 2580, + }, + { + type: 'replay_action_stop', + step: 2, + line: 4, + command: 'retry', + ok: true, + durationMs: 3310, + }, + ] + .map((entry) => JSON.stringify(entry)) + .join('\n'), + ); + + try { + const result = await runCliCapture(['test', './suite', '--verbose'], async () => ({ + ok: true, + data: { + total: 1, + executed: 1, + passed: 1, + failed: 0, + skipped: 0, + notRun: 0, + durationMs: 3310, + failures: [], + tests: [ + { + file: '/tmp/material-top-tabs.yml', + title: 'Material Top Tabs - Basic', + session: 'default:test:suite:1', + status: 'passed', + durationMs: 3310, + finalAttemptDurationMs: 3310, + attempts: 1, + artifactsDir, + replayed: 1, + healed: 0, + }, + ], + }, + })); + + assert.equal(result.code, null); + assert.match( + result.stdout, + /open "org\.reactnavigation\.playground" "rne:\/\/material-top-tabs-basic" \(line 4, 0\.727s\)/, + ); + assert.match( + result.stdout, + /assertVisible "label=\\"Chat\\" \|\| text=\\"Chat\\" \|\| id=\\"Chat\\"" "60000" \(line 4, 2\.58s\)/, + ); + assert.match(result.stdout, /retry "3" \(line 4, 3\.31s\)/); + assert.doesNotMatch( + result.stdout, + /open "org\.reactnavigation\.playground" "rne:\/\/material-top-tabs-basic" \(line 4, 3\.31s\)/, + ); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}); + test('test command reports flaky passed-on-retry cases in the default summary', async () => { const result = await runCliCapture(['test', './suite'], async () => ({ ok: true, diff --git a/src/cli-test.ts b/src/cli-test.ts index 2ed9c3d73..d1b9240c3 100644 --- a/src/cli-test.ts +++ b/src/cli-test.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import type { ReplaySuiteResult, ReplaySuiteTestResult } from './daemon/types.ts'; +import { formatDurationSeconds } from './utils/duration-format.ts'; import { AppError } from './utils/errors.ts'; import { printJson } from './utils/output.ts'; @@ -142,20 +143,40 @@ function replayTestStepLines(result: ReplaySuiteTestResult): string[] { const events = readReplayTimingTrace(tracePath); if (events.length === 0) return []; - const starts = new Map(); - const stops: ReplayActionStopTrace[] = []; + const starts: ReplayActionStartTrace[] = []; + const stops: Array<{ stop: ReplayActionStopTrace; start: ReplayActionStartTrace | undefined }> = + []; for (const event of events) { - if (isReplayActionStartTrace(event)) starts.set(event.step, event); - if (isReplayActionStopTrace(event)) stops.push(event); + if (isReplayActionStartTrace(event)) { + starts.push(event); + continue; + } + if (isReplayActionStopTrace(event)) { + stops.push({ stop: event, start: consumeReplayActionStart(starts, event) }); + } } if (stops.length === 0) return []; return [ result.attempts > 1 ? `steps (attempt ${result.attempts}):` : 'steps:', - ...stops.map((stop) => renderReplayStepTrace(stop, starts.get(stop.step))), + ...stops.map(({ stop, start }) => renderReplayStepTrace(stop, start)), ]; } +function consumeReplayActionStart( + starts: ReplayActionStartTrace[], + stop: ReplayActionStopTrace, +): ReplayActionStartTrace | undefined { + const stopCommand = stop.command; + const matchingIndex = starts.findIndex( + (start) => + start.step === stop.step && + (stopCommand === undefined || start.command === undefined || start.command === stopCommand), + ); + if (matchingIndex < 0) return undefined; + return starts.splice(matchingIndex, 1)[0]; +} + function replayTestTimingTracePath( result: Extract, ): string | undefined { @@ -474,13 +495,6 @@ function formatJUnitSeconds(durationMs: number): string { return (Math.max(0, durationMs) / 1000).toFixed(3); } -function formatDurationSeconds(durationMs: number): string { - const seconds = Math.max(0, durationMs) / 1000; - if (seconds >= 10) return `${seconds.toFixed(1)}s`; - if (seconds >= 1) return `${seconds.toFixed(2)}s`; - return `${seconds.toFixed(3).replace(/0+$/, '').replace(/\.$/, '')}s`; -} - function xmlEscape(value: string): string { return value .replaceAll('&', '&') diff --git a/src/commands/interaction-targeting.ts b/src/commands/interaction-targeting.ts index 7c9e8f0e2..bb10c2198 100644 --- a/src/commands/interaction-targeting.ts +++ b/src/commands/interaction-targeting.ts @@ -3,6 +3,7 @@ import { centerOfRect } from '../utils/snapshot.ts'; import { containsPoint, pickLargestRect } from '../utils/rect-visibility.ts'; import { findNearestHittableAncestor, normalizeType } from '../utils/snapshot-processing.ts'; import { normalizeRect, resolveRectCenter } from '../utils/rect-center.ts'; +import { intersectArea } from '../utils/screenshot-geometry.ts'; const SEMANTIC_TOUCH_ROLE_FRAGMENTS = [ 'button', @@ -142,7 +143,7 @@ function resolveRootViewportRect(nodes: SnapshotNode[], targetRect: Rect): Rect } function isRectViewportSized(rect: Rect, viewportRect: Rect): boolean { - const overlapArea = intersectionArea(rect, viewportRect); + const overlapArea = intersectArea(rect, viewportRect); const rectArea = rect.width * rect.height; const viewportArea = viewportRect.width * viewportRect.height; if (overlapArea <= 0 || rectArea <= 0 || viewportArea <= 0) return false; @@ -151,15 +152,3 @@ function isRectViewportSized(rect: Rect, viewportRect: Rect): boolean { const rectCoverage = overlapArea / rectArea; return viewportCoverage >= 0.9 && rectCoverage >= 0.8; } - -function intersectionArea(left: Rect, right: Rect): number { - const xOverlap = Math.max( - 0, - Math.min(left.x + left.width, right.x + right.width) - Math.max(left.x, right.x), - ); - const yOverlap = Math.max( - 0, - Math.min(left.y + left.height, right.y + right.height) - Math.max(left.y, right.y), - ); - return xOverlap * yOverlap; -} diff --git a/src/compat/maestro/__tests__/runtime-assertions.test.ts b/src/compat/maestro/__tests__/runtime-assertions.test.ts index 05e69f498..858695be5 100644 --- a/src/compat/maestro/__tests__/runtime-assertions.test.ts +++ b/src/compat/maestro/__tests__/runtime-assertions.test.ts @@ -151,6 +151,85 @@ test('invokeMaestroAssertVisible uses snapshot resolution for short iOS assertio assert.deepEqual(calls, [['snapshot', []]]); }); +test('invokeMaestroAssertVisible falls back to raw snapshot shaping when optimized snapshot misses', async () => { + const snapshotFlags: Array = []; + const response = await invokeMaestroAssertVisible({ + baseReq: { + token: 't', + session: 's', + flags: { platform: 'ios' }, + }, + positionals: ['id="chat"', '1000'], + invoke: async (req): Promise => { + if (req.command === 'snapshot') { + snapshotFlags.push(req.flags); + return { + ok: true, + data: + req.flags?.snapshotRaw === true + ? snapshot([node('Chat', { identifier: 'chat' })]) + : snapshot([]), + }; + } + return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } }; + }, + }); + + assert.equal(response.ok, true); + assert.equal(snapshotFlags.length, 2); + assert.equal(snapshotFlags[0]?.snapshotRaw, undefined); + assert.equal(snapshotFlags[1]?.snapshotRaw, true); + assert.equal(snapshotFlags[1]?.snapshotForceFull, undefined); +}); + +test('invokeMaestroAssertVisible does not use raw fallback for Android identifiers', async () => { + const snapshotFlags: Array = []; + const response = await invokeMaestroAssertVisible({ + baseReq: { + token: 't', + session: 's', + flags: { platform: 'android' }, + }, + positionals: ['id="album-0"', '1000'], + invoke: async (req): Promise => { + if (req.command === 'snapshot') { + snapshotFlags.push(req.flags); + return { + ok: true, + data: snapshot([node('Album item', { identifier: 'album-0' })]), + }; + } + return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } }; + }, + }); + + assert.equal(response.ok, true); + assert.equal(snapshotFlags.length, 1); + assert.equal(snapshotFlags[0]?.snapshotRaw, undefined); +}); + +test('invokeMaestroAssertVisible does not use Android raw fallback for generated text selectors', async () => { + const snapshotFlags: Array = []; + const response = await invokeMaestroAssertVisible({ + baseReq: { + token: 't', + session: 's', + flags: { platform: 'android' }, + }, + positionals: ['label="Chat" || text="Chat" || id="Chat"', '0'], + invoke: async (req): Promise => { + if (req.command === 'snapshot') { + snapshotFlags.push(req.flags); + return { ok: true, data: snapshot([]) }; + } + return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } }; + }, + }); + + assert.equal(response.ok, false); + assert.equal(snapshotFlags.some((flags) => flags?.snapshotRaw === true), false); +}); + test('invokeMaestroAssertVisible treats an elapsed ellipsis loading gate as already past loading', async () => { vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValueOnce(250); diff --git a/src/compat/maestro/__tests__/runtime-flow.test.ts b/src/compat/maestro/__tests__/runtime-flow.test.ts index 2754147a5..0feb63b33 100644 --- a/src/compat/maestro/__tests__/runtime-flow.test.ts +++ b/src/compat/maestro/__tests__/runtime-flow.test.ts @@ -64,6 +64,64 @@ test('invokeMaestroRunFlowWhenControl waits briefly for visible conditions', asy } }); +test('invokeMaestroRunFlowWhenControl falls back to raw iOS snapshots after optimized miss', async () => { + const snapshotFlags: Array = []; + const invokedActions: SessionAction[] = []; + const actions: SessionAction[] = [ + { ts: Date.now(), command: 'click', positionals: ['label="Continue"'], flags: {} }, + ]; + + const response = await invokeMaestroRunFlowWhenControl({ + baseReq: { + token: 't', + session: 's', + flags: { platform: 'ios' }, + }, + control: { + kind: 'maestroRunFlowWhen', + mode: 'visible', + selector: 'label="Continue" || text="Continue" || id="Continue"', + actions, + }, + line: 12, + step: 4, + invoke: async (req: DaemonRequest): Promise => { + assert.equal(req.command, 'snapshot'); + snapshotFlags.push(req.flags); + const isRaw = req.flags?.snapshotRaw === true; + return { + ok: true, + data: { + createdAt: Date.now(), + nodes: isRaw + ? [ + { + index: 1, + ref: 'e1', + type: 'Button', + label: 'Continue', + rect: { x: 100, y: 420, width: 120, height: 44 }, + depth: 4, + }, + ] + : [], + }, + }; + }, + invokeReplayAction: async ({ action }): Promise => { + invokedActions.push(action); + return { ok: true, data: { clicked: true } }; + }, + }); + + assert.equal(response.ok, true); + assert.deepEqual(snapshotFlags.map((flags) => flags?.snapshotRaw), [undefined, true]); + assert.deepEqual( + invokedActions.map((action) => [action.command, action.positionals]), + [['click', ['label="Continue"']]], + ); +}); + test('invokeMaestroRunFlowWhenControl keeps notVisible conditions immediate', async () => { let snapshots = 0; const response = await invokeMaestroRunFlowWhenControl({ diff --git a/src/compat/maestro/__tests__/runtime-geometry.test.ts b/src/compat/maestro/__tests__/runtime-geometry.test.ts index 0ef840fa2..b04fda9e3 100644 --- a/src/compat/maestro/__tests__/runtime-geometry.test.ts +++ b/src/compat/maestro/__tests__/runtime-geometry.test.ts @@ -15,11 +15,31 @@ test('pointForMaestroTapOnTarget biases large scroll-area text containers toward frame: { referenceWidth: 402, referenceHeight: 874 }, }, true, + { allowLargeContainerBias: true }, ); expect(point).toEqual({ x: 84, y: 141 }); }); +test('pointForMaestroTapOnTarget centers optimized broad text containers by default', () => { + const point = pointForMaestroTapOnTarget( + { + node: { + index: 5, + ref: 'e5', + type: 'scroll-area', + label: 'Article', + rect: { x: 0, y: 117, width: 402, height: 180 }, + }, + rect: { x: 0, y: 117, width: 402, height: 180 }, + frame: { referenceWidth: 402, referenceHeight: 874 }, + }, + true, + ); + + expect(point).toEqual({ x: 201, y: 207 }); +}); + test('pointForMaestroTapOnTarget centers tall Android bottom-tab containers', () => { const point = pointForMaestroTapOnTarget( { diff --git a/src/compat/maestro/__tests__/runtime-interactions.test.ts b/src/compat/maestro/__tests__/runtime-interactions.test.ts index acf5c5377..a8bddc4b9 100644 --- a/src/compat/maestro/__tests__/runtime-interactions.test.ts +++ b/src/compat/maestro/__tests__/runtime-interactions.test.ts @@ -7,7 +7,7 @@ import { invokeMaestroTapPointPercent, } from '../runtime-interactions.ts'; -test('invokeMaestroTapOn resolves mutating taps from the current raw snapshot', async () => { +test('invokeMaestroTapOn resolves mutating taps from the current snapshot', async () => { const selector = 'label="Article by Gandalf" || text="Article by Gandalf" || id="Article by Gandalf"'; @@ -20,6 +20,78 @@ test('invokeMaestroTapOn resolves mutating taps from the current raw snapshot', expect(clicks).toEqual([['86', '89']]); }); +test('invokeMaestroTapOn uses optimized interactive snapshots by default', async () => { + const snapshotFlags: Array = []; + const response = await invokeMaestroTapOn({ + baseReq: { + token: 'test', + session: 'article', + flags: { platform: 'ios' }, + }, + positionals: [ + 'label="Article by Gandalf" || text="Article by Gandalf" || id="Article by Gandalf"', + ], + invoke: async (req: DaemonRequest): Promise => { + if (req.command === 'snapshot') { + snapshotFlags.push(req.flags); + return { ok: true, data: currentBreadcrumbSnapshot() }; + } + if (req.command === 'click') return { ok: true, data: {} }; + return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } }; + }, + }); + + expect(response.ok).toBe(true); + expect(snapshotFlags).toHaveLength(1); + expect(snapshotFlags[0]?.noRecord).toBe(true); + expect(snapshotFlags[0]?.snapshotInteractiveOnly).toBe(true); + expect(snapshotFlags[0]?.snapshotRaw).toBeUndefined(); + expect(snapshotFlags[0]?.snapshotForceFull).toBeUndefined(); +}); + +test('invokeMaestroTapOn falls back to raw snapshot shaping when optimized snapshot misses', async () => { + const snapshotFlags: Array = []; + const response = await invokeMaestroTapOn({ + baseReq: { + token: 'test', + session: 'tabs', + flags: { platform: 'ios' }, + }, + positionals: ['id="article"'], + invoke: async (req: DaemonRequest): Promise => { + if (req.command === 'snapshot') { + snapshotFlags.push(req.flags); + return { + ok: true, + data: + req.flags?.snapshotRaw === true + ? { + nodes: [ + { + index: 1, + identifier: 'article', + type: 'Button', + rect: { x: 0, y: 820, width: 134, height: 54 }, + }, + ], + } + : { nodes: [] }, + }; + } + if (req.command === 'click') return { ok: true, data: {} }; + return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } }; + }, + }); + + expect(response.ok).toBe(true); + expect(snapshotFlags).toHaveLength(2); + expect(snapshotFlags[0]?.snapshotInteractiveOnly).toBe(true); + expect(snapshotFlags[0]?.snapshotRaw).toBeUndefined(); + expect(snapshotFlags[1]?.snapshotInteractiveOnly).toBeUndefined(); + expect(snapshotFlags[1]?.snapshotRaw).toBe(true); + expect(snapshotFlags[1]?.snapshotForceFull).toBeUndefined(); +}); + test('invokeMaestroTapOn taps resolved iOS buttons by coordinates', async () => { const { response, clicks } = await runTapOn( 'label="Pop to top" || text="Pop to top" || id="Pop to top"', diff --git a/src/compat/maestro/__tests__/runtime-targets.test.ts b/src/compat/maestro/__tests__/runtime-targets.test.ts index e5b3a0f23..af39867b2 100644 --- a/src/compat/maestro/__tests__/runtime-targets.test.ts +++ b/src/compat/maestro/__tests__/runtime-targets.test.ts @@ -1,10 +1,41 @@ import { test, expect } from 'vitest'; -import type { SnapshotState } from '../../../utils/snapshot.ts'; +import type { SnapshotNode, SnapshotState } from '../../../utils/snapshot.ts'; import { resolveMaestroNodeFromSnapshot, resolveVisibleMaestroNodeFromSnapshot, } from '../runtime-targets.ts'; +const IOS_TAB_FRAME = { referenceWidth: 402, referenceHeight: 874 } as const; + +type SnapshotNodeFixture = Omit & { ref?: string }; +type ResolveMaestroOptions = NonNullable[5]>; + +function makeSnapshot(nodes: SnapshotNodeFixture[]): SnapshotState { + return { + createdAt: Date.now(), + nodes: nodes.map((node) => ({ ref: `e${node.index}`, ...node })), + }; +} + +function maestroTextSelector(text: string): string { + return `label="${text}" || text="${text}" || id="${text}"`; +} + +function resolveIosTabTarget( + snapshot: SnapshotState, + text: string, + options: ResolveMaestroOptions, +) { + return resolveMaestroNodeFromSnapshot( + snapshot, + maestroTextSelector(text), + {}, + 'ios', + IOS_TAB_FRAME, + options, + ); +} + test('resolveVisibleMaestroNodeFromSnapshot treats app content behind React Native overlays as hidden', () => { const snapshot = makeReactNativeOverlaySnapshot(); @@ -874,6 +905,237 @@ test('resolveMaestroNodeFromSnapshot infers missing selected tab slot from tab-s }); }); +test('resolveMaestroNodeFromSnapshot infers leading tab slot with selected sibling and content context', () => { + const snapshot = makeSnapshot([ + { index: 1, type: 'Window', rect: { x: 0, y: 0, width: 402, height: 874 }, depth: 1 }, + { + index: 4, + type: 'ScrollView', + label: 'Chat', + rect: { x: 0, y: 116.66666412353516, width: 402, height: 48 }, + depth: 3, + parentIndex: 1, + }, + { + index: 5, + type: 'Cell', + label: 'Contacts', + rect: { x: 134, y: 116.66666412353516, width: 134, height: 48.00000762939453 }, + depth: 4, + parentIndex: 4, + }, + { + index: 6, + type: 'Cell', + label: 'Albums', + selected: true, + rect: { x: 268, y: 116.66666412353516, width: 134, height: 48.00000762939453 }, + depth: 4, + parentIndex: 4, + }, + { + index: 10, + type: 'Other', + label: 'album-0', + identifier: 'album-0', + rect: { x: 16, y: 220, width: 100, height: 100 }, + depth: 7, + parentIndex: 1, + }, + ]); + const contentNode = snapshot.nodes[4]!; + + const target = resolveIosTabTarget(snapshot, 'Chat', { + promoteTapTarget: true, + preferredContext: { node: contentNode, rect: contentNode.rect! }, + }); + + expect(target).toMatchObject({ + ok: true, + node: expect.objectContaining({ index: 4 }), + rect: { x: 0, y: 116.66666412353516, width: 134, height: 48 }, + }); +}); + +test('resolveMaestroNodeFromSnapshot infers leading tab slot from broad matching cell', () => { + const snapshot = makeSnapshot([ + { + index: 4, + type: 'ScrollView', + label: 'Article', + rect: { x: 0, y: 116.66666412353516, width: 402, height: 48 }, + depth: 3, + }, + { + index: 5, + type: 'Cell', + label: 'Article', + rect: { x: -9, y: 116.66666412353516, width: 560, height: 48.00000762939453 }, + depth: 4, + parentIndex: 4, + }, + { + index: 6, + type: 'Cell', + label: 'Contacts', + selected: true, + rect: { x: 141, y: 116.66666412353516, width: 120, height: 48.00000762939453 }, + depth: 5, + parentIndex: 5, + }, + { + index: 7, + type: 'Cell', + label: 'Albums', + rect: { x: 281, y: 116.66666412353516, width: 120, height: 48.00000762939453 }, + depth: 5, + parentIndex: 5, + }, + { + index: 8, + type: 'Cell', + label: 'Chat', + rect: { x: 421, y: 116.66666412353516, width: 120, height: 48.00000762939453 }, + depth: 5, + parentIndex: 5, + }, + ]); + + const target = resolveIosTabTarget(snapshot, 'Article', { promoteTapTarget: true }); + + expect(target).toMatchObject({ + ok: true, + node: expect.objectContaining({ index: 5 }), + rect: { x: -9, y: 116.66666412353516, width: 150, height: 48.00000762939453 }, + }); +}); + +test('resolveMaestroNodeFromSnapshot rejects off-screen interaction targets when required', () => { + const snapshot = makeSnapshot([ + { + index: 1, + type: 'ScrollView', + label: 'Article', + rect: { x: 0, y: 116.66666412353516, width: 402, height: 48 }, + depth: 3, + }, + { + index: 2, + type: 'Other', + label: 'Contacts', + rect: { + x: -173.66666412353516, + y: 116.66666412353516, + width: 91.99999237060547, + height: 48, + }, + depth: 4, + parentIndex: 1, + }, + ]); + + const target = resolveIosTabTarget(snapshot, 'Contacts', { + promoteTapTarget: true, + requireOnScreen: true, + }); + + expect(target).toMatchObject({ + ok: false, + message: expect.stringContaining('none were visible'), + }); +}); + +test('resolveMaestroNodeFromSnapshot does not promote tab child to broad tab-strip cell', () => { + const snapshot = makeSnapshot([ + { + index: 1, + type: 'Cell', + label: 'Article', + rect: { + x: -150, + y: 116.66666412353516, + width: 701.6666870117188, + height: 48.00000762939453, + }, + depth: 4, + }, + { + index: 2, + type: 'Other', + label: 'Contacts', + rect: { + x: -44.666664123535156, + y: 116.66666412353516, + width: 91.99999237060547, + height: 48.00000762939453, + }, + depth: 5, + parentIndex: 1, + }, + ]); + + const target = resolveIosTabTarget(snapshot, 'Contacts', { + promoteTapTarget: true, + requireOnScreen: true, + }); + + expect(target).toMatchObject({ + ok: true, + node: expect.objectContaining({ index: 2 }), + rect: { + x: -44.666664123535156, + y: 116.66666412353516, + width: 91.99999237060547, + height: 48.00000762939453, + }, + }); +}); + +test('resolveMaestroNodeFromSnapshot infers missing selected tab slot from nested tab-strip children', () => { + const snapshot = makeSnapshot([ + { + index: 1, + type: 'Other', + label: 'Chat', + rect: { x: 0, y: 116.66666412353516, width: 402, height: 48 }, + depth: 4, + }, + { + index: 2, + type: 'ScrollView', + label: 'Chat', + rect: { x: 0, y: 116.66666412353516, width: 402, height: 48 }, + depth: 5, + parentIndex: 1, + }, + { + index: 3, + type: 'Other', + label: 'Contacts', + selected: true, + rect: { x: 134, y: 116.66666412353516, width: 134, height: 48 }, + depth: 6, + parentIndex: 2, + }, + { + index: 4, + type: 'Other', + label: 'Albums', + rect: { x: 268, y: 116.66666412353516, width: 134, height: 48 }, + depth: 6, + parentIndex: 2, + }, + ]); + + const target = resolveIosTabTarget(snapshot, 'Chat', { promoteTapTarget: true }); + + expect(target).toMatchObject({ + ok: true, + node: expect.objectContaining({ index: 2 }), + rect: { x: 0, y: 116.66666412353516, width: 134, height: 48 }, + }); +}); + test('resolveMaestroNodeFromSnapshot keeps concrete child matches over tab-strip inference', () => { const snapshot: SnapshotState = { createdAt: Date.now(), diff --git a/src/compat/maestro/runtime-assertions.ts b/src/compat/maestro/runtime-assertions.ts index 155923c98..0ff668d6b 100644 --- a/src/compat/maestro/runtime-assertions.ts +++ b/src/compat/maestro/runtime-assertions.ts @@ -4,10 +4,12 @@ import type { ReplayVarScope } from '../../replay/vars.ts'; import type { SnapshotState } from '../../utils/snapshot.ts'; import { sleep } from '../../utils/timeouts.ts'; import { - captureMaestroRawSnapshot, + captureMaestroSnapshot, + emitMaestroRawSnapshotFallbackDiagnostic, errorResponse, rememberMaestroVisibleContext, readSnapshotState, + shouldUseMaestroRawSnapshotFallback, type MaestroRuntimeInvoke, type ReplayBaseRequest, } from './runtime-support.ts'; @@ -117,7 +119,9 @@ async function invokeSnapshotMaestroAssertVisible( let capturedAfterDeadline = false; while (true) { const captureStartedAt = Date.now(); - const sample = await readMaestroVisibilitySample(params, args.selector, 'assertVisible'); + const sample = await readMaestroVisibilitySample(params, args.selector, 'assertVisible', { + rawFallback: true, + }); if (sample.visible) return visibleAssertionResponse(sample.response, args.selector, startedAt); lastResponse = sample.response; const failedSample = handleFailedVisibleSample(params.baseReq, args, sample, startedAt); @@ -261,8 +265,33 @@ async function readMaestroVisibilitySample( }, selector: string, command: string, + options: { rawFallback?: boolean } = {}, ): Promise { - const response = await captureMaestroRawSnapshot(params); + const response = await captureMaestroSnapshot(params); + const sample = readMaestroVisibilitySampleFromResponse(params, selector, command, response); + if (sample.visible || sample.infrastructureFailure) return sample; + if ( + !options.rawFallback || + !shouldUseMaestroRawSnapshotFallback(params.baseReq) || + isReactNativeOverlayBlockingAssertion(sample.response) + ) { + return sample; + } + + emitMaestroRawSnapshotFallbackDiagnostic(command, selector); + const rawResponse = await captureMaestroSnapshot({ ...params, mode: 'raw' }); + return readMaestroVisibilitySampleFromResponse(params, selector, command, rawResponse); +} + +function readMaestroVisibilitySampleFromResponse( + params: { + baseReq: ReplayBaseRequest; + scope?: ReplayVarScope; + }, + selector: string, + command: string, + response: DaemonResponse, +): MaestroVisibilitySample { if (!response.ok) return { visible: false, response, infrastructureFailure: true }; const snapshot = readSnapshotState(response.data); if (!snapshot) { @@ -434,7 +463,7 @@ export async function invokeMaestroWaitForAnimationToEnd(params: { let lastResponse: DaemonResponse | undefined; while (Date.now() - startedAt < timeoutMs) { - const response = await captureMaestroRawSnapshot(params); + const response = await captureMaestroSnapshot(params); const poll = readAnimationPollResult(response, previousSignature, timeoutMs); if (poll.done) return poll.response; previousSignature = poll.signature ?? previousSignature; diff --git a/src/compat/maestro/runtime-flow.ts b/src/compat/maestro/runtime-flow.ts index 87948d865..c2cba5aa2 100644 --- a/src/compat/maestro/runtime-flow.ts +++ b/src/compat/maestro/runtime-flow.ts @@ -2,9 +2,11 @@ import type { DaemonRequest, DaemonResponse, SessionReplayControl } from '../../ import { getSnapshotReferenceFrame } from '../../daemon/touch-reference-frame.ts'; import { invokeReplayActionBlock } from '../../replay/control-flow-runtime.ts'; import { - captureMaestroRawSnapshot, + captureMaestroSnapshot, + emitMaestroRawSnapshotFallbackDiagnostic, errorResponse, readSnapshotState, + shouldUseMaestroRawSnapshotFallback, type MaestroReplayInvoker, type ReplayBaseRequest, } from './runtime-support.ts'; @@ -51,9 +53,7 @@ async function evaluateMaestroRunFlowWhenCondition( return await waitForMaestroRunFlowVisibleCondition(params, condition); } - const response = await captureMaestroRawSnapshot(params); - if (!response.ok) return { ok: false, response }; - const result = readMaestroRunFlowVisibleCondition(params, condition.selector, response); + const result = await readMaestroRunFlowVisibleConditionWithFallback(params, condition.selector); if (!result.ok) { return { ok: false, @@ -75,9 +75,7 @@ async function waitForMaestroRunFlowVisibleCondition( // a point-in-time condition so optional cleanup blocks do not become waits. const startedAt = Date.now(); while (true) { - const response = await captureMaestroRawSnapshot(params); - if (!response.ok) return { ok: false, response }; - const result = readMaestroRunFlowVisibleCondition(params, condition.selector, response); + const result = await readMaestroRunFlowVisibleConditionWithFallback(params, condition.selector); if (!result.ok) return { ok: false, response: result.response }; if (result.matched) return { ok: true, matched: true }; if (Date.now() - startedAt >= MAESTRO_RUN_FLOW_WHEN_POLICY.visibleTimeoutMs) { @@ -87,6 +85,30 @@ async function waitForMaestroRunFlowVisibleCondition( } } +async function readMaestroRunFlowVisibleConditionWithFallback( + params: { + baseReq: ReplayBaseRequest; + invoke: (req: DaemonRequest) => Promise; + }, + selector: string, +): Promise<{ ok: true; matched: boolean } | { ok: false; response: DaemonResponse }> { + const response = await captureMaestroSnapshot(params); + if (!response.ok) return { ok: false, response }; + const result = readMaestroRunFlowVisibleCondition(params, selector, response); + if ( + !result.ok || + result.matched || + !shouldUseMaestroRawSnapshotFallback(params.baseReq) + ) { + return result; + } + + emitMaestroRawSnapshotFallbackDiagnostic('runFlow.when', selector); + const rawResponse = await captureMaestroSnapshot({ ...params, mode: 'raw' }); + if (!rawResponse.ok) return { ok: false, response: rawResponse }; + return readMaestroRunFlowVisibleCondition(params, selector, rawResponse); +} + function readMaestroRunFlowVisibleCondition( params: { baseReq: ReplayBaseRequest; diff --git a/src/compat/maestro/runtime-geometry.ts b/src/compat/maestro/runtime-geometry.ts index 4d0875205..a15003cab 100644 --- a/src/compat/maestro/runtime-geometry.ts +++ b/src/compat/maestro/runtime-geometry.ts @@ -80,8 +80,16 @@ export function swipeCoordinatesFromTarget( export function pointForMaestroTapOnTarget( target: MaestroSnapshotTarget, isVisibleTextSelector: boolean, + options: { allowLargeContainerBias?: boolean } = {}, ): { x: number; y: number } { - if (!shouldBiasMaestroVisibleTextTap(target.node, isVisibleTextSelector, target.rect)) { + if ( + !shouldBiasMaestroVisibleTextTap( + target.node, + isVisibleTextSelector, + target.rect, + options.allowLargeContainerBias === true, + ) + ) { return pointInsideRect(target.rect); } return { @@ -111,14 +119,22 @@ function shouldBiasMaestroVisibleTextTap( node: SnapshotNode, isVisibleTextSelector: boolean, rect: Rect, + allowLargeContainerBias: boolean, ): boolean { - if (!isVisibleTextSelector) return false; - if (rect.width < MAESTRO_GEOMETRY_POLICY.largeTextContainerBias.minWidth) { - return false; - } + if (!allowLargeContainerBias || !isVisibleTextSelector) return false; + return isLargeTextContainerRect(rect) && isLargeTextContainerType(node); +} + +function isLargeTextContainerRect(rect: Rect): boolean { + const policy = MAESTRO_GEOMETRY_POLICY.largeTextContainerBias; + return rect.width >= policy.minWidth && rect.height >= policy.minHeight && rect.height <= policy.maxHeight; +} + +function isLargeTextContainerType(node: SnapshotNode): boolean { const type = normalizeType(node.type ?? ''); - const scrollableTextContainer = type === 'scrollview' || type === 'scroll-area'; - if (rect.height < MAESTRO_GEOMETRY_POLICY.largeTextContainerBias.minHeight) return false; - if (rect.height > MAESTRO_GEOMETRY_POLICY.largeTextContainerBias.maxHeight) return false; - return type === 'cell' || type === 'other' || scrollableTextContainer; + return type === 'cell' || type === 'other' || isScrollableTextContainerType(type); +} + +function isScrollableTextContainerType(type: string): boolean { + return type === 'scrollview' || type === 'scroll-area'; } diff --git a/src/compat/maestro/runtime-interactions.ts b/src/compat/maestro/runtime-interactions.ts index 300daaed2..0eb6dc2da 100644 --- a/src/compat/maestro/runtime-interactions.ts +++ b/src/compat/maestro/runtime-interactions.ts @@ -12,14 +12,17 @@ import type { SnapshotState } from '../../utils/snapshot.ts'; import { sleep } from '../../utils/timeouts.ts'; import { pointForMaestroTapOnTarget, swipeCoordinatesFromTarget } from './runtime-geometry.ts'; import { - captureMaestroRawSnapshot, + captureMaestroSnapshot, clearMaestroVisibleContext, + emitMaestroRawSnapshotFallbackDiagnostic, errorResponse, readCachedMaestroReferenceFrame, readMaestroVisibleContext, readSnapshotState, + shouldUseMaestroRawSnapshotFallback, type FailedDaemonResponse, type MaestroRuntimeInvoke, + type MaestroSnapshotMode, type ReplayBaseRequest, } from './runtime-support.ts'; import { @@ -64,6 +67,7 @@ type MaestroScreenSwipeResolution = type ResolvedMaestroSnapshotTarget = MaestroSnapshotTarget & { snapshot: SnapshotState; + snapshotMode: MaestroSnapshotMode; }; export async function invokeMaestroScrollUntilVisible( @@ -119,7 +123,7 @@ export async function invokeMaestroTapPointPercent(params: { return errorResponse('INVALID_ARGS', 'tapOn percentage point requires numeric x/y values.'); } - const snapshotResponse = await captureMaestroRawSnapshot(params); + const snapshotResponse = await captureMaestroSnapshot(params); if (!snapshotResponse.ok) return snapshotResponse; const snapshot = readSnapshotState(snapshotResponse.data); @@ -271,7 +275,7 @@ async function captureFrameForMaestroScreenSwipe(params: { invoke: MaestroRuntimeInvoke; scope?: ReplayVarScope; }): Promise<{ referenceWidth: number; referenceHeight: number } | undefined> { - const snapshotResponse = await captureMaestroRawSnapshot(params); + const snapshotResponse = await captureMaestroSnapshot(params); if (!snapshotResponse.ok) return undefined; const snapshot = readSnapshotState(snapshotResponse.data); return getSnapshotReferenceFrame(snapshot); @@ -442,11 +446,14 @@ async function invokeMaestroSnapshotTapOn( async function clickMaestroSnapshotTarget( params: MaestroTapOnParams, selector: string, - target: MaestroSnapshotTarget, + target: ResolvedMaestroSnapshotTarget, ): Promise<{ response: DaemonResponse; targetResolved: true }> { const point = pointForMaestroTapOnTarget( target, extractMaestroVisibleTextQuery(selector) !== null, + { + allowLargeContainerBias: target.snapshotMode === 'raw', + }, ); emitDiagnostic({ level: 'debug', @@ -497,6 +504,14 @@ async function invokeMaestroFuzzyTapOn( interactionOutcome: { retryOnNoChange: true }, }, }); + emitDiagnostic({ + level: findResponse.ok ? 'info' : 'debug', + phase: 'maestro_fuzzy_tap_fallback', + data: { + query, + ok: findResponse.ok, + }, + }); if (findResponse.ok) return { retry: false, response: findResponse }; return { retry: true, response: findResponse }; } @@ -514,9 +529,52 @@ async function resolveMaestroSnapshotTarget( ): Promise< { ok: true; target: ResolvedMaestroSnapshotTarget } | { ok: false; response: DaemonResponse } > { - const snapshotResponse = await captureMaestroRawSnapshot(params); - if (!snapshotResponse.ok) return { ok: false, response: snapshotResponse }; + const snapshotResponse = await captureMaestroSnapshot({ ...params, mode: 'interactive' }); + const resolution = resolveMaestroSnapshotTargetFromResponse( + params, + selector, + options, + commandLabel, + resolutionOptions, + snapshotResponse, + 'interactive', + ); + if ( + resolution.ok || + !snapshotResponse.ok || + !shouldUseMaestroRawSnapshotFallback(params.baseReq) + ) { + return resolution; + } + + emitMaestroRawSnapshotFallbackDiagnostic(commandLabel, selector); + const rawSnapshotResponse = await captureMaestroSnapshot({ ...params, mode: 'raw' }); + return resolveMaestroSnapshotTargetFromResponse( + params, + selector, + options, + commandLabel, + resolutionOptions, + rawSnapshotResponse, + 'raw', + ); +} +function resolveMaestroSnapshotTargetFromResponse( + params: { + baseReq: ReplayBaseRequest; + scope?: ReplayVarScope; + }, + selector: string, + options: MaestroTapOnOptions, + commandLabel: string, + resolutionOptions: { promoteTapTarget: boolean }, + snapshotResponse: DaemonResponse, + snapshotMode: MaestroSnapshotMode, +): + | { ok: true; target: ResolvedMaestroSnapshotTarget } + | { ok: false; response: DaemonResponse } { + if (!snapshotResponse.ok) return { ok: false, response: snapshotResponse }; const snapshot = readSnapshotState(snapshotResponse.data); if (!snapshot) { return { @@ -534,6 +592,7 @@ async function resolveMaestroSnapshotTarget( const resolution = resolveMaestroNodeFromSnapshot(snapshot, selector, options, platform, frame, { ...resolutionOptions, preferredContext, + requireOnScreen: true, }); if (!resolution.ok) { const fuzzyTextQuery = extractMaestroVisibleTextQuery(selector); @@ -543,7 +602,7 @@ async function resolveMaestroSnapshotTarget( fuzzyTextQuery, platform, frame, - { ...resolutionOptions, preferredContext }, + { ...resolutionOptions, preferredContext, requireOnScreen: true }, ); if (fuzzyResolution.ok) { return { @@ -553,6 +612,7 @@ async function resolveMaestroSnapshotTarget( rect: fuzzyResolution.rect, frame, snapshot, + snapshotMode, }, }; } @@ -575,6 +635,7 @@ async function resolveMaestroSnapshotTarget( rect: resolution.rect, frame, snapshot, + snapshotMode, }, }; } diff --git a/src/compat/maestro/runtime-support.ts b/src/compat/maestro/runtime-support.ts index f58f3f1ab..2241e40f7 100644 --- a/src/compat/maestro/runtime-support.ts +++ b/src/compat/maestro/runtime-support.ts @@ -2,9 +2,15 @@ import { getSnapshotReferenceFrame, type TouchReferenceFrame, } from '../../daemon/touch-reference-frame.ts'; -import type { DaemonRequest, DaemonResponse, SessionAction } from '../../daemon/types.ts'; +import type { + DaemonRequest, + DaemonResponse, + DaemonResponseData, + SessionAction, +} from '../../daemon/types.ts'; import type { ReplayVarScope } from '../../replay/vars.ts'; import type { SnapshotState } from '../../utils/snapshot.ts'; +import { emitDiagnostic } from '../../utils/diagnostics.ts'; export type ReplayBaseRequest = Omit; @@ -17,6 +23,7 @@ export type MaestroReplayInvoker = (params: { export type MaestroRuntimeInvoke = (req: DaemonRequest) => Promise; export type FailedDaemonResponse = Extract; +export type MaestroSnapshotMode = 'interactive' | 'raw'; const maestroReferenceFrameCache = new WeakMap(); const maestroVisibleContextCache = new WeakMap(); @@ -32,11 +39,14 @@ export function errorResponse( }; } -export async function captureMaestroRawSnapshot(params: { +export async function captureMaestroSnapshot(params: { baseReq: ReplayBaseRequest; invoke: MaestroRuntimeInvoke; scope?: ReplayVarScope; + mode?: MaestroSnapshotMode; }): Promise { + const useRawSnapshot = + params.mode === 'raw' || process.env.AGENT_DEVICE_MAESTRO_RAW_SNAPSHOTS === '1'; const response = await params.invoke({ ...params.baseReq, command: 'snapshot', @@ -44,23 +54,34 @@ export async function captureMaestroRawSnapshot(params: { flags: { ...params.baseReq.flags, noRecord: true, - snapshotRaw: true, - snapshotForceFull: true, + ...(params.mode === 'interactive' && !useRawSnapshot + ? { snapshotInteractiveOnly: true } + : {}), + ...(useRawSnapshot ? { snapshotRaw: true } : {}), }, }); if (response.ok && params.scope) rememberMaestroReferenceFrame(params.scope, response.data); return response; } -export function readSnapshotState(data: unknown): SnapshotState | undefined { - if ( - typeof data === 'object' && - data !== null && - Array.isArray((data as { nodes?: unknown }).nodes) - ) { - return data as SnapshotState; - } - return undefined; +export function readSnapshotState(data: DaemonResponseData | undefined): SnapshotState | undefined { + return Array.isArray(data?.nodes) ? (data as SnapshotState) : undefined; +} + +export function shouldUseMaestroRawSnapshotFallback(baseReq: ReplayBaseRequest): boolean { + return baseReq.flags?.platform === 'ios'; +} + +export function emitMaestroRawSnapshotFallbackDiagnostic(command: string, selector: string): void { + emitDiagnostic({ + level: 'debug', + phase: 'maestro_raw_snapshot_fallback', + data: { + command, + selector, + reason: 'optimized_snapshot_missed', + }, + }); } export function readCachedMaestroReferenceFrame( @@ -86,7 +107,10 @@ export function clearMaestroVisibleContext(scope: ReplayVarScope | undefined): v if (scope) maestroVisibleContextCache.delete(scope); } -function rememberMaestroReferenceFrame(scope: ReplayVarScope, data: unknown): void { +function rememberMaestroReferenceFrame( + scope: ReplayVarScope, + data: DaemonResponseData | undefined, +): void { const snapshot = readSnapshotState(data); const frame = getSnapshotReferenceFrame(snapshot); if (frame) maestroReferenceFrameCache.set(scope, frame); diff --git a/src/compat/maestro/runtime-targets.ts b/src/compat/maestro/runtime-targets.ts index 09279b182..1f2489310 100644 --- a/src/compat/maestro/runtime-targets.ts +++ b/src/compat/maestro/runtime-targets.ts @@ -31,6 +31,8 @@ const MAESTRO_TAP_TARGET_TYPE_RANK = new Map([ ['statictext', 2], ]); +const RECT_CONTAINS_EPSILON = 1; + export type MaestroTapOnOptions = { childOf?: string; index?: number; @@ -56,6 +58,7 @@ export type MaestroPreferredContext = { type MaestroMatchResolutionOptions = { promoteTapTarget?: boolean; preferredContext?: MaestroPreferredContext; + requireOnScreen?: boolean; }; type MaestroSelectorMatchOptions = { @@ -107,7 +110,7 @@ export function resolveMaestroNodeFromSnapshot( options.index, extractMaestroVisibleTextQuery(selector), frame, - false, + resolutionOptions.requireOnScreen === true, resolutionOptions.promoteTapTarget, resolutionOptions.preferredContext, ); @@ -144,7 +147,7 @@ export function resolveMaestroFuzzyTextNodeFromSnapshot( undefined, query, frame, - false, + resolutionOptions.requireOnScreen === true, resolutionOptions.promoteTapTarget, resolutionOptions.preferredContext, ); @@ -815,7 +818,12 @@ function inferMaestroMissingTabSlotMatch( query: string, ): MaestroResolvedSnapshotMatch | null { if (!isMaestroTabStripContainerMatch(match, query)) return null; - const children = collectMaestroTabStripChildCandidates(nodes, match, query); + const children = collectMaestroTabStripChildCandidates( + nodes, + match, + query, + buildSnapshotNodeByIndex(nodes), + ); if (children.length === 0) return null; const medianChildWidth = median(children.map((child) => child.rect.width)); const allGaps = resolveHorizontalGaps( @@ -831,12 +839,14 @@ function collectMaestroTabStripChildCandidates( nodes: SnapshotState['nodes'], match: MaestroResolvedSnapshotMatch, query: string, + nodeByIndex: SnapshotNodeByIndex, ): Array { return nodes .filter((node): node is SnapshotNode & { rect: Rect } => { return ( - node.parentIndex === match.node.index && + node.index !== match.node.index && Boolean(node.rect) && + isDescendantOfSnapshotNode(nodes, node, match.node, nodeByIndex) && isMaestroTabStripChildCandidate(node as SnapshotNode & { rect: Rect }, match.rect, query) ); }) @@ -915,7 +925,9 @@ function isMaestroTabStripContainerMatch( query: string, ): boolean { const type = normalizeType(match.node.type ?? ''); - if (type !== 'other' && type !== 'scrollview' && type !== 'scroll-area') return false; + if (type !== 'cell' && type !== 'other' && type !== 'scrollview' && type !== 'scroll-area') { + return false; + } if (match.rect.width < 120 || match.rect.height < 32 || match.rect.height > 80) return false; return maestroVisibleTextMatchRank(match.node, query) <= 1; } @@ -1020,6 +1032,7 @@ function isUsefulMaestroTapAncestorRect( frame: TouchReferenceFrame | undefined, ): boolean { if (!rectContains(ancestorRect, matchRect)) return false; + if (wouldPromoteTabSlotToWholeStrip(matchRect, ancestorRect)) return false; const ancestorArea = rectArea(ancestorRect); const matchArea = rectArea(matchRect); // Keep promotion close to the matched label/id instead of jumping to a broad container. @@ -1032,12 +1045,20 @@ function isUsefulMaestroTapAncestorRect( return true; } +function wouldPromoteTabSlotToWholeStrip(matchRect: Rect, ancestorRect: Rect): boolean { + if (ancestorRect.height < 32 || ancestorRect.height > 80) return false; + if (matchRect.height < ancestorRect.height * 0.75) return false; + if (verticalOverlapRatio(matchRect, ancestorRect) < 0.75) return false; + if (ancestorRect.width < 240) return false; + return ancestorRect.width >= matchRect.width * 3; +} + function rectContains(container: Rect, child: Rect): boolean { return ( - child.x >= container.x && - child.y >= container.y && - child.x + child.width <= container.x + container.width && - child.y + child.height <= container.y + container.height + child.x >= container.x - RECT_CONTAINS_EPSILON && + child.y >= container.y - RECT_CONTAINS_EPSILON && + child.x + child.width <= container.x + container.width + RECT_CONTAINS_EPSILON && + child.y + child.height <= container.y + container.height + RECT_CONTAINS_EPSILON ); } diff --git a/src/core/__tests__/dispatch-interactions.test.ts b/src/core/__tests__/dispatch-interactions.test.ts index 2cba4ca20..b1c42fdcf 100644 --- a/src/core/__tests__/dispatch-interactions.test.ts +++ b/src/core/__tests__/dispatch-interactions.test.ts @@ -1,6 +1,17 @@ -import { test, vi } from 'vitest'; +import { beforeEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; + +const { mockRunIosRunnerCommand } = vi.hoisted(() => ({ + mockRunIosRunnerCommand: vi.fn(), +})); + +vi.mock('../../platforms/ios/runner-client.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, runIosRunnerCommand: mockRunIosRunnerCommand }; +}); + import { + handlePanCommand, handleRotateGestureCommand, handleSwipeCommand, handleSwipePresetCommand, @@ -17,6 +28,10 @@ vi.mock('../../platforms/ios/macos-helper.ts', async (importOriginal) => { }; }); +beforeEach(() => { + mockRunIosRunnerCommand.mockReset(); +}); + function makeUnusedInteractor(): Interactor { const fail = async () => { throw new Error('interactor should not be used for macOS menubar press'); @@ -150,6 +165,68 @@ test('handleSwipeCommand preserves iOS swipe duration through dispatch', async ( }); }); +test('handleSwipeCommand uses synthesized iOS runner drag series for repeated swipes', async () => { + mockRunIosRunnerCommand.mockResolvedValueOnce({ + gestureStartUptimeMs: 100, + gestureEndUptimeMs: 720, + }); + const interactor = makeUnusedInteractor(); + + const result = await handleSwipeCommand( + IOS_SIMULATOR, + interactor, + ['100', '650', '100', '450', '120'], + { + count: 2, + pauseMs: 50, + pattern: 'ping-pong', + appBundleId: 'com.example.App', + }, + ); + + assert.deepEqual(mockRunIosRunnerCommand.mock.calls[0]?.[1], { + command: 'dragSeries', + x: 100, + y: 650, + x2: 100, + y2: 450, + durationMs: 120, + count: 2, + pauseMs: 50, + pattern: 'ping-pong', + synthesized: true, + appBundleId: 'com.example.App', + }); + assert.equal(result.timingMode, 'runner-series'); + assert.equal(result.message, 'Swiped 2 times (ping-pong)'); +}); + +test('handlePanCommand preserves interactor result metadata', async () => { + const calls: unknown[][] = []; + const interactor = { + ...makeUnusedInteractor(), + pan: async (...args: unknown[]) => { + calls.push(args); + return { backend: 'xctest' }; + }, + }; + + const result = await handlePanCommand(interactor, ['196', '122', '80', '0', '500']); + + assert.deepEqual(calls, [[196, 122, 276, 122, 500]]); + assert.deepEqual(result, { + x: 196, + y: 122, + dx: 80, + dy: 0, + x2: 276, + y2: 122, + durationMs: 500, + backend: 'xctest', + message: 'Panned (196, 122) by (80, 0)', + }); +}); + test('handleRotateGestureCommand routes Android through the interactor', async () => { const calls: unknown[][] = []; const interactor = { diff --git a/src/core/__tests__/dispatch-series.test.ts b/src/core/__tests__/dispatch-series.test.ts index 96adabf44..a72f166ef 100644 --- a/src/core/__tests__/dispatch-series.test.ts +++ b/src/core/__tests__/dispatch-series.test.ts @@ -4,6 +4,7 @@ import { requireIntInRange, shouldUseIosTapSeries, shouldUseIosDragSeries, + shouldUseSynthesizedIosDrag, } from '../dispatch-series.ts'; import { AppError } from '../../utils/errors.ts'; import type { DeviceInfo } from '../../utils/device.ts'; @@ -65,6 +66,23 @@ test('shouldUseIosDragSeries returns false when count is 1', () => { assert.equal(shouldUseIosDragSeries(iosDevice, 1), false); }); +// --- shouldUseSynthesizedIosDrag --- + +test('shouldUseSynthesizedIosDrag returns true only for non-tvOS iOS targets', () => { + assert.equal(shouldUseSynthesizedIosDrag(iosDevice), true); + assert.equal(shouldUseSynthesizedIosDrag({ ...iosDevice, target: 'tv' }), false); + assert.equal( + shouldUseSynthesizedIosDrag({ + platform: 'macos', + id: 'mac', + name: 'Mac', + kind: 'device', + target: 'desktop', + }), + false, + ); +}); + // --- computeDeterministicJitter --- // --- runRepeatedSeries --- diff --git a/src/core/dispatch-interactions.ts b/src/core/dispatch-interactions.ts index fd6fb63d3..5264538d2 100644 --- a/src/core/dispatch-interactions.ts +++ b/src/core/dispatch-interactions.ts @@ -25,6 +25,7 @@ import { requireIntInRange, shouldUseIosTapSeries, shouldUseIosDragSeries, + shouldUseSynthesizedIosDrag, computeDeterministicJitter, runRepeatedSeries, } from './dispatch-series.ts'; @@ -487,6 +488,7 @@ async function runSwipeCoordinates(params: { count, pauseMs, pattern, + ...(shouldUseSynthesizedIosDrag(device) ? { synthesized: true } : {}), appBundleId: context?.appBundleId, }, { @@ -552,7 +554,7 @@ export async function handlePanCommand( const durationMs = requireIntInRange(requestedDurationMs, 'durationMs', 16, 10_000); const x2 = x + dx; const y2 = y + dy; - await interactor.pan(x, y, x2, y2, durationMs); + const interactionResult = await interactor.pan(x, y, x2, y2, durationMs); return { x, y, @@ -561,6 +563,7 @@ export async function handlePanCommand( x2, y2, durationMs, + ...(interactionResult ?? {}), ...successText(`Panned (${x}, ${y}) by (${dx}, ${dy})`), }; } diff --git a/src/core/dispatch-series.ts b/src/core/dispatch-series.ts index f43d431b4..655d74c4c 100644 --- a/src/core/dispatch-series.ts +++ b/src/core/dispatch-series.ts @@ -27,6 +27,10 @@ export function shouldUseIosDragSeries(device: DeviceInfo, count: number): boole return isApplePlatform(device.platform) && count > 1; } +export function shouldUseSynthesizedIosDrag(device: DeviceInfo): boolean { + return device.platform === 'ios' && device.target !== 'tv'; +} + export function computeDeterministicJitter(index: number, jitterPx: number): [number, number] { if (jitterPx <= 0) return [0, 0]; const [dx, dy] = DETERMINISTIC_JITTER_PATTERN[index % DETERMINISTIC_JITTER_PATTERN.length]!; diff --git a/src/daemon/request-progress-protocol.ts b/src/daemon/request-progress-protocol.ts index 307ded613..925f50201 100644 --- a/src/daemon/request-progress-protocol.ts +++ b/src/daemon/request-progress-protocol.ts @@ -1,4 +1,5 @@ import path from 'node:path'; +import { formatDurationSeconds } from '../utils/duration-format.ts'; import type { DaemonRequest, DaemonResponse } from './types.ts'; import type { RequestProgressEvent } from './request-progress.ts'; @@ -91,10 +92,3 @@ function formatReplayProgressDuration(event: RequestProgressEvent): string { const duration = formatDurationSeconds(event.durationMs ?? 0); return event.attempt && event.attempt > 1 && !event.retrying ? `total ${duration}` : duration; } - -function formatDurationSeconds(durationMs: number): string { - const seconds = Math.max(0, durationMs) / 1000; - if (seconds >= 10) return `${seconds.toFixed(1)}s`; - if (seconds >= 1) return `${seconds.toFixed(2)}s`; - return `${seconds.toFixed(3).replace(/0+$/, '').replace(/\.$/, '')}s`; -} diff --git a/src/platforms/android/__tests__/index.test.ts b/src/platforms/android/__tests__/index.test.ts index 62d20e812..effdf5cb9 100644 --- a/src/platforms/android/__tests__/index.test.ts +++ b/src/platforms/android/__tests__/index.test.ts @@ -161,6 +161,22 @@ test('parseUiHierarchy decodes XML entities in Android node attributes', () => { assert.equal(result.nodes[0]!.label, 'Line 1\nLine 2\t&<>"\''); }); +test('parseUiHierarchy keeps visible Android nodes with meaningful test identifiers', () => { + const xml = ` + + + + +`; + + const result = parseUiHierarchy(xml, 800, {}); + + assert.equal( + result.nodes.some((node) => node.identifier === 'album-0'), + true, + ); +}); + test('parseUiHierarchy reads Android bounds with negative coordinates', () => { const xml = ''; diff --git a/src/platforms/android/ui-hierarchy.ts b/src/platforms/android/ui-hierarchy.ts index 87b26a509..e1b435390 100644 --- a/src/platforms/android/ui-hierarchy.ts +++ b/src/platforms/android/ui-hierarchy.ts @@ -1,5 +1,6 @@ import type { RawSnapshotNode, Rect, SnapshotOptions } from '../../utils/snapshot.ts'; import { isScrollableType } from '../../utils/scrollable.ts'; +import { intersectArea } from '../../utils/screenshot-geometry.ts'; export type AndroidSnapshotAnalysis = { rawNodeCount: number; @@ -590,19 +591,7 @@ function hasPositiveRect(node: AndroidNode): node is AndroidNode & { rect: Rect function rectCoverage(coveringRect: Rect, targetRect: Rect): number { const targetArea = targetRect.width * targetRect.height; if (targetArea <= 0) return 0; - return intersectionArea(coveringRect, targetRect) / targetArea; -} - -function intersectionArea(left: Rect, right: Rect): number { - const xOverlap = Math.max( - 0, - Math.min(left.x + left.width, right.x + right.width) - Math.max(left.x, right.x), - ); - const yOverlap = Math.max( - 0, - Math.min(left.y + left.height, right.y + right.height) - Math.max(left.y, right.y), - ); - return xOverlap * yOverlap; + return intersectArea(coveringRect, targetRect) / targetArea; } function applyAndroidScrollActionHints(root: AndroidUiHierarchy): void { @@ -761,7 +750,7 @@ function shouldIncludeStructuralAndroidNode( ): boolean { if (node.hittable) return true; if (info.hasMeaningfulText) return true; - if (info.hasMeaningfulId && descendantHittable) return true; + if (info.hasMeaningfulId) return true; return descendantHittable; } diff --git a/src/platforms/ios/__tests__/index.test.ts b/src/platforms/ios/__tests__/index.test.ts index 3fa6a692a..47f2c7855 100644 --- a/src/platforms/ios/__tests__/index.test.ts +++ b/src/platforms/ios/__tests__/index.test.ts @@ -207,103 +207,67 @@ test('iosRunnerOverrides maps swipe to synthesized iOS drag duration', async () x2: 180, y2: 200, durationMs: 300, + synthesized: true, appBundleId: 'com.example.App', }); }); -test('iosRunnerOverrides keeps macOS swipes on the standard drag path', async () => { - mockRunIosRunnerCommand.mockResolvedValue({}); - - const { overrides } = iosRunnerOverrides(MACOS_TEST_DEVICE, { - appBundleId: 'com.example.App', - }); - - await overrides.swipe(100, 200, 180, 200, 300); - - assert.deepEqual(mockRunIosRunnerCommand.mock.calls[0]?.[1], { - command: 'drag', - x: 100, - y: 200, - x2: 180, - y2: 200, - durationMs: 300, - appBundleId: 'com.example.App', - }); -}); - -test('iosRunnerOverrides keeps tvOS swipes on the standard drag path', async () => { - mockRunIosRunnerCommand.mockResolvedValue({}); - - const { overrides } = iosRunnerOverrides(TVOS_TEST_SIMULATOR, { - appBundleId: 'com.example.App', - }); - - await overrides.swipe(100, 200, 180, 200, 300); - - assert.deepEqual(mockRunIosRunnerCommand.mock.calls[0]?.[1], { - command: 'drag', - x: 100, - y: 200, - x2: 180, - y2: 200, - durationMs: 300, - appBundleId: 'com.example.App', - }); -}); - -test('iosRunnerOverrides maps iOS scroll to synthesized drag', async () => { - mockRunIosRunnerCommand - .mockResolvedValueOnce({ - x: 0, - y: 0, - referenceWidth: 400, - referenceHeight: 800, - }) - .mockResolvedValueOnce({}); +for (const [name, device] of [ + ['macOS', MACOS_TEST_DEVICE], + ['tvOS', TVOS_TEST_SIMULATOR], +] as const) { + test(`iosRunnerOverrides keeps ${name} swipes on the standard drag path`, async () => { + mockRunIosRunnerCommand.mockResolvedValue({}); - const { overrides } = iosRunnerOverrides(IOS_TEST_SIMULATOR, { - appBundleId: 'com.example.App', - }); + const { overrides } = iosRunnerOverrides(device, { + appBundleId: 'com.example.App', + }); - await overrides.scroll('down'); + await overrides.swipe(100, 200, 180, 200, 300); - assert.deepEqual(mockRunIosRunnerCommand.mock.calls[1]?.[1], { - command: 'drag', - x: 200, - y: 640, - x2: 200, - y2: 160, - durationMs: 250, - synthesized: true, - appBundleId: 'com.example.App', + assert.deepEqual(mockRunIosRunnerCommand.mock.calls[0]?.[1], { + command: 'drag', + x: 100, + y: 200, + x2: 180, + y2: 200, + durationMs: 300, + appBundleId: 'com.example.App', + }); }); -}); - -test('iosRunnerOverrides keeps macOS scroll on the standard drag path', async () => { - mockRunIosRunnerCommand - .mockResolvedValueOnce({ - x: 0, - y: 0, - referenceWidth: 400, - referenceHeight: 800, - }) - .mockResolvedValueOnce({}); +} - const { overrides } = iosRunnerOverrides(MACOS_TEST_DEVICE, { - appBundleId: 'com.example.App', - }); +for (const [name, device, expectedGestureFields] of [ + ['iOS', IOS_TEST_SIMULATOR, { durationMs: 250, synthesized: true }], + ['macOS', MACOS_TEST_DEVICE, {}], +] as const) { + test(`iosRunnerOverrides maps ${name} scroll to the expected drag path`, async () => { + mockRunIosRunnerCommand + .mockResolvedValueOnce({ + x: 0, + y: 0, + referenceWidth: 400, + referenceHeight: 800, + }) + .mockResolvedValueOnce({}); + + const { overrides } = iosRunnerOverrides(device, { + appBundleId: 'com.example.App', + }); - await overrides.scroll('down'); + await overrides.scroll('down'); - assert.deepEqual(mockRunIosRunnerCommand.mock.calls[1]?.[1], { - command: 'drag', - x: 200, - y: 640, - x2: 200, - y2: 160, - appBundleId: 'com.example.App', + assert.deepEqual(mockRunIosRunnerCommand.mock.calls[1]?.[1], { + command: 'drag', + x: 200, + y: 640, + x2: 200, + y2: 160, + ...expectedGestureFields, + appBundleId: 'com.example.App', + }); }); -}); +} test('AGENT_DEVICE_MACOS_HELPER_BIN rejects relative override paths', async () => { const previousHelperPath = process.env.AGENT_DEVICE_MACOS_HELPER_BIN; diff --git a/src/platforms/ios/__tests__/runner-client.test.ts b/src/platforms/ios/__tests__/runner-client.test.ts index 436690bc0..c26126ef6 100644 --- a/src/platforms/ios/__tests__/runner-client.test.ts +++ b/src/platforms/ios/__tests__/runner-client.test.ts @@ -30,6 +30,7 @@ vi.mock('../runner-macos-products.ts', async () => { }); import type { DeviceInfo } from '../../../utils/device.ts'; +import { flushDiagnosticsToSessionFile, withDiagnosticsScope } from '../../../utils/diagnostics.ts'; import { AppError } from '../../../utils/errors.ts'; import type { RunnerCommand } from '../runner-contract.ts'; import { withRunnerCommandId } from '../runner-contract.ts'; @@ -610,9 +611,7 @@ test('parseRunnerResponse preserves runner unsupported-operation codes', async ( }, }), ); - const session = { - ready: false, - } as any; + const session = { ready: false }; await assert.rejects( () => parseRunnerResponse(response, session, '/tmp/runner.log'), @@ -638,9 +637,7 @@ test('parseRunnerResponse preserves iOS AX snapshot failure code and hint', asyn }, }), ); - const session = { - ready: true, - } as any; + const session = { ready: true }; await assert.rejects( () => parseRunnerResponse(response, session, '/tmp/runner.log'), @@ -655,6 +652,30 @@ test('parseRunnerResponse preserves iOS AX snapshot failure code and hint', asyn ); }); +test('parseRunnerResponse emits diagnostics for runner gesture fallbacks', async () => { + const response = new Response( + JSON.stringify({ + ok: true, + data: { + message: 'dragged', + gestureFallback: 'xctest-coordinate-drag', + gestureFallbackMessage: 'Runner synthesized drag is unavailable', + gestureFallbackHint: 'Using XCTest coordinate drag fallback.', + }, + }), + ); + const session = { ready: false }; + + const diagnostics = await captureParseRunnerDiagnostics(async () => { + const data = await parseRunnerResponse(response, session, '/tmp/runner.log'); + assert.equal(data.gestureFallback, 'xctest-coordinate-drag'); + }); + + assert.equal(session.ready, true); + assert.match(diagnostics, /ios_runner_gesture_fallback/); + assert.match(diagnostics, /xctest-coordinate-drag/); +}); + test('isRetryableRunnerError does not retry xcodebuild early-exit errors', () => { const err = new AppError( 'COMMAND_FAILED', @@ -663,6 +684,24 @@ test('isRetryableRunnerError does not retry xcodebuild early-exit errors', () => assert.equal(isRetryableRunnerError(err), false); }); +async function captureParseRunnerDiagnostics(callback: () => Promise): Promise { + const previousHome = process.env.HOME; + process.env.HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-runner-parse-diag-')); + try { + return await withDiagnosticsScope( + { session: 'runner-parse-test', requestId: 'request-1', command: 'drag' }, + async () => { + await callback(); + const diagnosticsPath = flushDiagnosticsToSessionFile({ force: true }); + assert.ok(diagnosticsPath); + return fs.readFileSync(diagnosticsPath, 'utf8'); + }, + ); + } finally { + process.env.HOME = previousHome; + } +} + test('isRetryableRunnerError does not retry busy-connecting errors', () => { const err = new AppError('COMMAND_FAILED', 'Device is busy (Connecting to iPhone)'); assert.equal(isRetryableRunnerError(err), false); @@ -740,8 +779,14 @@ test('resolveRunnerDerivedPath reuses cache path for identical runner source fin await fs.promises.mkdir(path.dirname(path.join(secondRoot, runnerRelativePath)), { recursive: true, }); - await fs.promises.writeFile(path.join(firstRoot, runnerRelativePath), 'final class RunnerTests {}\n'); - await fs.promises.writeFile(path.join(secondRoot, runnerRelativePath), 'final class RunnerTests {}\n'); + await fs.promises.writeFile( + path.join(firstRoot, runnerRelativePath), + 'final class RunnerTests {}\n', + ); + await fs.promises.writeFile( + path.join(secondRoot, runnerRelativePath), + 'final class RunnerTests {}\n', + ); const firstPath = resolveRunnerDerivedPath( iosSimulator, diff --git a/src/platforms/ios/interactions.ts b/src/platforms/ios/interactions.ts index d387a4d72..c7277f018 100644 --- a/src/platforms/ios/interactions.ts +++ b/src/platforms/ios/interactions.ts @@ -1,5 +1,6 @@ import { AppError } from '../../utils/errors.ts'; import type { DeviceInfo } from '../../utils/device.ts'; +import { shouldUseSynthesizedIosDrag } from '../../core/dispatch-series.ts'; import { buildScrollGesturePlan, type ScrollDirection } from '../../core/scroll-gesture.ts'; import { runIosRunnerCommand } from './runner-client.ts'; import type { RunnerCommand } from './runner-contract.ts'; @@ -108,34 +109,21 @@ export function iosRunnerOverrides( ); }, swipe: async (x1, y1, x2, y2, durationMs) => { - const useSynthesizedSwipe = shouldUseSynthesizedSwipe(device); return await runIosRunnerCommand( device, - { - command: 'drag', - x: x1, - y: y1, - x2, - y2, - durationMs: useSynthesizedSwipe ? iosSwipeDurationMs(durationMs) : durationMs, - ...(useSynthesizedSwipe ? { synthesized: true } : {}), - appBundleId: ctx.appBundleId, - }, + iosDragCommand(device, ctx, x1, y1, x2, y2, durationMs, { + synthesizedDefaultDurationMs: IOS_SWIPE_DEFAULT_DURATION_MS, + }), runnerOpts, ); }, pan: async (x1, y1, x2, y2, durationMs) => { return await runIosRunnerCommand( device, - { - command: 'drag', - x: x1, - y: y1, - x2, - y2, - durationMs: durationMs ?? 500, - appBundleId: ctx.appBundleId, - }, + iosDragCommand(device, ctx, x1, y1, x2, y2, durationMs, { + synthesizedDefaultDurationMs: 500, + legacyDefaultDurationMs: 500, + }), runnerOpts, ); }, @@ -270,14 +258,42 @@ export function iosRunnerOverrides( }; } -function iosSwipeDurationMs(durationMs: number | undefined): number { - if (durationMs === undefined) return IOS_SWIPE_DEFAULT_DURATION_MS; - - return Math.min(IOS_SWIPE_MAX_DURATION_MS, Math.max(IOS_SWIPE_MIN_DURATION_MS, Math.round(durationMs))); +function iosDragCommand( + device: DeviceInfo, + ctx: RunnerContext, + x: number, + y: number, + x2: number, + y2: number, + durationMs: number | undefined, + options: { + synthesizedDefaultDurationMs: number; + legacyDefaultDurationMs?: number; + }, +): RunnerCommand { + const useSynthesizedDrag = shouldUseSynthesizedIosDrag(device); + const normalizedDurationMs = useSynthesizedDrag + ? iosGestureDurationMs(durationMs, options.synthesizedDefaultDurationMs) + : (durationMs ?? options.legacyDefaultDurationMs); + return { + command: 'drag', + x, + y, + x2, + y2, + ...(normalizedDurationMs !== undefined ? { durationMs: normalizedDurationMs } : {}), + ...(useSynthesizedDrag ? { synthesized: true } : {}), + appBundleId: ctx.appBundleId, + }; } -function shouldUseSynthesizedSwipe(device: DeviceInfo): boolean { - return device.platform === 'ios' && device.target !== 'tv'; +function iosGestureDurationMs(durationMs: number | undefined, defaultDurationMs: number): number { + if (durationMs === undefined) return defaultDurationMs; + + return Math.min( + IOS_SWIPE_MAX_DURATION_MS, + Math.max(IOS_SWIPE_MIN_DURATION_MS, Math.round(durationMs)), + ); } export function appleRemotePressCommand( @@ -323,17 +339,16 @@ async function runAppleScroll( }); const runnerResult = await runRunnerCommand( device, - { - command: 'drag', - x: frame.originX + plan.x1, - y: frame.originY + plan.y1, - x2: frame.originX + plan.x2, - y2: frame.originY + plan.y2, - ...(shouldUseSynthesizedSwipe(device) - ? { durationMs: IOS_SWIPE_DEFAULT_DURATION_MS, synthesized: true } - : {}), - appBundleId: ctx.appBundleId, - }, + iosDragCommand( + device, + ctx, + frame.originX + plan.x1, + frame.originY + plan.y1, + frame.originX + plan.x2, + frame.originY + plan.y2, + undefined, + { synthesizedDefaultDurationMs: IOS_SWIPE_DEFAULT_DURATION_MS }, + ), runnerOpts, ); return normalizeIosScrollResult(runnerResult, { diff --git a/src/platforms/ios/runner-session.ts b/src/platforms/ios/runner-session.ts index bf7032199..84099e0d3 100644 --- a/src/platforms/ios/runner-session.ts +++ b/src/platforms/ios/runner-session.ts @@ -547,7 +547,7 @@ type RunnerResponsePayload = { export async function parseRunnerResponse( response: Response, - session: RunnerSession, + session: Pick, logPath?: string, ): Promise> { const text = await response.text(); @@ -580,11 +580,28 @@ export async function parseRunnerResponse( session.ready = true; session.lastSuccessfulRunnerResponseAtMs = Date.now(); if (json.data && typeof json.data === 'object' && !Array.isArray(json.data)) { - return json.data as Record; + const data = json.data as Record; + emitRunnerResponseDiagnostics(data); + return data; } return {}; } +function emitRunnerResponseDiagnostics(data: Record): void { + const fallback = data.gestureFallback; + if (typeof fallback !== 'string' || fallback.length === 0) return; + emitDiagnostic({ + level: 'debug', + phase: 'ios_runner_gesture_fallback', + data: { + fallback, + message: + typeof data.gestureFallbackMessage === 'string' ? data.gestureFallbackMessage : undefined, + hint: typeof data.gestureFallbackHint === 'string' ? data.gestureFallbackHint : undefined, + }, + }); +} + function resolveRunnerReadinessPreflightDecision( session: RunnerSession, command: RunnerCommand, @@ -624,30 +641,23 @@ function resolveRunnerReadinessPreflightDecision( } function markRunnerReadinessPreflightError(error: unknown): AppError { - const appErr = - error instanceof AppError - ? error - : new AppError( - 'COMMAND_FAILED', - error instanceof Error ? error.message : String(error), - undefined, - error, - ); - return new AppError( - appErr.code, - appErr.message, - { - ...(appErr.details ?? {}), - runnerReadinessPreflightFailed: true, - }, - appErr.cause ?? error, - ); + return markRunnerPreflightError(error, { + runnerReadinessPreflightFailed: true, + }); } function markRunnerSkippedReadinessPreflightError( error: unknown, decision: Extract, ): AppError { + return markRunnerPreflightError(error, { + runnerReadinessPreflightSkipped: true, + runnerReadinessPreflightSkipReason: decision.reason, + runnerReadinessPreflightSkippedAgeMs: decision.lastSuccessfulRunnerResponseAgeMs, + }); +} + +function markRunnerPreflightError(error: unknown, details: Record): AppError { const appErr = error instanceof AppError ? error @@ -662,9 +672,7 @@ function markRunnerSkippedReadinessPreflightError( appErr.message, { ...(appErr.details ?? {}), - runnerReadinessPreflightSkipped: true, - runnerReadinessPreflightSkipReason: decision.reason, - runnerReadinessPreflightSkippedAgeMs: decision.lastSuccessfulRunnerResponseAgeMs, + ...details, }, appErr.cause ?? error, ); diff --git a/src/platforms/ios/runner-xctestrun.ts b/src/platforms/ios/runner-xctestrun.ts index 4f48ef6df..7585d9b73 100644 --- a/src/platforms/ios/runner-xctestrun.ts +++ b/src/platforms/ios/runner-xctestrun.ts @@ -398,6 +398,7 @@ async function acquireXcodebuildSimulatorSetLock(params: { throw new AppError('COMMAND_FAILED', `Timed out waiting for ${description}`, { lockDirPath, + ...readXcodebuildSimulatorSetLockDiagnostics(lockDirPath, ownerFilePath), }); } @@ -462,6 +463,28 @@ function readXcodebuildSimulatorSetLockOwner( } } +function readXcodebuildSimulatorSetLockDiagnostics( + lockDirPath: string, + ownerFilePath: string, +): Record { + const nowMs = Date.now(); + const owner = readXcodebuildSimulatorSetLockOwner(ownerFilePath); + let lockAgeMs: number | undefined; + try { + lockAgeMs = Math.max(0, Math.round(nowMs - fs.statSync(lockDirPath).mtimeMs)); + } catch {} + return { + ...(lockAgeMs !== undefined ? { lockAgeMs } : {}), + ...(owner + ? { + ownerPid: owner.pid, + ownerStartTime: owner.startTime, + ownerAgeMs: Math.max(0, Math.round(nowMs - owner.acquiredAtMs)), + } + : {}), + }; +} + function isLiveXcodebuildSimulatorSetLockOwner(owner: XcodebuildSimulatorSetLockOwner): boolean { if (!Number.isInteger(owner.pid) || owner.pid <= 0) { return false; @@ -498,7 +521,6 @@ export async function ensureXctestrun( }); } -// fallow-ignore-next-line complexity async function ensureXctestrunUnderCacheLock(params: { device: DeviceInfo; options: { verbose?: boolean; logPath?: string; traceLogPath?: string }; @@ -527,31 +549,13 @@ async function ensureXctestrunUnderCacheLock(params: { }); } if (existing.reason === 'reuse_ready') { - try { - await repairMacOsRunnerProductsIfNeeded(device, existing.productPaths, existing.xctestrunPath); - emitRunnerXctestrunDecision('reuse', 'reuse_ready', { - derived, - xctestrunPath: existing.xctestrunPath, - }); - writeRunnerCacheMetadata( - derived, - withRunnerCacheArtifacts( - expectedCacheMetadata, - existing.xctestrunPath, - existing.productPaths, - ), - ); - return existing.xctestrunPath; - } catch (error) { - if (!isExpectedRunnerRepairFailure(error)) { - throw error; - } - emitRunnerXctestrunDecision('rebuild', 'repair_failed', { - derived, - xctestrunPath: existing.xctestrunPath, - }); - // Fall through and rebuild from a clean derived state. - } + const reusableXctestrun = await tryReuseExistingXctestrun( + device, + derived, + expectedCacheMetadata, + existing, + ); + if (reusableXctestrun) return reusableXctestrun; } if (existing.xctestrunPath) { assertSafeDerivedCleanup(derived); @@ -584,10 +588,7 @@ async function ensureXctestrunUnderCacheLock(params: { // Release/dev script builds patch the synthesized XCTest runner app in scripts/. // This covers direct local xcodebuilds triggered by ensureXctestrun on cache miss. await applyXctestRunnerAppIcon(builtProductPaths); - writeRunnerCacheMetadata( - derived, - withRunnerCacheArtifacts(expectedCacheMetadata, built, builtProductPaths), - ); + writeRunnerCacheMetadataForArtifacts(derived, expectedCacheMetadata, built, builtProductPaths); emitRunnerXctestrunDecision('build', 'built_new', { derived, xctestrunPath: built, @@ -595,6 +596,49 @@ async function ensureXctestrunUnderCacheLock(params: { return built; } +async function tryReuseExistingXctestrun( + device: DeviceInfo, + derived: string, + expectedCacheMetadata: RunnerXctestrunCacheMetadata, + existing: Extract, +): Promise { + try { + await repairMacOsRunnerProductsIfNeeded(device, existing.productPaths, existing.xctestrunPath); + emitRunnerXctestrunDecision('reuse', 'reuse_ready', { + derived, + xctestrunPath: existing.xctestrunPath, + }); + writeRunnerCacheMetadataForArtifacts( + derived, + expectedCacheMetadata, + existing.xctestrunPath, + existing.productPaths, + ); + return existing.xctestrunPath; + } catch (error) { + if (!isExpectedRunnerRepairFailure(error)) { + throw error; + } + emitRunnerXctestrunDecision('rebuild', 'repair_failed', { + derived, + xctestrunPath: existing.xctestrunPath, + }); + return null; + } +} + +function writeRunnerCacheMetadataForArtifacts( + derived: string, + metadata: RunnerXctestrunCacheMetadata, + xctestrunPath: string, + productPaths: string[], +): void { + writeRunnerCacheMetadata( + derived, + withRunnerCacheArtifacts(metadata, xctestrunPath, productPaths), + ); +} + function cleanRunnerDerivedArtifacts(derived: string): void { try { if (!fs.existsSync(derived)) return; @@ -1411,13 +1455,17 @@ type ExistingXctestrunState = reason: 'missing_xctestrun'; xctestrunPath: null; } + | { + reason: 'reuse_ready'; + xctestrunPath: string; + productPaths: string[]; + } | { reason: | 'project_root_mismatch' | 'missing_products' | 'cache_metadata_missing' - | 'cache_metadata_mismatch' - | 'reuse_ready'; + | 'cache_metadata_mismatch'; xctestrunPath: string; productPaths: string[]; }; diff --git a/src/utils/duration-format.ts b/src/utils/duration-format.ts new file mode 100644 index 000000000..8068bd51e --- /dev/null +++ b/src/utils/duration-format.ts @@ -0,0 +1,6 @@ +export function formatDurationSeconds(durationMs: number): string { + const seconds = Math.max(0, durationMs) / 1000; + if (seconds >= 10) return `${seconds.toFixed(1)}s`; + if (seconds >= 1) return `${seconds.toFixed(2)}s`; + return `${seconds.toFixed(3).replace(/0+$/, '').replace(/\.$/, '')}s`; +} diff --git a/test/integration/provider-scenarios/ios-world.ts b/test/integration/provider-scenarios/ios-world.ts index 8072427ce..06d2c81a3 100644 --- a/test/integration/provider-scenarios/ios-world.ts +++ b/test/integration/provider-scenarios/ios-world.ts @@ -69,6 +69,7 @@ export async function createIosSettingsWorld(): Promise { x2: 276, y2: 122, durationMs: 500, + synthesized: true, appBundleId: 'com.apple.Preferences', }, result: { dragged: true },