diff --git a/.github/actions/maestro-ios/action.yml b/.github/actions/maestro-ios/action.yml index 782005fb4899..136adf73f539 100644 --- a/.github/actions/maestro-ios/action.yml +++ b/.github/actions/maestro-ios/action.yml @@ -45,6 +45,14 @@ runs: "https://github.com/software-mansion/sim-remote-releases/releases/download/softu/sim-remote-${TARGET}" chmod +x "$HOME/.local/bin/sim-remote" echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + # `softu` is a rolling tag and has been rebuilt without the shims before. + if ! "$HOME/.local/bin/sim-remote" install-shims --help >/dev/null 2>&1; then + echo "The sim-remote build at the 'softu' tag has no 'install-shims' command," >&2 + echo "so Maestro cannot drive a remote simulator. Rebuild the tag from a tree" >&2 + echo "that includes the Maestro shims (software-mansion/radon-cloud#143)." >&2 + exit 1 + fi - name: Acquire remote simulator shell: bash # Credentials come from the calling job's env: @@ -53,7 +61,21 @@ runs: # An unset SIM_ROUTER_URL secret arrives as an empty env var, which # would override the router URL baked into the binary — drop it. [ -n "${SIM_ROUTER_URL:-}" ] || unset SIM_ROUTER_URL - sim-remote login --timeout 900 + + sim-remote login --no-acquire + + # The router clamps a single acquire wait to 300s, so retry rather than + # asking for a longer timeout that is silently truncated. + for attempt in $(seq 1 8); do + if sim-remote acquire --timeout 300; then + exit 0 + fi + echo "Attempt $attempt: no fleet machine available yet, retrying..." + sleep 30 + done + + echo "Could not acquire a fleet machine" >&2 + exit 1 - name: Set up JDK 17 uses: actions/setup-java@v5 with: diff --git a/.github/workflow-scripts/maestro-ios.js b/.github/workflow-scripts/maestro-ios.js index 2859fd90988c..c7b547937cbc 100644 --- a/.github/workflow-scripts/maestro-ios.js +++ b/.github/workflow-scripts/maestro-ios.js @@ -61,11 +61,7 @@ function installAppOnSimulator(appPath, udid) { childProcess.execSync(`xcrun simctl install "${udid}" "${appPath}"`); } -// When running against a remote simulator through the sim-remote Maestro -// shims (`sim-remote install-shims` exports SIM_REMOTE_BIN), localhost inside -// the simulator is the remote host, not this machine — so the app cannot -// reach the local Metro server on its own. A reverse tunnel exposes the local -// Metro port to the simulator via localhost. +// A remote simulator's localhost is the remote host, so Metro needs a tunnel. function exposeMetroToSimulator(udid) { const simRemote = process.env.SIM_REMOTE_BIN; if (!simRemote) { @@ -79,6 +75,10 @@ function exposeMetroToSimulator(udid) { } function bringSimulatorInForeground() { + // Nothing to foreground on a remote simulator. + if (process.env.SIM_REMOTE_BIN) { + return; + } console.log('Bringing simulator in foreground'); childProcess.execSync('open -a simulator'); } @@ -116,18 +116,52 @@ function startVideoRecording(udid, currentAttempt) { return recordingProcess; } +// The movie is only written after SIGINT, so returning early truncates it. +const RECORDING_SHUTDOWN_TIMEOUT_MS = 30 * 1000; + function stopVideoRecording(recordingProcess) { if (!recordingProcess) { console.log("Passed a null recording process. Can't kill it"); - return; + return Promise.resolve(); } console.log(`Stop video record using pid: ${recordingProcess.pid}`); - recordingProcess.kill('SIGINT'); + if ( + recordingProcess.exitCode != null || + recordingProcess.signalCode != null + ) { + return Promise.resolve(); + } + + // Awaiting the exit is also what reaps the child: the flows run in a + // synchronous loop, so nothing else turns the event loop. + return new Promise(resolve => { + const done = () => { + clearTimeout(timer); + resolve(); + }; + const timer = setTimeout(() => { + console.log( + `Recorder ${recordingProcess.pid} did not exit in time, killing it`, + ); + recordingProcess.kill('SIGKILL'); + }, RECORDING_SHUTDOWN_TIMEOUT_MS); + timer.unref?.(); + + recordingProcess.once('exit', done); + recordingProcess.once('error', done); + recordingProcess.kill('SIGINT'); + }); } -function executeFlowWithRetries(appId, udid, flow, jsengine, currentAttempt) { +async function executeFlowWithRetries( + appId, + udid, + flow, + jsengine, + currentAttempt, +) { const recProcess = startVideoRecording(udid, currentAttempt); try { const timeout = 1000 * 60 * 10; // 10 minutes @@ -139,13 +173,19 @@ function executeFlowWithRetries(appId, udid, flow, jsengine, currentAttempt) { timeout, }); - stopVideoRecording(recProcess); + await stopVideoRecording(recProcess); } catch (error) { - stopVideoRecording(recProcess); + await stopVideoRecording(recProcess); if (currentAttempt < MAX_ATTEMPTS) { console.info(`Retrying flow: ${flow}`); - executeFlowWithRetries(appId, udid, flow, jsengine, currentAttempt + 1); + await executeFlowWithRetries( + appId, + udid, + flow, + jsengine, + currentAttempt + 1, + ); } else { console.error( `Failed to execute flow ${flow} after ${MAX_ATTEMPTS} attempts.`, @@ -155,18 +195,23 @@ function executeFlowWithRetries(appId, udid, flow, jsengine, currentAttempt) { } } -function executeFlows(appId, udid, maestroFlow, jsengine) { +async function executeFlows(appId, udid, maestroFlow, jsengine) { if (!fs.existsSync(maestroFlow) || !fs.lstatSync(maestroFlow).isDirectory()) { - executeFlowWithRetries(appId, udid, maestroFlow, jsengine, 1); + await executeFlowWithRetries(appId, udid, maestroFlow, jsengine, 1); return; } for (const file of fs.readdirSync(maestroFlow).sort()) { const filePath = `${maestroFlow.replace(/\/$/, '')}/${file}`; if (fs.lstatSync(filePath).isDirectory()) { - executeFlows(appId, udid, filePath, jsengine); + // Fragments pulled in via `runFlow`; they have no `launchApp` of their + // own and fail when run standalone. + if (file === 'helpers') { + continue; + } + await executeFlows(appId, udid, filePath, jsengine); } else if (file.endsWith('.yml') || file.endsWith('.yaml')) { - executeFlowWithRetries(appId, udid, filePath, jsengine, 1); + await executeFlowWithRetries(appId, udid, filePath, jsengine, 1); } } } @@ -201,7 +246,7 @@ async function main(args = process.argv.slice(2)) { exposeMetroToSimulator(simulator.udid); } await launchAppOnSimulator(appId, simulator.udid, isDebug); - executeFlows(appId, simulator.udid, maestroFlow, jsengine); + await executeFlows(appId, simulator.udid, maestroFlow, jsengine); console.log('Test finished'); } diff --git a/.github/workflows/e2e-maestro.yml b/.github/workflows/e2e-maestro.yml index fd15bc8053c0..2d296fa2fc5b 100644 --- a/.github/workflows/e2e-maestro.yml +++ b/.github/workflows/e2e-maestro.yml @@ -69,6 +69,8 @@ jobs: fail-on-error: true secrets: inherit + # Every flavor of both suites runs at once: a dispatch wants four fleet + # machines, and waits on the acquire retry when they are not free. test_e2e_ios_templateapp: needs: [ diff --git a/packages/rn-tester/.maestro/scrollview-minindex-maintainvisible.yml b/packages/rn-tester/.maestro/scrollview-minindex-maintainvisible.yml index 0925eda988fe..d0c6fa6bb0d8 100644 --- a/packages/rn-tester/.maestro/scrollview-minindex-maintainvisible.yml +++ b/packages/rn-tester/.maestro/scrollview-minindex-maintainvisible.yml @@ -14,6 +14,9 @@ appId: ${APP_ID} id: 'ScrollViewMaintainVisibleContentPositionExample' direction: DOWN speed: 80 + # Each scroll step ships the whole accessibility tree over the wire + # against a remote simulator, which the default timeout cannot absorb. + timeout: 90000 - tapOn: id: 'ScrollViewMaintainVisibleContentPositionExample' - waitForAnimationToEnd: diff --git a/packages/rn-tester/.maestro/scrollview-threshold-maintainvisible.yml b/packages/rn-tester/.maestro/scrollview-threshold-maintainvisible.yml index c865c3cb26e9..9c179612b9fa 100644 --- a/packages/rn-tester/.maestro/scrollview-threshold-maintainvisible.yml +++ b/packages/rn-tester/.maestro/scrollview-threshold-maintainvisible.yml @@ -14,6 +14,9 @@ appId: ${APP_ID} id: 'ScrollViewMaintainVisibleContentPositionExample' direction: DOWN speed: 80 + # Each scroll step ships the whole accessibility tree over the wire + # against a remote simulator, which the default timeout cannot absorb. + timeout: 90000 - tapOn: id: 'ScrollViewMaintainVisibleContentPositionExample' - waitForAnimationToEnd: