Skip to content

Commit a13a683

Browse files
authored
feat: add selector-targeted drag gestures (#1567)
* feat: add selector-targeted drag gestures * fix: address drag gesture review feedback * fix: satisfy drag review quality gates * fix(android): lower drag trajectories piecewise * test(replay): validate drag fixture selectors * fix(ios): ignore full-viewport chrome containers * test(drag): prove destination on live devices
1 parent 4c7a899 commit a13a683

84 files changed

Lines changed: 2744 additions & 178 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/replays-nightly.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,9 @@ jobs:
138138
BOOT_FINISHED_AT="$(date +%s)"
139139
pnpm build
140140
pnpm clean:daemon
141+
adb -s "$ANDROID_SERIAL" install -r "${{ steps.fixture-app.outputs.apk-path }}"
142+
node --experimental-strip-types src/bin.ts replay examples/test-app/replays/drag-android.ad --platform android --serial "$ANDROID_SERIAL" --session nightly-android-target-drag --json
143+
pnpm clean:daemon
141144
FULL_STARTED_AT="$(date +%s)"
142145
AGENT_DEVICE_ANDROID_E2E=1 AGENT_DEVICE_ANDROID_E2E_TIER=full AGENT_DEVICE_ANDROID_SERIAL="$ANDROID_SERIAL" AGENT_DEVICE_FIXTURE_APP_PATH="${{ steps.fixture-app.outputs.apk-path }}" AGENT_DEVICE_FIXTURE_APP_ID="${{ steps.fixture-app.outputs.app-id }}" node --test test/integration/smoke-android-emulator.test.ts
143146
FULL_FINISHED_AT="$(date +%s)"
@@ -210,6 +213,13 @@ jobs:
210213
run: |
211214
echo "Fixture app source: ${{ steps.fixture-app.outputs.source }}" >> "$GITHUB_STEP_SUMMARY"
212215
216+
- name: Prove selector drag reaches its destination on iOS
217+
run: |
218+
xcrun simctl install "${{ steps.ios-simulator.outputs.simulator-udid }}" "${{ steps.fixture-app.outputs.app-path }}"
219+
pnpm clean:daemon
220+
node --experimental-strip-types src/bin.ts replay examples/test-app/replays/drag.ad --platform ios --udid "${{ steps.ios-simulator.outputs.simulator-udid }}" --session nightly-ios-target-drag --json
221+
pnpm clean:daemon
222+
213223
- name: Run full fixture-backed iOS simulator E2E
214224
env:
215225
AGENT_DEVICE_FIXTURE_APP_ID: ${{ steps.fixture-app.outputs.app-id }}

docs/adr/0011-interaction-guarantee-contract.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ trade-offs independently:
1515
| --- | --- | --- |
1616
| `runtime-selector` | daemon tree capture → `resolveSelectorChain` → guards → coordinate tap | full semantics |
1717
| `runtime-ref` | session snapshot → ref lookup → guards → coordinate tap | full semantics |
18+
| `target-drag` | independently resolve source + destination → guards → one pointer plan | dual-endpoint drag semantics |
1819
| `direct-ios-selector` | selector sent to the XCTest runner, which queries and taps natively | saves a full snapshot round trip |
1920
| `native-ref` | `backend.tapTarget`/`fillTarget` for `click @ref` / `fill @ref` | saves resolution round trips |
2021
| `coordinate` | raw x/y tap | escape hatch; semantics intentionally minimal |
@@ -266,6 +267,10 @@ dimensions as their only frame source; cheaper frame sources such as
266267
intentionally excluded because they can diverge from full-screen screenshot
267268
coordinates on affected simulators.
268269

270+
Target-authored `gesture drag` is classified as the `target-drag` path above because it resolves two
271+
elements and therefore inherits the element guarantees and ADR 0012 identity requirements for both.
272+
Coordinate-authored drag remains outside this matrix.
273+
269274
Two-contact pan/pinch/rotate/transform planning is now owned by the typed
270275
gesture-plan contract in [ADR 0013](0013-unified-gesture-plans.md). It remains a
271276
sibling of this element-targeting matrix: coordinate gestures do not acquire

docs/adr/0012-interactive-replay.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,15 @@ the corresponding provider contract cases in the same change.
132132

133133
### 3. Versioned `.ad` target-binding evidence
134134

135+
> **Amendment (#1567): dual-endpoint target evidence.** An action that resolves two elements records
136+
> one `# agent-device:targets-v1 {"source":{...},"destination":{...}}` annotation instead of a
137+
> single `target-v1`. Each nested value is a complete `TargetAnnotationV1` under the same identity,
138+
> normalization, field-size, and verification rules. The wrapper is capped at 8,320 bytes. Replay
139+
> verifies both selectors and carries two post-resolution guards before dispatch; either endpoint's
140+
> selector miss, identity mismatch, unverifiable evidence, or dispatch-time guard mismatch refuses
141+
> the entire action before pointer-down. Unknown future `targets-vN` annotations remain ordinary
142+
> comments to an older reader, matching the single-target versioning rule.
143+
135144
Recording writes evidence for every action that resolves an element target. The plain-text format is a
136145
versioned comment immediately before the action it annotates:
137146

docs/adr/0013-unified-gesture-plans.md

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ while still routing through the canonical `pan` input.
3333
The runtime plans canonical intent in `packages/contracts/src/gesture-plan.ts`. Contact topology is separate
3434
from motion:
3535

36-
- one contact: pan or fling with a complete pointer trajectory and an explicit execution profile;
36+
- one contact: pan, fling, or target-authored drag with a complete pointer trajectory and an explicit
37+
execution profile;
3738
- two contacts: pan, pinch, rotate, or transform with two complete, synchronized trajectories.
3839

3940
`swipe` without a duration remains public sugar for a fixed-duration fling. Timed public forms
@@ -133,8 +134,22 @@ Public two-finger pan is additive: `pointerCount?: 1 | 2` on pan and CLI
133134
`kind`, `durationMs`, `pointerCount`, `from`, and `to` fields, followed by backend evidence.
134135
Recording/replay keeps its existing public command identity and session semantics.
135136

136-
ADR 0011's element dispatch-path matrix remains unchanged: coordinate gestures do not resolve
137-
selectors or refs and therefore cannot claim element-targeting guarantees.
137+
Target-authored drag is additive: `gesture drag <source> <destination>` accepts a selector or pinned
138+
snapshot ref at each endpoint, resolves both endpoints before device injection, then lowers their center
139+
points to one uninterrupted single-pointer plan. The plan holds at the source, moves over the authored
140+
duration, optionally holds at the destination, and releases. Both endpoint-resolution disclosures and
141+
portable selector chains are returned to the caller. Recordings replace session-local refs at both
142+
endpoints with those selector chains and attach one `targets-v1` annotation containing independent source
143+
and destination identity evidence. Replay verifies both identities before pointer-down and guards both
144+
independent endpoint resolutions against the verified elements. Ref admission happens for both endpoints
145+
before either is dispatched, and the usual mutation boundary expires the frame after the gesture. Because
146+
this contract includes the source hold, timed movement, and destination hold phases, target-authored drag
147+
is admitted only on Android touch devices and iOS/iPadOS, whose adapters preserve the full plan.
148+
149+
ADR 0011's coordinate path remains unchanged: coordinate-authored gestures do not resolve selectors or
150+
refs and therefore cannot claim element-targeting guarantees. Target-authored drag explicitly runs the
151+
shared selector/ref resolution preflight before it enters the coordinate gesture executor; its endpoint
152+
resolution and recording contracts are tested at that composition seam.
138153

139154
## Consequences
140155

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
context platform=android kind=emulator timeout=60000
2+
3+
env APP_TARGET="com.callstack.agentdevicelab"
4+
env APP_URL=""
5+
6+
open "${APP_TARGET}" --relaunch --launch-url "${APP_URL}"
7+
react-native dismiss-overlay
8+
wait "Gesture lab" 30000
9+
wait "gesture canary ready" 5000
10+
wait "drag completed no" 5000
11+
12+
gesture drag id="drag-source" id="drag-destination" 600 500 0
13+
wait "drag completed yes" 5000
14+
15+
close

examples/test-app/replays/drag.ad

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
context platform=ios kind=simulator timeout=60000
2+
3+
env APP_TARGET="Agent Device Tester"
4+
env APP_URL=""
5+
6+
open "${APP_TARGET}" --relaunch --launch-url "${APP_URL}"
7+
wait "Gesture lab" 30000
8+
wait "gesture canary ready" 5000
9+
wait "drag completed no" 5000
10+
11+
gesture drag id="drag-source" id="drag-destination" 600 500 0
12+
wait "drag completed yes" 5000
13+
14+
close

examples/test-app/src/screens/GestureLab.tsx

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useEffect, useRef, useState } from 'react';
2-
import { Image, Platform, Text, View, type GestureResponderEvent } from 'react-native';
2+
import { Image, Platform, Pressable, Text, View, type GestureResponderEvent } from 'react-native';
33
import {
44
Directions,
55
FlingGestureHandler,
@@ -64,6 +64,7 @@ export function GestureLab() {
6464
const [canaryReady, setCanaryReady] = useState(false);
6565
const [transform, setTransform] = useState<TransformState>(initialTransform);
6666
const [counts, setCounts] = useState<GestureCounts>(initialCounts);
67+
const [dragCompleted, setDragCompleted] = useState(false);
6768
const transformRef = useRef<TransformState>(initialTransform);
6869
const gestureStartRef = useRef<TransformState>(initialTransform);
6970
const androidTouchStartRef = useRef<AndroidTouchStart | undefined>(undefined);
@@ -191,6 +192,15 @@ export function GestureLab() {
191192
.minDistance(4)
192193
.runOnJS(true)
193194
.onStart(handleTwoPointerPan);
195+
const holdDragGesture = Gesture.Pan()
196+
.activateAfterLongPress(500)
197+
.minDistance(10)
198+
.runOnJS(true)
199+
.onEnd((event, completed) => {
200+
if (completed && Math.hypot(event.translationX, event.translationY) > 60) {
201+
setDragCompleted(true);
202+
}
203+
});
194204
const legacyFlingRefs = [
195205
legacyFlingLeftRef,
196206
legacyFlingRightRef,
@@ -282,6 +292,32 @@ export function GestureLab() {
282292
title="Gesture lab"
283293
testID="gesture-lab-card"
284294
>
295+
<View style={styles.dragRow} testID="drag-gesture-fixture">
296+
<GestureDetector gesture={holdDragGesture}>
297+
<Pressable
298+
accessibilityLabel="Drag source"
299+
accessibilityRole="button"
300+
onPress={() => undefined}
301+
style={styles.dragEndpoint}
302+
testID="drag-source"
303+
>
304+
<Text style={styles.dragEndpointLabel}>Source</Text>
305+
</Pressable>
306+
</GestureDetector>
307+
<Pressable
308+
accessibilityLabel="Drag destination"
309+
accessibilityRole="button"
310+
onPress={() => undefined}
311+
style={styles.dragEndpoint}
312+
testID="drag-destination"
313+
>
314+
<Text style={styles.dragEndpointLabel}>Destination</Text>
315+
</Pressable>
316+
</View>
317+
<Text style={styles.metric} testID="drag-gesture-status">
318+
drag completed {dragCompleted ? 'yes' : 'no'}
319+
</Text>
320+
285321
<View
286322
accessibilityLabel="Gesture test image"
287323
onTouchEnd={

examples/test-app/src/screens/gesture-lab-styles.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,25 @@ export function createGestureLabStyles(colors: AppColors) {
4444
fontWeight: '600',
4545
lineHeight: 18,
4646
},
47+
dragRow: {
48+
flexDirection: 'row',
49+
gap: 20,
50+
justifyContent: 'space-between',
51+
},
52+
dragEndpoint: {
53+
alignItems: 'center',
54+
backgroundColor: colors.cardStrong,
55+
borderColor: colors.line,
56+
borderRadius: 8,
57+
borderWidth: 1,
58+
flex: 1,
59+
height: 56,
60+
justifyContent: 'center',
61+
},
62+
dragEndpointLabel: {
63+
color: colors.text,
64+
fontSize: 13,
65+
fontWeight: '700',
66+
},
4767
});
4868
}

packages/ad-replay/src/internal/__tests__/plan-digest.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,32 @@ test('computeReplayPlanDigest changes when target evidence consumed before actio
118118
}
119119
});
120120

121+
test('computeReplayPlanDigest binds both entries of multi-target evidence', () => {
122+
const evidence = {
123+
role: 'view',
124+
ancestry: [],
125+
sibling: 0,
126+
viewportOrder: 0,
127+
verification: 'verified' as const,
128+
};
129+
const targetEvidences = {
130+
source: { ...evidence, id: 'source' },
131+
destination: { ...evidence, id: 'destination' },
132+
};
133+
const original = digestFor([action({ targetEvidences })]);
134+
assert.notEqual(
135+
original,
136+
digestFor([
137+
action({
138+
targetEvidences: {
139+
...targetEvidences,
140+
destination: { ...targetEvidences.destination, id: 'different-destination' },
141+
},
142+
}),
143+
]),
144+
);
145+
});
146+
121147
test('computeReplayPlanDigest never changes based on unsubstituted ${VAR} text (variable VALUES never affect the digest)', () => {
122148
// The digest is computed over the still-unsubstituted action text; --env
123149
// values are resolved later, at invocation time, so two runs with

packages/ad-replay/src/internal/plan-digest.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ function canonicalizeAction(
5959
flags: action.flags ?? {},
6060
runtime: action.runtime ?? null,
6161
targetEvidence: action.targetEvidence ?? null,
62+
targetEvidences: action.targetEvidences ?? null,
6263
source: { path: sourcePath, line },
6364
};
6465
}

0 commit comments

Comments
 (0)