Skip to content

Commit 37895ca

Browse files
authored
refactor: replace Maestro compat with typed direct engine (#1217)
* test: add pinned Maestro conformance harness * feat: add typed Maestro program IR parser * docs: define direct Maestro engine architecture * test: compare Maestro oracle with typed IR * feat: add direct Maestro program engine * refactor: narrow Maestro execution context * refactor: tighten Maestro program parsing * fix: verify iOS Maestro visibility waits * refactor: isolate retained Maestro runtimes * refactor: type Maestro target resolution * refactor: harden typed Maestro execution * refactor: share in-page swipe planning * feat: add typed Maestro runtime port * refactor: parse Maestro suite metadata from typed IR * refactor: centralize Maestro include loading * feat: execute Maestro files through typed engine * refactor: share replay built-in variables * fix: make Maestro target intent explicit * fix: refresh Maestro targets before input * refactor: format Maestro progress from typed IR * feat: compile typed Maestro replay plans * feat: bind typed Maestro runtime to public commands * feat: route Maestro YAML through typed runtime * refactor: remove legacy Maestro runtime * refactor: remove obsolete replay control model * refactor: split typed Maestro plan modules * fix: harden typed Maestro runtime semantics * docs: update direct Maestro architecture * fix: reconcile Maestro runtime with merged contracts * fix: harden typed Maestro execution boundaries * fix: harden typed Maestro runtime evidence * perf: avoid eager Maestro device resolution * refactor: finalize typed Maestro execution * fix: reject Android system-only helper snapshots * fix: preserve Android system dialog snapshots * fix: make helper-backed CI deterministic * refactor: invalidate Maestro observations before dispatch * fix: make Maestro selector policy explicit * refactor: remove Maestro ranking sentinels * refactor: make Maestro own observation stabilization * refactor: source Maestro compatibility presets * refactor: keep Maestro failure reports typed * refactor: simplify Maestro runtime policy * fix: isolate Maestro engine failures * refactor: consolidate Maestro swipe presets * fix: align Maestro selector and observation semantics * fix: preserve atomic iOS Maestro taps * fix: require semantic uniqueness for Maestro taps * fix: preserve Maestro parse provenance * docs: pin Maestro compatibility presets * docs: reconcile Maestro gesture viewport contract * perf: resolve Maestro gesture viewport directly * test: align Maestro replay regressions * fix: order Android gesture lift after endpoint * fix: settle Maestro gestures before continuation * fixup! fix: order Android gesture lift after endpoint * refactor: normalize Maestro swipes once * refactor: fail impossible Maestro observations * refactor: normalize Maestro defaults alias * test: reconcile Android provider scenarios * fix(android): synchronize single-pointer move events * test: align repair digest parsing * refactor: type Maestro runtime operations * refactor: keep Maestro controls compact * refactor: name Maestro diagnostic limit * fix: align Maestro parser and settling semantics * fix: complete Maestro compatibility semantics * docs: define Maestro compatibility boundaries * fix: refresh iOS runner target after relaunch * fix: reset prewarmed iOS runner after URL open * fix: preserve iOS Maestro target and swipe intent * fix: harden direct Maestro runtime semantics * fix: preserve ranked Maestro replay suggestions * fix: align maestro tap runtime semantics * fix: stabilize maestro ci contracts * fix: tighten maestro runtime architecture * fix: reconcile maestro replay with latest main * perf: tighten Maestro iOS stabilization * fix: preserve Maestro app lifecycle sessions * fix: restore Maestro CI coverage * fix: address Maestro engine review findings * refactor: consolidate Maestro compatibility internals * fix: scope Maestro target evidence to childOf
1 parent 22a3c47 commit 37895ca

278 files changed

Lines changed: 19283 additions & 12584 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ios.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ jobs:
7171
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSystemModalProbeSliceSharesAndClampsToPlanDeadline \
7272
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testDispatchRecoverySkipsBookkeepingWhileXCTestChannelOccupied \
7373
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testBoundedSystemModalProbeTimeoutRecoversThenReleasesOnDrain \
74-
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testBoundedSystemModalProbeTimeoutRecoversThenReleasesOnDrainForSnapshotRaw
74+
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testBoundedSystemModalProbeTimeoutRecoversThenReleasesOnDrainForSnapshotRaw \
75+
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSnapshotTraversalIdentityPreservesSameOriginNodesWithDifferentBounds
7576
7677
- name: Prepare iOS runner
7778
run: |

CONTEXT.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@
5555
seam. Helper long-press executes its absolute stationary path without a viewport probe; provider
5656
long-press receives its paired provider-owned viewport. See ADR 0013.
5757
- Multi-touch geometry: the internal initial span and angle plus centroid translation, scale, and rotation used to build both contact trajectories. Geometry is viewport-aware and fails early when the requested motion cannot fit; it is not a public tuning surface.
58+
- Maestro program: source-preserving typed representation of supported Maestro YAML. It is interpreted directly through the compatibility runtime port and never lowered through generic replay action strings. See ADR 0015.
59+
- Maestro observation generation: explicit compatibility-engine state identifying evidence captured since the most recent mutation. Queries may share semantic evidence within one generation; every mutation attempt invalidates it before dispatch. Interaction geometry is action-local: unique exact iOS selectors resolve and tap atomically in XCTest, while coordinate dispatch uses a fresh target snapshot. Rectangles are never shared across command boundaries.
5860
- Guarantee cell: one (dispatch path, guarantee) entry in `src/contracts/interaction-guarantees.ts`, classified as runtime/runner/delegated/inapplicable/waived. Completeness is a compile error; honesty is gate-tested.
5961
- Owned waiver: a `gap:`-prefixed waived cell carrying a `trackingIssue` URL. Waivers are diffable debt with an owner, never folklore.
6062
- Parity table: golden JSON fixture under `contracts/fixtures/` consumed by both vitest and the runner's gated Swift tests, so a cross-language rule (e.g. tap-point policy) cannot drift silently. Change the rule only via the table.
@@ -158,10 +160,14 @@ the observable freshness and failure semantics below before any runtime refactor
158160
disables direct iOS selector shortcuts while pending.
159161
- `setSessionSnapshot` is the centralized session snapshot mutation path. Sparse captures do not
160162
write back, and empty `@ref`-scoped snapshot output must not replace the stored session snapshot.
161-
- Maestro target matching remains snapshot-based, fresh, and policy-rich. Native selector
162-
simplification must not erase Maestro regex/string selector behavior, visibility filtering,
163-
ranking, fuzzy fallback, visible-context preference, Android duplicate handling, tab-strip
164-
inference, or assertion/wait semantics.
163+
- Maestro target matching remains snapshot-based and policy-owned. Coordinate dispatch always uses a
164+
fresh target snapshot. A unique exact iOS match may instead reuse bound same-generation semantic
165+
evidence and dispatch through XCTest's atomic selector tap; structured live-selector failures return
166+
to fresh Maestro resolution. This optimization must not erase Maestro regex/string selector behavior,
167+
visibility filtering, provider-order first-match selection, explicit index selection, or
168+
assertion/wait semantics. Provider normalization belongs below the compatibility layer. Plain text is
169+
exact and regex-aware; do not add substring/fuzzy recovery, synthetic geometry, or hierarchy-shape
170+
heuristics that change authored selector meaning.
165171

166172
Evidence: [ADR 0002](docs/adr/0002-persistent-platform-helper-sessions.md),
167173
[ADR 0004](docs/adr/0004-ios-snapshot-backend-strategy.md),
@@ -170,7 +176,7 @@ Evidence: [ADR 0002](docs/adr/0002-persistent-platform-helper-sessions.md),
170176
[`find.test.ts`](src/daemon/handlers/__tests__/find.test.ts),
171177
[`snapshot-handler.test.ts`](src/daemon/handlers/__tests__/snapshot-handler.test.ts),
172178
[`snapshot-scoped-refs.test.ts`](src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts),
173-
[`runtime-targets.test.ts`](src/compat/maestro/__tests__/runtime-targets.test.ts), and
179+
[`runtime-targets-typed.test.ts`](src/compat/maestro/__tests__/runtime-targets-typed.test.ts), and
174180
[`android-test-suite.test.ts`](test/integration/provider-scenarios/android-test-suite.test.ts).
175181

176182
## Testing Principles

android-multitouch-helper/src/main/java/com/callstack/agentdevice/multitouchhelper/MultiTouchInstrumentation.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,12 +137,12 @@ private int injectPlan(GesturePlan plan) {
137137
inject(
138138
automation,
139139
motionEvent(downTime, eventTime, motionEventAction(step.action), active),
140-
step.action != PointerEventSchedule.Action.MOVE);
140+
step.waitForDispatch);
141141
count += 1;
142142
}
143143
return count;
144144
} catch (RuntimeException error) {
145-
if (count > 0) injectCancel(automation, downTime, eventTime + 16, active);
145+
if (count > 0) injectCancel(automation, downTime, SystemClock.uptimeMillis(), active);
146146
throw error;
147147
}
148148
}

android-multitouch-helper/src/main/java/com/callstack/agentdevice/multitouchhelper/PointerEventSchedule.java

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
/** Defines the platform-independent event order consumed by Android touch injection. */
77
final class PointerEventSchedule {
8-
private static final long POINTER_LIFT_DELAY_MS = 8;
8+
private static final long POINTER_TRANSITION_DELAY_MS = 8;
99

1010
enum Action {
1111
DOWN,
@@ -20,12 +20,19 @@ static final class Step {
2020
final int sampleIndex;
2121
final int pointerCount;
2222
final long offsetMs;
23+
final boolean waitForDispatch;
2324

24-
Step(Action action, int sampleIndex, int pointerCount, long offsetMs) {
25+
Step(
26+
Action action,
27+
int sampleIndex,
28+
int pointerCount,
29+
long offsetMs,
30+
boolean waitForDispatch) {
2531
this.action = action;
2632
this.sampleIndex = sampleIndex;
2733
this.pointerCount = pointerCount;
2834
this.offsetMs = offsetMs;
35+
this.waitForDispatch = waitForDispatch;
2936
}
3037
}
3138

@@ -41,22 +48,28 @@ static List<Step> create(int pointerCount, long[] sampleOffsetsMs) {
4148

4249
int lastIndex = sampleOffsetsMs.length - 1;
4350
List<Step> steps = new ArrayList<>(sampleOffsetsMs.length + (pointerCount == 1 ? 1 : 3));
44-
steps.add(new Step(Action.DOWN, 0, 1, 0));
51+
steps.add(new Step(Action.DOWN, 0, 1, 0, true));
4552
if (pointerCount == 2) {
46-
long pointerDownOffset = Math.max(1, Math.min(8, sampleOffsetsMs[1] - 1));
47-
steps.add(new Step(Action.POINTER_DOWN, 0, 2, pointerDownOffset));
53+
long pointerDownOffset =
54+
Math.max(1, Math.min(POINTER_TRANSITION_DELAY_MS, sampleOffsetsMs[1] - 1));
55+
steps.add(new Step(Action.POINTER_DOWN, 0, 2, pointerDownOffset, true));
4856
}
4957
for (int sampleIndex = 1; sampleIndex <= lastIndex; sampleIndex += 1) {
5058
steps.add(
51-
new Step(Action.MOVE, sampleIndex, pointerCount, sampleOffsetsMs[sampleIndex]));
59+
new Step(
60+
Action.MOVE,
61+
sampleIndex,
62+
pointerCount,
63+
sampleOffsetsMs[sampleIndex],
64+
pointerCount == 1 || sampleIndex == lastIndex));
5265
}
5366
if (pointerCount == 2) {
5467
steps.add(
55-
new Step(Action.POINTER_UP, lastIndex, 2, sampleOffsetsMs[lastIndex]));
68+
new Step(Action.POINTER_UP, lastIndex, 2, sampleOffsetsMs[lastIndex], true));
5669
}
5770
long finalUpOffset =
58-
sampleOffsetsMs[lastIndex] + (pointerCount == 2 ? POINTER_LIFT_DELAY_MS : 0);
59-
steps.add(new Step(Action.UP, lastIndex, 1, finalUpOffset));
71+
sampleOffsetsMs[lastIndex] + (pointerCount == 2 ? POINTER_TRANSITION_DELAY_MS : 0);
72+
steps.add(new Step(Action.UP, lastIndex, 1, finalUpOffset, true));
6073
return steps;
6174
}
6275
}

android-multitouch-helper/src/test/java/com/callstack/agentdevice/multitouchhelper/PointerEventScheduleTest.java

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,18 @@ private PointerEventScheduleTest() {}
88
public static void main(String[] args) {
99
assertSteps(
1010
PointerEventSchedule.create(1, new long[] {0, 16, 32}),
11-
"DOWN:0:1:0",
12-
"MOVE:1:1:16",
13-
"MOVE:2:1:32",
14-
"UP:2:1:32");
11+
"DOWN:0:1:0:true",
12+
"MOVE:1:1:16:true",
13+
"MOVE:2:1:32:true",
14+
"UP:2:1:32:true");
1515
assertSteps(
1616
PointerEventSchedule.create(2, new long[] {0, 16, 32}),
17-
"DOWN:0:1:0",
18-
"POINTER_DOWN:0:2:8",
19-
"MOVE:1:2:16",
20-
"MOVE:2:2:32",
21-
"POINTER_UP:2:2:32",
22-
"UP:2:1:40");
17+
"DOWN:0:1:0:true",
18+
"POINTER_DOWN:0:2:8:true",
19+
"MOVE:1:2:16:false",
20+
"MOVE:2:2:32:true",
21+
"POINTER_UP:2:2:32:true",
22+
"UP:2:1:40:true");
2323
}
2424

2525
private static void assertSteps(List<PointerEventSchedule.Step> actual, String... expected) {
@@ -35,7 +35,9 @@ private static void assertSteps(List<PointerEventSchedule.Step> actual, String..
3535
+ ":"
3636
+ step.pointerCount
3737
+ ":"
38-
+ step.offsetMs;
38+
+ step.offsetMs
39+
+ ":"
40+
+ step.waitForDispatch;
3941
if (!expected[index].equals(description)) {
4042
throw new AssertionError(
4143
"Step " + index + ": expected " + expected[index] + ", got " + description);

apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ extension RunnerTests {
263263
}
264264
let command = try runnerCommandFixture(
265265
"""
266-
{"command":"gesture","commandId":"gesture-fling-fallback","gesturePlan":{"topology":"single","intent":"fling","durationMs":100,"viewport":{"x":0,"y":0,"width":200,"height":300},"pointers":[{"pointerId":0,"samples":[{"offsetMs":0,"point":{"x":160,"y":150}},{"offsetMs":100,"point":{"x":40,"y":150}}]}]}}
266+
{"command":"gesture","commandId":"gesture-fling-fallback","gesturePlan":{"topology":"single","intent":"fling","executionProfile":"endpoint-hold","durationMs":100,"viewport":{"x":0,"y":0,"width":200,"height":300},"pointers":[{"pointerId":0,"samples":[{"offsetMs":0,"point":{"x":160,"y":150}},{"offsetMs":100,"point":{"x":40,"y":150}}]}]}}
267267
"""
268268
)
269269

@@ -1293,6 +1293,8 @@ extension RunnerTests {
12931293
switch command.command {
12941294
case .status:
12951295
return executeStatus(command: command)
1296+
case .targetReset:
1297+
return resetTargetAfterExternalRelaunch()
12961298
case .shutdown:
12971299
stopRecordingIfNeeded()
12981300
return Response(ok: true, data: DataPayload(message: "shutdown"))
@@ -1446,7 +1448,7 @@ extension RunnerTests {
14461448
) throws -> Response {
14471449
var activeApp = activeApp
14481450
switch command.command {
1449-
case .status, .shutdown, .recordStart, .recordStop, .uptime:
1451+
case .status, .targetReset, .shutdown, .recordStart, .recordStop, .uptime:
14501452
return Response(
14511453
ok: false,
14521454
error: ErrorPayload(
@@ -1456,11 +1458,21 @@ extension RunnerTests {
14561458
)
14571459
case .tap:
14581460
if let selectorKey = command.selectorKey, let selectorValue = command.selectorValue {
1461+
let expectedPoint: CGPoint?
1462+
if command.allowNonHittableCoordinateFallback == true,
1463+
let x = command.x,
1464+
let y = command.y
1465+
{
1466+
expectedPoint = CGPoint(x: x, y: y)
1467+
} else {
1468+
expectedPoint = nil
1469+
}
14591470
let match = findElement(
14601471
app: activeApp,
14611472
selectorKey: selectorKey,
14621473
selectorValue: selectorValue,
1463-
allowNonHittableFallback: command.allowNonHittableCoordinateFallback == true
1474+
allowNonHittableFallback: command.allowNonHittableCoordinateFallback == true,
1475+
expectedPoint: expectedPoint
14641476
)
14651477
if match.isAmbiguous {
14661478
return Response(ok: false, error: ErrorPayload(code: "AMBIGUOUS_MATCH", message: "selector matched multiple elements"))
@@ -1486,15 +1498,19 @@ extension RunnerTests {
14861498
message: "element resolved off-screen at (\(Int(frame.midX)), \(Int(frame.midY)))"))
14871499
}
14881500
let isTextEntry = isTextEntryElement(element)
1489-
let touchFrame = frame.isEmpty
1490-
? nil
1491-
: resolvedTouchVisualizationFrame(app: activeApp, x: frame.midX, y: frame.midY)
1501+
let touchPoint = expectedPoint ?? CGPoint(x: frame.midX, y: frame.midY)
1502+
let touchFrame = resolvedTouchVisualizationFrame(
1503+
app: activeApp,
1504+
x: touchPoint.x,
1505+
y: touchPoint.y
1506+
)
14921507
let (timing, outcome) = performGesture(activeApp) {
1493-
if match.usedNonHittableFallback {
1508+
if expectedPoint != nil || match.usedNonHittableFallback {
14941509
// Maestro compatibility: RN E2E backdoor controls can be 1x1 and
1495-
// reported non-hittable by XCTest, while Maestro still taps their
1496-
// resolved bounds. Keep this behind the explicit replay-only flag.
1497-
return tapAt(app: activeApp, x: frame.midX, y: frame.midY)
1510+
// reported non-hittable by XCTest. Snapshot-resolved targets also
1511+
// need coordinate delivery because XCUIElement.activate() does not
1512+
// invoke every React Native accessibility wrapper.
1513+
return tapAt(app: activeApp, x: touchPoint.x, y: touchPoint.y)
14981514
}
14991515
return activateElement(app: activeApp, element: element, action: "tap by selector")
15001516
}

apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ final class RunnerCommandJournal {
147147
.remotePress, .type, .swipe, .scroll, .desktopScroll, .findText, .querySelector, .readText, .back,
148148
.backInApp, .backSystem, .home, .rotate, .appSwitcher, .keyboardDismiss, .keyboardReturn,
149149
.alert, .sequence, .gesture, .gestureViewport, .recordStart, .recordStop,
150-
.status, .uptime, .shutdown:
150+
.status, .uptime, .targetReset, .shutdown:
151151
return true
152152
}
153153
}

apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,8 @@ extension RunnerTests {
149149
app: XCUIApplication,
150150
selectorKey: String,
151151
selectorValue: String,
152-
allowNonHittableFallback: Bool = false
152+
allowNonHittableFallback: Bool = false,
153+
expectedPoint: CGPoint? = nil
153154
) -> SelectorElementMatch {
154155
let value = selectorValue.trimmingCharacters(in: .whitespacesAndNewlines)
155156
guard !value.isEmpty else {
@@ -173,6 +174,9 @@ extension RunnerTests {
173174
var nonHittableElement: XCUIElement?
174175
let matches = app.descendants(matching: .any).matching(predicate).allElementsBoundByIndex
175176
for element in matches where element.exists {
177+
if let expectedPoint, !element.frame.contains(expectedPoint) {
178+
continue
179+
}
176180
if !element.isHittable {
177181
if allowNonHittableFallback && hasTappableFrame(app: app, element: element) {
178182
guard nonHittableElement == nil else {
@@ -1186,6 +1190,13 @@ extension RunnerTests {
11861190
: plan.intent == "pan" || plan.intent == "pinch" || plan.intent == "rotate"
11871191
|| plan.intent == "transform"
11881192
guard supportedIntent else { return "planned gesture has unsupported intent for its topology" }
1193+
if plan.topology == "single" {
1194+
guard plan.executionProfile == "endpoint-hold" || plan.executionProfile == "timed-pan" else {
1195+
return "single-pointer gesture requires a supported execution profile"
1196+
}
1197+
} else if plan.executionProfile != nil {
1198+
return "multi-touch gesture cannot define a single-pointer execution profile"
1199+
}
11891200
guard plan.durationMs.isFinite, plan.durationMs >= 16, plan.durationMs <= 10_000 else {
11901201
return "planned gesture durationMs must be between 16 and 10000"
11911202
}
@@ -1245,7 +1256,9 @@ extension RunnerTests {
12451256
}
12461257

12471258
func plannedGestureExecution(for plan: RunnerGesturePlan) -> PlannedGestureExecution {
1248-
plan.topology == "single" && plan.intent == "fling" ? .fastSwipe : .sampled
1259+
plan.topology == "single" && plan.executionProfile == "endpoint-hold"
1260+
? .fastSwipe
1261+
: .sampled
12491262
}
12501263

12511264
func sampledPlannedGesture(
@@ -1459,6 +1472,7 @@ extension RunnerTests {
14591472
)
14601473

14611474
XCTAssertNil(plannedGestureValidationError(plan))
1475+
XCTAssertEqual(plannedGestureExecution(for: plan), .sampled)
14621476
}
14631477

14641478
func testPlannedMultiTouchGestureRejectsMismatchedOffsets() throws {
@@ -1479,7 +1493,7 @@ extension RunnerTests {
14791493
let plan = try JSONDecoder().decode(
14801494
RunnerGesturePlan.self,
14811495
from: Data(
1482-
#"{"topology":"single","intent":"fling","durationMs":100,"viewport":{"x":0,"y":0,"width":200,"height":300},"pointers":[{"pointerId":0,"samples":[{"offsetMs":0,"point":{"x":160,"y":150}},{"offsetMs":100,"point":{"x":40,"y":150}}]}]}"#.utf8
1496+
#"{"topology":"single","intent":"fling","executionProfile":"endpoint-hold","durationMs":100,"viewport":{"x":0,"y":0,"width":200,"height":300},"pointers":[{"pointerId":0,"samples":[{"offsetMs":0,"point":{"x":160,"y":150}},{"offsetMs":100,"point":{"x":40,"y":150}}]}]}"#.utf8
14831497
)
14841498
)
14851499

@@ -1490,13 +1504,39 @@ extension RunnerTests {
14901504
let plan = try JSONDecoder().decode(
14911505
RunnerGesturePlan.self,
14921506
from: Data(
1493-
#"{"topology":"single","intent":"pan","durationMs":500,"viewport":{"x":0,"y":0,"width":200,"height":300},"pointers":[{"pointerId":0,"samples":[{"offsetMs":0,"point":{"x":160,"y":150}},{"offsetMs":250,"point":{"x":100,"y":150}},{"offsetMs":500,"point":{"x":40,"y":150}}]}]}"#.utf8
1507+
#"{"topology":"single","intent":"pan","executionProfile":"timed-pan","durationMs":500,"viewport":{"x":0,"y":0,"width":200,"height":300},"pointers":[{"pointerId":0,"samples":[{"offsetMs":0,"point":{"x":160,"y":150}},{"offsetMs":250,"point":{"x":100,"y":150}},{"offsetMs":500,"point":{"x":40,"y":150}}]}]}"#.utf8
14941508
)
14951509
)
14961510

14971511
XCTAssertEqual(plannedGestureExecution(for: plan), .sampled)
14981512
}
14991513

1514+
func testSinglePointerEndpointHoldUsesFastSwipeExecution() throws {
1515+
let plan = try JSONDecoder().decode(
1516+
RunnerGesturePlan.self,
1517+
from: Data(
1518+
#"{"topology":"single","intent":"pan","executionProfile":"endpoint-hold","durationMs":500,"viewport":{"x":0,"y":0,"width":200,"height":300},"pointers":[{"pointerId":0,"samples":[{"offsetMs":0,"point":{"x":160,"y":150}},{"offsetMs":500,"point":{"x":40,"y":150}}]}]}"#.utf8
1519+
)
1520+
)
1521+
1522+
XCTAssertNil(plannedGestureValidationError(plan))
1523+
XCTAssertEqual(plannedGestureExecution(for: plan), .fastSwipe)
1524+
}
1525+
1526+
func testSinglePointerGestureRejectsMissingExecutionProfile() throws {
1527+
let plan = try JSONDecoder().decode(
1528+
RunnerGesturePlan.self,
1529+
from: Data(
1530+
#"{"topology":"single","intent":"pan","durationMs":500,"viewport":{"x":0,"y":0,"width":200,"height":300},"pointers":[{"pointerId":0,"samples":[{"offsetMs":0,"point":{"x":160,"y":150}},{"offsetMs":500,"point":{"x":40,"y":150}}]}]}"#.utf8
1531+
)
1532+
)
1533+
1534+
XCTAssertEqual(
1535+
plannedGestureValidationError(plan),
1536+
"single-pointer gesture requires a supported execution profile"
1537+
)
1538+
}
1539+
15001540
func testDesktopScrollWheelDeltasMapDirections() throws {
15011541
XCTAssertEqual(try XCTUnwrap(desktopScrollWheelDeltas(direction: "up", pixels: 120)).vertical, 120)
15021542
XCTAssertEqual(try XCTUnwrap(desktopScrollWheelDeltas(direction: "down", pixels: 120)).vertical, -120)

apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,12 @@ extension RunnerTests {
9999
snapshotXCTestPenaltyWarmupExemptionPending = false
100100
}
101101

102+
func resetTargetAfterExternalRelaunch() -> Response {
103+
invalidateCachedTarget(reason: "external_app_relaunch")
104+
needsFirstInteractionDelay = true
105+
return Response(ok: true, data: DataPayload(message: "target reset"))
106+
}
107+
102108
func refreshCachedTargetIfProcessChanged(bundleId: String) {
103109
guard currentBundleId == bundleId, currentApp != nil else { return }
104110
let candidate = XCUIApplication(bundleIdentifier: bundleId)

0 commit comments

Comments
 (0)