Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
83 commits
Select commit Hold shift + click to select a range
2fbc196
test: add pinned Maestro conformance harness
thymikee Jul 11, 2026
3c12ffc
feat: add typed Maestro program IR parser
thymikee Jul 11, 2026
d005c93
docs: define direct Maestro engine architecture
thymikee Jul 11, 2026
b11ef9a
test: compare Maestro oracle with typed IR
thymikee Jul 11, 2026
0cdbf53
feat: add direct Maestro program engine
thymikee Jul 11, 2026
f439d54
refactor: narrow Maestro execution context
thymikee Jul 11, 2026
2d21436
refactor: tighten Maestro program parsing
thymikee Jul 11, 2026
b67ffbc
fix: verify iOS Maestro visibility waits
thymikee Jul 11, 2026
42d53c5
refactor: isolate retained Maestro runtimes
thymikee Jul 12, 2026
4f1e4f5
refactor: type Maestro target resolution
thymikee Jul 12, 2026
5830a5c
refactor: harden typed Maestro execution
thymikee Jul 12, 2026
bea9c12
refactor: share in-page swipe planning
thymikee Jul 12, 2026
10ef23f
feat: add typed Maestro runtime port
thymikee Jul 12, 2026
8a3058d
refactor: parse Maestro suite metadata from typed IR
thymikee Jul 12, 2026
ff21f52
refactor: centralize Maestro include loading
thymikee Jul 12, 2026
1dbdc36
feat: execute Maestro files through typed engine
thymikee Jul 12, 2026
958918d
refactor: share replay built-in variables
thymikee Jul 12, 2026
27f537a
fix: make Maestro target intent explicit
thymikee Jul 12, 2026
2bbf4ac
fix: refresh Maestro targets before input
thymikee Jul 12, 2026
ada393e
refactor: format Maestro progress from typed IR
thymikee Jul 12, 2026
20dd99e
feat: compile typed Maestro replay plans
thymikee Jul 12, 2026
3e2a1f4
feat: bind typed Maestro runtime to public commands
thymikee Jul 12, 2026
ebe2ca3
feat: route Maestro YAML through typed runtime
thymikee Jul 12, 2026
901b38f
refactor: remove legacy Maestro runtime
thymikee Jul 12, 2026
63f6dc3
refactor: remove obsolete replay control model
thymikee Jul 12, 2026
57f097b
refactor: split typed Maestro plan modules
thymikee Jul 12, 2026
89b7344
fix: harden typed Maestro runtime semantics
thymikee Jul 12, 2026
f1e10e9
docs: update direct Maestro architecture
thymikee Jul 12, 2026
59fad44
fix: reconcile Maestro runtime with merged contracts
thymikee Jul 13, 2026
817fcf7
fix: harden typed Maestro execution boundaries
thymikee Jul 13, 2026
03ea032
fix: harden typed Maestro runtime evidence
thymikee Jul 13, 2026
674f1b4
perf: avoid eager Maestro device resolution
thymikee Jul 13, 2026
4e7c190
refactor: finalize typed Maestro execution
thymikee Jul 14, 2026
f821c7f
fix: reject Android system-only helper snapshots
thymikee Jul 14, 2026
b4d5c3f
fix: preserve Android system dialog snapshots
thymikee Jul 14, 2026
6788eb7
fix: make helper-backed CI deterministic
thymikee Jul 14, 2026
c28e70a
refactor: invalidate Maestro observations before dispatch
thymikee Jul 14, 2026
db4d0b4
fix: make Maestro selector policy explicit
thymikee Jul 14, 2026
c9d0b05
refactor: remove Maestro ranking sentinels
thymikee Jul 14, 2026
957999d
refactor: make Maestro own observation stabilization
thymikee Jul 14, 2026
10f14c9
refactor: source Maestro compatibility presets
thymikee Jul 14, 2026
d7ff12d
refactor: keep Maestro failure reports typed
thymikee Jul 14, 2026
60d6214
refactor: simplify Maestro runtime policy
thymikee Jul 14, 2026
9200e84
fix: isolate Maestro engine failures
thymikee Jul 14, 2026
8be10ee
refactor: consolidate Maestro swipe presets
thymikee Jul 14, 2026
16a9891
fix: align Maestro selector and observation semantics
thymikee Jul 14, 2026
a0949ab
fix: preserve atomic iOS Maestro taps
thymikee Jul 14, 2026
681a15b
fix: require semantic uniqueness for Maestro taps
thymikee Jul 14, 2026
d9684c2
fix: preserve Maestro parse provenance
thymikee Jul 14, 2026
6fd2aba
docs: pin Maestro compatibility presets
thymikee Jul 14, 2026
6c41054
docs: reconcile Maestro gesture viewport contract
thymikee Jul 14, 2026
2a9a273
perf: resolve Maestro gesture viewport directly
thymikee Jul 14, 2026
449c761
test: align Maestro replay regressions
thymikee Jul 14, 2026
eeb5e42
fix: order Android gesture lift after endpoint
thymikee Jul 14, 2026
bb44b59
fix: settle Maestro gestures before continuation
thymikee Jul 14, 2026
dda941c
fixup! fix: order Android gesture lift after endpoint
thymikee Jul 14, 2026
2a2098a
refactor: normalize Maestro swipes once
thymikee Jul 14, 2026
d08557d
refactor: fail impossible Maestro observations
thymikee Jul 14, 2026
6c1a249
refactor: normalize Maestro defaults alias
thymikee Jul 14, 2026
71f5eed
test: reconcile Android provider scenarios
thymikee Jul 14, 2026
5b24d3c
fix(android): synchronize single-pointer move events
thymikee Jul 14, 2026
2085373
test: align repair digest parsing
thymikee Jul 14, 2026
d70d665
refactor: type Maestro runtime operations
thymikee Jul 14, 2026
f059ae1
refactor: keep Maestro controls compact
thymikee Jul 14, 2026
935c867
refactor: name Maestro diagnostic limit
thymikee Jul 14, 2026
7a5bc0a
fix: align Maestro parser and settling semantics
thymikee Jul 14, 2026
0e7887a
fix: complete Maestro compatibility semantics
thymikee Jul 14, 2026
db7f87a
docs: define Maestro compatibility boundaries
thymikee Jul 14, 2026
ed688d3
fix: refresh iOS runner target after relaunch
thymikee Jul 14, 2026
2277395
fix: reset prewarmed iOS runner after URL open
thymikee Jul 14, 2026
05aea30
fix: preserve iOS Maestro target and swipe intent
thymikee Jul 14, 2026
02d592b
fix: harden direct Maestro runtime semantics
thymikee Jul 14, 2026
3839f24
fix: preserve ranked Maestro replay suggestions
thymikee Jul 14, 2026
3c3403d
fix: align maestro tap runtime semantics
thymikee Jul 14, 2026
ee76539
fix: stabilize maestro ci contracts
thymikee Jul 14, 2026
662a4fd
fix: tighten maestro runtime architecture
thymikee Jul 14, 2026
66268a5
fix: reconcile maestro replay with latest main
thymikee Jul 15, 2026
13bddfa
perf: tighten Maestro iOS stabilization
thymikee Jul 15, 2026
8d0e28d
fix: preserve Maestro app lifecycle sessions
thymikee Jul 15, 2026
c359870
fix: restore Maestro CI coverage
thymikee Jul 15, 2026
6fb7eb5
fix: address Maestro engine review findings
thymikee Jul 15, 2026
05dfe06
refactor: consolidate Maestro compatibility internals
thymikee Jul 15, 2026
4f1d625
fix: scope Maestro target evidence to childOf
thymikee Jul 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ jobs:
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSystemModalProbeSliceSharesAndClampsToPlanDeadline \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testDispatchRecoverySkipsBookkeepingWhileXCTestChannelOccupied \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testBoundedSystemModalProbeTimeoutRecoversThenReleasesOnDrain \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testBoundedSystemModalProbeTimeoutRecoversThenReleasesOnDrainForSnapshotRaw
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testBoundedSystemModalProbeTimeoutRecoversThenReleasesOnDrainForSnapshotRaw \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSnapshotTraversalIdentityPreservesSameOriginNodesWithDifferentBounds

- name: Prepare iOS runner
run: |
Expand Down
16 changes: 11 additions & 5 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@
seam. Helper long-press executes its absolute stationary path without a viewport probe; provider
long-press receives its paired provider-owned viewport. See ADR 0013.
- 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.
- 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.
- 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.
- 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.
- Owned waiver: a `gap:`-prefixed waived cell carrying a `trackingIssue` URL. Waivers are diffable debt with an owner, never folklore.
- 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.
Expand Down Expand Up @@ -158,10 +160,14 @@ the observable freshness and failure semantics below before any runtime refactor
disables direct iOS selector shortcuts while pending.
- `setSessionSnapshot` is the centralized session snapshot mutation path. Sparse captures do not
write back, and empty `@ref`-scoped snapshot output must not replace the stored session snapshot.
- Maestro target matching remains snapshot-based, fresh, and policy-rich. Native selector
simplification must not erase Maestro regex/string selector behavior, visibility filtering,
ranking, fuzzy fallback, visible-context preference, Android duplicate handling, tab-strip
inference, or assertion/wait semantics.
- Maestro target matching remains snapshot-based and policy-owned. Coordinate dispatch always uses a
fresh target snapshot. A unique exact iOS match may instead reuse bound same-generation semantic
evidence and dispatch through XCTest's atomic selector tap; structured live-selector failures return
to fresh Maestro resolution. This optimization must not erase Maestro regex/string selector behavior,
visibility filtering, provider-order first-match selection, explicit index selection, or
assertion/wait semantics. Provider normalization belongs below the compatibility layer. Plain text is
exact and regex-aware; do not add substring/fuzzy recovery, synthetic geometry, or hierarchy-shape
heuristics that change authored selector meaning.

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

## Testing Principles
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,12 @@ private int injectPlan(GesturePlan plan) {
inject(
automation,
motionEvent(downTime, eventTime, motionEventAction(step.action), active),
step.action != PointerEventSchedule.Action.MOVE);
step.waitForDispatch);
count += 1;
}
return count;
} catch (RuntimeException error) {
if (count > 0) injectCancel(automation, downTime, eventTime + 16, active);
if (count > 0) injectCancel(automation, downTime, SystemClock.uptimeMillis(), active);
throw error;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

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

enum Action {
DOWN,
Expand All @@ -20,12 +20,19 @@ static final class Step {
final int sampleIndex;
final int pointerCount;
final long offsetMs;
final boolean waitForDispatch;

Step(Action action, int sampleIndex, int pointerCount, long offsetMs) {
Step(
Action action,
int sampleIndex,
int pointerCount,
long offsetMs,
boolean waitForDispatch) {
this.action = action;
this.sampleIndex = sampleIndex;
this.pointerCount = pointerCount;
this.offsetMs = offsetMs;
this.waitForDispatch = waitForDispatch;
}
}

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

int lastIndex = sampleOffsetsMs.length - 1;
List<Step> steps = new ArrayList<>(sampleOffsetsMs.length + (pointerCount == 1 ? 1 : 3));
steps.add(new Step(Action.DOWN, 0, 1, 0));
steps.add(new Step(Action.DOWN, 0, 1, 0, true));
if (pointerCount == 2) {
long pointerDownOffset = Math.max(1, Math.min(8, sampleOffsetsMs[1] - 1));
steps.add(new Step(Action.POINTER_DOWN, 0, 2, pointerDownOffset));
long pointerDownOffset =
Math.max(1, Math.min(POINTER_TRANSITION_DELAY_MS, sampleOffsetsMs[1] - 1));
steps.add(new Step(Action.POINTER_DOWN, 0, 2, pointerDownOffset, true));
}
for (int sampleIndex = 1; sampleIndex <= lastIndex; sampleIndex += 1) {
steps.add(
new Step(Action.MOVE, sampleIndex, pointerCount, sampleOffsetsMs[sampleIndex]));
new Step(
Action.MOVE,
sampleIndex,
pointerCount,
sampleOffsetsMs[sampleIndex],
pointerCount == 1 || sampleIndex == lastIndex));
}
if (pointerCount == 2) {
steps.add(
new Step(Action.POINTER_UP, lastIndex, 2, sampleOffsetsMs[lastIndex]));
new Step(Action.POINTER_UP, lastIndex, 2, sampleOffsetsMs[lastIndex], true));
}
long finalUpOffset =
sampleOffsetsMs[lastIndex] + (pointerCount == 2 ? POINTER_LIFT_DELAY_MS : 0);
steps.add(new Step(Action.UP, lastIndex, 1, finalUpOffset));
sampleOffsetsMs[lastIndex] + (pointerCount == 2 ? POINTER_TRANSITION_DELAY_MS : 0);
steps.add(new Step(Action.UP, lastIndex, 1, finalUpOffset, true));
return steps;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,18 @@ private PointerEventScheduleTest() {}
public static void main(String[] args) {
assertSteps(
PointerEventSchedule.create(1, new long[] {0, 16, 32}),
"DOWN:0:1:0",
"MOVE:1:1:16",
"MOVE:2:1:32",
"UP:2:1:32");
"DOWN:0:1:0:true",
"MOVE:1:1:16:true",
"MOVE:2:1:32:true",
"UP:2:1:32:true");
assertSteps(
PointerEventSchedule.create(2, new long[] {0, 16, 32}),
"DOWN:0:1:0",
"POINTER_DOWN:0:2:8",
"MOVE:1:2:16",
"MOVE:2:2:32",
"POINTER_UP:2:2:32",
"UP:2:1:40");
"DOWN:0:1:0:true",
"POINTER_DOWN:0:2:8:true",
"MOVE:1:2:16:false",
"MOVE:2:2:32:true",
"POINTER_UP:2:2:32:true",
"UP:2:1:40:true");
}

private static void assertSteps(List<PointerEventSchedule.Step> actual, String... expected) {
Expand All @@ -35,7 +35,9 @@ private static void assertSteps(List<PointerEventSchedule.Step> actual, String..
+ ":"
+ step.pointerCount
+ ":"
+ step.offsetMs;
+ step.offsetMs
+ ":"
+ step.waitForDispatch;
if (!expected[index].equals(description)) {
throw new AssertionError(
"Step " + index + ": expected " + expected[index] + ", got " + description);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ extension RunnerTests {
}
let command = try runnerCommandFixture(
"""
{"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}}]}]}}
{"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}}]}]}}
"""
)

Expand Down Expand Up @@ -1293,6 +1293,8 @@ extension RunnerTests {
switch command.command {
case .status:
return executeStatus(command: command)
case .targetReset:
return resetTargetAfterExternalRelaunch()
case .shutdown:
stopRecordingIfNeeded()
return Response(ok: true, data: DataPayload(message: "shutdown"))
Expand Down Expand Up @@ -1446,7 +1448,7 @@ extension RunnerTests {
) throws -> Response {
var activeApp = activeApp
switch command.command {
case .status, .shutdown, .recordStart, .recordStop, .uptime:
case .status, .targetReset, .shutdown, .recordStart, .recordStop, .uptime:
return Response(
ok: false,
error: ErrorPayload(
Expand All @@ -1456,11 +1458,21 @@ extension RunnerTests {
)
case .tap:
if let selectorKey = command.selectorKey, let selectorValue = command.selectorValue {
let expectedPoint: CGPoint?
if command.allowNonHittableCoordinateFallback == true,
let x = command.x,
let y = command.y
{
expectedPoint = CGPoint(x: x, y: y)
} else {
expectedPoint = nil
}
let match = findElement(
app: activeApp,
selectorKey: selectorKey,
selectorValue: selectorValue,
allowNonHittableFallback: command.allowNonHittableCoordinateFallback == true
allowNonHittableFallback: command.allowNonHittableCoordinateFallback == true,
expectedPoint: expectedPoint
)
if match.isAmbiguous {
return Response(ok: false, error: ErrorPayload(code: "AMBIGUOUS_MATCH", message: "selector matched multiple elements"))
Expand All @@ -1486,15 +1498,19 @@ extension RunnerTests {
message: "element resolved off-screen at (\(Int(frame.midX)), \(Int(frame.midY)))"))
}
let isTextEntry = isTextEntryElement(element)
let touchFrame = frame.isEmpty
? nil
: resolvedTouchVisualizationFrame(app: activeApp, x: frame.midX, y: frame.midY)
let touchPoint = expectedPoint ?? CGPoint(x: frame.midX, y: frame.midY)
let touchFrame = resolvedTouchVisualizationFrame(
app: activeApp,
x: touchPoint.x,
y: touchPoint.y
)
let (timing, outcome) = performGesture(activeApp) {
if match.usedNonHittableFallback {
if expectedPoint != nil || match.usedNonHittableFallback {
// Maestro compatibility: RN E2E backdoor controls can be 1x1 and
// reported non-hittable by XCTest, while Maestro still taps their
// resolved bounds. Keep this behind the explicit replay-only flag.
return tapAt(app: activeApp, x: frame.midX, y: frame.midY)
// reported non-hittable by XCTest. Snapshot-resolved targets also
// need coordinate delivery because XCUIElement.activate() does not
// invoke every React Native accessibility wrapper.
return tapAt(app: activeApp, x: touchPoint.x, y: touchPoint.y)
}
return activateElement(app: activeApp, element: element, action: "tap by selector")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ final class RunnerCommandJournal {
.remotePress, .type, .swipe, .scroll, .desktopScroll, .findText, .querySelector, .readText, .back,
.backInApp, .backSystem, .home, .rotate, .appSwitcher, .keyboardDismiss, .keyboardReturn,
.alert, .sequence, .gesture, .gestureViewport, .recordStart, .recordStop,
.status, .uptime, .shutdown:
.status, .uptime, .targetReset, .shutdown:
return true
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,8 @@ extension RunnerTests {
app: XCUIApplication,
selectorKey: String,
selectorValue: String,
allowNonHittableFallback: Bool = false
allowNonHittableFallback: Bool = false,
expectedPoint: CGPoint? = nil
) -> SelectorElementMatch {
let value = selectorValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else {
Expand All @@ -173,6 +174,9 @@ extension RunnerTests {
var nonHittableElement: XCUIElement?
let matches = app.descendants(matching: .any).matching(predicate).allElementsBoundByIndex
for element in matches where element.exists {
if let expectedPoint, !element.frame.contains(expectedPoint) {
continue
}
if !element.isHittable {
if allowNonHittableFallback && hasTappableFrame(app: app, element: element) {
guard nonHittableElement == nil else {
Expand Down Expand Up @@ -1186,6 +1190,13 @@ extension RunnerTests {
: plan.intent == "pan" || plan.intent == "pinch" || plan.intent == "rotate"
|| plan.intent == "transform"
guard supportedIntent else { return "planned gesture has unsupported intent for its topology" }
if plan.topology == "single" {
guard plan.executionProfile == "endpoint-hold" || plan.executionProfile == "timed-pan" else {
return "single-pointer gesture requires a supported execution profile"
}
} else if plan.executionProfile != nil {
return "multi-touch gesture cannot define a single-pointer execution profile"
}
guard plan.durationMs.isFinite, plan.durationMs >= 16, plan.durationMs <= 10_000 else {
return "planned gesture durationMs must be between 16 and 10000"
}
Expand Down Expand Up @@ -1245,7 +1256,9 @@ extension RunnerTests {
}

func plannedGestureExecution(for plan: RunnerGesturePlan) -> PlannedGestureExecution {
plan.topology == "single" && plan.intent == "fling" ? .fastSwipe : .sampled
plan.topology == "single" && plan.executionProfile == "endpoint-hold"
? .fastSwipe
: .sampled
}

func sampledPlannedGesture(
Expand Down Expand Up @@ -1459,6 +1472,7 @@ extension RunnerTests {
)

XCTAssertNil(plannedGestureValidationError(plan))
XCTAssertEqual(plannedGestureExecution(for: plan), .sampled)
}

func testPlannedMultiTouchGestureRejectsMismatchedOffsets() throws {
Expand All @@ -1479,7 +1493,7 @@ extension RunnerTests {
let plan = try JSONDecoder().decode(
RunnerGesturePlan.self,
from: Data(
#"{"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
#"{"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
)
)

Expand All @@ -1490,13 +1504,39 @@ extension RunnerTests {
let plan = try JSONDecoder().decode(
RunnerGesturePlan.self,
from: Data(
#"{"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
#"{"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
)
)

XCTAssertEqual(plannedGestureExecution(for: plan), .sampled)
}

func testSinglePointerEndpointHoldUsesFastSwipeExecution() throws {
let plan = try JSONDecoder().decode(
RunnerGesturePlan.self,
from: Data(
#"{"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
)
)

XCTAssertNil(plannedGestureValidationError(plan))
XCTAssertEqual(plannedGestureExecution(for: plan), .fastSwipe)
}

func testSinglePointerGestureRejectsMissingExecutionProfile() throws {
let plan = try JSONDecoder().decode(
RunnerGesturePlan.self,
from: Data(
#"{"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
)
)

XCTAssertEqual(
plannedGestureValidationError(plan),
"single-pointer gesture requires a supported execution profile"
)
}

func testDesktopScrollWheelDeltasMapDirections() throws {
XCTAssertEqual(try XCTUnwrap(desktopScrollWheelDeltas(direction: "up", pixels: 120)).vertical, 120)
XCTAssertEqual(try XCTUnwrap(desktopScrollWheelDeltas(direction: "down", pixels: 120)).vertical, -120)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ extension RunnerTests {
snapshotXCTestPenaltyWarmupExemptionPending = false
}

func resetTargetAfterExternalRelaunch() -> Response {
invalidateCachedTarget(reason: "external_app_relaunch")
needsFirstInteractionDelay = true
return Response(ok: true, data: DataPayload(message: "target reset"))
}

func refreshCachedTargetIfProcessChanged(bundleId: String) {
guard currentBundleId == bundleId, currentApp != nil else { return }
let candidate = XCUIApplication(bundleIdentifier: bundleId)
Expand Down
Loading
Loading