Skip to content
Merged
24 changes: 23 additions & 1 deletion .github/actions/maestro-ios/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
77 changes: 61 additions & 16 deletions .github/workflow-scripts/maestro-ios.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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');
}
Expand Down Expand Up @@ -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
Expand All @@ -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.`,
Expand All @@ -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);
}
}
}
Expand Down Expand Up @@ -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');
}

Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/e2e-maestro.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading