enulator test (#1)

* enulator test

* enulator fix

* fix adb name

* fix path for detox

* fix bundeling

* fix api

* fix path

* fix path

* path

* path

* path

* path

* fix: start Metro before emulator-runner to avoid pipe block

* add bare-minimum e2e workflow using release build (no Metro)

* fix: use debug instrumentation APK against release app build

* fix: add Detox androidTest dep and testInstrumentationRunner, use debug build

* fix: add version to detox androidTestImplementation

* fix: add Detox local Maven repo to allprojects

* fix: remove androidTestImplementation detox, not needed with auto-linking

* fix: add androidx.test:runner to provide AndroidJUnitRunner in test APK

* fix: add proper Detox test class and dependency with exclusiveContent

* fix: correct Detox maven coordinate to com.wix:detox

* fix: override Detox minSdk 24 requirement for androidTest

* remove viewer.e2e.ts, requires launch args bridge not yet wired up

* cleanup: remove E2E from build-android workflow, revert release detox config

* update github action

* enable ABI splits for smaller per-arch release APKs

* fix: move abiCodes into closure scope

* fix: only split release builds, keep universal debug APK for E2E
This commit is contained in:
2026-04-18 22:32:40 +05:30
committed by GitHub
parent ab38f47852
commit e61ca8a629
17 changed files with 3400 additions and 58 deletions

47
.detoxrc.js Normal file
View File

@@ -0,0 +1,47 @@
/** @type {Detox.DetoxConfig} */
module.exports = {
testRunner: {
args: {
$0: 'jest',
config: 'e2e/jest.config.js',
},
jest: {
setupTimeout: 120000,
},
},
apps: {
'android.debug': {
type: 'android.apk',
binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk',
testBinaryPath:
'android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk',
build:
'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug --no-daemon',
reversePorts: [8081],
},
'android.release': {
type: 'android.apk',
binaryPath: 'android/app/build/outputs/apk/release/app-release.apk',
build:
'cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release --no-daemon',
},
},
devices: {
emulator: {
type: 'android.emulator',
// avdName is set by reactivecircus/android-emulator-runner in CI.
// For local runs, create an AVD named below or override via DETOX_AVD_NAME.
device: {avdName: process.env.DETOX_AVD_NAME ?? 'Pixel_6_API_34'},
},
},
configurations: {
'android.emu.debug': {
device: 'emulator',
app: 'android.debug',
},
'android.emu.release': {
device: 'emulator',
app: 'android.release',
},
},
};

View File

@@ -1,4 +1,4 @@
name: Build Android APK
name: Build Android
on:
push:
@@ -27,23 +27,22 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Java
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
- name: Setup Node
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 20
# Cache node_modules directly — faster than re-running npm ci on every build
- name: Cache node_modules
id: cache-node-modules
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: node_modules
key: node-modules-${{ hashFiles('package-lock.json') }}
@@ -58,7 +57,7 @@ jobs:
# automatically invalidates the cache and installs the new versions.
- name: Cache Android SDK components
id: cache-android-sdk
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: /home/runner/android-sdk
key: android-sdk-${{ hashFiles('android/build.gradle') }}
@@ -73,9 +72,8 @@ jobs:
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
"platforms;android-35" "build-tools;35.0.0" "platform-tools"
# Gradle cache must be restored before the build, not after
- name: Cache Gradle
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
~/.gradle/caches
@@ -102,12 +100,12 @@ jobs:
- name: Build APK
working-directory: android
run: |
BUILD_TYPE=${{ github.event.inputs.build_type || 'release' }}
./gradlew assemble$(echo "${BUILD_TYPE^}") --no-daemon
run: ./gradlew assemble$(echo "${BUILD_TYPE^}") --no-daemon
env:
BUILD_TYPE: ${{ github.event.inputs.build_type || 'release' }}
- name: Upload APK artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: LensApp-${{ github.event.inputs.build_type || 'release' }}-${{ github.run_number }}
path: android/app/build/outputs/apk/${{ github.event.inputs.build_type || 'release' }}/*.apk

120
.github/workflows/e2e-android.yml vendored Normal file
View File

@@ -0,0 +1,120 @@
name: E2E Android (Detox)
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
workflow_dispatch:
jobs:
e2e:
name: Detox E2E (debug)
runs-on: ubuntu-latest
env:
ANDROID_HOME: /home/runner/android-sdk
ANDROID_SDK_ROOT: /home/runner/android-sdk
steps:
- uses: actions/checkout@v6
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
- uses: actions/setup-node@v6
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Cache Android SDK
id: cache-sdk
uses: actions/cache@v5
with:
path: /home/runner/android-sdk
key: android-sdk-${{ hashFiles('android/build.gradle') }}
- name: Install Android SDK
if: steps.cache-sdk.outputs.cache-hit != 'true'
run: |
mkdir -p $ANDROID_HOME/cmdline-tools
curl -sSL https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -o cmdline-tools.zip
unzip -q cmdline-tools.zip -d $ANDROID_HOME/cmdline-tools
mv $ANDROID_HOME/cmdline-tools/cmdline-tools $ANDROID_HOME/cmdline-tools/latest
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
"platforms;android-35" "build-tools;35.0.0" "platform-tools"
- name: Cache Gradle
uses: actions/cache@v5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
android/.gradle
key: gradle-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/**/*.gradle') }}
restore-keys: gradle-
- name: chmod gradlew
run: chmod +x android/gradlew
# debuggableVariants=[] in build.gradle means JS is bundled into debug APK too,
# so no Metro server is needed at runtime.
- name: Build APKs
working-directory: android
run: ./gradlew assembleDebug assembleAndroidTest --no-daemon
- 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
# Cache the AVD snapshot to speed up subsequent runs
- name: Cache AVD snapshot
uses: actions/cache@v5
id: avd-cache
with:
path: |
~/.android/avd/*
~/.android/adb*
key: avd-34-x86_64-google_apis
- name: Create AVD snapshot
if: steps.avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
target: google_apis
arch: x86_64
profile: pixel_6
avd-name: Pixel_6_API_34
force-avd-creation: false
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: false
script: echo "AVD snapshot ready"
- name: Run Detox E2E
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
target: google_apis
arch: x86_64
profile: pixel_6
avd-name: Pixel_6_API_34
force-avd-creation: false
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
script: npx detox test --configuration android.emu.debug --headless --record-logs failing
- name: Upload Detox logs
if: failure()
uses: actions/upload-artifact@v7
with:
name: detox-logs-${{ github.run_number }}
path: artifacts/
retention-days: 7

20
App.tsx
View File

@@ -1,4 +1,4 @@
import React, {useState, useCallback} from 'react';
import React, {useState, useCallback, useEffect} from 'react';
import {Alert} from 'react-native';
import HomeScreen from './src/screens/HomeScreen';
import ScanScreen from './src/screens/ScanScreen';
@@ -14,6 +14,24 @@ export default function App() {
useDocumentStore();
const [screen, setScreen] = useState<Screen>('home');
// Seed a fake document when launched with detoxSeedDocument=1 (E2E only)
useEffect(() => {
if (__DEV__ || process.env.DETOX_SEED) {
const args = (global as any).__detox_launch_args ?? {};
if (args.detoxSeedDocument === '1') {
createDocument([
{
id: 'seed-page-1',
uri: 'https://via.placeholder.com/600x800.jpg',
width: 600,
height: 800,
},
]);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [pendingPages, setPendingPages] = useState<ScannedPage[]>([]);
const [viewingDoc, setViewingDoc] = useState<ScannedDocument | null>(null);

View File

@@ -18,10 +18,10 @@ react {
// cliFile = file("../node_modules/react-native/cli.js")
/* Variants */
// The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
// debuggableVariants = ["liteDebug", "prodDebug"]
// Empty list means the JS bundle is embedded in ALL variants including debug.
// This is required for E2E tests in CI where no Metro server is running.
// For local dev, Metro still takes precedence if the packager is running.
debuggableVariants = []
/* Bundling */
// A list containing the node command and its flags. Default is just 'node'.
@@ -81,6 +81,7 @@ android {
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
debug {
@@ -102,12 +103,38 @@ android {
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
}
}
splits {
abi {
reset()
// Only split release builds — debug keeps a single universal APK
// so Detox/E2E can find the expected app-debug.apk path.
enable gradle.startParameter.taskNames.any { it.toLowerCase().contains("release") }
universalApk false
include (*project.property("reactNativeArchitectures").split(","))
}
}
}
// Give each per-ABI APK a distinct versionCode so Play Store can serve them
// side-by-side. Multiplier leaves plenty of room for the app's own versionCode.
android.applicationVariants.all { variant ->
def abiCodes = ["armeabi-v7a": 1, "x86": 2, "arm64-v8a": 3, "x86_64": 4]
variant.outputs.each { output ->
def abi = output.getFilter(com.android.build.OutputFile.ABI)
if (abi != null) {
output.versionCodeOverride = abiCodes.get(abi) * 1048576 + variant.versionCode
}
}
}
dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
implementation("com.facebook.react:flipper-integration")
androidTestImplementation("androidx.test:runner:1.6.2")
androidTestImplementation("androidx.test:rules:1.6.1")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("com.wix:detox:20.50.1") { transitive = true }
if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android")

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-sdk tools:overrideLibrary="com.wix.detox" />
</manifest>

View File

@@ -0,0 +1,30 @@
package com.lensapp;
import com.wix.detox.Detox;
import com.wix.detox.config.DetoxConfig;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
import androidx.test.rule.ActivityTestRule;
@RunWith(AndroidJUnit4.class)
@LargeTest
public class DetoxTest {
@Rule
public ActivityTestRule<MainActivity> mActivityRule =
new ActivityTestRule<>(MainActivity.class, false, false);
@Test
public void runDetoxTests() {
DetoxConfig detoxConfig = new DetoxConfig();
detoxConfig.idlePolicyConfig.masterTimeoutSec = 90;
detoxConfig.idlePolicyConfig.idleResourceTimeoutSec = 60;
detoxConfig.rnContextLoadTimeoutSec = 180;
Detox.runTests(mActivityRule, detoxConfig);
}
}

View File

@@ -19,3 +19,21 @@ buildscript {
}
apply plugin: "com.facebook.react.rootproject"
allprojects {
repositories {
// Route com.wix.detox lookups to Detox's bundled local Maven repo so
// they bypass JitPack (which RN's Gradle plugin adds as a fallback and
// which returns an HTML error page instead of a valid POM).
exclusiveContent {
forRepository {
maven {
url "$rootDir/../node_modules/detox/Detox-android"
}
}
filter {
includeModule "com.wix", "detox"
}
}
}
}

28
e2e/home.e2e.ts Normal file
View File

@@ -0,0 +1,28 @@
import {device, element, by, expect as detoxExpect} from 'detox';
describe('Home Screen', () => {
beforeAll(async () => {
await device.launchApp({newInstance: true});
});
beforeEach(async () => {
await device.reloadReactNative();
});
it('shows the app title', async () => {
await detoxExpect(element(by.text('Lens'))).toBeVisible();
});
it('shows empty state when no documents exist', async () => {
await detoxExpect(element(by.id('empty-state'))).toBeVisible();
await detoxExpect(element(by.text('No scans yet'))).toBeVisible();
});
it('shows the scan FAB', async () => {
await detoxExpect(element(by.id('scan-fab'))).toBeVisible();
});
it('scan FAB label reads Scan', async () => {
await detoxExpect(element(by.text('Scan'))).toBeVisible();
});
});

12
e2e/jest.config.js Normal file
View File

@@ -0,0 +1,12 @@
/** @type {import('@jest/types').Config.InitialOptions} */
module.exports = {
rootDir: '..',
testMatch: ['<rootDir>/e2e/**/*.e2e.ts'],
testTimeout: 120000,
maxWorkers: 1,
globalSetup: 'detox/runners/jest/globalSetup',
globalTeardown: 'detox/runners/jest/globalTeardown',
reporters: ['detox/runners/jest/reporter'],
testEnvironment: 'detox/runners/jest/testEnvironment',
verbose: true,
};

8
e2e/tsconfig.json Normal file
View File

@@ -0,0 +1,8 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": ["detox", "jest"]
},
"include": ["**/*.e2e.ts", "jest.config.js"],
"exclude": []
}

3085
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,8 @@
"lint": "eslint .",
"start": "react-native start",
"test": "jest",
"test:e2e:build": "detox build --configuration android.emu.debug",
"test:e2e": "detox test --configuration android.emu.debug --headless",
"postinstall": "patch-package"
},
"dependencies": {
@@ -29,11 +31,14 @@
"@react-native/typescript-config": "^0.73.1",
"@testing-library/jest-native": "^5.4.3",
"@testing-library/react-native": "^13.3.3",
"@types/jest": "^30.0.0",
"@types/react": "^18.2.6",
"@types/react-test-renderer": "^18.0.0",
"babel-jest": "^29.6.3",
"detox": "^20.50.1",
"eslint": "^8.19.0",
"jest": "^29.6.3",
"jest-circus": "^30.3.0",
"patch-package": "^8.0.1",
"prettier": "2.8.8",
"react-test-renderer": "18.2.0",

View File

@@ -12,7 +12,7 @@ interface Props {
export default function DocumentCard({document, onPress, onDelete}: Props) {
const thumb = document.pages[0]?.uri;
return (
<TouchableOpacity style={styles.card} onPress={onPress} activeOpacity={0.8}>
<TouchableOpacity testID={`document-card-${document.id}`} style={styles.card} onPress={onPress} activeOpacity={0.8}>
<View style={styles.thumbContainer}>
{thumb ? (
<Image source={{uri: thumb}} style={styles.thumb} resizeMode="cover" />
@@ -32,7 +32,7 @@ export default function DocumentCard({document, onPress, onDelete}: Props) {
{document.pages.length} {document.pages.length === 1 ? 'page' : 'pages'}
</Text>
</View>
<TouchableOpacity style={styles.deleteBtn} onPress={onDelete} hitSlop={{top: 8, bottom: 8, left: 8, right: 8}}>
<TouchableOpacity testID={`delete-card-${document.id}`} style={styles.deleteBtn} onPress={onDelete} hitSlop={{top: 8, bottom: 8, left: 8, right: 8}}>
<Text style={styles.deleteText}></Text>
</TouchableOpacity>
</TouchableOpacity>

View File

@@ -53,13 +53,14 @@ export default function HomeScreen({documents, onScan, onView, onDelete, onRenam
</View>
{documents.length === 0 ? (
<View style={styles.empty}>
<View testID="empty-state" style={styles.empty}>
<Text style={styles.emptyIcon}>📄</Text>
<Text style={styles.emptyTitle}>No scans yet</Text>
<Text style={styles.emptyHint}>Tap the button below to scan a document</Text>
</View>
) : (
<FlatList
testID="document-list"
data={documents}
keyExtractor={d => d.id}
renderItem={({item}) => (
@@ -73,16 +74,17 @@ export default function HomeScreen({documents, onScan, onView, onDelete, onRenam
/>
)}
<TouchableOpacity style={styles.fab} onPress={onScan} activeOpacity={0.85}>
<TouchableOpacity testID="scan-fab" style={styles.fab} onPress={onScan} activeOpacity={0.85}>
<Text style={styles.fabIcon}></Text>
<Text style={styles.fabLabel}>Scan</Text>
</TouchableOpacity>
<Modal visible={!!renameTarget} transparent animationType="fade">
<View style={styles.modalBg}>
<View style={styles.modalBox}>
<View testID="rename-modal" style={styles.modalBox}>
<Text style={styles.modalTitle}>Rename Document</Text>
<TextInput
testID="rename-input"
style={styles.modalInput}
value={renameText}
onChangeText={setRenameText}
@@ -94,7 +96,7 @@ export default function HomeScreen({documents, onScan, onView, onDelete, onRenam
<TouchableOpacity onPress={() => setRenameTarget(null)} style={styles.modalBtn}>
<Text style={styles.modalCancel}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity onPress={submitRename} style={styles.modalBtn}>
<TouchableOpacity testID="rename-confirm" onPress={submitRename} style={styles.modalBtn}>
<Text style={styles.modalConfirm}>Rename</Text>
</TouchableOpacity>
</View>

View File

@@ -68,7 +68,7 @@ export default function ViewerScreen({document, onBack, onDelete, onRename}: Pro
<StatusBar barStyle="light-content" backgroundColor="#000" />
<View style={styles.header}>
<TouchableOpacity onPress={onBack} style={styles.headerBtn}>
<TouchableOpacity testID="viewer-back" onPress={onBack} style={styles.headerBtn}>
<Text style={styles.back}> Back</Text>
</TouchableOpacity>
<View style={styles.headerCenter}>
@@ -78,6 +78,7 @@ export default function ViewerScreen({document, onBack, onDelete, onRename}: Pro
</Text>
</View>
<TouchableOpacity
testID="share-pdf-btn"
onPress={() => sharePdf()}
style={styles.headerBtn}
disabled={generating}>

View File

@@ -1,3 +1,4 @@
{
"extends": "@react-native/typescript-config/tsconfig.json"
"extends": "@react-native/typescript-config/tsconfig.json",
"exclude": ["e2e"]
}