Fix mobile sidebar swipe and drag failures (#2709)

* fix(app): prevent text selection during Android swipes

Gesture interception did not cancel the native view where the touch began, leaving Android's pending text-selection action alive.

* fix(app): restore iOS sidebar close swipes

Fabric could fail to attach close handlers when their target lived inside retained but display-hidden panel content. Keep a registered native gesture host mounted outside the presentation wrapper so close swipes work over nested lists without activating rows.

* fix(app): recover sidebar drags after cancellation

Cancelled native drags could leave a lifted row and the shared outer list locked. Scope terminal cleanup to the owning gesture session and release the lock on cancellation or unmount.

* test(app): clean up partial drag fixtures
This commit is contained in:
Mohamed Boudra
2026-08-01 04:05:54 +08:00
committed by GitHub
parent eb1c7def54
commit 3778d0ad7b
11 changed files with 966 additions and 22 deletions
+25
View File
@@ -3,6 +3,31 @@
This directory contains local mobile UI flows. Keep flows small enough that a
failure screenshot proves the intended behavior, not just that the app launched.
## iOS Sidebar Close Regression
`ios-sidebar-close-regression.yaml` exercises close swipes over a semantic header
and the nested workspace list without activating the content below the swipe.
Start the `sh.paseo.debug` dev client against the intended Metro server first;
the flow preserves that running connection.
```bash
maestro test packages/app/maestro/ios-sidebar-close-regression.yaml --udid <simulator-udid>
```
## iOS Sidebar Drag Cancellation Regression
`test-sidebar-drag-cancellation-ios.sh` creates three temporary projects and
uses real native slow swipes to cover successful reorder, a second touch during
the drop spring, horizontal close cancellation, outer-list scroll recovery, and
a subsequent reorder. The harness removes its daemon projects and local fixture
directories on exit.
Start the debug dev client against the intended Metro server, then run:
```bash
bash packages/app/maestro/test-sidebar-drag-cancellation-ios.sh
```
## New Workspace Android Flow
Use these files when debugging or extending workspace creation on Android:
@@ -0,0 +1,40 @@
appId: sh.paseo.debug
---
# Establish History with the sidebar closed. The host filter then proves a close
# swipe did not activate the row below it.
- tapOn:
id: "menu-button"
- tapOn:
id: "sidebar-sessions"
- assertVisible:
id: "sessions-host-filter-trigger"
# A close swipe over a semantic header row must close without navigating.
- swipe:
direction: RIGHT
duration: 300
- assertVisible:
id: "sidebar-schedules"
- swipe:
start: "85%,19%"
end: "10%,19%"
duration: 300
- assertNotVisible:
id: "sidebar-schedules"
- assertVisible:
id: "sessions-host-filter-trigger"
# A close swipe over the nested workspace list must beat horizontal list intent.
- swipe:
direction: RIGHT
duration: 300
- assertVisible:
id: "sidebar-project-workspace-list-scroll"
- swipe:
start: "85%,46%"
end: "10%,46%"
duration: 300
- assertNotVisible:
id: "sidebar-project-workspace-list-scroll"
- assertVisible:
id: "sessions-host-filter-trigger"
@@ -0,0 +1,105 @@
appId: ${PASEO_MAESTRO_APP_ID}
---
- launchApp:
clearState: true
- runFlow: flows/dev-client.yaml
- runFlow: flows/connect-direct-if-welcome.yaml
- extendedWaitUntil:
visible:
id: "menu-button"
timeout: 60000
- tapOn:
id: "menu-button"
- extendedWaitUntil:
visible:
id: "sidebar-project-list"
timeout: 10000
- extendedWaitUntil:
visible:
text: ".*${PASEO_MAESTRO_DRAG_A_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_A_NAME}, Project actions"
timeout: 30000
- extendedWaitUntil:
visible:
text: ".*${PASEO_MAESTRO_DRAG_B_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_B_NAME}, Project actions"
timeout: 30000
# A slow swipe stays inside the stationary slop long enough to arm the native
# long-press drag before it moves the second project above the first.
- assertVisible:
text: ".*${PASEO_MAESTRO_DRAG_A_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_A_NAME}, Project actions"
above:
text: ".*${PASEO_MAESTRO_DRAG_B_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_B_NAME}, Project actions"
- swipe:
from:
text: ".*${PASEO_MAESTRO_DRAG_B_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_B_NAME}, Project actions"
direction: UP
duration: 20000
waitToSettleTimeoutMs: 0
# A second touch during the drop spring must not cancel the committed reorder.
- tapOn:
text: ".*${PASEO_MAESTRO_DRAG_B_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_B_NAME}, Project actions"
- assertVisible:
text: ".*${PASEO_MAESTRO_DRAG_B_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_B_NAME}, Project actions"
above:
text: ".*${PASEO_MAESTRO_DRAG_A_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_A_NAME}, Project actions"
# Horizontal intent after the lift belongs to panel close. It must cancel the
# drag without committing another reorder or leaving the outer list locked.
- swipe:
from:
text: ".*${PASEO_MAESTRO_DRAG_B_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_B_NAME}, Project actions"
direction: LEFT
duration: 20000
- assertNotVisible:
id: "sidebar-project-list"
- tapOn:
id: "menu-button"
- extendedWaitUntil:
visible:
id: "sidebar-project-list"
timeout: 10000
- assertVisible:
text: ".*${PASEO_MAESTRO_DRAG_B_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_B_NAME}, Project actions"
above:
text: ".*${PASEO_MAESTRO_DRAG_A_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_A_NAME}, Project actions"
# Reaching a fixture at the bottom proves the nestable outer scroll was
# unlocked by cancellation.
- scrollUntilVisible:
element:
text: ".*${PASEO_MAESTRO_DRAG_Z_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_Z_NAME}, Project actions"
direction: DOWN
timeout: 30000
speed: 40
visibilityPercentage: 50
centerElement: true
- assertVisible:
text: ".*${PASEO_MAESTRO_DRAG_Z_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_Z_NAME}, Project actions"
- scrollUntilVisible:
element:
text: ".*${PASEO_MAESTRO_DRAG_A_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_A_NAME}, Project actions"
direction: UP
timeout: 30000
speed: 40
visibilityPercentage: 50
centerElement: true
# A real reorder after cancellation proves the list is not merely scrollable;
# its drag recognizer and semantic order update both recovered.
- swipe:
from:
text: ".*${PASEO_MAESTRO_DRAG_A_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_A_NAME}, Project actions"
direction: UP
duration: 20000
- assertVisible:
text: ".*${PASEO_MAESTRO_DRAG_A_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_A_NAME}, Project actions"
above:
text: ".*${PASEO_MAESTRO_DRAG_B_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_B_NAME}, Project actions"
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env bash
# iOS native regression harness for sidebar drag cancellation and recovery.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
FLOW_TEMPLATE="$REPO_ROOT/packages/app/maestro/sidebar-drag-cancellation-regression.yaml"
FLOW_TEMPLATE_DIR="$REPO_ROOT/packages/app/maestro"
OUT_DIR="/tmp/paseo-sidebar-drag-cancellation-$(date +%s)"
CLIENT_EXPORTS="$REPO_ROOT/packages/client/dist/daemon-client.js"
RELAY_EXPORTS="$REPO_ROOT/node_modules/@getpaseo/relay/dist/e2ee.js"
FIXTURE_ROOT=""
PROJECT_IDS_FILE="$OUT_DIR/project-ids.json"
export PASEO_MAESTRO_APP_ID="${PASEO_MAESTRO_APP_ID:-sh.paseo.debug}"
export PASEO_MAESTRO_DIRECT_ENDPOINT="${PASEO_MAESTRO_DIRECT_ENDPOINT:-127.0.0.1:6767}"
export PASEO_MAESTRO_DAEMON_WS_URL="${PASEO_MAESTRO_DAEMON_WS_URL:-ws://127.0.0.1:6767/ws}"
export PASEO_MAESTRO_DAEMON_HEALTH_URL="${PASEO_MAESTRO_DAEMON_HEALTH_URL:-http://127.0.0.1:6767/api/health}"
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing required command: $1" >&2
exit 1
fi
}
require_command git
require_command curl
require_command maestro
require_command node
require_command perl
require_command xcrun
if [ ! -f "$CLIENT_EXPORTS" ] || [ ! -f "$RELAY_EXPORTS" ]; then
echo "Missing client or relay build artifacts." >&2
echo "Run: npm run build:server" >&2
exit 1
fi
if ! curl --fail --silent --show-error --max-time 3 "$PASEO_MAESTRO_DAEMON_HEALTH_URL" >/dev/null; then
echo "Paseo daemon is unavailable at $PASEO_MAESTRO_DAEMON_HEALTH_URL" >&2
exit 1
fi
FIXTURE_ROOT="$(mktemp -d /tmp/paseo-sidebar-drag-fixture-XXXXXX)"
export PASEO_MAESTRO_DRAG_A_NAME="000-paseo-drag-a-$(basename "$FIXTURE_ROOT")"
export PASEO_MAESTRO_DRAG_B_NAME="001-paseo-drag-b-$(basename "$FIXTURE_ROOT")"
export PASEO_MAESTRO_DRAG_Z_NAME="zzz-paseo-drag-z-$(basename "$FIXTURE_ROOT")"
mkdir -p "$OUT_DIR/flows"
if [ -z "${PASEO_MAESTRO_IOS_UDID:-}" ]; then
export PASEO_MAESTRO_IOS_UDID
PASEO_MAESTRO_IOS_UDID="$({ xcrun simctl list devices booted -j || true; } | node -e '
let input = "";
process.stdin.on("data", (chunk) => (input += chunk));
process.stdin.on("end", () => {
const devices = Object.values(JSON.parse(input).devices ?? {}).flat();
const booted = devices.find((device) => device.state === "Booted");
if (booted?.udid) process.stdout.write(booted.udid);
});
')"
fi
if [ -z "$PASEO_MAESTRO_IOS_UDID" ]; then
echo "No booted iOS simulator found." >&2
exit 1
fi
render_flow() {
local source="$1"
local target="$2"
perl -0pe '
s/^appId: sh\.paseo$/appId: $ENV{PASEO_MAESTRO_APP_ID}/m;
s/\$\{PASEO_MAESTRO_APP_ID\}/$ENV{PASEO_MAESTRO_APP_ID}/g;
s/\$\{PASEO_MAESTRO_DIRECT_ENDPOINT\}/$ENV{PASEO_MAESTRO_DIRECT_ENDPOINT}/g;
s/\$\{PASEO_MAESTRO_DRAG_A_NAME\}/$ENV{PASEO_MAESTRO_DRAG_A_NAME}/g;
s/\$\{PASEO_MAESTRO_DRAG_B_NAME\}/$ENV{PASEO_MAESTRO_DRAG_B_NAME}/g;
s/\$\{PASEO_MAESTRO_DRAG_Z_NAME\}/$ENV{PASEO_MAESTRO_DRAG_Z_NAME}/g;
' "$source" > "$target"
}
for project_name in \
"$PASEO_MAESTRO_DRAG_A_NAME" \
"$PASEO_MAESTRO_DRAG_B_NAME" \
"$PASEO_MAESTRO_DRAG_Z_NAME"; do
project_path="$FIXTURE_ROOT/$project_name"
mkdir -p "$project_path"
git -C "$project_path" init >/dev/null
git -C "$project_path" checkout -b main >/dev/null 2>&1 || true
git -C "$project_path" config user.name "Paseo Maestro"
git -C "$project_path" config user.email "maestro@getpaseo.local"
printf '# Sidebar drag cancellation fixture\n' > "$project_path/README.md"
git -C "$project_path" add README.md
git -C "$project_path" commit -m "Initial commit" >/dev/null
done
cleanup() {
if [ -s "$PROJECT_IDS_FILE" ]; then
REPO_ROOT="$REPO_ROOT" PROJECT_IDS_FILE="$PROJECT_IDS_FILE" node --input-type=module <<'NODE' || true
import { readFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
import WebSocket from "ws";
const moduleUrl = pathToFileURL(`${process.env.REPO_ROOT}/packages/client/dist/daemon-client.js`).href;
const { DaemonClient } = await import(moduleUrl);
const projectIds = JSON.parse(await readFile(process.env.PROJECT_IDS_FILE, "utf8"));
const client = new DaemonClient({
url: process.env.PASEO_MAESTRO_DAEMON_WS_URL,
clientId: `maestro-sidebar-drag-cleanup-${Date.now()}`,
clientType: "cli",
webSocketFactory: (url, options) => new WebSocket(url, { headers: options?.headers }),
});
try {
await client.connect();
for (const projectId of projectIds) {
await client.removeProject(projectId).catch(() => undefined);
}
} finally {
await client.close().catch(() => undefined);
}
NODE
fi
rm -rf "$FIXTURE_ROOT"
}
trap cleanup EXIT
REPO_ROOT="$REPO_ROOT" FIXTURE_ROOT="$FIXTURE_ROOT" PROJECT_IDS_FILE="$PROJECT_IDS_FILE" node --input-type=module <<'NODE'
import { writeFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
import WebSocket from "ws";
const moduleUrl = pathToFileURL(`${process.env.REPO_ROOT}/packages/client/dist/daemon-client.js`).href;
const { DaemonClient } = await import(moduleUrl);
const projectNames = [
process.env.PASEO_MAESTRO_DRAG_A_NAME,
process.env.PASEO_MAESTRO_DRAG_B_NAME,
process.env.PASEO_MAESTRO_DRAG_Z_NAME,
];
const client = new DaemonClient({
url: process.env.PASEO_MAESTRO_DAEMON_WS_URL,
clientId: `maestro-sidebar-drag-setup-${Date.now()}`,
clientType: "cli",
webSocketFactory: (url, options) => new WebSocket(url, { headers: options?.headers }),
});
try {
await client.connect();
const projectIds = [];
for (const projectName of projectNames) {
const payload = await client.addProject(`${process.env.FIXTURE_ROOT}/${projectName}`);
if (payload.error || !payload.project) {
throw new Error(payload.error ?? `addProject returned no project for ${projectName}`);
}
projectIds.push(payload.project.id);
await writeFile(process.env.PROJECT_IDS_FILE, JSON.stringify(projectIds));
}
} finally {
await client.close().catch(() => undefined);
}
NODE
FLOW="$OUT_DIR/sidebar-drag-cancellation-regression.rendered.yaml"
render_flow "$FLOW_TEMPLATE" "$FLOW"
render_flow "$FLOW_TEMPLATE_DIR/flows/dev-client.yaml" "$OUT_DIR/flows/dev-client.yaml"
render_flow "$FLOW_TEMPLATE_DIR/flows/connect-direct-if-welcome.yaml" "$OUT_DIR/flows/connect-direct-if-welcome.yaml"
echo "Running sidebar drag cancellation regression on $PASEO_MAESTRO_IOS_UDID"
echo "Artifacts: $OUT_DIR"
(cd "$OUT_DIR" && maestro test "$FLOW" --udid "$PASEO_MAESTRO_IOS_UDID")