Skip to content

Commit 06d27de

Browse files
authored
test(gesture): assert pan duration in the iOS gesture-lab replay (#1901)
* test(gesture): assert pan duration in the iOS gesture-lab replay (#1584) The only replay exercising the `gesture pan` command class that regressed in #1562 asserted a counter, which stays green even if the requested duration collapses — nothing in CI could catch the regression coming back. Record an observed-duration bucket from a single-pointer Gesture.Pan's begin/end timestamps in GestureLab.tsx (iOS-only, so Android's raw-touch transform handling in the same shared component is untouched), render it as plain text, and assert it with a one-line wait in gesture-lab.ad. No runner protocol changes needed. * style: fix oxfmt line-wrap in GestureLab.tsx * ci(ios): run the pan-duration canary automatically on every PR gesture-lab.ad (and its new duration assertion) only runs under full:fixture-replays, which is currently dispatch-only in replays-manual.yml — the PR-triggered ios.yml lane runs the smoke tier, and replays-nightly.yml no longer carries device replays at all (#1781 A1). So the #1584 guard could not actually catch a regression automatically. Split the duration check into its own minimal, isolated replay (gesture-pan-duration.ad) and run it as a smoke-tier step in ios.yml, so it's cheap and doesn't depend on gesture-lab.ad's multi-touch commands, which stay full-tier only. * test: require pan recognition in duration canary
1 parent 1281cf3 commit 06d27de

5 files changed

Lines changed: 115 additions & 37 deletions

File tree

.github/workflows/ios.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,17 @@ jobs:
209209
pnpm gate build
210210
node --experimental-strip-types scripts/node-test-tmpdir.ts --test test/integration/smoke-ios-simulator-coverage.test.ts test/integration/smoke-ios-simulator.test.ts
211211
212+
# #1584: isolated, cheap automatic guard for the #1562 class of regression (iOS silently
213+
# ignoring a `gesture pan` duration). Split out of gesture-lab.ad, which stays full-tier
214+
# (dispatch-only via replays-manual.yml) because its multi-touch commands are a separate,
215+
# heavier concern. This is the only automatic lane this assertion runs in today.
216+
- name: Run gesture pan-duration smoke replay
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 test examples/test-app/replays/gesture-pan-duration.ad --udid "${{ steps.ios-simulator.outputs.simulator-udid }}" --retries 2 --artifacts-dir test/artifacts/replays-ios-gesture-pan-duration --report-junit test/artifacts/replays-ios-gesture-pan-duration.junit.xml
221+
pnpm clean:daemon
222+
212223
- name: Assert simulator automation preserved host focus
213224
if: ${{ always() }}
214225
run: |

examples/test-app/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,13 @@ two-pointer gesture changes all three semantic states. On Android, these checks
251251
are intentionally qualitative because recognizers can report non-exact centroid,
252252
scale, and rotation values for one simultaneous two-finger gesture.
253253

254+
`gesture-pan-duration.ad` is a separate, minimal iOS replay that asserts a single-pointer
255+
`gesture pan`'s requested duration is actually observed by the app (a bucketed
256+
`pan duration` status on the Home screen), rather than just that the gesture activated.
257+
It's split out of `gesture-lab.ad` so it can run as an automatic PR-tier check
258+
(`.github/workflows/ios.yml`) without depending on `gesture-lab.ad`'s multi-touch commands,
259+
which stay full-tier only.
260+
254261
To target a specific iOS simulator or an installed Expo development build, run the
255262
underlying command directly so global flags stay before replay inputs:
256263

examples/test-app/replays/gesture-lab.ad

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ wait "two-pointer pan activations 0" 5000
1010

1111
gesture pan 110 443 48 0 500
1212
wait "two-pointer pan activations 0" 5000
13+
wait text "pan duration >=400ms" 5000
1314

1415
gesture pan 110 443 48 0 500 --pointer-count 2
1516
wait "two-pointer pan activations 1" 5000
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Isolated pan-duration canary (#1584), split out of gesture-lab.ad so the smoke tier gets a
2+
# cheap automatic regression guard for #1562 without depending on gesture-lab.ad's multi-touch
3+
# commands (pinch/rotate/transform/two-pointer pan), which are a separate, heavier full-tier
4+
# concern.
5+
context platform=ios kind=simulator timeout=60000
6+
7+
env APP_TARGET="Agent Device Tester"
8+
env APP_URL=""
9+
10+
open "${APP_TARGET}" --relaunch --launch-url "${APP_URL}"
11+
wait "Gesture lab" 30000
12+
wait "gesture canary ready" 5000
13+
14+
gesture pan 110 443 48 0 500
15+
wait text "pan duration >=400ms" 5000
16+
17+
close

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

Lines changed: 79 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ type GestureCounts = {
3737
twoPointerPan: number;
3838
};
3939

40+
const panDurationBucketMs = 400;
41+
4042
type AndroidTouchStart = TransformState & {
4143
angle: number;
4244
centroidX: number;
@@ -65,8 +67,10 @@ export function GestureLab() {
6567
const [transform, setTransform] = useState<TransformState>(initialTransform);
6668
const [counts, setCounts] = useState<GestureCounts>(initialCounts);
6769
const [dragCompleted, setDragCompleted] = useState(false);
70+
const [panDurationStatus, setPanDurationStatus] = useState('pending');
6871
const transformRef = useRef<TransformState>(initialTransform);
6972
const gestureStartRef = useRef<TransformState>(initialTransform);
73+
const panDurationStartRef = useRef<number | undefined>(undefined);
7074
const androidTouchStartRef = useRef<AndroidTouchStart | undefined>(undefined);
7175
const legacyFlingDownRef = useRef(null);
7276
const legacyFlingLeftRef = useRef(null);
@@ -207,6 +211,33 @@ export function GestureLab() {
207211
legacyFlingUpRef,
208212
legacyFlingDownRef,
209213
];
214+
const panDurationGesture = Gesture.Pan()
215+
.minPointers(1)
216+
.maxPointers(1)
217+
.runOnJS(true)
218+
.simultaneousWithExternalGesture(
219+
twoPointerPanGesture,
220+
legacyPanRef,
221+
legacyPinchRef,
222+
legacyRotationRef,
223+
...legacyFlingRefs,
224+
)
225+
.onBegin(() => {
226+
panDurationStartRef.current = Date.now();
227+
})
228+
// `onEnd` only fires after the recognizer reached ACTIVE. `onFinalize` would also run for a
229+
// failed or cancelled pan, allowing a long-lived non-gesture to satisfy the duration canary.
230+
.onEnd(() => {
231+
const start = panDurationStartRef.current;
232+
panDurationStartRef.current = undefined;
233+
if (start === undefined) return;
234+
const durationMs = Date.now() - start;
235+
const bucket =
236+
durationMs >= panDurationBucketMs
237+
? `>=${panDurationBucketMs}ms`
238+
: `<${panDurationBucketMs}ms`;
239+
setPanDurationStatus(bucket);
240+
});
210241

211242
const androidTransformTarget = (
212243
<FlingGestureHandler
@@ -286,6 +317,46 @@ export function GestureLab() {
286317
pinchChanged ? 'yes' : 'no'
287318
}, rotate changed ${rotateChanged ? 'yes' : 'no'}`;
288319

320+
const targetView = (
321+
<View
322+
accessibilityLabel="Gesture test image"
323+
onTouchEnd={
324+
Platform.OS === 'android' ? () => (androidTouchStartRef.current = undefined) : undefined
325+
}
326+
onTouchMove={Platform.OS === 'android' ? handleAndroidTouchMove : undefined}
327+
onTouchStart={Platform.OS === 'android' ? handleAndroidTouchStart : undefined}
328+
style={styles.target}
329+
testID="gesture-target"
330+
>
331+
<Image
332+
accessibilityIgnoresInvertColors
333+
accessibilityLabel="Gesture test image"
334+
resizeMode="cover"
335+
source={{ uri: gestureImageUri }}
336+
style={[
337+
styles.image,
338+
{
339+
transform: [
340+
{ translateX: transform.offsetX },
341+
{ translateY: transform.offsetY },
342+
{ scale: transform.scale },
343+
{ rotate: `${rotationDegrees}deg` },
344+
],
345+
},
346+
]}
347+
testID="gesture-target-image"
348+
/>
349+
{androidTransformTarget}
350+
<GestureDetector gesture={twoPointerPanGesture}>
351+
<View
352+
accessibilityLabel="Exact two-pointer pan target"
353+
style={styles.twoPointerTarget}
354+
testID="two-pointer-pan-target"
355+
/>
356+
</GestureDetector>
357+
</View>
358+
);
359+
289360
return (
290361
<SectionCard
291362
subtitle={`Image target for pan, pinch, rotate, and fling. ${changeStatusLabel}`}
@@ -318,43 +389,11 @@ export function GestureLab() {
318389
drag completed {dragCompleted ? 'yes' : 'no'}
319390
</Text>
320391

321-
<View
322-
accessibilityLabel="Gesture test image"
323-
onTouchEnd={
324-
Platform.OS === 'android' ? () => (androidTouchStartRef.current = undefined) : undefined
325-
}
326-
onTouchMove={Platform.OS === 'android' ? handleAndroidTouchMove : undefined}
327-
onTouchStart={Platform.OS === 'android' ? handleAndroidTouchStart : undefined}
328-
style={styles.target}
329-
testID="gesture-target"
330-
>
331-
<Image
332-
accessibilityIgnoresInvertColors
333-
accessibilityLabel="Gesture test image"
334-
resizeMode="cover"
335-
source={{ uri: gestureImageUri }}
336-
style={[
337-
styles.image,
338-
{
339-
transform: [
340-
{ translateX: transform.offsetX },
341-
{ translateY: transform.offsetY },
342-
{ scale: transform.scale },
343-
{ rotate: `${rotationDegrees}deg` },
344-
],
345-
},
346-
]}
347-
testID="gesture-target-image"
348-
/>
349-
{androidTransformTarget}
350-
<GestureDetector gesture={twoPointerPanGesture}>
351-
<View
352-
accessibilityLabel="Exact two-pointer pan target"
353-
style={styles.twoPointerTarget}
354-
testID="two-pointer-pan-target"
355-
/>
356-
</GestureDetector>
357-
</View>
392+
{Platform.OS === 'ios' ? (
393+
<GestureDetector gesture={panDurationGesture}>{targetView}</GestureDetector>
394+
) : (
395+
targetView
396+
)}
358397

359398
<View style={styles.metrics} testID="gesture-metrics">
360399
<Text style={styles.metric} testID="gesture-canary-ready">
@@ -369,6 +408,9 @@ export function GestureLab() {
369408
<Text style={styles.metric} testID="gesture-two-pointer-pan-status">
370409
two-pointer pan activations {counts.twoPointerPan}
371410
</Text>
411+
<Text style={styles.metric} testID="gesture-pan-duration-status">
412+
pan duration {panDurationStatus}
413+
</Text>
372414
<Text style={styles.metric} testID="gesture-change-status">
373415
{changeStatusLabel}
374416
</Text>

0 commit comments

Comments
 (0)