diff --git a/.github/workflows/e2e-expo.yml b/.github/workflows/e2e-expo.yml new file mode 100644 index 000000000..14dd815d4 --- /dev/null +++ b/.github/workflows/e2e-expo.yml @@ -0,0 +1,270 @@ +name: e2e (Expo) + +on: + pull_request: + paths-ignore: + - 'website/**' + - '**/*.md' + push: + branches: [main] + paths-ignore: + - 'website/**' + - '**/*.md' + workflow_dispatch: + +# one run per PR; a new push cancels the older one +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name != 'push' }} + +# keep it read-only +permissions: + contents: read + +env: + E2E_TELEMETRY_DISABLED: '1' + +jobs: + android: + name: e2e-expo (Android) + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # tag=v4.2.2 + + - name: Setup workspace + uses: ./.github/actions/setup-pnpm-node + with: + node-version: '24.22.1' + + - name: Setup Java + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # tag=v6.0.1 + with: + distribution: temurin + java-version: '17' + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Build workspace packages + run: pnpm --filter tester-expo... --filter tester-expo-widget... --filter tester-federation-v2... build + + # android/ is generated, never committed + - name: Generate native project + run: pnpm --filter tester-expo exec expo prebuild --platform android --no-install + + # Gradle dependencies come from the Actions cache, not Maven Central (it sometimes answers 404 + # for artifacts that exist). After prebuild: the key hashes files in android/ + - name: Restore Gradle cache + id: gradle-cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # tag=v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-expo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'apps/tester-expo/android/**/*.gradle', 'apps/tester-expo/android/gradle.properties', 'apps/tester-expo/android/gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: gradle-expo-${{ runner.os }}- + + # nohup/disown: the runner kills step processes at the end + - name: Start dev servers + run: | + nohup pnpm --filter tester-expo repack:start > /tmp/host.log 2>&1 & + disown + nohup pnpm --filter tester-expo-widget repack:start:remote > /tmp/expo-widget.log 2>&1 & + disown + nohup pnpm --filter tester-federation-v2 start:expo-host-widget > /tmp/ordinary-widget.log 2>&1 & + disown + timeout 180 sh -c 'until [ "$(curl -sf localhost:8081/status)" = "packager-status:running" ] && [ "$(curl -sf localhost:8082/status)" = "packager-status:running" ] && [ "$(curl -sf localhost:8083/status)" = "packager-status:running" ]; do sleep 2; done' + + # ask for the bundles now so the servers compile them — they only build on first request + - name: Warm all compilers + run: | + curl -sf --max-time 300 -o /dev/null 'http://localhost:8081/index.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.callstack.repackexpo&modulesOnly=false&runModule=true' + curl -sf --max-time 300 -o /dev/null 'http://localhost:8082/android/mf-manifest.json' + curl -sf --max-time 300 -o /dev/null 'http://localhost:8083/android/mf-manifest.json' + echo 'All three served; compilers are warm.' + + - name: Check shared versions are real semver + run: | + for p in 8081 8082; do + M=$(curl -sf --retry 3 --retry-delay 2 "http://localhost:$p/android/mf-manifest.json") || { echo "::error::port $p: mf-manifest.json not served"; exit 1; } + [ "$(jq '.shared | length' <<<"$M")" -gt 0 ] || { echo "::error::port $p: manifest has no shared entries"; exit 1; } + BAD=$(jq -r '.shared[] | select((.version // "") | test("^[0-9]+\\.[0-9]+\\.[0-9]+") | not) | "\(.name): \(.version)"' <<<"$M") + [ -z "$BAD" ] || { echo "::error::port $p has non-semver shared versions: $BAD"; exit 1; } + done + + - name: Build Android app + working-directory: apps/tester-expo/android + run: ./gradlew :app:assembleDebug -PreactNativeArchitectures=x86_64 + + # stop the daemon and drop lock files so the saved Gradle User Home is consistent + - name: Stop Gradle before caching + if: github.ref == 'refs/heads/main' && steps.gradle-cache.outputs.cache-hit != 'true' + working-directory: apps/tester-expo/android + run: | + ./gradlew --stop + rm -f ~/.gradle/caches/modules-2/modules-2.lock ~/.gradle/caches/modules-2/gc.properties + + - name: Save Gradle cache + if: github.ref == 'refs/heads/main' && steps.gradle-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # tag=v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ steps.gradle-cache.outputs.cache-primary-key }} + + # expose dev servers to the emulator, install the APK, run the tests + - name: Run e2e on an emulator + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # tag=v2.38.0 + with: + api-level: 35 + target: google_apis + arch: x86_64 + profile: pixel_7 + avd-name: e2e + disable-animations: false + working-directory: apps/tester-expo + script: | + adb shell settings put global hide_error_dialogs 1 + for i in 1 2 3; do adb shell "uiautomator dump /sdcard/d.xml >/dev/null && grep -q aerr_ /sdcard/d.xml" && { echo 'ANR dialog on screen, clearing it'; adb shell am force-stop com.google.android.apps.nexuslauncher; }; sleep 2; done + adb reverse tcp:8081 tcp:8081 + adb reverse tcp:8082 tcp:8082 + adb reverse tcp:8083 tcp:8083 + adb reverse --list + adb reverse --list | grep -q 'tcp:8081' || { echo '::error::adb reverse missing for tcp:8081'; exit 1; } + adb reverse --list | grep -q 'tcp:8082' || { echo '::error::adb reverse missing for tcp:8082'; exit 1; } + adb reverse --list | grep -q 'tcp:8083' || { echo '::error::adb reverse missing for tcp:8083'; exit 1; } + adb install -r -g android/app/build/outputs/apk/debug/app-debug.apk || { echo '::error::adb install failed'; exit 1; } + E2E_DEVICE="${ANDROID_SERIAL:-$(adb get-serialno)}" pnpm exec e2e run --target android --reporter list,junit; ec=$?; adb logcat -d -v time > .e2e/logcat.txt 2>&1; exit $ec + + - name: Collect dev server logs + if: ${{ !cancelled() }} + working-directory: apps/tester-expo + run: | + mkdir -p .e2e + cp /tmp/host.log /tmp/expo-widget.log /tmp/ordinary-widget.log .e2e/ + + - name: Upload e2e artifacts + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # tag=v7.0.1 + with: + name: e2e-expo-android + path: apps/tester-expo/.e2e + include-hidden-files: true + if-no-files-found: warn + retention-days: 7 + + - name: Show dev server logs + if: ${{ !cancelled() }} + run: tail -n 80 /tmp/host.log /tmp/expo-widget.log /tmp/ordinary-widget.log + + # main + dispatch only + ios: + name: e2e-expo (iOS) + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + runs-on: macos-15 + timeout-minutes: 45 + # expo-modules-jsi needs Swift 6.2; the image's default Xcode 16.4 has 6.1 + env: + DEVELOPER_DIR: /Applications/Xcode_26.3.app/Contents/Developer + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # tag=v4.2.2 + + - name: Setup workspace + uses: ./.github/actions/setup-pnpm-node + with: + node-version: '24.22.1' + + - name: Build workspace packages + run: pnpm --filter tester-expo... --filter tester-expo-widget... --filter tester-federation-v2... build + + - name: Start dev servers + run: | + nohup pnpm --filter tester-expo repack:start > /tmp/host.log 2>&1 & + disown + nohup pnpm --filter tester-expo-widget repack:start:remote > /tmp/expo-widget.log 2>&1 & + disown + nohup pnpm --filter tester-federation-v2 start:expo-host-widget > /tmp/ordinary-widget.log 2>&1 & + disown + for p in 8081 8082 8083; do curl -sf --retry 90 --retry-delay 2 --retry-all-errors --retry-connrefused -o /dev/null "http://localhost:$p/status"; done + + - name: Warm all compilers + run: | + curl -sf --max-time 300 -o /dev/null 'http://localhost:8081/index.bundle?platform=ios&dev=true&lazy=true&minify=false&app=com.callstack.repackexpo&modulesOnly=false&runModule=true' + curl -sf --max-time 300 -o /dev/null 'http://localhost:8082/ios/mf-manifest.json' + curl -sf --max-time 300 -o /dev/null 'http://localhost:8083/ios/mf-manifest.json' + echo 'All three served; compilers are warm.' + + - name: Check shared versions are real semver + run: | + for p in 8081 8082; do + M=$(curl -sf --retry 3 --retry-delay 2 "http://localhost:$p/ios/mf-manifest.json") || { echo "::error::port $p: mf-manifest.json not served"; exit 1; } + [ "$(jq '.shared | length' <<<"$M")" -gt 0 ] || { echo "::error::port $p: manifest has no shared entries"; exit 1; } + BAD=$(jq -r '.shared[] | select((.version // "") | test("^[0-9]+\\.[0-9]+\\.[0-9]+") | not) | "\(.name): \(.version)"' <<<"$M") + [ -z "$BAD" ] || { echo "::error::port $p has non-semver shared versions: $BAD"; exit 1; } + done + + # SIM_RUNTIME_PREFIX is tied to the Xcode picked in DEVELOPER_DIR + - name: Boot simulator + env: + SIM_RUNTIME_PREFIX: com.apple.CoreSimulator.SimRuntime.iOS-26- + SIM_DEVICE: iPhone 17 Pro + run: | + RT=$(xcrun simctl list runtimes -j | jq -r --arg prefix "$SIM_RUNTIME_PREFIX" '.runtimes | map(select(.isAvailable and (.identifier | startswith($prefix)))) | sort_by(.version) | last | .identifier') + echo "using $RT" + [ "$RT" != "null" ] && [ -n "$RT" ] + xcrun simctl create e2e "$SIM_DEVICE" "$RT" + xcrun simctl boot e2e + xcrun simctl bootstatus e2e -b + + # ios/ is generated, never committed + - name: Generate native project + run: pnpm --filter tester-expo exec expo prebuild --platform ios --no-install + + # CocoaPods needs a UTF-8 locale + - name: Build iOS app + working-directory: apps/tester-expo/ios + env: + LANG: en_US.UTF-8 + LC_ALL: en_US.UTF-8 + run: pod install && xcodebuild -workspace RepackExpoTester.xcworkspace -scheme RepackExpoTester -configuration Debug -destination 'generic/platform=iOS Simulator' -derivedDataPath build CODE_SIGNING_ALLOWED=NO ARCHS=arm64 build + + - name: Run e2e + working-directory: apps/tester-expo + env: + E2E_DEVICE: e2e + run: | + xcrun simctl install booted ios/build/Build/Products/Debug-iphonesimulator/*.app + pnpm exec e2e run --target ios --reporter list,junit + + - name: Collect dev server logs + if: ${{ !cancelled() }} + working-directory: apps/tester-expo + run: | + mkdir -p .e2e + cp /tmp/host.log /tmp/expo-widget.log /tmp/ordinary-widget.log .e2e/ + xcrun simctl spawn booted log show --style compact --last 30m \ + --predicate 'process == "RepackExpoTester"' \ + > .e2e/ios-syslog.txt 2>&1 || true + + - name: Upload e2e artifacts + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # tag=v7.0.1 + with: + name: e2e-expo-ios + path: apps/tester-expo/.e2e + include-hidden-files: true + if-no-files-found: warn + retention-days: 7 + + - name: Show dev server logs + if: ${{ !cancelled() }} + run: tail -n 80 /tmp/host.log /tmp/expo-widget.log /tmp/ordinary-widget.log diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 85561c188..49d17917b 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -2,18 +2,14 @@ name: e2e on: pull_request: - paths: - - 'apps/tester-federation-v2/**' - - 'packages/**' - - 'pnpm-lock.yaml' - - '.github/workflows/e2e.yml' + paths-ignore: + - 'website/**' + - '**/*.md' push: branches: [main] - paths: - - 'apps/tester-federation-v2/**' - - 'packages/**' - - 'pnpm-lock.yaml' - - '.github/workflows/e2e.yml' + paths-ignore: + - 'website/**' + - '**/*.md' workflow_dispatch: # one run per PR; a new push cancels the older one @@ -129,6 +125,7 @@ jobs: arch: x86_64 profile: pixel_7 avd-name: e2e + disable-animations: false working-directory: apps/tester-federation-v2 script: | adb shell settings put global hide_error_dialogs 1 diff --git a/agent_context/README.md b/agent_context/README.md index 35109711a..73f7e5bee 100644 --- a/agent_context/README.md +++ b/agent_context/README.md @@ -9,3 +9,4 @@ works, and is kept in sync with the implementation as it evolves. | Folder | Topic | | --- | --- | | [rspackv2-jul2026](./rspackv2-jul2026/design.md) | Dual Rspack 1.x/2.x support | +| [e2e-oct2026](./e2e-oct2026/design.md) | e2e tests on an emulator, on CI | diff --git a/agent_context/e2e-oct2026/design.md b/agent_context/e2e-oct2026/design.md new file mode 100644 index 000000000..0977a5554 --- /dev/null +++ b/agent_context/e2e-oct2026/design.md @@ -0,0 +1,410 @@ +# e2e tests on an emulator + +End-to-end tests that run the Re.Pack tester apps on a real emulator or +simulator, so a Re.Pack bundling or runtime bug is caught before merge instead +of inside a consumer app. What the current form optimizes for: a red run means +a real problem rather than a flake. That goal shows up below as concrete rules, +not intentions. + +Two lanes, one app each. They share the tooling, the test conventions and the +trigger rule; the rest is per lane. + +| Lane | App | Workflow | +| --- | --- | --- | +| [MF2](#mf2-lane) | `apps/tester-federation-v2` | `.github/workflows/e2e.yml` | +| [Expo](#expo-lane) | `apps/tester-expo` | `.github/workflows/e2e-expo.yml` | + +Both lanes use ports 8081 and 8082, so they never run on one machine at the +same time. + +Tooling: [`tester-army/e2e`](https://e2e.tester.army/docs) with +`@e2e-dev/mobile`, built on +[agent-device](https://github.com/callstack/agent-device). Chosen because +deterministic tests need no model credential, which is what lets a fork PR run +the suite at all. Both packages are pinned exact — pre-1.0, APIs move between +minors. Both are pinned without a caret in the `testers` catalog of +`pnpm-workspace.yaml`, so the app manifest alone does not show the pin. + +## Shared by both lanes + +### Test conventions + +- **Screens report values, tests hold the expectation.** `` renders `mode: Inlined` on one `Text` that carries the + testID; the test compares it against what the build should have produced. + Never print `ok`, and never hardcode a label the feature could contradict. +- **No global-counter asserts.** Any background load bumps a global counter, so + `≥ 1` passes whatever the feature under test does. Counters live only on the + Runtime Events screen, where they are the subject; elsewhere the proof of a + load is the loaded content appearing. +- **No branching on app state inside tests.** A loop over a fixed list of named + rows (the event reports in `runtime-events`) is fine; an `if` on what the app + currently shows is not — it hides which branch actually ran. +- **Timeouts: defaults first.** Test 120 s, assertion 5 s, `scrollUntilVisible` + 30 s. An extension needs a named physical cause and belongs at the assert — a + test-level number hides which step stalled. The overrides: + **30 s on the boot anchor (`home-screen` after `app.open()`) in every file** — + `app.open()` returns when the window draws, not when the first frame renders; + after that the app still downloads and evaluates the whole dev bundle, which + on a cold process takes over 10 s on the CI Android emulator (an emulated + device sharing a small runner with the dev servers) and only a few seconds on + the iOS simulator, which runs natively on the host CPU. A test that follows a + warm run can pass at 5 s, so the default hides the problem until a run starts + cold; 30 s covers the cold boot at twice the measured worst case. 45 s on + `miniapp-gallery` in `gallery` and `runtime-events`, and in the Expo lane on + `expo-widget-ready` and `ordinary-repack-widget-ready` (each file pays the + manifest + container + chunk chain itself); 30 s on `pipeline: settled` (the + load chain's own ceiling is `Script.DEFAULT_TIMEOUT`). Note rspack compiles + the whole graph in one run: lazy chunks are memory files the moment the main + bundle is served, so chunk asserts are network + execute, never "compile + waits". +- **testIDs:** `home--button`, `-screen`, + `--report`, `--button`. +- **Assert app state, never the absence of a warning.** +- **Break each test once and watch it go red.** Say so in the PR. A test nobody + saw fail proves nothing. + +### Triggers + +Android gates `pull_request`; iOS runs on `push` to `main` and +`workflow_dispatch`. Plain `pull_request` rather than `pull_request_target`: the +job reads no secrets, needs only `contents: read`, and the default merge-ref +checkout means a PR tests its own workflow file. The gate is GitHub's own fork +approval, which is per push. Triggers skip a run only when every changed file +is `website/**` or `**/*.md` (`paths-ignore`) — never an allow-list of folders +that "can" affect the app: root files like `pnpm-workspace.yaml` (`allowBuilds`), +root `package.json` (`packageManager`), `tsconfig.base.json` and +`.github/actions/setup-pnpm-node` change what the app runs without touching the +lockfile, and an allow-list silently skips them. +Fork runs get a read-only cache token from the platform, so they can restore +`main`'s caches but not poison them. + +--- + +## MF2 lane + +### Where things live + +| Path | Role | +| --- | --- | +| `apps/tester-federation-v2/e2e/*.e2e.ts` | One file per feature | +| `apps/tester-federation-v2/e2e.config.ts` | Targets, `retries: 0`, `workers: 1` | +| `apps/tester-federation-v2/src/host/FEATURES.ts` | Registry Home renders; one entry = one button. Routes stay hand-listed in `MainNavigator.tsx`; `screen` is typed against them | +| `apps/tester-federation-v2/src/host/ui/` | `Report.tsx` (the one widget screens use to report a value), `ActionButton.tsx` (the button screens share) | +| `apps/tester-federation-v2/src/host/setup.ts` | The chunk resolver; registers the event counters | +| `apps/tester-federation-v2/src/host/features/runtime/events.ts` | The event counters the tests read | +| `.github/workflows/e2e.yml` | Android job (PRs) + iOS job (main, dispatch) | +| `apps/tester-federation-v2/config.expo-host-widget.mts` | Not part of this lane: builds `OrdinaryWidget` (port 8083) for the Expo lane | + +`e2e`'s default glob is `tests/**/*.e2e.ts`; this app keeps specs under `e2e/`, +so the config sets `tests` explicitly. Output goes to `.e2e/`, gitignored. + +#### Running it locally + +No orchestration script by design — the framework and the developer each already +own booting devices and building apps. Three terminals: + +```bash +# 1. boot an emulator/simulator first — webpack-start only reverses ports for +# devices attached at its start +pnpm start:hostapp # 8081 +pnpm start:miniapp # 8082 +E2E_DEVICE= pnpm exec e2e run --target ios +``` + +`e2e run` without `--target` runs both targets; a file path narrows to one test. +Pin `E2E_DEVICE` — unset, the engine takes every booted device of the platform. +Needs Node `^22.22.3 || >=24.8.0` (CI pins 24.22.1). The app must be installed +(`pnpm android` / `pnpm ios`). + +**Warm the platform bundle before the first run**, on a fresh server: +`curl 'localhost:8081/index.bundle?platform=android&dev=true&lazy=true&…'` (the +iOS job's shape is in the workflow). A cold first launch compiles before it can +boot, that compile eats the `home-screen` anchor's budget, and the failure looks +like a broken app. This bites locally and in CI equally. + +### How the app gets under test + +The app is a Debug build that loads its JavaScript from the two dev servers — +host on 8081, MiniApp remote on 8082 — not from an embedded bundle. `app.json` +points `resources.android` at a directory this app never emits, so there is no +fallback bundle. Three consequences are load-bearing: + +- **Both servers must answer before the app starts.** react-native-test-app + chooses its bundle source once at startup and never retries. If 8081 is down, + Android raises a modal "Unable to find JavaScript bundle" that blocks every + later tap; iOS silently falls back and keeps going. Both read as a test failure + that is really an environment failure — the uploaded `.e2e/` artifacts tell + them apart. +- **Workspace packages build first.** `pnpm --filter tester-federation-v2... + build` is how a PR's Repack change reaches the servers under test. +- **8082 must be reachable as `localhost` from the device.** The remote URL is + baked into the bundle as `http://localhost:8082/…` and Repack's + `__PUBLIC_HOST__` rewrite covers asset public paths and HMR only, never Module + Federation remote URLs. The device needs `adb reverse tcp:8081 tcp:8082`; locally + the servers do this at startup, CI does it explicitly. + +Re.Pack builds a platform lazily, on that platform's first asset request, so +"`/status` answers" has never meant "bundle is built". That is why the workflow +has an explicit warm step: without it the emulator waits on a cold compile after +a tap. + +### What is covered + +One file per feature; every test starts from a fresh app process (`app.open()` +restarts the app), so nothing accumulates between tests. + +| File | What a red run means | Re.Pack surface | +| --- | --- | --- | +| `gallery` | the MiniApp remote fails to install/load/render | MF2 container load over `localhost:8082` | +| `runtime-events` | the resolve/load chain or the hook chain is broken; one test drives it via Gallery, then reads counters + a computed `pipeline: settled` row | all 6 `ScriptManager` events + the 4 non-owning hooks, counted in `features/runtime/events.ts`; each of the 8 driving rows asserts `≥ 1`, `error: 0` is asserted, and `prefetching`/`error` feed the pipeline row | +| `async-chunk` | a host-owned async chunk no longer loads through `ScriptManager` | `import()` + the allowlist resolver. Not `extraChunks`: `OutputPlugin` is off under a dev server and the resolver's `__DEV__` branch sends every chunk to it, so the `local` rule never runs in this Debug lane | +| `remote-chunk` | the prefetch → load cycle broke, or `invalidateScripts` threw | `ScriptManager.prefetchScript` / `invalidateScripts` on host remote chunks. The asserts after invalidate read UI state the screen sets itself, so an invalidate that resolves but clears nothing stays green | +| `miniapp-install` | install / show / hide / uninstall of a host mini-app chunk broke | same APIs, second lifecycle; same invalidate gap as `remote-chunk` | +| `host-assets` | asset rules no longer produce what they claim: inline gate (pic-1 87 KB in, pic-2 119 KB out), folder rules (`localAssets` emitted despite being small, `inlineAssets` inlined), the emitted URI is servable (fetch → 200, port and query string taken from the reported URI, never hardcoded). The `.svg` row asserts nothing, and that is honest: svgr compiles at build time, so a broken rule reddens the whole app at boot, before any assert could run | `assets-loader` (inline gate, per-folder rules), served asset route, `@svgr/webpack` rule | +| `nativewind` | CSS stopped computing: `padding-applied: 16` and `calc-width: 248` are layout numbers, not text checks. The dark-mode round trip reads NativeWind's `useColorScheme()` state, not a resolved `dark:` style, and assumes the device starts in light appearance | `NativeWindPlugin` + the host-scoped JSX retag | +| `reanimated` | the worklet pipeline broke — the width row (`100 → 150 → 200 → 100`) is a shared value driven through `useAnimatedStyle` + `useAnimatedReaction` + `scheduleOnRN` (from `react-native-worklets` — reanimated's `runOnJS` re-export is deprecated), so it only moves if user worklets execute; an untransformed worklet throws at boot | the babel `react-native-worklets/plugin` transform; `ReanimatedPlugin`'s `unstable_disableTransform` only suppresses its own rules, which the new babel-swc-loader makes obsolete | + +Every row on a screen must own a distinct way to fail. That rule deleted the +third host bitmap fixture (`pic_3.jpg`, 58 KB): under the same 90 KB gate as +`pic_1`, so it could only ever fail where `pic_1` already fails. The same rule is +the filter for any future row. + +Navigation is not a subject of its own. The navigator is react-native-screens — +upstream code with upstream tests — and a broken one reddens every test at its +prologue tap anyway. The back-navigation round trip still gets exercised, implied +where a test needs it: `runtime-events` calls `app.back()` and its next step can +only succeed if Home came back. + +### Adding a test + +1. `FEATURES.ts`: one entry. `screen` is typed against the navigator, so a + missing route is a typecheck error. +2. `MainNavigator.tsx`: param-list key + ``. +3. The screen: root `testID="-screen"`, and one `` row per + value, `testID="--report"`, `name` the bare noun. +4. `e2e/.e2e.ts`: copy the prologue verbatim — `app.open()`, assert + `home-screen`, `scrollUntilVisible(entry)` (Home is a ScrollView; a bare tap + on an off-screen row silently misses), `tap`, assert the screen — then only + your asserts. No helpers, deliberately: each file reads standalone. +5. Break it once: invert the feature in the config or the screen, watch exactly + your assert go red, revert. + +### CI + +Steps: setup → build packages → start servers + wait `/status` → warm both +compilers → check manifest shared versions are real semver → build the app → +device { reverse ×2, install, run } → collect logs → artifacts. Lines worth +knowing about: + +| Line | Defends against | +| --- | --- | +| `timeout 180 … until /status` | app launching mid-server-boot → the never-retry bundle alert | +| the two warm `curl`s | a cold compile landing on the device's critical path | +| the semver manifest check | `catalog:` specifiers leaking into manifest shared versions, at the source | +| `adb reverse` ×2, asserted | CI starts servers before the emulator exists, and the server's own reverse does not self-heal | +| `nohup` + `disown` | the runner kills the step's process group at step end | +| install + run inside the emulator action's `script` | the action kills the emulator when the step ends | +| ANR-dialog clear loop before the run | a launcher "isn't responding" dialog swallows every tap; `hide_error_dialogs` does not suppress it | +| `disable-animations: false` | the flag writes `transition_animation_scale` 0.0, which RN and Reanimated both read as "reduce motion on"; the dev warning's LogBox container then eats taps on Home's last row | +| `include-hidden-files: true` on artifacts | `upload-artifact` matching zero files for the dot-dir `.e2e` and still reporting success | +| `if: ${{ !cancelled() }}` on artifacts | losing evidence from a test that failed then passed | +| always tail the server logs | they live outside `.e2e` and are the only evidence that survives a broken upload | + +### Traps + +- **Tapping `ScriptManager.hooks.resolve`/`hooks.load` replaces the built-in + loop**, not decorates it — a counter tap that returns `undefined` makes the + manager resolve nothing. Counters only tap the 4 non-owning hooks; the events + cover what `resolve`/`load` would have. +- **`extraChunks.include` is matched against chunk names and the numeric chunk + id** (`OutputPlugin` flattens both). An unanchored pattern can catch other + chunks whose names contain it, and a near-miss like `/.+local.+/` does not + match `host-async-local` at all, so the chunk silently falls to the catch-all + remote rule. The rule is `/^host-async-local$/`. It only applies to builds + without a dev server; the e2e lane does not exercise it. +- **The ignored LogBox string is load-bearing**: NativeWind's runtime reads RN's + deprecated `ImageBackground` getter at boot + (`react-native-css-interop/components.js`) though no screen uses it, and the + toast covers Home's last row and steals taps. A NativeWind upgrade can change + the wording; the toast reappearing is the symptom. +- **The shared `babel.config.js` serves all three compilations** (host, mini, + expo widget — all set `env.context` to the app root). A host-only JSX retag + goes in `overrides` with a regex (string `test` resolves against the CLI cwd); + adding it at top level retags `src/mini` too and breaks the remote that + `gallery` and `runtime-events` load (`miniapp-install` is a host-owned chunk, + not that remote). The worklets transform must stay top level: + `react-native-worklets` ships compiled code that still carries `'worklet'` + directives, and host-scoping it leaves the boot red. +- **`NativeWindPlugin`'s JSX half is a silent no-op** with + `@callstack/repack/babel-swc-loader` (it only matches rules named + `builtin:swc-loader`); the operative path is babel detecting + `transform-react-jsx` and feeding `importSource` to swc. +- **iOS layout numbers are device-dependent** (393 pt vs 402 pt). Assert a + difference, not a width: `padding-applied: 16` holds everywhere, a raw box + width does not. Exact pixel quality is out of scope entirely — no assertion + worth maintaining. +- **iOS CI pods need Ruby ≥ 3.4** via `ruby/setup-ruby` with `working-directory` + (`gemfile:` is not an input — the action warns, then silently caches nothing). + The floor exists because of a CocoaPods null-byte crash on pnpm-symlinked + `node_modules`; CocoaPods remains intermittent there, `bundle update + cocoapods` is the candidate fix. +- **Native module changes need a rebuild on both platforms**, and iOS may need + `pod install` twice — the first pass can symlink headers before codegen emits + `rnworklets/rnworklets.h`. Same trap as the emulator snapshot: the JS bundle + looks correct while the native side is old. +- **`app.open()` restarts the app** (terminate + relaunch), so module-scope + counters reset per test — event assertions avoid exact totals only because + background loads inside one test also bump them. +- Actions are pinned to SHAs verified against the GitHub API (note + `android-emulator-runner`'s tag is annotated — dereference it). Node matches + `release.yml`. `.github/actions/setup-pnpm-node` is reused, not re-derived. + +--- + +## Expo lane + +Runs `apps/tester-expo`, a real Expo SDK app (expo-router, the +`@callstack/repack-expo` config plugin, `ExpoPlugin`). Only `tester-expo` is +installed on the device; its two Module Federation remotes are dev servers: + +| Port | Server | Built from | +| --- | --- | --- | +| 8081 | `tester-expo repack:start` | the Expo host | +| 8082 | `tester-expo-widget repack:start:remote` | `ExpoWidget`, an Expo-built remote | +| 8083 | `tester-federation-v2 start:expo-host-widget` | `OrdinaryWidget`, a plain Re.Pack remote (no `ExpoPlugin`) | + +### Where things live + +| Path | Role | +| --- | --- | +| `apps/tester-expo/e2e/*.e2e.ts` | One file per feature | +| `apps/tester-expo/e2e.config.ts` | Targets, `retries: 0`, `workers: 1` | +| `apps/tester-expo/FEATURES.ts` | Registry Home renders, in MF2's shape. `href` is a plain string: typed routes are off (they need files only the Expo CLI generates) | +| `apps/tester-expo/ui/` | `Report.tsx`, `ActionButton.tsx` (copies of MF2's), `Screen.tsx` (page layout + root testID), `LoadState.ts` | +| `apps/tester-expo/babel.config.cjs` | Turns off the deep-import warning (see Traps) | +| `.github/workflows/e2e-expo.yml` | Android job (PRs) + iOS job (main, dispatch) | + +#### Running it locally + +```bash +# boot an emulator/simulator first, as for MF2 +pnpm --filter tester-expo repack:start # 8081 +pnpm --filter tester-expo-widget repack:start:remote # 8082 +pnpm --filter tester-federation-v2 start:expo-host-widget # 8083 +E2E_DEVICE= pnpm exec e2e run --target ios # in apps/tester-expo +``` + +The app must be installed (`pnpm repack:android` / `pnpm repack:ios`; `expo +run` generates the native folders, which are not in git). Warm all three +servers first, as in the workflow. + +### What is covered + +| File | What a red run means | Re.Pack surface | +| --- | --- | --- | +| `expo-module` | an Expo native module can't answer a Re.Pack bundle. The row shows `Constants.expoConfig.name`, which only the native build embeds (from `app.json`); `expoVersion` would pass with the module missing | config plugin (prebuild), `ExpoModulesPlugin` | +| `image` | Expo asset rules, the asset registry or monorepo resolution broke; rows are set in `onLoad`, so the bytes were served. The second image lives in `tests/integration/…`, outside the app | `configureExpoAssets`, `configureExpoModuleResolution` | +| `density-image` | the wrong density variant is picked. Both CI devices resolve to 3, so a bug that always picks `@3x` stays green | `configureExpoAssets` (`platform` option) | +| `font` | a bundled font no longer reaches `expo-font` | `configureExpoAssets` (font branch; `configureAndroidFontAssets` is production-only) | +| `lazy` | the host's `.local` async chunk does not load, proven by the chunk's own content | `configureExpoChunkFilename`, `localChunkResolver` (dev-server path) | +| `expo-widget` | the Expo remote: shared `expo-constants`, its own `EXPO_PUBLIC_*` values vs the host's, `expo-asset` metadata, remote asset scale, its nested chunk | MF2 + `ExpoPlugin` remote side, `inlineExpoPublicEnvironment` | +| `ordinary-widget` | an Expo host can no longer load a remote built without `ExpoPlugin` | MF2 interop | +| `fast-refresh` | an edit no longer reaches the app, or it reloads fully (the counter drops to 0). The test edits `components/FastRefreshLeaf.tsx` and restores it in `finally` | dev server HMR, `WebpackHMRClient`, `react-refresh` | +| `symbolication` | a stack frame no longer maps back to `components/SymbolicationProbe.tsx:`; the test reads `` from the file | source maps, `applyRepackSourceMapFix`, the dev server's `/symbolicate` | + +Expected values come from source: the tests read `app.json` and both apps' +`.env.development`. The widgets keep their own testIDs; only the host's screens +use `Report`. Not covered on purpose: Release paths (`repack-expo build`, +packaged `.local` chunks, EAS), the widget as its own app. + +### Adding a test + +As for MF2, with three differences: the route is a file, `app/.tsx` +(expo-router: file path = URL); its title goes in `app/_layout.tsx`; a wrong +`href` in `FEATURES.ts` is not a type error — the prologue's screen assert +catches it. + +### Break-once record + +| File | Break | Red at | +| --- | --- | --- | +| `expo-module` | rename the app in `app.json` without a native rebuild | the app-name row (shows the built-in name) | +| `image` | user rule `{ test: /\.png$/, type: 'asset/resource' }` | `local: .png` instead of `local: repack.png` | +| `density-image` | remove `density@3x.png` | `scale: 2` instead of 3 | +| `font` | user rule `{ test: /\.ttf$/, type: 'asset/resource' }` | `font-load-report` | +| `lazy` | the chunk throws on load | `lazy-component-ready` | +| `expo-widget` | wrong `remote.publicPath` in the widget's asset rules | `expo-widget-ready` | +| `ordinary-widget` | rename `'./Widget'` in `configs/rspack.expo-host-widget.mts` | `ordinary-repack-widget-ready` | +| `fast-refresh` | `devServer: { ...devServer, hot: false }` in the host config | `fast-refresh-leaf-report` keeps the old marker | +| `symbolication` | `devtool: false` in the host config | the frame is the bundle URL, not the source file | + +A user asset rule only replaces `ExpoPlugin`'s when it covers the whole +extension; a rule for one file name (`/logo\.png$/`) is silently ignored. + +### CI + +`e2e-expo.yml` is the MF2 workflow plus `expo prebuild` (native folders are not +in git), a third server and `adb reverse tcp:8083`. The semver check runs on +8081 and 8082 only: `OrdinaryWidget` shares with `requiredVersion: false`, so +8083 says `*` by design. The remotes warm on `mf-manifest.json` — any request +compiles the whole platform. + +The Android job keeps `~/.gradle` in the Actions cache, so Gradle does not +download its dependencies from Maven Central on every run. Central sometimes +answers 404 for artifacts that exist, and Gradle does not retry a 404. Only +`main` runs save the cache; PR runs only restore it. The restore runs after +`expo prebuild`, because the key hashes files in the generated `android/`. The +key prefix is `gradle-expo-`, so after a lockfile change the fallback is an +older Expo copy, not another job's copy that lacks the Expo libraries. + +### Traps + +- **RN 0.88 turns the first warning into a toast** ("Open debugger to view + warnings.") that steals taps. The one warning was babel-preset-expo's + deep-import lint, which fires outside `node_modules`: the linked + `packages/repack` (`WebpackHMRClient.js`) and `SymbolicationProbe.tsx` (RN + has no public export for its helpers) both trip it. `babel.config.cjs` turns + it off — no LogBox filter, so a new warning still shows and a test goes red. +- **A `pnpm install` that re-resolves peers needs fresh dev servers.** A + running server keeps the old `.pnpm/@` paths and mixes copies — + boot dies with "Tried to register two views with the same name + ExpoRouterActivityContents". +- **CocoaPods needs a UTF-8 locale.** Without `LANG=en_US.UTF-8` it crashes + while printing its own error report. The system CocoaPods works; no `Gemfile` + (unlike MF2). +- **The iOS job needs Xcode 26.** `expo-modules-jsi` builds a Swift package + with `swift-tools-version: 6.2`; `macos-15`'s default Xcode 16.4 has Swift + 6.1 and fails with "Could not resolve package dependencies". The job sets + `DEVELOPER_DIR` to Xcode 26.3, which matches local, and boots an iOS 26 simulator. +- **`fast-refresh` is the only test that edits a source file.** A run killed + before `finally` leaves `components/FastRefreshLeaf.tsx` changed — check + `git status` locally. +- **The host config is read once at server start.** A break in + `rspack.config.mjs` needs an 8081 restart, and so does the revert. +- **After any rebuild, the manifest reports `*` for the `react-native/…` + deep-import shares**, and the semver check fails. Only the first compile + after server start has real versions; locally, restart before the check. +- **`asset/source` on font files breaks the whole bundle** ("octals not allowed + in strict mode"). Use `asset/resource` for breaks. + +--- + +## Debugging a red run (both lanes) + +Evidence, in order of usefulness: `.e2e/results//trace.md` (step-by-step, +with the full node dump at failure), `attempt-*/screenshots/`, +`screen-at-failure.txt`, `logcat.txt` / `ios-syslog.txt`, the dev-server logs — +all uploaded as the `e2e-android` / `e2e-ios` artifact (`e2e-expo-*` in the +Expo lane) even when the run dies before tests start. + +| Symptom | Cause | Fix | +| --- | --- | --- | +| `home-screen` never visible, first test of the day | cold dev-server compile eating the boot anchor's budget on first launch | warm the platform bundle ("Running it locally") | +| `home-screen` never visible, screenshot is the status bar / launcher | something owns the screen: ANR dialog, toast | the workflow's ANR clear; locally dismiss and rerun | +| taps do nothing, screen dump says "unchanged" | a toast covers the control — CI screens are ~15 % smaller than devices, so it may not reproduce locally | screenshot proves it; suppress the warning at its source, never with `ignoreAllLogs` | +| everything passes on iOS, fails on Android only | the local emulator is `arm64-v8a`, docs habit is `-PreactNativeArchitectures=x86_64` → `INSTALL_FAILED_NO_MATCHING_ABIS`; or a restored AVD snapshot rolled the APK back | build for `arm64-v8a`; compare `dumpsys package ` `lastUpdateTime` against the APK on disk | +| Gallery hangs, nothing loads | 8082 never compiled, or its reverse is missing | the warm step / `adb reverse --list` | +| `404 to be 200` on the asset fetch | the served-asset route needs the query string (`?platform=…&hash=…`) — path alone 404s | the test keeps `pathname + search`; if the URL shape changed, that is the regression | +| `Property '__X__' doesn't exist` redbox | `DefinePlugin` only applies at server start | restart the server after config edits | +| the engine reads an empty/stale screen tree while `adb shell uiautomator dump` sees everything | the engine's snapshot helper is wedged | restart the emulator; reinstall `com.callstack.agentdevice.*` helpers if it persists | diff --git a/apps/tester-expo/.gitignore b/apps/tester-expo/.gitignore index a486fe0a3..c8cbf4394 100644 --- a/apps/tester-expo/.gitignore +++ b/apps/tester-expo/.gitignore @@ -1,3 +1,6 @@ # Native projects are generated by Expo prebuild and intentionally untracked. /android /ios + +# e2e +.e2e/ diff --git a/apps/tester-expo/FEATURES.ts b/apps/tester-expo/FEATURES.ts new file mode 100644 index 000000000..93be35e78 --- /dev/null +++ b/apps/tester-expo/FEATURES.ts @@ -0,0 +1,60 @@ +import type { Href } from 'expo-router'; + +export const FEATURE_GROUPS = [ + 'Expo modules', + 'Assets', + 'Chunks', + 'Module Federation', + 'Dev loop', +] as const; + +export type FeatureGroup = (typeof FEATURE_GROUPS)[number]; + +export type Feature = { + key: string; + name: string; + group: FeatureGroup; + href: Href; +}; + +export const FEATURES: Feature[] = [ + { + key: 'expo-module', + name: 'Expo module', + group: 'Expo modules', + href: '/details', + }, + { key: 'image', name: 'Image', group: 'Assets', href: '/image' }, + { + key: 'density-image', + name: 'Density image', + group: 'Assets', + href: '/density-image', + }, + { key: 'font', name: 'Font', group: 'Assets', href: '/font' }, + { key: 'lazy', name: 'Lazy chunk', group: 'Chunks', href: '/lazy' }, + { + key: 'expo-widget', + name: 'Expo widget', + group: 'Module Federation', + href: '/widget', + }, + { + key: 'ordinary-widget', + name: 'Ordinary Re.Pack widget', + group: 'Module Federation', + href: '/ordinary-widget', + }, + { + key: 'fast-refresh', + name: 'Fast Refresh', + group: 'Dev loop', + href: '/fast-refresh', + }, + { + key: 'symbolication', + name: 'Symbolication', + group: 'Dev loop', + href: '/symbolication', + }, +]; diff --git a/apps/tester-expo/app/_layout.tsx b/apps/tester-expo/app/_layout.tsx index 624cfe6c7..75d81a809 100644 --- a/apps/tester-expo/app/_layout.tsx +++ b/apps/tester-expo/app/_layout.tsx @@ -1,5 +1,21 @@ import { Stack } from 'expo-router'; export default function RootLayout() { - return ; + return ( + + + + + + + + + + + + + ); } diff --git a/apps/tester-expo/app/density-image.tsx b/apps/tester-expo/app/density-image.tsx index 88fea187a..b1029c48a 100644 --- a/apps/tester-expo/app/density-image.tsx +++ b/apps/tester-expo/app/density-image.tsx @@ -1,55 +1,44 @@ -import { Link } from 'expo-router'; import { useState } from 'react'; -import { Image, PixelRatio, StyleSheet, Text, View } from 'react-native'; +import { Image, PixelRatio, StyleSheet } from 'react-native'; +import type { LoadState } from '../ui/LoadState'; +import Report from '../ui/Report'; +import Screen from '../ui/Screen'; const densityImage = require('../assets/images/density.png'); export default function DensityImageRoute() { const resolvedImage = Image.resolveAssetSource(densityImage); - const [imageLoaded, setImageLoaded] = useState(false); - const [imageError, setImageError] = useState(false); + const [load, setLoad] = useState('loading'); return ( - - {imageLoaded && !imageError ? ( - - Density-aware bundled image loaded - - ) : null} - {imageError ? ( - - Density-aware bundled image failed to load - - ) : null} + + + + setImageError(true)} - onLoad={() => setImageLoaded(true)} + onError={() => setLoad('error')} + onLoad={() => setLoad('loaded')} source={densityImage} style={styles.image} testID="density-image" /> - - Selected density scale: {resolvedImage?.scale} - - - Device density scale: {PixelRatio.get()} - - Back home - + ); } const styles = StyleSheet.create({ - container: { - alignItems: 'center', - flex: 1, - gap: 16, - justifyContent: 'center', - padding: 24, - }, image: { height: 48, + marginHorizontal: 12, width: 48, }, }); diff --git a/apps/tester-expo/app/details.tsx b/apps/tester-expo/app/details.tsx index ddc776e6a..f33be519f 100644 --- a/apps/tester-expo/app/details.tsx +++ b/apps/tester-expo/app/details.tsx @@ -1,24 +1,15 @@ import Constants from 'expo-constants'; -import { Link } from 'expo-router'; -import { StyleSheet, Text, View } from 'react-native'; +import Report from '../ui/Report'; +import Screen from '../ui/Screen'; export default function DetailsRoute() { return ( - - - Expo runtime: {Constants.expoVersion ?? 'development build'} - - Back home - + + + ); } - -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - flex: 1, - gap: 16, - justifyContent: 'center', - padding: 24, - }, -}); diff --git a/apps/tester-expo/app/fast-refresh.tsx b/apps/tester-expo/app/fast-refresh.tsx new file mode 100644 index 000000000..ee7bd0860 --- /dev/null +++ b/apps/tester-expo/app/fast-refresh.tsx @@ -0,0 +1,25 @@ +import { useState } from 'react'; +import FastRefreshLeaf from '../components/FastRefreshLeaf'; +import ActionButton from '../ui/ActionButton'; +import Report from '../ui/Report'; +import Screen from '../ui/Screen'; + +export default function FastRefreshRoute() { + const [count, setCount] = useState(0); + + return ( + + + setCount((value) => value + 1)} + /> + + + ); +} diff --git a/apps/tester-expo/app/font.tsx b/apps/tester-expo/app/font.tsx index 08fe4d71a..390041de8 100644 --- a/apps/tester-expo/app/font.tsx +++ b/apps/tester-expo/app/font.tsx @@ -1,49 +1,35 @@ import { useFonts } from 'expo-font'; -import { Link } from 'expo-router'; -import { StyleSheet, Text, View } from 'react-native'; +import { StyleSheet, Text } from 'react-native'; +import Report from '../ui/Report'; +import Screen from '../ui/Screen'; export default function FontRoute() { const [fontsLoaded, fontError] = useFonts({ ExpoFixture: require('../assets/fonts/material-symbols-regular.ttf'), }); - if (fontError) { - return ( - - Font error: {fontError.message} - - ); - } - - if (!fontsLoaded) { - return ( - - Loading bundled font… - - ); - } + const state = fontError + ? `error: ${fontError.message}` + : fontsLoaded + ? 'loaded' + : 'loading'; return ( - - Bundled font loaded by expo-font: - - home - - Back home - + + + {fontsLoaded ? ( + + home + + ) : null} + ); } const styles = StyleSheet.create({ - container: { - alignItems: 'center', - flex: 1, - gap: 16, - justifyContent: 'center', - padding: 24, - }, fixtureFont: { fontFamily: 'ExpoFixture', fontSize: 32, + paddingHorizontal: 12, }, }); diff --git a/apps/tester-expo/app/image.tsx b/apps/tester-expo/app/image.tsx index 122c16af6..d96ecb3d5 100644 --- a/apps/tester-expo/app/image.tsx +++ b/apps/tester-expo/app/image.tsx @@ -1,68 +1,63 @@ import { Asset } from 'expo-asset'; -import { Link } from 'expo-router'; import { useState } from 'react'; -import { Image, StyleSheet, Text, View } from 'react-native'; +import { Image, StyleSheet } from 'react-native'; +import type { LoadState } from '../ui/LoadState'; +import Report from '../ui/Report'; +import Screen from '../ui/Screen'; const packagedImage = require('../assets/images/repack.png'); const packagedAsset = Asset.fromModule(packagedImage); const monorepoImage = require('../../../tests/integration/src/loaders/__fixtures__/assets/logo.png'); const monorepoAsset = Asset.fromModule(monorepoImage); +const assetLabel = (state: LoadState, asset: Asset) => + state === 'loaded' ? `${asset.name}.${asset.type}` : state; + export default function ImageRoute() { - const [appLocalImageLoaded, setAppLocalImageLoaded] = useState(false); - const [monorepoImageLoaded, setMonorepoImageLoaded] = useState(false); - const [imageError, setImageError] = useState(); - const imagesLoaded = - appLocalImageLoaded && monorepoImageLoaded && !imageError; + const [local, setLocal] = useState('loading'); + const [monorepo, setMonorepo] = useState('loading'); return ( - - {imagesLoaded ? ( - - Expo assets loaded: {packagedAsset.name}.{packagedAsset.type} and{' '} - monorepo {monorepoAsset.name}.{monorepoAsset.type} - - ) : null} - {imageError ? {imageError} : null} + + - setImageError((error) => error ?? 'App-local image failed to load') - } - onLoad={() => setAppLocalImageLoaded(true)} + onError={() => setLocal('error')} + onLoad={() => setLocal('loaded')} source={packagedImage} style={styles.logo} testID="app-local-image" /> + - setImageError((error) => error ?? 'Monorepo image failed to load') - } - onLoad={() => setMonorepoImageLoaded(true)} + onError={() => setMonorepo('error')} + onLoad={() => setMonorepo('loaded')} source={monorepoImage} style={styles.monorepoLogo} testID="monorepo-image" /> - Back home - + ); } const styles = StyleSheet.create({ - container: { - alignItems: 'center', - flex: 1, - gap: 16, - justifyContent: 'center', - padding: 24, - }, logo: { height: 30, + marginHorizontal: 12, width: 147, }, monorepoLogo: { height: 64, + marginHorizontal: 12, width: 64, }, }); diff --git a/apps/tester-expo/app/index.tsx b/apps/tester-expo/app/index.tsx index 8e5a32427..4ac687662 100644 --- a/apps/tester-expo/app/index.tsx +++ b/apps/tester-expo/app/index.tsx @@ -1,42 +1,83 @@ -import { Link } from 'expo-router'; +import { useRouter } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; -import { useState } from 'react'; -import { Button, StyleSheet, Text, View } from 'react-native'; -import FastRefreshLeaf from '../components/FastRefreshLeaf'; -import SymbolicationProbe from '../components/SymbolicationProbe'; +import { + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import { FEATURE_GROUPS, FEATURES } from '../FEATURES'; export default function HomeRoute() { - const [count, setCount] = useState(0); + const router = useRouter(); return ( - - Expo Router compiled by Re.Pack - Parent state: {count} - + ); +} diff --git a/apps/tester-federation-v2/src/host/features/nativewind/ui/Button.tsx b/apps/tester-federation-v2/src/host/features/nativewind/ui/Button.tsx new file mode 100644 index 000000000..19693965a --- /dev/null +++ b/apps/tester-federation-v2/src/host/features/nativewind/ui/Button.tsx @@ -0,0 +1,81 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; +import { Pressable } from 'react-native'; +import { TextClassContext } from './Text'; +import { cn } from './utils'; + +const buttonVariants = cva( + 'group flex items-center justify-center rounded-md', + { + variants: { + variant: { + default: 'bg-primary active:opacity-90', + primary: 'bg-primary active:opacity-90', + }, + size: { + default: 'h-10 px-4 py-2 native:h-12 native:px-5 native:py-3', + sm: 'h-9 rounded-md px-3', + lg: 'h-11 rounded-md px-8 native:h-14', + icon: 'h-10 w-10', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +); + +const buttonTextVariants = cva( + 'web:whitespace-nowrap text-sm native:text-base font-medium text-foreground web:transition-colors', + { + variants: { + variant: { + default: 'text-primary-foreground', + primary: 'text-primary-foreground', + }, + size: { + default: '', + sm: '', + lg: 'native:text-lg', + icon: '', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +); + +type ButtonProps = React.ComponentPropsWithoutRef & + VariantProps; + +const Button = React.forwardRef< + React.ElementRef, + ButtonProps +>(({ className, variant, size, ...props }, ref) => { + return ( + + + + ); +}); +Button.displayName = 'Button'; + +export type { ButtonProps }; +export { Button, buttonTextVariants, buttonVariants }; diff --git a/apps/tester-federation-v2/src/host/features/nativewind/ui/Text.tsx b/apps/tester-federation-v2/src/host/features/nativewind/ui/Text.tsx new file mode 100644 index 000000000..a933f0829 --- /dev/null +++ b/apps/tester-federation-v2/src/host/features/nativewind/ui/Text.tsx @@ -0,0 +1,28 @@ +import * as Slot from '@rn-primitives/slot'; +import type { SlottableTextProps, TextRef } from '@rn-primitives/types'; +import * as React from 'react'; +import { Text as RNText } from 'react-native'; +import { cn } from './utils'; + +const TextClassContext = React.createContext(undefined); + +const Text = React.forwardRef( + ({ className, asChild = false, ...props }, ref) => { + const textClass = React.useContext(TextClassContext); + const Component = asChild ? Slot.Text : RNText; + return ( + + ); + } +); +Text.displayName = 'Text'; + +export { Text, TextClassContext }; diff --git a/apps/tester-federation-v2/src/host/features/nativewind/ui/utils.ts b/apps/tester-federation-v2/src/host/features/nativewind/ui/utils.ts new file mode 100644 index 000000000..9ad0df426 --- /dev/null +++ b/apps/tester-federation-v2/src/host/features/nativewind/ui/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/apps/tester-federation-v2/src/host/features/reanimated/ReanimatedBox.tsx b/apps/tester-federation-v2/src/host/features/reanimated/ReanimatedBox.tsx new file mode 100644 index 000000000..e8653e05a --- /dev/null +++ b/apps/tester-federation-v2/src/host/features/reanimated/ReanimatedBox.tsx @@ -0,0 +1,80 @@ +import * as React from 'react'; +import { Button, StyleSheet, View } from 'react-native'; +import Animated, { + useAnimatedReaction, + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; +import { scheduleOnRN } from 'react-native-worklets'; + +const BASE_WIDTH = 100; + +export default function ReanimatedBox({ + onWidthChange, +}: { + onWidthChange: (width: number) => void; +}) { + const width = useSharedValue(BASE_WIDTH); + const target = React.useRef(BASE_WIDTH); + + const boxStyle = useAnimatedStyle(() => { + 'worklet'; + return { width: width.value }; + }); + + useAnimatedReaction( + () => width.value, + (_previous, next) => { + 'worklet'; + if (next !== null) { + scheduleOnRN(onWidthChange, Math.round(next)); + } + } + ); + + const handlePress = () => { + target.current += 50; + width.value = withSpring(target.current); + }; + + const handleReset = () => { + target.current = BASE_WIDTH; + width.value = withSpring(target.current); + }; + + return ( + + + +