From 4c3c7db679218f1aba7d5777b9ee488171f9e643 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Wed, 30 Oct 2024 17:48:25 +0100 Subject: [PATCH] test(e2e): Switch from Appium to Maestro (#4210) --- .github/workflows/e2e.yml | 27 ++-- dev-packages/e2e-tests/.gitignore | 3 +- dev-packages/e2e-tests/cli.mjs | 142 ++++------------- .../e2e-tests/maestro/captureException.yml | 5 + .../e2e-tests/maestro/captureMessage.yml | 5 + .../captureUnhandledPromiseRejection.yml | 5 + dev-packages/e2e-tests/maestro/close.yml | 7 + dev-packages/e2e-tests/maestro/crash.yml | 7 + .../maestro/utils/assertEventIdVisible.yml | 10 ++ .../maestro/utils/launchTestAppClear.yml | 10 ++ dev-packages/e2e-tests/package.json | 4 +- dev-packages/e2e-tests/src/EndToEndTests.tsx | 27 +++- .../e2e-tests/src/utils/fetchEvent.ts | 70 ++++---- .../e2e-tests/src/utils/getTestProps.ts | 25 --- dev-packages/e2e-tests/test/e2e.test.ts | 149 ------------------ dev-packages/e2e-tests/test/utils/waitFor.ts | 32 ---- yarn.lock | 33 ++++ 17 files changed, 178 insertions(+), 383 deletions(-) create mode 100644 dev-packages/e2e-tests/maestro/captureException.yml create mode 100644 dev-packages/e2e-tests/maestro/captureMessage.yml create mode 100644 dev-packages/e2e-tests/maestro/captureUnhandledPromiseRejection.yml create mode 100644 dev-packages/e2e-tests/maestro/close.yml create mode 100644 dev-packages/e2e-tests/maestro/crash.yml create mode 100644 dev-packages/e2e-tests/maestro/utils/assertEventIdVisible.yml create mode 100644 dev-packages/e2e-tests/maestro/utils/launchTestAppClear.yml delete mode 100644 dev-packages/e2e-tests/src/utils/getTestProps.ts delete mode 100644 dev-packages/e2e-tests/test/e2e.test.ts delete mode 100644 dev-packages/e2e-tests/test/utils/waitFor.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 6b151ac1cf..09e6a32a88 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -14,6 +14,8 @@ concurrency: env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + MAESTRO_VERSION: '1.39.0' + IOS_DEVICE: 'iPhone 14' jobs: diff_check: @@ -168,12 +170,10 @@ jobs: rn-version: '0.76.0' runs-on: macos-14 # uses m1 https://github.blog/changelog/2024-01-30-github-actions-macos-14-sonoma-is-now-available/ runtime: 'latest' - device: 'iPhone 14' - platform: ios rn-version: '0.65.3' runs-on: macos-13 runtime: 'latest' - device: 'iPhone 14' - platform: android runs-on: ubuntu-latest exclude: @@ -308,12 +308,10 @@ jobs: rn-version: '0.76.0' runs-on: macos-14 # uses m1 https://github.blog/changelog/2024-01-30-github-actions-macos-14-sonoma-is-now-available/ runtime: 'latest' - device: 'iPhone 14' - platform: ios rn-version: '0.65.3' runs-on: macos-latest runtime: 'latest' - device: 'iPhone 14' - platform: android runs-on: ubuntu-latest exclude: @@ -329,12 +327,18 @@ jobs: - rn-version: '0.76.0' platform: 'ios' rn-architecture: 'new' - env: - PLATFORM: ${{ matrix.platform }} - DEVICE: ${{ matrix.device }} steps: - uses: actions/checkout@v4 + - name: Install Maestro + uses: dniHze/maestro-test-action@bda8a93211c86d0a05b7a4597c5ad134566fbde4 # pin@v1.0.0 + with: + version: ${{env.MAESTRO_VERSION}} + + - name: Install iDB Companion + if: ${{ matrix.platform == 'ios' }} + run: brew tap facebook/fb && brew install facebook/fb/idb-companion + - uses: ./.github/actions/disk-cleanup if: ${{ matrix.platform == 'android' }} @@ -400,11 +404,10 @@ jobs: -timezone US/Pacific script: ./dev-packages/e2e-tests/cli.mjs ${{ matrix.platform }} --test - - uses: actions/cache@v4 + - uses: futureware-tech/simulator-action@bfa03d93ec9de6dacb0c5553bbf8da8afc6c2ee9 # pin@v3 if: ${{ matrix.platform == 'ios' }} with: - path: test/e2e/DerivedData/Build/Products/Debug-iphonesimulator/WebDriverAgentRunner-Runner.app - key: appium-webdriveragent-${{ hashFiles('test/e2e/yarn.lock') }} + model: ${{ env.IOS_DEVICE }} - name: Run tests on iOS if: ${{ matrix.platform == 'ios' }} @@ -415,6 +418,4 @@ jobs: uses: actions/upload-artifact@v4 with: name: ${{ matrix.rn-version }}-${{ matrix.rn-architecture }}-${{ matrix.engine }}-${{ matrix.platform }}-${{ matrix.build-type }}-${{ matrix.ios-use-frameworks }}-logs - path: | - test/e2e/*.log - test/e2e/*.png + path: ./dev-packages/e2e-tests/maestro-logs diff --git a/dev-packages/e2e-tests/.gitignore b/dev-packages/e2e-tests/.gitignore index e48ee6f7bd..9ea35d81b3 100644 --- a/dev-packages/e2e-tests/.gitignore +++ b/dev-packages/e2e-tests/.gitignore @@ -3,4 +3,5 @@ *.app *.apk -react-native-versions +/react-native-versions +/maestro-logs diff --git a/dev-packages/e2e-tests/cli.mjs b/dev-packages/e2e-tests/cli.mjs index 861e1275a2..4e1d49918f 100755 --- a/dev-packages/e2e-tests/cli.mjs +++ b/dev-packages/e2e-tests/cli.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node 'use strict'; -import { execSync, spawn } from 'child_process'; +import { execSync, execFileSync, spawn } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import { argv, env } from 'process'; @@ -64,8 +64,9 @@ const appRepoDir = `${e2eDir}/react-native-versions/${RNVersion}`; const appName = 'RnDiffApp'; const appDir = `${appRepoDir}/${appName}`; const testAppName = `${appName}.${platform == 'ios' ? 'app' : 'apk'}`; -const runtime = env.IOS_RUNTIME ? env.IOS_RUNTIME : 'latest'; -const device = env.IOS_DEVICE ? env.IOS_DEVICE : 'iPhone 15'; +const testApp = `${e2eDir}/${testAppName}`; +const appId = platform === 'ios' ? 'org.reactjs.native.example.RnDiffApp' : 'com.rndiffapp'; +const sentryAuthToken = env.SENTRY_AUTH_TOKEN; // Build and publish the SDK - we only need to do this once in CI. // Locally, we may want to get updates from the latest build so do it on every app build. @@ -143,9 +144,6 @@ if (actions.includes('create')) { }); if (fs.existsSync(`${appDir}/Gemfile`)) { - // TMP Fix for https://github.com/CocoaPods/Xcodeproj/issues/989 - fs.appendFileSync(`${appDir}/Gemfile`, "gem 'xcodeproj', '< 1.26.0'\n"); - execSync(`bundle install`, { stdio: 'inherit', cwd: appDir, env: env }); execSync('bundle exec pod install --repo-update', { stdio: 'inherit', cwd: `${appDir}/ios`, env: env }); } else { @@ -190,7 +188,8 @@ if (actions.includes('build')) { -workspace ${appName}.xcworkspace \ -configuration ${buildType} \ -scheme ${appName} \ - -destination 'platform=iOS Simulator,OS=${runtime},name=${device}' \ + -sdk 'iphonesimulator' \ + -destination 'generic/platform=iOS Simulator' \ ONLY_ACTIVE_ARCH=yes \ -derivedDataPath DerivedData \ build | tee xcodebuild.log | xcbeautify`, @@ -207,123 +206,40 @@ if (actions.includes('build')) { appProduct = `${appDir}/android/app/build/outputs/apk/release/app-release.apk`; } - var testApp = `${e2eDir}/${testAppName}`; console.log(`Moving ${appProduct} to ${testApp}`); if (fs.existsSync(testApp)) fs.rmSync(testApp, { recursive: true }); fs.renameSync(appProduct, testApp); } if (actions.includes('test')) { - if ( - platform == 'ios' && - !fs.existsSync(`${e2eDir}/DerivedData/Build/Products/Debug-iphonesimulator/WebDriverAgentRunner-Runner.app`) - ) { - // Build iOS WebDriverAgent - execSync( - `set -o pipefail && xcodebuild \ - -project node_modules/appium-webdriveragent/WebDriverAgent.xcodeproj \ - -scheme WebDriverAgentRunner \ - -destination 'platform=iOS Simulator,OS=${runtime},name=${device}' \ - GCC_TREAT_WARNINGS_AS_ERRORS=0 \ - COMPILER_INDEX_STORE_ENABLE=NO \ - ONLY_ACTIVE_ARCH=yes \ - -derivedDataPath DerivedData \ - build | tee xcodebuild-agent.log | xcbeautify`, - { stdio: 'inherit', cwd: e2eDir, env: env }, - ); - } - - // Start the appium server. - var processesToKill = {}; - async function newProcess(name, process) { - await new Promise((resolve, reject) => { - process.on('error', e => { - console.error(`Failed to start process '${name}': ${e}`); - reject(e); - }); - process.on('spawn', () => { - console.log(`Process '${name}' (${process.pid}) started`); - resolve(); - }); - }); - - processesToKill[name] = { - process: process, - complete: new Promise((resolve, _reject) => { - process.on('close', resolve); - }), - }; - } - await newProcess( - 'appium', - spawn('node_modules/.bin/appium', ['--log-timestamp', '--log-no-colors', '--log', `appium${platform}.log`], { - stdio: 'inherit', - cwd: e2eDir, - env: env, - shell: false, - }), - ); - - try { - await waitForAppium(); - - // Run e2e tests - const testEnv = env; - testEnv.PLATFORM = platform; - testEnv.APPIUM_APP = `./${testAppName}`; - - if (platform == 'ios') { - testEnv.APPIUM_DERIVED_DATA = 'DerivedData'; - } else if (platform == 'android') { - execSync(`adb devices -l`, { stdio: 'inherit', cwd: e2eDir, env: env }); - - execSync(`adb logcat -c`, { stdio: 'inherit', cwd: e2eDir, env: env }); - - var adbLogStream = fs.createWriteStream(`${e2eDir}/adb.log`); - const adbLogProcess = spawn('adb', ['logcat'], { cwd: e2eDir, env: env, shell: false }); - adbLogProcess.stdout.pipe(adbLogStream); - adbLogProcess.stderr.pipe(adbLogStream); - adbLogProcess.on('close', () => adbLogStream.close()); - await newProcess('adb logcat', adbLogProcess); - } - - execSync(`yarn test:e2e:runner --verbose`, { stdio: 'inherit', cwd: e2eDir, env: testEnv }); - } finally { - for (const [name, info] of Object.entries(processesToKill)) { - console.log(`Sending termination signal to process '${name}' (${info.process.pid})`); - - // Send SIGTERM first to allow graceful shutdown. - info.process.kill(15); - - // Also send SIGKILL after 10 seconds. - const killTimeout = setTimeout(() => process.kill(9), '10000'); - - // Wait for the process to exit (either via SIGTERM or SIGKILL). - const code = await info.complete; - - // Successfully exited now, no need to kill (if it hasn't run yet). - clearTimeout(killTimeout); - - console.log(`Process '${name}' (${info.process.pid}) exited with code ${code}`); + // Run e2e tests + if (platform == 'ios') { + try { + execSync('xcrun simctl list devices | grep -q "(Booted)"'); + } catch (error) { + throw new Error('No simulator is currently booted. Please boot a simulator before running this script.'); } - } -} -async function waitForAppium() { - console.log('Waiting for Appium server to start...'); - for (let i = 0; i < 60; i++) { + execFileSync('xcrun', ['simctl', 'install', 'booted', testApp]); + } else if (platform == 'android') { try { - await fetch('http://127.0.0.1:4723/sessions', { method: 'HEAD' }); - console.log('Appium server started'); - return; + execSync('adb devices | grep -q "emulator"'); } catch (error) { - console.log(`Appium server hasn't started yet (${error})...`); - await sleep(1000); + throw new Error('No Android emulator is currently running. Please start an emulator before running this script.'); } + + execFileSync('adb', ['install', '-r', '-d', testApp]); } - throw new Error('Appium server failed to start'); -} -async function sleep(millis) { - return new Promise(resolve => setTimeout(resolve, millis)); + execSync( + `maestro test maestro \ + --env=APP_ID="${appId}" \ + --env=SENTRY_AUTH_TOKEN="${sentryAuthToken}" \ + --debug-output maestro-logs \ + --flatten-debug-output`, + { + stdio: 'inherit', + cwd: e2eDir, + }, + ); } diff --git a/dev-packages/e2e-tests/maestro/captureException.yml b/dev-packages/e2e-tests/maestro/captureException.yml new file mode 100644 index 0000000000..d768aae04a --- /dev/null +++ b/dev-packages/e2e-tests/maestro/captureException.yml @@ -0,0 +1,5 @@ +appId: ${APP_ID} +--- +- runFlow: utils/launchTestAppClear.yml +- tapOn: "Capture Exception" +- runFlow: utils/assertEventIdVisible.yml diff --git a/dev-packages/e2e-tests/maestro/captureMessage.yml b/dev-packages/e2e-tests/maestro/captureMessage.yml new file mode 100644 index 0000000000..40df1a88cd --- /dev/null +++ b/dev-packages/e2e-tests/maestro/captureMessage.yml @@ -0,0 +1,5 @@ +appId: ${APP_ID} +--- +- runFlow: utils/launchTestAppClear.yml +- tapOn: "Capture Message" +- runFlow: utils/assertEventIdVisible.yml diff --git a/dev-packages/e2e-tests/maestro/captureUnhandledPromiseRejection.yml b/dev-packages/e2e-tests/maestro/captureUnhandledPromiseRejection.yml new file mode 100644 index 0000000000..292f2593a6 --- /dev/null +++ b/dev-packages/e2e-tests/maestro/captureUnhandledPromiseRejection.yml @@ -0,0 +1,5 @@ +appId: ${APP_ID} +--- +- runFlow: utils/launchTestAppClear.yml +- tapOn: "Unhandled Promise Rejection" +- runFlow: utils/assertEventIdVisible.yml diff --git a/dev-packages/e2e-tests/maestro/close.yml b/dev-packages/e2e-tests/maestro/close.yml new file mode 100644 index 0000000000..e233373426 --- /dev/null +++ b/dev-packages/e2e-tests/maestro/close.yml @@ -0,0 +1,7 @@ +appId: ${APP_ID} +--- +- runFlow: utils/launchTestAppClear.yml +- tapOn: "Close" + +- assertNotVisible: + id: "eventId" diff --git a/dev-packages/e2e-tests/maestro/crash.yml b/dev-packages/e2e-tests/maestro/crash.yml new file mode 100644 index 0000000000..fb76f3529b --- /dev/null +++ b/dev-packages/e2e-tests/maestro/crash.yml @@ -0,0 +1,7 @@ +appId: ${APP_ID} +--- +- runFlow: utils/launchTestAppClear.yml +- tapOn: "Crash" + +- launchApp +- assertVisible: "E2E Tests Ready" diff --git a/dev-packages/e2e-tests/maestro/utils/assertEventIdVisible.yml b/dev-packages/e2e-tests/maestro/utils/assertEventIdVisible.yml new file mode 100644 index 0000000000..f7995a5f6b --- /dev/null +++ b/dev-packages/e2e-tests/maestro/utils/assertEventIdVisible.yml @@ -0,0 +1,10 @@ +appId: ${APP_ID} +--- +- extendedWaitUntil: + visible: + id: "eventId" + timeout: 600_000 # 10 minutes + +- copyTextFrom: + id: "eventId" +- assertTrue: ${maestro.copiedText} diff --git a/dev-packages/e2e-tests/maestro/utils/launchTestAppClear.yml b/dev-packages/e2e-tests/maestro/utils/launchTestAppClear.yml new file mode 100644 index 0000000000..3216e1ed89 --- /dev/null +++ b/dev-packages/e2e-tests/maestro/utils/launchTestAppClear.yml @@ -0,0 +1,10 @@ +appId: ${APP_ID} +--- +- launchApp: + clearState: true + arguments: + sentryAuthToken: ${SENTRY_AUTH_TOKEN} + +- extendedWaitUntil: + visible: "E2E Tests Ready" + timeout: 120_000 # 2 minutes diff --git a/dev-packages/e2e-tests/package.json b/dev-packages/e2e-tests/package.json index 6d7061caa1..094de1868a 100644 --- a/dev-packages/e2e-tests/package.json +++ b/dev-packages/e2e-tests/package.json @@ -6,7 +6,8 @@ "main": "dist/index.js", "scripts": { "build": "tsc --project tsconfig.build.json", - "test:e2e:runner": "NODE_OPTIONS=--experimental-vm-modules jest" + "test:ios": "./cli.mjs ios --create --build --test", + "test:android": "./cli.mjs android --create --build --test" }, "license": "MIT", "devDependencies": { @@ -29,6 +30,7 @@ }, "dependencies": { "minimist": "1.2.8", + "p-retry": "^6.2.0", "semver": "7.6.3", "xcode": "3.0.1" }, diff --git a/dev-packages/e2e-tests/src/EndToEndTests.tsx b/dev-packages/e2e-tests/src/EndToEndTests.tsx index b2ea625c0d..d6c38d9961 100644 --- a/dev-packages/e2e-tests/src/EndToEndTests.tsx +++ b/dev-packages/e2e-tests/src/EndToEndTests.tsx @@ -3,9 +3,10 @@ import * as React from 'react'; import { Text, View } from 'react-native'; import { LaunchArguments } from "react-native-launch-arguments"; -import { getTestProps } from './utils/getTestProps'; import { fetchEvent } from './utils/fetchEvent'; +const E2E_TESTS_READY_TEXT = 'E2E Tests Ready'; + const getSentryAuthToken = (): | { token: string } | { error: string } => { @@ -25,7 +26,8 @@ const getSentryAuthToken = (): }; const EndToEndTestsScreen = (): JSX.Element => { - const [eventId, setEventId] = React.useState(); + const [isReady, setIsReady] = React.useState(false); + const [eventId, setEventId] = React.useState(null); const [error, setError] = React.useState('No error'); async function assertEventReceived(eventId: string | undefined) { @@ -40,7 +42,12 @@ const EndToEndTestsScreen = (): JSX.Element => { return; } - await fetchEvent(eventId, value.token); + const event = await fetchEvent(eventId, value.token); + + if (event.event_id !== eventId) { + setError('Event ID mismatch'); + return; + } setEventId(eventId); } @@ -59,6 +66,8 @@ const EndToEndTestsScreen = (): JSX.Element => { assertEventReceived(e.event_id); return e; }; + + setIsReady(true); }, []); const testCases = [ @@ -82,17 +91,23 @@ const EndToEndTestsScreen = (): JSX.Element => { name: 'Close', action: async () => await Sentry.close(), }, + { + id: 'crash', + name: 'Crash', + action: () => Sentry.nativeCrash(), + }, ]; return ( + {isReady ? E2E_TESTS_READY_TEXT : 'Loading...'} {error} - {eventId} - setEventId('')}> + {eventId ? {eventId} : No event ID} + setEventId(null)}> Clear Event Id {testCases.map((testCase) => ( - + {testCase.name} ))} diff --git a/dev-packages/e2e-tests/src/utils/fetchEvent.ts b/dev-packages/e2e-tests/src/utils/fetchEvent.ts index 148cbee608..d792f9748e 100644 --- a/dev-packages/e2e-tests/src/utils/fetchEvent.ts +++ b/dev-packages/e2e-tests/src/utils/fetchEvent.ts @@ -1,23 +1,18 @@ +import pRetry from 'p-retry'; import type { Event } from '@sentry/types'; const domain = 'sentry.io'; -const eventEndpoint = '/api/0/projects/sentry-sdks/sentry-react-native/events/'; - -interface ApiEvent extends Event { - /** - * The event returned from the API uses eventID - */ - eventID: string; -} +const eventEndpoint = 'api/0/projects/sentry-sdks/sentry-react-native/events'; const RETRY_COUNT = 600; -const RETRY_INTERVAL = 1000; +const FIRST_RETRY_MS = 1_000; +const MAX_RETRY_TIMEOUT = 5_000; -const fetchEvent = async (eventId: string, authToken: string): Promise => { - const url = `https://${domain}${eventEndpoint}${eventId}/`; +const fetchEvent = async (eventId: string, authToken: string): Promise => { + const url = `https://${domain}/${eventEndpoint}/${eventId}/json/`; - const request = () => - fetch(url, { + const toRetry = async () => { + const response = await fetch(url, { headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json', @@ -25,36 +20,25 @@ const fetchEvent = async (eventId: string, authToken: string): Promise method: 'GET', }); - let retries = 0; - const retryer: (json: any) => Promise = (jsonResponse: any) => - new Promise((resolve, reject) => { - if (jsonResponse.detail === 'Event not found') { - if (retries < RETRY_COUNT) { - setTimeout(() => { - retries++; - // eslint-disable-next-line no-console - console.log(`Retrying api request. Retry number: ${retries}`); - resolve( - request() - .then(res => res.json()) - .then(retryer), - ); - }, RETRY_INTERVAL); - } else { - reject(new Error('Could not fetch event within retry limit.')); - } - } else { - console.log('Fetched event', jsonResponse.detail); - resolve(jsonResponse); - } - }); - - const json: ApiEvent = (await request() - // tslint:disable-next-line: no-unsafe-any - .then(res => res.json()) - .then(retryer)) as ApiEvent; - - return json; + const json = (await response.json()) as Event; + if (!json.event_id) { + throw new Error('No event ID found in the response'); + } + + return json; + }; + + const response = await pRetry(toRetry, { + retries: RETRY_COUNT, + minTimeout: FIRST_RETRY_MS, + maxTimeout: MAX_RETRY_TIMEOUT, + factor: 2, + onFailedAttempt: e => { + console.log(`Failed attempt ${e.attemptNumber} of ${RETRY_COUNT}: ${e.message}`); + }, + }); + + return response; }; export { fetchEvent }; diff --git a/dev-packages/e2e-tests/src/utils/getTestProps.ts b/dev-packages/e2e-tests/src/utils/getTestProps.ts deleted file mode 100644 index 838848e652..0000000000 --- a/dev-packages/e2e-tests/src/utils/getTestProps.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Platform } from 'react-native'; - -/** - * Each platform uses different test ids There is a bug in Appium where accessibilityLabel does not work on iOS so we need testID, - * and testID does not work on Android so we need accessibilityLabel, - * @param id - * @param platform - */ -const getTestProps = ( - id: string, -): { - accessibilityLabel?: string; - accessible?: boolean; - testID?: string; -} => - Platform.OS === 'android' - ? { - accessibilityLabel: id, - accessible: true, - } - : { - testID: id, - }; - -export { getTestProps }; diff --git a/dev-packages/e2e-tests/test/e2e.test.ts b/dev-packages/e2e-tests/test/e2e.test.ts deleted file mode 100644 index d68692f95d..0000000000 --- a/dev-packages/e2e-tests/test/e2e.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -/* eslint-disable import/no-unresolved */ -import path from 'path'; -import type { RemoteOptions } from 'webdriverio'; -import { remote } from 'webdriverio'; - -import { waitForTruthyResult } from './utils/waitFor'; -import { expect, jest, beforeAll, afterAll, beforeEach, afterEach, describe, test } from '@jest/globals'; - -const DRIVER_NOT_INITIALIZED = 'Driver not initialized'; - -const T_20_MINUTES_IN_MS = 20 * 60e3; -jest.setTimeout(T_20_MINUTES_IN_MS); - -let driver: WebdriverIO.Browser | null = null; - -const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); - -async function getElement(accessibilityId: string): Promise { - if (!driver) { - throw new Error(DRIVER_NOT_INITIALIZED); - } - const element = await driver.$(`~${accessibilityId}`); - await element.waitForDisplayed({ timeout: 60_000 }); - return element; -} - -async function waitForEventId(): Promise { - const element = await getElement('eventId'); - let value: string; - await waitForTruthyResult(async () => { - value = await element.getText(); - return value.length > 0; - }); - return value!; -} - -async function waitUntilEventIdIsEmpty() { - const element = await getElement('eventId'); - await waitForTruthyResult(async () => (await element.getText()).length === 0); -} - -beforeAll(async () => { - const conf: RemoteOptions = { - logLevel: 'info', - port: 4723, - capabilities: {}, - }; - - if (process.env.APPIUM_APP === undefined) { - throw new Error('APPIUM_APP environment variable must be set'); - } - if (process.env.PLATFORM === 'ios' && process.env.APPIUM_DERIVED_DATA === undefined) { - throw new Error('APPIUM_DERIVED_DATA environment variable must be set'); - } - - if (process.env.PLATFORM === 'android') { - conf.capabilities = { - platformName: 'Android', - 'appium:automationName': 'UIAutomator2', - 'appium:app': process.env.APPIUM_APP, - 'appium:optionalIntentArguments': `--es sentryAuthToken '${process.env.SENTRY_AUTH_TOKEN}'`, - }; - } else { - conf.capabilities = { - platformName: 'iOS', - 'appium:automationName': 'XCUITest', - 'appium:app': process.env.APPIUM_APP, - // DerivedData of the WebDriverRunner Xcode project. - 'appium:derivedDataPath': path.resolve(process.env.APPIUM_DERIVED_DATA || ''), - 'appium:showXcodeLog': true, - 'appium:usePrebuiltWDA': true, - 'appium:processArguments': { args: ['-sentryAuthToken', `${process.env.SENTRY_AUTH_TOKEN}`] }, - }; - } - - if (process.env.DEVICE !== undefined) { - conf.capabilities['appium:deviceName'] = process.env.DEVICE; - } - - // 5 minutes - to accommodate the timeouts for things like getting events from Sentry. - conf.capabilities['appium:newCommandTimeout'] = 300_000; - - driver = await remote(conf); - - const maxInitTries = 3; - for (let i = 1; i <= maxInitTries; i++) { - if (i === maxInitTries) { - await getElement('eventId'); - } else { - try { - await getElement('eventId'); - break; - } catch (error) { - // eslint-disable-next-line no-console - console.log(error); - } - } - } -}); - -describe('End to end tests for common events', () => { - afterAll(async () => { - await driver?.deleteSession(); - }); - - beforeEach(async () => { - const element = await getElement('clearEventId'); - await element.click(); - await waitUntilEventIdIsEmpty(); - }); - - afterEach(async () => { - const testName = expect.getState().currentTestName; - const fileName = `screen-${testName}.png`.replace(/[^0-9a-zA-Z-+.]/g, '_'); - await driver?.saveScreenshot(fileName); - }); - - test('captureMessage', async () => { - const element = await getElement('captureMessage'); - await element.click(); - - await waitForEventId(); - }); - - test('captureException', async () => { - const element = await getElement('captureException'); - await element.click(); - - await waitForEventId(); - }); - - test('unhandledPromiseRejection', async () => { - const element = await getElement('unhandledPromiseRejection'); - await element.click(); - - await waitForEventId(); - }); - - test('close', async () => { - const element = await getElement('close'); - await element.click(); - - // Wait a while in case it gets set. - await sleep(5000); - - // This time we don't expect an eventId. - await waitUntilEventIdIsEmpty(); - }); -}); diff --git a/dev-packages/e2e-tests/test/utils/waitFor.ts b/dev-packages/e2e-tests/test/utils/waitFor.ts deleted file mode 100644 index 15f37a8526..0000000000 --- a/dev-packages/e2e-tests/test/utils/waitFor.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* eslint-disable import/no-unresolved */ -import { expect } from '@jest/globals'; - -const RETRY_TIMEOUT_MS = 1000; -const FINAL_TIMEOUT_MS = 1 * 60 * 1000; - -export async function waitForTruthyResult(value: () => Promise): Promise { - const promise = new Promise((resolve, reject) => { - // eslint-disable-next-line prefer-const - let timeout: NodeJS.Timeout; - // eslint-disable-next-line prefer-const - let interval: NodeJS.Timer; - - // eslint-disable-next-line prefer-const - interval = setInterval(async () => { - const result = await value(); - if (result) { - clearInterval(interval); - clearTimeout(timeout); - resolve(result); - } - }, RETRY_TIMEOUT_MS); - - // eslint-disable-next-line prefer-const - timeout = setTimeout(() => { - clearInterval(interval); - reject(new Error(`waitForTruthyResult function timed out after ${FINAL_TIMEOUT_MS} ms`)); - }, FINAL_TIMEOUT_MS); - }); - - await expect(promise).resolves.toBeTruthy(); -} diff --git a/yarn.lock b/yarn.lock index 118c767f58..c7a878fba0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7366,6 +7366,13 @@ __metadata: languageName: node linkType: hard +"@types/retry@npm:0.12.2": + version: 0.12.2 + resolution: "@types/retry@npm:0.12.2" + checksum: e5675035717b39ce4f42f339657cae9637cf0c0051cf54314a6a2c44d38d91f6544be9ddc0280587789b6afd056be5d99dbe3e9f4df68c286c36321579b1bf4a + languageName: node + linkType: hard + "@types/scheduler@npm:*": version: 0.23.0 resolution: "@types/scheduler@npm:0.23.0" @@ -15351,6 +15358,13 @@ __metadata: languageName: node linkType: hard +"is-network-error@npm:^1.0.0": + version: 1.1.0 + resolution: "is-network-error@npm:1.1.0" + checksum: b2fe6aac07f814a9de275efd05934c832c129e7ba292d27614e9e8eec9e043b7a0bbeaeca5d0916b0f462edbec2aa2eaee974ee0a12ac095040e9515c222c251 + languageName: node + linkType: hard + "is-node-process@npm:^1.2.0": version: 1.2.0 resolution: "is-node-process@npm:1.2.0" @@ -19936,6 +19950,17 @@ __metadata: languageName: node linkType: hard +"p-retry@npm:^6.2.0": + version: 6.2.0 + resolution: "p-retry@npm:6.2.0" + dependencies: + "@types/retry": 0.12.2 + is-network-error: ^1.0.0 + retry: ^0.13.1 + checksum: 6003573c559ee812329c9c3ede7ba12a783fdc8dd70602116646e850c920b4597dc502fe001c3f9526fca4e93275045db7a27341c458e51db179c1374a01ac44 + languageName: node + linkType: hard + "p-settle@npm:^3.0.0": version: 3.1.0 resolution: "p-settle@npm:3.1.0" @@ -22322,6 +22347,13 @@ __metadata: languageName: node linkType: hard +"retry@npm:^0.13.1": + version: 0.13.1 + resolution: "retry@npm:0.13.1" + checksum: 47c4d5be674f7c13eee4cfe927345023972197dbbdfba5d3af7e461d13b44de1bfd663bfc80d2f601f8ef3fc8164c16dd99655a221921954a65d044a2fc1233b + languageName: node + linkType: hard + "reusify@npm:^1.0.4": version: 1.0.4 resolution: "reusify@npm:1.0.4" @@ -22706,6 +22738,7 @@ __metadata: babel-jest: ^29.7.0 jest: ^29.7.0 minimist: 1.2.8 + p-retry: ^6.2.0 react: 18.3.1 react-native: 0.75.4 react-native-launch-arguments: ^4.0.2