Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 237 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
name: e2e

on:
pull_request:
paths:
- 'apps/tester-federation-v2/**'
- 'packages/**'
- 'pnpm-lock.yaml'
- '.github/workflows/e2e.yml'
push:
branches: [main]
paths:
- 'apps/tester-federation-v2/**'
- 'packages/**'
- 'pnpm-lock.yaml'
- '.github/workflows/e2e.yml'
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 (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.18.0'

- 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-federation-v2... build

# nohup/disown: the runner kills step processes at the end. /status wait: RNTA picks its bundle source once, never retries
- name: Start dev servers
working-directory: apps/tester-federation-v2
run: |
nohup pnpm start:hostapp > /tmp/hostapp.log 2>&1 &
disown
nohup pnpm start:miniapp > /tmp/miniapp.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" ]; do sleep 2; done'

# ask for the bundles now so the servers compile them — they only build on first request
- name: Warm both compilers
working-directory: apps/tester-federation-v2
run: |
curl -sf --max-time 300 -o /dev/null 'http://localhost:8081/android/index.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.tester.federationV2&modulesOnly=false&runModule=true'
curl -sf --max-time 300 -o /dev/null 'http://localhost:8082/android/index.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.tester.federationV2&modulesOnly=false&runModule=true'
echo 'Both bundles 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-federation-v2/android
run: ./gradlew :app:assembleDebug -PreactNativeArchitectures=x86_64

# 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
working-directory: apps/tester-federation-v2
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 --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 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-federation-v2
run: |
mkdir -p .e2e
cp /tmp/hostapp.log /tmp/miniapp.log .e2e/

- name: Upload e2e artifacts
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # tag=v7.0.1
with:
name: e2e-android
path: apps/tester-federation-v2/.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/hostapp.log /tmp/miniapp.log

# dispatch + main only for now — no PR triggers until this lane has greens
ios:
name: e2e (iOS)
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
runs-on: macos-15
timeout-minutes: 45

steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # tag=v4.2.2

- name: Setup Ruby
uses: ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # tag=v1.329.0
with:
ruby-version: '3.4'
bundler-cache: true
working-directory: apps/tester-federation-v2

- name: Setup workspace
uses: ./.github/actions/setup-pnpm-node
with:
node-version: '24.18.0'

- name: Build workspace packages
run: pnpm --filter tester-federation-v2... build

# start both dev servers in the background, wait until both answer /status
- name: Start dev servers
working-directory: apps/tester-federation-v2
run: |
nohup pnpm start:hostapp > /tmp/hostapp.log 2>&1 &
disown
nohup pnpm start:miniapp > /tmp/miniapp.log 2>&1 &
disown
for p in 8081 8082; do curl -sf --retry 90 --retry-delay 2 --retry-all-errors --retry-connrefused -o /dev/null "http://localhost:$p/status"; done

# ask for the iOS bundles now so the servers compile them — they only build on first request
- name: Warm both compilers
working-directory: apps/tester-federation-v2
run: |
curl -sf --max-time 300 -o /dev/null 'http://localhost:8081/index.bundle?platform=ios&dev=true&lazy=true&minify=false&modulesOnly=false&runModule=true&app=com.tester.federationV2'
curl -sf --max-time 300 -o /dev/null 'http://localhost:8082/index.bundle?platform=ios&dev=true&lazy=true&minify=false&modulesOnly=false&runModule=true&app=com.tester.federationV2'
echo 'Both bundles 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 runner image: macos-15's default Xcode
# (16.4) targets iOS 18.x. If the image moves, this check fails every run —
# bump the prefix (and SIM_DEVICE) to the new SDK's major.
- name: Boot simulator
env:
SIM_RUNTIME_PREFIX: com.apple.CoreSimulator.SimRuntime.iOS-18-
SIM_DEVICE: iPhone 16
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

# pod install generates the Xcode project the workspace points at
- name: Build iOS app
working-directory: apps/tester-federation-v2/ios
run: bundle install && bundle exec pod install && xcodebuild -workspace ModuleFederationTesterV2.xcworkspace -scheme ReactTestApp -configuration Debug -destination 'generic/platform=iOS Simulator' -derivedDataPath build CODE_SIGNING_ALLOWED=NO ARCHS=arm64 build

- name: Run e2e

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The iOS lane can't catch the bug this PR exists to fix. Same app, same device, only the configs differing:

configs manifest version Android iOS
main (pkg.dependencies[...]) catalog:testers ❌ Gallery fails ✅ passes 3/3
this PR 5.8.0 / 4.26.2 ✅ 3/3 ✅ 3/3

iOS passes with the bug fully active. It isn't silently absent — the host iOS bundle I served contains version: "catalog:testers" / requiredVersion: "catalog:testers" for both react-native-safe-area-context and react-native-screens, and the Gallery test still went green.

Because with singleton: true MF2 only warns and carries on (@module-federation/runtime-core dist/utils/share.js:189-193 — strictVersion is false because singleton is true), and satisfy("catalog:testers","catalog:testers") is false even against itself. The module loads fine. On Android the visible symptom is the warning banner covering the Mini Apps row, which eats the tap — the trace shows tap group "🖼️, Gallery" → screen: unchanged since step 3 → 327 failed reads of miniapp-gallery. iOS has no banner, so the tap lands and the test passes.

So the description's "the Gallery test passing with zero suppression is the regression test for this" holds on Android only. Making it platform-agnostic, ~5s:

      # MF2 warns rather than fails on a non-semver shared version, and the only
      # symptom is a banner on screen — assert on the manifest instead, which
      # works the same on both platforms.
      - name: Check shared versions are real semver
        run: |
          for p in 8081 8082; do
            BAD=$(curl -sf "http://localhost:$p/android/mf-manifest.json" | jq -r \
              '.shared[] | select((.version // "") | test("^[0-9]+\\.[0-9]+\\.[0-9]+") | not) | "\(.id): \(.version)"')
            [ -z "$BAD" ] || { echo "::error::port $p has non-semver shared versions: $BAD"; exit 1; }
          done

Verified: against main's configs it prints exactly the two offenders; against this PR's configs it's silent and exits 0. Both ports serve a manifest, so the loop covers host and remote.

Related, same root cause: iOS collects no device logs — Android does adb logcat -d > .e2e/logcat.txt (line 102), this job copies only the two dev-server logs. A JS console warning is the only symptom on iOS, so an iOS-only federation regression gives a green run and nothing to debug from even when it does fail. Worth a simctl spawn booted log show in the collect step.

working-directory: apps/tester-federation-v2
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-federation-v2
run: |
mkdir -p .e2e
cp /tmp/hostapp.log /tmp/miniapp.log .e2e/
xcrun simctl spawn booted log show --style compact --last 30m \
--predicate 'process == "ReactTestApp"' \
> .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-ios
path: apps/tester-federation-v2/.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/hostapp.log /tmp/miniapp.log
3 changes: 3 additions & 0 deletions apps/tester-federation-v2/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,6 @@ yarn-error.log

# MF
**/@mf-types

# e2e
.e2e/
2 changes: 1 addition & 1 deletion apps/tester-federation-v2/android/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,4 @@ hermesEnabled=true
# Use this property to enable edge-to-edge display support.
# This allows your app to draw behind system bars for an immersive UI.
# Note: Only works with ReactActivity and should not be used with custom Activity.
edgeToEdgeEnabled=true
edgeToEdgeEnabled=true
44 changes: 3 additions & 41 deletions apps/tester-federation-v2/configs/rspack.host-app.mts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import * as Repack from '@callstack/repack';
import rspack from '@rspack/core';
import reactPkg from 'react/package.json' with { type: 'json' };
import reactNativePkg from 'react-native/package.json' with { type: 'json' };
import pkg from '../package.json' with { type: 'json' };

import { buildSharedSingletons } from './sharedSingletons.mts';

export default Repack.defineRspackConfig((env) => {
const { mode, context, platform } = env;
Expand Down Expand Up @@ -52,44 +51,7 @@ export default Repack.defineRspackConfig((env) => {
MiniApp: `MiniApp@http://localhost:8082/${platform}/mf-manifest.json`,
},
dts: false,
shared: {
react: {
singleton: true,
eager: true,
version: reactPkg.version,
requiredVersion: reactPkg.version,
},
'react-native': {
singleton: true,
eager: true,
version: reactNativePkg.version,
requiredVersion: reactNativePkg.version,
},
'@react-navigation/native': {
singleton: true,
eager: true,
version: pkg.dependencies['@react-navigation/native'],
requiredVersion: pkg.dependencies['@react-navigation/native'],
},
'@react-navigation/native-stack': {
singleton: true,
eager: true,
version: pkg.dependencies['@react-navigation/native-stack'],
requiredVersion: pkg.dependencies['@react-navigation/native-stack'],
},
'react-native-safe-area-context': {
singleton: true,
eager: true,
version: pkg.dependencies['react-native-safe-area-context'],
requiredVersion: pkg.dependencies['react-native-safe-area-context'],
},
'react-native-screens': {
singleton: true,
eager: true,
version: pkg.dependencies['react-native-screens'],
requiredVersion: pkg.dependencies['react-native-screens'],
},
},
shared: buildSharedSingletons(true),
}),
// silence missing @react-native-masked-view optionally required by @react-navigation/elements
new rspack.IgnorePlugin({
Expand Down
44 changes: 3 additions & 41 deletions apps/tester-federation-v2/configs/rspack.mini-app.mts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import * as Repack from '@callstack/repack';
import rspack from '@rspack/core';
import reactPkg from 'react/package.json' with { type: 'json' };
import reactNativePkg from 'react-native/package.json' with { type: 'json' };
import pkg from '../package.json' with { type: 'json' };

import { buildSharedSingletons } from './sharedSingletons.mts';

export default Repack.defineRspackConfig((env) => {
const { mode, context, platform } = env;
Expand Down Expand Up @@ -49,44 +48,7 @@ export default Repack.defineRspackConfig((env) => {
'./MiniAppNavigator': './src/mini/navigation/MainNavigator',
},
dts: false,
shared: {
react: {
singleton: true,
eager: false,
version: reactPkg.version,
requiredVersion: reactPkg.version,
},
'react-native': {
singleton: true,
eager: false,
version: reactNativePkg.version,
requiredVersion: reactNativePkg.version,
},
'@react-navigation/native': {
singleton: true,
eager: false,
version: pkg.dependencies['@react-navigation/native'],
requiredVersion: pkg.dependencies['@react-navigation/native'],
},
'@react-navigation/native-stack': {
singleton: true,
eager: false,
version: pkg.dependencies['@react-navigation/native-stack'],
requiredVersion: pkg.dependencies['@react-navigation/native-stack'],
},
'react-native-safe-area-context': {
singleton: true,
eager: false,
version: pkg.dependencies['react-native-safe-area-context'],
requiredVersion: pkg.dependencies['react-native-safe-area-context'],
},
'react-native-screens': {
singleton: true,
eager: false,
version: pkg.dependencies['react-native-screens'],
requiredVersion: pkg.dependencies['react-native-screens'],
},
},
shared: buildSharedSingletons(false),
}),
// silence missing @react-native-masked-view optionally required by @react-navigation/elements
new rspack.IgnorePlugin({
Expand Down
32 changes: 32 additions & 0 deletions apps/tester-federation-v2/configs/sharedSingletons.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import nativePkg from '@react-navigation/native/package.json' with {
type: 'json',
};
import nativeStackPkg from '@react-navigation/native-stack/package.json' with {
type: 'json',
};
import reactPkg from 'react/package.json' with { type: 'json' };
import reactNativePkg from 'react-native/package.json' with { type: 'json' };
import safeAreaPkg from 'react-native-safe-area-context/package.json' with {
type: 'json',
};
import screensPkg from 'react-native-screens/package.json' with {
type: 'json',
};

const SHARED_SINGLETONS: Array<[name: string, version: string]> = [
['react', reactPkg.version],
['react-native', reactNativePkg.version],
['@react-navigation/native', nativePkg.version],
['@react-navigation/native-stack', nativeStackPkg.version],
['react-native-safe-area-context', safeAreaPkg.version],
['react-native-screens', screensPkg.version],
];

export function buildSharedSingletons(eager: boolean) {
return Object.fromEntries(
SHARED_SINGLETONS.map(([name, version]) => [
name,
{ singleton: true, eager, version, requiredVersion: version },
])
);
}
Loading