Skip to content

Commit 413da76

Browse files
fix(intro-screen-native): ignore the trailing drag a settled scroll emits
The Android IntroScreen flow failed at its last assertion: after re-entering the page, NEXT moved slide 2 -> 3 and the attribute correctly became 3, then immediately flipped to 1, so "Active slide: 3" was never visible. The failure video shows the value on screen going 2 -> 3 -> 1 with no input in between. Cause: once the programmatic scroll to slide 3 settles, Android's list emits a SECOND onScrollBeginDrag + onMomentumScrollEnd pair, and that momentum event carries the offset the list moved AWAY from (0). The existing pendingScrollTarget guard had already been cleared by the first, legitimate momentum end, and wasUserDragging was true because the settling list — not a finger — began the drag. So the event was treated as a swipe back to slide 1 and rewrote the attribute. This is the same class of bug as the earlier phantom-momentum fix, one step later in the sequence. Fix: remember the slide a programmatic scroll settled on, and ignore a momentum end that moves more than one page from activeIndex while that memory is set. The list is pagingEnabled, so a genuine drag moves exactly one page; a multi-page jump right after a scroll settled can only be that scroll's tail. One-page swipes, including a swipe straight after a programmatic scroll, still report normally (covered by existing tests). Regression test added; it fails with setValue receiving [3, 1] and a spurious onSlideChange(0, 2) when the guard is removed. Revert "fix(e2e): scroll the alphabet-index target into view before tapping it" This reverts commit 31e9fd78c2fb70fb0e88b33b2d9bf8a983c4db51. fix(e2e): scroll the alphabet-index target into view before tapping it FloatingActionButton.yaml failed on iOS with Tap on "F"... COMPLETED Assert that "Floating action button" is visible... FAILED then passed on retry with the same build. The flow already had the 10s extendedWaitUntil added in 20d3d52, and that wait is what timed out — so this is not the render race that commit fixed; nothing was going to change in second 11 and a longer timeout would not have helped. extendedWaitUntil: visible requires the element to be IN THE VIEWPORT, not merely present in the hierarchy. Tapping the index scrolls to the letter's section, and under F the entries are Feedback then Floating action button; if the scroll settles with Feedback at the bottom edge, the target is rendered just below the fold and `visible` stays false forever. The retry's scroll landed a few pixels differently and it passed. Use scrollUntilVisible instead: it succeeds immediately when the element is already in view and scrolls to it when it is not, so it covers both the original render race and the off-screen case. centerElement keeps the subsequent tap away from the screen edge. Applied to the 55 flows whose index letter hosts more than one widget entry (B, C, F, I, L, P, R, S) — the case where the target can be pushed below the fold. The 16 flows on a sole-entry letter keep the cheaper wait. Verified: all 82 flows still parse, all 71 index taps remain guarded (55 scrollUntilVisible + 16 extendedWaitUntil, 0 unguarded), and the pinned 2.6.1 binary accepts every field used — confirmed it rejects an unknown property, so that parse is a real schema check. ci(e2e): archive maestro's debug output for failed flows Maestro writes a debug bundle per run — failure screenshot, maestro.log (which records the element Bounds each selector matched) and commands-<flow>.json — to ~/.maestro/tests/<timestamp>/. That is outside the workspace, so none of it was ever uploaded, and it is exactly what distinguishes "element never rendered" from "element rendered off-screen below the fold" when an assertVisible/extendedWaitUntil fails. Without it those failures can only be diagnosed by inference. Redirect it into the workspace with --debug-output (+ --flatten-debug-output, which its own help text recommends for CI) one dir per flow, and archive it. Kept only for FAILED flows, mirroring the existing video policy, so a green shard uploads nothing — each bundle is ~300KB. Also gitignore maestro/debug and maestro/videos: both are produced into the workspace by local runs too. The existing *.log rule covered maestro.log but not the bundle's screenshots or commands JSON. Verified --debug-output and --flatten-debug-output against the pinned 2.6.1 binary, and drove run_tests with a stub to confirm a failing flow's bundle is kept while a passing flow's is discarded. fix(maestro): recover from iOS driver/accessibility faults instead of hanging A shard could die with, and never recover from: Exception in thread "main" UnknownFailure(errorResponse=Request for viewHierarchy failed, code: 500, body: {"errorMessage":"Error getting element frame kAXErrorInvalidUIElement","code":"internal"}) Traced through the pinned 2.6.1 jars, this is unrecoverable by design. XCTestDriverClient.handleExceptions maps a 500 to a typed error only for four known message strings and otherwise throws UnknownFailure; XCTestIOSDevice.execute catches only AppCrash/OperationTimeout/Unreachable, and IOSDriver.runDeviceCall catches only the matching IOSDeviceErrors. UnknownFailure matches none, so it escapes to main — hence "Exception in thread main" rather than a failed step, and hence a flow-level `retry:` block cannot help: the CLI is already dead, no command failed. kAXError* means the accessibility element reference is stale, so every later query against that driver fails identically. Recovery therefore has to live in the shell. Add run_maestro(), which: - runs `maestro test` under a wall-clock watchdog (MAESTRO_FLOW_TIMEOUT, 480s) so a JVM that died in main but is held open by non-daemon threads can't eat the 30-minute job budget, and - classifies a FAILED run as infrastructure (exit 90) when the output carries a driver/accessibility signature, so callers can tell "the device broke" from "the widget is wrong". run_tests then resets the device and gives the flow one clean attempt with a freshly reinstalled driver, instead of recording a widget failure and marching every remaining flow through the same wedged driver. All three call sites (flows, smoke check, retries) now go through run_maestro, and the duplicated device-reset branches collapse into reset_device(). Only non-zero exits are reclassified, so a flow that passed after Maestro's own internal retry logged such an error still counts as a pass. fix(maestro): actually apply the iOS driver startup timeout MAESTRO_DRIVER_STARTUP_TIMEOUT was passed to maestro as `--env`, but LocalXCTestInstaller.getStartupTimeout() reads it with System.getenv() — `--env` only fills Maestro's per-flow variable map, which that function never consults. So every value we passed was silently ignored and the Kotlin runCatching kept falling back to its built-in 120000 default. Export it instead (and raise it to 240000), and drop the three no-op `--env MAESTRO_DRIVER_STARTUP_TIMEOUT` arguments. Also stop reinstalling the XCUITest driver once per flow. Maestro's TestCommand.reinstallDriver defaults to true, so each `maestro test` process runs restartXCTestRunner (uninstall + install + start) — and that install is the window that throws IOSDriverTimeoutException on a 3-vCPU macOS runner. The smoke check still installs the driver fresh at the start of every shard and retries still reinstall via restart_simulator, so only the first-pass flows reuse it. MAESTRO_REUSE_DRIVER=false restores the old behaviour.
1 parent 238a920 commit 413da76

5 files changed

Lines changed: 275 additions & 16 deletions

File tree

.github/actions/archive-test-results/action.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,21 @@ runs:
6767
path: packages/pluggableWidgets/**/artifacts/
6868
if-no-files-found: ignore
6969

70+
# Maestro's own debug bundle per FAILED flow: the failure screenshot, maestro.log (which
71+
# records the element Bounds each selector matched) and commands-<flow>.json. This is what
72+
# separates "element never rendered" from "element rendered off-screen below the fold" on
73+
# an assertVisible/extendedWaitUntil failure. It defaults to ~/.maestro/tests/<timestamp>/,
74+
# outside the workspace, so it was never uploaded and those failures had to be diagnosed by
75+
# inference; helpers.sh now redirects it here with --debug-output and keeps only the
76+
# failures' bundles, same policy as the videos below.
77+
- name: Archive maestro debug output
78+
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7
79+
if: ${{ !cancelled() }}
80+
with:
81+
name: ${{ inputs.platform }}-debug-${{ inputs.test-type }}
82+
path: ${{ inputs.workspace-path }}/maestro/debug/**
83+
if-no-files-found: ignore
84+
7085
# Per-flow videos are recorded for every flow but kept only for FAILED flows (passing
7186
# clips are deleted in helpers.sh to save storage), so on a fully-green shard this dir is
7287
# empty and the upload is a no-op (if-no-files-found: ignore).

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ mendixProject
2020
**/tests/testProject
2121
**/pluggableWidgets/**/artifacts
2222

23+
# Per-flow e2e failure artifacts, produced into the workspace so CI can upload them
24+
# (helpers.sh). Local runs leave them behind too; they are never committed. *.log above
25+
# already covers maestro/debug/*/maestro.log, but not its screenshots or commands JSON.
26+
maestro/videos
27+
maestro/debug
28+
2329
.pnpm-debug.log*
2430
.pnpm-store
2531
.pnpmfile.cjs

maestro/helpers/helpers.sh

Lines changed: 201 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@ RETRY_DELAY=10
1010
# CI emulators/simulators are slower, but 5 min (300000) was the per-attempt multiplier that
1111
# amplified broken-build runs to ~25 min. 2 min is generous for the driver to start while
1212
# keeping the worst-case failure time bounded.
13-
MAESTRO_DRIVER_STARTUP_TIMEOUT=120000
13+
#
14+
# MUST be `export`ed. Maestro reads this with System.getenv("MAESTRO_DRIVER_STARTUP_TIMEOUT")
15+
# (LocalXCTestInstaller.getStartupTimeout), i.e. from the PROCESS ENVIRONMENT — it is not a
16+
# flow variable. Passing it as `--env` only populates Maestro's per-flow variable map, which
17+
# getStartupTimeout never consults, so the parse failed and the Kotlin runCatching fell back
18+
# to its built-in 120000 default. Every `--env MAESTRO_DRIVER_STARTUP_TIMEOUT=...` we passed
19+
# was silently ignored; raising the number here had no effect until it became an env var.
20+
export MAESTRO_DRIVER_STARTUP_TIMEOUT=240000
1421

1522
# --- Per-flow video recording (BrowserStack-style debugging) ----------------------------
1623
# We record a screen video around every flow, then KEEP it only if the flow FAILS and DELETE
@@ -78,6 +85,150 @@ stop_recording() {
7885
REC_FILE=""
7986
}
8087

88+
# --- Driver/accessibility failure handling ----------------------------------------------
89+
#
90+
# Some iOS failures are NOT test failures — they are the XCUITest driver or the simulator's
91+
# accessibility server breaking, and Maestro cannot recover from them by design:
92+
#
93+
# Error: Exception in thread "main" UnknownFailure(errorResponse=Request for viewHierarchy
94+
# failed, code: 500, body: {"errorMessage":"Error getting element frame
95+
# kAXErrorInvalidUIElement","code":"internal"})
96+
#
97+
# Traced through the pinned 2.6.1 jars: XCTestDriverClient.handleExceptions maps a 500 body to
98+
# a specific error only for four known strings ("Lost connection to the application…",
99+
# "Application … is not running", "Error getting main window…") and otherwise throws
100+
# XCUITestServerError$UnknownFailure. XCTestIOSDevice.execute catches only AppCrash /
101+
# OperationTimeout / Unreachable, and IOSDriver.runDeviceCall likewise catches only
102+
# IOSDeviceErrors Unreachable / AppCrash / OperationTimeout. UnknownFailure matches none of
103+
# them, so it propagates uncaught to main — which is why the message says "Exception in thread
104+
# main" rather than a failed step, and why a flow-level `retry:` block cannot catch it: the CLI
105+
# is already dead, no command failed. kAXError* means the accessibility element reference went
106+
# stale, so every subsequent query against that driver fails the same way.
107+
#
108+
# So recovery has to happen HERE, in the shell, on two axes:
109+
# 1. bound the wall clock, because a died-in-main JVM does not reliably exit (non-daemon
110+
# threads keep it alive) and an unbounded hang eats the whole job timeout; and
111+
# 2. reset the DEVICE and retry, instead of recording it as a widget failure and marching
112+
# the remaining flows through the same wedged driver.
113+
114+
# Wall-clock cap for one `maestro test` invocation. A hang breaker, not a performance budget:
115+
# Precondition allows 60s × 3 attempts and the slowest widget flows add a couple of minutes on
116+
# a 3-vCPU runner, so a healthy flow stays well under this while a wedged driver is cut off.
117+
#
118+
# Sized against the widget-test jobs' timeout-minutes: 30 (NativePipeline.yml). At 480s a shard
119+
# survives ~2 hung flows and still has time to report and upload artifacts; the whole point is
120+
# that the shard fails with videos/logs attached instead of being killed by GitHub mid-upload,
121+
# which is what leaves you with no evidence at all.
122+
MAESTRO_FLOW_TIMEOUT="${MAESTRO_FLOW_TIMEOUT:-480}"
123+
124+
# run_maestro returns this (rather than maestro's own exit code) when the run died from a
125+
# driver/accessibility fault or the watchdog had to kill it. 90 is outside the 0-1 range
126+
# maestro itself uses and below the 128+N signal range.
127+
MAESTRO_INFRA_EXIT=90
128+
129+
# Signatures that mean "the device/driver broke", not "the widget is wrong". Deliberately
130+
# narrow: a real assertion failure prints "Assertion is false" and exits 1 through the normal
131+
# path, so it matches none of these.
132+
# kAXError… accessibility server returned a stale/invalid element
133+
# UnknownFailure the unmapped-500 case above
134+
# Exception in thread "main" the CLI died instead of reporting a result
135+
# IOSDriverTimeoutException driver never came up
136+
# Failed to connect … driver port never opened
137+
MAESTRO_INFRA_PATTERNS='kAXError|UnknownFailure|Exception in thread "main"|IOSDriverTimeoutException|Failed to connect to 127\.0\.0\.1'
138+
139+
# --- Per-flow debug output ---------------------------------------------------------------
140+
# Maestro writes a debug bundle per run to ~/.maestro/tests/<timestamp>/ containing the
141+
# failure screenshot, maestro.log (which records the element Bounds it matched against) and
142+
# commands-<flow>.json. That is exactly what distinguishes "element never rendered" from
143+
# "element rendered off-screen below the fold" on an `assertVisible`/`extendedWaitUntil`
144+
# failure — and it lived outside the workspace, so none of it was ever uploaded and every
145+
# such failure had to be diagnosed by inference.
146+
#
147+
# Redirect it into the workspace with --debug-output so it can be archived, one dir per flow
148+
# (--flatten-debug-output drops the per-run timestamp subdir, which is what its help text
149+
# recommends for CI). Kept only for FAILED flows, mirroring the video policy, since a green
150+
# shard's bundles are ~300KB × every flow of noise.
151+
DEBUG_OUTPUT_DIR="${DEBUG_OUTPUT_DIR:-$PWD/maestro/debug}"
152+
153+
# run_maestro <yaml_or_flow> [extra maestro args...]
154+
#
155+
# Runs `maestro test` under a watchdog, streaming output live (CI needs to see progress) while
156+
# also capturing it so we can classify the failure afterwards. Sets DEBUG_RUN_DIR for the
157+
# caller, so a failing flow's debug bundle can be kept and a passing one's discarded.
158+
run_maestro() {
159+
local flow="$1"
160+
shift
161+
local log_file
162+
log_file="$(mktemp -t maestro-run)"
163+
164+
DEBUG_RUN_DIR="$DEBUG_OUTPUT_DIR/${PLATFORM}-$(basename "${flow%.yaml}" | tr -c 'A-Za-z0-9._-' '_')"
165+
rm -rf "$DEBUG_RUN_DIR"
166+
mkdir -p "$DEBUG_RUN_DIR"
167+
set -- --debug-output "$DEBUG_RUN_DIR" --flatten-debug-output "$@"
168+
169+
# Process substitution rather than a `| tee` pipeline: with a pipeline $! is tee's PID and we
170+
# would have no handle on the JVM to kill. maestro's bin script ends in `exec "$JAVACMD"`, so
171+
# $! is the JVM itself. Its own children (XCTRunner, xcodebuild) can outlive the kill, but the
172+
# infra path follows up with reset_device, which on iOS runs cleanup_xctest_processes and
173+
# pkills exactly those.
174+
"$HOME/.local/bin/maestro/bin/maestro" test "$@" \
175+
--env APP_ID="$APP_ID" --env PLATFORM="$PLATFORM" "$flow" \
176+
> >(tee "$log_file") 2>&1 &
177+
local maestro_pid=$!
178+
179+
(
180+
local waited=0
181+
while [ "$waited" -lt "$MAESTRO_FLOW_TIMEOUT" ]; do
182+
kill -0 "$maestro_pid" 2>/dev/null || exit 0
183+
sleep 5
184+
waited=$((waited + 5))
185+
done
186+
echo "::warning::maestro exceeded ${MAESTRO_FLOW_TIMEOUT}s on $(basename "$flow") — killing it (hung driver)"
187+
kill -TERM "$maestro_pid" 2>/dev/null || true
188+
sleep 10
189+
kill -9 "$maestro_pid" 2>/dev/null || true
190+
) &
191+
local watchdog_pid=$!
192+
193+
wait "$maestro_pid"
194+
local status=$?
195+
kill "$watchdog_pid" 2>/dev/null || true
196+
wait "$watchdog_pid" 2>/dev/null || true
197+
198+
# Let tee drain before reading the log; it is reaped asynchronously from the substitution.
199+
sleep 1
200+
201+
# Only reclassify a FAILED run: a flow that exited 0 passed, whatever its log happens to
202+
# contain (Maestro logs a retried command's error inline, so a passing flow's output can
203+
# legitimately mention a driver error it already recovered from).
204+
# status >128 means the watchdog signalled it, i.e. it hung — infrastructure by definition.
205+
if [ "$status" -ne 0 ] &&
206+
{ [ "$status" -gt 128 ] || grep -qE "$MAESTRO_INFRA_PATTERNS" "$log_file" 2>/dev/null; }; then
207+
rm -f "$log_file"
208+
return "$MAESTRO_INFRA_EXIT"
209+
fi
210+
rm -f "$log_file"
211+
return "$status"
212+
}
213+
214+
# Drop the last run's debug bundle. Called on pass so only failing flows' bundles survive to
215+
# be uploaded; a retry that passes also clears the earlier attempt's, matching the videos.
216+
discard_debug_output() {
217+
[ -n "${DEBUG_RUN_DIR:-}" ] || return 0
218+
rm -rf "$DEBUG_RUN_DIR"
219+
DEBUG_RUN_DIR=""
220+
}
221+
222+
# Reset the layer that actually flakes: on iOS restart the simulator (re-runs prepare_ios.sh,
223+
# re-establishing a clean XCUITest driver); on Android just re-confirm the emulator is up.
224+
reset_device() {
225+
if [ "$PLATFORM" == "android" ]; then
226+
ensure_emulator_ready
227+
else
228+
restart_simulator
229+
fi
230+
}
231+
81232
# Function to clean up stale XCTest processes (iOS only)
82233
cleanup_xctest_processes() {
83234
if [ "$PLATFORM" != "ios" ]; then
@@ -184,6 +335,26 @@ ensure_emulator_ready() {
184335
done
185336
}
186337

338+
# Extra flags for the per-flow `maestro test` runs (NOT the smoke check, NOT retries).
339+
#
340+
# Maestro reinstalls the driver on EVERY invocation by default (TestCommand.reinstallDriver
341+
# defaults to true → XCTestDriverClient.restartXCTestRunner uninstalls, reinstalls, then
342+
# starts). Since each flow is its own `maestro test` process, a shard pays that full
343+
# uninstall+install+launch once per flow, and that install is exactly the window that throws
344+
# IOSDriverTimeoutException on a 3-vCPU macOS runner.
345+
#
346+
# The smoke check runs WITH reinstall, so every shard starts from a freshly installed driver
347+
# and the first-pass flows then reuse it. Every RECOVERY path deliberately omits this flag and
348+
# so reinstalls: the post-fault retry in run_tests, and rerun_failed_tests (which also calls
349+
# reset_device). That is the point — if the driver is what broke, reusing it is the last thing
350+
# we want. Set MAESTRO_REUSE_DRIVER=false to reinstall per flow everywhere.
351+
MAESTRO_REUSE_DRIVER="${MAESTRO_REUSE_DRIVER:-true}"
352+
flow_driver_args() {
353+
if [ "$PLATFORM" == "ios" ] && [ "$MAESTRO_REUSE_DRIVER" = "true" ]; then
354+
echo "--no-reinstall-driver"
355+
fi
356+
}
357+
187358
# Function to run tests
188359
run_tests() {
189360
local test_files=("$@")
@@ -194,10 +365,32 @@ run_tests() {
194365
set_status_bar
195366
fi
196367
start_recording "$(basename "${yaml_test_file%.yaml}")"
197-
if $HOME/.local/bin/maestro/bin/maestro test --env APP_ID=$APP_ID --env PLATFORM=$PLATFORM --env MAESTRO_DRIVER_STARTUP_TIMEOUT=$MAESTRO_DRIVER_STARTUP_TIMEOUT "$yaml_test_file"; then
368+
run_maestro "$yaml_test_file" $(flow_driver_args)
369+
local status=$?
370+
if [ "$status" -eq 0 ]; then
198371
echo "✅ Test passed: $yaml_test_file"
199372
stop_recording discard
373+
discard_debug_output
200374
passed_tests+=("$yaml_test_file")
375+
elif [ "$status" -eq "$MAESTRO_INFRA_EXIT" ]; then
376+
# The driver/accessibility server broke rather than the widget misbehaving, so this run
377+
# says nothing about the flow. Reset the device and give it ONE clean attempt right here,
378+
# with a freshly installed driver (no --no-reinstall-driver), before deciding anything.
379+
# Without this the wedged driver would fail this flow AND every flow after it.
380+
echo "::warning::Driver/device fault on $(basename "$yaml_test_file") (not a test failure) — resetting device and retrying once"
381+
stop_recording keep
382+
reset_device
383+
start_recording "$(basename "${yaml_test_file%.yaml}")"
384+
if run_maestro "$yaml_test_file"; then
385+
echo "✅ Test passed after device reset: $yaml_test_file"
386+
stop_recording discard
387+
discard_debug_output
388+
passed_tests+=("$yaml_test_file")
389+
else
390+
echo "❌ Test failed: $yaml_test_file"
391+
stop_recording keep
392+
failed_tests+=("$yaml_test_file")
393+
fi
201394
else
202395
echo "❌ Test failed: $yaml_test_file"
203396
stop_recording keep
@@ -228,22 +421,17 @@ smoke_check() {
228421
set_status_bar
229422
fi
230423
start_recording "smoke-attempt-$attempt"
231-
if $HOME/.local/bin/maestro/bin/maestro test --env APP_ID=$APP_ID --env PLATFORM=$PLATFORM --env MAESTRO_DRIVER_STARTUP_TIMEOUT=$MAESTRO_DRIVER_STARTUP_TIMEOUT "$script_dir/Smoke.yaml"; then
424+
if run_maestro "$script_dir/Smoke.yaml"; then
232425
echo "✅ Smoke check passed — running widget flows."
233426
stop_recording discard
427+
discard_debug_output
234428
return 0
235429
fi
236430
# Keep this attempt's clip — a launch crash / blank screen here is exactly what we want to see.
237431
stop_recording keep
238432
if [ "$attempt" -lt "$max_attempts" ]; then
239433
echo "⚠️ Smoke check attempt $attempt failed — resetting driver/simulator and retrying once."
240-
# Reset the layer that actually flakes: on iOS restart the sim (re-runs prepare_ios.sh,
241-
# re-establishing the Maestro driver); on Android just re-confirm the emulator is up.
242-
if [ "$PLATFORM" == "android" ]; then
243-
ensure_emulator_ready
244-
else
245-
restart_simulator
246-
fi
434+
reset_device
247435
fi
248436
attempt=$((attempt + 1))
249437
done
@@ -260,17 +448,14 @@ rerun_failed_tests() {
260448
for yaml_test_file in "${retry_failed_tests[@]}"; do
261449
retry_count=$((retry_count + 1))
262450
echo "🧪 Retrying test $retry_count/$total_retries: $(basename "$yaml_test_file")"
263-
if [ "$PLATFORM" == "android" ]; then
264-
ensure_emulator_ready
265-
else
266-
restart_simulator
267-
fi
451+
reset_device
268452
local attempt=0
269453
while [ $attempt -lt $MAX_RETRIES ]; do
270454
start_recording "$(basename "${yaml_test_file%.yaml}")"
271-
if $HOME/.local/bin/maestro/bin/maestro test --env APP_ID=$APP_ID --env PLATFORM=$PLATFORM --env MAESTRO_DRIVER_STARTUP_TIMEOUT=$MAESTRO_DRIVER_STARTUP_TIMEOUT "$yaml_test_file"; then
455+
if run_maestro "$yaml_test_file"; then
272456
echo "✅ Test passed: $yaml_test_file"
273457
stop_recording discard
458+
discard_debug_output
274459
passed_tests+=("$yaml_test_file")
275460
break
276461
else

packages/pluggableWidgets/intro-screen-native/src/SwipeableContainer.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ export const SwipeableContainer = (props: SwipeableContainerProps): ReactElement
8080
// wherever the user left it. Used to recognise momentum events that contradict the
8181
// scroll we just asked for; see onMomentumScrollEnd.
8282
const pendingScrollTarget = useRef<number | null>(null);
83+
// Slide the last programmatic scroll settled on. Android can follow that scroll with a
84+
// second drag+momentum pair reporting the offset the list LEFT, which looks exactly like
85+
// a user swipe back; see onMomentumScrollEnd.
86+
const settledScrollTarget = useRef<number | null>(null);
8387

8488
const rtlSafeIndex = useCallback(
8589
(i: number): number => (isAndroidRTL ? props.slides.length - 1 - i : i),
@@ -91,6 +95,7 @@ export const SwipeableContainer = (props: SwipeableContainerProps): ReactElement
9195
setActiveIndex(pageNum);
9296
if (flashList && flashList.current) {
9397
pendingScrollTarget.current = pageNum;
98+
settledScrollTarget.current = null;
9499
flashList.current.scrollToOffset({
95100
offset: rtlSafeIndex(pageNum) * width,
96101
animated
@@ -355,6 +360,10 @@ export const SwipeableContainer = (props: SwipeableContainerProps): ReactElement
355360
if (pendingTarget !== null) {
356361
if (newIndex === pendingTarget) {
357362
pendingScrollTarget.current = null;
363+
// Remember where the scroll landed: on Android the settling list can emit a
364+
// SECOND drag + momentum pair afterwards, and that one carries the offset it
365+
// moved away from.
366+
settledScrollTarget.current = pendingTarget;
358367
}
359368
return;
360369
}
@@ -363,6 +372,17 @@ export const SwipeableContainer = (props: SwipeableContainerProps): ReactElement
363372
return;
364373
}
365374

375+
// The list is pagingEnabled, so one drag moves exactly one page. A momentum end that
376+
// jumps more than a page right after a programmatic scroll settled is therefore the
377+
// tail of that scroll, reporting the offset it moved AWAY from — not a swipe. Android
378+
// emits it with the drag flag set (the settling list, not a finger, starts the drag),
379+
// so wasUserDragging cannot tell the two apart, and trusting it rewrote the attribute
380+
// to the slide being left: pressing NEXT on slide 2 wrote 3, then 1. A one-page swipe
381+
// still reports normally.
382+
if (settledScrollTarget.current !== null && Math.abs(newIndex - activeIndex) > 1) {
383+
return;
384+
}
385+
366386
// Only a user swipe reports a change here. Programmatic scrolls (initial positioning,
367387
// Next/Previous/pagination) already fired onSlideChange, so re-firing would double
368388
// count the change and, on remount, emit a spurious change back to slide 1.

0 commit comments

Comments
 (0)