From ecbbabcf4dcbcee421f12f06e0181dc52b18b43c Mon Sep 17 00:00:00 2001 From: Ritesh Shukla Date: Thu, 5 Jun 2025 02:14:52 +0530 Subject: [PATCH] Maestro Setup for app (#407) Implementing Maestro tests against onboarding process --- .env | 2 + .github/workflows/maestro-test.yml | 86 +++++++++++++++++-- .gitignore | 3 + android/app/build.gradle | 3 + ios/Podfile.lock | 8 ++ maestro-tests/flow.yaml | 9 ++ package.json | 1 + scripts/maestro-android.js | 66 ++++++++++++++ scripts/updateEnv.js | 66 ++++++++++++++ .../Login/screens/server-address.tsx | 2 + .../Login/screens/server-authentication.tsx | 11 ++- src/components/OtaUpdates/index.tsx | 4 +- src/configs/config.ts | 6 ++ src/types/react-native-config.d.ts | 9 ++ yarn.lock | 5 ++ 15 files changed, 273 insertions(+), 8 deletions(-) create mode 100644 .env create mode 100644 scripts/maestro-android.js create mode 100644 scripts/updateEnv.js create mode 100644 src/configs/config.ts create mode 100644 src/types/react-native-config.d.ts diff --git a/.env b/.env new file mode 100644 index 00000000..6a842123 --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +OTA_UPDATE_ENABLED=true +IS_MAESTRO_BUILD = false \ No newline at end of file diff --git a/.github/workflows/maestro-test.yml b/.github/workflows/maestro-test.yml index 63300506..d485a189 100644 --- a/.github/workflows/maestro-test.yml +++ b/.github/workflows/maestro-test.yml @@ -4,11 +4,69 @@ on: workflow_dispatch: jobs: + build-android: + runs-on: macos-15 + outputs: + version: ${{ steps.setver.outputs.version }} + steps: + - name: 🛒 Checkout + uses: actions/checkout@v4 + with: + token: ${{ secrets.SIGNING_REPO_PAT }} + + - name: 🖥 Setup Node 20 + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: 💎 Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.0' + bundler-cache: true + + - name: 🍎 Run yarn init-android + run: yarn install --network-concurrency 1 + + - name: 💬 Disable OTA Updates and Enable Maestro Build + run: node scripts/updateEnv.js OTA_UPDATE_ENABLED=false IS_MAESTRO_BUILD=true + + - name: 🤫 Output TelemetryDeck Secrets to TelemetryDeck.json + run: | + echo "{" > telemetrydeck.json + echo "\"appID\": \"${{ secrets.TELEMETRYDECK_APPID }}\"," >> telemetrydeck.json + echo "\"clientUser\": \"anonymous\"," >> telemetrydeck.json + echo "\"app\": \"Jellify\"" >> telemetrydeck.json + echo "}" >> telemetrydeck.json + + + - name: 🤫 Output Glitchtip Secrets to Glitchtip.json + run: | + echo "{" > glitchtip.json + echo "\"dsn\": \"${{ secrets.GLITCHTIP_DSN }}\"" >> glitchtip.json + echo "}" >> glitchtip.json + + - name: ✅ Validate Config Files + run: | + node -e "JSON.parse(require('fs').readFileSync('telemetrydeck.json'))" + node -e "JSON.parse(require('fs').readFileSync('glitchtip.json'))" + + - name: 🚀 Run Android fastlane build + run: yarn fastlane:android:build + + - name: 📤 Upload Android Artifacts + uses: actions/upload-artifact@v4 + with: + name: android-artifacts + path: ./android/app/build/outputs/apk/release/*.apk + run-maestro-tests: runs-on: ubuntu-latest + needs: build-android env: - JELLYFIN_TEST_ADDRESS: ${{ secrets.JELLYFIN_TEST_ADDRESS }} + JELLYFIN_TEST_ADDRESS: ${{ secrets.JELLYFIN_TEST_URL }} JELLYFIN_TEST_USERNAME: ${{ secrets.JELLYFIN_TEST_USERNAME }} + JELLYFIN_TEST_PASSWORD: ${{ secrets.JELLYFIN_TEST_PASSWORD }} steps: - name: 🛒 Checkout @@ -24,12 +82,23 @@ jobs: run: export MAESTRO_VERSION=1.40.0; curl -Ls "https://get.maestro.mobile.dev" | bash - name: Set up JDK 17 - if: ${{ inputs.install-java == 'true' }} uses: actions/setup-java@v4 with: java-version: '17' distribution: 'zulu' + - name: 💎 Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.0' + bundler-cache: true + + - name: ⬇️ Download Android Artifacts + uses: actions/download-artifact@v4 + with: + name: android-artifacts + path: artifacts/ + - name: Enable KVM group perms shell: bash run: | @@ -41,7 +110,7 @@ jobs: id: run-tests uses: reactivecircus/android-emulator-runner@v2 with: - api-level: 24 + api-level: '24' arch: x86 ram-size: '8192M' heap-size: '4096M' @@ -50,6 +119,11 @@ jobs: disable-animations: false avd-name: e2e_emulator script: | - maestro test maestro-tests/flow.yaml \ - --env server_address=${{ env.JELLYFIN_TEST_ADDRESS }} \ - --env username=${{ env.JELLYFIN_TEST_USERNAME }} + node scripts/maestro-android.js ${{ env.JELLYFIN_TEST_ADDRESS }} ${{ env.JELLYFIN_TEST_USERNAME }} ${{ env.JELLYFIN_TEST_PASSWORD }} + - name: Store tests result + uses: actions/upload-artifact@v4.3.4 + if: always() + with: + name: TestResult + path: | + video.mp4 diff --git a/.gitignore b/.gitignore index e01735c2..c0795169 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,6 @@ yarn-error.log .expo dist/ web-build/ + +# Maestro Output +video.mp4 \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index 89d9ac72..9013d5a0 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -2,6 +2,9 @@ apply plugin: "com.android.application" apply plugin: "org.jetbrains.kotlin.android" apply plugin: "com.facebook.react" + +apply from: project(':react-native-config').projectDir.getPath() + "/dotenv.gradle" + /** * This is the configuration block to customize your React Native Android app. * By default you don't need to apply any configuration, just uncomment the lines you need. diff --git a/ios/Podfile.lock b/ios/Podfile.lock index a0323aed..6be5a32b 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1396,6 +1396,10 @@ PODS: - Yoga - react-native-carplay (2.4.1-beta.0): - React + - react-native-config (1.5.5): + - react-native-config/App (= 1.5.5) + - react-native-config/App (1.5.5): + - React-Core - react-native-mmkv (3.2.0): - DoubleConversion - glog @@ -2258,6 +2262,7 @@ DEPENDENCIES: - react-native-blob-util (from `../node_modules/react-native-blob-util`) - "react-native-blur (from `../node_modules/@react-native-community/blur`)" - react-native-carplay (from `../node_modules/react-native-carplay`) + - react-native-config (from `../node_modules/react-native-config`) - react-native-mmkv (from `../node_modules/react-native-mmkv`) - "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)" - react-native-ota-hot-update (from `../node_modules/react-native-ota-hot-update`) @@ -2405,6 +2410,8 @@ EXTERNAL SOURCES: :path: "../node_modules/@react-native-community/blur" react-native-carplay: :path: "../node_modules/react-native-carplay" + react-native-config: + :path: "../node_modules/react-native-config" react-native-mmkv: :path: "../node_modules/react-native-mmkv" react-native-netinfo: @@ -2550,6 +2557,7 @@ SPEC CHECKSUMS: react-native-blob-util: f6fbaf3935531be0b3c4bcaf46f7bff2802fd93b react-native-blur: 06d0f9906ecd6cde3a42de16c6cd829a2bf0710c react-native-carplay: 8f388f6f73e5e0f73ed154ad8794371343ee20c0 + react-native-config: 644074ab88db883fcfaa584f03520ec29589d7df react-native-mmkv: d3cc73d2554fafa20dc5b86386359034d1faf8ff react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187 react-native-ota-hot-update: e3c981113fc84b550db8a23d2a2d440bd91d9f4b diff --git a/maestro-tests/flow.yaml b/maestro-tests/flow.yaml index 21086aae..c1ea30ef 100644 --- a/maestro-tests/flow.yaml +++ b/maestro-tests/flow.yaml @@ -1,6 +1,12 @@ appId: com.jellify --- - launchApp +- tapOn: + text: "Allow" + optional: true +- tapOn: + text: "Allow" + optional: true - tapOn: id: "server_address_input" - inputText: "${server_address}" @@ -9,6 +15,9 @@ appId: com.jellify - assertVisible: "Sign in to Cosmonautical" - tapOn: id: "username_input" +- inputText: "${username}" +- tapOn: + id: "password_input" - inputText: "${password}" - tapOn: id: "sign_in_button" diff --git a/package.json b/package.json index e265f148..2ba99eca 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "react-native-background-actions": "^4.0.1", "react-native-blob-util": "^0.21.2", "react-native-carplay": "^2.4.1-beta.0", + "react-native-config": "^1.5.5", "react-native-device-info": "^14.0.4", "react-native-dns-lookup": "^1.0.6", "react-native-draggable-flatlist": "^4.0.3", diff --git a/scripts/maestro-android.js b/scripts/maestro-android.js new file mode 100644 index 00000000..6ce18be1 --- /dev/null +++ b/scripts/maestro-android.js @@ -0,0 +1,66 @@ +const { execSync, exec, spawn } = require('child_process') +const path = require('path') + +// Read arguments from CLI +const [, , serverAddress, username, password] = process.argv + +if (!serverAddress || !username || !password) { + console.error('Usage: node runMaestro.js ') + process.exit(1) +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function stopRecording(pid) { + try { + // Kill the adb screenrecord process + process.kill(pid, 'SIGINT') + + // Wait 3 seconds for file to finalize + await sleep(3000) + + // Pull the recorded file + execSync('adb pull /sdcard/screen.mp4 video.mp4', { stdio: 'inherit' }) + + // Optionally delete the file on device + execSync('adb shell rm /sdcard/screen.mp4') + + console.log('✅ Recording pulled and cleaned up') + } catch (err) { + console.error('❌ Failed to stop or pull recording:', err.message) + } +} + +;(async () => { + execSync('adb install ./artifacts/app-x86-release.apk', { stdio: 'inherit', env: process.env }) + execSync(`adb shell monkey -p com.jellify 1`, { stdio: 'inherit' }) + + const recording = spawn('adb', ['shell', 'screenrecord', '/sdcard/screen.mp4'], { + stdio: 'ignore', + detached: true, + }) + const pid = recording.pid + + try { + const MAESTRO_PATH = path.join(process.env.HOME, '.maestro', 'bin', 'maestro') + const FLOW_PATH = './maestro-tests/flow.yaml' + + const command = `${MAESTRO_PATH} test ${FLOW_PATH} \ + --env server_address=${serverAddress} \ + --env username=${username} \ + --env password=${password}` + + const output = execSync(command, { stdio: 'inherit', env: process.env }) + console.log('✅ Maestro test completed') + console.log(output) + await stopRecording(pid) + process.exit(0) + } catch (error) { + await stopRecording(pid) + execSync('pwd', { stdio: 'inherit' }) + console.error(`❌ Error: ${error.message}`) + process.exit(1) + } +})() diff --git a/scripts/updateEnv.js b/scripts/updateEnv.js new file mode 100644 index 00000000..1a09b4c8 --- /dev/null +++ b/scripts/updateEnv.js @@ -0,0 +1,66 @@ +const fs = require('fs') +const path = require('path') + +function parseArgs(args) { + const updates = {} + args.forEach((arg) => { + const [key, value] = arg.split('=') + if (key && value !== undefined) { + updates[key.trim()] = value.trim() + } + }) + return updates +} + +function updateEnvFile(filePath, updates) { + let envContent = '' + try { + envContent = fs.readFileSync(filePath, 'utf8') + } catch (err) { + console.error(`❌ Failed to read .env file at ${filePath}`, err.message) + return + } + + const lines = envContent.split(/\r?\n/) + const seenKeys = new Set() + + const updatedLines = lines.map((line) => { + if (!line.trim() || line.trim().startsWith('#')) return line + + const [key, ...rest] = line.split('=') + const trimmedKey = key.trim() + + if (updates.hasOwnProperty(trimmedKey)) { + seenKeys.add(trimmedKey) + return `${trimmedKey}=${updates[trimmedKey]}` + } + + return line + }) + + // Add any new keys not already in the file + Object.entries(updates).forEach(([key, value]) => { + if (!seenKeys.has(key)) { + updatedLines.push(`${key}=${value}`) + } + }) + + try { + fs.writeFileSync(filePath, updatedLines.join('\n'), 'utf8') + console.log('✅ .env file updated successfully') + } catch (err) { + console.error(`❌ Failed to write .env file:`, err.message) + } +} + +// Get CLI args (excluding node and script path) +const args = process.argv.slice(2) +if (args.length === 0) { + console.error('❗ Usage: node updateEnv.js KEY1=value1 KEY2=value2') + process.exit(1) +} + +const updates = parseArgs(args) +const envPath = path.resolve(__dirname, '../.env') + +updateEnvFile(envPath, updates) diff --git a/src/components/Login/screens/server-address.tsx b/src/components/Login/screens/server-address.tsx index 0571561f..97632215 100644 --- a/src/components/Login/screens/server-address.tsx +++ b/src/components/Login/screens/server-address.tsx @@ -16,6 +16,7 @@ import { useSettingsContext } from '../../../providers/Settings' import Icon from '../../Global/components/icon' import { PublicSystemInfo } from '@jellyfin/sdk/lib/generated-client/models' import { connectToServer } from '../../../api/mutations/login' +import { IS_MAESTRO_BUILD } from '../../../configs/config' export default function ServerAddress({ navigation, @@ -126,6 +127,7 @@ export default function ServerAddress({ onChangeText={setServerAddress} autoCapitalize='none' autoCorrect={false} + secureTextEntry={IS_MAESTRO_BUILD} // If Maestro build, don't show the server address as screen Records flex={1} placeholder='jellyfin.org' testID='server_address_input' diff --git a/src/components/Login/screens/server-authentication.tsx b/src/components/Login/screens/server-authentication.tsx index 5a5e8586..7e161cb7 100644 --- a/src/components/Login/screens/server-authentication.tsx +++ b/src/components/Login/screens/server-authentication.tsx @@ -13,6 +13,7 @@ import Icon from '../../Global/components/icon' import { useJellifyContext } from '../../../providers' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import Toast from 'react-native-toast-message' +import { IS_MAESTRO_BUILD } from '../../../configs/config' export default function ServerAuthentication({ navigation, @@ -78,7 +79,11 @@ export default function ServerAuthentication({ prependElement={} placeholder='Username' value={username} + style={ + IS_MAESTRO_BUILD ? { backgroundColor: '#000', color: '#000' } : undefined + } testID='username_input' + secureTextEntry={IS_MAESTRO_BUILD} // If Maestro build, don't show the username as screen Records onChangeText={(value: string | undefined) => setUsername(value)} autoCapitalize='none' autoCorrect={false} @@ -90,10 +95,14 @@ export default function ServerAuthentication({ prependElement={} placeholder='Password' value={password} + testID='password_input' + style={ + IS_MAESTRO_BUILD ? { backgroundColor: '#000', color: '#000' } : undefined + } onChangeText={(value: string | undefined) => setPassword(value)} autoCapitalize='none' autoCorrect={false} - secureTextEntry + secureTextEntry={IS_MAESTRO_BUILD} // If Maestro build, don't show the password as screen Records /> diff --git a/src/components/OtaUpdates/index.tsx b/src/components/OtaUpdates/index.tsx index b6ce287a..9589c282 100644 --- a/src/components/OtaUpdates/index.tsx +++ b/src/components/OtaUpdates/index.tsx @@ -12,6 +12,7 @@ import { import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated' import hotUpdate from 'react-native-ota-hot-update' import DeviceInfo from 'react-native-device-info' +import { OTA_UPDATE_ENABLED } from '../../configs/config' const version = DeviceInfo.getVersion() @@ -72,7 +73,8 @@ const GitUpdateModal = () => { } useEffect(() => { - if (__DEV__) { + console.log('OTA_UPDATE_ENABLED', OTA_UPDATE_ENABLED) + if (__DEV__ || !OTA_UPDATE_ENABLED) { return } onCheckGitVersion() diff --git a/src/configs/config.ts b/src/configs/config.ts new file mode 100644 index 00000000..607f957e --- /dev/null +++ b/src/configs/config.ts @@ -0,0 +1,6 @@ +import Config from 'react-native-config' + +const OTA_UPDATE_ENABLED = Config.OTA_UPDATE_ENABLED === 'true' +const IS_MAESTRO_BUILD = Config.IS_MAESTRO_BUILD === 'true' + +export { OTA_UPDATE_ENABLED, IS_MAESTRO_BUILD } diff --git a/src/types/react-native-config.d.ts b/src/types/react-native-config.d.ts new file mode 100644 index 00000000..9ef78166 --- /dev/null +++ b/src/types/react-native-config.d.ts @@ -0,0 +1,9 @@ +declare module 'react-native-config' { + export interface NativeConfig { + OTA_UPDATE_ENABLED?: string + IS_MAESTRO_BUILD?: string + } + + export const Config: NativeConfig + export default Config +} diff --git a/yarn.lock b/yarn.lock index d67f0cb4..b8c5ed97 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8147,6 +8147,11 @@ react-native-cli-bump-version@^1.5.1: resolved "https://registry.yarnpkg.com/react-native-cli-bump-version/-/react-native-cli-bump-version-1.5.1.tgz#12bc0aa6328413ed20b7a2599a4a908e0fff9945" integrity sha512-C7Vss+BBD4iNMnn2YR00cU+GDDPZ+LDmIqWoh3FPwI/LBsJ/Vp5qanwtyVYRPcIe7Cg1PPB8WdeZ8XcnqF5Klw== +react-native-config@^1.5.5: + version "1.5.5" + resolved "https://registry.yarnpkg.com/react-native-config/-/react-native-config-1.5.5.tgz#5977db58ff20b6fe9dd6fb28c815a9ba43d018d2" + integrity sha512-dGdLnBU0cd5xL5bF0ROTmHYbsstZnQKOEPfglvZi1vStvAjpld14X25K6mY3KGPTMWAzx6TbjKeq5dR+ILuMMA== + react-native-device-info@^14.0.4: version "14.0.4" resolved "https://registry.yarnpkg.com/react-native-device-info/-/react-native-device-info-14.0.4.tgz#56b24ace9ff29a66bdfc667209086421ed6cfdce"