diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 0bfe83e9..5adeb62a 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,6 +1,6 @@ name: E2E -# Maestro E2E (iOS sim). Two ways in: +# Maestro E2E (Android by default, with an iOS option). Two ways in: # # * Monthly schedule — 00:00 UTC on the 1st, the low-frequency regression gate # this suite has always been. It lived in `test.yml` until this workflow @@ -8,14 +8,15 @@ name: E2E # * Manual dispatch — run it against any branch, tag, commit SHA, or open PR # number. Defaults to `main`. # -# The macOS runner + iOS Release build is ~15 minutes and ~$3/run, which is why -# this is never wired to `pull_request`: per-commit E2E would cost more than it -# catches. Dispatch it by hand when a change actually touches a flow. +# The Android Release build is still expensive enough that this is never wired +# to `pull_request`: per-commit E2E would cost more than it catches. Dispatch it +# by hand when a change actually touches a flow. # # gh workflow run E2E # main, whole mock suite # gh workflow run E2E -f ref=572 # PR #572's head # gh workflow run E2E -f ref=my/branch # a branch # gh workflow run E2E -f ref=v1.2.3 # a tag +# gh workflow run E2E -f platform=ios # iOS instead of the default Android # gh workflow run E2E -f flows=e2e/codex_parity.yaml # one flow # gh workflow run E2E -f ref=572 -f flows="e2e/a.yaml e2e/b.yaml" # @@ -30,6 +31,14 @@ name: E2E on: workflow_dispatch: inputs: + platform: + description: 'Platform to test' + required: false + default: 'android' + type: choice + options: + - android + - ios ref: description: 'Branch, tag, commit SHA, or PR number to test (default: main)' required: false @@ -45,15 +54,14 @@ on: - cron: '0 0 1 * *' concurrency: - # One E2E at a time per target — two runs would fight over the same simulator - # cache key for no benefit, and each one costs a macOS runner. + # One E2E at a time per target — two runs would fight over the same emulator. group: e2e-${{ inputs.ref || 'schedule' }} cancel-in-progress: false jobs: e2e-maestro: - name: E2E maestro (iOS) - runs-on: macos-15 + name: E2E maestro + runs-on: ${{ github.event_name == 'workflow_dispatch' && inputs.platform == 'ios' && 'macos-15' || 'ubuntu-24.04' }} timeout-minutes: 75 steps: # `ref` is the one piece of attacker-controllable input in this workflow, @@ -87,18 +95,113 @@ jobs: - uses: actions/setup-node@v5 with: - node-version: 22 + node-version: ${{ github.event_name == 'workflow_dispatch' && inputs.platform == 'ios' && '22' || '24.15.0' }} cache: npm + - name: Install dependencies + run: npm ci + + - uses: actions/setup-java@v5 + if: ${{ github.event_name != 'workflow_dispatch' || inputs.platform == 'android' }} + with: + distribution: temurin + java-version: '17' + + - uses: android-actions/setup-android@v4 + if: ${{ github.event_name != 'workflow_dispatch' || inputs.platform == 'android' }} + + - name: Install Maestro CLI + run: | + MAESTRO_VERSION=2.8.0 curl -fsSL "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + "$HOME/.maestro/bin/maestro" --version + + # This action installs the Android emulator, platform tools, API 35 SDK, + # and Google APIs x86_64 image, then waits for the one requested emulator + # to finish booting before it runs the script below. + - name: Build, install, and run on Android API 35 + if: ${{ github.event_name != 'workflow_dispatch' || inputs.platform == 'android' }} + uses: reactivecircus/android-emulator-runner@v2 + env: + FLOWS: ${{ inputs.flows }} + E2E_PLATFORM: android + E2E_ANDROID_API_LEVEL: '35' + E2E_MOCK_SERVER_URL: http://10.0.2.2:7071 + # Simulator-only release signing: use the repository's debug key, not + # production signing material. Production deploys keep their own path. + TB_MOBILE_UPLOAD_KEYSTORE: ${{ github.workspace }}/android/app/debug.keystore + TB_MOBILE_UPLOAD_KEYSTORE_PASSWORD: android + TB_MOBILE_UPLOAD_KEY_ALIAS: androiddebugkey + TB_MOBILE_UPLOAD_KEY_PASSWORD: android + # The app bundles Sentry, but E2E must never require upload credentials. + SENTRY_DISABLE_AUTO_UPLOAD: true + with: + api-level: 35 + target: google_apis + arch: x86_64 + profile: pixel_6 + emulator-options: -no-window -noaudio -no-boot-anim -gpu swiftshader_indirect + disable-animations: true + # android-emulator-runner runs `script` under /usr/bin/sh (dash on + # ubuntu), which rejects `set -o pipefail`. Hand the body to bash. + script: | + bash <<'EOF' + set -euo pipefail + capture_failure() { + STATUS=$? + if [ "$STATUS" -ne 0 ]; then + adb exec-out screencap -p > e2e/_artifacts/fallback/emulator-at-failure.png || true + LATEST=$(ls -dt ~/.maestro/tests/*/ 2>/dev/null | head -1) + if [ -n "$LATEST" ]; then + mkdir -p e2e/_artifacts/maestro-session + cp -R "$LATEST" e2e/_artifacts/maestro-session/ || true + fi + fi + exit "$STATUS" + } + trap capture_failure EXIT + adb wait-for-device + until [ "$(adb shell getprop sys.boot_completed | tr -d '\r')" = "1" ]; do sleep 1; done + adb shell input keyevent 82 + (cd android && ./gradlew :app:assembleRelease -PreactNativeArchitectures=x86_64) + adb install -r android/app/build/outputs/apk/release/app-release.apk + mkdir -p e2e/_artifacts/debug e2e/_artifacts/fallback + if [ -z "$FLOWS" ]; then + npm run test:e2e:mock + exit 0 + fi + for f in $FLOWS; do + case "$f" in + e2e/*.yaml) ;; + *) echo "::error::Refusing flow '$f' — expected a path like e2e/.yaml."; exit 1 ;; + esac + if [ ! -f "$f" ]; then + echo "::error::No such flow: $f" + exit 1 + fi + done + echo "Running flows: $FLOWS" >> "$GITHUB_STEP_SUMMARY" + node e2e/check-sim.js + node e2e/ensure-release-build.js + MOCK_PORTS=7071,7072 node e2e/mock-server.js & + MOCK_PID=$! + sleep 1 + set +e + node e2e/run-maestro.js test --debug-output e2e/_artifacts/debug $FLOWS + STATUS=$? + set -e + kill "$MOCK_PID" 2>/dev/null || true + exit $STATUS + EOF + - uses: ruby/setup-ruby@v1 + if: ${{ github.event_name == 'workflow_dispatch' && inputs.platform == 'ios' }} with: ruby-version: '3.3' bundler-cache: true - - name: Install dependencies - run: npm ci - - name: Restore iOS DerivedData cache + if: ${{ github.event_name == 'workflow_dispatch' && inputs.platform == 'ios' }} uses: actions/cache@v5 with: path: build/ios-ci @@ -108,44 +211,38 @@ jobs: ios-derived-v1-${{ runner.os }}-xcode26.3- - name: Select Xcode 26 + if: ${{ github.event_name == 'workflow_dispatch' && inputs.platform == 'ios' }} # Expo SDK 56's expo-modules-jsi Package.swift requires swift-tools 6.2, # which ships with Xcode 26. The macos-15 runner defaults to Xcode 16.4 # (Swift 6.1) and rejects the package with "Could not resolve package # dependencies: package 'apple' is using Swift tools version 6.2.0". run: sudo xcode-select -s /Applications/Xcode_26.3.app - - name: Install Maestro CLI - run: | - curl -fsSL "https://get.maestro.mobile.dev" | bash - echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" - - name: Boot iOS simulator + if: ${{ github.event_name == 'workflow_dispatch' && inputs.platform == 'ios' }} run: | DEVICE=$(xcrun simctl list devices available --json | jq -r '.devices | to_entries | map(select(.key | contains("iOS"))) | sort_by(.key) | reverse | .[0].value | map(select(.name | test("iPhone 1[5-9]"))) | .[0].udid') echo "Booting simulator: $DEVICE" xcrun simctl boot "$DEVICE" || true xcrun simctl bootstatus "$DEVICE" -b - # `ios/` is committed, so `expo prebuild` reports "reusing /ios" and skips - # the CocoaPods step entirely — leaving a CI checkout with no `ios/Pods/` - # and an xcodebuild that dies on a missing `Pods-Threadbase.release - # .xcconfig`. That is why every scheduled run since at least June 2026 - # failed at the build step; a local machine never sees it because its - # `ios/Pods/` already exists. Prebuild first (it can rewrite the pbxproj), - # then install pods against the result. - # - # Mirrors deploy.yml: no `--deployment`, because prebuilt-artifact pods - # regenerate fresh checksums on CI and `--deployment` treats that as a - # fatal lockfile change. `bundle exec` keeps the Gemfile's CocoaPods 1.16.2 - # pin, and reset-podfile-lock-path-noise.sh drops the four path-dependent - # checksums the runner's checkout path produces. - name: Install CocoaPods + if: ${{ github.event_name == 'workflow_dispatch' && inputs.platform == 'ios' }} + # `ios/` is committed, so `expo prebuild` reports "reusing /ios" and skips + # the CocoaPods step entirely — leaving a CI checkout with no `ios/Pods/` + # and an xcodebuild that dies on a missing `Pods-Threadbase.release + # .xcconfig`. Prebuild first (it can rewrite the pbxproj), then install + # pods against the result. No `--deployment`: prebuilt-artifact pods + # regenerate fresh checksums on CI, which `--deployment` rejects. run: | npx expo prebuild --platform ios --no-clean (cd ios && bundle exec pod install) ./scripts/reset-podfile-lock-path-noise.sh - name: Build and install iOS app (Release) + if: ${{ github.event_name == 'workflow_dispatch' && inputs.platform == 'ios' }} + env: + SENTRY_DISABLE_AUTO_UPLOAD: true run: | xcodebuild \ -workspace ios/Threadbase.xcworkspace \ @@ -157,18 +254,8 @@ jobs: APP_PATH=$(find build/ios-ci/Build/Products/Release-iphonesimulator -name "Threadbase.app" -type d | head -1) xcrun simctl install booted "$APP_PATH" - # `flows` empty runs the whole mock suite through its npm script, which - # stays the single source of truth for the flow list. A non-empty value - # reproduces that script's setup around a narrower guarded Maestro run — worth - # the duplicated three lines to keep the full-suite path untouched. - # - # Every entry is checked to look like `e2e/.yaml` and to exist - # before it reaches the command. Word-splitting `$FLOWS` unquoted is the - # point (it is a list), and is safe here: expansion splits on whitespace - # without re-parsing shell metacharacters, so a `;` inside an entry is a - # filename character, not a separator — and the existence check rejects it - # regardless. - - name: Run Maestro E2E suite + - name: Run Maestro E2E suite on iOS + if: ${{ github.event_name == 'workflow_dispatch' && inputs.platform == 'ios' }} env: FLOWS: ${{ inputs.flows }} run: | @@ -201,12 +288,12 @@ jobs: kill "$MOCK_PID" 2>/dev/null || true exit $STATUS - - name: Capture sim screenshot + Maestro session dir on failure - if: failure() + - name: Capture emulator screenshot + Maestro session dir on failure + if: ${{ failure() && (github.event_name != 'workflow_dispatch' || inputs.platform == 'android') }} run: | - # Snapshot the booted sim so we have at least one image even if + # Snapshot the booted emulator so we have at least one image even if # Maestro wrote no per-flow debug output to e2e/_artifacts/debug. - xcrun simctl io booted screenshot e2e/_artifacts/fallback/sim-at-failure.png || true + adb exec-out screencap -p > e2e/_artifacts/fallback/emulator-at-failure.png || true # Maestro defaults to writing test session output under # ~/.maestro/tests//. Copy the most recent session into # the artifact dir so commands JSON + hierarchy snapshots survive. @@ -216,6 +303,19 @@ jobs: cp -R "$LATEST" e2e/_artifacts/maestro-session/ || true fi + - name: Capture iOS simulator screenshot on failure + if: ${{ failure() && github.event_name == 'workflow_dispatch' && inputs.platform == 'ios' }} + run: xcrun simctl io booted screenshot e2e/_artifacts/fallback/sim-at-failure.png || true + + - name: Copy Maestro session dir on failure + if: failure() + run: | + LATEST=$(ls -dt ~/.maestro/tests/*/ 2>/dev/null | head -1) + if [ -n "$LATEST" ]; then + mkdir -p e2e/_artifacts/maestro-session + cp -R "$LATEST" e2e/_artifacts/maestro-session/ || true + fi + - name: Upload Maestro artifacts on failure if: failure() uses: actions/upload-artifact@v6 diff --git a/__tests__/unit/scripts/check-sim.test.js b/__tests__/unit/scripts/check-sim.test.js new file mode 100644 index 00000000..1177ee9e --- /dev/null +++ b/__tests__/unit/scripts/check-sim.test.js @@ -0,0 +1,56 @@ +/** + * @jest-environment node + */ + +'use strict'; + +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const SCRIPT = path.resolve(__dirname, '../../../e2e/check-sim.js'); +const tmpDirs = []; + +function runAndroidCheck({ apiLevel = '35' } = {}) { + const bin = fs.mkdtempSync(path.join(os.tmpdir(), 'check-sim-bin-')); + tmpDirs.push(bin); + fs.writeFileSync( + path.join(bin, 'adb'), + `#!/bin/sh +case "$*" in + devices) printf 'List of devices attached\\nemulator-5554\\tdevice\\n' ;; + '-s emulator-5554 shell getprop sys.boot_completed') printf '1\\n' ;; + '-s emulator-5554 shell getprop ro.build.version.sdk') printf '${apiLevel}\\n' ;; +esac +`, + { mode: 0o755 }, + ); + + return spawnSync(process.execPath, [SCRIPT], { + encoding: 'utf8', + env: { + ...process.env, + E2E_PLATFORM: 'android', + PATH: [bin, process.env.PATH].join(path.delimiter), + }, + }); +} + +afterEach(() => { + while (tmpDirs.length) fs.rmSync(tmpDirs.pop(), { recursive: true, force: true }); +}); + +test('accepts one fully booted Android API 35 emulator', () => { + const result = runAndroidCheck(); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('emulator-5554 (API 35)'); +}); + +test('rejects an Android emulator on a different API level', () => { + const result = runAndroidCheck({ apiLevel: '34' }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('runs API 34, expected API 35'); +}); diff --git a/__tests__/unit/scripts/ensure-release-build.test.js b/__tests__/unit/scripts/ensure-release-build.test.js index 68f620ab..78f9340e 100644 --- a/__tests__/unit/scripts/ensure-release-build.test.js +++ b/__tests__/unit/scripts/ensure-release-build.test.js @@ -63,7 +63,7 @@ function writeStub(binDir, name, body) { const tmpDirs = []; /** Runs the copied script as a real subprocess with xcrun/npx stubbed and git real. */ -function runScript(repo, { allowStale = false } = {}) { +function runScript(repo, { allowStale = false, platform } = {}) { const bin = fs.mkdtempSync(path.join(os.tmpdir(), 'ensure-release-bin-')); const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ensure-release-home-')); tmpDirs.push(bin, home); @@ -92,6 +92,7 @@ function runScript(repo, { allowStale = false } = {}) { APP_DIR: appDirFor(repo), }; if (allowStale) env.E2E_ALLOW_STALE_BUILD = '1'; + if (platform) env.E2E_PLATFORM = platform; const result = spawnSync(process.execPath, [path.join(repo, 'e2e/ensure-release-build.js')], { cwd: repo, @@ -222,3 +223,15 @@ test('no existing build at all still builds fresh, same as before the staleness expect(second.npxLog).toBe(''); expect(second.stdout).toMatch(/current/i); }); + +test('Android CI leaves build and installation to the workflow', () => { + const made = makeRepo(); + repo = made.repo; + + const result = runScript(repo, { platform: 'android' }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('Android Release APK was installed by the E2E runner'); + expect(result.npxLog).toBe(''); + expect(result.xcrunLog).toBe(''); +}); diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md index 80c4365d..6e384356 100644 --- a/docs/e2e-testing.md +++ b/docs/e2e-testing.md @@ -6,6 +6,21 @@ This document describes the E2E (end-to-end) testing setup for tb-mobile using M We use [Maestro](https://maestro.mobile.dev/) for automated E2E testing on iOS and Android. Tests are written in YAML and interact with the app through testIDs and UI elements. +## Android CI + +The `E2E` GitHub Actions workflow defaults to Android and runs on `ubuntu-24.04` with one Android API 35 +Google APIs `x86_64` `pixel_6` emulator and Maestro CLI 2.8.0. It uses the +existing Gradle Release task, installs the APK with `adb`, and runs the suite +through `e2e/run-maestro.js`. The workflow disables Sentry source-map upload +and uses the committed debug keystore only for this simulator APK, so it does +not need production Sentry or signing credentials. + +The Android emulator reaches the runner-hosted mock server at `10.0.2.2`, while +local iOS runs use `localhost`. The Android preflight checks emulator readiness +and API level; iOS runs additionally retain the separate XCTest teardown-crash +guard in `e2e/run-maestro.js`. Manual dispatch also provides `platform=ios` to +run the retained macOS/iOS workflow. + ## Prerequisites 1. **Install Maestro:** diff --git a/e2e/README.md b/e2e/README.md index 2a925db2..19c586a3 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -1,6 +1,31 @@ # E2E tests (Maestro) -A minimal smoke-test suite for the Threadbase iOS app, driven by [Maestro](https://maestro.dev) against a local mock server. +A minimal smoke-test suite for the Threadbase app, driven by [Maestro](https://maestro.dev) against a local mock server. + +## Android CI + +The scheduled `E2E` GitHub Actions workflow, and manual runs with the default +`platform=android`, run the mock suite on Ubuntu using one Android **API 35** Google APIs `x86_64` emulator +(`pixel_6`) and Maestro CLI **2.8.0**. It builds `:app:assembleRelease`, signs +that simulator-only APK with the repository debug key, installs it with `adb`, +and sets `E2E_MOCK_SERVER_URL=http://10.0.2.2:7071` so the emulator can reach +the runner-hosted mock server. Sentry source-map upload is disabled for this +build; production deploy workflows and their signing/Sentry configuration are +separate and unchanged. + +Use the normal dispatch inputs to select the code and, optionally, a subset of +flows. The full mock-suite flow list remains `test:e2e:mock` in `package.json`. + +```bash +gh workflow run E2E -f ref=my/branch # Android (default) +gh workflow run E2E -f ref=my/branch -f flows="e2e/codex_parity.yaml" +gh workflow run E2E -f platform=ios -f ref=my/branch # retained iOS path +``` + +Android validation does not replace the separate local iOS XCTest check. The +iOS paths still use `e2e/check-sim.js` and `e2e/run-maestro.js`; the latter +keeps its Apple XCTest teardown-crash detection and artifacts. Android has an +API/device readiness check but no XCTest path. ## What it covers diff --git a/e2e/check-sim.js b/e2e/check-sim.js index 88cfac0e..71e6d554 100644 --- a/e2e/check-sim.js +++ b/e2e/check-sim.js @@ -14,6 +14,54 @@ // to bypass for any runtime above the compatibility ceiling below. const { execFileSync } = require('child_process') +const E2E_PLATFORM = process.env.E2E_PLATFORM || 'ios' +const ANDROID_API_LEVEL = process.env.E2E_ANDROID_API_LEVEL || '35' + +function checkAndroidEmulator() { + const devices = execFileSync('adb', ['devices'], { encoding: 'utf8' }) + .split('\n') + .slice(1) + .map((line) => line.trim().split(/\s+/)) + .filter(([serial, state]) => serial && state === 'device') + + if (devices.length !== 1) { + console.error( + `Error: expected exactly one ready Android emulator, found ${devices.length}.\n` + + 'Fix: boot one emulator, wait for it to finish starting, then re-run.', + ) + process.exit(1) + } + + const [serial] = devices[0] + const bootCompleted = execFileSync('adb', ['-s', serial, 'shell', 'getprop', 'sys.boot_completed'], { + encoding: 'utf8', + }).trim() + const apiLevel = execFileSync('adb', ['-s', serial, 'shell', 'getprop', 'ro.build.version.sdk'], { + encoding: 'utf8', + }).trim() + + if (bootCompleted !== '1') { + console.error(`Error: Android emulator ${serial} is connected but has not finished booting.`) + process.exit(1) + } + if (apiLevel !== ANDROID_API_LEVEL) { + console.error(`Error: Android emulator ${serial} runs API ${apiLevel || '?'}, expected API ${ANDROID_API_LEVEL}.`) + process.exit(1) + } + + console.log(`Android emulator is running: ${serial} (API ${apiLevel}).`) +} + +if (E2E_PLATFORM === 'android') { + checkAndroidEmulator() + process.exit(0) +} + +if (E2E_PLATFORM !== 'ios') { + console.error(`Error: unsupported E2E_PLATFORM '${E2E_PLATFORM}'. Expected 'ios' or 'android'.`) + process.exit(1) +} + // Highest iOS major exercised here with Maestro 2.6.1 or newer. Bump only after // a newer iOS runtime is verified; this ceiling is not a teardown-health claim. const MAX_SUPPORTED_IOS_MAJOR = 26 diff --git a/e2e/ensure-release-build.js b/e2e/ensure-release-build.js index d5536806..7e01f9a3 100755 --- a/e2e/ensure-release-build.js +++ b/e2e/ensure-release-build.js @@ -9,6 +9,13 @@ const BUNDLE_ID = 'com.ronenmars.threadbase' const APP_NAME = 'Threadbase.app' const REPO_ROOT = path.join(__dirname, '..') +if (process.env.E2E_PLATFORM === 'android') { + // Android CI builds and installs its Release APK before this shared suite + // starts. Keep the iOS freshness/install guard below for every local iOS path. + console.log('Android Release APK was installed by the E2E runner.') + process.exit(0) +} + // Set to reuse a build we know is stale (or can't prove is fresh) rather than // rebuilding — mirrors e2e/check-sim.js's E2E_ALLOW_UNSUPPORTED_IOS escape hatch. const ALLOW_STALE = process.env.E2E_ALLOW_STALE_BUILD === '1' diff --git a/e2e/setup.yaml b/e2e/setup.yaml index bb65b12d..28295c17 100644 --- a/e2e/setup.yaml +++ b/e2e/setup.yaml @@ -2,7 +2,8 @@ # 7-step onboarding carousel; on subsequent runs the app lands on the hub # directly because `expo-secure-store` survives `launchApp: clearState`. # Either way, the flow ends with hub-screen visible. -# Requires: mock server running on localhost:7071 +# Requires: mock server running at E2E_MOCK_SERVER_URL (localhost:7071 by default; +# Android CI uses 10.0.2.2:7071 to reach the GitHub runner host). appId: com.ronenmars.threadbase --- @@ -27,7 +28,7 @@ appId: com.ronenmars.threadbase - tapOn: id: "onboarding-connect-url-input" - - inputText: "http://localhost:7071" + - inputText: "${E2E_MOCK_SERVER_URL}" - pressKey: Enter diff --git a/package.json b/package.json index 59c0de43..83521d1f 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "test:integration": "jest --ci --testPathPattern='__tests__/integration'", "test:e2e": "jest --ci --testTimeout=20000 --testPathPattern='__tests__/e2e'", "test:i18n": "jest --ci --testPathPattern='__tests__/i18n'", - "test:e2e:mock": "node e2e/check-sim.js && node e2e/ensure-release-build.js && (MOCK_PORTS=7071,7072 node e2e/mock-server.js & MOCK_PID=$!; sleep 1; node e2e/run-maestro.js test --debug-output e2e/_artifacts/debug e2e/launch.yaml e2e/browse.yaml e2e/session_lifecycle.yaml e2e/server_drag_reorder.yaml e2e/bug6_bottom_bar_inset.yaml e2e/pty_turn_divider.yaml e2e/feat1_tree_drill_new_session.yaml e2e/feat2_export_in_info_shelf.yaml e2e/codex_parity.yaml e2e/voice_dictation.yaml e2e/settings_qr_scanner.yaml e2e/feedback_flow.yaml e2e/05_chat_flow.yaml e2e/06_search_anchor.yaml e2e/07_conversation_scroll_gaps.yaml; STATUS=$?; kill $MOCK_PID 2>/dev/null || true; exit $STATUS)", + "test:e2e:mock": "E2E_MOCK_SERVER_URL=${E2E_MOCK_SERVER_URL:-http://localhost:7071}; export E2E_MOCK_SERVER_URL; node e2e/check-sim.js && node e2e/ensure-release-build.js && (MOCK_PORTS=7071,7072 node e2e/mock-server.js & MOCK_PID=$!; sleep 1; node e2e/run-maestro.js test --debug-output e2e/_artifacts/debug e2e/launch.yaml e2e/browse.yaml e2e/session_lifecycle.yaml e2e/server_drag_reorder.yaml e2e/bug6_bottom_bar_inset.yaml e2e/pty_turn_divider.yaml e2e/feat1_tree_drill_new_session.yaml e2e/feat2_export_in_info_shelf.yaml e2e/codex_parity.yaml e2e/voice_dictation.yaml e2e/settings_qr_scanner.yaml e2e/feedback_flow.yaml e2e/05_chat_flow.yaml e2e/06_search_anchor.yaml e2e/07_conversation_scroll_gaps.yaml; STATUS=$?; kill $MOCK_PID 2>/dev/null || true; exit $STATUS)", "test:e2e:parallel-fetch": "node e2e/check-sim.js && (MOCK_PORT=7073 MOCK_TOTAL_CONVERSATIONS=30 MOCK_TOTAL_SESSIONS=25 MOCK_PAGE_DELAY_MS=3000 node e2e/pagination-mock-server.js & MOCK_PID=$!; sleep 1; node e2e/run-maestro.js test --debug-output e2e/_artifacts/debug e2e/parallel-fetch-progress.yaml; STATUS=$?; kill $MOCK_PID 2>/dev/null || true; exit $STATUS)", "test:e2e:parallel-fetch:non-merged": "node e2e/check-sim.js && (MOCK_PORT=7073 MOCK_TOTAL_CONVERSATIONS=30 MOCK_TOTAL_SESSIONS=25 MOCK_PAGE_DELAY_MS=3000 node e2e/pagination-mock-server.js & MOCK_PID=$!; sleep 1; node e2e/run-maestro.js test --debug-output e2e/_artifacts/debug e2e/non-merged-conv-loading.yaml; STATUS=$?; kill $MOCK_PID 2>/dev/null || true; exit $STATUS)", "test:e2e:ts1": "node e2e/check-sim.js && node e2e/ensure-release-build.js && (MOCK_PORTS=7071,7072 node e2e/mock-server.js & MOCK_PID=$!; sleep 1; node e2e/run-maestro.js test --debug-output e2e/_artifacts/debug e2e/ts1_onboarding_pairing.yaml; STATUS=$?; kill $MOCK_PID 2>/dev/null || true; exit $STATUS)",