diff --git a/.github/actions/setup-fixture-app/action.yml b/.github/actions/setup-fixture-app/action.yml index 6495842e5..bbdc40652 100644 --- a/.github/actions/setup-fixture-app/action.yml +++ b/.github/actions/setup-fixture-app/action.yml @@ -1,5 +1,5 @@ name: 'Setup Fixture App' -description: 'Install a ready Release build of examples/test-app on the booted iOS simulator, from the shared build cache' +description: 'Restore a ready Release build of examples/test-app from the shared build cache' # Any job that needs a controlled app to drive can use this instead of an Apple # system app. It fetches the Release binary that test-app-build-cache.yml @@ -20,6 +20,10 @@ description: 'Install a ready Release build of examples/test-app on the booted i # Relevant to #320 (move replay coverage off system apps onto a stable fixture). inputs: + platform: + description: 'Fixture platform to restore: ios or android' + required: false + default: 'ios' device-name: description: 'Simulator device name used for the inline-build fallback' required: false @@ -34,11 +38,14 @@ inputs: default: '0' outputs: app-path: - description: 'Path to the ready .app bundle' - value: ${{ steps.locate.outputs.app-path }} + description: 'Path to the ready .app bundle or APK' + value: ${{ steps.locate-ios.outputs.app-path || steps.locate-android.outputs.app-path }} + apk-path: + description: 'Path to the ready APK when platform is android' + value: ${{ steps.locate-android.outputs.app-path }} app-id: - description: "Bundle identifier read from the app's Info.plist" - value: ${{ steps.locate.outputs.app-id }} + description: 'Bundle/package identifier read from the restored artifact' + value: ${{ steps.locate-ios.outputs.app-id || steps.locate-android.outputs.app-id }} source: description: "Where the binary came from: 'artifact' or 'build'" value: ${{ steps.fetch.outputs.source }} @@ -51,113 +58,49 @@ runs: - name: Setup test app dependencies uses: ./.github/actions/setup-test-app-dependencies + # The Android replay host caches its helpers independently, so a cache hit + # must not make artifact inspection/repacking depend on the runner image. + - name: Ensure Android artifact build tools + if: inputs.platform == 'android' + shell: bash + run: | + # sdkmanager intentionally closes stdin after accepting licenses; disable + # the runner's inherited pipefail only around that benign SIGPIPE. + set -eu + SDK_ROOT="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}}" + BUILD_TOOLS="$SDK_ROOT/build-tools/36.0.0" + if [ -x "$BUILD_TOOLS/aapt" ] && [ -x "$BUILD_TOOLS/apksigner" ]; then + exit 0 + fi + SDKMANAGER="$SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" + if [ ! -x "$SDKMANAGER" ]; then + SDKMANAGER="$SDK_ROOT/cmdline-tools/bin/sdkmanager" + fi + if [ ! -x "$SDKMANAGER" ]; then + echo "::error::sdkmanager not found under $SDK_ROOT." >&2 + exit 1 + fi + set +o pipefail + yes | "$SDKMANAGER" --licenses >/dev/null + set -o pipefail + "$SDKMANAGER" "platforms;android-36" "build-tools;36.0.0" + - name: Fetch the cached Release binary id: fetch shell: bash env: GH_TOKEN: ${{ github.token }} run: | - set -euo pipefail - NAME="$(sh "$GITHUB_ACTION_PATH/resolve-artifact-name.sh" ios)" DEST="${{ github.workspace }}/.tmp/fixture-app" - REPOSITORY="${{ github.repository }}" - EXPECTED_HEAD_SHA="${{ github.event.pull_request.head.sha || github.sha }}" - TRUSTED_ARTIFACT="$GITHUB_ACTION_PATH/trusted-artifact.mjs" - rm -rf "$DEST"; mkdir -p "$DEST" - WAIT_SECONDS="${{ inputs.wait-for-artifact-seconds }}" - case "$WAIT_SECONDS" in - ''|*[!0-9]*) - echo "::error::wait-for-artifact-seconds must be a non-negative integer." - exit 1 - ;; - esac - - # A lookup failure (API outage, auth, transient 5xx) must not fail the - # caller or trigger a pointless wait: it takes the inline-build path. - query_artifact() { - node "$TRUSTED_ARTIFACT" find "$REPOSITORY" "$NAME" "$EXPECTED_HEAD_SHA" 2>/dev/null - } - query_producer_state() { - node "$TRUSTED_ARTIFACT" producer-state "$REPOSITORY" "$EXPECTED_HEAD_SHA" 2>/dev/null - } - if ! ART_ID="$(query_artifact)"; then - echo "::warning::Could not query the build cache for $NAME; building inline." - ART_ID="" - WAIT_SECONDS=0 - fi - if [ -z "$ART_ID" ] && [ "$WAIT_SECONDS" -gt 0 ]; then - DEADLINE=$((SECONDS + WAIT_SECONDS)) - MISSING_PRODUCER_POLLS=0 - echo "$NAME is not available; waiting up to ${WAIT_SECONDS}s for its producer." - while [ -z "$ART_ID" ] && [ "$SECONDS" -lt "$DEADLINE" ]; do - if ! PRODUCER_STATE="$(query_producer_state)"; then - echo "::warning::Could not inspect the fixture producer; building inline." - break - fi - case "$PRODUCER_STATE" in - queued) - echo "Fixture producer is queued; building inline instead of waiting for a macOS runner." - break - ;; - failed) - echo "Fixture producer failed; building inline." - break - ;; - success) - echo "Fixture producer completed without $NAME; building inline." - break - ;; - absent) - MISSING_PRODUCER_POLLS=$((MISSING_PRODUCER_POLLS + 1)) - if [ "$MISSING_PRODUCER_POLLS" -ge 3 ]; then - echo "No fixture producer appeared for $EXPECTED_HEAD_SHA; building inline." - break - fi - ;; - in_progress) - MISSING_PRODUCER_POLLS=0 - ;; - *) - echo "::warning::Fixture producer returned unknown state '$PRODUCER_STATE'; building inline." - break - ;; - esac - sleep 30 - if ! ART_ID="$(query_artifact)"; then - echo "::warning::Build cache polling failed for $NAME; building inline." - ART_ID="" - break - fi - if [ -n "$ART_ID" ]; then - break - fi - done - fi - - SOURCE=build - if [ -n "$ART_ID" ]; then - STAGE="$(mktemp -d)" - # Any failure along the download path falls through to an inline build - # rather than failing the caller. - if gh api "repos/${{ github.repository }}/actions/artifacts/${ART_ID}/zip" > "$STAGE/a.zip" \ - && unzip -q "$STAGE/a.zip" -d "$STAGE" \ - && tar -xzf "$STAGE/binary.tar.gz" -C "$DEST"; then - SOURCE=artifact - echo "restored $NAME from the build cache" - else - echo "::warning::Could not restore $NAME; building inline." - rm -rf "$DEST"/*.app - fi - rm -rf "$STAGE" - else - echo "$NAME not in the cache after the configured wait; building inline." - fi - echo "source=$SOURCE" >> "$GITHUB_OUTPUT" + bash "$GITHUB_ACTION_PATH/fetch-artifact.sh" \ + "${{ inputs.platform }}" "$DEST" "${{ inputs.wait-for-artifact-seconds }}" \ + "${{ github.repository }}" "${{ github.event.pull_request.head.sha || github.sha }}" \ + "$GITHUB_ACTION_PATH" # Fallback only: the producer builds the same way. `--device generic` avoids # needing a booted simulator; Release embeds the bundle so no Metro is needed. - name: Build the Release app (fallback) - if: steps.fetch.outputs.source == 'build' + if: inputs.platform == 'ios' && steps.fetch.outputs.source == 'build' shell: bash run: | set -euo pipefail @@ -168,7 +111,8 @@ runs: --output "${{ github.workspace }}/.tmp/fixture-app" - name: Locate fixture app - id: locate + id: locate-ios + if: inputs.platform == 'ios' shell: bash run: | set -euo pipefail @@ -188,11 +132,11 @@ runs: # --js-bundle-only refreshes exactly the gap the native fingerprint leaves; # an inline build already has current JS, so it is skipped there. - name: Repack the cached app with the current JS - if: steps.fetch.outputs.source == 'artifact' + if: inputs.platform == 'ios' && steps.fetch.outputs.source == 'artifact' shell: bash run: | set -euo pipefail - APP="${{ steps.locate.outputs.app-path }}" + APP="${{ steps.locate-ios.outputs.app-path }}" OUT="${{ github.workspace }}/.tmp/fixture-app-repacked/$(basename "$APP")" rm -rf "$(dirname "$OUT")"; mkdir -p "$(dirname "$OUT")" pnpm --dir examples/test-app exec repack-app \ @@ -204,16 +148,45 @@ runs: mv "$OUT" "$APP" - name: Install fixture app - if: inputs.install == 'true' + if: inputs.platform == 'ios' && inputs.install == 'true' shell: bash run: | set -euo pipefail - xcrun simctl install booted "${{ steps.locate.outputs.app-path }}" + xcrun simctl install booted "${{ steps.locate-ios.outputs.app-path }}" # Fail loudly rather than let a caller drive a device with no app: a # missing app produces scenarios that fail for the wrong reason, or pass # while proving nothing. - if ! xcrun simctl get_app_container booted "${{ steps.locate.outputs.app-id }}" >/dev/null 2>&1; then - echo "::error::Fixture app ${{ steps.locate.outputs.app-id }} is not installed on the booted simulator." + if ! xcrun simctl get_app_container booted "${{ steps.locate-ios.outputs.app-id }}" >/dev/null 2>&1; then + echo "::error::Fixture app ${{ steps.locate-ios.outputs.app-id }} is not installed on the booted simulator." exit 1 fi - echo "installed ${{ steps.locate.outputs.app-id }}" + echo "installed ${{ steps.locate-ios.outputs.app-id }}" + + - name: Build the Android Release APK (fallback) + if: inputs.platform == 'android' && steps.fetch.outputs.source == 'build' + shell: bash + run: | + set -euo pipefail + pnpm --dir examples/test-app exec expo prebuild --platform android --no-install + ( + cd examples/test-app/android + ./gradlew :app:assembleRelease + ) + cp examples/test-app/android/app/build/outputs/apk/release/app-release.apk \ + "${{ github.workspace }}/.tmp/fixture-app/fixture-app.apk" + + - name: Locate fixture APK + id: locate-android + if: inputs.platform == 'android' + shell: bash + run: 'bash "$GITHUB_ACTION_PATH/locate-android-apk.sh" "${{ github.workspace }}/.tmp/fixture-app"' + + - name: Repack the cached APK with the current JS + if: inputs.platform == 'android' && steps.fetch.outputs.source == 'artifact' + shell: bash + run: | + set -euo pipefail + APK="${{ steps.locate-android.outputs.app-path }}" + OUT="${{ github.workspace }}/.tmp/fixture-app-repacked/$(basename "$APK")" + bash "$GITHUB_ACTION_PATH/repack-android-apk.sh" "$APK" "$OUT" + mv "$OUT" "$APK" diff --git a/.github/actions/setup-fixture-app/fetch-artifact.sh b/.github/actions/setup-fixture-app/fetch-artifact.sh new file mode 100644 index 000000000..f6ea077f9 --- /dev/null +++ b/.github/actions/setup-fixture-app/fetch-artifact.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Shared trusted-artifact retrieval for the fixture-app platform adapters. +set -euo pipefail + +PLATFORM="${1:?platform is required}" +DEST="${2:?destination is required}" +WAIT_SECONDS="${3:?wait duration is required}" +REPOSITORY="${4:?repository is required}" +EXPECTED_HEAD_SHA="${5:?expected head SHA is required}" +ACTION_PATH="${6:?action path is required}" + +NAME="$(sh "$ACTION_PATH/resolve-artifact-name.sh" "$PLATFORM")" +TRUSTED_ARTIFACT="$ACTION_PATH/trusted-artifact.mjs" +rm -rf "$DEST"; mkdir -p "$DEST" +case "$WAIT_SECONDS" in + ''|*[!0-9]*) + echo "::error::wait-for-artifact-seconds must be a non-negative integer." + exit 1 + ;; +esac + +# A lookup failure (API outage, auth, transient 5xx) must not fail the caller +# or trigger a pointless wait: it takes the inline-build path. +query_artifact() { + node "$TRUSTED_ARTIFACT" find "$REPOSITORY" "$NAME" "$EXPECTED_HEAD_SHA" 2>/dev/null +} +query_producer_state() { + node "$TRUSTED_ARTIFACT" producer-state "$REPOSITORY" "$EXPECTED_HEAD_SHA" 2>/dev/null +} +if ! ART_ID="$(query_artifact)"; then + echo "::warning::Could not query the build cache for $NAME; building inline." + ART_ID="" + WAIT_SECONDS=0 +fi +if [ -z "$ART_ID" ] && [ "$WAIT_SECONDS" -gt 0 ]; then + DEADLINE=$((SECONDS + WAIT_SECONDS)) + MISSING_PRODUCER_POLLS=0 + echo "$NAME is not available; waiting up to ${WAIT_SECONDS}s for its producer." + while [ -z "$ART_ID" ] && [ "$SECONDS" -lt "$DEADLINE" ]; do + if ! PRODUCER_STATE="$(query_producer_state)"; then + echo "::warning::Could not inspect the fixture producer; building inline." + break + fi + case "$PRODUCER_STATE" in + queued) + echo "Fixture producer is queued; building inline instead of waiting for a native runner." + break + ;; + failed) + echo "Fixture producer failed; building inline." + break + ;; + success) + echo "Fixture producer completed without $NAME; building inline." + break + ;; + absent) + MISSING_PRODUCER_POLLS=$((MISSING_PRODUCER_POLLS + 1)) + if [ "$MISSING_PRODUCER_POLLS" -ge 3 ]; then + echo "No fixture producer appeared for $EXPECTED_HEAD_SHA; building inline." + break + fi + ;; + in_progress) + MISSING_PRODUCER_POLLS=0 + ;; + *) + echo "::warning::Fixture producer returned unknown state '$PRODUCER_STATE'; building inline." + break + ;; + esac + sleep 30 + if ! ART_ID="$(query_artifact)"; then + echo "::warning::Build cache polling failed for $NAME; building inline." + ART_ID="" + break + fi + done +fi + +SOURCE=build +if [ -n "$ART_ID" ]; then + STAGE="$(mktemp -d)" + # Any failure along the download path falls through to an inline build rather + # than failing the caller. + if gh api "repos/${REPOSITORY}/actions/artifacts/${ART_ID}/zip" > "$STAGE/a.zip" \ + && unzip -q "$STAGE/a.zip" -d "$STAGE" \ + && tar -xzf "$STAGE/binary.tar.gz" -C "$DEST"; then + SOURCE=artifact + echo "restored $NAME from the build cache" + else + echo "::warning::Could not restore $NAME; building inline." + rm -rf "$DEST"/* + fi + rm -rf "$STAGE" +else + echo "$NAME not in the cache after the configured wait; building inline." +fi +echo "source=$SOURCE" >> "$GITHUB_OUTPUT" diff --git a/.github/actions/setup-fixture-app/locate-android-apk.sh b/.github/actions/setup-fixture-app/locate-android-apk.sh new file mode 100644 index 000000000..1b25711e8 --- /dev/null +++ b/.github/actions/setup-fixture-app/locate-android-apk.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +DEST="${1:?fixture directory is required}" +SDK_ROOT="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}}" +BUILD_TOOLS="$SDK_ROOT/build-tools/36.0.0" + +APKS=() +while IFS= read -r apk; do + APKS+=("$apk") +done < <(find "$DEST" -maxdepth 1 -type f -name '*.apk' -print) +if [ "${#APKS[@]}" -ne 1 ]; then + echo "::error::Expected exactly one fixture APK at $DEST; found ${#APKS[@]}." >&2 + exit 1 +fi +if [ ! -x "$BUILD_TOOLS/aapt" ]; then + echo "::error::Android build tools were not found at $BUILD_TOOLS." >&2 + exit 1 +fi +if ! BADGING="$("$BUILD_TOOLS/aapt" dump badging "${APKS[0]}")"; then + echo "::error::Could not inspect fixture APK ${APKS[0]}; it may be malformed." >&2 + exit 1 +fi +APP_ID="$(printf '%s\n' "$BADGING" | sed -n "s/^package: name='\([^']*\)'.*/\1/p")" +if [ -z "$APP_ID" ]; then + echo "::error::Fixture APK ${APKS[0]} has no readable package id." >&2 + exit 1 +fi +echo "app-path=${APKS[0]}" >> "$GITHUB_OUTPUT" +echo "app-id=$APP_ID" >> "$GITHUB_OUTPUT" +echo "fixture APK: $(basename "${APKS[0]}") ($APP_ID)" diff --git a/.github/actions/setup-fixture-app/repack-android-apk.sh b/.github/actions/setup-fixture-app/repack-android-apk.sh new file mode 100644 index 000000000..9628a5528 --- /dev/null +++ b/.github/actions/setup-fixture-app/repack-android-apk.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +APK="${1:?source APK is required}" +OUT="${2:?output APK is required}" +SDK_ROOT="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}}" +BUILD_TOOLS="$SDK_ROOT/build-tools/36.0.0" +if [ ! -x "$BUILD_TOOLS/aapt" ] || [ ! -x "$BUILD_TOOLS/apksigner" ]; then + echo "::error::Android build tools were not found at $BUILD_TOOLS." >&2 + exit 1 +fi +rm -rf "$(dirname "$OUT")"; mkdir -p "$(dirname "$OUT")" +# repack-app signs APKs with its packaged Android debug key when no key options +# are supplied. That is the same installable debug-signing contract used by the +# generated Expo Release build, and avoids carrying a key through artifacts. +pnpm --dir examples/test-app exec repack-app \ + --platform android \ + --source-app "$APK" \ + --output "$OUT" \ + --android-build-tools-dir "$BUILD_TOOLS" \ + --js-bundle-only +if ! "$BUILD_TOOLS/apksigner" verify --verbose "$OUT"; then + echo "::error::Repacked APK has an invalid signature." >&2 + exit 1 +fi + +apk_package_id() { + local badging + badging="$("$BUILD_TOOLS/aapt" dump badging "$1")" || return 1 + printf '%s\n' "$badging" | sed -n "s/^package: name='\([^']*\)'.*/\1/p" +} + +apk_signer_digest() { + local certificate + certificate="$("$BUILD_TOOLS/apksigner" verify --print-certs "$1")" || return 1 + printf '%s\n' "$certificate" | sed -n 's/^Signer #1 certificate SHA-256 digest: //p' +} + +SOURCE_APP_ID="$(apk_package_id "$APK")" || SOURCE_APP_ID="" +OUTPUT_APP_ID="$(apk_package_id "$OUT")" || OUTPUT_APP_ID="" +if [ -z "$SOURCE_APP_ID" ] || [ "$SOURCE_APP_ID" != "$OUTPUT_APP_ID" ]; then + echo "::error::Repacked APK did not preserve package id $SOURCE_APP_ID." >&2 + exit 1 +fi +SOURCE_CERTIFICATE="$(apk_signer_digest "$APK")" || SOURCE_CERTIFICATE="" +OUTPUT_CERTIFICATE="$(apk_signer_digest "$OUT")" || OUTPUT_CERTIFICATE="" +if [ -z "$SOURCE_CERTIFICATE" ] || [ "$SOURCE_CERTIFICATE" != "$OUTPUT_CERTIFICATE" ]; then + echo "::error::Repacked APK did not preserve the source signing certificate." >&2 + exit 1 +fi diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 35b353bca..155aff259 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -16,6 +16,7 @@ on: permissions: contents: read + actions: read concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} @@ -39,6 +40,17 @@ jobs: with: package-helpers: 'true' + - name: Restore fixture APK + id: fixture-app + uses: ./.github/actions/setup-fixture-app + with: + platform: android + install: 'false' + wait-for-artifact-seconds: '600' + + - name: Report fixture cache source + run: echo "Android fixture APK source=${{ steps.fixture-app.outputs.source }}" + - name: Run Android smoke checks uses: reactivecircus/android-emulator-runner@b530d96654c385303d652368551fb075bc2f0b6b # v2.35.0 with: @@ -48,7 +60,7 @@ jobs: target: google_apis_playstore emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -no-metrics script: | - set -e + sh ./test/scripts/android-fixture-cache-smoke.sh "${{ steps.fixture-app.outputs.apk-path }}" "${{ steps.fixture-app.outputs.app-id }}" node --experimental-strip-types src/bin.ts test test/integration/replays/android/01-settings.ad --retries 2 --report-junit test/artifacts/replays-android-smoke.junit.xml sh ./test/scripts/android-snapshot-helper-release-smoke.sh diff --git a/.github/workflows/test-app-build-cache.yml b/.github/workflows/test-app-build-cache.yml index 06397f00f..990a76bc3 100644 --- a/.github/workflows/test-app-build-cache.yml +++ b/.github/workflows/test-app-build-cache.yml @@ -122,6 +122,15 @@ jobs: if: matrix.build uses: ./.github/actions/setup-test-app-dependencies + # The generated Android project is safe to assemble without an emulator. + # Keep Gradle's heavy dependency/transforms cache, but never let an + # untrusted fork PR write it. + - name: Setup Gradle cache + if: matrix.platform == 'android' && matrix.build + uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3 + with: + cache-read-only: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }} + # `--device generic` builds for the simulator without booting one or # installing. Release embeds the bundle (no Metro) and links a universal # x86_64+arm64 slice, so the artifact runs on any developer's simulator. @@ -142,10 +151,6 @@ jobs: --no-bundler \ --output "${{ github.workspace }}/.tmp/test-app-build" - - name: Setup Android host - if: matrix.platform == 'android' && matrix.build - uses: ./.github/actions/setup-android-replay-host - # Deliberately no `set -o pipefail`: `yes` is killed by SIGPIPE once # sdkmanager stops reading, and pipefail would report that 141 as the # step's own failure. @@ -164,27 +169,21 @@ jobs: yes | "$SDKMANAGER" --licenses >/dev/null "$SDKMANAGER" "platforms;android-36" "build-tools;36.0.0" - # Android has no build-only mode, so an emulator satisfies device - # resolution; it installs the APK as a bonus check that it loads. A Release - # build already spans every ABI (the CLI only narrows debug builds to the - # device ABI), so no --all-arch and no emulator-architecture coupling. + # CNG's generated Android project exposes the ordinary Gradle Release task; + # it builds and packages every ABI without device resolution. Installation + # and launch are deliberately proven by the Android consumer instead. - name: Build the Android Release apk if: matrix.platform == 'android' && matrix.build - uses: reactivecircus/android-emulator-runner@b530d96654c385303d652368551fb075bc2f0b6b # v2.35.0 - with: - api-level: 36 - arch: x86_64 - profile: pixel_7 - target: google_apis_playstore - emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -no-metrics - # This action runs each line in its own `sh -c`, so every line must be - # self-contained: no line continuations, and no variables shared between - # lines. `expo run:android` also installs and launches the APK, proving - # the Release build loads before it is published. - script: | - pnpm --dir examples/test-app exec expo run:android --variant release --no-bundler - mkdir -p "${{ github.workspace }}/.tmp/test-app-build" - cp "$(find examples/test-app/android/app/build/outputs/apk/release -name '*.apk' | head -1)" "${{ github.workspace }}/.tmp/test-app-build/app-release.apk" + run: | + set -euo pipefail + pnpm --dir examples/test-app exec expo prebuild --platform android --no-install + ( + cd examples/test-app/android + ./gradlew :app:assembleRelease + ) + mkdir -p "${{ github.workspace }}/.tmp/test-app-build" + cp examples/test-app/android/app/build/outputs/apk/release/app-release.apk \ + "${{ github.workspace }}/.tmp/test-app-build/app-release.apk" - name: Stage the binary for upload id: stage diff --git a/src/contracts/result-serialization.test.ts b/src/contracts/result-serialization.test.ts index 6044ecff1..df3faac26 100644 --- a/src/contracts/result-serialization.test.ts +++ b/src/contracts/result-serialization.test.ts @@ -106,6 +106,7 @@ test('serializeSnapshotResult includes Android backend metadata', () => { const data = serializeSnapshotResult({ nodes: [], truncated: false, + appBundleId: 'com.callstack.agentdevicelab', androidSnapshot: { backend: 'android-helper', helperVersion: '0.13.3', @@ -121,6 +122,7 @@ test('serializeSnapshotResult includes Android backend metadata', () => { assert.deepEqual(data, { nodes: [], truncated: false, + appBundleId: 'com.callstack.agentdevicelab', androidSnapshot: { backend: 'android-helper', helperVersion: '0.13.3', diff --git a/src/core/__tests__/capabilities.test.ts b/src/core/__tests__/capabilities.test.ts index 45a2066c9..19dcf72a1 100644 --- a/src/core/__tests__/capabilities.test.ts +++ b/src/core/__tests__/capabilities.test.ts @@ -221,6 +221,19 @@ test('core commands support iOS simulator, iOS device, and Android', () => { ); }); +test('Android denies Apple runner preparation and viewport mutation until durable backends exist', () => { + assertCommandSupport( + ['prepare', 'viewport'], + [ + { device: iosSimulator, expected: true, label: 'on iOS simulator' }, + { device: macOsDevice, expected: true, label: 'on macOS' }, + { device: tvOsSimulator, expected: true, label: 'on tvOS simulator' }, + { device: androidDevice, expected: false, label: 'on Android device' }, + { device: androidEmulator, expected: false, label: 'on Android emulator' }, + ], + ); +}); + test('capabilities reject CoreDevice-only commands for XCTest-backed devices', () => { const coreDeviceOnlyCommands = [ 'apps', diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index 6122ac926..caa02111d 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -715,6 +715,9 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'public' }, recordsSessionAction: false, daemon: { route: 'session', refFrameEffect: 'preserve' }, + // `ios-runner` has no Android implementation; admission must agree with + // the handler rather than advertising a command that always rejects later. + capability: { apple: APPLE_SIM_AND_DEVICE, android: {}, linux: LINUX_NONE }, // Runner warm-up builds are the longest fixed envelope; --timeout overrides. timeoutPolicy: { budget: { source: 'flag' }, @@ -1134,7 +1137,9 @@ export const RAW_COMMAND_DESCRIPTORS = [ recordingEffect: 'mutates-app', daemon: { route: 'generic', refFrameEffect: 'may-invalidate' }, dispatch: {}, - capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_NONE }, + // Android has no durable viewport set/read/reset lifecycle. Deny it until + // that contract, including cleanup, exists instead of accepting a no-op. + capability: { apple: APPLE_SIM_AND_DEVICE, android: {}, linux: LINUX_NONE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, }, diff --git a/src/daemon/handlers/__tests__/session-capabilities.test.ts b/src/daemon/handlers/__tests__/session-capabilities.test.ts index 363f851d9..f9f740d1d 100644 --- a/src/daemon/handlers/__tests__/session-capabilities.test.ts +++ b/src/daemon/handlers/__tests__/session-capabilities.test.ts @@ -7,6 +7,11 @@ import { withTargetDeviceResolutionScope } from '../../../core/dispatch-resolve. import type { DeviceInfo } from '../../../kernel/device.ts'; import { handleSessionCommands } from '../session.ts'; +function assertAndroidCapabilityHonesty(availableCommands: unknown): void { + expect(availableCommands).not.toContain(PUBLIC_COMMANDS.prepare); + expect(availableCommands).not.toContain(PUBLIC_COMMANDS.viewport); +} + test('capabilities reports supported commands for the selected session device', async () => { const sessionName = 'android-capabilities'; const sessionStore = makeSessionStore('agent-device-capabilities-'); @@ -47,6 +52,7 @@ test('capabilities reports supported commands for the selected session device', ); expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.capabilities); expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.devices); + assertAndroidCapabilityHonesty(response.data?.availableCommands); }); test('capabilities accepts a stopped Android AVD placeholder for explicit platform discovery', async () => { diff --git a/src/daemon/handlers/__tests__/session-open-url-prewarm.test.ts b/src/daemon/handlers/__tests__/session-open-url-prewarm.test.ts index dad162917..9cca63c2e 100644 --- a/src/daemon/handlers/__tests__/session-open-url-prewarm.test.ts +++ b/src/daemon/handlers/__tests__/session-open-url-prewarm.test.ts @@ -741,9 +741,7 @@ test('prepare ios-runner rejects non-Apple runner devices', async () => { expect(response?.ok).toBe(false); if (response && !response.ok) { expect(response.error.code).toBe('UNSUPPORTED_OPERATION'); - expect(response.error.message).toBe( - 'prepare ios-runner is only supported on Apple runner platforms', - ); + expect(response.error.message).toBe('prepare is not supported on this device'); } expect(mockPrepareIosRunner).not.toHaveBeenCalled(); }); diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 15eef43d6..2d13563cb 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -7,7 +7,7 @@ import { type PrepareIosRunnerResult, } from '../../platforms/apple/core/runner/runner-client.ts'; import type { DeviceInfo } from '../../kernel/device.ts'; -import { isApplePlatform, publicPlatformString } from '../../kernel/device.ts'; +import { publicPlatformString } from '../../kernel/device.ts'; import type { DaemonInvokeFn, DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { contextFromFlags } from '../context.ts'; @@ -71,12 +71,8 @@ async function handlePrepareCommand(params: { flags, ensureReady: true, }); - if (!isApplePlatform(device.platform)) { - return errorResponse( - 'UNSUPPORTED_OPERATION', - 'prepare ios-runner is only supported on Apple runner platforms', - ); - } + const unsupported = requireCommandSupported(PUBLIC_COMMANDS.prepare, device); + if (unsupported) return unsupported; const startedAtMs = Date.now(); const result = await prepareIosRunner( diff --git a/test/ci/trusted-fixture-artifact.test.mjs b/test/ci/trusted-fixture-artifact.test.mjs index dd957df6c..ee653590d 100644 --- a/test/ci/trusted-fixture-artifact.test.mjs +++ b/test/ci/trusted-fixture-artifact.test.mjs @@ -9,6 +9,7 @@ import { classifyProducerState, findTrustedArtifact, } from '../../.github/actions/setup-fixture-app/trusted-artifact.mjs'; +import { assertAndroidFixtureSnapshot } from '../scripts/assert-android-fixture-snapshot.mjs'; const repository = { default_branch: 'main', id: 42 }; const trustedRun = { @@ -21,20 +22,35 @@ const trustedRun = { status: 'in_progress', }; -test('producer, consumer, upload, and concurrency use the canonical artifact name', () => { +test('producer, consumers, upload, and concurrency use the canonical platform-scoped artifact name', () => { const action = parse(fs.readFileSync('.github/actions/setup-fixture-app/action.yml', 'utf8')); const workflow = parse(fs.readFileSync('.github/workflows/test-app-build-cache.yml', 'utf8')); const fetchStep = action.runs.steps.find((step) => step.id === 'fetch'); + const androidBuildToolsStep = action.runs.steps.find( + (step) => step.name === 'Ensure Android artifact build tools', + ); + const androidFallbackStep = action.runs.steps.find( + (step) => step.name === 'Build the Android Release APK (fallback)', + ); const fingerprintStep = workflow.jobs.fingerprint.steps.find((step) => step.id === 'fingerprint'); const uploadStep = workflow.jobs.release.steps.find((step) => step.uses?.startsWith('actions/upload-artifact@'), ); - assert.match( - fetchStep.run, - /NAME="\$\(sh "\$GITHUB_ACTION_PATH\/resolve-artifact-name\.sh" ios\)"/, + const fetchArtifact = fs.readFileSync( + '.github/actions/setup-fixture-app/fetch-artifact.sh', + 'utf8', ); - assert.match(fetchStep.run, /find "\$REPOSITORY" "\$NAME" "\$EXPECTED_HEAD_SHA"/); + + assert.match(fetchStep.run, /"\$\{\{ inputs\.platform \}\}" "\$DEST"/); + assert.match(fetchArtifact, /resolve-artifact-name\.sh" "\$PLATFORM"/); + assert.match(fetchArtifact, /find "\$REPOSITORY" "\$NAME" "\$EXPECTED_HEAD_SHA"/); + assert.match(androidBuildToolsStep.run, /build-tools;36\.0\.0/); + assert.match(androidBuildToolsStep.run, /apksigner/); + assert.match(androidBuildToolsStep.run, /set \+o pipefail/); + assert.match(androidFallbackStep.if, /inputs\.platform == 'android'/); + assert.match(androidFallbackStep.run, /expo prebuild --platform android --no-install/); + assert.match(androidFallbackStep.run, /:app:assembleRelease/); assert.match( fingerprintStep.run, /ARTIFACT_NAME_RESOLVER="\.github\/actions\/setup-fixture-app\/resolve-artifact-name\.sh"/, @@ -48,6 +64,238 @@ test('producer, consumer, upload, and concurrency use the canonical artifact nam ); }); +test('Android smoke keeps its install/open/snapshot evidence in a checked-in script', (t) => { + const workflow = parse(fs.readFileSync('.github/workflows/android.yml', 'utf8')); + const smokeStep = workflow.jobs['smoke-android'].steps.find( + (step) => step.name === 'Run Android smoke checks', + ); + const restoreStep = workflow.jobs['smoke-android'].steps.find( + (step) => step.name === 'Restore fixture APK', + ); + const sourceStep = workflow.jobs['smoke-android'].steps.find( + (step) => step.name === 'Report fixture cache source', + ); + const assertion = fs.readFileSync('test/scripts/assert-android-fixture-snapshot.mjs', 'utf8'); + const smokeScript = fs.readFileSync('test/scripts/android-fixture-cache-smoke.sh', 'utf8'); + const packageVersion = JSON.parse(fs.readFileSync('package.json', 'utf8')).version; + + assert.match(smokeStep.with.script, /android-fixture-cache-smoke\.sh/); + assert.match(smokeStep.with.script, /steps\.fixture-app\.outputs\.apk-path/); + assert.match(smokeStep.with.script, /steps\.fixture-app\.outputs\.app-id/); + assert.equal(restoreStep.with['wait-for-artifact-seconds'], '600'); + assert.match(sourceStep.run, /steps\.fixture-app\.outputs\.source/); + assert.match(smokeScript, /snapshot -i .*--json > "\$SNAPSHOT_PATH"/); + assert.match(smokeScript, /\[ -z "\$1" \] \|\| \[ -z "\$2" \]/); + assert.match(smokeScript, /assert-android-fixture-snapshot\.mjs/); + assert.match(assertion, /metadata\.backend !== 'android-helper'/); + assert.match(assertion, /metadata\.helperVersion !== packageVersion/); + assert.match(assertion, /Agent Device Tester/); + assert.throws( + () => + assertAndroidFixtureSnapshot( + { success: true, data: { androidSnapshot: { backend: 'uiautomator' }, nodes: [] } }, + 'com.callstack.agentdevicelab', + packageVersion, + ), + /Expected android-helper backend/, + ); + + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'fixture-android-snapshot-')); + t.after(() => fs.rmSync(tempRoot, { force: true, recursive: true })); + const snapshotPath = path.join(tempRoot, 'snapshot.json'); + fs.writeFileSync( + snapshotPath, + JSON.stringify({ + success: true, + data: { + appBundleId: 'com.callstack.agentdevicelab', + androidSnapshot: { backend: 'android-helper', helperVersion: packageVersion }, + nodes: [{ label: 'Agent Device Tester' }], + }, + }), + ); + const result = spawnSync( + process.execPath, + [ + 'test/scripts/assert-android-fixture-snapshot.mjs', + snapshotPath, + 'com.callstack.agentdevicelab', + ], + { cwd: process.cwd(), encoding: 'utf8' }, + ); + assert.equal(result.status, 0, result.stderr); + + fs.writeFileSync( + snapshotPath, + JSON.stringify({ + success: true, + data: { + appBundleId: 'com.callstack.agentdevicelab', + androidSnapshot: { backend: 'android-helper', helperVersion: 'stale-helper' }, + nodes: [{ label: 'Agent Device Tester' }], + }, + }), + ); + const staleHelper = spawnSync( + process.execPath, + [ + 'test/scripts/assert-android-fixture-snapshot.mjs', + snapshotPath, + 'com.callstack.agentdevicelab', + ], + { cwd: process.cwd(), encoding: 'utf8' }, + ); + assert.notEqual(staleHelper.status, 0); + assert.match(staleHelper.stderr, /Expected helper version/); +}); + +test('Android APK locator emits an exact APK path and package id, and rejects collisions or malformed artifacts', (t) => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'fixture-android-apk-')); + t.after(() => fs.rmSync(tempRoot, { force: true, recursive: true })); + const apkDir = path.join(tempRoot, 'apk'); + const binDir = path.join(tempRoot, 'build-tools', '36.0.0'); + const outputPath = path.join(tempRoot, 'output'); + fs.mkdirSync(apkDir); + fs.mkdirSync(binDir, { recursive: true }); + fs.writeFileSync( + path.join(binDir, 'aapt'), + [ + '#!/bin/sh', + 'if [ "${TEST_AAPT_FAIL:-}" = 1 ]; then exit 23; fi', + 'if [ -n "$TEST_AAPT_PACKAGE" ]; then', + ' printf "package: name=\'%s\' versionCode=1 versionName=1\\n" "$TEST_AAPT_PACKAGE"', + 'fi', + '', + ].join('\n'), + ); + fs.chmodSync(path.join(binDir, 'aapt'), 0o755); + const runLocator = (packageId, aaptFails = false) => + spawnSync('bash', ['.github/actions/setup-fixture-app/locate-android-apk.sh', apkDir], { + cwd: process.cwd(), + encoding: 'utf8', + env: { + ...process.env, + ANDROID_HOME: tempRoot, + GITHUB_OUTPUT: outputPath, + PATH: `${binDir}:${process.env.PATH}`, + TEST_AAPT_PACKAGE: packageId, + TEST_AAPT_FAIL: aaptFails ? '1' : '', + }, + }); + + fs.writeFileSync(path.join(apkDir, 'fixture-app.apk'), 'not-a-real-apk'); + let result = runLocator('com.example.fixture'); + assert.equal(result.status, 0, result.stderr); + assert.equal( + fs.readFileSync(outputPath, 'utf8'), + `app-path=${path.join(apkDir, 'fixture-app.apk')}\napp-id=com.example.fixture\n`, + ); + + fs.writeFileSync(path.join(apkDir, 'another.apk'), 'not-a-real-apk'); + result = runLocator('com.example.fixture'); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Expected exactly one fixture APK/); + + fs.rmSync(path.join(apkDir, 'another.apk')); + result = runLocator(''); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /has no readable package id/); + + result = runLocator('com.example.fixture', true); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /it may be malformed/); +}); + +test('Android APK repack signs the output and preserves its package id', (t) => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'fixture-android-repack-')); + t.after(() => fs.rmSync(tempRoot, { force: true, recursive: true })); + const buildTools = path.join(tempRoot, 'build-tools', '36.0.0'); + const binDir = path.join(tempRoot, 'bin'); + const source = path.join(tempRoot, 'source.apk'); + const output = path.join(tempRoot, 'output', 'fixture.apk'); + const commandLog = path.join(tempRoot, 'commands'); + fs.mkdirSync(buildTools, { recursive: true }); + fs.mkdirSync(binDir); + fs.writeFileSync(source, 'fixture'); + fs.writeFileSync( + path.join(buildTools, 'aapt'), + [ + '#!/bin/sh', + 'printf "package: name=\'com.example.fixture\' versionCode=1 versionName=1\\n"', + '', + ].join('\n'), + ); + fs.writeFileSync( + path.join(buildTools, 'apksigner'), + [ + '#!/bin/sh', + 'if [ "$1" != verify ] || [ ! -f "$3" ]; then exit 2; fi', + 'if [ "${2:-}" = --print-certs ]; then', + ' digest="$TEST_SOURCE_DIGEST"', + ' if [ "$3" = "$TEST_REPACK_OUTPUT" ]; then digest="$TEST_OUTPUT_DIGEST"; fi', + ' printf "Signer #1 certificate SHA-256 digest: %s\\n" "$digest"', + 'fi', + '', + ].join('\n'), + ); + fs.writeFileSync( + path.join(binDir, 'pnpm'), + [ + '#!/bin/sh', + 'printf "%s\\n" "$*" >> "$TEST_COMMAND_LOG"', + 'while [ "$#" -gt 0 ]; do', + ' case "$1" in', + ' --source-app) source="$2"; shift 2 ;;', + ' --output) output="$2"; shift 2 ;;', + ' *) shift ;;', + ' esac', + 'done', + 'mkdir -p "$(dirname "$output")"', + 'cp "$source" "$output"', + '', + ].join('\n'), + ); + for (const executable of [ + path.join(buildTools, 'aapt'), + path.join(buildTools, 'apksigner'), + path.join(binDir, 'pnpm'), + ]) { + fs.chmodSync(executable, 0o755); + } + + const runRepack = (repackOutput, outputDigest = 'source-digest') => + spawnSync( + 'bash', + ['.github/actions/setup-fixture-app/repack-android-apk.sh', source, repackOutput], + { + cwd: process.cwd(), + encoding: 'utf8', + env: { + ...process.env, + ANDROID_HOME: tempRoot, + PATH: `${binDir}:${process.env.PATH}`, + TEST_COMMAND_LOG: commandLog, + TEST_REPACK_OUTPUT: repackOutput, + TEST_SOURCE_DIGEST: 'source-digest', + TEST_OUTPUT_DIGEST: outputDigest, + }, + }, + ); + + const result = runRepack(output); + assert.equal(result.status, 0, result.stderr); + assert.equal(fs.readFileSync(output, 'utf8'), 'fixture'); + assert.match( + fs.readFileSync(commandLog, 'utf8'), + /repack-app --platform android --source-app .*source\.apk --output .*fixture\.apk .*--js-bundle-only/, + ); + + const mismatchedOutput = path.join(tempRoot, 'output', 'mismatched.apk'); + const mismatch = runRepack(mismatchedOutput, 'different-digest'); + assert.notEqual(mismatch.status, 0); + assert.match(mismatch.stderr, /did not preserve the source signing certificate/); +}); + test('producer maps each platform to its resolved lookup and matrix artifact name', (t) => { const workflow = parse(fs.readFileSync('.github/workflows/test-app-build-cache.yml', 'utf8')); const fingerprintStep = workflow.jobs.fingerprint.steps.find((step) => step.id === 'fingerprint'); diff --git a/test/scripts/android-fixture-cache-smoke.sh b/test/scripts/android-fixture-cache-smoke.sh new file mode 100644 index 000000000..b2ee3770b --- /dev/null +++ b/test/scripts/android-fixture-cache-smoke.sh @@ -0,0 +1,24 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 2 ] || [ -z "$1" ] || [ -z "$2" ]; then + echo "Usage: android-fixture-cache-smoke.sh " >&2 + exit 2 +fi + +APK_PATH="$1" +APP_ID="$2" +PROJECT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" +SESSION="fixture-cache" +SNAPSHOT_PATH="$PROJECT_DIR/test/artifacts/android-fixture-cache-snapshot.json" + +cleanup() { + node --experimental-strip-types "$PROJECT_DIR/src/bin.ts" close --platform android --session "$SESSION" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +mkdir -p "$(dirname -- "$SNAPSHOT_PATH")" +node --experimental-strip-types "$PROJECT_DIR/src/bin.ts" install "$APK_PATH" --platform android +node --experimental-strip-types "$PROJECT_DIR/src/bin.ts" open "$APP_ID" --platform android --session "$SESSION" --relaunch +node --experimental-strip-types "$PROJECT_DIR/src/bin.ts" snapshot -i --platform android --session "$SESSION" --json > "$SNAPSHOT_PATH" +node "$PROJECT_DIR/test/scripts/assert-android-fixture-snapshot.mjs" "$SNAPSHOT_PATH" "$APP_ID" diff --git a/test/scripts/assert-android-fixture-snapshot.mjs b/test/scripts/assert-android-fixture-snapshot.mjs new file mode 100644 index 000000000..1cd378abb --- /dev/null +++ b/test/scripts/assert-android-fixture-snapshot.mjs @@ -0,0 +1,76 @@ +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +function readSnapshotData(snapshot) { + if (!isSuccessfulSnapshotEnvelope(snapshot)) throw invalidSnapshotEnvelope(); + if (!hasSnapshotNodes(snapshot.data)) throw invalidSnapshotEnvelope(); + return snapshot.data; +} + +function isSuccessfulSnapshotEnvelope(value) { + return isRecord(value) && value.success === true; +} + +function hasSnapshotNodes(value) { + return isRecord(value) && Array.isArray(value.nodes); +} + +function isRecord(value) { + return Boolean(value) && typeof value === 'object'; +} + +function invalidSnapshotEnvelope() { + return new Error('Expected the public successful snapshot JSON envelope with a nodes array'); +} + +function readSnapshotLabels(data) { + return data.nodes.map((node) => String(node.label ?? '')); +} + +function assertHelperBackend(metadata) { + if (metadata.backend !== 'android-helper') { + throw new Error(`Expected android-helper backend, received ${metadata.backend}`); + } +} + +function assertHelperVersion(metadata, packageVersion) { + if (metadata.helperVersion !== packageVersion) { + throw new Error( + `Expected helper version ${packageVersion}, received ${metadata.helperVersion}`, + ); + } +} + +function assertFixtureHomeScreen(labels, expectedAppId) { + if (!labels.includes('Agent Device Tester')) { + throw new Error(`Expected ${expectedAppId} home screen, received labels: ${labels.join(', ')}`); + } +} + +function assertForegroundApp(data, expectedAppId) { + if (data.appBundleId !== expectedAppId) { + throw new Error( + `Expected foreground app ${expectedAppId}, received ${String(data.appBundleId)}`, + ); + } +} + +export function assertAndroidFixtureSnapshot(snapshot, expectedAppId, packageVersion) { + const data = readSnapshotData(snapshot); + const metadata = data.androidSnapshot; + assertHelperBackend(metadata); + assertHelperVersion(metadata, packageVersion); + assertForegroundApp(data, expectedAppId); + assertFixtureHomeScreen(readSnapshotLabels(data), expectedAppId); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const [snapshotPath, expectedAppId] = process.argv.slice(2); + if (!snapshotPath || !expectedAppId) { + throw new Error('Usage: assert-android-fixture-snapshot.mjs '); + } + const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf8')); + const packageJsonPath = fileURLToPath(new URL('../../package.json', import.meta.url)); + const packageVersion = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).version; + assertAndroidFixtureSnapshot(snapshot, expectedAppId, packageVersion); +} diff --git a/test/scripts/setup-fixture-app-fallback-smoke.sh b/test/scripts/setup-fixture-app-fallback-smoke.sh index 8d9785bd9..019221fa3 100755 --- a/test/scripts/setup-fixture-app-fallback-smoke.sh +++ b/test/scripts/setup-fixture-app-fallback-smoke.sh @@ -4,40 +4,21 @@ # # The fetch step runs under `set -euo pipefail`, so an unhandled API error would # exit the whole composite — turning a cache-service blip into a conformance -# failure, the opposite of what the action promises. This extracts that step's -# actual shell from action.yml (so it cannot drift from a copy), points its real -# API client at an unavailable endpoint, and asserts it still reaches +# failure, the opposite of what the action promises. This invokes the shared +# fetch helper itself, points its real API client at an unavailable endpoint, +# and asserts each platform still reaches # `source=build` and exits 0. set -eu ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" -ACTION="$ROOT/.github/actions/setup-fixture-app/action.yml" +ACTION_PATH="$ROOT/.github/actions/setup-fixture-app" WORK="$(mktemp -d)" export WORK trap 'rm -rf "$WORK"' EXIT pnpm test:fixture-cache -# Extract the exact `run:` body of the fetch step, then substitute the GitHub -# expressions the composite would have expanded. -extract_fetch() { - node -e ' - const fs = require("fs"); - const yaml = require("yaml"); - const doc = yaml.parse(fs.readFileSync(process.argv[1], "utf8")); - const step = doc.runs.steps.find((s) => s.id === "fetch"); - if (!step) { console.error("no fetch step in action.yml"); process.exit(2); } - let body = step.run - .replaceAll("${{ github.repository }}", "octo/repo") - .replaceAll("${{ github.event.pull_request.head.sha || github.sha }}", "current-head") - .replaceAll("${{ github.workspace }}", process.env.WORK) - .replaceAll("${{ inputs.wait-for-artifact-seconds }}", process.env.TEST_WAIT_SECONDS); - fs.writeFileSync(process.env.WORK + "/fetch.sh", body); - ' "$ACTION" -} - export TEST_WAIT_SECONDS=0 -extract_fetch # pnpm yields a fixed fingerprint so the step has a name to look up without # installing the app. The real selector uses the deliberately unavailable API. @@ -59,44 +40,52 @@ chmod +x "$WORK/bin/pnpm" "$WORK/bin/node" GITHUB_OUTPUT="$WORK/out" : > "$GITHUB_OUTPUT" export GITHUB_OUTPUT -GITHUB_ACTION_PATH="$ROOT/.github/actions/setup-fixture-app" +GITHUB_ACTION_PATH="$ACTION_PATH" GITHUB_API_URL="http://127.0.0.1:1" GH_TOKEN="test-token" export GITHUB_ACTION_PATH GITHUB_API_URL GH_TOKEN -set +e -PATH="$WORK/bin:$PATH" bash "$WORK/fetch.sh" > "$WORK/log" 2>&1 -RC=$? -set -e +FAIL=0 +run_lookup_outage() { + PLATFORM="$1" + : > "$GITHUB_OUTPUT" + set +e + PATH="$WORK/bin:$PATH" bash "$ACTION_PATH/fetch-artifact.sh" \ + "$PLATFORM" "$WORK/fixture" "$TEST_WAIT_SECONDS" octo/repo current-head "$ACTION_PATH" \ + > "$WORK/log-outage-$PLATFORM" 2>&1 + RC=$? + set -e -echo "--- fetch step output ---" -sed 's/^/ /' "$WORK/log" -echo "--- exit code: $RC ---" + echo "--- $PLATFORM fetch step output ---" + sed 's/^/ /' "$WORK/log-outage-$PLATFORM" + echo "--- exit code: $RC ---" + if [ "$RC" -ne 0 ]; then + echo "FAIL: $PLATFORM lookup outage exited the step (rc=$RC) instead of falling back." >&2 + FAIL=1 + fi + if ! grep -q '^source=build$' "$GITHUB_OUTPUT"; then + echo "FAIL: $PLATFORM expected source=build after a lookup failure; got: $(cat "$GITHUB_OUTPUT")" >&2 + FAIL=1 + fi + if ! grep -q "building inline" "$WORK/log-outage-$PLATFORM"; then + echo "FAIL: $PLATFORM expected a warning that it is building inline." >&2 + FAIL=1 + fi + if ! grep -qx -- "--dir examples/test-app exec fingerprint fingerprint:generate --platform $PLATFORM" "$WORK/fingerprint-calls"; then + echo "FAIL: fixture consumer did not request the $PLATFORM-scoped fingerprint." >&2 + FAIL=1 + fi + if ! grep -q -- " find octo/repo fingerprint.deadbeefcafe.$PLATFORM current-head$" "$WORK/node-calls"; then + echo "FAIL: fixture consumer did not query the exact $PLATFORM artifact name." >&2 + FAIL=1 + fi +} -FAIL=0 -if [ "$RC" -ne 0 ]; then - echo "FAIL: a lookup outage exited the step (rc=$RC) instead of falling back." >&2 - FAIL=1 -fi -if ! grep -q '^source=build$' "$GITHUB_OUTPUT"; then - echo "FAIL: expected source=build after a lookup failure; got: $(cat "$GITHUB_OUTPUT")" >&2 - FAIL=1 -fi -if ! grep -q "building inline" "$WORK/log"; then - echo "FAIL: expected a warning that it is building inline." >&2 - FAIL=1 -fi -if ! grep -qx -- '--dir examples/test-app exec fingerprint fingerprint:generate --platform ios' "$WORK/fingerprint-calls"; then - echo "FAIL: fixture consumer did not request the iOS-scoped fingerprint." >&2 - FAIL=1 -fi -if ! grep -q -- ' find octo/repo fingerprint.deadbeefcafe.ios current-head$' "$WORK/node-calls"; then - echo "FAIL: fixture consumer did not query the exact platform-scoped artifact name." >&2 - FAIL=1 -fi +run_lookup_outage ios +run_lookup_outage android if [ "$FAIL" -eq 0 ]; then - echo "PASS: build-cache lookup failure degrades to an inline build." + echo "PASS: platform-scoped build-cache lookup failures degrade to inline builds." fi # Exercise the composite's real wait loop without sleeping. The selector's @@ -124,29 +113,33 @@ chmod +x "$WORK/bin/node" "$WORK/bin/sleep" run_terminal_producer_case() { TEST_PRODUCER_STATE="$1" EXPECTED_LOG="$2" - export TEST_PRODUCER_STATE EXPECTED_LOG + PLATFORM="$3" + export TEST_PRODUCER_STATE EXPECTED_LOG PLATFORM TEST_WAIT_SECONDS=1800 export TEST_WAIT_SECONDS - extract_fetch : > "$GITHUB_OUTPUT" - if ! PATH="$WORK/bin:$PATH" bash "$WORK/fetch.sh" > "$WORK/log-$TEST_PRODUCER_STATE" 2>&1; then - echo "FAIL: $TEST_PRODUCER_STATE producer state did not fall back cleanly." >&2 + if ! PATH="$WORK/bin:$PATH" bash "$ACTION_PATH/fetch-artifact.sh" \ + "$PLATFORM" "$WORK/fixture" "$TEST_WAIT_SECONDS" octo/repo current-head "$ACTION_PATH" \ + > "$WORK/log-$TEST_PRODUCER_STATE-$PLATFORM" 2>&1; then + echo "FAIL: $PLATFORM $TEST_PRODUCER_STATE producer state did not fall back cleanly." >&2 FAIL=1 return fi if ! grep -q '^source=build$' "$GITHUB_OUTPUT"; then - echo "FAIL: $TEST_PRODUCER_STATE producer state did not select inline build." >&2 + echo "FAIL: $PLATFORM $TEST_PRODUCER_STATE producer state did not select inline build." >&2 FAIL=1 fi - if ! grep -q "$EXPECTED_LOG" "$WORK/log-$TEST_PRODUCER_STATE"; then - echo "FAIL: $TEST_PRODUCER_STATE producer state did not explain its bounded exit." >&2 + if ! grep -q "$EXPECTED_LOG" "$WORK/log-$TEST_PRODUCER_STATE-$PLATFORM"; then + echo "FAIL: $PLATFORM $TEST_PRODUCER_STATE producer state did not explain its bounded exit." >&2 FAIL=1 fi } -run_terminal_producer_case queued 'producer is queued' -run_terminal_producer_case failed 'producer failed' -run_terminal_producer_case absent 'No fixture producer appeared' +for PLATFORM in ios android; do + run_terminal_producer_case queued 'producer is queued' "$PLATFORM" + run_terminal_producer_case failed 'producer failed' "$PLATFORM" + run_terminal_producer_case absent 'No fixture producer appeared' "$PLATFORM" +done if [ "$FAIL" -eq 0 ]; then echo "PASS: queued, failed, and absent producers exit the wait path early."