also looking at this
test(mobile): run the generated apps on a simulator and an emulator
#199
26 files
+3009
-293
| @@ -1,13 +1,61 @@ | ||
| 1 | 1 | # GitHub Actions |
| 2 | 2 | |
| 3 | This folder contains the following GitHub Actions: | |
| 3 | | Workflow | What it proves | | |
| 4 | |---|---| | |
| 5 | | [CI](./ci.yml) | Lint, typecheck, the Zig and TypeScript suites, and that the generated iOS, Watch, Live Activity and Android projects still **compile** | | |
| 6 | | [Mobile E2E Testing](./mobile-e2e.yml) | That a generated app **runs**: a call from JavaScript reaches native code and comes back, on a real simulator and a real emulator | | |
| 7 | | [Native Package Lifecycle](./native-lifecycle.yml) | Install, update, rollback and uninstall of the packaged app on macOS, Linux and Windows | | |
| 8 | | [Benchmark](./benchmarks.yml) | Startup and runtime numbers, tracked over time | | |
| 9 | | [Binary Size](./binary-size.yml) | The shipped binary has not grown unexpectedly | | |
| 10 | | [SBOM](./sbom.yml) | A software bill of materials for each build | | |
| 11 | | [Release](./release.yml) | Tagging, changelog, pantry publish and npm publish | | |
| 4 | 12 | |
| 5 | - [CI][CI] - all CI jobs for the project | |
| 6 | - lints the code | |
| 7 | - `typecheck`s the code | |
| 8 | - runs test suite | |
| 9 | - runs on `ubuntu-latest` | |
| 10 | - [Release][Release] - automates the release process & changelog generation | |
| 13 | ## Running the mobile E2E locally | |
| 11 | 14 | |
| 12 | [CI]: ./workflows/ci.yml | |
| 13 | [Release]: ./workflows/release.yml | |
| 15 | The harness generates an app, drops a self-driving page into its web assets, | |
| 16 | builds it, installs it on a device and reads the page's report back off the | |
| 17 | console. It asserts a required list of cases per platform — at least one | |
| 18 | success path and one rejection path — and nothing in it skips: a missing | |
| 19 | toolchain or an app that printed nothing is a failure with a named cause. | |
| 20 | ||
| 21 | iOS needs Xcode, `xcodegen` (`brew install xcodegen`), and the Zig archives, | |
| 22 | because the suite runs twice — once with the Zig runtime linked and once | |
| 23 | without. `build-ios-all` rather than `build-ios-simulator`: the generated | |
| 24 | project links a device slice too, and project generation refuses a runtime | |
| 25 | directory that has none. | |
| 26 | ||
| 27 | ```bash | |
| 28 | cd packages/zig && zig build build-ios-all -Doptimize=ReleaseSafe && cd - | |
| 29 | bun run test:mobile-e2e:ios | |
| 30 | ``` | |
| 31 | ||
| 32 | ``` | |
| 33 | ok ios-shim — 4/4 cases, no zig | |
| 34 | ok ios-runtime — 4/4 cases, zig served clipboardRead, clipboardWrite, getCurrentPosition, getDeviceInfo, log | |
| 35 | ``` | |
| 36 | ||
| 37 | Android needs JDK 17, Gradle 8.11.1, an Android SDK with `platforms;android-36` | |
| 38 | and `build-tools;36.0.0`, a booted emulator or attached device, and the Zig | |
| 39 | Android libraries — it runs twice as well, once with `libcraft.so` packaged | |
| 40 | into the APK and once without: | |
| 41 | ||
| 42 | The Zig Android library links bionic, so building it needs the NDK — | |
| 43 | `sdkmanager --install "ndk;26.1.10909125"`. Without it `build-android` refuses | |
| 44 | rather than producing a library that installs and then fails to load. | |
| 45 | ||
| 46 | ```bash | |
| 47 | cd packages/zig && zig build build-android-all -Doptimize=ReleaseSafe -Dandroid-ndk="$ANDROID_NDK_HOME" && cd - | |
| 48 | bun run test:mobile-e2e:android | |
| 49 | ``` | |
| 50 | ||
| 51 | ``` | |
| 52 | ok android-shim — 4/4 cases, no zig | |
| 53 | ok android-runtime — 4/4 cases, zig served registered:59 | |
| 54 | ``` | |
| 55 | ||
| 56 | Both write everything they saw — console logs, build logs, a screenshot, the | |
| 57 | exact page that ran and a `report.json` — to | |
| 58 | `artifacts/mobile-e2e/<label>/`, which is what CI uploads. | |
| 59 | ||
| 60 | The cases themselves are in [`scripts/mobile-e2e/driver.html`](../../scripts/mobile-e2e/driver.html); | |
| 61 | which of them are mandatory is in [`scripts/mobile-e2e/protocol.ts`](../../scripts/mobile-e2e/protocol.ts). | |
| @@ -1,156 +1,208 @@ | ||
| 1 | 1 | name: Mobile E2E Testing |
| 2 | 2 | |
| 3 | # Manual only. This workflow has been red since May and cannot go green from | |
| 4 | # a workflow edit: the iOS Zig sources stopped compiling in the 0.17 migration | |
| 5 | # (1b6f285) and need real source work — see the notes in the PR that made this | |
| 6 | # manual. It also runs no end-to-end tests today; every step that would has an | |
| 7 | # `if: hashFiles(...)` guard pointing at a TestApp/project that is not in the | |
| 8 | # tree, so they all skip. Firing it on push meant a red X on unrelated commits | |
| 9 | # for a signal nobody could act on, and two of its path filters | |
| 10 | # (packages/zig/src/ios/**, packages/zig/src/android/**) match directories that | |
| 11 | # do not exist at all. | |
| 3 | # Runs a generated Craft app on a real iOS simulator and a real Android | |
| 4 | # emulator and asserts that a call made from JavaScript reached native code and | |
| 5 | # came back. | |
| 6 | # | |
| 7 | # The version this replaces asserted nothing. Every iOS step that would have | |
| 8 | # tested something was guarded by | |
| 9 | # `if: hashFiles('packages/ios/TestApp/TestApp.xcodeproj') != ''` against a | |
| 10 | # project that has never existed in the tree, and the Android job's only test | |
| 11 | # invocation was gated on `packages/android/gradlew`, which the generator does | |
| 12 | # not write. Both skipped on every run, and the job reported success. Its | |
| 13 | # header also blamed the iOS Zig sources for not compiling since the 0.17 | |
| 14 | # migration (1b6f285); that is no longer true — | |
| 15 | # `zig build build-ios-simulator` succeeds at this commit, and the iOS job | |
| 16 | # below depends on it. | |
| 17 | # | |
| 18 | # What the harness asserts, per platform, is in scripts/mobile-e2e/protocol.ts: | |
| 19 | # a required list of cases that must all run and all pass, covering at least | |
| 20 | # one success path and one rejection path. Nothing here skips. A missing | |
| 21 | # toolchain, an absent device, or an app that printed nothing is a failure with | |
| 22 | # a named cause. | |
| 23 | ||
| 24 | # GitHub Actions does not resolve YAML anchors, so the two path filters are | |
| 25 | # written out rather than shared. Keep them identical. | |
| 12 | 26 | on: |
| 27 | push: | |
| 28 | branches: [main] | |
| 29 | paths: | |
| 30 | - packages/ios/** | |
| 31 | - packages/android/** | |
| 32 | # The mobile Zig sources are flat files under src/, not src/ios/ and | |
| 33 | # src/android/ as the previous filters claimed — those two directories | |
| 34 | # have never existed, so the filter matched nothing. | |
| 35 | - packages/zig/src/ios*.zig | |
| 36 | - packages/zig/src/android*.zig | |
| 37 | - packages/zig/src/mobile*.zig | |
| 38 | - packages/zig/src/bridge_mobile_*.zig | |
| 39 | - packages/zig/src/bridge_android_*.zig | |
| 40 | - packages/zig/src/jni*.zig | |
| 41 | - packages/zig/build.zig | |
| 42 | - scripts/mobile-e2e.ts | |
| 43 | - scripts/mobile-e2e/** | |
| 44 | - .github/workflows/mobile-e2e.yml | |
| 45 | pull_request: | |
| 46 | paths: | |
| 47 | - packages/ios/** | |
| 48 | - packages/android/** | |
| 49 | - packages/zig/src/ios*.zig | |
| 50 | - packages/zig/src/android*.zig | |
| 51 | - packages/zig/src/mobile*.zig | |
| 52 | - packages/zig/src/bridge_mobile_*.zig | |
| 53 | - packages/zig/src/bridge_android_*.zig | |
| 54 | - packages/zig/src/jni*.zig | |
| 55 | - packages/zig/build.zig | |
| 56 | - scripts/mobile-e2e.ts | |
| 57 | - scripts/mobile-e2e/** | |
| 58 | - .github/workflows/mobile-e2e.yml | |
| 13 | 59 | workflow_dispatch: |
| 14 | 60 | |
| 15 | 61 | concurrency: |
| 16 | 62 | group: mobile-e2e-${{ github.head_ref || github.ref }} |
| 17 | 63 | cancel-in-progress: true |
| 18 | 64 | |
| 65 | permissions: | |
| 66 | contents: read | |
| 67 | ||
| 19 | 68 | defaults: |
| 20 | 69 | run: |
| 21 | 70 | shell: bash |
| 22 | 71 | |
| 23 | 72 | jobs: |
| 24 | ios-simulator: | |
| 25 | # name: iOS Simulator Tests | |
| 26 | runs-on: macos-14 # M1 runner for better iOS simulator performance | |
| 73 | # The transcript reader and the required-case list are the part of this | |
| 74 | # harness that can be tested without a device, and nothing ran those tests: | |
| 75 | # every `bun test` in this repository is scoped to a package directory or to | |
| 76 | # one explicit file, and the root `test` script is the Zig suite. So the | |
| 77 | # ratchet that stops the suite being hollowed out was itself unenforced. | |
| 78 | harness: | |
| 79 | name: Harness unit tests | |
| 80 | runs-on: ubuntu-latest | |
| 81 | timeout-minutes: 10 | |
| 27 | 82 | |
| 28 | 83 | steps: |
| 29 | 84 | - uses: actions/checkout@v6 |
| 30 | 85 | |
| 31 | - name: Setup Zig | |
| 86 | - name: Setup Pantry (provides Bun, Zig, etc.) | |
| 32 | 87 | uses: pantry-pm/pantry/packages/action@235036fa0f48bae99b2293df5a3dc35c809b1777 # pinned: last SHA whose bundled typescript resolves on linux-x64 |
| 33 | - name: Setup Xcode | |
| 34 | uses: maxim-lobanov/setup-xcode@v1 | |
| 35 | with: | |
| 36 | xcode-version: latest-stable | |
| 37 | ||
| 38 | - name: Cache Zig artifacts | |
| 39 | uses: actions/cache@v5 | |
| 40 | with: | |
| 41 | path: | | |
| 42 | ~/.cache/zig | |
| 43 | packages/zig/.zig-cache | |
| 44 | key: ios-${{ runner.os }}-zig-master-${{ hashFiles('packages/zig/build.zig') }} | |
| 45 | 88 | |
| 46 | - name: List available simulators | |
| 47 | run: | | |
| 48 | xcrun simctl list devices available | |
| 89 | - name: Install dependencies | |
| 90 | run: bun install --frozen-lockfile | |
| 49 | 91 | |
| 50 | - name: Boot iOS Simulator | |
| 51 | run: | | |
| 52 | # Find an iPhone 15 simulator | |
| 53 | DEVICE_ID=$(xcrun simctl list devices available | grep "iPhone 15" | head -1 | grep -oE '[0-9A-F-]{36}') | |
| 92 | # The explicit path is load-bearing: a bare filter makes bun walk the | |
| 93 | # repository and run out of file descriptors under the hoisted linker. | |
| 94 | # See the note in native-lifecycle.yml. | |
| 95 | - name: Test the report protocol | |
| 96 | run: bun test ./scripts/mobile-e2e/protocol.test.ts | |
| 54 | 97 | |
| 55 | if [ -z "$DEVICE_ID" ]; then | |
| 56 | # Create one if not found | |
| 57 | DEVICE_ID=$(xcrun simctl create "iPhone 15 Test" "iPhone 15" "iOS17.0") | |
| 58 | fi | |
| 98 | ios-simulator: | |
| 99 | name: iOS simulator | |
| 100 | runs-on: macos-15 | |
| 101 | # A hung simulator launch would otherwise burn the six-hour default. The | |
| 102 | # harness has its own per-app timeout; this is the backstop for everything | |
| 103 | # around it. | |
| 104 | timeout-minutes: 45 | |
| 105 | env: | |
| 106 | CRAFT_EVIDENCE_LABEL: ios-simulator | |
| 59 | 107 | |
| 60 | echo "DEVICE_ID=$DEVICE_ID" >> $GITHUB_ENV | |
| 108 | steps: | |
| 109 | - uses: actions/checkout@v6 | |
| 61 | 110 | |
| 62 | # Boot the simulator | |
| 63 | xcrun simctl boot "$DEVICE_ID" || true | |
| 111 | - name: Setup Pantry (provides Bun, Zig, etc.) | |
| 112 | uses: pantry-pm/pantry/packages/action@235036fa0f48bae99b2293df5a3dc35c809b1777 # pinned: last SHA whose bundled typescript resolves on linux-x64 | |
| 64 | 113 | |
| 65 | # Wait for simulator to finish booting. `bootstatus -b` blocks until | |
| 66 | # the device reaches a Booted state, eliminating the flaky fixed sleep. | |
| 67 | xcrun simctl bootstatus "$DEVICE_ID" -b | |
| 114 | - name: Install XcodeGen | |
| 115 | run: brew install xcodegen | |
| 68 | 116 | |
| 69 | - name: First-party Zig dependencies | |
| 70 | uses: ./.github/actions/first-party-zig-deps | |
| 117 | - name: Install dependencies | |
| 118 | run: bun install --frozen-lockfile | |
| 71 | 119 | |
| 72 | - name: Build iOS library | |
| 73 | working-directory: packages/zig | |
| 74 | run: | | |
| 75 | # Build iOS simulator static libraries using dedicated build step | |
| 76 | # (not the default target which builds macOS executables) | |
| 77 | zig build build-ios-simulator -Doptimize=ReleaseSafe -Dmacos-sdk="$(xcrun --sdk iphonesimulator --show-sdk-path)" | |
| 120 | - name: Cache Zig artifacts | |
| 121 | uses: actions/cache@v5 | |
| 122 | with: | |
| 123 | path: | | |
| 124 | ~/.cache/zig | |
| 125 | packages/zig/.zig-cache | |
| 126 | key: mobile-e2e-ios-${{ runner.os }}-${{ hashFiles('packages/zig/build.zig') }} | |
| 78 | 127 | |
| 79 | 128 | - name: First-party Zig dependencies |
| 80 | 129 | uses: ./.github/actions/first-party-zig-deps |
| 81 | 130 | |
| 82 | - name: Run iOS unit tests | |
| 131 | # build-ios-all, not build-ios-simulator: installRuntime() resolves every | |
| 132 | # SDK in RUNTIME_ARCHIVES before it writes anything and throws when one | |
| 133 | # has no archive at all, so a zig-out holding only the simulator slices | |
| 134 | # fails at project generation. It passes locally only because a device | |
| 135 | # archive is usually already sitting there from an earlier build. | |
| 136 | # | |
| 137 | # No -Dmacos-sdk: the iOS steps resolve their SDK through xcrun at | |
| 138 | # configure time (build.zig's iosSdkPath), and the option is read only by | |
| 139 | # desktop and host-test artifacts, so passing it was a no-op. | |
| 140 | - name: Build the Zig iOS runtime | |
| 83 | 141 | working-directory: packages/zig |
| 84 | run: | | |
| 85 | # `zig build test` reports through its exit code. The old form piped | |
| 86 | # it to a file and grepped for "mobile"/"ios", which fails under | |
| 87 | # `set -e` whenever the run is clean and the file is therefore empty. | |
| 88 | zig build test | |
| 89 | ||
| 90 | - name: Build iOS test app | |
| 91 | if: hashFiles('packages/ios/TestApp/TestApp.xcodeproj') != '' | |
| 92 | run: | | |
| 93 | cd packages/ios/TestApp | |
| 94 | xcodebuild build-for-testing \ | |
| 95 | -scheme TestApp \ | |
| 96 | -destination "platform=iOS Simulator,id=$DEVICE_ID" \ | |
| 97 | -configuration Debug \ | |
| 98 | CODE_SIGN_IDENTITY="" \ | |
| 99 | CODE_SIGNING_REQUIRED=NO | |
| 100 | ||
| 101 | - name: Run iOS E2E tests | |
| 102 | if: hashFiles('packages/ios/TestApp/TestApp.xcodeproj') != '' | |
| 103 | run: | | |
| 104 | cd packages/ios/TestApp | |
| 105 | xcodebuild test-without-building \ | |
| 106 | -scheme TestApp \ | |
| 107 | -destination "platform=iOS Simulator,id=$DEVICE_ID" \ | |
| 108 | -configuration Debug | |
| 109 | ||
| 110 | - name: Capture iOS screenshots | |
| 111 | run: | | |
| 112 | mkdir -p screenshots/ios | |
| 113 | ||
| 114 | # Take a screenshot of the simulator | |
| 115 | xcrun simctl io "$DEVICE_ID" screenshot screenshots/ios/simulator.png || true | |
| 116 | ||
| 117 | - name: Shutdown simulator | |
| 118 | if: always() | |
| 119 | run: | | |
| 120 | xcrun simctl shutdown "$DEVICE_ID" || true | |
| 121 | ||
| 122 | - name: Upload iOS screenshots | |
| 142 | run: zig build build-ios-all -Doptimize=ReleaseSafe | |
| 143 | ||
| 144 | - name: Run mobile E2E on the simulator | |
| 145 | id: harness | |
| 146 | run: bun scripts/mobile-e2e.ts --platform ios --ios-runtime packages/zig/zig-out/lib | |
| 147 | ||
| 148 | - name: Upload iOS evidence | |
| 149 | # Not a bare `always()`: when an earlier step fails the harness never | |
| 150 | # runs, the evidence directory is never created, and `if-no-files-found: | |
| 151 | # error` would then add a "No files were found" error on top of the real | |
| 152 | # one. Skipped-means-skipped; ran-and-produced-nothing still fails. | |
| 153 | if: always() && steps.harness.conclusion != 'skipped' | |
| 123 | 154 | uses: actions/upload-artifact@v4 |
| 124 | 155 | with: |
| 125 | name: ios-screenshots | |
| 126 | path: screenshots/ios/ | |
| 127 | retention-days: 7 | |
| 128 | ||
| 129 | - name: Generate iOS test report | |
| 130 | run: | | |
| 131 | echo "## iOS Simulator Tests" >> $GITHUB_STEP_SUMMARY | |
| 132 | echo "" >> $GITHUB_STEP_SUMMARY | |
| 133 | echo "- Simulator: iPhone 15" >> $GITHUB_STEP_SUMMARY | |
| 134 | echo "- iOS Version: 17.0" >> $GITHUB_STEP_SUMMARY | |
| 135 | echo "- Status: Completed" >> $GITHUB_STEP_SUMMARY | |
| 156 | name: mobile-e2e-ios | |
| 157 | # Console logs, build logs, screenshots, the generated project.yml and | |
| 158 | # the exact page that ran. Excludes work/ — the generated projects and | |
| 159 | # their DerivedData are hundreds of megabytes and the interesting | |
| 160 | # parts are copied out. | |
| 161 | path: | | |
| 162 | artifacts/mobile-e2e/ios-simulator/** | |
| 163 | !artifacts/mobile-e2e/ios-simulator/work/** | |
| 164 | # A run that produced no evidence is a broken harness, not a pass. | |
| 165 | if-no-files-found: error | |
| 166 | retention-days: 30 | |
| 136 | 167 | |
| 137 | 168 | android-emulator: |
| 138 | # name: Android Emulator Tests | |
| 169 | name: Android emulator | |
| 139 | 170 | runs-on: ubuntu-latest |
| 171 | timeout-minutes: 60 | |
| 172 | env: | |
| 173 | CRAFT_EVIDENCE_LABEL: android-emulator | |
| 140 | 174 | |
| 141 | 175 | steps: |
| 142 | 176 | - uses: actions/checkout@v6 |
| 143 | 177 | |
| 144 | - name: Setup Zig | |
| 178 | - name: Setup Pantry (provides Bun, Zig, etc.) | |
| 145 | 179 | uses: pantry-pm/pantry/packages/action@235036fa0f48bae99b2293df5a3dc35c809b1777 # pinned: last SHA whose bundled typescript resolves on linux-x64 |
| 180 | ||
| 146 | 181 | - name: Setup Java |
| 147 | uses: actions/setup-java@v4 | |
| 182 | uses: actions/setup-java@v6 | |
| 148 | 183 | with: |
| 149 | distribution: 'temurin' | |
| 184 | distribution: temurin | |
| 150 | 185 | java-version: '17' |
| 151 | 186 | |
| 152 | 187 | - name: Setup Android SDK |
| 153 | uses: android-actions/setup-android@v3 | |
| 188 | uses: android-actions/setup-android@v4 | |
| 189 | with: | |
| 190 | # The action defaults to `tools platform-tools`, but the obsolete | |
| 191 | # `tools` package is no longer offered by the Android SDK repository. | |
| 192 | packages: platform-tools | |
| 193 | ||
| 194 | # Same platform and build-tools as ci.yml's android-builder, so the | |
| 195 | # emulator job and the compile gate cannot drift onto different SDKs. | |
| 196 | - name: Install Android platform | |
| 197 | run: sdkmanager "platforms;android-36" "build-tools;36.0.0" | |
| 198 | ||
| 199 | - name: Setup Gradle | |
| 200 | uses: gradle/actions/setup-gradle@v6 | |
| 201 | with: | |
| 202 | gradle-version: '8.11.1' | |
| 203 | ||
| 204 | - name: Install dependencies | |
| 205 | run: bun install --frozen-lockfile | |
| 154 | 206 | |
| 155 | 207 | - name: Cache Zig artifacts |
| 156 | 208 | uses: actions/cache@v5 |
| @@ -158,36 +210,25 @@ jobs: | ||
| 158 | 210 | path: | |
| 159 | 211 | ~/.cache/zig |
| 160 | 212 | packages/zig/.zig-cache |
| 161 | key: android-${{ runner.os }}-zig-master-${{ hashFiles('packages/zig/build.zig') }} | |
| 162 | ||
| 163 | - name: Install Android NDK | |
| 164 | run: | | |
| 165 | sdkmanager --install "ndk;26.1.10909125" | |
| 166 | echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/26.1.10909125" >> $GITHUB_ENV | |
| 213 | key: mobile-e2e-android-${{ runner.os }}-${{ hashFiles('packages/zig/build.zig') }} | |
| 167 | 214 | |
| 168 | 215 | - name: First-party Zig dependencies |
| 169 | 216 | uses: ./.github/actions/first-party-zig-deps |
| 170 | 217 | |
| 171 | - name: Build Android library | |
| 172 | working-directory: packages/zig | |
| 218 | # The NDK is what supplies bionic. Without it the JNI library builds and | |
| 219 | # then fails at dlopen with `cannot locate symbol "__tls_get_addr"`, which | |
| 220 | # CraftNative catches and turns into a silent fallback to the Kotlin shim | |
| 221 | # — the failure this whole leg exists to catch. | |
| 222 | - name: Install the Android NDK | |
| 173 | 223 | run: | |
| 174 | # Use the dedicated android step, which resolves its own target. | |
| 175 | # Passing -Dtarget steers the *default* install step instead, which | |
| 176 | # builds the desktop binaries — and since an android triple reports | |
| 177 | # os_tag == .linux, that links GTK3/WebKit2GTK against a sysroot that | |
| 178 | # does not exist for Android. | |
| 179 | zig build build-android -Doptimize=ReleaseSafe | |
| 180 | ||
| 181 | - name: First-party Zig dependencies | |
| 182 | uses: ./.github/actions/first-party-zig-deps | |
| 224 | sdkmanager --install "ndk;26.1.10909125" | |
| 225 | echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/26.1.10909125" >> "$GITHUB_ENV" | |
| 183 | 226 | |
| 184 | - name: Run Android unit tests | |
| 227 | # Both ABIs. x86_64 is the one the emulator actually loads; arm64-v8a is | |
| 228 | # built so the runtime leg installs the same APK a phone would get. | |
| 229 | - name: Build the Zig Android runtime | |
| 185 | 230 | working-directory: packages/zig |
| 186 | run: | | |
| 187 | # `zig build test` reports through its exit code. The old form piped | |
| 188 | # it to a file and grepped for "mobile"/"android", which fails under | |
| 189 | # `set -e` whenever the run is clean and the file is therefore empty. | |
| 190 | zig build test | |
| 231 | run: zig build build-android-all -Doptimize=ReleaseSafe -Dandroid-ndk="$ANDROID_NDK_HOME" | |
| 191 | 232 | |
| 192 | 233 | - name: Enable KVM |
| 193 | 234 | run: | |
| @@ -202,9 +243,17 @@ jobs: | ||
| 202 | 243 | path: | |
| 203 | 244 | ~/.android/avd/* |
| 204 | 245 | ~/.android/adb* |
| 205 | key: avd-api-34-${{ runner.os }} | |
| 206 | ||
| 207 | - name: Create AVD and run tests | |
| 246 | # The device, not just the API level. With force-avd-creation off the | |
| 247 | # action reuses whatever the cache restored, so a key that ignores | |
| 248 | # arch and profile would keep testing the old device after someone | |
| 249 | # changed them. | |
| 250 | key: avd-api-34-x86_64-pixel_6-${{ runner.os }} | |
| 251 | ||
| 252 | # `script:` is the only place a command can run with the emulator | |
| 253 | # attached, so the harness is invoked from inside it rather than as its | |
| 254 | # own step. | |
| 255 | - name: Run mobile E2E on the emulator | |
| 256 | id: harness | |
| 208 | 257 | uses: reactivecircus/android-emulator-runner@v2 |
| 209 | 258 | with: |
| 210 | 259 | api-level: 34 |
| @@ -214,56 +263,110 @@ jobs: | ||
| 214 | 263 | emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim |
| 215 | 264 | disable-animations: true |
| 216 | 265 | script: | |
| 217 | echo "Emulator started" | |
| 218 | 266 | adb wait-for-device |
| 219 | mkdir -p screenshots/android | |
| 220 | adb exec-out screencap -p > screenshots/android/emulator.png || true | |
| 221 | if [ -x "packages/android/gradlew" ]; then | |
| 222 | cd packages/android | |
| 223 | ./gradlew test | |
| 224 | else | |
| 225 | echo "Android Gradle wrapper not present; skipping project-level tests" | |
| 226 | fi | |
| 227 | ||
| 228 | - name: Upload Android screenshots | |
| 267 | bun scripts/mobile-e2e.ts --platform android --android-runtime packages/zig/zig-out/android | |
| 268 | ||
| 269 | - name: Upload Android evidence | |
| 270 | if: always() && steps.harness.conclusion != 'skipped' | |
| 229 | 271 | uses: actions/upload-artifact@v4 |
| 230 | 272 | with: |
| 231 | name: android-screenshots | |
| 232 | path: screenshots/android/ | |
| 233 | retention-days: 7 | |
| 273 | name: mobile-e2e-android | |
| 274 | path: | | |
| 275 | artifacts/mobile-e2e/android-emulator/** | |
| 276 | !artifacts/mobile-e2e/android-emulator/work/** | |
| 277 | if-no-files-found: error | |
| 278 | retention-days: 30 | |
| 279 | ||
| 280 | # The Zig runtime's own simulator fixture: a hand-linked four-file app that | |
| 281 | # drives libcraft-ios-simulator directly, with assertions the generated-app | |
| 282 | # suite cannot make — the host hand-off, the error route's escaping, a | |
| 283 | # Keychain round trip, in-process SQLite, and a sensor the simulator does not | |
| 284 | # have refusing rather than fabricating a stream. It has been in the tree, | |
| 285 | # passing, and wired into no workflow. | |
| 286 | ios-zig-slice: | |
| 287 | name: iOS Zig runtime slice | |
| 288 | runs-on: macos-15 | |
| 289 | timeout-minutes: 45 | |
| 290 | ||
| 291 | steps: | |
| 292 | - uses: actions/checkout@v6 | |
| 293 | ||
| 294 | - name: Setup Pantry (provides Bun, Zig, etc.) | |
| 295 | uses: pantry-pm/pantry/packages/action@235036fa0f48bae99b2293df5a3dc35c809b1777 # pinned: last SHA whose bundled typescript resolves on linux-x64 | |
| 234 | 296 | |
| 235 | - name: Generate Android test report | |
| 297 | - name: Cache Zig artifacts | |
| 298 | uses: actions/cache@v5 | |
| 299 | with: | |
| 300 | path: | | |
| 301 | ~/.cache/zig | |
| 302 | packages/zig/.zig-cache | |
| 303 | key: mobile-e2e-slice-${{ runner.os }}-${{ hashFiles('packages/zig/build.zig') }} | |
| 304 | ||
| 305 | - name: First-party Zig dependencies | |
| 306 | uses: ./.github/actions/first-party-zig-deps | |
| 307 | ||
| 308 | # The script defaults CRAFT_ZIG to a pinned toolchain under | |
| 309 | # ~/.cache/craft-ci-local, which exists on the author's machine and not on | |
| 310 | # a runner. Point it at the one pantry provides. | |
| 311 | - name: Exercise the Zig runtime on a simulator | |
| 312 | id: harness | |
| 313 | # The fixture polls for its late markers against a wall-clock deadline. | |
| 314 | # A runner is several times slower than a laptop here — the first CI run | |
| 315 | # of this job reached marker 36 of 42 before the old iteration-count | |
| 316 | # budget ran out — so give it room. It exits as soon as the markers | |
| 317 | # arrive; the budget only matters when something is genuinely stuck. | |
| 318 | env: | |
| 319 | CRAFT_SLICE_TIMEOUT: '900' | |
| 236 | 320 | run: | |
| 237 | echo "## Android Emulator Tests" >> $GITHUB_STEP_SUMMARY | |
| 238 | echo "" >> $GITHUB_STEP_SUMMARY | |
| 239 | echo "- Emulator: Pixel 6" >> $GITHUB_STEP_SUMMARY | |
| 240 | echo "- API Level: 34" >> $GITHUB_STEP_SUMMARY | |
| 241 | echo "- Status: Completed" >> $GITHUB_STEP_SUMMARY | |
| 321 | CRAFT_ZIG="$(command -v zig)" \ | |
| 322 | packages/ios/fixtures/zig-slice/build-and-run.sh \ | |
| 323 | "$PWD/artifacts/mobile-e2e/ios-zig-slice" | |
| 324 | ||
| 325 | - name: Upload slice evidence | |
| 326 | if: always() && steps.harness.conclusion != 'skipped' | |
| 327 | uses: actions/upload-artifact@v4 | |
| 328 | with: | |
| 329 | name: mobile-e2e-ios-zig-slice | |
| 330 | path: | | |
| 331 | artifacts/mobile-e2e/ios-zig-slice/console.log | |
| 332 | artifacts/mobile-e2e/ios-zig-slice/console.plain | |
| 333 | if-no-files-found: error | |
| 334 | retention-days: 30 | |
| 242 | 335 | |
| 243 | 336 | mobile-summary: |
| 244 | # name: Mobile Test Summary | |
| 245 | needs: [ios-simulator, android-emulator] | |
| 337 | name: Mobile E2E summary | |
| 338 | needs: [harness, ios-simulator, android-emulator, ios-zig-slice] | |
| 246 | 339 | runs-on: ubuntu-latest |
| 247 | 340 | if: always() |
| 248 | 341 | |
| 249 | 342 | steps: |
| 250 | - name: Check test results | |
| 343 | - name: Report | |
| 251 | 344 | run: | |
| 252 | echo "## Mobile E2E Test Summary" >> $GITHUB_STEP_SUMMARY | |
| 253 | echo "" >> $GITHUB_STEP_SUMMARY | |
| 254 | echo "| Platform | Status |" >> $GITHUB_STEP_SUMMARY | |
| 255 | echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY | |
| 256 | echo "| iOS Simulator | $ |" >> $GITHUB_STEP_SUMMARY | |
| 257 | echo "| Android Emulator | $ |" >> $GITHUB_STEP_SUMMARY | |
| 258 | ||
| 259 | - name: Download all screenshots | |
| 260 | uses: actions/download-artifact@v4 | |
| 261 | with: | |
| 262 | path: all-screenshots | |
| 263 | ||
| 264 | - name: Upload combined screenshots | |
| 265 | uses: actions/upload-artifact@v4 | |
| 266 | with: | |
| 267 | name: mobile-screenshots | |
| 268 | path: all-screenshots/ | |
| 269 | retention-days: 30 | |
| 345 | { | |
| 346 | echo "## Mobile E2E" | |
| 347 | echo | |
| 348 | echo "| Leg | Result |" | |
| 349 | echo "|-----|--------|" | |
| 350 | echo "| Harness unit tests | $ |" | |
| 351 | echo "| iOS simulator (generated app) | $ |" | |
| 352 | echo "| Android emulator (generated app) | $ |" | |
| 353 | echo "| iOS Zig runtime slice | $ |" | |
| 354 | } >> "$GITHUB_STEP_SUMMARY" | |
| 355 | ||
| 356 | # The summary job used to be the whole story, and it reported whatever it | |
| 357 | # was given without ever failing. `needs` does not fail a job on a failed | |
| 358 | # dependency when `if: always()` is set, so say it explicitly. | |
| 359 | # | |
| 360 | # `failure` and `skipped`, not `!= success`: with cancel-in-progress on, | |
| 361 | # pushing twice in quick succession cancels the first run, and treating | |
| 362 | # `cancelled` as a failure would put a red X on it — a signal about | |
| 363 | # nothing, which is how the workflow this replaces earned its reputation. | |
| 364 | - name: Fail if any leg failed | |
| 365 | if: >- | |
| 366 | contains(fromJSON('["failure", "skipped"]'), needs.harness.result) | |
| 367 | || contains(fromJSON('["failure", "skipped"]'), needs.ios-simulator.result) | |
| 368 | || contains(fromJSON('["failure", "skipped"]'), needs.android-emulator.result) | |
| 369 | || contains(fromJSON('["failure", "skipped"]'), needs.ios-zig-slice.result) | |
| 370 | run: | | |
| 371 | echo "::error::at least one mobile E2E leg did not succeed" | |
| 372 | exit 1 | |
| @@ -35,6 +35,8 @@ | ||
| 35 | 35 | "test": "command -v zig >/dev/null 2>&1 || eval \"$(pantry env | sed -n '/^export /,$p')\"; cd packages/zig && zig build test", |
| 36 | 36 | "test:sdk": "cd packages/typescript && bun test", |
| 37 | 37 | "test:native-lifecycle": "bun scripts/native-lifecycle.ts", |
| 38 | "test:mobile-e2e:ios": "bun scripts/mobile-e2e.ts --platform ios --ios-runtime packages/zig/zig-out/lib", | |
| 39 | "test:mobile-e2e:android": "bun scripts/mobile-e2e.ts --platform android --android-runtime packages/zig/zig-out/android", | |
| 38 | 40 | "typecheck": "cd packages/typescript && bun run typecheck && cd ../create-craft && bun run typecheck && cd ../ts-maps && bun run typecheck", |
| 39 | 41 | "run": "command -v zig >/dev/null 2>&1 || eval \"$(pantry env | sed -n '/^export /,$p')\"; cd packages/zig && zig build run", |
| 40 | 42 | "run:demo": "command -v zig >/dev/null 2>&1 || eval \"$(pantry env | sed -n '/^export /,$p')\"; cd packages/zig && zig build run-demo", |
| @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFil | ||
| 2 | 2 | import { tmpdir } from 'node:os' |
| 3 | 3 | import { join } from 'node:path' |
| 4 | 4 | import { describe, expect, it } from 'bun:test' |
| 5 | import { build, init, renderAndroidDeepLinks, renderAndroidPermissions, syncAndroidWebAssets } from './index' | |
| 5 | import { build, init, installRuntime, renderAndroidDeepLinks, renderAndroidPermissions, resolveRuntimeDir, syncAndroidWebAssets } from './index' | |
| 6 | 6 | |
| 7 | 7 | function generatedFiles(path: string): string[] { |
| 8 | 8 | return readdirSync(path, { withFileTypes: true }).flatMap((entry) => { |
| @@ -1188,3 +1188,140 @@ describe('Craft Android builder', () => { | ||
| 1188 | 1188 | } |
| 1189 | 1189 | }) |
| 1190 | 1190 | }) |
| 1191 | ||
| 1192 | /** | |
| 1193 | * A runtime directory shaped like `zig build build-android-all`'s output. | |
| 1194 | * | |
| 1195 | * The contents are not a real ELF — nothing here loads it — but the layout is | |
| 1196 | * exactly `zig-out/android/<abi>/libcraft.so`, because the layout is the part | |
| 1197 | * the installer depends on. | |
| 1198 | */ | |
| 1199 | function fakeRuntime(abis: string[]): string { | |
| 1200 | const dir = mkdtempSync(join(tmpdir(), 'craft-android-runtime-')) | |
| 1201 | for (const abi of abis) { | |
| 1202 | mkdirSync(join(dir, abi), { recursive: true }) | |
| 1203 | writeFileSync(join(dir, abi, 'libcraft.so'), `not-really-${abi}`) | |
| 1204 | } | |
| 1205 | return dir | |
| 1206 | } | |
| 1207 | ||
| 1208 | describe('Zig runtime installation', () => { | |
| 1209 | it('lands the library where AGP already looks for it', () => { | |
| 1210 | // app/src/main/jniLibs is the default jniLibs.srcDirs, which is why no | |
| 1211 | // Gradle template has to know this happened. | |
| 1212 | const output = mkdtempSync(join(tmpdir(), 'craft-android-jnilibs-')) | |
| 1213 | installRuntime(output, fakeRuntime(['arm64-v8a', 'x86_64'])) | |
| 1214 | ||
| 1215 | expect(readFileSync(join(output, 'app/src/main/jniLibs/arm64-v8a/libcraft.so'), 'utf8')).toBe('not-really-arm64-v8a') | |
| 1216 | expect(readFileSync(join(output, 'app/src/main/jniLibs/x86_64/libcraft.so'), 'utf8')).toBe('not-really-x86_64') | |
| 1217 | }) | |
| 1218 | ||
| 1219 | it('installs what it has when an architecture is missing', () => { | |
| 1220 | // A one-ABI dev loop is legitimate; it just cannot run on the other kind | |
| 1221 | // of device, which the installer warns about rather than refusing. | |
| 1222 | const output = mkdtempSync(join(tmpdir(), 'craft-android-one-abi-')) | |
| 1223 | expect(installRuntime(output, fakeRuntime(['x86_64']))).toBe(true) | |
| 1224 | ||
| 1225 | expect(existsSync(join(output, 'app/src/main/jniLibs/x86_64/libcraft.so'))).toBe(true) | |
| 1226 | expect(existsSync(join(output, 'app/src/main/jniLibs/arm64-v8a'))).toBe(false) | |
| 1227 | }) | |
| 1228 | ||
| 1229 | it('refuses a directory with no library at all, and names the build step', () => { | |
| 1230 | const output = mkdtempSync(join(tmpdir(), 'craft-android-no-abi-')) | |
| 1231 | const empty = mkdtempSync(join(tmpdir(), 'craft-android-empty-')) | |
| 1232 | ||
| 1233 | expect(() => installRuntime(output, empty)).toThrow(/zig build build-android-all/) | |
| 1234 | // Nothing written on the way to throwing. | |
| 1235 | expect(existsSync(join(output, 'app/src/main/jniLibs'))).toBe(false) | |
| 1236 | }) | |
| 1237 | ||
| 1238 | it('does not leave an architecture behind that a later build dropped', () => { | |
| 1239 | // A stale .so still ships in the APK and still loads, so the app would run | |
| 1240 | // an architecture nobody built for this version. | |
| 1241 | const output = mkdtempSync(join(tmpdir(), 'craft-android-stale-')) | |
| 1242 | installRuntime(output, fakeRuntime(['arm64-v8a', 'x86_64'])) | |
| 1243 | installRuntime(output, fakeRuntime(['x86_64'])) | |
| 1244 | ||
| 1245 | expect(existsSync(join(output, 'app/src/main/jniLibs/arm64-v8a'))).toBe(false) | |
| 1246 | expect(existsSync(join(output, 'app/src/main/jniLibs/x86_64/libcraft.so'))).toBe(true) | |
| 1247 | }) | |
| 1248 | ||
| 1249 | it('generates a shim-only app when no runtime is configured', async () => { | |
| 1250 | // The default, and the state every generated app was in before this | |
| 1251 | // existed: System.loadLibrary throws, isAvailable is false, Kotlin serves. | |
| 1252 | const output = mkdtempSync(join(tmpdir(), 'craft-android-shim-only-')) | |
| 1253 | await init({ name: 'ShimOnly', packageName: 'dev.craft.shimonly', output, runtimeDir: null }) | |
| 1254 | ||
| 1255 | expect(existsSync(join(output, 'app/src/main/jniLibs'))).toBe(false) | |
| 1256 | }) | |
| 1257 | ||
| 1258 | it('installs the runtime from init when one is given', async () => { | |
| 1259 | const output = mkdtempSync(join(tmpdir(), 'craft-android-with-runtime-')) | |
| 1260 | await init({ | |
| 1261 | name: 'WithRuntime', | |
| 1262 | packageName: 'dev.craft.withruntime', | |
| 1263 | output, | |
| 1264 | runtimeDir: fakeRuntime(['arm64-v8a', 'x86_64']), | |
| 1265 | }) | |
| 1266 | ||
| 1267 | expect(existsSync(join(output, 'app/src/main/jniLibs/arm64-v8a/libcraft.so'))).toBe(true) | |
| 1268 | expect(existsSync(join(output, 'app/src/main/jniLibs/x86_64/libcraft.so'))).toBe(true) | |
| 1269 | }) | |
| 1270 | ||
| 1271 | it('reads CRAFT_ANDROID_RUNTIME only when the caller says nothing', () => { | |
| 1272 | const dir = fakeRuntime(['x86_64']) | |
| 1273 | const previous = process.env.CRAFT_ANDROID_RUNTIME | |
| 1274 | process.env.CRAFT_ANDROID_RUNTIME = dir | |
| 1275 | ||
| 1276 | try { | |
| 1277 | expect(resolveRuntimeDir()).toBe(dir) | |
| 1278 | // null is a declaration, not a gap: no runtime whatever the environment | |
| 1279 | // says, which is what the E2E suite's shim leg depends on. | |
| 1280 | expect(resolveRuntimeDir(null)).toBe(null) | |
| 1281 | } | |
| 1282 | finally { | |
| 1283 | if (previous === undefined) delete process.env.CRAFT_ANDROID_RUNTIME | |
| 1284 | else process.env.CRAFT_ANDROID_RUNTIME = previous | |
| 1285 | } | |
| 1286 | }) | |
| 1287 | ||
| 1288 | it('names which knob pointed at a directory that is not there', () => { | |
| 1289 | const previous = process.env.CRAFT_ANDROID_RUNTIME | |
| 1290 | process.env.CRAFT_ANDROID_RUNTIME = '/craft/definitely/not/here' | |
| 1291 | ||
| 1292 | try { | |
| 1293 | expect(() => resolveRuntimeDir()).toThrow(/CRAFT_ANDROID_RUNTIME points at/) | |
| 1294 | expect(() => resolveRuntimeDir('/craft/also/not/here')).toThrow(/runtimeDir is/) | |
| 1295 | } | |
| 1296 | finally { | |
| 1297 | if (previous === undefined) delete process.env.CRAFT_ANDROID_RUNTIME | |
| 1298 | else process.env.CRAFT_ANDROID_RUNTIME = previous | |
| 1299 | } | |
| 1300 | }) | |
| 1301 | }) | |
| 1302 | ||
| 1303 | describe('the Zig library and the generated app agree on how old a device may be', () => { | |
| 1304 | it('builds the JNI library for no newer an API than minSdk', () => { | |
| 1305 | // One prebuilt libcraft.so serves every generated app, so it has to be | |
| 1306 | // linked against the *oldest* API craft's default configuration claims to | |
| 1307 | // support. Build it against a newer one and the linker happily binds | |
| 1308 | // symbols that are simply absent on an older phone — and the failure is | |
| 1309 | // dlopen refusing the library at startup, on exactly the devices the app | |
| 1310 | // said it ran on. | |
| 1311 | // | |
| 1312 | // Nothing else connects a Zig constant to a TypeScript one, so this reads | |
| 1313 | // build.zig and compares. | |
| 1314 | const buildZig = readFileSync(join(import.meta.dir, '../../zig/build.zig'), 'utf8') | |
| 1315 | const declared = buildZig.match(/const android_api_level: u32 = (\d+);/) | |
| 1316 | ||
| 1317 | expect(declared).not.toBeNull() | |
| 1318 | ||
| 1319 | // DEFAULT_CONFIG is not exported, so read it the same way: from the source | |
| 1320 | // that defines it, which is the thing that would have to change. | |
| 1321 | const generator = readFileSync(join(import.meta.dir, 'index.ts'), 'utf8') | |
| 1322 | const minSdk = generator.match(/minSdk:\s*(\d+)/) | |
| 1323 | ||
| 1324 | expect(minSdk).not.toBeNull() | |
| 1325 | expect(Number(declared![1])).toBeLessThanOrEqual(Number(minSdk![1])) | |
| 1326 | }) | |
| 1327 | }) | |
| @@ -77,6 +77,14 @@ export interface InitOptions { | ||
| 77 | 77 | packageName?: string |
| 78 | 78 | output: string |
| 79 | 79 | config?: Partial<CraftAndroidConfig> |
| 80 | /** | |
| 81 | * Where to find the Zig runtime's `<abi>/libcraft.so`. | |
| 82 | * | |
| 83 | * `null` means no runtime regardless of the environment; `undefined` falls | |
| 84 | * through to `CRAFT_ANDROID_RUNTIME`. Same shape as the iOS builder's | |
| 85 | * `runtimeDir`. | |
| 86 | */ | |
| 87 | runtimeDir?: string | null | |
| 80 | 88 | } |
| 81 | 89 | |
| 82 | 90 | export interface BuildOptions { |
| @@ -85,6 +93,8 @@ export interface BuildOptions { | ||
| 85 | 93 | output: string |
| 86 | 94 | release?: boolean |
| 87 | 95 | compile?: boolean |
| 96 | /** Same meaning as `InitOptions.runtimeDir`; refreshes what init installed. */ | |
| 97 | runtimeDir?: string | null | |
| 88 | 98 | } |
| 89 | 99 | |
| 90 | 100 | export interface OpenOptions { |
| @@ -341,6 +351,96 @@ export function renderAndroidDeepLinks(config: CraftAndroidConfig): string { | ||
| 341 | 351 | /** |
| 342 | 352 | * Initialize a new Android project |
| 343 | 353 | */ |
| 354 | /** | |
| 355 | * The ABI directories a runtime directory is expected to hold. | |
| 356 | * | |
| 357 | * These are Android's own names for the two architectures craft builds, and | |
| 358 | * they are also `zig build build-android-all`'s output layout — `zig-out/android/` | |
| 359 | * already contains `arm64-v8a/libcraft.so` and `x86_64/libcraft.so`, which is | |
| 360 | * exactly the shape `app/src/main/jniLibs/` wants. So the install below is a | |
| 361 | * copy and not a rearrangement. | |
| 362 | * | |
| 363 | * x86_64 is not optional-in-practice: every Android emulator anyone tests on | |
| 364 | * is x86_64, so a runtime shipped for arm64 alone is one that never runs in | |
| 365 | * CI and never runs on a developer's machine either. | |
| 366 | */ | |
| 367 | const RUNTIME_ABIS = ['arm64-v8a', 'x86_64'] as const | |
| 368 | ||
| 369 | /** | |
| 370 | * Where the runtime directory comes from when the caller does not say. | |
| 371 | * | |
| 372 | * The same shape as `CRAFT_BIN` and the iOS builder's `CRAFT_IOS_RUNTIME`: an | |
| 373 | * explicit override for the monorepo dev loop, not a lookup path. Shipping the | |
| 374 | * runtime to real apps means putting these libraries in the pantry package | |
| 375 | * beside the `craft` binary, which is a distribution decision this function | |
| 376 | * does not make. | |
| 377 | */ | |
| 378 | export function resolveRuntimeDir(override?: string | null): string | null { | |
| 379 | if (override === null) return null | |
| 380 | const dir = override ?? process.env.CRAFT_ANDROID_RUNTIME | |
| 381 | if (!dir) return null | |
| 382 | if (!existsSync(dir)) { | |
| 383 | const source = override === undefined ? 'CRAFT_ANDROID_RUNTIME points at' : 'runtimeDir is' | |
| 384 | throw new Error(`${source} ${dir}, which does not exist.`) | |
| 385 | } | |
| 386 | return dir | |
| 387 | } | |
| 388 | ||
| 389 | /** | |
| 390 | * Copy the Zig runtime into the generated project as | |
| 391 | * `app/src/main/jniLibs/<abi>/libcraft.so`. | |
| 392 | * | |
| 393 | * That path is AGP's default `jniLibs.srcDirs`, so nothing in the Gradle | |
| 394 | * templates has to know about it — the library is packaged into the APK and | |
| 395 | * `System.loadLibrary("craft")` finds it because the file is named for the | |
| 396 | * `craft` it asks for. | |
| 397 | * | |
| 398 | * Until this existed, nothing put the library anywhere. `CraftNative` caught | |
| 399 | * the `UnsatisfiedLinkError`, set `isAvailable = false`, and every action fell | |
| 400 | * through to the Kotlin shim — by design, so an app with no runtime still | |
| 401 | * works, which is also why nobody noticed that *every* generated app was in | |
| 402 | * that state and the whole Android half of the Zig bridge had never run. | |
| 403 | * | |
| 404 | * Returns true when a runtime was installed. | |
| 405 | */ | |
| 406 | export function installRuntime(output: string, runtimeDir: string): boolean { | |
| 407 | // Every ABI resolved before anything is written, so a half-populated | |
| 408 | // runtime directory cannot leave a project carrying one architecture and | |
| 409 | // claiming both. A single-ABI APK installs fine and then fails to load on | |
| 410 | // the other kind of device, which is the break that only shows up on | |
| 411 | // somebody else's phone. | |
| 412 | const found = RUNTIME_ABIS.map(abi => ({ abi, source: join(runtimeDir, abi, 'libcraft.so') })) | |
| 413 | .filter(entry => existsSync(entry.source)) | |
| 414 | ||
| 415 | if (found.length === 0) { | |
| 416 | throw new Error( | |
| 417 | `${runtimeDir} has no <abi>/libcraft.so for any of ${RUNTIME_ABIS.join(', ')}. ` | |
| 418 | + 'Run `zig build build-android-all -Doptimize=ReleaseSafe` in packages/zig and point at its zig-out/android.', | |
| 419 | ) | |
| 420 | } | |
| 421 | ||
| 422 | if (found.length < RUNTIME_ABIS.length) { | |
| 423 | const missing = RUNTIME_ABIS.filter(abi => !found.some(entry => entry.abi === abi)) | |
| 424 | console.warn( | |
| 425 | ` ⚠ only ${found.map(entry => entry.abi).join(', ')} was found; ${missing.join(', ')} is missing. ` | |
| 426 | + 'The app will fall back to the Kotlin shim on those devices.', | |
| 427 | ) | |
| 428 | } | |
| 429 | ||
| 430 | const dest = join(output, 'app/src/main/jniLibs') | |
| 431 | // Wiped rather than merged, so an ABI dropped from a later build does not | |
| 432 | // linger in the project as a stale library the APK still ships. | |
| 433 | rmSync(dest, { force: true, recursive: true }) | |
| 434 | ||
| 435 | for (const { abi, source } of found) { | |
| 436 | const abiDir = join(dest, abi) | |
| 437 | mkdirSync(abiDir, { recursive: true }) | |
| 438 | cpSync(source, join(abiDir, 'libcraft.so')) | |
| 439 | } | |
| 440 | ||
| 441 | return true | |
| 442 | } | |
| 443 | ||
| 344 | 444 | export async function init(options: InitOptions): Promise<void> { |
| 345 | 445 | const { name, packageName, output } = options |
| 346 | 446 | |
| @@ -670,6 +770,15 @@ zipStorePath=wrapper/dists | ||
| 670 | 770 | |
| 671 | 771 | writeFileSync(join(output, 'app/src/main/assets/index.html'), placeholderHtml) |
| 672 | 772 | |
| 773 | // The Zig runtime, when one was configured. Absent, the generated app is the | |
| 774 | // Kotlin shim and behaves exactly as it always has — which is the fallback | |
| 775 | // `CraftNative` was written for, not a failure. | |
| 776 | const runtimeDir = resolveRuntimeDir(options.runtimeDir) | |
| 777 | if (runtimeDir) { | |
| 778 | installRuntime(output, runtimeDir) | |
| 779 | console.log(' Installed the Zig runtime from', runtimeDir) | |
| 780 | } | |
| 781 | ||
| 673 | 782 | console.log('✅ Project initialized') |
| 674 | 783 | console.log('') |
| 675 | 784 | console.log('Next steps:') |
| @@ -696,6 +805,20 @@ export async function build(options: BuildOptions): Promise<void> { | ||
| 696 | 805 | |
| 697 | 806 | const config: CraftAndroidConfig = JSON.parse(readFileSync(configPath, 'utf-8')) |
| 698 | 807 | |
| 808 | // Refresh the runtime the same way the iOS builder does: only for a project | |
| 809 | // that already has one, so `craft android build` in a shim-only project does | |
| 810 | // not quietly acquire a native library it was never generated with. | |
| 811 | if (existsSync(join(output, 'app/src/main/jniLibs'))) { | |
| 812 | const runtimeDir = resolveRuntimeDir(options.runtimeDir) | |
| 813 | if (runtimeDir) { | |
| 814 | installRuntime(output, runtimeDir) | |
| 815 | console.log(' Refreshed the Zig runtime from', runtimeDir) | |
| 816 | } | |
| 817 | else { | |
| 818 | console.log(' Keeping the Zig runtime installed at init (no runtime directory configured)') | |
| 819 | } | |
| 820 | } | |
| 821 | ||
| 699 | 822 | // Update dev server URL if provided |
| 700 | 823 | if (devServer) { |
| 701 | 824 | const url = androidWebUrl(devServer, 'Android dev server URL') |
| @@ -2366,10 +2366,49 @@ class CraftBridge( | ||
| 2366 | 2366 | activity.packageManager.getPackageInfo(activity.packageName, 0).versionCode |
| 2367 | 2367 | } |
| 2368 | 2368 | } catch (e: Exception) { 0 }) |
| 2369 | put("isEmulator", Build.FINGERPRINT.contains("generic") || Build.FINGERPRINT.contains("emulator")) | |
| 2369 | put("isEmulator", isEmulator()) | |
| 2370 | 2370 | }.toString() |
| 2371 | 2371 | } |
| 2372 | 2372 | |
| 2373 | /** | |
| 2374 | * Whether this is an emulator rather than a phone. | |
| 2375 | * | |
| 2376 | * This used to be `FINGERPRINT.contains("generic") || | |
| 2377 | * FINGERPRINT.contains("emulator")`, and it answered false on the AVDs | |
| 2378 | * anyone actually runs. A current Google APIs image fingerprints as | |
| 2379 | * | |
| 2380 | * google/sdk_gphone64_x86_64/emu64xa:14/UE1A.230829.036/...:user/release-keys | |
| 2381 | * | |
| 2382 | * which contains neither word. The mobile E2E suite caught it on an API 34 | |
| 2383 | * x86_64 emulator, reporting `isEmulator is false` from inside one. | |
| 2384 | * | |
| 2385 | * `HARDWARE` is the reliable signal and leads here: the AVD's virtual board | |
| 2386 | * is `goldfish` on the old QEMU pipeline and `ranchu` on the current one, | |
| 2387 | * and has been one of those two for over a decade. The rest are kept as | |
| 2388 | * secondary evidence for images that report something else - Genymotion, | |
| 2389 | * and the older `generic`/`sdk` products. | |
| 2390 | * | |
| 2391 | * Note this is a hint, not a security boundary. Anything that can rewrite | |
| 2392 | * these properties can defeat it, and nothing here should be treated as | |
| 2393 | * proof of where the code is running. | |
| 2394 | */ | |
| 2395 | private fun isEmulator(): Boolean { | |
| 2396 | val hardware = Build.HARDWARE.lowercase() | |
| 2397 | if (hardware.startsWith("goldfish") || hardware.startsWith("ranchu")) return true | |
| 2398 | ||
| 2399 | val fingerprint = Build.FINGERPRINT.lowercase() | |
| 2400 | if (fingerprint.startsWith("generic") || fingerprint.startsWith("unknown")) return true | |
| 2401 | if (fingerprint.contains("emulator") || fingerprint.contains("sdk_gphone")) return true | |
| 2402 | ||
| 2403 | val product = Build.PRODUCT.lowercase() | |
| 2404 | if (product.startsWith("sdk") || product.contains("_sdk") || product.contains("sdk_")) return true | |
| 2405 | ||
| 2406 | val model = Build.MODEL.lowercase() | |
| 2407 | if (model.contains("google_sdk") || model.contains("emulator") || model.contains("android sdk built for")) return true | |
| 2408 | ||
| 2409 | return Build.MANUFACTURER.contains("Genymotion") || Build.BRAND.startsWith("generic") | |
| 2410 | } | |
| 2411 | ||
| 2373 | 2412 | // ==================== Network Status ==================== |
| 2374 | 2413 | |
| 2375 | 2414 | @JavascriptInterface |
| @@ -110,10 +110,24 @@ object CraftNative { | ||
| 110 | 110 | */ |
| 111 | 111 | val isAvailable: Boolean = try { |
| 112 | 112 | System.loadLibrary("craft") |
| 113 | // Said out loud, because the two outcomes below are otherwise | |
| 114 | // indistinguishable from outside: a library that never shipped and a | |
| 115 | // library that loaded and then failed to bind both end with the shim | |
| 116 | // answering every call. Zig logs its own registration under the same | |
| 117 | // tag, so the pair reads as a sequence. | |
| 118 | android.util.Log.i("CraftNative", "craft: libcraft.so loaded") | |
| 113 | 119 | true |
| 114 | 120 | } catch (e: UnsatisfiedLinkError) { |
| 121 | // The reason, not just the fact. This used to be a bare `false`, and | |
| 122 | // the first time the runtime was genuinely shipped and genuinely | |
| 123 | // failed to load, the only evidence anywhere was that nothing | |
| 124 | // happened — no linker line, no exception, nothing in logcat at all. | |
| 125 | // The message carries the dynamic linker's own complaint, which is the | |
| 126 | // one thing that says *why*. | |
| 127 | android.util.Log.w("CraftNative", "craft: libcraft.so did not load: ${e.message}") | |
| 115 | 128 | false |
| 116 | 129 | } catch (e: SecurityException) { |
| 130 | android.util.Log.w("CraftNative", "craft: libcraft.so was refused: ${e.message}") | |
| 117 | 131 | false |
| 118 | 132 | } |
| 119 | 133 | |
| @@ -123,14 +123,45 @@ LAUNCH_PID=$! | ||
| 123 | 123 | |
| 124 | 124 | # The round trip is fast, but a cold simulator is not. Poll rather than sleep a |
| 125 | 125 | # fixed amount, so a slow boot does not read as a failure. |
| 126 | for _ in $(seq 1 60); do | |
| 127 | if grep -q 'i=26' "$LOG" 2>/dev/null && grep -q 'i=28' "$LOG" 2>/dev/null \ | |
| 128 | && grep -q 'i=32' "$LOG" 2>/dev/null && grep -q 'i=22' "$LOG" 2>/dev/null \ | |
| 129 | && grep -q 'i=34' "$LOG" 2>/dev/null && grep -q 'i=38' "$LOG" 2>/dev/null \ | |
| 130 | && grep -q 'i=46' "$LOG" 2>/dev/null && grep -q 'i=44' "$LOG" 2>/dev/null \ | |
| 131 | && grep -q 'i=24' "$LOG" 2>/dev/null && grep -q 'i=20' "$LOG" 2>/dev/null \ | |
| 132 | && grep -q 'i=72' "$LOG" 2>/dev/null && grep -q 'i=76' "$LOG" 2>/dev/null \ | |
| 133 | && grep -q 'i=78' "$LOG" 2>/dev/null && grep -q 'i=82' "$LOG" 2>/dev/null; then sleep 1; break; fi | |
| 126 | # | |
| 127 | # The budget is wall-clock, not iterations. It used to be `seq 1 60` around a | |
| 128 | # body that runs fourteen greps, which on this laptop is about a minute and on | |
| 129 | # a loaded CI runner is nearer three — and when it ran out the loop simply fell | |
| 130 | # through to the assertions below, which then failed on whichever late marker | |
| 131 | # had not arrived yet. That is a timeout wearing the costume of a wrong result: | |
| 132 | # the first CI run of this fixture reported "the deletion completion never | |
| 133 | # fired, or its reply was lost" when the truth was that the app was still | |
| 134 | # working through the ids before it. | |
| 135 | # | |
| 136 | # So: a deadline, and an explicit failure that names what is missing. The two | |
| 137 | # situations look nothing alike now. | |
| 138 | SLICE_TIMEOUT="${CRAFT_SLICE_TIMEOUT:-600}" | |
| 139 | AWAITED_IDS="20 22 24 26 28 32 34 38 44 46 72 76 78 82" | |
| 140 | DEADLINE=$(( $(date +%s) + SLICE_TIMEOUT )) | |
| 141 | ||
| 142 | missing_ids() { | |
| 143 | local missing="" | |
| 144 | for id in $AWAITED_IDS; do | |
| 145 | # ERE with an explicit boundary rather than \b, which is a GNU | |
| 146 | # extension this script cannot count on, and rather than a bare | |
| 147 | # "i=$id", which matches i=260 when it is looking for i=26. | |
| 148 | grep -qE "i=$id(\$|[^0-9])" "$LOG" 2>/dev/null || missing="$missing i=$id" | |
| 149 | done | |
| 150 | echo "$missing" | |
| 151 | } | |
| 152 | ||
| 153 | while :; do | |
| 154 | MISSING="$(missing_ids)" | |
| 155 | [ -z "$MISSING" ] && { sleep 1; break; } | |
| 156 | if [ "$(date +%s)" -ge "$DEADLINE" ]; then | |
| 157 | echo "==> console" | |
| 158 | cat "$LOG" || true | |
| 159 | echo "FAIL: timed out after ${SLICE_TIMEOUT}s waiting for:$MISSING" | |
| 160 | echo " The app never got that far — this is a timeout, not a wrong answer." | |
| 161 | echo " Raise CRAFT_SLICE_TIMEOUT if the machine is simply slow." | |
| 162 | kill "$LAUNCH_PID" 2>/dev/null || true | |
| 163 | exit 1 | |
| 164 | fi | |
| 134 | 165 | sleep 1 |
| 135 | 166 | done |
| 136 | 167 | kill "$LAUNCH_PID" 2>/dev/null || true |
| @@ -350,6 +350,23 @@ describe('choosing a simulator', () => { | ||
| 350 | 350 | expect(chosen?.name).toBe('iPhone 17 Pro') |
| 351 | 351 | }) |
| 352 | 352 | |
| 353 | it('ignores simulators that cannot run an iOS app', () => { | |
| 354 | // Booted sorts ahead of everything, so an open Apple Watch or Apple TV | |
| 355 | // simulator would otherwise be handed back as the place to install an | |
| 356 | // iphonesimulator build. | |
| 357 | const chosen = orderSimulators([ | |
| 358 | device('Apple Watch Series 10', 'watchOS-11-0', 'Booted'), | |
| 359 | device('Apple TV 4K', 'tvOS-18-0', 'Booted'), | |
| 360 | device('iPhone 17', 'iOS-27-0'), | |
| 361 | ])[0] | |
| 362 | ||
| 363 | expect(chosen?.name).toBe('iPhone 17') | |
| 364 | }) | |
| 365 | ||
| 366 | it('returns nothing when only non-iOS simulators exist', () => { | |
| 367 | expect(orderSimulators([device('Apple Watch Series 10', 'watchOS-11-0', 'Booted')])[0]).toBeUndefined() | |
| 368 | }) | |
| 369 | ||
| 353 | 370 | it('and never invents one that is not installed', () => { |
| 354 | 371 | // The regression in one line: no devices means no device, rather than a |
| 355 | 372 | // hard-coded name xcodebuild will refuse. |
| @@ -819,23 +819,30 @@ export interface SimulatorDevice { | ||
| 819 | 819 | } |
| 820 | 820 | |
| 821 | 821 | /** |
| 822 | * Every available simulator, newest runtime first, iPhones before iPads. | |
| 822 | * Every available *iOS* simulator, newest runtime first, iPhones before iPads. | |
| 823 | 823 | * |
| 824 | 824 | * Exported because the choice is worth testing without a simulator present - |
| 825 | 825 | * `pickSimulator` is the policy and this is the data it reads. |
| 826 | 826 | */ |
| 827 | 827 | export function orderSimulators(devices: SimulatorDevice[]): SimulatorDevice[] { |
| 828 | return [...devices].sort((left, right) => { | |
| 829 | const booted = Number(right.state === 'Booted') - Number(left.state === 'Booted') | |
| 830 | if (booted !== 0) | |
| 831 | return booted | |
| 832 | ||
| 833 | const phone = Number(right.name.startsWith('iPhone')) - Number(left.name.startsWith('iPhone')) | |
| 834 | if (phone !== 0) | |
| 835 | return phone | |
| 836 | ||
| 837 | return right.runtime.localeCompare(left.runtime) | |
| 838 | }) | |
| 828 | // iOS runtimes only, before anything else is considered. A booted device | |
| 829 | // sorts ahead of every shut-down one, and a developer with an Apple Watch or | |
| 830 | // Apple TV simulator open would otherwise have it handed back as the place | |
| 831 | // to install an iphonesimulator build - which fails at `simctl install`, | |
| 832 | // naming a device nobody asked for. | |
| 833 | return devices | |
| 834 | .filter(device => device.runtime.startsWith('iOS')) | |
| 835 | .sort((left, right) => { | |
| 836 | const booted = Number(right.state === 'Booted') - Number(left.state === 'Booted') | |
| 837 | if (booted !== 0) | |
| 838 | return booted | |
| 839 | ||
| 840 | const phone = Number(right.name.startsWith('iPhone')) - Number(left.name.startsWith('iPhone')) | |
| 841 | if (phone !== 0) | |
| 842 | return phone | |
| 843 | ||
| 844 | return right.runtime.localeCompare(left.runtime) | |
| 845 | }) | |
| 839 | 846 | } |
| 840 | 847 | |
| 841 | 848 | /** |
| @@ -1445,11 +1445,29 @@ struct CraftWebView: UIViewRepresentable { | ||
| 1445 | 1445 | |
| 1446 | 1446 | private func isTrustedURL(_ url: URL?) -> Bool { |
| 1447 | 1447 | guard let url = url else { return false } |
| 1448 | if url.scheme == "craft" && url.host == "app" { return true } | |
| 1449 | 1448 | return isTrustedOrigin(scheme: url.scheme ?? "", host: url.host ?? "", port: url.port ?? 0) |
| 1450 | 1449 | } |
| 1451 | 1450 | |
| 1451 | /// The single answer to "may this origin reach native?". | |
| 1452 | /// | |
| 1453 | /// The bundled app is served from craft://app by | |
| 1454 | /// `BundledAssetSchemeHandler`, and it is the only content a generated | |
| 1455 | /// app loads when no dev server is configured. That origin used to be | |
| 1456 | /// trusted for *navigation* only: `isTrustedURL` carried the clause, | |
| 1457 | /// while the `userContentController` guard called this function | |
| 1458 | /// directly and fell through to the https/localhost check. So every | |
| 1459 | /// bridge call from the app's own page was answered with | |
| 1460 | /// | |
| 1461 | /// Blocked Craft bridge message from untrusted origin: craft://app | |
| 1462 | /// | |
| 1463 | /// and its promise never settled — the whole native surface was dead | |
| 1464 | /// in the default configuration, which is the one every generated app | |
| 1465 | /// ships with. Two guards that had to agree, and did not. There is one | |
| 1466 | /// now, and `scripts/mobile-e2e.ts` runs a real round trip on a booted | |
| 1467 | /// simulator so a future hardening pass cannot quietly take the bridge | |
| 1468 | /// away again. | |
| 1452 | 1469 | private func isTrustedOrigin(scheme: String, host: String, port: Int) -> Bool { |
| 1470 | if scheme == "craft" && host == "app" { return true } | |
| 1453 | 1471 | if scheme == "file" { return true } |
| 1454 | 1472 | guard scheme == "https" || (scheme == "http" && ["localhost", "127.0.0.1", "::1"].contains(host)) else { return false } |
| 1455 | 1473 | let defaultPort = scheme == "https" ? 443 : 80 |
| @@ -15,6 +15,19 @@ pub fn build(b: *std.Build) void { | ||
| 15 | 15 | // to avoid Zig bug where --sysroot breaks @cImport (ziglang/zig#22704, #25010) |
| 16 | 16 | const macos_sdk = b.option([]const u8, "macos-sdk", "macOS SDK path for cross-compilation"); |
| 17 | 17 | |
| 18 | // The Android NDK, needed by the JNI library and nothing else. An explicit | |
| 19 | // path rather than a read of ANDROID_NDK_HOME, matching -Dmacos-sdk: the | |
| 20 | // caller passes "$ANDROID_NDK_HOME" when that is what they mean, and a | |
| 21 | // build that depends on an environment variable is a build that behaves | |
| 22 | // differently in two shells for reasons neither of them shows. | |
| 23 | // | |
| 24 | // It stopped being optional because without bionic the library builds, | |
| 25 | // ships, and then fails to load — std references getauxval and | |
| 26 | // __tls_get_addr, both of which live there, and Zig answers "unable to | |
| 27 | // provide libc" for an android target. The failure was a silent fallback | |
| 28 | // to the Kotlin shim, which is the quietest of the three outcomes. | |
| 29 | const android_ndk = b.option([]const u8, "android-ndk", "Android NDK root, required by build-android"); | |
| 30 | ||
| 18 | 31 | // Set when the opt-in JavaScript tests are wired up, so `test` can include |
| 19 | 32 | // them without the declaration being scoped inside the `if`. |
| 20 | 33 | var js_test_run: ?*std.Build.Step.Run = null; |
| @@ -2391,6 +2404,11 @@ pub fn build(b: *std.Build) void { | ||
| 2391 | 2404 | .cpu_arch = .aarch64, |
| 2392 | 2405 | .os_tag = .linux, |
| 2393 | 2406 | .abi = .android, |
| 2407 | // Named explicitly, because Zig's default is 29 and the stubs this | |
| 2408 | // links against come from the API-26 directory. A target and a sysroot | |
| 2409 | // that disagree is how a library binds a symbol the device does not | |
| 2410 | // have. | |
| 2411 | .android_api_level = android_api_level, | |
| 2394 | 2412 | }); |
| 2395 | 2413 | |
| 2396 | 2414 | const android_arm64_lib = b.addLibrary(.{ |
| @@ -2425,46 +2443,74 @@ pub fn build(b: *std.Build) void { | ||
| 2425 | 2443 | // the arch-suffixed name beside it: the loader picks the ABI directory, |
| 2426 | 2444 | // and a per-arch name would make the Kotlin need to know its own |
| 2427 | 2445 | // architecture. |
| 2428 | const android_arm64_jni = b.addLibrary(.{ | |
| 2429 | .linkage = .dynamic, | |
| 2430 | .name = "craft", | |
| 2431 | .root_module = b.createModule(.{ | |
| 2432 | .root_source_file = b.path("src/android_dispatch.zig"), | |
| 2433 | .target = android_arm64_target, | |
| 2434 | .optimize = optimize, | |
| 2435 | }), | |
| 2436 | }); | |
| 2446 | if (android_ndk) |ndk| { | |
| 2447 | const android_arm64_jni = b.addLibrary(.{ | |
| 2448 | .linkage = .dynamic, | |
| 2449 | .name = "craft", | |
| 2450 | .root_module = b.createModule(.{ | |
| 2451 | .root_source_file = b.path("src/android_dispatch.zig"), | |
| 2452 | .target = android_arm64_target, | |
| 2453 | .optimize = optimize, | |
| 2454 | }), | |
| 2455 | }); | |
| 2456 | linkAndroidLibc(b, android_arm64_jni, ndk, "aarch64-linux-android"); | |
| 2437 | 2457 | |
| 2438 | const android_arm64_jni_install = b.addInstallArtifact(android_arm64_jni, .{ | |
| 2439 | .dest_dir = .{ .override = .{ .custom = "android/arm64-v8a" } }, | |
| 2440 | }); | |
| 2441 | build_android.dependOn(&android_arm64_jni_install.step); | |
| 2442 | build_android_all.dependOn(&android_arm64_jni_install.step); | |
| 2458 | const android_arm64_jni_install = b.addInstallArtifact(android_arm64_jni, .{ | |
| 2459 | .dest_dir = .{ .override = .{ .custom = "android/arm64-v8a" } }, | |
| 2460 | }); | |
| 2461 | build_android.dependOn(&android_arm64_jni_install.step); | |
| 2462 | build_android_all.dependOn(&android_arm64_jni_install.step); | |
| 2463 | } else { | |
| 2464 | // Not built at all rather than built without bionic. The library would | |
| 2465 | // compile, install, ship in the APK and then fail at `dlopen` with two | |
| 2466 | // unresolved symbols, at which point `CraftNative` catches the | |
| 2467 | // `UnsatisfiedLinkError` and every action quietly uses the Kotlin shim. | |
| 2468 | // A step that refuses, naming the flag, is the same information | |
| 2469 | // delivered where someone can act on it. | |
| 2470 | // | |
| 2471 | // A `Fail` step rather than a panic, so this is only reached by | |
| 2472 | // *asking* for an Android build: `zig build test` on a machine with no | |
| 2473 | // NDK must still work, and it did not while this was a configure-time | |
| 2474 | // panic. | |
| 2475 | const missing = b.addFail( | |
| 2476 | "build-android needs the NDK: pass -Dandroid-ndk=\"$ANDROID_NDK_HOME\" " ++ | |
| 2477 | "(sdkmanager --install \"ndk;26.1.10909125\"). Without bionic the JNI library " ++ | |
| 2478 | "builds and then loads nowhere — std references getauxval and __tls_get_addr, " ++ | |
| 2479 | "both of which live there, and Zig cannot supply libc for an android target.", | |
| 2480 | ); | |
| 2481 | build_android.dependOn(&missing.step); | |
| 2482 | build_android_x86.dependOn(&missing.step); | |
| 2483 | build_android_all.dependOn(&missing.step); | |
| 2484 | } | |
| 2443 | 2485 | |
| 2444 | 2486 | // Android Emulator (x86_64) |
| 2445 | 2487 | const android_x86_target = b.resolveTargetQuery(.{ |
| 2446 | 2488 | .cpu_arch = .x86_64, |
| 2447 | 2489 | .os_tag = .linux, |
| 2448 | 2490 | .abi = .android, |
| 2491 | .android_api_level = android_api_level, | |
| 2449 | 2492 | }); |
| 2450 | 2493 | |
| 2451 | const android_x86_jni = b.addLibrary(.{ | |
| 2452 | .linkage = .dynamic, | |
| 2453 | .name = "craft", | |
| 2454 | .root_module = b.createModule(.{ | |
| 2455 | .root_source_file = b.path("src/android_dispatch.zig"), | |
| 2456 | .target = android_x86_target, | |
| 2457 | .optimize = optimize, | |
| 2458 | }), | |
| 2459 | }); | |
| 2494 | if (android_ndk) |ndk| { | |
| 2495 | const android_x86_jni = b.addLibrary(.{ | |
| 2496 | .linkage = .dynamic, | |
| 2497 | .name = "craft", | |
| 2498 | .root_module = b.createModule(.{ | |
| 2499 | .root_source_file = b.path("src/android_dispatch.zig"), | |
| 2500 | .target = android_x86_target, | |
| 2501 | .optimize = optimize, | |
| 2502 | }), | |
| 2503 | }); | |
| 2504 | linkAndroidLibc(b, android_x86_jni, ndk, "x86_64-linux-android"); | |
| 2460 | 2505 | |
| 2461 | // `x86_64`, matching the jniLibs directory the loader looks in — not | |
| 2462 | // `x86`, which is the 32-bit ABI and would be silently ignored. | |
| 2463 | const android_x86_jni_install = b.addInstallArtifact(android_x86_jni, .{ | |
| 2464 | .dest_dir = .{ .override = .{ .custom = "android/x86_64" } }, | |
| 2465 | }); | |
| 2466 | build_android_x86.dependOn(&android_x86_jni_install.step); | |
| 2467 | build_android_all.dependOn(&android_x86_jni_install.step); | |
| 2506 | // `x86_64`, matching the jniLibs directory the loader looks in — not | |
| 2507 | // `x86`, which is the 32-bit ABI and would be silently ignored. | |
| 2508 | const android_x86_jni_install = b.addInstallArtifact(android_x86_jni, .{ | |
| 2509 | .dest_dir = .{ .override = .{ .custom = "android/x86_64" } }, | |
| 2510 | }); | |
| 2511 | build_android_x86.dependOn(&android_x86_jni_install.step); | |
| 2512 | build_android_all.dependOn(&android_x86_jni_install.step); | |
| 2513 | } | |
| 2468 | 2514 | |
| 2469 | 2515 | const android_x86_lib = b.addLibrary(.{ |
| 2470 | 2516 | .linkage = .static, |
| @@ -2524,6 +2570,112 @@ pub fn build(b: *std.Build) void { | ||
| 2524 | 2570 | /// Link platform-specific system libraries for a build module. |
| 2525 | 2571 | /// Centralizes the per-OS library linking that is shared across exe, craft_exe, |
| 2526 | 2572 | /// lib_unit_tests, and cross-compilation targets. |
| 2573 | /// The Android API level the JNI library targets. | |
| 2574 | /// | |
| 2575 | /// Must not exceed the generator's default `minSdk`, because one prebuilt | |
| 2576 | /// library serves every generated app: building against a newer API would let | |
| 2577 | /// the linker bind symbols that are simply absent on an older device, and the | |
| 2578 | /// failure is `dlopen` refusing the library at startup on exactly the phones | |
| 2579 | /// the app claims to support. `packages/android/src/index.test.ts` asserts the | |
| 2580 | /// two numbers agree, since nothing else connects a Zig constant to a | |
| 2581 | /// TypeScript one. | |
| 2582 | const android_api_level: u32 = 26; | |
| 2583 | ||
| 2584 | /// The NDK's sysroot, or null when `ndk` does not contain one. | |
| 2585 | /// | |
| 2586 | /// The prebuilt directory is named for the *host* and the NDK ships exactly | |
| 2587 | /// one, so the candidates are tried in turn rather than guessed from | |
| 2588 | /// `builtin.os`. An Apple-silicon Mac still gets `darwin-x86_64` from most NDK | |
| 2589 | /// releases, which is the guess that would have been wrong. | |
| 2590 | /// | |
| 2591 | /// Null rather than a panic: this runs at configure time, so a panic here | |
| 2592 | /// would break `zig build test` for anyone who passed a wrong -Dandroid-ndk, | |
| 2593 | /// not just the Android steps. | |
| 2594 | fn androidSysroot(b: *std.Build, ndk: []const u8) ?[]const u8 { | |
| 2595 | const io = b.graph.io; | |
| 2596 | for ([_][]const u8{ "darwin-x86_64", "darwin-arm64", "linux-x86_64", "windows-x86_64" }) |host| { | |
| 2597 | const candidate = b.pathJoin(&.{ ndk, "toolchains/llvm/prebuilt", host, "sysroot" }); | |
| 2598 | std.Io.Dir.cwd().access(io, candidate, .{}) catch continue; | |
| 2599 | return candidate; | |
| 2600 | } | |
| 2601 | return null; | |
| 2602 | } | |
| 2603 | ||
| 2604 | /// Link one Android artifact against the NDK's bionic. | |
| 2605 | /// | |
| 2606 | /// A `--libc` file, not include and library paths. Paths alone are what | |
| 2607 | /// `applySdkPaths` does for Apple SDKs and they are not enough here: with | |
| 2608 | /// `link_libc` set and no libc file, Zig looks the target up in its own table | |
| 2609 | /// of libcs it can build, does not find android, and stops with | |
| 2610 | /// | |
| 2611 | /// error: unable to provide libc for target "x86_64-linux...-android.29" | |
| 2612 | /// | |
| 2613 | /// before it ever consults a search path. The libc file is how Zig is told the | |
| 2614 | /// libc already exists. | |
| 2615 | /// | |
| 2616 | /// `gcc_dir` is not optional, and its absence is reported under the wrong | |
| 2617 | /// name. For any `.linux` target — Android is one — Zig takes crtbegin and | |
| 2618 | /// crtend from `gcc_dir`, not `crt_dir`, and returns | |
| 2619 | /// `LibCInstallationMissingCrtDir` when `gcc_dir` is empty | |
| 2620 | /// (std/zig/LibCInstallation.zig, `resolveCrtPaths`). So a file with a correct | |
| 2621 | /// `crt_dir` and a blank `gcc_dir` fails with "libc installation is missing crt | |
| 2622 | /// directory", which sends you checking a directory that is fine. Bionic keeps | |
| 2623 | /// crtbegin_so.o and crtend_so.o beside its stubs, so both fields point at the | |
| 2624 | /// same API-level directory. Zig 0.17.0-dev.1441 read them from `crt_dir`, | |
| 2625 | /// which is why this passed locally and failed on CI's 1963. | |
| 2626 | /// | |
| 2627 | /// Still not `--sysroot`, which breaks `@cImport` (ziglang/zig#22704, #25010) | |
| 2628 | /// and is why the Apple side takes the path route too. | |
| 2629 | fn linkAndroidLibc(b: *std.Build, compile: *std.Build.Step.Compile, ndk: []const u8, triple: []const u8) void { | |
| 2630 | const io = b.graph.io; | |
| 2631 | ||
| 2632 | const sysroot = androidSysroot(b, ndk) orelse { | |
| 2633 | compile.step.dependOn(&b.addFail(b.fmt( | |
| 2634 | "-Dandroid-ndk={s} has no toolchains/llvm/prebuilt/<host>/sysroot. " ++ | |
| 2635 | "Point it at an NDK root, not at the SDK.", | |
| 2636 | .{ndk}, | |
| 2637 | )).step); | |
| 2638 | return; | |
| 2639 | }; | |
| 2640 | ||
| 2641 | const api = b.fmt("{d}", .{android_api_level}); | |
| 2642 | const lib_dir = b.pathJoin(&.{ sysroot, "usr/lib", triple, api }); | |
| 2643 | ||
| 2644 | // Checked here so a missing API level fails with the path and the fix, | |
| 2645 | // rather than as a linker error about crtbegin_so.o. Lazy, like the NDK | |
| 2646 | // check: only a build that asks for this artifact is refused. | |
| 2647 | std.Io.Dir.cwd().access(io, b.pathJoin(&.{ lib_dir, "crtbegin_so.o" }), .{}) catch { | |
| 2648 | compile.step.dependOn(&b.addFail(b.fmt( | |
| 2649 | "no crtbegin_so.o in {s}: this NDK does not carry API {s} for {s}. " ++ | |
| 2650 | "Install one that does, or lower android_api_level.", | |
| 2651 | .{ lib_dir, api, triple }, | |
| 2652 | )).step); | |
| 2653 | return; | |
| 2654 | }; | |
| 2655 | ||
| 2656 | const libc_file = b.addWriteFile("android-libc.conf", b.fmt( | |
| 2657 | \\include_dir={s} | |
| 2658 | \\sys_include_dir={s} | |
| 2659 | \\crt_dir={s} | |
| 2660 | \\msvc_lib_dir= | |
| 2661 | \\kernel32_lib_dir= | |
| 2662 | \\gcc_dir={s} | |
| 2663 | \\ | |
| 2664 | , .{ | |
| 2665 | b.pathJoin(&.{ sysroot, "usr/include" }), | |
| 2666 | // The triple-specific headers: bionic keeps its architecture-dependent | |
| 2667 | // definitions there and the generic ones include them by relative path. | |
| 2668 | b.pathJoin(&.{ sysroot, "usr/include", triple }), | |
| 2669 | lib_dir, | |
| 2670 | lib_dir, | |
| 2671 | })); | |
| 2672 | ||
| 2673 | compile.setLibCFile(libc_file.getDirectory().path(b, "android-libc.conf")); | |
| 2674 | compile.step.dependOn(&libc_file.step); | |
| 2675 | compile.root_module.link_libc = true; | |
| 2676 | compile.root_module.addLibraryPath(.{ .cwd_relative = lib_dir }); | |
| 2677 | } | |
| 2678 | ||
| 2527 | 2679 | /// The iOS SDK sysroot, resolved through xcrun at configure time. |
| 2528 | 2680 | /// |
| 2529 | 2681 | /// Needed because the iOS archives compile the vendored sqlite3.c, and C |
| @@ -36,6 +36,7 @@ | ||
| 36 | 36 | const std = ; |
| 37 | 37 | const builtin = ; |
| 38 | 38 | const jni = ; |
| 39 | const android_log = ; | |
| 39 | 40 | const permissions = ; |
| 40 | 41 | const device = ; |
| 41 | 42 | const system = ; |
| @@ -76,12 +77,24 @@ const Jni = jni.Jni; | ||
| 76 | 77 | |
| 77 | 78 | /// The allocator every reply is built with. |
| 78 | 79 | /// |
| 79 | /// `page_allocator`, and the choice is a build constraint rather than a | |
| 80 | /// preference. `c_allocator` would pull in bionic, and Zig cannot provide | |
| 81 | /// bionic — a static Android library links nothing and builds anyway, but this | |
| 82 | /// one is shared and has to resolve its symbols, so libc here means the whole | |
| 83 | /// library needs the NDK to build at all. Standing free of libc keeps it | |
| 84 | /// buildable with nothing but Zig. | |
| 80 | /// This used to carry a note explaining that `page_allocator` was chosen over | |
| 81 | /// `c_allocator` to keep the library buildable with nothing but Zig, since | |
| 82 | /// bionic would mean needing the NDK. That reasoning was sound and the | |
| 83 | /// conclusion was wrong: standing free of libc does not make the references go | |
| 84 | /// away, it only defers them. `page_allocator` needs `getauxval` for the page | |
| 85 | /// size, any `threadlocal` needs `__tls_get_addr`, and so does `std.fmt` — so | |
| 86 | /// the library linked clean, shipped, and then failed at `dlopen` with | |
| 87 | /// | |
| 88 | /// cannot locate symbol "__tls_get_addr" referenced by ".../libcraft.so" | |
| 89 | /// | |
| 90 | /// which `CraftNative` caught as an `UnsatisfiedLinkError` and turned into a | |
| 91 | /// silent fallback to the Kotlin shim. Every generated Android app was in that | |
| 92 | /// state; the mobile E2E suite is what finally said so out loud. | |
| 93 | /// | |
| 94 | /// So the library links bionic now and `build-android` requires the NDK | |
| 95 | /// (`build.zig`'s -Dandroid-ndk). The allocator is free to be whichever one | |
| 96 | /// suits; it stays `page_allocator` because the arena below wants pages | |
| 97 | /// anyway. | |
| 85 | 98 | /// |
| 86 | 99 | /// Every native call wraps this in an arena, so the page granularity costs one |
| 87 | 100 | /// page per call rather than one per allocation, and nothing has to be freed |
| @@ -97,6 +110,17 @@ const backing = std.heap.page_allocator; | ||
| 97 | 110 | const request_calendar: i32 = 1005; |
| 98 | 111 | |
| 99 | 112 | /// The shim's default event length: `System.currentTimeMillis() + 3600000`. |
| 113 | /// The log handler, installed here because this file is the root module of the | |
| 114 | /// JNI library and `std_options` only takes effect from a root. | |
| 115 | /// | |
| 116 | /// Without it every `std.log` line below goes to stderr, which ART discards — | |
| 117 | /// including `JNI_OnLoad`'s report of a refused registration, which is the one | |
| 118 | /// message worth having. `android_log.zig` says more about why it routes | |
| 119 | /// through `android.util.Log` rather than liblog. | |
| 120 | pub const std_options: std.Options = .{ | |
| 121 | .logFn = android_log.logFn, | |
| 122 | }; | |
| 123 | ||
| 100 | 124 | const one_hour_ms: i64 = 60 * 60 * 1000; |
| 101 | 125 | |
| 102 | 126 | /// `CraftBridge.REQUEST_CONTACTS`. |
| @@ -137,13 +161,27 @@ pub const holder_class = "com/craft/runtime/CraftNative"; | ||
| 137 | 161 | /// linked. |
| 138 | 162 | pub export fn JNI_OnLoad(vm_handle: jni.JavaVM, _: ?*anyopaque) callconv(.c) jni.jint { |
| 139 | 163 | java_vm = vm_handle; |
| 164 | // The log channel first, so the registration report below has somewhere to | |
| 165 | // go. It is the only diagnosis available for a library that loaded and | |
| 166 | // failed to bind. | |
| 167 | android_log.setVm(vm_handle); | |
| 140 | 168 | // The reply channel needs the same handle, and needs it before any action |
| 141 | 169 | // runs: a callback that fires between load and the first native call has |
| 142 | 170 | // nowhere to deliver otherwise. |
| 143 | 171 | events.setVm(vm_handle); |
| 144 | 172 | |
| 145 | 173 | switch (bindNatives(vm_handle)) { |
| 146 | .registered => {}, | |
| 174 | // Said out loud on the happy path, not only on the failures below. | |
| 175 | // "The library is loaded and bound" and "the library was never | |
| 176 | // shipped" are otherwise both silent, and they are the two states | |
| 177 | // anyone debugging this needs to tell apart first. It is also the only | |
| 178 | // line that proves the log channel itself works: a diagnostic that | |
| 179 | // only speaks when something is wrong cannot be trusted when it says | |
| 180 | // nothing. | |
| 181 | .registered => std.log.info( | |
| 182 | "craft: registered {d} natives on {s}", | |
| 183 | .{ natives.len, holder_class }, | |
| 184 | ), | |
| 147 | 185 | .no_env => std.log.err( |
| 148 | 186 | "craft: no JNIEnv at load; every action stays on the Kotlin shim", |
| 149 | 187 | .{}, |
| @@ -26,9 +26,13 @@ | ||
| 26 | 26 | //! which is the normal case for a callback arriving on a framework thread, |
| 27 | 27 | //! exactly like a `CLLocationManager` delegate on iOS. |
| 28 | 28 | //! |
| 29 | //! A thread this file attaches, this file detaches. Leaving one attached keeps | |
| 30 | //! the JVM's thread record alive after the OS thread is gone, and the crash | |
| 31 | //! surfaces somewhere unrelated much later. | |
| 29 | //! A thread this file attaches, this file detaches: `jni.attachCurrentThread` | |
| 30 | //! reports whether the attachment was ours, and `Attachment.release` gives | |
| 31 | //! back only those. Leaving one attached keeps the JVM's thread record alive | |
| 32 | //! after the OS thread is gone, and the crash surfaces somewhere unrelated | |
| 33 | //! much later. That dance lives in `jni_runtime.zig` rather than here because | |
| 34 | //! the log channel needs it too, and a second copy of it is how the two would | |
| 35 | //! drift apart. | |
| 32 | 36 | |
| 33 | 37 | const std = ; |
| 34 | 38 | const jni = ; |
| @@ -66,34 +70,6 @@ pub fn resetDroppedForTest() void { | ||
| 66 | 70 | dropped = 0; |
| 67 | 71 | } |
| 68 | 72 | |
| 69 | /// This thread's env, attaching if it has none. | |
| 70 | /// | |
| 71 | /// Returns whether the caller must detach. A thread the JVM created — the UI | |
| 72 | /// thread, a binder thread — is already attached and must **not** be detached | |
| 73 | /// by us; a thread Zig or a native library created must be. | |
| 74 | const Attachment = struct { | |
| 75 | env: jni.JNIEnv, | |
| 76 | owned: bool, | |
| 77 | }; | |
| 78 | ||
| 79 | fn attach(vm: jni.JavaVM) ?Attachment { | |
| 80 | if (jni.envForThisThread(vm)) |env| return .{ .env = env, .owned = false }; | |
| 81 | ||
| 82 | const attach_fn: *const fn (jni.JavaVM, *?*anyopaque, ?*anyopaque) callconv(.c) jni.jint = | |
| 83 | @ptrCast(vm.*.AttachCurrentThread orelse return null); | |
| 84 | ||
| 85 | var env_ptr: ?*anyopaque = null; | |
| 86 | if (attach_fn(vm, &env_ptr, null) != jni.JNI_OK) return null; | |
| 87 | const env: jni.JNIEnv = @ptrCast(@alignCast(env_ptr orelse return null)); | |
| 88 | return .{ .env = env, .owned = true }; | |
| 89 | } | |
| 90 | ||
| 91 | fn detach(vm: jni.JavaVM) void { | |
| 92 | const detach_fn: *const fn (jni.JavaVM) callconv(.c) jni.jint = | |
| 93 | @ptrCast(vm.*.DetachCurrentThread orelse return); | |
| 94 | _ = detach_fn(vm); | |
| 95 | } | |
| 96 | ||
| 97 | 73 | /// Hand `script` to `CraftNative.deliver`, from any thread. |
| 98 | 74 | /// |
| 99 | 75 | /// Silent on every failure, and deliberately: the caller is a device callback |
| @@ -105,11 +81,11 @@ pub fn evaluate(allocator: std.mem.Allocator, script: []const u8) void { | ||
| 105 | 81 | return; |
| 106 | 82 | }; |
| 107 | 83 | |
| 108 | const attachment = attach(vm) orelse { | |
| 84 | const attachment = jni.attachCurrentThread(vm) orelse { | |
| 109 | 85 | dropped += 1; |
| 110 | 86 | return; |
| 111 | 87 | }; |
| 112 | defer if (attachment.owned) detach(vm); | |
| 88 | defer attachment.release(vm); | |
| 113 | 89 | |
| 114 | 90 | deliverThrough(allocator, attachment.env, script) catch { |
| 115 | 91 | dropped += 1; |
| @@ -0,0 +1,181 @@ | ||
| 1 | //! Zig's `std.log` output, routed to logcat through `android.util.Log`. | |
| 2 | //! | |
| 3 | //! Before this, every `std.log` line in the Android build went to stderr, and | |
| 4 | //! ART discards stderr. `JNI_OnLoad` reports a refused `RegisterNatives` that | |
| 5 | //! way, so three quite different states were indistinguishable from outside: | |
| 6 | //! the library was never shipped, the library loaded but a descriptor was | |
| 7 | //! wrong, or the library loaded and bound and the action simply declined. All | |
| 8 | //! three end with the Kotlin shim answering and nothing anywhere saying which | |
| 9 | //! happened. | |
| 10 | //! | |
| 11 | //! Through Java rather than `__android_log_write`, because this library | |
| 12 | //! deliberately links no libc — `android_dispatch.zig` records why, and the | |
| 13 | //! same reasoning rules out `dlopen`ing liblog, since libdl is not linked | |
| 14 | //! either. JNI calls are the one thing it can already do, and `android.util.Log` | |
| 15 | //! is reachable that way with no new link dependency at all. | |
| 16 | //! | |
| 17 | //! iOS has never needed this: `ios_dispatch.zig`'s lines reach the device | |
| 18 | //! console as they are, which is what the mobile E2E suite reads to prove the | |
| 19 | //! Zig runtime served a call. This is the Android counterpart. | |
| 20 | ||
| 21 | const std = ; | |
| 22 | const jni = ; | |
| 23 | ||
| 24 | /// The logcat tag. Distinct from the Kotlin shim's `CraftBridge`, so a reader | |
| 25 | /// can tell which side of the hand-off produced a line. | |
| 26 | pub const tag = "CraftNative"; | |
| 27 | ||
| 28 | /// Longest line that survives intact. Longer ones are replaced by their own | |
| 29 | /// format string, which is comptime-known and still names the log site. | |
| 30 | const max_line = 1024; | |
| 31 | ||
| 32 | var java_vm: ?jni.JavaVM = null; | |
| 33 | var dropped: std.atomic.Value(usize) = .init(0); | |
| 34 | ||
| 35 | /// Guards against a log inside a log. Everything below can fail, and the | |
| 36 | /// failure paths must stay silent — one bad JNI call would otherwise become an | |
| 37 | /// unbounded stack of them. Thread-local because two threads logging at once | |
| 38 | /// is normal and neither should mute the other. | |
| 39 | threadlocal var writing: bool = false; | |
| 40 | ||
| 41 | /// Give the log channel the VM, from `JNI_OnLoad`. | |
| 42 | /// | |
| 43 | /// Until this is called there is nowhere to write, which covers the window | |
| 44 | /// before the library is loaded. Lines in that window are counted, not queued: | |
| 45 | /// a queue would have to be bounded, and a bound is another thing to get wrong | |
| 46 | /// for output nobody is reading yet. | |
| 47 | pub fn setVm(vm: jni.JavaVM) void { | |
| 48 | java_vm = vm; | |
| 49 | } | |
| 50 | ||
| 51 | /// Lines that could not be written. A diagnosis reads this rather than | |
| 52 | /// wondering whether the code was silent or the channel was. | |
| 53 | pub fn droppedCount() usize { | |
| 54 | return dropped.load(.monotonic); | |
| 55 | } | |
| 56 | ||
| 57 | pub fn resetForTest() void { | |
| 58 | java_vm = null; | |
| 59 | dropped.store(0, .monotonic); | |
| 60 | } | |
| 61 | ||
| 62 | /// `std.Options.logFn`, installed by `android_dispatch.zig`. | |
| 63 | pub fn logFn( | |
| 64 | comptime message_level: std.log.Level, | |
| 65 | comptime scope: @EnumLiteral(), | |
| 66 | comptime format: []const u8, | |
| 67 | args: anytype, | |
| 68 | ) void { | |
| 69 | if (writing) return; | |
| 70 | writing = true; | |
| 71 | defer writing = false; | |
| 72 | ||
| 73 | // The scope is prefixed rather than dropped, the same choice | |
| 74 | // `src/minimal.zig` makes: `std.log.scoped(...)` is used throughout craft | |
| 75 | // and the scope is most of what makes a line findable. | |
| 76 | const prefix = if (scope == .default) "" else "[" ++ @tagName(scope) ++ "] "; | |
| 77 | ||
| 78 | var buffer: [max_line]u8 = undefined; | |
| 79 | // On overflow, the format string itself — comptime-known, no arguments to | |
| 80 | // render, and it still says which log site fired. A truncated line would | |
| 81 | // be better still, but `bufPrint` does not report how far it got. | |
| 82 | const text = std.fmt.bufPrint(&buffer, prefix ++ format, args) catch prefix ++ format; | |
| 83 | ||
| 84 | write(message_level, text); | |
| 85 | } | |
| 86 | ||
| 87 | fn write(level: std.log.Level, text: []const u8) void { | |
| 88 | const vm = java_vm orelse { | |
| 89 | _ = dropped.fetchAdd(1, .monotonic); | |
| 90 | return; | |
| 91 | }; | |
| 92 | ||
| 93 | const attachment = jni.attachCurrentThread(vm) orelse { | |
| 94 | _ = dropped.fetchAdd(1, .monotonic); | |
| 95 | return; | |
| 96 | }; | |
| 97 | defer attachment.release(vm); | |
| 98 | ||
| 99 | writeThrough(attachment.env, level, text) catch { | |
| 100 | _ = dropped.fetchAdd(1, .monotonic); | |
| 101 | }; | |
| 102 | } | |
| 103 | ||
| 104 | fn writeThrough(env: jni.JNIEnv, level: std.log.Level, text: []const u8) !void { | |
| 105 | const j = jni.Jni.init(env); | |
| 106 | ||
| 107 | // A frame around the class and the two strings. Logging is the one thing | |
| 108 | // that can happen on any thread at any rate, so leaking a local ref per | |
| 109 | // line would overflow the JVM's sixteen slots and abort the process — | |
| 110 | // turning a diagnostic into the fault it was meant to diagnose. | |
| 111 | try j.pushLocalFrame(8); | |
| 112 | defer _ = j.popLocalFrame(null); | |
| 113 | ||
| 114 | // Stack, not the heap: a logger that allocates is a logger that cannot | |
| 115 | // report an allocation failure. Sized for the tag plus a `max_line` | |
| 116 | // message re-encoded to modified UTF-8. | |
| 117 | var storage: [4 * max_line]u8 = undefined; | |
| 118 | var fba = std.heap.FixedBufferAllocator.init(&storage); | |
| 119 | const allocator = fba.allocator(); | |
| 120 | ||
| 121 | const cls = try j.findClass("android/util/Log"); | |
| 122 | const tag_string = try j.newStringUtf8(allocator, tag); | |
| 123 | const message = try j.newStringUtf8(allocator, text); | |
| 124 | ||
| 125 | const method = try j.staticMethodId(cls, methodFor(level), "(Ljava/lang/String;Ljava/lang/String;)I"); | |
| 126 | _ = try j.callStaticIntMethodA(cls, method, &.{ .{ .l = tag_string }, .{ .l = message } }); | |
| 127 | } | |
| 128 | ||
| 129 | /// `android.util.Log`'s one-letter method for each Zig level. | |
| 130 | /// | |
| 131 | /// Named methods rather than `Log.println(priority, …)` so the priority | |
| 132 | /// constants do not have to be mirrored here: getting `WARN` wrong by one | |
| 133 | /// would misfile every warning under a level that reads as routine. | |
| 134 | pub fn methodFor(level: std.log.Level) [*:0]const u8 { | |
| 135 | return switch (level) { | |
| 136 | .err => "e", | |
| 137 | .warn => "w", | |
| 138 | .info => "i", | |
| 139 | .debug => "d", | |
| 140 | }; | |
| 141 | } | |
| 142 | ||
| 143 | const testing = std.testing; | |
| 144 | ||
| 145 | test "every level maps to a real android.util.Log method" { | |
| 146 | // The four one-letter statics on android.util.Log, and nothing else. A | |
| 147 | // typo here fails at `GetStaticMethodID` on a device and nowhere before. | |
| 148 | for ([_]std.log.Level{ .err, .warn, .info, .debug }) |level| { | |
| 149 | const name = std.mem.span(methodFor(level)); | |
| 150 | try testing.expectEqual(@as(usize, 1), name.len); | |
| 151 | try testing.expect(std.mem.indexOfScalar(u8, "ewid", name[0]) != null); | |
| 152 | } | |
| 153 | } | |
| 154 | ||
| 155 | test "levels do not share a method" { | |
| 156 | const levels = [_]std.log.Level{ .err, .warn, .info, .debug }; | |
| 157 | for (levels, 0..) |left, i| { | |
| 158 | for (levels[i + 1 ..]) |right| { | |
| 159 | try testing.expect(!std.mem.eql( | |
| 160 | u8, | |
| 161 | std.mem.span(methodFor(left)), | |
| 162 | std.mem.span(methodFor(right)), | |
| 163 | )); | |
| 164 | } | |
| 165 | } | |
| 166 | } | |
| 167 | ||
| 168 | test "a line with no VM is counted rather than written" { | |
| 169 | resetForTest(); | |
| 170 | defer resetForTest(); | |
| 171 | ||
| 172 | try testing.expectEqual(@as(usize, 0), droppedCount()); | |
| 173 | write(.warn, "nowhere to put this"); | |
| 174 | try testing.expectEqual(@as(usize, 1), droppedCount()); | |
| 175 | } | |
| 176 | ||
| 177 | test "the tag is not the shim's" { | |
| 178 | // Both reach logcat, and telling Zig's output from Kotlin's is the whole | |
| 179 | // point of the Android half of the E2E suite's attribution. | |
| 180 | try testing.expect(!std.mem.eql(u8, tag, "CraftBridge")); | |
| 181 | } | |
| @@ -146,15 +146,53 @@ fn appendString( | ||
| 146 | 146 | // The JNI half |
| 147 | 147 | // ============================================================================= |
| 148 | 148 | |
| 149 | /// `Build.FINGERPRINT.contains("generic") || contains("emulator")`. | |
| 149 | /// The `Build` fields the emulator test reads. | |
| 150 | pub const BuildIdentity = struct { | |
| 151 | hardware: []const u8, | |
| 152 | fingerprint: []const u8, | |
| 153 | product: []const u8, | |
| 154 | model: []const u8, | |
| 155 | manufacturer: []const u8, | |
| 156 | brand: []const u8, | |
| 157 | }; | |
| 158 | ||
| 159 | /// Whether this is an emulator, asking exactly what `CraftBridge.isEmulator()` | |
| 160 | /// asks. | |
| 161 | /// | |
| 162 | /// Kept as a function so it can be asserted without a device, and kept in step | |
| 163 | /// with the Kotlin deliberately: the whole point of the hand-off is that a page | |
| 164 | /// cannot tell which side answered, and two emulator tests that disagree would | |
| 165 | /// make `isEmulator` depend on whether libcraft.so happened to load. | |
| 150 | 166 | /// |
| 151 | /// Kotlin's own test, kept as a function so it can be asserted without a | |
| 152 | /// device. Deliberately not widened: the Kotlin does not check `"sdk"`, | |
| 153 | /// `"vbox"` or the other markers a more thorough emulator test would, and this | |
| 154 | /// answers the same question the shim answers. | |
| 155 | pub fn fingerprintLooksEmulated(fingerprint: []const u8) bool { | |
| 156 | return std.mem.indexOf(u8, fingerprint, "generic") != null or | |
| 157 | std.mem.indexOf(u8, fingerprint, "emulator") != null; | |
| 167 | /// Both sides used to be `FINGERPRINT.contains("generic") || | |
| 168 | /// contains("emulator")`, which answers false on the AVDs anyone actually runs | |
| 169 | /// - a current Google APIs image fingerprints as | |
| 170 | /// `google/sdk_gphone64_x86_64/emu64xa:14/...` and contains neither word. The | |
| 171 | /// mobile E2E suite caught it on an API 34 emulator. | |
| 172 | /// | |
| 173 | /// Note the last line is case-sensitive on both sides. That is the Kotlin's | |
| 174 | /// behaviour, not an oversight here. | |
| 175 | pub fn looksEmulated(build: BuildIdentity) bool { | |
| 176 | const ascii = std.ascii; | |
| 177 | ||
| 178 | if (ascii.startsWithIgnoreCase(build.hardware, "goldfish") or | |
| 179 | ascii.startsWithIgnoreCase(build.hardware, "ranchu")) return true; | |
| 180 | ||
| 181 | if (ascii.startsWithIgnoreCase(build.fingerprint, "generic") or | |
| 182 | ascii.startsWithIgnoreCase(build.fingerprint, "unknown")) return true; | |
| 183 | if (ascii.findIgnoreCase(build.fingerprint, "emulator") != null or | |
| 184 | ascii.findIgnoreCase(build.fingerprint, "sdk_gphone") != null) return true; | |
| 185 | ||
| 186 | if (ascii.startsWithIgnoreCase(build.product, "sdk") or | |
| 187 | ascii.findIgnoreCase(build.product, "_sdk") != null or | |
| 188 | ascii.findIgnoreCase(build.product, "sdk_") != null) return true; | |
| 189 | ||
| 190 | if (ascii.findIgnoreCase(build.model, "google_sdk") != null or | |
| 191 | ascii.findIgnoreCase(build.model, "emulator") != null or | |
| 192 | ascii.findIgnoreCase(build.model, "android sdk built for") != null) return true; | |
| 193 | ||
| 194 | return std.mem.indexOf(u8, build.manufacturer, "Genymotion") != null or | |
| 195 | std.mem.startsWith(u8, build.brand, "generic"); | |
| 158 | 196 | } |
| 159 | 197 | |
| 160 | 198 | /// Read a `static final String` off a class, as UTF-8. |
| @@ -200,6 +238,10 @@ pub fn read(allocator: std.mem.Allocator, j: Jni, activity: jobject) !DeviceInfo | ||
| 200 | 238 | |
| 201 | 239 | const fingerprint = try staticString(allocator, j, build, "FINGERPRINT"); |
| 202 | 240 | defer allocator.free(fingerprint); |
| 241 | const hardware = try staticString(allocator, j, build, "HARDWARE"); | |
| 242 | defer allocator.free(hardware); | |
| 243 | const product = try staticString(allocator, j, build, "PRODUCT"); | |
| 244 | defer allocator.free(product); | |
| 203 | 245 | |
| 204 | 246 | // `Build$VERSION` — a nested class, and the `$` is not decoration: the JVM |
| 205 | 247 | // knows this type by that binary name and `android/os/Build/VERSION` finds |
| @@ -227,7 +269,14 @@ pub fn read(allocator: std.mem.Allocator, j: Jni, activity: jobject) !DeviceInfo | ||
| 227 | 269 | .screen_height = metrics.height, |
| 228 | 270 | .density = metrics.density, |
| 229 | 271 | .app_build = package.build, |
| 230 | .is_emulator = fingerprintLooksEmulated(fingerprint), | |
| 272 | .is_emulator = looksEmulated(.{ | |
| 273 | .hardware = hardware, | |
| 274 | .fingerprint = fingerprint, | |
| 275 | .product = product, | |
| 276 | .model = model, | |
| 277 | .manufacturer = manufacturer, | |
| 278 | .brand = brand, | |
| 279 | }), | |
| 231 | 280 | }; |
| 232 | 281 | } |
| 233 | 282 | |
| @@ -450,15 +499,73 @@ test "density renders the way JSONObject renders a float" { | ||
| 450 | 499 | } |
| 451 | 500 | |
| 452 | 501 | test "the emulator test asks exactly what the Kotlin asks" { |
| 453 | // Real fingerprints, and the two markers the shim looks for. | |
| 454 | try testing.expect(fingerprintLooksEmulated("generic/sdk_gphone64_arm64/emu64a:14/UE1A.230829.036/11228894:user/release-keys")); | |
| 455 | try testing.expect(fingerprintLooksEmulated("Android/aosp_cf_x86_64_phone/emulator:13/x/y:userdebug/test-keys")); | |
| 456 | try testing.expect(!fingerprintLooksEmulated("google/husky/husky:14/AP1A.240405.002/11480754:user/release-keys")); | |
| 457 | try testing.expect(!fingerprintLooksEmulated("samsung/dm3qxxx/dm3q:14/UP1A.231005.007/S918BXXU4BWL5:user/release-keys")); | |
| 458 | ||
| 459 | // Not widened past the shim: these are emulator markers this deliberately | |
| 460 | // does not know about, because the Kotlin does not either. | |
| 461 | try testing.expect(!fingerprintLooksEmulated("unknown/vbox86p/vbox86p:9/x/y:userdebug/test-keys")); | |
| 502 | const phone: BuildIdentity = .{ | |
| 503 | .hardware = "husky", | |
| 504 | .fingerprint = "google/husky/husky:14/AP1A.240405.002/11480754:user/release-keys", | |
| 505 | .product = "husky", | |
| 506 | .model = "Pixel 8 Pro", | |
| 507 | .manufacturer = "Google", | |
| 508 | .brand = "google", | |
| 509 | }; | |
| 510 | try testing.expect(!looksEmulated(phone)); | |
| 511 | ||
| 512 | var samsung = phone; | |
| 513 | samsung.hardware = "qcom"; | |
| 514 | samsung.fingerprint = "samsung/dm3qxxx/dm3q:14/UP1A.231005.007/S918BXXU4BWL5:user/release-keys"; | |
| 515 | samsung.product = "dm3qxxx"; | |
| 516 | samsung.model = "SM-S918B"; | |
| 517 | samsung.manufacturer = "samsung"; | |
| 518 | samsung.brand = "samsung"; | |
| 519 | try testing.expect(!looksEmulated(samsung)); | |
| 520 | ||
| 521 | // The image the E2E suite runs on, and the one the old fingerprint test | |
| 522 | // answered `false` for: neither "generic" nor "emulator" appears anywhere | |
| 523 | // in it. HARDWARE is what gives it away. | |
| 524 | var avd = phone; | |
| 525 | avd.hardware = "ranchu"; | |
| 526 | avd.fingerprint = "google/sdk_gphone64_x86_64/emu64xa:14/UE1A.230829.036/11228894:user/release-keys"; | |
| 527 | avd.product = "sdk_gphone64_x86_64"; | |
| 528 | avd.model = "sdk_gphone64_x86_64"; | |
| 529 | try testing.expect(looksEmulated(avd)); | |
| 530 | ||
| 531 | // Each marker on its own, so a mutation to any one line fails. | |
| 532 | var hardware_only = phone; | |
| 533 | hardware_only.hardware = "goldfish"; | |
| 534 | try testing.expect(looksEmulated(hardware_only)); | |
| 535 | ||
| 536 | var fingerprint_only = phone; | |
| 537 | fingerprint_only.fingerprint = "generic/sdk/generic:9/x/y:eng/test-keys"; | |
| 538 | try testing.expect(looksEmulated(fingerprint_only)); | |
| 539 | ||
| 540 | var cuttlefish = phone; | |
| 541 | cuttlefish.fingerprint = "Android/aosp_cf_x86_64_phone/emulator:13/x/y:userdebug/test-keys"; | |
| 542 | try testing.expect(looksEmulated(cuttlefish)); | |
| 543 | ||
| 544 | var product_only = phone; | |
| 545 | product_only.product = "sdk_google_phone_x86"; | |
| 546 | try testing.expect(looksEmulated(product_only)); | |
| 547 | ||
| 548 | var model_only = phone; | |
| 549 | model_only.model = "Android SDK built for x86"; | |
| 550 | try testing.expect(looksEmulated(model_only)); | |
| 551 | ||
| 552 | var genymotion = phone; | |
| 553 | genymotion.manufacturer = "Genymotion"; | |
| 554 | try testing.expect(looksEmulated(genymotion)); | |
| 555 | ||
| 556 | var brand_only = phone; | |
| 557 | brand_only.brand = "generic_x86"; | |
| 558 | try testing.expect(looksEmulated(brand_only)); | |
| 559 | ||
| 560 | // Genymotion, which the old fingerprint test missed and the `unknown` | |
| 561 | // prefix now catches on its own - before MANUFACTURER is even consulted. | |
| 562 | var vbox = phone; | |
| 563 | vbox.fingerprint = "unknown/vbox86p/vbox86p:9/x/y:userdebug/test-keys"; | |
| 564 | try testing.expect(looksEmulated(vbox)); | |
| 565 | ||
| 566 | // Still a hint and not a boundary, on both sides: anything that can rewrite | |
| 567 | // these properties defeats it, and nothing here is proof of where the code | |
| 568 | // is running. | |
| 462 | 569 | } |
| 463 | 570 | |
| 464 | 571 | test "the action name matches the Kotlin method exactly" { |
| @@ -437,6 +437,42 @@ pub fn envForThisThread(vm: JavaVM) ?JNIEnv { | ||
| 437 | 437 | return @ptrCast(@alignCast(env orelse return null)); |
| 438 | 438 | } |
| 439 | 439 | |
| 440 | /// A `JNIEnv` for this thread, and whether this code owes the JVM a detach. | |
| 441 | /// | |
| 442 | /// A thread the JVM created — the UI thread, a binder thread — is already | |
| 443 | /// attached and must **not** be detached by us; a thread Zig or a native | |
| 444 | /// library created must be. Getting that backwards either leaks the attachment | |
| 445 | /// or detaches a thread still using it, and neither fails at the call site. | |
| 446 | pub const Attachment = struct { | |
| 447 | env: JNIEnv, | |
| 448 | owned: bool, | |
| 449 | ||
| 450 | /// Give the attachment back, if it was ours to give. | |
| 451 | pub fn release(self: Attachment, vm: JavaVM) void { | |
| 452 | if (!self.owned) return; | |
| 453 | const detach: *const fn (JavaVM) callconv(.c) jint = | |
| 454 | @ptrCast(vm.*.DetachCurrentThread orelse return); | |
| 455 | _ = detach(vm); | |
| 456 | } | |
| 457 | }; | |
| 458 | ||
| 459 | /// This thread's `JNIEnv`, attaching the thread if it has none. | |
| 460 | /// | |
| 461 | /// Lives here rather than beside either caller because there are two of them — | |
| 462 | /// the reply channel and the log channel — and a second copy of a JNI dance | |
| 463 | /// this subtle is how the two drift apart. | |
| 464 | pub fn attachCurrentThread(vm: JavaVM) ?Attachment { | |
| 465 | if (envForThisThread(vm)) |env| return .{ .env = env, .owned = false }; | |
| 466 | ||
| 467 | const attach: *const fn (JavaVM, *?*anyopaque, ?*anyopaque) callconv(.c) jint = | |
| 468 | @ptrCast(vm.*.AttachCurrentThread orelse return null); | |
| 469 | ||
| 470 | var env_ptr: ?*anyopaque = null; | |
| 471 | if (attach(vm, &env_ptr, null) != JNI_OK) return null; | |
| 472 | const env: JNIEnv = @ptrCast(@alignCast(env_ptr orelse return null)); | |
| 473 | return .{ .env = env, .owned = true }; | |
| 474 | } | |
| 475 | ||
| 440 | 476 | /// Bind native implementations to a Java class's `external`/`native` methods. |
| 441 | 477 | /// |
| 442 | 478 | /// The alternative is exporting a symbol named |
| @@ -0,0 +1,128 @@ | ||
| 1 | #!/usr/bin/env bun | |
| 2 | ||
| 3 | /** | |
| 4 | * Mobile end-to-end: generate an app, run it on a simulator or emulator, and | |
| 5 | * assert that a call made from JavaScript reached native code and came back. | |
| 6 | * | |
| 7 | * What this replaces ran nothing. Every step in the old mobile-e2e workflow | |
| 8 | * that would have tested something was guarded by | |
| 9 | * `if: hashFiles('packages/ios/TestApp/TestApp.xcodeproj') != ''`, and that | |
| 10 | * project has never existed in the tree, so each one skipped and the job went | |
| 11 | * green. The Android half looked for `packages/android/gradlew`, which the | |
| 12 | * generator does not write either, and echoed "skipping project-level tests". | |
| 13 | * Months of green with no coverage behind it. | |
| 14 | * | |
| 15 | * So the rules here are the opposite ones. Nothing skips: a missing toolchain, | |
| 16 | * an absent device, an app that never printed anything are all failures with a | |
| 17 | * named cause. The page announces which cases it is going to run before it | |
| 18 | * runs them, so a suite that dies halfway cannot be read as a shorter suite | |
| 19 | * that passed. And every leg writes its console log, its build log and a | |
| 20 | * screenshot under artifacts/mobile-e2e/, uploaded with | |
| 21 | * `if-no-files-found: error` so a run that produced no evidence is red rather | |
| 22 | * than quietly empty. | |
| 23 | * | |
| 24 | * bun scripts/mobile-e2e.ts --platform ios --ios-runtime packages/zig/zig-out/lib | |
| 25 | * bun scripts/mobile-e2e.ts --platform android | |
| 26 | */ | |
| 27 | ||
| 28 | import type { LegOutcome, RunnerOptions } from './mobile-e2e/types' | |
| 29 | import { mkdirSync, rmSync, writeFileSync } from 'node:fs' | |
| 30 | import { join, resolve } from 'node:path' | |
| 31 | import { runAndroid } from './mobile-e2e/android' | |
| 32 | import { runIos } from './mobile-e2e/ios' | |
| 33 | ||
| 34 | const root = resolve(import.meta.dir, '..') | |
| 35 | ||
| 36 | function flag(name: string): string | undefined { | |
| 37 | const prefixed = `--${name}` | |
| 38 | const index = process.argv.indexOf(prefixed) | |
| 39 | if (index !== -1 && index + 1 < process.argv.length) return process.argv[index + 1] | |
| 40 | ||
| 41 | const inline = process.argv.find(argument => argument.startsWith(`${prefixed}=`)) | |
| 42 | return inline?.slice(prefixed.length + 1) | |
| 43 | } | |
| 44 | ||
| 45 | const platform = flag('platform') | |
| 46 | if (platform !== 'ios' && platform !== 'android') | |
| 47 | throw new Error('pass --platform ios or --platform android') | |
| 48 | ||
| 49 | const evidenceLabel = process.env.CRAFT_EVIDENCE_LABEL || `${platform}-${process.arch}` | |
| 50 | const evidenceDir = join(root, 'artifacts', 'mobile-e2e', evidenceLabel) | |
| 51 | const workDir = join(evidenceDir, 'work') | |
| 52 | ||
| 53 | // A single run id, reused as the clipboard nonce suffix. Deliberately not a | |
| 54 | // timestamp alone: two legs run minutes apart and each needs its own value, so | |
| 55 | // the leg name is mixed in downstream. | |
| 56 | const runId = process.env.GITHUB_RUN_ID || String(process.pid) | |
| 57 | ||
| 58 | const options: RunnerOptions = { | |
| 59 | root, | |
| 60 | evidenceDir, | |
| 61 | workDir, | |
| 62 | timeoutMs: Number(flag('timeout') ?? 180_000), | |
| 63 | runId, | |
| 64 | iosRuntimeDir: flag('ios-runtime') | |
| 65 | ? resolve(root, flag('ios-runtime')!) | |
| 66 | : (process.env.CRAFT_IOS_RUNTIME ? resolve(root, process.env.CRAFT_IOS_RUNTIME) : null), | |
| 67 | androidRuntimeDir: flag('android-runtime') | |
| 68 | ? resolve(root, flag('android-runtime')!) | |
| 69 | : (process.env.CRAFT_ANDROID_RUNTIME ? resolve(root, process.env.CRAFT_ANDROID_RUNTIME) : null), | |
| 70 | } | |
| 71 | ||
| 72 | async function main(): Promise<void> { | |
| 73 | rmSync(evidenceDir, { force: true, recursive: true }) | |
| 74 | mkdirSync(workDir, { recursive: true }) | |
| 75 | ||
| 76 | let outcomes: LegOutcome[] = [] | |
| 77 | let error: string | undefined | |
| 78 | ||
| 79 | try { | |
| 80 | outcomes = platform === 'ios' ? await runIos(options) : await runAndroid(options) | |
| 81 | } | |
| 82 | catch (caught) { | |
| 83 | error = caught instanceof Error ? caught.stack || caught.message : String(caught) | |
| 84 | } | |
| 85 | ||
| 86 | // A run with no legs is the shape the old workflow had, and it must not read | |
| 87 | // as success. Named here rather than left to the caller, because "zero | |
| 88 | // failures" is true of an empty list. | |
| 89 | if (!error && outcomes.length === 0) | |
| 90 | error = 'no legs ran; the harness produced no result at all' | |
| 91 | ||
| 92 | const failedLegs = outcomes.filter(leg => leg.status === 'failed') | |
| 93 | if (!error && failedLegs.length) | |
| 94 | error = failedLegs.map(leg => `${leg.name}: ${leg.failures.join('; ')}`).join('\n') | |
| 95 | ||
| 96 | const report = { | |
| 97 | schemaVersion: 1, | |
| 98 | generatedAt: new Date().toISOString(), | |
| 99 | revision: process.env.GITHUB_SHA || 'local', | |
| 100 | platform, | |
| 101 | label: evidenceLabel, | |
| 102 | runner: { os: process.platform, arch: process.arch, bun: Bun.version }, | |
| 103 | status: error ? 'failed' : 'passed', | |
| 104 | legs: outcomes, | |
| 105 | error, | |
| 106 | } | |
| 107 | ||
| 108 | mkdirSync(evidenceDir, { recursive: true }) | |
| 109 | writeFileSync(join(evidenceDir, 'report.json'), `${JSON.stringify(report, null, 2)}\n`) | |
| 110 | ||
| 111 | for (const leg of outcomes) { | |
| 112 | const cases = `${leg.passed.length}/${leg.planned.length} cases` | |
| 113 | const zig = leg.zigActions.length ? `zig served ${leg.zigActions.join(', ')}` : 'no zig' | |
| 114 | console.log(`${leg.status === 'passed' ? 'ok ' : 'FAIL'} ${leg.name} — ${cases}, ${zig}`) | |
| 115 | for (const failure of leg.failures) console.log(` ${failure}`) | |
| 116 | } | |
| 117 | ||
| 118 | if (error) { | |
| 119 | // The annotation is what makes the reason visible on the run summary | |
| 120 | // rather than only inside a step log the reader has to expand. | |
| 121 | console.log(`::error::mobile e2e (${evidenceLabel}) failed: ${error.split('\n')[0]}`) | |
| 122 | throw new Error(error) | |
| 123 | } | |
| 124 | ||
| 125 | console.log(`\nmobile e2e passed on ${platform}; evidence in artifacts/mobile-e2e/${evidenceLabel}`) | |
| 126 | } | |
| 127 | ||
| 128 | await main() | |
| @@ -0,0 +1,270 @@ | ||
| 1 | import type { LegOutcome, RunnerOptions } from './types' | |
| 2 | import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' | |
| 3 | import { join } from 'node:path' | |
| 4 | import { init } from '../../packages/android/src/index' | |
| 5 | import { evaluateRun, hasTerminated } from './protocol' | |
| 6 | import { command, driverPage, waitForFile } from './support' | |
| 7 | ||
| 8 | /** | |
| 9 | * Push off, which is what makes the rejection case deterministic: with it | |
| 10 | * disabled the generator writes a literal `_craftPushReject('Push | |
| 11 | * notifications are disabled')` into CraftBridge.kt rather than a Firebase | |
| 12 | * block, so the refusal is fixed at generation time and needs no network, no | |
| 13 | * Play Services and no account on the emulator. | |
| 14 | * | |
| 15 | * Clipboard is not a configurable capability on Android - CraftBridge serves | |
| 16 | * clipboardRead/clipboardWrite unconditionally - so the success case needs no | |
| 17 | * flag, and asking for one would imply a gate that is not there. | |
| 18 | */ | |
| 19 | const CONFIG = { | |
| 20 | enablePushNotifications: false, | |
| 21 | } | |
| 22 | ||
| 23 | const PACKAGE = 'dev.craft.e2e.probe' | |
| 24 | const APP_NAME = 'CraftE2EProbe' | |
| 25 | ||
| 26 | /** | |
| 27 | * Tags worth reading. CraftBridge is the Kotlin shim's, CraftNative is Zig's | |
| 28 | * own — that pair is how this leg tells which side answered — chromium carries | |
| 29 | * the page's console for a run whose bridge never arrived, and AndroidRuntime | |
| 30 | * carries the crash if it did not get that far. | |
| 31 | */ | |
| 32 | const LOGCAT_FILTER = ['CraftBridge:D', 'CraftNative:D', 'chromium:I', 'AndroidRuntime:E', '*:S'] | |
| 33 | ||
| 34 | /** | |
| 35 | * One leg is one answer to "who served the call". | |
| 36 | * | |
| 37 | * The same split the iOS suite runs, and it exists here for a sharper reason: | |
| 38 | * until this change nothing copied libcraft.so into a generated app at all, so | |
| 39 | * every Android app anyone has ever generated was the shim leg and the whole | |
| 40 | * Zig bridge had never run. The runtime leg is what stops that being true | |
| 41 | * again without anybody noticing. | |
| 42 | */ | |
| 43 | interface Leg { | |
| 44 | name: 'shim' | 'runtime' | |
| 45 | runtimeDir: string | null | |
| 46 | requireZig: boolean | |
| 47 | } | |
| 48 | ||
| 49 | function legs(runtimeDir: string | null): Leg[] { | |
| 50 | return [ | |
| 51 | { name: 'shim', runtimeDir: null, requireZig: false }, | |
| 52 | { name: 'runtime', runtimeDir, requireZig: true }, | |
| 53 | ] | |
| 54 | } | |
| 55 | ||
| 56 | /** What JNI_OnLoad logs once it has bound the natives. */ | |
| 57 | const REGISTERED = /craft: registered (\d+) natives on com\/craft\/runtime\/CraftNative/ | |
| 58 | ||
| 59 | async function adb(argv: string[], options: { serial: string, logPath?: string, allowFailure?: boolean }) { | |
| 60 | return command(['adb', '-s', options.serial, ...argv], { logPath: options.logPath, allowFailure: options.allowFailure }) | |
| 61 | } | |
| 62 | ||
| 63 | async function resolveSerial(): Promise<string> { | |
| 64 | const listed = await command(['adb', 'devices']) | |
| 65 | const serials = listed.stdout | |
| 66 | .split('\n') | |
| 67 | .slice(1) | |
| 68 | .map(line => line.trim().split(/\s+/)) | |
| 69 | .filter(parts => parts.length >= 2 && parts[1] === 'device') | |
| 70 | .map(parts => parts[0]!) | |
| 71 | ||
| 72 | if (!serials.length) | |
| 73 | throw new Error(`no Android device is attached; \`adb devices\` reported:\n${listed.stdout.trim()}`) | |
| 74 | ||
| 75 | return serials[0]! | |
| 76 | } | |
| 77 | ||
| 78 | async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> { | |
| 79 | const label = `android-${leg.name}` | |
| 80 | const evidence = join(options.evidenceDir, label) | |
| 81 | const project = join(options.workDir, label) | |
| 82 | const failures: string[] = [] | |
| 83 | ||
| 84 | mkdirSync(evidence, { recursive: true }) | |
| 85 | rmSync(project, { force: true, recursive: true }) | |
| 86 | ||
| 87 | await init({ | |
| 88 | name: APP_NAME, | |
| 89 | packageName: PACKAGE, | |
| 90 | output: project, | |
| 91 | config: CONFIG, | |
| 92 | runtimeDir: leg.runtimeDir, | |
| 93 | }) | |
| 94 | ||
| 95 | const nonce = `craft-e2e-${label}-${options.runId}` | |
| 96 | const assets = join(project, 'app', 'src', 'main', 'assets') | |
| 97 | writeFileSync(join(assets, 'index.html'), driverPage(nonce, 'android')) | |
| 98 | copyFileSync(join(assets, 'index.html'), join(evidence, 'index.html')) | |
| 99 | copyFileSync(join(assets, 'craft.config.json'), join(evidence, 'craft.config.json')) | |
| 100 | ||
| 101 | // The generator writes gradle-wrapper.properties but never gradlew, so there | |
| 102 | // is no wrapper to invoke. Call Gradle directly, the way the templates gate | |
| 103 | // already does, and honour the same override so both use one toolchain. | |
| 104 | const gradle = process.env.GRADLE_EXECUTABLE || 'gradle' | |
| 105 | await command([gradle, '--no-daemon', '--stacktrace', ':app:assembleDebug'], { | |
| 106 | cwd: project, | |
| 107 | logPath: join(evidence, 'gradle.log'), | |
| 108 | }) | |
| 109 | ||
| 110 | const apk = join(project, 'app', 'build', 'outputs', 'apk', 'debug', 'app-debug.apk') | |
| 111 | if (!existsSync(apk)) | |
| 112 | throw new Error(`gradle reported success but ${apk} is missing`) | |
| 113 | ||
| 114 | // Which side is about to answer, established from the APK rather than | |
| 115 | // assumed. `System.loadLibrary("craft")` finding nothing is a designed | |
| 116 | // no-op — CraftNative catches it and every action falls through to Kotlin — | |
| 117 | // so a runtime leg whose library never shipped would look exactly like the | |
| 118 | // shim leg and pass. The APK is the only place to see the difference before | |
| 119 | // the app runs. | |
| 120 | const listed = await command(['unzip', '-l', apk], { logPath: join(evidence, 'apk.txt') }) | |
| 121 | const zigLibraries = listed.stdout | |
| 122 | .split('\n') | |
| 123 | .map(line => line.trim().split(/\s+/).pop() ?? '') | |
| 124 | .filter(name => /^lib\/[^/]+\/libcraft\.so$/.test(name)) | |
| 125 | ||
| 126 | if (leg.requireZig && !zigLibraries.some(name => name.includes('/x86_64/'))) { | |
| 127 | failures.push( | |
| 128 | `the runtime leg's APK carries ${zigLibraries.length ? zigLibraries.join(', ') : 'no libcraft.so at all'}, ` | |
| 129 | + 'and nothing for x86_64 — which is the architecture every emulator runs, so the runtime could not load here whatever else shipped', | |
| 130 | ) | |
| 131 | } | |
| 132 | if (!leg.requireZig && zigLibraries.length) { | |
| 133 | failures.push(`the shim leg's APK ships ${zigLibraries.join(', ')}; it was generated with no runtime`) | |
| 134 | } | |
| 135 | ||
| 136 | const serial = await resolveSerial() | |
| 137 | writeFileSync(join(evidence, 'device.txt'), `${serial}\n`) | |
| 138 | ||
| 139 | // Ground truth from outside the app, to check getDeviceInfo against. | |
| 140 | const sdk = (await adb(['shell', 'getprop', 'ro.build.version.sdk'], { serial })).stdout.trim() | |
| 141 | ||
| 142 | await adb(['uninstall', PACKAGE], { serial, allowFailure: true }) | |
| 143 | await adb(['install', '-r', apk], { serial, logPath: join(evidence, 'adb.log') }) | |
| 144 | ||
| 145 | // Not best-effort: the ring buffer outlives an uninstall, and a transcript | |
| 146 | // left in it would otherwise be read as this run's. The plan event carries | |
| 147 | // this run's nonce as a second guard. | |
| 148 | await adb(['logcat', '-c'], { serial }) | |
| 149 | await adb(['shell', 'am', 'force-stop', PACKAGE], { serial, allowFailure: true }) | |
| 150 | await adb(['shell', 'am', 'start', '-n', `${PACKAGE}/.MainActivity`], { serial, logPath: join(evidence, 'adb.log') }) | |
| 151 | ||
| 152 | // logcat -d dumps and exits, so poll it into the evidence file rather than | |
| 153 | // streaming: a stream would have to be killed at exactly the right moment, | |
| 154 | // and the dump is cheap. | |
| 155 | const logPath = join(evidence, 'logcat.txt') | |
| 156 | const finished = await waitForFile(logPath, options.timeoutMs, hasTerminated, async () => { | |
| 157 | const dumped = await command(['adb', '-s', serial, 'logcat', '-d', ...LOGCAT_FILTER], { allowFailure: true }) | |
| 158 | writeFileSync(logPath, dumped.stdout) | |
| 159 | }) | |
| 160 | ||
| 161 | await command(['adb', '-s', serial, 'exec-out', 'screencap', '-p'], { allowFailure: true, outPath: join(evidence, 'screen.png') }) | |
| 162 | const full = await command(['adb', '-s', serial, 'logcat', '-d', '-b', 'all'], { allowFailure: true }) | |
| 163 | writeFileSync(join(evidence, 'logcat-full.txt'), full.stdout) | |
| 164 | await adb(['shell', 'am', 'force-stop', PACKAGE], { serial, allowFailure: true }) | |
| 165 | ||
| 166 | const logText = existsSync(logPath) ? readFileSync(logPath, 'utf8') : '' | |
| 167 | if (!finished) | |
| 168 | failures.push(`the app produced no terminating event within ${options.timeoutMs}ms; see ${label}/logcat.txt`) | |
| 169 | ||
| 170 | const verdict = evaluateRun('android', logText, nonce) | |
| 171 | failures.push(...verdict.failures) | |
| 172 | ||
| 173 | // The page reported the SDK level the bridge told it; the host asked the | |
| 174 | // device directly. Agreement means the value crossed the bridge rather than | |
| 175 | // being invented in JavaScript - this leg's substitute for the pasteboard | |
| 176 | // read the iOS leg does, since Android has no equally cheap host-side | |
| 177 | // clipboard read. | |
| 178 | const observed = logText.match(/"event":"observed","name":"sdkVersion","value":(\d+)/) | |
| 179 | if (!sdk) { | |
| 180 | // Not skipped when the probe comes back empty. An unverifiable check that | |
| 181 | // quietly passes is worse than one that fails: it reads as corroboration | |
| 182 | // and is not. | |
| 183 | failures.push('`adb shell getprop ro.build.version.sdk` returned nothing, so the page\'s sdkVersion could not be corroborated') | |
| 184 | } | |
| 185 | else if (!observed) { | |
| 186 | failures.push('the page never reported the sdkVersion it got from the bridge') | |
| 187 | } | |
| 188 | else if (observed[1] !== sdk) { | |
| 189 | failures.push(`the bridge reported sdkVersion ${observed[1]} but the device says ${sdk}`) | |
| 190 | } | |
| 191 | ||
| 192 | // Did the Zig runtime actually load and bind? JNI_OnLoad says so itself, on | |
| 193 | // the happy path as well as the failure ones — which is deliberate, because | |
| 194 | // "loaded and bound" and "never shipped" are otherwise both silent, and this | |
| 195 | // is also the only line that proves the log channel is working rather than | |
| 196 | // merely quiet. | |
| 197 | // | |
| 198 | // There is no per-action dispatch line to count the way the iOS legs do. | |
| 199 | // Binding is the gate instead: once the natives are registered, every | |
| 200 | // CraftNative wrapper returns a value and the Kotlin falls through to Zig | |
| 201 | // rather than the other way round. | |
| 202 | const registered = logText.match(REGISTERED) | |
| 203 | const zigActions: string[] = [] | |
| 204 | ||
| 205 | if (leg.requireZig) { | |
| 206 | if (!registered) { | |
| 207 | failures.push( | |
| 208 | 'the Zig runtime never reported registering its natives. Either libcraft.so did not load, ' | |
| 209 | + `or JNI_OnLoad refused a descriptor — see ${label}/logcat.txt under the CraftNative tag, which now carries the reason`, | |
| 210 | ) | |
| 211 | } | |
| 212 | else { | |
| 213 | zigActions.push(`registered:${registered[1]}`) | |
| 214 | } | |
| 215 | } | |
| 216 | else if (registered) { | |
| 217 | failures.push(`the shim leg registered ${registered[1]} natives; it was generated with no runtime`) | |
| 218 | } | |
| 219 | ||
| 220 | return { | |
| 221 | name: label, | |
| 222 | status: failures.length ? 'failed' : 'passed', | |
| 223 | failures, | |
| 224 | planned: verdict.planned, | |
| 225 | passed: verdict.passed, | |
| 226 | failed: verdict.failed, | |
| 227 | zigActions, | |
| 228 | evidence: evidence.replace(`${options.root}/`, ''), | |
| 229 | } | |
| 230 | } | |
| 231 | ||
| 232 | export async function runAndroid(options: RunnerOptions): Promise<LegOutcome[]> { | |
| 233 | const runtimeDir = options.androidRuntimeDir | |
| 234 | if (!runtimeDir) { | |
| 235 | throw new Error( | |
| 236 | 'no Android Zig runtime directory; run `zig build build-android-all -Doptimize=ReleaseSafe` ' | |
| 237 | + 'in packages/zig and pass --android-runtime packages/zig/zig-out/android', | |
| 238 | ) | |
| 239 | } | |
| 240 | ||
| 241 | const abi = join(runtimeDir, 'x86_64', 'libcraft.so') | |
| 242 | if (!existsSync(abi)) { | |
| 243 | throw new Error( | |
| 244 | `${abi} is missing. Every Android emulator is x86_64, so without it the runtime leg would ` | |
| 245 | + 'install an APK that cannot load the library and pass as though the shim were the only option. ' | |
| 246 | + 'Run `zig build build-android-all -Doptimize=ReleaseSafe` in packages/zig.', | |
| 247 | ) | |
| 248 | } | |
| 249 | ||
| 250 | const outcomes: LegOutcome[] = [] | |
| 251 | for (const leg of legs(runtimeDir)) { | |
| 252 | try { | |
| 253 | outcomes.push(await runLeg(leg, options)) | |
| 254 | } | |
| 255 | catch (caught) { | |
| 256 | outcomes.push({ | |
| 257 | name: `android-${leg.name}`, | |
| 258 | status: 'failed', | |
| 259 | failures: [caught instanceof Error ? caught.message : String(caught)], | |
| 260 | planned: [], | |
| 261 | passed: [], | |
| 262 | failed: [], | |
| 263 | zigActions: [], | |
| 264 | evidence: join(options.evidenceDir, `android-${leg.name}`).replace(`${options.root}/`, ''), | |
| 265 | }) | |
| 266 | } | |
| 267 | } | |
| 268 | ||
| 269 | return outcomes | |
| 270 | } | |
| @@ -0,0 +1,254 @@ | ||
| 1 | <!DOCTYPE html> | |
| 2 | <html lang="en"> | |
| 3 | <head> | |
| 4 | <meta charset="UTF-8"> | |
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | <title>Craft mobile E2E</title> | |
| 7 | <style> | |
| 8 | body { font: 14px -apple-system, Roboto, sans-serif; background: #0b1712; color: #e6f2ec; padding: 16px; } | |
| 9 | li { margin: 4px 0; font-family: ui-monospace, monospace; } | |
| 10 | .pass { color: #7ee08a; } | |
| 11 | .fail { color: #ff8a80; } | |
| 12 | </style> | |
| 13 | </head> | |
| 14 | <body> | |
| 15 | <h1 id="headline">Craft mobile E2E</h1> | |
| 16 | <ul id="results"></ul> | |
| 17 | <script> | |
| 18 | // The page drives itself and reports through the bridge's own `log` action, | |
| 19 | // which is the one channel both runtimes already forward off-device: iOS | |
| 20 | // prints "[Craft Web] <message>" to the process stderr that | |
| 21 | // `simctl launch --console-pty` captures, and Android writes it to logcat | |
| 22 | // under the CraftBridge tag. Nothing else in the generated app reaches a | |
| 23 | // host-side reader, so the harness would otherwise be watching a screenshot. | |
| 24 | // | |
| 25 | // Every line is CRAFT-E2E followed by one JSON object. The harness reads | |
| 26 | // three kinds: `plan` (once, up front), `case` (once per case), and `done`. | |
| 27 | // Announcing the plan before running anything is what lets the harness tell | |
| 28 | // "this case failed" apart from "this case never ran" — a suite that dies | |
| 29 | // halfway through is a failure, not a pass with fewer cases. | |
| 30 | (function () { | |
| 31 | var NONCE = '__CRAFT_E2E_NONCE__' | |
| 32 | // Which platform the harness built this leg for. Substituted in from | |
| 33 | // outside, because the page comparing window.craft.platform against a | |
| 34 | // value it read out of window.craft.platform is a tautology - it was one, | |
| 35 | // and bridge.ready passed unconditionally until this arrived. | |
| 36 | var EXPECTED_PLATFORM = '__CRAFT_E2E_PLATFORM__' | |
| 37 | var CASE_TIMEOUT_MS = 8000 | |
| 38 | ||
| 39 | function emit(payload) { | |
| 40 | var line = 'CRAFT-E2E ' + JSON.stringify(payload) | |
| 41 | // console.log first: on Android it reaches logcat under `chromium` even | |
| 42 | // when the bridge never arrived, which is the case the harness most | |
| 43 | // needs evidence for. | |
| 44 | try { console.log(line) } catch (ignored) {} | |
| 45 | try { if (window.craft && window.craft.log) window.craft.log(line) } catch (ignored) {} | |
| 46 | } | |
| 47 | ||
| 48 | function show(name, ok, detail) { | |
| 49 | var li = document.createElement('li') | |
| 50 | li.className = ok ? 'pass' : 'fail' | |
| 51 | li.textContent = (ok ? 'PASS ' : 'FAIL ') + name + (detail ? ' — ' + detail : '') | |
| 52 | document.getElementById('results').appendChild(li) | |
| 53 | } | |
| 54 | ||
| 55 | // Rejections arrive in three shapes across the two runtimes: an Error from | |
| 56 | // iOS's _rejectCallback, a bare string from Android's named reject globals | |
| 57 | // (promise-runtime.ts parks `window._craftPushReject` and Kotlin calls it | |
| 58 | // with a quoted string), and an object with `code`/`message` from iOS's | |
| 59 | // capability gate. Normalise once here so a case can assert on a code | |
| 60 | // without caring which runtime produced it. | |
| 61 | function describeError(error) { | |
| 62 | if (error === null || error === undefined) return { code: '', message: '' } | |
| 63 | if (typeof error === 'string') return { code: '', message: error } | |
| 64 | return { code: String(error.code || ''), message: String(error.message || error) } | |
| 65 | } | |
| 66 | ||
| 67 | function withTimeout(run) { | |
| 68 | return new Promise(function (resolve) { | |
| 69 | var settled = false | |
| 70 | var timer = setTimeout(function () { | |
| 71 | if (settled) return | |
| 72 | settled = true | |
| 73 | resolve({ state: 'timeout' }) | |
| 74 | }, CASE_TIMEOUT_MS) | |
| 75 | ||
| 76 | function finish(value) { | |
| 77 | if (settled) return | |
| 78 | settled = true | |
| 79 | clearTimeout(timer) | |
| 80 | resolve(value) | |
| 81 | } | |
| 82 | ||
| 83 | try { | |
| 84 | Promise.resolve(run()).then( | |
| 85 | function (value) { finish({ state: 'resolved', value: value }) }, | |
| 86 | function (error) { finish({ state: 'rejected', error: describeError(error) }) } | |
| 87 | ) | |
| 88 | } | |
| 89 | catch (thrown) { | |
| 90 | finish({ state: 'rejected', error: describeError(thrown) }) | |
| 91 | } | |
| 92 | }) | |
| 93 | } | |
| 94 | ||
| 95 | // A case passes only by returning nothing. Returning a string is how it | |
| 96 | // explains what it saw instead, and that string lands in the report — so a | |
| 97 | // failure names the actual value rather than just the assertion that broke. | |
| 98 | var CASES = [ | |
| 99 | { | |
| 100 | name: 'bridge.ready', | |
| 101 | platforms: ['ios', 'android'], | |
| 102 | run: function () { | |
| 103 | if (!window.craft) return 'window.craft is absent' | |
| 104 | if (typeof window.craft.log !== 'function') return 'window.craft has no log function' | |
| 105 | if (window.craft.platform !== EXPECTED_PLATFORM) | |
| 106 | return 'platform is ' + window.craft.platform + ', expected ' + EXPECTED_PLATFORM | |
| 107 | } | |
| 108 | }, | |
| 109 | { | |
| 110 | // Ground truth the page cannot invent: `isSimulator` comes from Swift's | |
| 111 | // `#if targetEnvironment(simulator)`, decided by the compiler, and | |
| 112 | // `isEmulator` from the device's own Build properties. A stubbed bridge | |
| 113 | // that echoed plausible JSON would have to guess it right. | |
| 114 | name: 'deviceInfo.isSimulator', | |
| 115 | platforms: ['ios'], | |
| 116 | run: function () { | |
| 117 | return withTimeout(function () { return window.craft.getDeviceInfo() }).then(function (outcome) { | |
| 118 | if (outcome.state !== 'resolved') return 'getDeviceInfo ' + outcome.state | |
| 119 | if (!outcome.value) return 'getDeviceInfo resolved with nothing' | |
| 120 | if (outcome.value.platform !== 'ios') return 'platform is ' + outcome.value.platform | |
| 121 | if (outcome.value.isSimulator !== true) return 'isSimulator is ' + outcome.value.isSimulator | |
| 122 | }) | |
| 123 | } | |
| 124 | }, | |
| 125 | { | |
| 126 | name: 'deviceInfo.isEmulator', | |
| 127 | platforms: ['android'], | |
| 128 | run: function () { | |
| 129 | return withTimeout(function () { return window.craft.getDeviceInfo() }).then(function (outcome) { | |
| 130 | if (outcome.state !== 'resolved') return 'getDeviceInfo ' + outcome.state | |
| 131 | if (!outcome.value) return 'getDeviceInfo resolved with nothing' | |
| 132 | ||
| 133 | // Reported before anything is asserted, so a failing assertion | |
| 134 | // still leaves the values behind. The first run of this case said | |
| 135 | // only "isEmulator is false" and the log did not say what the | |
| 136 | // device had called itself, which is the one thing needed to fix | |
| 137 | // it. | |
| 138 | emit({ event: 'observed', name: 'sdkVersion', value: outcome.value.sdkVersion }) | |
| 139 | emit({ event: 'observed', name: 'model', value: outcome.value.model }) | |
| 140 | emit({ event: 'observed', name: 'manufacturer', value: outcome.value.manufacturer }) | |
| 141 | emit({ event: 'observed', name: 'device', value: outcome.value.device }) | |
| 142 | ||
| 143 | if (outcome.value.platform !== 'android') return 'platform is ' + outcome.value.platform | |
| 144 | if (!outcome.value.sdkVersion) return 'sdkVersion is ' + outcome.value.sdkVersion | |
| 145 | if (outcome.value.isEmulator !== true) | |
| 146 | return 'isEmulator is ' + outcome.value.isEmulator + ' on model ' + outcome.value.model | |
| 147 | }) | |
| 148 | } | |
| 149 | }, | |
| 150 | { | |
| 151 | // The success path. On iOS the harness then reads the device pasteboard | |
| 152 | // from the host with `simctl pbpaste`, so the case asserts the value | |
| 153 | // reached real system clipboard state rather than a variable the bridge | |
| 154 | // handed back. Android has no equally cheap host-side read, so its | |
| 155 | // external check is the sdkVersion cross-reference below instead. | |
| 156 | name: 'clipboard.roundTrip', | |
| 157 | platforms: ['ios', 'android'], | |
| 158 | run: function () { | |
| 159 | return withTimeout(function () { return window.craft.clipboard.write(NONCE) }).then(function (written) { | |
| 160 | if (written.state !== 'resolved') return 'clipboard.write ' + written.state + ' ' + (written.error ? written.error.message : '') | |
| 161 | return withTimeout(function () { return window.craft.clipboard.read() }).then(function (read) { | |
| 162 | if (read.state !== 'resolved') return 'clipboard.read ' + read.state + ' ' + (read.error ? read.error.message : '') | |
| 163 | if (read.value !== NONCE) return 'read back ' + JSON.stringify(read.value) | |
| 164 | }) | |
| 165 | }) | |
| 166 | } | |
| 167 | }, | |
| 168 | { | |
| 169 | // The failure path. Geolocation is off in the harness config, so the | |
| 170 | // native side must answer — with a code that names the configuration — | |
| 171 | // rather than leave the promise pending. A dropped callback here is | |
| 172 | // indistinguishable from a slow one to the page, which is exactly why | |
| 173 | // the case asserts the rejection and not merely "did not resolve". | |
| 174 | name: 'geolocation.disabled.rejects', | |
| 175 | platforms: ['ios'], | |
| 176 | run: function () { | |
| 177 | return withTimeout(function () { return window.craft.getCurrentPosition() }).then(function (outcome) { | |
| 178 | if (outcome.state === 'timeout') return 'getCurrentPosition never settled' | |
| 179 | if (outcome.state === 'resolved') return 'getCurrentPosition resolved on a disabled capability' | |
| 180 | if (outcome.error.code !== 'CAPABILITY_DISABLED') return 'code is ' + JSON.stringify(outcome.error.code) | |
| 181 | }) | |
| 182 | } | |
| 183 | }, | |
| 184 | { | |
| 185 | // Android's equivalent gate. With push disabled the generator emits a | |
| 186 | // literal reject call rather than a Firebase block, so the rejection | |
| 187 | // string is fixed at generation time and is safe to match on. | |
| 188 | name: 'push.disabled.rejects', | |
| 189 | platforms: ['android'], | |
| 190 | run: function () { | |
| 191 | return withTimeout(function () { return window.craft.registerPush() }).then(function (outcome) { | |
| 192 | if (outcome.state === 'timeout') return 'registerPush never settled' | |
| 193 | if (outcome.state === 'resolved') return 'registerPush resolved while push is disabled' | |
| 194 | if (outcome.error.message.indexOf('Push notifications are disabled') === -1) | |
| 195 | return 'message is ' + JSON.stringify(outcome.error.message) | |
| 196 | }) | |
| 197 | } | |
| 198 | } | |
| 199 | ] | |
| 200 | ||
| 201 | function waitForBridge(attempt) { | |
| 202 | if (window.craft && window.craft.getDeviceInfo) return Promise.resolve(true) | |
| 203 | if (attempt >= 150) return Promise.resolve(false) | |
| 204 | return new Promise(function (resolve) { | |
| 205 | setTimeout(function () { resolve(waitForBridge(attempt + 1)) }, 100) | |
| 206 | }) | |
| 207 | } | |
| 208 | ||
| 209 | waitForBridge(0).then(function (ready) { | |
| 210 | if (!ready) { | |
| 211 | emit({ event: 'fatal', reason: 'window.craft never appeared' }) | |
| 212 | document.getElementById('headline').textContent = 'FATAL: no bridge' | |
| 213 | return | |
| 214 | } | |
| 215 | ||
| 216 | var platform = window.craft.platform | |
| 217 | var selected = CASES.filter(function (entry) { return entry.platforms.indexOf(platform) !== -1 }) | |
| 218 | emit({ event: 'plan', platform: platform, run: NONCE, cases: selected.map(function (entry) { return entry.name }) }) | |
| 219 | ||
| 220 | var passed = 0 | |
| 221 | var failed = 0 | |
| 222 | ||
| 223 | // Sequential, not Promise.all. Android's promise runtime is single-flight | |
| 224 | // per channel and rejects a second in-flight request outright; running | |
| 225 | // the suite in parallel would fail cases for colliding with each other. | |
| 226 | function step(index) { | |
| 227 | if (index >= selected.length) { | |
| 228 | emit({ event: 'done', passed: passed, failed: failed }) | |
| 229 | document.getElementById('headline').textContent = failed ? 'FAILED' : 'PASSED' | |
| 230 | return | |
| 231 | } | |
| 232 | ||
| 233 | var entry = selected[index] | |
| 234 | Promise.resolve() | |
| 235 | .then(function () { return entry.run() }) | |
| 236 | .then( | |
| 237 | function (detail) { return detail ? String(detail) : '' }, | |
| 238 | function (thrown) { return 'threw: ' + describeError(thrown).message } | |
| 239 | ) | |
| 240 | .then(function (detail) { | |
| 241 | if (detail) failed++ | |
| 242 | else passed++ | |
| 243 | emit({ event: 'case', name: entry.name, status: detail ? 'fail' : 'pass', detail: detail }) | |
| 244 | show(entry.name, !detail, detail) | |
| 245 | step(index + 1) | |
| 246 | }) | |
| 247 | } | |
| 248 | ||
| 249 | step(0) | |
| 250 | }) | |
| 251 | })() | |
| 252 | </script> | |
| 253 | </body> | |
| 254 | </html> | |
| @@ -0,0 +1,241 @@ | ||
| 1 | import type { LegOutcome, RunnerOptions } from './types' | |
| 2 | import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs' | |
| 3 | import { join } from 'node:path' | |
| 4 | import { bootSimulator, init, pickSimulator } from '../../packages/ios/src/index' | |
| 5 | import { evaluateRun, hasTerminated, ZIG_REFUSED_ACTION, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigRefusals } from './protocol' | |
| 6 | import { command, driverPage, waitForFile } from './support' | |
| 7 | ||
| 8 | /** | |
| 9 | * The app the suite runs against. | |
| 10 | * | |
| 11 | * Clipboard on, geolocation off, and that pairing is the whole point: the | |
| 12 | * success case needs a capability that is enabled and reachable without a | |
| 13 | * permission prompt, and the rejection case needs one that is switched off so | |
| 14 | * the refusal is a property of the configuration rather than of the machine. | |
| 15 | * Reading a pasteboard the app itself just wrote raises no iOS paste prompt, | |
| 16 | * which is what makes the round trip scriptable. | |
| 17 | */ | |
| 18 | const CONFIG = { | |
| 19 | enableClipboard: true, | |
| 20 | enableGeolocation: false, | |
| 21 | } | |
| 22 | ||
| 23 | const BUNDLE_ID = 'dev.craft.e2e.probe' | |
| 24 | const APP_NAME = 'CraftE2EProbe' | |
| 25 | ||
| 26 | /** | |
| 27 | * One leg is one answer to "who served the call". | |
| 28 | * | |
| 29 | * `shim` is what every generated app is today: no Zig archive, every dlsym | |
| 30 | * miss, Swift serving the whole surface. `runtime` links the archive so Zig | |
| 31 | * takes the actions it owns. They assert the same cases, because the hand-off | |
| 32 | * is supposed to be invisible to the page - and that is exactly why the | |
| 33 | * runtime leg also counts Zig's dispatch log lines. Identical behaviour is the | |
| 34 | * requirement; identical behaviour with the archive silently absent is the bug | |
| 35 | * that requirement would otherwise hide. | |
| 36 | */ | |
| 37 | interface Leg { | |
| 38 | name: 'shim' | 'runtime' | |
| 39 | runtimeDir: string | null | |
| 40 | requireZigDispatch: boolean | |
| 41 | } | |
| 42 | ||
| 43 | function legs(runtimeDir: string | null): Leg[] { | |
| 44 | return [ | |
| 45 | { name: 'shim', runtimeDir: null, requireZigDispatch: false }, | |
| 46 | { name: 'runtime', runtimeDir, requireZigDispatch: true }, | |
| 47 | ] | |
| 48 | } | |
| 49 | ||
| 50 | async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> { | |
| 51 | const label = `ios-${leg.name}` | |
| 52 | const evidence = join(options.evidenceDir, label) | |
| 53 | const project = join(options.workDir, label) | |
| 54 | const failures: string[] = [] | |
| 55 | ||
| 56 | mkdirSync(evidence, { recursive: true }) | |
| 57 | rmSync(project, { force: true, recursive: true }) | |
| 58 | ||
| 59 | await init({ | |
| 60 | name: APP_NAME, | |
| 61 | bundleId: BUNDLE_ID, | |
| 62 | output: project, | |
| 63 | config: CONFIG, | |
| 64 | runtimeDir: leg.runtimeDir, | |
| 65 | }) | |
| 66 | ||
| 67 | // The archive is linked through project.yml's , | |
| 68 | // which init() leaves empty when it installed no runtime. Checking the | |
| 69 | // generated file rather than trusting the option means a leg cannot quietly | |
| 70 | // become a second copy of the shim leg. | |
| 71 | const projectYml = readFileSync(join(project, 'project.yml'), 'utf8') | |
| 72 | const linksRuntime = projectYml.includes('-lcraft-ios') | |
| 73 | if (leg.requireZigDispatch && !linksRuntime) | |
| 74 | failures.push(`the ${leg.name} leg generated a project that does not link the Zig archive; check ${leg.runtimeDir}`) | |
| 75 | if (!leg.requireZigDispatch && linksRuntime) | |
| 76 | failures.push(`the ${leg.name} leg was supposed to have no runtime but its project links one`) | |
| 77 | ||
| 78 | const nonce = `craft-e2e-${label}-${options.runId}` | |
| 79 | writeFileSync(join(project, 'dist', 'index.html'), driverPage(nonce, 'ios')) | |
| 80 | copyFileSync(join(project, 'dist', 'index.html'), join(evidence, 'index.html')) | |
| 81 | copyFileSync(join(project, 'craft.config.json'), join(evidence, 'craft.config.json')) | |
| 82 | copyFileSync(join(project, 'project.yml'), join(evidence, 'project.yml')) | |
| 83 | ||
| 84 | await command(['xcodegen', 'generate'], { cwd: project, logPath: join(evidence, 'xcodegen.log') }) | |
| 85 | ||
| 86 | const derived = join(project, 'DerivedData') | |
| 87 | await command([ | |
| 88 | 'xcodebuild', | |
| 89 | '-project', `${APP_NAME}.xcodeproj`, | |
| 90 | '-scheme', APP_NAME, | |
| 91 | '-configuration', 'Debug', | |
| 92 | // The simulator SDK, which no existing job builds against: the templates | |
| 93 | // gate compiles -sdk iphoneos only. A generated app that links for a | |
| 94 | // device and not for a simulator would have gone unnoticed. | |
| 95 | '-sdk', 'iphonesimulator', | |
| 96 | '-derivedDataPath', derived, | |
| 97 | 'CODE_SIGNING_ALLOWED=NO', | |
| 98 | 'build', | |
| 99 | ], { cwd: project, logPath: join(evidence, 'xcodebuild.log') }) | |
| 100 | ||
| 101 | const app = join(derived, 'Build', 'Products', 'Debug-iphonesimulator', `${APP_NAME}.app`) | |
| 102 | if (!existsSync(app)) | |
| 103 | throw new Error(`xcodebuild reported success but ${app} is missing`) | |
| 104 | ||
| 105 | const device = await pickSimulator() | |
| 106 | if (!device) | |
| 107 | throw new Error('no iOS simulator is available; `xcrun simctl list devices available` found none') | |
| 108 | await bootSimulator(device) | |
| 109 | writeFileSync(join(evidence, 'device.json'), `${JSON.stringify(device, null, 2)}\n`) | |
| 110 | ||
| 111 | await command(['xcrun', 'simctl', 'uninstall', device.udid, BUNDLE_ID], { allowFailure: true }) | |
| 112 | await command(['xcrun', 'simctl', 'install', device.udid, app]) | |
| 113 | ||
| 114 | // Clear the pasteboard first. Without this a nonce left by the previous leg | |
| 115 | // would still be there, and the external check below would pass on a value | |
| 116 | // this run never wrote. | |
| 117 | await command(['xcrun', 'simctl', 'pbcopy', device.udid], { stdin: 'craft-e2e-pasteboard-cleared', allowFailure: true }) | |
| 118 | ||
| 119 | // The app's own stdout and stderr, straight to a file. `--console-pty` is | |
| 120 | // what makes Swift's `print` - and therefore the page's `craft.log` - leave | |
| 121 | // the device at all; without it the launch returns a pid and says nothing. | |
| 122 | // A raw descriptor rather than a pipe, so the writing outlives this process | |
| 123 | // reading it and the poll below sees the file grow. | |
| 124 | const consolePath = join(evidence, 'console.log') | |
| 125 | const consoleFd = openSync(consolePath, 'w') | |
| 126 | const launched = Bun.spawn(['xcrun', 'simctl', 'launch', '--console-pty', device.udid, BUNDLE_ID], { | |
| 127 | stdout: consoleFd, | |
| 128 | stderr: consoleFd, | |
| 129 | }) | |
| 130 | ||
| 131 | // The suite is quick once the app is up, but a cold simulator is not, so | |
| 132 | // poll for the terminator rather than sleeping a fixed amount. | |
| 133 | // | |
| 134 | // `hasTerminated` parses rather than matching a substring, because the very | |
| 135 | // next statement kills the writer: a poll landing mid-write of the final | |
| 136 | // line would otherwise leave a truncated event in the file and turn a | |
| 137 | // passing suite into "driver emitted an unparseable event". | |
| 138 | const finished = await waitForFile(consolePath, options.timeoutMs, hasTerminated) | |
| 139 | ||
| 140 | launched.kill() | |
| 141 | closeSync(consoleFd) | |
| 142 | await command(['xcrun', 'simctl', 'terminate', device.udid, BUNDLE_ID], { allowFailure: true }) | |
| 143 | ||
| 144 | // Evidence before assertions, always: a leg that dies here is the one whose | |
| 145 | // screenshot is worth the most. | |
| 146 | await command(['xcrun', 'simctl', 'io', device.udid, 'screenshot', join(evidence, 'screen.png')], { allowFailure: true }) | |
| 147 | const pasteboard = await command(['xcrun', 'simctl', 'pbpaste', device.udid], { allowFailure: true }) | |
| 148 | writeFileSync(join(evidence, 'pasteboard.txt'), pasteboard.stdout) | |
| 149 | ||
| 150 | const consoleText = existsSync(consolePath) ? readFileSync(consolePath, 'utf8') : '' | |
| 151 | if (!finished) | |
| 152 | failures.push(`the app produced no terminating event within ${options.timeoutMs}ms; see ${label}/console.log`) | |
| 153 | ||
| 154 | const verdict = evaluateRun('ios', consoleText, nonce) | |
| 155 | failures.push(...verdict.failures) | |
| 156 | ||
| 157 | // The one assertion the page cannot fake. simctl reads the device's own | |
| 158 | // pasteboard from the host, so a nonce found here travelled from JavaScript | |
| 159 | // through the bridge into real UIPasteboard state. | |
| 160 | if (!pasteboard.stdout.includes(nonce)) | |
| 161 | failures.push(`the simulator pasteboard does not hold the nonce this run wrote; it holds ${JSON.stringify(pasteboard.stdout.trim().slice(0, 80))}`) | |
| 162 | ||
| 163 | const dispatched = zigDispatchedActions(consoleText) | |
| 164 | const refused = zigRefusals(consoleText) | |
| 165 | ||
| 166 | if (leg.requireZigDispatch) { | |
| 167 | const missing = ZIG_TESTED_ACTIONS.filter(action => !dispatched.includes(action)) | |
| 168 | if (missing.length) | |
| 169 | failures.push(`the runtime leg links the Zig archive but ${missing.join(', ')} never reached the Zig dispatcher; Swift served ${missing.length === 1 ? 'it' : 'them'} and the archive was not in the loop for the cases under test`) | |
| 170 | ||
| 171 | // A dispatch line is logged on entry, before Zig decides whether it owns | |
| 172 | // the action, so it alone does not prove Zig answered. The refusal line is | |
| 173 | // written by Zig's own capability gate, which means the rejection the page | |
| 174 | // saw came off Zig's error route. | |
| 175 | if (!refused.includes(ZIG_REFUSED_ACTION)) | |
| 176 | failures.push(`Zig never refused ${ZIG_REFUSED_ACTION}; the rejection the page saw came from Swift, not from the Zig gate`) | |
| 177 | } | |
| 178 | else { | |
| 179 | if (dispatched.length) | |
| 180 | failures.push(`the shim leg reached the Zig dispatcher for ${dispatched.join(', ')}; it was supposed to link no runtime`) | |
| 181 | if (refused.length) | |
| 182 | failures.push(`the shim leg saw Zig refuse ${refused.join(', ')}; it was supposed to link no runtime`) | |
| 183 | } | |
| 184 | ||
| 185 | return { | |
| 186 | name: label, | |
| 187 | status: failures.length ? 'failed' : 'passed', | |
| 188 | failures, | |
| 189 | planned: verdict.planned, | |
| 190 | passed: verdict.passed, | |
| 191 | failed: verdict.failed, | |
| 192 | zigActions: dispatched, | |
| 193 | evidence: evidence.replace(`${options.root}/`, ''), | |
| 194 | } | |
| 195 | } | |
| 196 | ||
| 197 | export async function runIos(options: RunnerOptions): Promise<LegOutcome[]> { | |
| 198 | const runtimeDir = options.iosRuntimeDir | |
| 199 | if (!runtimeDir) | |
| 200 | throw new Error('no iOS Zig runtime directory; run `zig build build-ios-all` in packages/zig and pass --ios-runtime <dir>') | |
| 201 | ||
| 202 | // The device archive as well as the simulator ones, because that is what | |
| 203 | // `installRuntime` requires: it resolves every SDK in RUNTIME_ARCHIVES before | |
| 204 | // writing anything and throws when one has none. Checking only the simulator | |
| 205 | // slices here would accept a zig-out that project generation then rejects, | |
| 206 | // several minutes later, with a message about a different build step. | |
| 207 | const simulator = ['libcraft-ios-simulator-arm64.a', 'libcraft-ios-simulator-x64.a'] | |
| 208 | const missing: string[] = [] | |
| 209 | if (!simulator.some(name => existsSync(join(runtimeDir, name)))) missing.push(...simulator) | |
| 210 | if (!existsSync(join(runtimeDir, 'libcraft-ios.a'))) missing.push('libcraft-ios.a') | |
| 211 | ||
| 212 | if (missing.length) { | |
| 213 | throw new Error( | |
| 214 | `${runtimeDir} is missing ${missing.join(', ')}. ` | |
| 215 | + 'Run `zig build build-ios-all -Doptimize=ReleaseSafe` in packages/zig first — ' | |
| 216 | + 'build-ios-simulator alone is not enough, because the generated project links a device slice too. ' | |
| 217 | + 'Skipping the runtime leg instead would leave the Zig bridge untested while the suite still reported green.', | |
| 218 | ) | |
| 219 | } | |
| 220 | ||
| 221 | const outcomes: LegOutcome[] = [] | |
| 222 | for (const leg of legs(runtimeDir)) { | |
| 223 | try { | |
| 224 | outcomes.push(await runLeg(leg, options)) | |
| 225 | } | |
| 226 | catch (caught) { | |
| 227 | outcomes.push({ | |
| 228 | name: `ios-${leg.name}`, | |
| 229 | status: 'failed', | |
| 230 | failures: [caught instanceof Error ? caught.message : String(caught)], | |
| 231 | planned: [], | |
| 232 | passed: [], | |
| 233 | failed: [], | |
| 234 | zigActions: [], | |
| 235 | evidence: join(options.evidenceDir, `ios-${leg.name}`).replace(`${options.root}/`, ''), | |
| 236 | }) | |
| 237 | } | |
| 238 | } | |
| 239 | ||
| 240 | return outcomes | |
| 241 | } | |
| @@ -0,0 +1,278 @@ | ||
| 1 | import { describe, expect, it } from 'bun:test' | |
| 2 | import { evaluateRun, hasTerminated, parseDriverOutput, REQUIRED_CASES, requiredCaseProblems, ZIG_REFUSED_ACTION, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigRefusals } from './protocol' | |
| 3 | ||
| 4 | const ESC = String.fromCharCode(27) | |
| 5 | ||
| 6 | /** A transcript shaped like the one a real iOS run produces. */ | |
| 7 | const RUN = 'craft-e2e-ios-shim-1234' | |
| 8 | ||
| 9 | function iosTranscript(overrides: { cases?: string[], omit?: string[], fail?: Record<string, string>, done?: boolean, run?: string } = {}): string { | |
| 10 | const planned = overrides.cases ?? REQUIRED_CASES.ios | |
| 11 | const omit = overrides.omit ?? [] | |
| 12 | const fail = overrides.fail ?? {} | |
| 13 | const reported = planned.filter(name => !omit.includes(name)) | |
| 14 | ||
| 15 | const lines = [ | |
| 16 | 'Some unrelated system chatter from the device', | |
| 17 | `[Craft Web] CRAFT-E2E ${JSON.stringify({ event: 'plan', platform: 'ios', run: overrides.run ?? RUN, cases: planned })}`, | |
| 18 | ...reported.map(name => `[Craft Web] CRAFT-E2E ${JSON.stringify({ | |
| 19 | event: 'case', | |
| 20 | name, | |
| 21 | status: fail[name] ? 'fail' : 'pass', | |
| 22 | detail: fail[name] ?? '', | |
| 23 | })}`), | |
| 24 | ] | |
| 25 | ||
| 26 | if (overrides.done !== false) { | |
| 27 | const failed = reported.filter(name => fail[name]).length | |
| 28 | lines.push(`[Craft Web] CRAFT-E2E ${JSON.stringify({ event: 'done', passed: reported.length - failed, failed })}`) | |
| 29 | } | |
| 30 | ||
| 31 | return `${lines.join('\n')}\n` | |
| 32 | } | |
| 33 | ||
| 34 | describe('required cases', () => { | |
| 35 | it('keeps a success path and a rejection path on every platform', () => { | |
| 36 | expect(requiredCaseProblems()).toEqual([]) | |
| 37 | }) | |
| 38 | ||
| 39 | it('names the same bridge.ready case on both platforms', () => { | |
| 40 | expect(REQUIRED_CASES.ios).toContain('bridge.ready') | |
| 41 | expect(REQUIRED_CASES.android).toContain('bridge.ready') | |
| 42 | }) | |
| 43 | }) | |
| 44 | ||
| 45 | describe('parseDriverOutput', () => { | |
| 46 | it('finds events inside noisy, colour-coded device logs', () => { | |
| 47 | const text = [ | |
| 48 | 'unrelated', | |
| 49 | `${ESC}[33m[Craft Web] CRAFT-E2E {"event":"plan","platform":"ios","cases":["a"]}${ESC}[0m`, | |
| 50 | 'more unrelated', | |
| 51 | ].join('\n') | |
| 52 | ||
| 53 | const { events, malformed } = parseDriverOutput(text) | |
| 54 | expect(malformed).toEqual([]) | |
| 55 | expect(events).toEqual([{ event: 'plan', platform: 'ios', cases: ['a'] }]) | |
| 56 | }) | |
| 57 | ||
| 58 | it('reports a garbled event rather than skipping it', () => { | |
| 59 | const { events, malformed } = parseDriverOutput('CRAFT-E2E {"event":"plan"\n') | |
| 60 | expect(events).toEqual([]) | |
| 61 | expect(malformed).toEqual(['{"event":"plan"']) | |
| 62 | }) | |
| 63 | ||
| 64 | it('tolerates carriage returns from the console pty', () => { | |
| 65 | const { events } = parseDriverOutput('CRAFT-E2E {"event":"done","passed":1,"failed":0}\r\n') | |
| 66 | expect(events).toEqual([{ event: 'done', passed: 1, failed: 0 }]) | |
| 67 | }) | |
| 68 | ||
| 69 | // The exact shape the first Android run produced. Chromium wraps a page's | |
| 70 | // console.log before it reaches logcat, and taking the rest of the line | |
| 71 | // turned every event into an unparseable one - six of them, which failed a | |
| 72 | // run whose bridge had in fact worked. | |
| 73 | it('reads an event out of a Chromium console wrapper', () => { | |
| 74 | const line = '09-15 13:49:12.345 1234 1234 I chromium: [INFO:CONSOLE(39)] "CRAFT-E2E ' | |
| 75 | + '{"event":"case","name":"clipboard.roundTrip","status":"pass","detail":""}", ' | |
| 76 | + 'source: https://appassets.androidplatform.net/ (39)' | |
| 77 | ||
| 78 | const { events, malformed } = parseDriverOutput(line) | |
| 79 | expect(malformed).toEqual([]) | |
| 80 | expect(events).toEqual([{ event: 'case', name: 'clipboard.roundTrip', status: 'pass', detail: '' }]) | |
| 81 | }) | |
| 82 | ||
| 83 | it('keeps one copy when the page reports through both channels', () => { | |
| 84 | const payload = '{"event":"case","name":"bridge.ready","status":"pass","detail":""}' | |
| 85 | const { events } = parseDriverOutput([ | |
| 86 | `I chromium: [INFO:CONSOLE(39)] "CRAFT-E2E ${payload}", source: https://appassets.androidplatform.net/ (39)`, | |
| 87 | `D CraftBridge: CRAFT-E2E ${payload}`, | |
| 88 | ].join('\n')) | |
| 89 | ||
| 90 | expect(events).toEqual([{ event: 'case', name: 'bridge.ready', status: 'pass', detail: '' }]) | |
| 91 | }) | |
| 92 | ||
| 93 | it('does not stop at a brace inside a string', () => { | |
| 94 | const { events, malformed } = parseDriverOutput( | |
| 95 | 'CRAFT-E2E {"event":"case","name":"a","status":"fail","detail":"read back \\"{oops}\\""}', | |
| 96 | ) | |
| 97 | expect(malformed).toEqual([]) | |
| 98 | expect(events).toEqual([{ event: 'case', name: 'a', status: 'fail', detail: 'read back "{oops}"' }]) | |
| 99 | }) | |
| 100 | ||
| 101 | it('still reports a genuinely truncated event', () => { | |
| 102 | const { events, malformed } = parseDriverOutput('CRAFT-E2E {"event":"plan","cases":["a"') | |
| 103 | expect(events).toEqual([]) | |
| 104 | expect(malformed).toEqual(['{"event":"plan","cases":["a"']) | |
| 105 | }) | |
| 106 | }) | |
| 107 | ||
| 108 | describe('evaluateRun', () => { | |
| 109 | it('passes a complete run', () => { | |
| 110 | const verdict = evaluateRun('ios', iosTranscript()) | |
| 111 | expect(verdict.failures).toEqual([]) | |
| 112 | expect(verdict.ok).toBe(true) | |
| 113 | expect(verdict.passed).toEqual(REQUIRED_CASES.ios) | |
| 114 | }) | |
| 115 | ||
| 116 | // The failure the workflow this replaces actually shipped: nothing ran, and | |
| 117 | // nothing said so. | |
| 118 | it('fails an empty log instead of finding nothing to complain about', () => { | |
| 119 | const verdict = evaluateRun('ios', '') | |
| 120 | expect(verdict.ok).toBe(false) | |
| 121 | expect(verdict.failures).toEqual(['the test page never announced a plan; it did not reach the native bridge']) | |
| 122 | }) | |
| 123 | ||
| 124 | it('fails a log with device chatter but no driver output', () => { | |
| 125 | const verdict = evaluateRun('ios', 'Booting\nAssertion failed somewhere unrelated\n') | |
| 126 | expect(verdict.ok).toBe(false) | |
| 127 | }) | |
| 128 | ||
| 129 | it('fails when a planned case never reports', () => { | |
| 130 | const verdict = evaluateRun('ios', iosTranscript({ omit: ['clipboard.roundTrip'], done: false })) | |
| 131 | expect(verdict.ok).toBe(false) | |
| 132 | expect(verdict.failures).toContain('case clipboard.roundTrip was planned but never reported; the suite stopped early') | |
| 133 | expect(verdict.failures).toContain('the suite never reported done') | |
| 134 | }) | |
| 135 | ||
| 136 | it('fails when the suite is hollowed out to the easy cases', () => { | |
| 137 | const verdict = evaluateRun('ios', iosTranscript({ cases: ['bridge.ready'] })) | |
| 138 | expect(verdict.ok).toBe(false) | |
| 139 | expect(verdict.failures).toContain('required case geolocation.disabled.rejects is not in the suite the page ran') | |
| 140 | expect(verdict.failures).toContain('required case clipboard.roundTrip is not in the suite the page ran') | |
| 141 | }) | |
| 142 | ||
| 143 | it('carries the page detail into the failure so the reason is the value seen', () => { | |
| 144 | const verdict = evaluateRun('ios', iosTranscript({ fail: { 'clipboard.roundTrip': 'read back ""' } })) | |
| 145 | expect(verdict.ok).toBe(false) | |
| 146 | expect(verdict.failures).toContain('case clipboard.roundTrip failed: read back ""') | |
| 147 | expect(verdict.failed).toEqual(['clipboard.roundTrip']) | |
| 148 | }) | |
| 149 | ||
| 150 | it('fails when the tally disagrees with the transcript', () => { | |
| 151 | const text = [ | |
| 152 | 'CRAFT-E2E {"event":"plan","platform":"ios","cases":["bridge.ready","deviceInfo.isSimulator","clipboard.roundTrip","geolocation.disabled.rejects"]}', | |
| 153 | ...REQUIRED_CASES.ios.map(name => `CRAFT-E2E {"event":"case","name":"${name}","status":"pass","detail":""}`), | |
| 154 | 'CRAFT-E2E {"event":"done","passed":99,"failed":0}', | |
| 155 | ].join('\n') | |
| 156 | ||
| 157 | const verdict = evaluateRun('ios', text) | |
| 158 | expect(verdict.ok).toBe(false) | |
| 159 | expect(verdict.failures).toContain('done says 99 passed / 0 failed, transcript shows 4 / 0') | |
| 160 | }) | |
| 161 | ||
| 162 | it('fails when the app is not the platform the leg was built for', () => { | |
| 163 | const text = iosTranscript().replace('"platform":"ios"', '"platform":"android"') | |
| 164 | const verdict = evaluateRun('ios', text) | |
| 165 | expect(verdict.ok).toBe(false) | |
| 166 | expect(verdict.failures).toContain('the app reports platform android, expected ios') | |
| 167 | }) | |
| 168 | ||
| 169 | // Android reads its transcript out of a ring buffer that survives an | |
| 170 | // uninstall, so a leg whose app printed nothing could otherwise be evaluated | |
| 171 | // against the previous run's output. | |
| 172 | it('refuses a transcript left over from an earlier run', () => { | |
| 173 | const verdict = evaluateRun('ios', iosTranscript({ run: 'craft-e2e-ios-shim-OLD' }), RUN) | |
| 174 | expect(verdict.ok).toBe(false) | |
| 175 | expect(verdict.failures).toContain( | |
| 176 | `the transcript is from run "craft-e2e-ios-shim-OLD", not "${RUN}"; this is stale output, not this run's`, | |
| 177 | ) | |
| 178 | }) | |
| 179 | ||
| 180 | it('accepts the transcript when the run matches', () => { | |
| 181 | expect(evaluateRun('ios', iosTranscript(), RUN).ok).toBe(true) | |
| 182 | }) | |
| 183 | ||
| 184 | it('does not check the run when none was given', () => { | |
| 185 | expect(evaluateRun('ios', iosTranscript({ run: 'anything' })).ok).toBe(true) | |
| 186 | }) | |
| 187 | ||
| 188 | // "Planned, and did report" is not the same as "passed". A third status | |
| 189 | // satisfies both of the other checks while leaving the case unasserted. | |
| 190 | it('refuses a status that is neither pass nor fail', () => { | |
| 191 | const text = [ | |
| 192 | `CRAFT-E2E ${JSON.stringify({ event: 'plan', platform: 'ios', run: RUN, cases: REQUIRED_CASES.ios })}`, | |
| 193 | ...REQUIRED_CASES.ios.map((name, index) => `CRAFT-E2E ${JSON.stringify({ | |
| 194 | event: 'case', | |
| 195 | name, | |
| 196 | status: index === 0 ? 'skipped' : 'pass', | |
| 197 | detail: '', | |
| 198 | })}`), | |
| 199 | `CRAFT-E2E ${JSON.stringify({ event: 'done', passed: 3, failed: 0 })}`, | |
| 200 | ].join('\n') | |
| 201 | ||
| 202 | const verdict = evaluateRun('ios', text, RUN) | |
| 203 | expect(verdict.ok).toBe(false) | |
| 204 | expect(verdict.failures).toContain('case bridge.ready reported status "skipped", which is neither pass nor fail') | |
| 205 | }) | |
| 206 | ||
| 207 | it('reports the driver fatal when the bridge never appeared', () => { | |
| 208 | const verdict = evaluateRun('ios', 'CRAFT-E2E {"event":"fatal","reason":"window.craft never appeared"}\n') | |
| 209 | expect(verdict.ok).toBe(false) | |
| 210 | expect(verdict.failures).toContain('driver reported a fatal condition: window.craft never appeared') | |
| 211 | }) | |
| 212 | ||
| 213 | it('evaluates an android transcript against the android list', () => { | |
| 214 | const lines = [ | |
| 215 | `CRAFT-E2E ${JSON.stringify({ event: 'plan', platform: 'android', run: RUN, cases: REQUIRED_CASES.android })}`, | |
| 216 | ...REQUIRED_CASES.android.map(name => `CRAFT-E2E ${JSON.stringify({ event: 'case', name, status: 'pass', detail: '' })}`), | |
| 217 | `CRAFT-E2E ${JSON.stringify({ event: 'done', passed: REQUIRED_CASES.android.length, failed: 0 })}`, | |
| 218 | ].join('\n') | |
| 219 | ||
| 220 | expect(evaluateRun('android', lines).ok).toBe(true) | |
| 221 | // The same transcript must not satisfy the iOS leg. | |
| 222 | expect(evaluateRun('ios', lines).ok).toBe(false) | |
| 223 | }) | |
| 224 | }) | |
| 225 | ||
| 226 | describe('hasTerminated', () => { | |
| 227 | it('waits for the whole event, not the first bytes of it', () => { | |
| 228 | // The harness kills the app the moment this is true, so a substring match | |
| 229 | // would cut the line it was waiting for and report it as malformed. | |
| 230 | expect(hasTerminated('[Craft Web] CRAFT-E2E {"event":"done","pa')).toBe(false) | |
| 231 | expect(hasTerminated('[Craft Web] CRAFT-E2E {"event":"done","passed":4,"failed":0}')).toBe(true) | |
| 232 | }) | |
| 233 | ||
| 234 | it('treats a fatal as terminal too', () => { | |
| 235 | expect(hasTerminated('CRAFT-E2E {"event":"fatal","reason":"window.craft never appeared"}')).toBe(true) | |
| 236 | }) | |
| 237 | ||
| 238 | it('is false while cases are still arriving', () => { | |
| 239 | expect(hasTerminated('CRAFT-E2E {"event":"case","name":"a","status":"pass"}')).toBe(false) | |
| 240 | }) | |
| 241 | }) | |
| 242 | ||
| 243 | describe('zig attribution', () => { | |
| 244 | it('names the actions the Zig dispatcher saw', () => { | |
| 245 | const text = [ | |
| 246 | 'info: craft-bridge dispatch t=mobile a=getDeviceInfo i=3', | |
| 247 | `${ESC}[32minfo: craft-bridge dispatch t=mobile a=clipboardWrite i=4${ESC}[0m`, | |
| 248 | 'info: something else entirely', | |
| 249 | ].join('\n') | |
| 250 | ||
| 251 | expect(zigDispatchedActions(text)).toEqual(['clipboardWrite', 'getDeviceInfo']) | |
| 252 | }) | |
| 253 | ||
| 254 | // The reason this counts actions and not lines: every report the page makes | |
| 255 | // goes through craft.log, and log is itself dispatched. A bare count is | |
| 256 | // satisfied by the reporting traffic while Swift serves everything tested. | |
| 257 | it('is not satisfied by the page reporting through craft.log', () => { | |
| 258 | const logOnly = Array.from({ length: 12 }, () => 'info: craft-bridge dispatch t=mobile a=log i=null').join('\n') | |
| 259 | const dispatched = zigDispatchedActions(logOnly) | |
| 260 | ||
| 261 | expect(dispatched).toEqual(['log']) | |
| 262 | expect(ZIG_TESTED_ACTIONS.filter(action => !dispatched.includes(action))).toEqual(ZIG_TESTED_ACTIONS) | |
| 263 | }) | |
| 264 | ||
| 265 | it('is empty for a run the platform shim served alone', () => { | |
| 266 | expect(zigDispatchedActions(iosTranscript())).toEqual([]) | |
| 267 | expect(zigRefusals(iosTranscript())).toEqual([]) | |
| 268 | }) | |
| 269 | ||
| 270 | it('reads the refusal Zig writes through its own capability gate', () => { | |
| 271 | const text = 'info: ios: refusing getCurrentPosition; enableGeolocation is not enabled in craft.config.json' | |
| 272 | expect(zigRefusals(text)).toEqual([ZIG_REFUSED_ACTION]) | |
| 273 | }) | |
| 274 | ||
| 275 | it('expects a refusal for an action the suite actually exercises', () => { | |
| 276 | expect(ZIG_TESTED_ACTIONS).toContain(ZIG_REFUSED_ACTION) | |
| 277 | }) | |
| 278 | }) | |
| @@ -0,0 +1,359 @@ | ||
| 1 | /** | |
| 2 | * The wire between the test page and the harness, and the rules for reading it. | |
| 3 | * | |
| 4 | * Kept separate from the two runners because this is the part that can be | |
| 5 | * tested without a simulator: `scripts/mobile-e2e/protocol.test.ts` feeds it | |
| 6 | * transcripts a device would have produced, including the ones that matter | |
| 7 | * most - a suite that died halfway, a case that never ran, a run with no | |
| 8 | * output at all. The runners stay untested; the reading of their output does | |
| 9 | * not. Same split as scripts/native-lifecycle-plan.ts and its test. | |
| 10 | */ | |
| 11 | ||
| 12 | /** Emitted once, before any case runs, so a truncated run is detectable. */ | |
| 13 | export interface PlanEvent { | |
| 14 | event: 'plan' | |
| 15 | platform: string | |
| 16 | cases: string[] | |
| 17 | /** This run's nonce, so a stale transcript cannot be mistaken for this one. */ | |
| 18 | run?: string | |
| 19 | } | |
| 20 | ||
| 21 | export interface CaseEvent { | |
| 22 | event: 'case' | |
| 23 | name: string | |
| 24 | status: 'pass' | 'fail' | |
| 25 | /** Empty on pass; on failure, what the page actually saw. */ | |
| 26 | detail?: string | |
| 27 | } | |
| 28 | ||
| 29 | export interface DoneEvent { | |
| 30 | event: 'done' | |
| 31 | passed: number | |
| 32 | failed: number | |
| 33 | } | |
| 34 | ||
| 35 | export interface FatalEvent { | |
| 36 | event: 'fatal' | |
| 37 | reason: string | |
| 38 | } | |
| 39 | ||
| 40 | export interface ObservedEvent { | |
| 41 | event: 'observed' | |
| 42 | name: string | |
| 43 | value: unknown | |
| 44 | } | |
| 45 | ||
| 46 | export type DriverEvent = PlanEvent | CaseEvent | DoneEvent | FatalEvent | ObservedEvent | |
| 47 | ||
| 48 | export type MobilePlatform = 'ios' | 'android' | |
| 49 | ||
| 50 | /** | |
| 51 | * The cases each platform must run, named here and nowhere else. | |
| 52 | * | |
| 53 | * The page decides what a case *asserts*; this decides which ones have to | |
| 54 | * exist. Both halves are needed: without the page there is nothing to assert, | |
| 55 | * and without this list a suite can be hollowed out to one trivially passing | |
| 56 | * case and still report green - which is the failure mode the workflow this | |
| 57 | * replaces actually had, for months, with `if: hashFiles(...)` guards that | |
| 58 | * skipped every real step. | |
| 59 | * | |
| 60 | * Each platform's list must contain at least one success path and one | |
| 61 | * rejection path; `requiredCaseProblems` enforces that rather than trusting | |
| 62 | * whoever edits it next. | |
| 63 | */ | |
| 64 | export const REQUIRED_CASES: Record<MobilePlatform, string[]> = { | |
| 65 | ios: [ | |
| 66 | 'bridge.ready', | |
| 67 | 'deviceInfo.isSimulator', | |
| 68 | 'clipboard.roundTrip', | |
| 69 | 'geolocation.disabled.rejects', | |
| 70 | ], | |
| 71 | android: [ | |
| 72 | 'bridge.ready', | |
| 73 | 'deviceInfo.isEmulator', | |
| 74 | 'clipboard.roundTrip', | |
| 75 | 'push.disabled.rejects', | |
| 76 | ], | |
| 77 | } | |
| 78 | ||
| 79 | /** Cases whose whole point is that the native side *refuses*. */ | |
| 80 | const REJECTION_SUFFIX = '.rejects' | |
| 81 | ||
| 82 | /** | |
| 83 | * Guard the list above against being watered down. | |
| 84 | * | |
| 85 | * A platform that lost its rejection case would still look like a passing | |
| 86 | * suite - every remaining case is a success path, and success paths are the | |
| 87 | * easy half. Run from the protocol test. | |
| 88 | */ | |
| 89 | export function requiredCaseProblems(): string[] { | |
| 90 | const problems: string[] = [] | |
| 91 | ||
| 92 | for (const [platform, names] of Object.entries(REQUIRED_CASES)) { | |
| 93 | if (!names.some(name => name.endsWith(REJECTION_SUFFIX))) | |
| 94 | problems.push(`${platform} has no ${REJECTION_SUFFIX} case; a suite of success paths only proves half the bridge`) | |
| 95 | ||
| 96 | if (!names.some(name => !name.endsWith(REJECTION_SUFFIX) && name !== 'bridge.ready')) | |
| 97 | problems.push(`${platform} has no success case beyond bridge.ready`) | |
| 98 | ||
| 99 | const duplicated = names.filter((name, index) => names.indexOf(name) !== index) | |
| 100 | if (duplicated.length) | |
| 101 | problems.push(`${platform} lists ${duplicated.join(', ')} more than once`) | |
| 102 | } | |
| 103 | ||
| 104 | return problems | |
| 105 | } | |
| 106 | ||
| 107 | const MARKER = 'CRAFT-E2E ' | |
| 108 | ||
| 109 | /** | |
| 110 | * Colour codes the iOS console adds, stripped before matching. | |
| 111 | * | |
| 112 | * Built from a char code rather than written as an escape so this file stays | |
| 113 | * free of literal control characters. | |
| 114 | */ | |
| 115 | const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g') | |
| 116 | ||
| 117 | /** | |
| 118 | * The first complete JSON object in `text`, or null. | |
| 119 | * | |
| 120 | * Taking the rest of the line would be enough on iOS, where the console shows | |
| 121 | * exactly what the app printed. It is not enough on Android: the page's | |
| 122 | * `console.log` reaches logcat through Chromium, which wraps it - | |
| 123 | * | |
| 124 | * [INFO:CONSOLE(1)] "CRAFT-E2E {...}", source: https://appassets... (1) | |
| 125 | * | |
| 126 | * - so the rest of the line carries a trailing quote and a source reference. | |
| 127 | * Scanning to the matching brace reads the event out of either shape, while | |
| 128 | * still rejecting a line that was genuinely truncated. | |
| 129 | */ | |
| 130 | function firstJsonObject(text: string): string | null { | |
| 131 | const start = text.indexOf('{') | |
| 132 | if (start === -1) return null | |
| 133 | ||
| 134 | let depth = 0 | |
| 135 | let inString = false | |
| 136 | let escaped = false | |
| 137 | ||
| 138 | for (let index = start; index < text.length; index++) { | |
| 139 | const character = text[index]! | |
| 140 | ||
| 141 | if (escaped) { escaped = false; continue } | |
| 142 | if (character === '\\' && inString) { escaped = true; continue } | |
| 143 | if (character === '"') { inString = !inString; continue } | |
| 144 | if (inString) continue | |
| 145 | ||
| 146 | if (character === '{') depth++ | |
| 147 | else if (character === '}') { | |
| 148 | depth-- | |
| 149 | if (depth === 0) return text.slice(start, index + 1) | |
| 150 | } | |
| 151 | } | |
| 152 | ||
| 153 | return null | |
| 154 | } | |
| 155 | ||
| 156 | /** | |
| 157 | * Pull the driver's events out of a device log. | |
| 158 | * | |
| 159 | * The log is not ours: on iOS it is the app's whole stderr as | |
| 160 | * `simctl launch --console-pty` saw it, on Android a logcat slice, both | |
| 161 | * carrying unrelated system chatter and, on iOS, ANSI colouring. So lines are | |
| 162 | * found by marker rather than by position, colour codes are stripped first, | |
| 163 | * and a marker line with no readable event is reported rather than skipped - | |
| 164 | * a garbled event is evidence of a problem, not something to shrug at. | |
| 165 | * | |
| 166 | * The page emits each event twice on purpose, through `console.log` and | |
| 167 | * through the bridge's own `log` action, so that a run whose bridge never | |
| 168 | * arrived can still say so. Only one of those reaches the log on iOS; both do | |
| 169 | * on Android. Duplicates are dropped here rather than being left for | |
| 170 | * `evaluateRun` to trip over. | |
| 171 | */ | |
| 172 | export function parseDriverOutput(text: string): { events: DriverEvent[], malformed: string[] } { | |
| 173 | const events: DriverEvent[] = [] | |
| 174 | const malformed: string[] = [] | |
| 175 | const seen = new Set<string>() | |
| 176 | ||
| 177 | for (const rawLine of text.split('\n')) { | |
| 178 | const line = rawLine.replace(ANSI, '').replace(/\r$/, '') | |
| 179 | const at = line.indexOf(MARKER) | |
| 180 | if (at === -1) continue | |
| 181 | ||
| 182 | const rest = line.slice(at + MARKER.length).trim() | |
| 183 | const payload = firstJsonObject(rest) | |
| 184 | if (payload === null) { | |
| 185 | malformed.push(rest) | |
| 186 | continue | |
| 187 | } | |
| 188 | ||
| 189 | let parsed: DriverEvent | |
| 190 | try { | |
| 191 | parsed = JSON.parse(payload) as DriverEvent | |
| 192 | } | |
| 193 | catch { | |
| 194 | malformed.push(payload) | |
| 195 | continue | |
| 196 | } | |
| 197 | ||
| 198 | if (!parsed || typeof parsed !== 'object' || typeof parsed.event !== 'string') { | |
| 199 | malformed.push(payload) | |
| 200 | continue | |
| 201 | } | |
| 202 | ||
| 203 | if (seen.has(payload)) continue | |
| 204 | seen.add(payload) | |
| 205 | events.push(parsed) | |
| 206 | } | |
| 207 | ||
| 208 | return { events, malformed } | |
| 209 | } | |
| 210 | ||
| 211 | /** | |
| 212 | * Has the suite finished, as a complete event rather than a substring? | |
| 213 | * | |
| 214 | * The callers kill the app the moment this is true, so matching `"event":"done"` | |
| 215 | * in a half-written line would truncate the very event being waited for, and | |
| 216 | * the truncation would then be reported as a malformed event - a passing run | |
| 217 | * failing on a race. Requiring the object to parse means the line is whole. | |
| 218 | */ | |
| 219 | export function hasTerminated(text: string): boolean { | |
| 220 | return parseDriverOutput(text).events.some(event => event.event === 'done' || event.event === 'fatal') | |
| 221 | } | |
| 222 | ||
| 223 | export interface RunVerdict { | |
| 224 | ok: boolean | |
| 225 | /** One line per reason the run is not a pass. Empty when ok. */ | |
| 226 | failures: string[] | |
| 227 | planned: string[] | |
| 228 | passed: string[] | |
| 229 | failed: string[] | |
| 230 | } | |
| 231 | ||
| 232 | /** | |
| 233 | * Decide whether a captured run is a pass. | |
| 234 | * | |
| 235 | * Silence is the thing being guarded against. A run that produced no output | |
| 236 | * at all, or announced a plan and then died, or ran every case it planned but | |
| 237 | * planned only one - each of those reads as "nothing went wrong" to a naive | |
| 238 | * reader, and each is a failure here. | |
| 239 | */ | |
| 240 | export function evaluateRun(platform: MobilePlatform, text: string, expectedRun?: string): RunVerdict { | |
| 241 | const { events, malformed } = parseDriverOutput(text) | |
| 242 | const failures: string[] = [] | |
| 243 | ||
| 244 | for (const line of malformed) | |
| 245 | failures.push(`driver emitted an unparseable event: ${line}`) | |
| 246 | ||
| 247 | const fatal = events.find((event): event is FatalEvent => event.event === 'fatal') | |
| 248 | if (fatal) failures.push(`driver reported a fatal condition: ${fatal.reason}`) | |
| 249 | ||
| 250 | const plan = events.find((event): event is PlanEvent => event.event === 'plan') | |
| 251 | const cases = events.filter((event): event is CaseEvent => event.event === 'case') | |
| 252 | const done = events.find((event): event is DoneEvent => event.event === 'done') | |
| 253 | ||
| 254 | const planned = plan?.cases ?? [] | |
| 255 | const passed = cases.filter(entry => entry.status === 'pass').map(entry => entry.name) | |
| 256 | const failed = cases.filter(entry => entry.status === 'fail').map(entry => entry.name) | |
| 257 | ||
| 258 | if (!plan) { | |
| 259 | // No plan means the page never reached the bridge - the app crashed, the | |
| 260 | // page never loaded, or the bridge refused it. Everything below would be | |
| 261 | // vacuously true, so say this and stop adding noise. | |
| 262 | failures.push('the test page never announced a plan; it did not reach the native bridge') | |
| 263 | return { ok: false, failures, planned, passed, failed } | |
| 264 | } | |
| 265 | ||
| 266 | if (plan.platform !== platform) | |
| 267 | failures.push(`the app reports platform ${plan.platform}, expected ${platform}`) | |
| 268 | ||
| 269 | // Android reads its transcript out of the device's logcat ring buffer, which | |
| 270 | // survives an uninstall and is cleared only on a best-effort basis. Without | |
| 271 | // this, a leg whose app printed nothing at all could be evaluated against — | |
| 272 | // and pass on — the previous run's output still sitting in the buffer. | |
| 273 | if (expectedRun !== undefined && plan.run !== expectedRun) | |
| 274 | failures.push(`the transcript is from run ${JSON.stringify(plan.run)}, not ${JSON.stringify(expectedRun)}; this is stale output, not this run's`) | |
| 275 | ||
| 276 | for (const required of REQUIRED_CASES[platform]) { | |
| 277 | if (!planned.includes(required)) | |
| 278 | failures.push(`required case ${required} is not in the suite the page ran`) | |
| 279 | } | |
| 280 | ||
| 281 | for (const name of planned) { | |
| 282 | if (!cases.some(entry => entry.name === name)) | |
| 283 | failures.push(`case ${name} was planned but never reported; the suite stopped early`) | |
| 284 | } | |
| 285 | ||
| 286 | for (const entry of cases) { | |
| 287 | if (entry.status === 'fail') | |
| 288 | failures.push(`case ${entry.name} failed: ${entry.detail || 'no detail'}`) | |
| 289 | // Neither a pass nor a fail is not a third outcome to be tolerated. A case | |
| 290 | // reporting anything else satisfies "was planned and did report" while | |
| 291 | // never appearing in `passed`, which would leave it unasserted inside a | |
| 292 | // green run - the precise shape of failure this harness exists to remove. | |
| 293 | else if (entry.status !== 'pass') | |
| 294 | failures.push(`case ${entry.name} reported status ${JSON.stringify(entry.status)}, which is neither pass nor fail`) | |
| 295 | } | |
| 296 | ||
| 297 | if (!done) | |
| 298 | failures.push('the suite never reported done') | |
| 299 | else if (done.failed !== failed.length || done.passed !== passed.length) | |
| 300 | failures.push(`done says ${done.passed} passed / ${done.failed} failed, transcript shows ${passed.length} / ${failed.length}`) | |
| 301 | ||
| 302 | return { ok: failures.length === 0, failures, planned, passed, failed } | |
| 303 | } | |
| 304 | ||
| 305 | /** | |
| 306 | * The actions the Zig dispatcher saw, by name. | |
| 307 | * | |
| 308 | * Zig and the platform shim answer the same actions with the same shapes - | |
| 309 | * that is the point of the hand-off design - so the only way to tell them | |
| 310 | * apart from outside is what Zig's own logger writes | |
| 311 | * (`packages/zig/src/ios_dispatch.zig`). With no archive linked, `dlsym` | |
| 312 | * misses and none of this is emitted at all, which is what separates the two | |
| 313 | * iOS legs. | |
| 314 | * | |
| 315 | * By name rather than by count, because the page routes every report through | |
| 316 | * `craft.log`, and `log` is itself a dispatched action: a bare count is | |
| 317 | * satisfied by the reporting traffic alone and would pass while Swift served | |
| 318 | * every capability under test. | |
| 319 | */ | |
| 320 | export function zigDispatchedActions(text: string): string[] { | |
| 321 | const plain = text.replace(ANSI, '') | |
| 322 | const seen = new Set<string>() | |
| 323 | for (const match of plain.matchAll(/craft-bridge dispatch t=\w+ a=(\w+)/g)) seen.add(match[1]!) | |
| 324 | return [...seen].sort() | |
| 325 | } | |
| 326 | ||
| 327 | /** | |
| 328 | * The actions Zig refused through its own capability gate. | |
| 329 | * | |
| 330 | * A dispatch line proves Zig was *offered* an action - it is logged on entry, | |
| 331 | * before the decision. This proves Zig *answered* one: `ios_config`'s gate | |
| 332 | * writes the line, and the rejection the page then sees came off Zig's error | |
| 333 | * route rather than Swift's. | |
| 334 | */ | |
| 335 | export function zigRefusals(text: string): string[] { | |
| 336 | const plain = text.replace(ANSI, '') | |
| 337 | const seen = new Set<string>() | |
| 338 | for (const match of plain.matchAll(/ios: refusing (\w+);/g)) seen.add(match[1]!) | |
| 339 | return [...seen].sort() | |
| 340 | } | |
| 341 | ||
| 342 | /** | |
| 343 | * The capability actions the page exercises, which the runtime leg expects to | |
| 344 | * see Zig dispatch. | |
| 345 | * | |
| 346 | * Kept in step with the cases in driver.html by hand. Derived from the page it | |
| 347 | * would have to be parsed out of HTML; named here it is at least checkable, | |
| 348 | * and a case removed from the page fails the leg loudly rather than quietly | |
| 349 | * reducing what the assertion covers. | |
| 350 | */ | |
| 351 | export const ZIG_TESTED_ACTIONS = [ | |
| 352 | 'clipboardRead', | |
| 353 | 'clipboardWrite', | |
| 354 | 'getCurrentPosition', | |
| 355 | 'getDeviceInfo', | |
| 356 | ] | |
| 357 | ||
| 358 | /** The one action the suite configures off, so Zig must refuse it by name. */ | |
| 359 | export const ZIG_REFUSED_ACTION = 'getCurrentPosition' | |
| @@ -0,0 +1,126 @@ | ||
| 1 | import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs' | |
| 2 | import { join } from 'node:path' | |
| 3 | ||
| 4 | const HERE = import.meta.dir | |
| 5 | ||
| 6 | export interface CommandOptions { | |
| 7 | cwd?: string | |
| 8 | /** Append the command line and its output here, for the evidence upload. */ | |
| 9 | logPath?: string | |
| 10 | /** Record the failure and carry on, for teardown steps whose failure is not the story. */ | |
| 11 | allowFailure?: boolean | |
| 12 | stdin?: string | |
| 13 | /** | |
| 14 | * Write raw stdout bytes here instead of returning them as text. | |
| 15 | * | |
| 16 | * `adb exec-out screencap -p` emits a PNG; decoding that as UTF-8 to hand it | |
| 17 | * back as a string is how a screenshot becomes an unopenable file. | |
| 18 | */ | |
| 19 | outPath?: string | |
| 20 | } | |
| 21 | ||
| 22 | export interface CommandResult { | |
| 23 | exitCode: number | |
| 24 | stdout: string | |
| 25 | stderr: string | |
| 26 | } | |
| 27 | ||
| 28 | /** | |
| 29 | * Run a command, keep its output, and fail loudly by default. | |
| 30 | * | |
| 31 | * Everything a leg does to a device goes through here so that the evidence | |
| 32 | * upload contains the actual command lines. A failing `xcodebuild` whose log | |
| 33 | * is three screens of Swift diagnostics is useless as an exception message and | |
| 34 | * invaluable as a file. | |
| 35 | */ | |
| 36 | export async function command(argv: string[], options: CommandOptions = {}): Promise<CommandResult> { | |
| 37 | const spawned = Bun.spawn(argv, { | |
| 38 | cwd: options.cwd, | |
| 39 | stdin: options.stdin === undefined ? 'ignore' : new TextEncoder().encode(options.stdin), | |
| 40 | stdout: 'pipe', | |
| 41 | stderr: 'pipe', | |
| 42 | }) | |
| 43 | ||
| 44 | const [raw, stderr] = await Promise.all([ | |
| 45 | new Response(spawned.stdout).arrayBuffer(), | |
| 46 | new Response(spawned.stderr).text(), | |
| 47 | ]) | |
| 48 | const exitCode = await spawned.exited | |
| 49 | ||
| 50 | if (options.outPath) writeFileSync(options.outPath, new Uint8Array(raw)) | |
| 51 | const stdout = options.outPath ? '' : new TextDecoder().decode(raw) | |
| 52 | ||
| 53 | if (options.logPath) { | |
| 54 | appendFileSync(options.logPath, [ | |
| 55 | `$ ${argv.join(' ')}`, | |
| 56 | options.cwd ? `# cwd ${options.cwd}` : '', | |
| 57 | stdout, | |
| 58 | stderr, | |
| 59 | `# exit ${exitCode}`, | |
| 60 | '', | |
| 61 | ].filter(Boolean).join('\n')) | |
| 62 | } | |
| 63 | ||
| 64 | if (exitCode !== 0 && !options.allowFailure) { | |
| 65 | const tail = `${stdout}\n${stderr}`.trim().split('\n').slice(-25).join('\n') | |
| 66 | throw new Error(`${argv.join(' ')} exited with ${exitCode}\n${tail}`) | |
| 67 | } | |
| 68 | ||
| 69 | return { exitCode, stdout, stderr } | |
| 70 | } | |
| 71 | ||
| 72 | /** | |
| 73 | * Wait for a file to say what we are waiting for. | |
| 74 | * | |
| 75 | * `refresh` exists because the two platforms deliver output differently: iOS | |
| 76 | * streams the app's stderr straight into the file, so there is nothing to do | |
| 77 | * between polls, while Android has to be asked - `adb logcat -d` dumps and | |
| 78 | * exits. Streaming logcat instead would mean killing it at exactly the right | |
| 79 | * moment, and the dump costs nothing. | |
| 80 | * | |
| 81 | * Returns false on the deadline rather than throwing, because a run that | |
| 82 | * produced no terminating event still has a log worth keeping and a screenshot | |
| 83 | * worth taking - and the caller does both before it decides anything. Throwing | |
| 84 | * here would skip the evidence for the failure that most needs it. | |
| 85 | */ | |
| 86 | export async function waitForFile( | |
| 87 | path: string, | |
| 88 | timeoutMs: number, | |
| 89 | done: (text: string) => boolean, | |
| 90 | refresh?: () => Promise<void>, | |
| 91 | ): Promise<boolean> { | |
| 92 | const deadline = Date.now() + timeoutMs | |
| 93 | ||
| 94 | while (Date.now() < deadline) { | |
| 95 | if (refresh) await refresh() | |
| 96 | if (existsSync(path)) { | |
| 97 | const text = readFileSync(path, 'utf8') | |
| 98 | if (done(text)) return true | |
| 99 | } | |
| 100 | await Bun.sleep(refresh ? 1000 : 250) | |
| 101 | } | |
| 102 | ||
| 103 | return false | |
| 104 | } | |
| 105 | ||
| 106 | /** | |
| 107 | * The test page, with this run's nonce baked in. | |
| 108 | * | |
| 109 | * One page serves both platforms: `window.craft` is injected by each runtime | |
| 110 | * with the same shape, so the suite that runs on a simulator is the same file | |
| 111 | * that runs on an emulator, and a case can only diverge where it says it does. | |
| 112 | */ | |
| 113 | export function driverPage(nonce: string, platform: string): string { | |
| 114 | const template = readFileSync(join(HERE, 'driver.html'), 'utf8') | |
| 115 | ||
| 116 | // Both placeholders checked, because a silently unsubstituted one turns its | |
| 117 | // case into an assertion about a constant the page chose for itself. | |
| 118 | for (const placeholder of ['__CRAFT_E2E_NONCE__', '__CRAFT_E2E_PLATFORM__']) { | |
| 119 | if (!template.includes(placeholder)) | |
| 120 | throw new Error(`driver.html no longer carries the ${placeholder} placeholder`) | |
| 121 | } | |
| 122 | ||
| 123 | return template | |
| 124 | .replaceAll('__CRAFT_E2E_NONCE__', nonce) | |
| 125 | .replaceAll('__CRAFT_E2E_PLATFORM__', platform) | |
| 126 | } | |
| @@ -0,0 +1,30 @@ | ||
| 1 | export interface RunnerOptions { | |
| 2 | /** Repository root, used to shorten evidence paths in the report. */ | |
| 3 | root: string | |
| 4 | /** artifacts/mobile-e2e/<label>, created before any leg runs. */ | |
| 5 | evidenceDir: string | |
| 6 | /** Scratch space for generated projects; kept under the evidence dir in CI. */ | |
| 7 | workDir: string | |
| 8 | /** How long one app may run before its leg is called a failure. */ | |
| 9 | timeoutMs: number | |
| 10 | /** Distinguishes this run's clipboard nonce from a stale one. */ | |
| 11 | runId: string | |
| 12 | /** Directory holding the Zig iOS simulator archives. */ | |
| 13 | iosRuntimeDir: string | null | |
| 14 | /** Directory holding the Zig Android `<abi>/libcraft.so` tree. */ | |
| 15 | androidRuntimeDir: string | null | |
| 16 | } | |
| 17 | ||
| 18 | export interface LegOutcome { | |
| 19 | name: string | |
| 20 | status: 'passed' | 'failed' | |
| 21 | /** Empty on a pass. Each entry is one reason, in the words of what was seen. */ | |
| 22 | failures: string[] | |
| 23 | planned: string[] | |
| 24 | passed: string[] | |
| 25 | failed: string[] | |
| 26 | /** Which actions reached the Zig dispatcher, i.e. which side answered. */ | |
| 27 | zigActions: string[] | |
| 28 | /** Repo-relative path to this leg's evidence. */ | |
| 29 | evidence: string | |
| 30 | } | |