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

on:
pull_request_target:
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

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

- 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
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')
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
# ARCHS=arm64: the generic simulator destination otherwise builds arm64 + x86_64 on an arm64-only runner.
# It must be a setting override — the runner's Xcode 16.4 rejects `-arch` alongside the destination.
- 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

- 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 @@ -18,7 +18,7 @@ org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryEr
# section on Gradle build performance:
# https://docs.gradle.org/current/userguide/performance.html#parallel_execution.
# Default is `false`.
#org.gradle.parallel=true
org.gradle.parallel=true

# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
Expand Down
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',
}),
],
};
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' }),
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 @@ -26,6 +27,7 @@
"devDependencies": {
"@babel/core": "^7.29.7",
"@babel/plugin-syntax-typescript": "7.25.4",
"@e2e-dev/mobile": "0.11.0",
"@react-native-community/cli": "catalog:testers",
"@react-native-community/cli-platform-android": "catalog:testers",
"@react-native-community/cli-platform-ios": "catalog:testers",
Expand All @@ -37,6 +39,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
14 changes: 13 additions & 1 deletion apps/tester-federation-v2/src/host/index.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
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';

// Set via DefinePlugin when the dev servers run with E2E=true (see CI's
// e2e.yml). Warning toasts are part of the app's own view tree and sit on
// top of the bottom of the screen, so a logged warning can silently absorb a
// tap meant for a control there — this cost the Gallery e2e test several CI
// runs (agent_context/e2e-ci-oct2026/design.md had flagged exactly this
// risk). Errors are NOT ignored: red screens still render, so a broken
// change still fails loudly. No flag, no muting: normal development keeps
// every warning.
if (__E2E__) {
LogBox.ignoreAllLogs(true);
}

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}>
<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