From 472a9177007890cb5054d67d18dfe3634ba87e06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 6 Oct 2026 12:33:14 +0200 Subject: [PATCH 1/4] ci(ios): prepare the runner before the fixture E2E so open does not pay its start --- .github/workflows/ios.yml | 4 ++++ .github/workflows/replays-manual.yml | 1 + 2 files changed, 5 insertions(+) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 9459c286ce..d217fab264 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -201,6 +201,9 @@ jobs: node --experimental-strip-types src/bin.ts test examples/test-app/replays/gesture-pan-duration.ad --udid "${{ steps.ios-simulator.outputs.simulator-udid }}" --timeout 180000 --retries 2 --artifacts-dir test/artifacts/replays-ios-gesture-pan-duration --report-junit test/artifacts/replays-ios-gesture-pan-duration.junit.xml pnpm clean:daemon + # The E2E steps drive the built CLI, so the daemon the previous steps left was stopped above. + # Prepare the runner in the new daemon first, under the prepare budget: otherwise the first + # runner-backed command pays the runner start inside its own 90 s request timeout. - name: Run fixture-backed iOS simulator E2E smoke env: AGENT_DEVICE_FIXTURE_APP_ID: ${{ steps.fixture-app.outputs.app-id }} @@ -210,6 +213,7 @@ jobs: AGENT_DEVICE_IOS_UDID: ${{ steps.ios-simulator.outputs.simulator-udid }} run: | pnpm gate build + node bin/agent-device.mjs prepare ios-runner --platform ios --udid "$AGENT_DEVICE_IOS_UDID" --timeout "$AGENT_DEVICE_IOS_PREPARE_TIMEOUT_MS" --json 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 - name: Assert simulator automation preserved host focus diff --git a/.github/workflows/replays-manual.yml b/.github/workflows/replays-manual.yml index d5e0d8ad2e..d20fc0d5dc 100644 --- a/.github/workflows/replays-manual.yml +++ b/.github/workflows/replays-manual.yml @@ -169,6 +169,7 @@ jobs: AGENT_DEVICE_IOS_UDID: ${{ steps.ios-simulator.outputs.simulator-udid }} run: | pnpm gate build + node bin/agent-device.mjs prepare ios-runner --platform ios --udid "$AGENT_DEVICE_IOS_UDID" --timeout "$AGENT_DEVICE_IOS_PREPARE_TIMEOUT_MS" --json 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 - name: Run iOS physical device replay suite From 8bc651c95d636339554f7631e578d09fbe03f95e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 6 Oct 2026 12:41:35 +0200 Subject: [PATCH 2/4] ci(ios): keep the warm runner across the source-to-built daemon handoff --- .github/workflows/ios.yml | 9 +++++---- .github/workflows/replays-manual.yml | 2 -- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index d217fab264..1125e78f9f 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -199,11 +199,12 @@ jobs: run: | xcrun simctl install "${{ steps.ios-simulator.outputs.simulator-udid }}" "${{ steps.fixture-app.outputs.app-path }}" node --experimental-strip-types src/bin.ts test examples/test-app/replays/gesture-pan-duration.ad --udid "${{ steps.ios-simulator.outputs.simulator-udid }}" --timeout 180000 --retries 2 --artifacts-dir test/artifacts/replays-ios-gesture-pan-duration --report-junit test/artifacts/replays-ios-gesture-pan-duration.junit.xml - pnpm clean:daemon - # The E2E steps drive the built CLI, so the daemon the previous steps left was stopped above. - # Prepare the runner in the new daemon first, under the prepare budget: otherwise the first - # runner-backed command pays the runner start inside its own 90 s request timeout. + # The E2E drives the built CLI, whose code signature differs from the source daemon above, so + # its first command replaces that daemon. A graceful replacement detaches the warm runner and + # the new daemon adopts it; `clean:daemon` here would kill it instead. Prepare runs first, + # under the prepare budget, so a runner that must start again does not start inside the first + # runner-backed command's 90 s request timeout. - name: Run fixture-backed iOS simulator E2E smoke env: AGENT_DEVICE_FIXTURE_APP_ID: ${{ steps.fixture-app.outputs.app-id }} diff --git a/.github/workflows/replays-manual.yml b/.github/workflows/replays-manual.yml index d20fc0d5dc..d9b1f23eae 100644 --- a/.github/workflows/replays-manual.yml +++ b/.github/workflows/replays-manual.yml @@ -156,9 +156,7 @@ jobs: - name: Prove selector drag reaches its destination on iOS run: | xcrun simctl install "${{ steps.ios-simulator.outputs.simulator-udid }}" "${{ steps.fixture-app.outputs.app-path }}" - pnpm clean:daemon node --experimental-strip-types src/bin.ts replay examples/test-app/replays/drag.ad --platform ios --udid "${{ steps.ios-simulator.outputs.simulator-udid }}" --session nightly-ios-target-drag --json - pnpm clean:daemon - name: Run full fixture-backed iOS simulator E2E env: From 5c24d0ba58191236f3255a7f603575888e6fb75c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 6 Oct 2026 13:25:36 +0200 Subject: [PATCH 3/4] test(ios-e2e): prepare the runner inside the test wrapper so its daemon keeps the run's claims dir --- .github/workflows/ios.yml | 8 ++--- .github/workflows/replays-manual.yml | 1 - .../ios-simulator-e2e/live-runner.ts | 31 +++++++++++++++++++ 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 1125e78f9f..e4476efc05 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -201,10 +201,9 @@ jobs: node --experimental-strip-types src/bin.ts test examples/test-app/replays/gesture-pan-duration.ad --udid "${{ steps.ios-simulator.outputs.simulator-udid }}" --timeout 180000 --retries 2 --artifacts-dir test/artifacts/replays-ios-gesture-pan-duration --report-junit test/artifacts/replays-ios-gesture-pan-duration.junit.xml # The E2E drives the built CLI, whose code signature differs from the source daemon above, so - # its first command replaces that daemon. A graceful replacement detaches the warm runner and - # the new daemon adopts it; `clean:daemon` here would kill it instead. Prepare runs first, - # under the prepare budget, so a runner that must start again does not start inside the first - # runner-backed command's 90 s request timeout. + # its first command (the harness's runner prepare) replaces that daemon. A graceful + # replacement detaches the warm runner and the new daemon adopts it; `clean:daemon` here + # would kill it instead. - name: Run fixture-backed iOS simulator E2E smoke env: AGENT_DEVICE_FIXTURE_APP_ID: ${{ steps.fixture-app.outputs.app-id }} @@ -214,7 +213,6 @@ jobs: AGENT_DEVICE_IOS_UDID: ${{ steps.ios-simulator.outputs.simulator-udid }} run: | pnpm gate build - node bin/agent-device.mjs prepare ios-runner --platform ios --udid "$AGENT_DEVICE_IOS_UDID" --timeout "$AGENT_DEVICE_IOS_PREPARE_TIMEOUT_MS" --json 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 - name: Assert simulator automation preserved host focus diff --git a/.github/workflows/replays-manual.yml b/.github/workflows/replays-manual.yml index d9b1f23eae..59c76e1d2c 100644 --- a/.github/workflows/replays-manual.yml +++ b/.github/workflows/replays-manual.yml @@ -167,7 +167,6 @@ jobs: AGENT_DEVICE_IOS_UDID: ${{ steps.ios-simulator.outputs.simulator-udid }} run: | pnpm gate build - node bin/agent-device.mjs prepare ios-runner --platform ios --udid "$AGENT_DEVICE_IOS_UDID" --timeout "$AGENT_DEVICE_IOS_PREPARE_TIMEOUT_MS" --json 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 - name: Run iOS physical device replay suite diff --git a/test/integration/ios-simulator-e2e/live-runner.ts b/test/integration/ios-simulator-e2e/live-runner.ts index c06669e0c9..5651219683 100644 --- a/test/integration/ios-simulator-e2e/live-runner.ts +++ b/test/integration/ios-simulator-e2e/live-runner.ts @@ -45,6 +45,7 @@ import { } from './snapshot-backend-conformance.ts'; const C = PUBLIC_COMMANDS; +const DEFAULT_RUNNER_PREPARE_TIMEOUT_MS = 420_000; type AgentDeviceSdk = typeof import('../../../src/sdk/index.ts'); @@ -85,6 +86,7 @@ export async function runIosSimulatorE2E(): Promise { const context = createContext(); let primaryError: unknown; try { + await prepareRunner(context); await executeLiveScenarios(context); } catch (error) { primaryError = error; @@ -93,6 +95,35 @@ export async function runIosSimulatorE2E(): Promise { throwLiveRunErrors(primaryError, cleanupError); } +/** + * Starts the runner in this run's daemon under the prepare budget, so a cold start or rebuild is + * not paid inside the first runner-backed command's 90 s request timeout. It runs inside the + * node-test-tmpdir wrapper, so the daemon it starts gets the run's TMPDIR and claims directory. + */ +async function prepareRunner(context: LiveContext): Promise { + const timeoutMs = Number( + process.env.AGENT_DEVICE_IOS_PREPARE_TIMEOUT_MS ?? DEFAULT_RUNNER_PREPARE_TIMEOUT_MS, + ); + await runStep( + context, + 'prepare iOS runner', + [ + 'prepare', + 'ios-runner', + '--platform', + 'ios', + '--udid', + context.udid, + '--state-dir', + context.stateDir, + '--timeout', + String(timeoutMs), + '--json', + ], + { commonFlags: false, timeoutMs: timeoutMs + 30_000 }, + ); +} + async function executeLiveScenarios(context: LiveContext): Promise { let assertionCaptureVerified = false; for (const scenario of LIVE_SCENARIOS.filter((candidate) => candidate.tier === 'smoke')) { From 1edd8b4d2b9656c3d72f7a8b0049dc33c05950c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 6 Oct 2026 13:35:52 +0200 Subject: [PATCH 4/4] test(ios-e2e): stop the run's daemon before the test wrapper deletes its directories --- .github/workflows/ios.yml | 4 ---- .../ios-simulator-e2e/live-runner.ts | 23 +++++++++++++++---- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index e4476efc05..b8e046bc3a 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -200,10 +200,6 @@ jobs: xcrun simctl install "${{ steps.ios-simulator.outputs.simulator-udid }}" "${{ steps.fixture-app.outputs.app-path }}" node --experimental-strip-types src/bin.ts test examples/test-app/replays/gesture-pan-duration.ad --udid "${{ steps.ios-simulator.outputs.simulator-udid }}" --timeout 180000 --retries 2 --artifacts-dir test/artifacts/replays-ios-gesture-pan-duration --report-junit test/artifacts/replays-ios-gesture-pan-duration.junit.xml - # The E2E drives the built CLI, whose code signature differs from the source daemon above, so - # its first command (the harness's runner prepare) replaces that daemon. A graceful - # replacement detaches the warm runner and the new daemon adopts it; `clean:daemon` here - # would kill it instead. - name: Run fixture-backed iOS simulator E2E smoke env: AGENT_DEVICE_FIXTURE_APP_ID: ${{ steps.fixture-app.outputs.app-id }} diff --git a/test/integration/ios-simulator-e2e/live-runner.ts b/test/integration/ios-simulator-e2e/live-runner.ts index 5651219683..7f4511f423 100644 --- a/test/integration/ios-simulator-e2e/live-runner.ts +++ b/test/integration/ios-simulator-e2e/live-runner.ts @@ -95,11 +95,7 @@ export async function runIosSimulatorE2E(): Promise { throwLiveRunErrors(primaryError, cleanupError); } -/** - * Starts the runner in this run's daemon under the prepare budget, so a cold start or rebuild is - * not paid inside the first runner-backed command's 90 s request timeout. It runs inside the - * node-test-tmpdir wrapper, so the daemon it starts gets the run's TMPDIR and claims directory. - */ +/** A runner start must not spend the first runner-backed command's 90 s request timeout. */ async function prepareRunner(context: LiveContext): Promise { const timeoutMs = Number( process.env.AGENT_DEVICE_IOS_PREPARE_TIMEOUT_MS ?? DEFAULT_RUNNER_PREPARE_TIMEOUT_MS, @@ -172,6 +168,11 @@ async function assertLiveAssertionCapture(context: LiveContext): Promise { async function finalizeLiveRun(context: LiveContext): Promise { let cleanupError = await finalizeSessionCleanup(context, sessionExists, cleanupSession); + try { + await stopDaemon(context); + } catch (error) { + cleanupError = combineErrors(cleanupError, error, 'cleanup and daemon stop failed'); + } try { writeCoverageReport(context); } catch (error) { @@ -180,6 +181,18 @@ async function finalizeLiveRun(context: LiveContext): Promise { return cleanupError; } +/** The daemon holds this run's TMPDIR and claims directory, which the test wrapper deletes. */ +async function stopDaemon(context: LiveContext): Promise { + await runStep( + context, + 'stop daemon', + ['daemon', 'stop', '--state-dir', context.stateDir, '--json'], + { + commonFlags: false, + }, + ); +} + /** * Decides whether session-scoped cleanup runs: full:device-lifecycle reboots the * simulator and the session lease does not survive that in every environment, so this