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
188 changes: 188 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
name: e2e

on:
pull_request_target:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'd use pull_request here instead of pull_request_target. The job uses no secrets and only needs contents: read, so the target trigger adds risk without enabling anything:

  • The label stays on the PR. Once a maintainer adds e2e to a fork PR, every later synchronize push runs fork code with no fresh review. With pull_request, GitHub's fork-workflow approval does this gating.
  • The fork picks the pnpm version. setup-pnpm-node passes no version, so pnpm/action-setup installs whatever packageManager says in the checked-out, fork-controlled package.json, against the fork's lockfile.
  • Workflow changes can't be tested before merge. With pull_request_target the workflow file always comes from main, so a PR's edits to e2e.yml never run on that PR. That includes this PR.
  • It tests the PR head, not the merge. The checkout uses head.sha. The default pull_request checkout uses the merge ref, which is the right thing for a merge gate.

On cache poisoning, the concern from #1499: GitHub now gives untrusted triggers a read-only cache token for the default-branch scope, so this is mitigated by the platform. The points above still apply.

With pull_request, I'd also:

  • drop labeled/unlabeled from types, since each label change currently starts a new run
  • add paths: (e.g. apps/tester-federation-v2/**, packages/**, pnpm-lock.yaml and this workflow file) so docs-only PRs don't spend emulator minutes

types: [opened, reopened, synchronize, labeled, unlabeled]
push:
branches: [main]
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: true

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This also applies to push on main, so the next merge cancels the run for the previous one. That run is the signal that main is healthy.

Suggested change
cancel-in-progress: true
cancel-in-progress: ${{ github.event_name != 'push' }}


# keep it read-only
permissions:
contents: read

jobs:
android:
name: e2e (Android)
# same-repo PRs - always run; forks - run only with the `e2e` label
if: >-
github.event_name != 'pull_request_target' ||
github.event.pull_request.head.repo.full_name == github.repository ||
contains(github.event.pull_request.labels.*.name, 'e2e')
runs-on: ubuntu-latest
timeout-minutes: 45

steps:
- name: Checkout pull request head
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # tag=v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}

- 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
env:
E2E: 'true'
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: 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
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

android-emulator-runner runs script: with sh -c and no -e. The two adb reverse --list checks are guarded, but adb install isn't. If the install fails (e.g. INSTALL_FAILED_UPDATE_INCOMPATIBLE), the suite still runs and fails about 60 s later with "home-details-button not found".

set -e would break the ec=$? / logcat pattern below, so guarding this line is simpler:

Suggested change
adb install -r -g android/app/build/outputs/apk/debug/app-debug.apk
adb install -r -g android/app/build/outputs/apk/debug/app-debug.apk || { echo '::error::adb install failed'; exit 1; }

pnpm exec e2e run --target android --reporter list,junit; ec=$?; adb logcat -d -v time > .e2e/logcat.txt 2>&1; exit $ec

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

e2e sends anonymous usage telemetry to PostHog by default. Neither job opts out, and the e2e docs recommend setting the opt-out in CI. A workflow-level env: covers both lanes:

env:
  E2E_TELEMETRY_DISABLED: '1'


- 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: error

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If the job fails before the e2e step (Gradle, pod install, dev server timeout), .e2e doesn't exist. This step then fails too, so the run shows a second error next to the real one. warn keeps the upload without that extra failure. The same applies to the iOS job.

Related: Show dev server logs runs after the upload, so the host and miniapp server logs never end up in the artifact. iOS artifacts also contain only the report and junit (no device log). Copying /tmp/hostapp.log and /tmp/miniapp.log into .e2e/ before uploading would make failures debuggable from the artifact.

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 != 'pull_request_target'
runs-on: macos-15
timeout-minutes: 45

steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # tag=v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}

- 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
env:
E2E: 'true'
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.'

# match the runtime to the default Xcode's SDK (16.4 → iOS 18.x), not just newest available
- name: Boot simulator
run: |
RT=$(xcrun simctl list runtimes -j | jq -r '.runtimes | map(select(.isAvailable and (.identifier | startswith("com.apple.CoreSimulator.SimRuntime.iOS-18-")))) | sort_by(.version) | last | .identifier')

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Matching the runtime to the default Xcode SDK makes sense, but this is a hard failure point. If macos-15 moves its default Xcode and drops the iOS 18 runtimes, [ "$RT" != "null" ] fails every run. iOS only runs on main/dispatch, so a red main run would be the first sign. A comment linking this pin to the runner image (or a variable for it) would make the fix obvious when that happens.

echo "using $RT"
[ "$RT" != "null" ] && [ -n "$RT" ]
xcrun simctl create e2e "iPhone 16" "$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: pod install && xcodebuild -workspace ModuleFederationTesterV2.xcworkspace -scheme ReactTestApp -configuration Debug -destination 'generic/platform=iOS Simulator' -derivedDataPath build CODE_SIGNING_ALLOWED=NO ARCHS=arm64 build

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The app's own scripts use the Gemfile's CocoaPods (pods:update → bundle install && bundle exec pod update). This uses whatever pod the runner image ships, so CI and local can resolve different CocoaPods versions.

Suggested change
run: pod install && xcodebuild -workspace ModuleFederationTesterV2.xcworkspace -scheme ReactTestApp -configuration Debug -destination 'generic/platform=iOS Simulator' -derivedDataPath build CODE_SIGNING_ALLOWED=NO ARCHS=arm64 build
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
working-directory: apps/tester-federation-v2
run: |
xcrun simctl install booted ios/build/Build/Products/Debug-iphonesimulator/*.app
pnpm exec e2e run --target ios --reporter list,junit

- 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: error
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
1 change: 1 addition & 0 deletions apps/tester-federation-v2/configs/rspack.host-app.mts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export default Repack.defineRspackConfig((env) => {
__WITH_PRELOAD__:
process.env.WITH_PRELOAD === 'true' ||
process.env.WITH_PRELOAD === '1',
__E2E__: process.env.E2E === 'true',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Minor: __E2E__ (like the existing __WITH_PRELOAD__) isn't declared anywhere for TypeScript, and this app has no typecheck script, so turbo run typecheck skips it. That means e2e.config.ts and e2e/*.e2e.ts are also never type-checked. A small src/globals.d.ts with declare const __E2E__: boolean; plus "typecheck": "tsc --noEmit" (as tester-expo has) would cover both.

}),
],
};
Expand Down
1 change: 1 addition & 0 deletions apps/tester-federation-v2/configs/webpack.host-app.mts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export default Repack.defineWebpackConfig((env) => {
__WITH_PRELOAD__:
process.env.WITH_PRELOAD === 'true' ||
process.env.WITH_PRELOAD === '1',
__E2E__: process.env.E2E === 'true',
}),
],
};
Expand Down
18 changes: 18 additions & 0 deletions apps/tester-federation-v2/e2e.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { mobile } from '@e2e-dev/mobile';
import type { E2EConfig } from 'e2e';

export default {
tests: 'e2e/**/*.e2e.ts',
targets: [
{
name: 'android',
engine: mobile({ platform: 'android' }),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Without device, @e2e-dev/mobile uses every booted device of the platform. Locally, e2e defaults to workers = cores / 2. When I ran pnpm exec e2e run --target android on a machine with several booted emulators and simulators, it would have spread across all of them, including ones other work was using. CI behaves only because each job has exactly one device and CI mode defaults workers to 1.

Pinning it in the config, with an env override for local runs, solved this for me:

engine: mobile({ platform: 'android', device: process.env.E2E_DEVICE }),

On iOS the job already names its simulator e2e, so CI can pass that in.

I'd also set retries: 0 and workers: 1 explicitly at the top level. In CI mode e2e defaults to retries: 1, and a test that fails once and then passes is counted as flaky, not failed, so the check stays green. For a 3-test smoke lane that targets cold start and MF remote loading, an intermittent first-attempt failure is exactly the regression we want to see.

app: { bundleId: 'com.tester.federationV2' },
},
{
name: 'ios',
engine: mobile({ platform: 'ios' }),
app: { bundleId: 'com.tester.federationV2' },
},
],
} satisfies E2EConfig;
40 changes: 40 additions & 0 deletions apps/tester-federation-v2/e2e/smoke.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { test } from '@e2e-dev/mobile';
import { expect } from 'e2e';

test('the host app boots to Home', async ({ app, screen }) => {
await app.open();

await expect(screen.getByTestId('home-details-button')).toBeVisible({
timeout: 60_000,
});
await expect(screen.getByTestId('home-gallery-button')).toBeVisible();
});

test('navigates from Home to Detail and back', async ({ app, screen }) => {
await app.open();
await expect(screen.getByTestId('home-details-button')).toBeVisible({
timeout: 60_000,
});

await screen.getByTestId('home-details-button').tap();
await expect(screen.getByTestId('detail-screen')).toBeVisible();

await app.back();
await expect(screen.getByTestId('home-details-button')).toBeVisible();
await expect(screen.getByTestId('detail-screen')).toBeHidden();
});

test('Home opens the Gallery MiniApp', {
timeout: 120_000,
}, async ({ app, screen }) => {
await app.open();
await expect(screen.getByTestId('home-gallery-button')).toBeVisible({
timeout: 60_000,
});

await screen.getByTestId('home-gallery-button').tap();
await expect(screen.getByTestId('miniapp-gallery')).toBeVisible({
timeout: 45_000,
});
await expect(screen.getByText('Picture 1')).toBeVisible();
});
5 changes: 4 additions & 1 deletion apps/tester-federation-v2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"pods:update": "(cd ios && bundle install && bundle exec pod update)",
"start:hostapp": "react-native webpack-start --config config.host-app.mts",
"start:miniapp": "react-native webpack-start --config config.mini-app.mts --port 8082",
"start:expo-host-widget": "react-native webpack-start --config config.expo-host-widget.mts --port 8083"
"start:expo-host-widget": "react-native webpack-start --config config.expo-host-widget.mts --port 8083",
"e2e": "pnpm exec e2e run"
},
"dependencies": {
"@callstack/repack": "workspace:*",
Expand All @@ -25,6 +26,7 @@
},
"devDependencies": {
"@babel/core": "^7.29.7",
"@e2e-dev/mobile": "0.11.0",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Two things about the new deps:

  • Catalog: every other tester dependency is catalog-managed, and @e2e-dev/mobile has a peer dependency on e2e >=0.15 <1, so the two need to move together. Adding them to catalog:testers (or adding a "pinned together" comment) would make that explicit. They also bring @vitest/*@5 into the lockfile while the catalog pins vitest ^4. That's harmless, but worth knowing.
  • Node version: e2e@0.19.0 and @e2e-dev/mobile@0.11.0 declare engines.node: ^22.22.3 || >=24.8.0, but the root engines allows >=24. There's no engine-strict, so on 24.0–24.7 a contributor only sees a warning and then gets a confusing failure.

"@react-native-community/cli": "catalog:testers",
"@react-native-community/cli-platform-android": "catalog:testers",
"@react-native-community/cli-platform-ios": "catalog:testers",
Expand All @@ -36,6 +38,7 @@
"@swc/helpers": "catalog:",
"@types/jest": "^29.5.13",
"@types/react": "catalog:testers",
"e2e": "0.19.0",
"react-native-test-app": "catalog:testers",
"terser-webpack-plugin": "catalog:",
"typescript": "catalog:",
Expand Down
6 changes: 5 additions & 1 deletion apps/tester-federation-v2/src/host/index.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { ScriptManager } from '@callstack/repack/client';
import { preloadRemote } from '@module-federation/runtime';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { AppRegistry } from 'react-native';
import { AppRegistry, LogBox } from 'react-native';

import { components } from '../../app.json';
import App from './App';

if (__E2E__) {
LogBox.ignoreAllLogs(true);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This hides the symptom from #1499, but the cause is still there. The green run's logcat.txt still logs [ Federation Runtime ] Version catalog:testers from HostApp of shared singleton module react-native-safe-area-context does not satisfy the requirement… (and the same for react-native-screens) on every launch. Locally, at this head, the Gallery test fails without E2E=true because the toast covers the tile.

Cause: the four MF configs pass pkg.dependencies['react-native-safe-area-context'] and ['react-native-screens'] as the shared version/requiredVersion. In package.json those values are the literal string catalog:testers, which MF2's semver check can't parse. Importing the real versions fixes it, the same way the configs already handle react and react-native:

import safeAreaPkg from 'react-native-safe-area-context/package.json' with { type: 'json' };
import screensPkg from 'react-native-screens/package.json' with { type: 'json' };
// ...
version: safeAreaPkg.version,
requiredVersion: safeAreaPkg.version,

With that in place, this line can go entirely, or be narrowed to LogBox.ignoreLogs([...]) for anything specific that's left.

Also, ignoreAllLogs(true) hides console.error toasts too, not just warnings (see LogBoxNotificationContainer, which returns nothing when isDisabled). Only fatal errors and red boxes still render. The description says errors are still shown, which only holds for fatal ones. A global mute also means future MF or React Native warnings won't show up in this lane, and catching those is what it's for.

}

if (__WITH_PRELOAD__) {
// enable caching of scripts in the AsyncStorage
ScriptManager.shared.setStorage(AsyncStorage);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { StyleSheet, Text, View } from 'react-native';

const DetailScreen = () => {
return (
<View style={styles.container}>
<View testID="detail-screen" style={styles.container}>
<Text style={styles.label}>DetailScreen</Text>
</View>
);
Expand Down
2 changes: 2 additions & 0 deletions apps/tester-federation-v2/src/host/screens/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const HomeScreen = () => {
Explore the host app and its features
</Text>
<TouchableOpacity
testID="home-details-button"
style={styles.button}
onPress={() => navigation.navigate('Detail')}
>
Expand All @@ -29,6 +30,7 @@ const HomeScreen = () => {
<Text style={styles.sectionTitle}>Mini Apps</Text>
<View style={styles.miniAppContainer}>
<TouchableOpacity
testID="home-gallery-button"
style={[styles.miniAppButton, styles.galleryButton]}
onPress={() => navigation.navigate('MiniApp')}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class ErrorBoundary extends React.Component<
render() {
if (this.state.hasError) {
return (
<View style={styles.container}>
<View testID="miniapp-error" style={styles.container}>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: nothing uses miniapp-error yet. When the remote fails to load, the Gallery test keeps polling miniapp-gallery for 45 s and then reports a timeout, so a broken remote looks the same as a slow device. Checking miniapp-error alongside the gallery check would make it fail fast with a clear message. Otherwise this testID can be dropped.

<Text>Failed to load Mini App</Text>
</View>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const Row = ({
const GalleryScreen = () => {
return (
<ScrollView
testID="miniapp-gallery"
contentContainerStyle={styles.contentContainer}
style={styles.container}
>
Expand Down
Loading