From 3a609fc6617b9eba5a7d740952c822b7e73f4bc6 Mon Sep 17 00:00:00 2001 From: paufau Date: Tue, 8 Sep 2026 19:05:10 +0400 Subject: [PATCH] chore(web): add web e2e and build to ci --- .github/actions/build-android/action.yml | 9 +-- .github/actions/build-ios/action.yml | 9 +-- .github/actions/build-web/action.yml | 28 ++++++++ .github/actions/e2e-android/action.yml | 6 +- .github/actions/e2e-ios/action.yml | 6 +- .github/actions/e2e-web/action.yml | 43 ++++++++++++ .../scripts/collect-failure-screenshots.sh | 2 + .github/scripts/e2e-web.sh | 10 +++ .github/scripts/install-example-deps.sh | 13 ++++ .github/workflows/ci.yml | 20 ++++++ .github/workflows/nightly.yml | 12 ++++ example/e2e-run.mjs | 66 ++++++++++++------- example/e2e/README.md | 14 ++++ example/e2e/flows/base.web.yaml | 3 + 14 files changed, 195 insertions(+), 46 deletions(-) create mode 100644 .github/actions/build-web/action.yml create mode 100644 .github/actions/e2e-web/action.yml create mode 100755 .github/scripts/e2e-web.sh create mode 100755 .github/scripts/install-example-deps.sh create mode 100644 example/e2e/flows/base.web.yaml diff --git a/.github/actions/build-android/action.yml b/.github/actions/build-android/action.yml index 0fa83f1..74dac83 100644 --- a/.github/actions/build-android/action.yml +++ b/.github/actions/build-android/action.yml @@ -27,14 +27,7 @@ runs: - name: Install example dependencies shell: bash - run: | - npm pkg delete scripts.prepare - if [ -n "${{ inputs.rn-version }}" ]; then - npm --prefix example pkg set "dependencies.react-native=${{ inputs.rn-version }}" "overrides.react-native=${{ inputs.rn-version }}" - npm --prefix example install --no-save - else - npm --prefix example ci - fi + run: .github/scripts/install-example-deps.sh "${{ inputs.rn-version }}" - name: Prebuild native project (android) shell: bash diff --git a/.github/actions/build-ios/action.yml b/.github/actions/build-ios/action.yml index 244156d..b9ac8dd 100644 --- a/.github/actions/build-ios/action.yml +++ b/.github/actions/build-ios/action.yml @@ -20,14 +20,7 @@ runs: - name: Install example dependencies shell: bash - run: | - npm pkg delete scripts.prepare - if [ -n "${{ inputs.rn-version }}" ]; then - npm --prefix example pkg set "dependencies.react-native=${{ inputs.rn-version }}" "overrides.react-native=${{ inputs.rn-version }}" - npm --prefix example install --no-save - else - npm --prefix example ci - fi + run: .github/scripts/install-example-deps.sh "${{ inputs.rn-version }}" - name: Prebuild native project (ios) shell: bash diff --git a/.github/actions/build-web/action.yml b/.github/actions/build-web/action.yml new file mode 100644 index 0000000..c3353c6 --- /dev/null +++ b/.github/actions/build-web/action.yml @@ -0,0 +1,28 @@ +name: Build Web +description: >- + Export the example app for web as a static bundle + +inputs: + rn-version: + description: 'react-native version to force in the example (e.g. "nightly", "0.84")' + required: false + default: '' + +runs: + using: composite + steps: + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version: 24 + cache: 'npm' + cache-dependency-path: example/package-lock.json + + - name: Install example dependencies + shell: bash + run: .github/scripts/install-example-deps.sh "${{ inputs.rn-version }}" + + - name: Export web + shell: bash + run: npx expo export --platform web + working-directory: example diff --git a/.github/actions/e2e-android/action.yml b/.github/actions/e2e-android/action.yml index 8280756..666f8f6 100644 --- a/.github/actions/e2e-android/action.yml +++ b/.github/actions/e2e-android/action.yml @@ -28,9 +28,7 @@ runs: - name: Install example dependencies shell: bash - run: | - npm pkg delete scripts.prepare - npm --prefix example ci + run: .github/scripts/install-example-deps.sh - name: Install Maestro shell: bash @@ -56,7 +54,7 @@ runs: - name: Upload failure screenshot if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: failure-screenshot-android path: maestro-debug/ diff --git a/.github/actions/e2e-ios/action.yml b/.github/actions/e2e-ios/action.yml index b51c032..006f3e4 100644 --- a/.github/actions/e2e-ios/action.yml +++ b/.github/actions/e2e-ios/action.yml @@ -14,9 +14,7 @@ runs: - name: Install example dependencies shell: bash - run: | - npm pkg delete scripts.prepare - npm --prefix example ci + run: .github/scripts/install-example-deps.sh - name: Install Maestro shell: bash @@ -41,7 +39,7 @@ runs: - name: Upload failure screenshot if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: failure-screenshot-ios path: maestro-debug/ diff --git a/.github/actions/e2e-web/action.yml b/.github/actions/e2e-web/action.yml new file mode 100644 index 0000000..d935dcf --- /dev/null +++ b/.github/actions/e2e-web/action.yml @@ -0,0 +1,43 @@ +name: E2E Web +description: >- + run the e2e Maestro tests on web (behavior only, no screenshot comparison) + +runs: + using: composite + steps: + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version: 24 + cache: 'npm' + cache-dependency-path: example/package-lock.json + + - name: Install example dependencies + shell: bash + run: .github/scripts/install-example-deps.sh + + - name: Install Maestro + shell: bash + run: | + curl -Ls "https://get.maestro.mobile.dev" | MAESTRO_VERSION=2.10.0 bash + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + + - name: Run E2E (web) + shell: bash + run: .github/scripts/e2e-web.sh + + - name: Collect web e2e debug output + if: failure() + shell: bash + run: | + mkdir -p maestro-debug + cp -R "$HOME/.maestro/tests" maestro-debug/ 2>/dev/null || true + cp "$RUNNER_TEMP/web-server.log" maestro-debug/ 2>/dev/null || true + + - name: Upload web e2e debug output + if: failure() + uses: actions/upload-artifact@v5 + with: + name: e2e-web-debug + path: maestro-debug/ + if-no-files-found: ignore diff --git a/.github/scripts/collect-failure-screenshots.sh b/.github/scripts/collect-failure-screenshots.sh index c083d6e..a848393 100755 --- a/.github/scripts/collect-failure-screenshots.sh +++ b/.github/scripts/collect-failure-screenshots.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash mkdir -p maestro-debug + latest=$(find "$HOME/.maestro/tests" -type f -name '*.png' -print0 2>/dev/null \ | xargs -0 ls -t 2>/dev/null | head -1) + if [ -n "$latest" ] && [ -e "$latest" ]; then cp "$latest" maestro-debug/failure.png fi diff --git a/.github/scripts/e2e-web.sh b/.github/scripts/e2e-web.sh new file mode 100755 index 0000000..514764c --- /dev/null +++ b/.github/scripts/e2e-web.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +log_path="${RUNNER_TEMP:-/tmp}/web-server.log" + +cd example + +npx expo export --platform web +npx expo serve --port 8081 > "$log_path" 2>&1 & +npm run e2e:run -- --platform web --run-steps generation tests diff --git a/.github/scripts/install-example-deps.sh b/.github/scripts/install-example-deps.sh new file mode 100755 index 0000000..442b3ca --- /dev/null +++ b/.github/scripts/install-example-deps.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +rn_version="${1:-}" + +npm pkg delete scripts.prepare + +if [ -n "$rn_version" ]; then + npm --prefix example pkg set "dependencies.react-native=$rn_version" "overrides.react-native=$rn_version" + npm --prefix example install --no-save +else + npm --prefix example ci +fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e5910b..0f2549d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,6 +75,16 @@ jobs: - name: Build iOS uses: ./.github/actions/build-ios + web: + name: Build Web + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Build Web + uses: ./.github/actions/build-web + e2e-android: name: E2E Android (new arch) runs-on: ubuntu-latest @@ -94,3 +104,13 @@ jobs: - name: Run E2E iOS uses: ./.github/actions/e2e-ios + + e2e-web: + name: E2E Web + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Run E2E Web + uses: ./.github/actions/e2e-web diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 6ed692e..3393f99 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -32,3 +32,15 @@ jobs: uses: ./.github/actions/build-ios with: rn-version: nightly + + nightly-web: + name: Build Web against react-native@nightly + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Build Web + uses: ./.github/actions/build-web + with: + rn-version: nightly diff --git a/example/e2e-run.mjs b/example/e2e-run.mjs index 60d61f6..512389f 100644 --- a/example/e2e-run.mjs +++ b/example/e2e-run.mjs @@ -27,6 +27,12 @@ const SCREENSHOTS_PATH = './e2e/screenshots/'; const EXPECTED_SCREENSHOTS_PATH = './e2e/expected-screenshots/'; const GENERATED_FLOW_NAME = "generated_flow.yaml"; +// Web runs headless in a browser tall enough to fit +// every demo card without scrolling: +// Maestro cannot scroll react-native-web's inner ScrollView, +// so off-screen cards would be unreachable +const WEB_SCREEN_SIZE = '1280x2200'; + // Initialize logger const logger = new Logger(); logger.isVerbose = true; @@ -37,7 +43,12 @@ const argv = yargs(hideBin(process.argv)) demandOption: true, describe: 'Specify the platform for the build', type: 'string', - choices: ['ios', 'android'], + choices: ['ios', 'android', 'web'], + }) + .option('url', { + describe: 'Base URL of the running web app (web platform only)', + type: 'string', + default: 'http://localhost:8081', }) .option('architecture', { alias: 'arch', @@ -77,20 +88,23 @@ const argv = yargs(hideBin(process.argv)) .parse(); // Setup variables -const { platform, architecture, runSteps, silent, updateScreenshots, device: specificDevice, deviceLabel } = argv; +const { platform, architecture, runSteps, silent, updateScreenshots, device: specificDevice, deviceLabel, url } = argv; +const isWeb = platform === 'web'; logger.isVerbose = !silent; // App id comes from the Expo config (single example app), not a per-project derivation. const appConfig = JSON.parse(fs.readFileSync('./app.json', 'utf8')); -const appId = platform === 'ios' - ? appConfig.expo.ios.bundleIdentifier - : appConfig.expo.android.package; +const appId = isWeb + ? 'web' + : platform === 'ios' + ? appConfig.expo.ios.bundleIdentifier + : appConfig.expo.android.package; const modalsConfig = JSON.parse(fs.readFileSync('./demo-components/src/modals.config.json', 'utf8')); const generatedFlowPath = path.join(process.cwd(), 'e2e', 'flows', GENERATED_FLOW_NAME); -const baseFlowPath = path.join(process.cwd(), 'e2e', 'flows', 'base.yaml'); +const baseFlowPath = path.join(process.cwd(), 'e2e', 'flows', isWeb ? 'base.web.yaml' : 'base.yaml'); const getBootedDevice = () => { if (specificDevice) { @@ -108,7 +122,7 @@ const getBootedDevice = () => { } } -const device = getBootedDevice(); +const device = isWeb ? null : getBootedDevice(); // Screenshots are keyed by a stable label, not the device UDID, so baselines stay reproducible // across machines. Pin the same simulator/emulator model when (re)generating expected screenshots. const screenshotDevice = deviceLabel || platform; @@ -152,28 +166,36 @@ const generateFlow = () => { // Run E2E tests using Maestro const runE2ETests = () => { - if (!device) { + if (!isWeb && !device) { logger.raiseException(`No booted ${platform} device found. Boot a simulator/emulator (or pass --device) before running e2e.`); } - logger.log(`Running E2E tests for app ${appId} on device: ${device}`); + let maestroCmd; + if (isWeb) { + logger.log(`Running web E2E tests against ${url}`); + maestroCmd = `maestro test --headless --screen-size ${WEB_SCREEN_SIZE} ${generatedFlowPath} -e URL=${url} -e APP_ID=${appId}`; + } else { + logger.log(`Running E2E tests for app ${appId} on device: ${device}`); + + // Freeze the status bar so screenshots are deterministic across runs (clock, battery, signal). + if (platform === 'ios') { + execSync(`xcrun simctl status_bar ${device} override --time "9:41" --batteryState charged --batteryLevel 100 --cellularMode active --cellularBars 4 --dataNetwork wifi --wifiMode active --wifiBars 3`, { stdio: 'ignore' }); + } else if (platform === 'android') { + // Android SystemUI demo mode: fixed clock/battery/signal. + const demo = (args) => execSync(`adb -s ${device} shell am broadcast -a com.android.systemui.demo ${args}`, { stdio: 'ignore' }); + execSync(`adb -s ${device} shell settings put global sysui_demo_allowed 1`, { stdio: 'ignore' }); + demo('-e command clock -e hhmm 0941'); + demo('-e command battery -e level 100 -e plugged false'); + demo('-e command network -e wifi show -e level 4'); + demo('-e command network -e mobile show -e datatype none -e level 4'); + demo('-e command notifications -e visible false'); + } - // Freeze the status bar so screenshots are deterministic across runs (clock, battery, signal). - if (platform === 'ios') { - execSync(`xcrun simctl status_bar ${device} override --time "9:41" --batteryState charged --batteryLevel 100 --cellularMode active --cellularBars 4 --dataNetwork wifi --wifiMode active --wifiBars 3`, { stdio: 'ignore' }); - } else if (platform === 'android') { - // Android SystemUI demo mode: fixed clock/battery/signal. - const demo = (args) => execSync(`adb -s ${device} shell am broadcast -a com.android.systemui.demo ${args}`, { stdio: 'ignore' }); - execSync(`adb -s ${device} shell settings put global sysui_demo_allowed 1`, { stdio: 'ignore' }); - demo('-e command clock -e hhmm 0941'); - demo('-e command battery -e level 100 -e plugged false'); - demo('-e command network -e wifi show -e level 4'); - demo('-e command network -e mobile show -e datatype none -e level 4'); - demo('-e command notifications -e visible false'); + maestroCmd = `maestro --device ${device} test ${generatedFlowPath} -e APP_ID=${appId}`; } try { - execSync(`maestro --device ${device} test ${generatedFlowPath} -e APP_ID=${appId}`, { stdio: 'inherit' }); + execSync(maestroCmd, { stdio: 'inherit' }); logger.log('E2E tests completed successfully.'); } catch (error) { logger.raiseException('E2E tests failed:', error.message); diff --git a/example/e2e/README.md b/example/e2e/README.md index bc7d83c..06bc57c 100644 --- a/example/e2e/README.md +++ b/example/e2e/README.md @@ -41,3 +41,17 @@ Useful flags: - `--device-label ` (stable label in screenshot file names) - `--run-steps generation tests images`, - `--silent` + +## Web behavior only + +Prerequisites: Maestro CLI 2.10.0+ + +```sh +npx expo start --web +``` + +Then in another terminal: + +```sh +npm run e2e:run -- --platform web --run-steps generation tests +``` diff --git a/example/e2e/flows/base.web.yaml b/example/e2e/flows/base.web.yaml new file mode 100644 index 0000000..ed36e0f --- /dev/null +++ b/example/e2e/flows/base.web.yaml @@ -0,0 +1,3 @@ +url: ${URL} +--- +- launchApp