From 0da3a23e587857022c0c19247d7a3f580beb78c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Tue, 27 Oct 2026 15:49:11 +0100 Subject: [PATCH] ci(ios): capture runner diagnostics when a Detox e2e test fails When a test or hook fails, capture host processes, memory, the booted simulators and a connect/TLS probe to api.mapbox.com while the app is still in the failing state. After a failed run, also collect crash reports and the simulator and CoreSimulator logs, capture the view hierarchy on failure, and keep the artifacts per attempt for 14 days. --- .github/workflows/ios-actions.yml | 21 +++++++- example/e2e/config.js | 2 +- example/e2e/environment.js | 89 +++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 example/e2e/environment.js diff --git a/.github/workflows/ios-actions.yml b/.github/workflows/ios-actions.yml index 1a478acd9..5857152a3 100644 --- a/.github/workflows/ios-actions.yml +++ b/.github/workflows/ios-actions.yml @@ -155,7 +155,7 @@ jobs: if: inputs.E2E_MODE == 'test' run: | npx detox --version - yarn detox test --configuration ios.debug.ci --take-screenshots failing --record-logs failing e2e/firstTest.e2e.js + yarn detox test --configuration ios.debug.ci --take-screenshots failing --record-logs failing --capture-view-hierarchy enabled e2e/firstTest.e2e.js # Informational only, the snapshots are compared by the percy job - name: Capture visual snapshots @@ -196,12 +196,29 @@ jobs: path: ${{ runner.temp }}/percy-doc-screenshots if-no-files-found: ignore + # e2e/environment.js has already captured the runner state at the moment a test failed + - name: Collect simulator diagnostics on failure + if: failure() + continue-on-error: true + run: | + mkdir -p artifacts/diagnostics/crash-reports + cp ~/Library/Logs/DiagnosticReports/RNMapboxExample* artifacts/diagnostics/crash-reports/ 2>/dev/null || true + uptime > artifacts/diagnostics/uptime.txt + /usr/bin/log show --last 15m --style compact --predicate 'process == "CoreSimulatorService" OR process == "simctl"' \ + > artifacts/diagnostics/host-coresimulator.log 2>&1 || true + if [ -n "$DETOX_DEVICE_UDID" ]; then + xcrun simctl spawn "$DETOX_DEVICE_UDID" log show --last 15m --style compact \ + --predicate 'process == "RNMapboxExample" OR process == "SpringBoard"' \ + > artifacts/diagnostics/simulator.log 2>&1 || true + fi + - name: Store Detox artifacts on test failure uses: actions/upload-artifact@v4 if: failure() with: - name: detox-artifacts + name: detox-artifacts-attempt-${{ github.run_attempt }} path: example/artifacts + retention-days: 14 percy: name: Percy visual snapshots diff --git a/example/e2e/config.js b/example/e2e/config.js index 58884c553..724b50c92 100644 --- a/example/e2e/config.js +++ b/example/e2e/config.js @@ -9,5 +9,5 @@ module.exports = { reporters: ['detox/runners/jest/reporter'], globalSetup: 'detox/runners/jest/globalSetup', globalTeardown: 'detox/runners/jest/globalTeardown', - testEnvironment: 'detox/runners/jest/testEnvironment', + testEnvironment: '/e2e/environment.js', }; diff --git a/example/e2e/environment.js b/example/e2e/environment.js new file mode 100644 index 000000000..6da6380c1 --- /dev/null +++ b/example/e2e/environment.js @@ -0,0 +1,89 @@ +/** + * @file Detox test environment that, on CI, snapshots the runner when a test or hook + * fails, while the app is still in the failing state, into + * artifacts/diagnostics/ (uploaded with the Detox artifacts). + */ + +const { execFile } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const { promisify } = require('util'); + +const DetoxCircusEnvironment = require('detox/runners/jest/testEnvironment'); + +const run = promisify(execFile); + +const OUT_DIR = path.join(__dirname, '..', 'artifacts', 'diagnostics'); +const MAX_CAPTURES = 3; + +async function capture(file, cmd, args, timeout = 20000) { + let text; + try { + const { stdout, stderr } = await run(cmd, args, { + timeout, + maxBuffer: 64 * 1024 * 1024, + }); + text = stdout + stderr; + } catch (e) { + text = `${e.stdout || ''}${e.stderr || ''}\n${e.message}`; + } + fs.writeFileSync(file, `$ ${cmd} ${args.join(' ')}\n${text}`); +} + +class DiagnosticsEnvironment extends DetoxCircusEnvironment { + constructor(config, context) { + super(config, context); + this.captures = 0; + } + + async handleTestEvent(event, state) { + await super.handleTestEvent(event, state); + if ( + process.env.CI && + (event.name === 'test_fn_failure' || event.name === 'hook_failure') && + this.captures < MAX_CAPTURES + ) { + this.captures += 1; + const name = event.test?.name ?? event.hook?.type ?? 'unknown'; + await this.captureDiagnostics( + `${path.basename(this.testPath, '.e2e.js')}-${this.captures}-${name}`, + ); + } + } + + async captureDiagnostics(label) { + const dir = path.join(OUT_DIR, label.replace(/[^\w.-]+/g, '_')); + fs.mkdirSync(dir, { recursive: true }); + const udid = process.env.DETOX_DEVICE_UDID; + + await Promise.all([ + // Host processes by CPU, including any simctl/applesimutils call Detox is stuck on + capture(path.join(dir, 'ps.txt'), 'ps', [ + '-Ao', + 'pid,ppid,etime,pcpu,pmem,command', + '-r', + ]), + capture(path.join(dir, 'vm_stat.txt'), 'vm_stat', []), + // Connect/TLS timings from the runner, for map loads stuck on a style request + capture(path.join(dir, 'curl-api.mapbox.com.txt'), 'curl', [ + '-sS', + '-o', + '/dev/null', + '--max-time', + '15', + '-w', + 'dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total} http=%{http_code} ip=%{remote_ip}\n', + 'https://api.mapbox.com/', + ]), + udid && + capture(path.join(dir, 'simctl-list.txt'), 'xcrun', [ + 'simctl', + 'list', + 'devices', + 'booted', + ]), + ]); + } +} + +module.exports = DiagnosticsEnvironment;