Skip to content
Open
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
6 changes: 4 additions & 2 deletions .detoxrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,18 @@ module.exports = {
binaryPath: "android/app/build/outputs/apk/debug/app-debug.apk",
testBinaryPath:
"android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk",
// Use E2E entry file (skips widget registration for Detox compatibility)
build:
"cd android && ./gradlew :app:assembleDebug :app:assembleDebugAndroidTest -DtestBuildType=debug",
"cd android && ./gradlew :app:assembleDebug :app:assembleDebugAndroidTest -DtestBuildType=debug -Pexpo.e2e=true",
},
"android.release": {
type: "android.apk",
binaryPath: "android/app/build/outputs/apk/release/app-release.apk",
testBinaryPath:
"android/app/build/outputs/apk/androidTest/release/app-release-androidTest.apk",
// Use E2E entry file (skips widget registration for Detox compatibility)
build:
"cd android && ./gradlew :app:assembleRelease :app:assembleReleaseAndroidTest -DtestBuildType=release",
"cd android && ./gradlew :app:assembleRelease :app:assembleReleaseAndroidTest -DtestBuildType=release -Pexpo.e2e=true",
},
},
devices: {
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,4 @@ jobs:
run: yarn install --frozen-lockfile

- name: Run tests
run: yarn test
run: yarn test:unit && yarn test:components
222 changes: 222 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
name: E2E Tests

on:
push:
branches: [master]
pull_request:
branches: [master]
workflow_dispatch: # Allow manual triggering

jobs:
e2e-android:
name: Android E2E Tests
runs-on: ubuntu-latest
timeout-minutes: 60

steps:
- name: Free disk space
run: |
echo "Before cleanup:"
df -h
# Remove unnecessary large packages (keep Android SDK!)
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
sudo rm -rf /opt/hostedtoolcache/CodeQL
sudo rm -rf /usr/local/share/boost
sudo rm -rf /usr/share/swift
sudo docker image prune --all --force
echo "After cleanup:"
df -h

- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "yarn"

- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: "temurin"
java-version: "17"

- name: Setup Android SDK
uses: android-actions/setup-android@v3

- name: Install Nix
uses: cachix/install-nix-action@v27
with:
nix_path: nixpkgs=channel:nixos-unstable

- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-

- name: Install dependencies
run: yarn install --frozen-lockfile

- name: Build and load org-agenda-api container
run: |
echo "Building org-agenda-api container using locked revision from flake.lock..."
# Use the locked org-agenda-api revision from our flake.lock to ensure compatibility
ORG_API_REV=$(nix flake metadata --json . | jq -r '.locks.nodes."org-agenda-api".locked.rev')
echo "Using org-agenda-api revision: $ORG_API_REV"
nix build "github:colonelpanic8/org-agenda-api/$ORG_API_REV#container" -o result-container

# Load and capture the image name from docker load output
LOAD_OUTPUT=$(docker load < result-container)
echo "Docker load output: $LOAD_OUTPUT"

# Extract image name from "Loaded image: <name>" output
IMAGE_NAME=$(echo "$LOAD_OUTPUT" | grep -oP 'Loaded image: \K.*' | tr -d '[:space:]')
echo "Extracted image name: $IMAGE_NAME"
if [ -z "$IMAGE_NAME" ]; then
echo "Failed to extract image name!"
exit 1
fi
echo "IMAGE_NAME=$IMAGE_NAME" >> $GITHUB_ENV

# Clean up Nix store to free disk space for Android build
rm -rf result-container
nix-collect-garbage -d

echo "After Nix cleanup:"
df -h

- name: Prepare test data
run: |
# Create a writable copy of test data
TEST_DATA_DIR=$(mktemp -d)
cp -r e2e/test-data/* "$TEST_DATA_DIR/"

# Initialize git in the test data directory
git -C "$TEST_DATA_DIR" init
git -C "$TEST_DATA_DIR" config user.email "test@test.local"
git -C "$TEST_DATA_DIR" config user.name "Test User"
git -C "$TEST_DATA_DIR" add .
git -C "$TEST_DATA_DIR" commit -m "Initial test data"

# Create inbox.org if it doesn't exist
if [ ! -f "$TEST_DATA_DIR/inbox.org" ]; then
echo -e "#+TITLE: Inbox\n\n* Tasks\n" > "$TEST_DATA_DIR/inbox.org"
git -C "$TEST_DATA_DIR" add inbox.org
git -C "$TEST_DATA_DIR" commit -m "Add inbox.org"
fi

echo "TEST_DATA_DIR=$TEST_DATA_DIR" >> $GITHUB_ENV

- name: Start test API container
run: |
# Read custom elisp config if it exists
CUSTOM_ELISP=""
if [ -f "e2e/test-config.el" ]; then
CUSTOM_ELISP=$(cat e2e/test-config.el)
fi

# Start the container
docker run -d \
--name mova-test-api \
-p 8080:80 \
-e AUTH_USER=testuser \
-e AUTH_PASSWORD=testpass \
-e GIT_USER_EMAIL=test@test.local \
-e GIT_USER_NAME="Test User" \
${CUSTOM_ELISP:+-e "ORG_API_CUSTOM_ELISP_CONTENT=$CUSTOM_ELISP"} \
-v "$TEST_DATA_DIR:/data/org" \
${{ env.IMAGE_NAME }}

# Wait for API to be ready
echo "Waiting for API to be ready..."
for i in {1..60}; do
if curl -s -o /dev/null -w '%{http_code}' "http://localhost:8080" | grep -q "401\|200"; then
echo "API is ready!"
break
fi
sleep 1
done

# Show container logs for debugging
docker logs mova-test-api

- name: Build Android app for testing
env:
NODE_ENV: production
EXPO_E2E: "true"
run: |
cd android
# Build with E2E entry file (skips widget registration for Detox compatibility)
./gradlew :app:assembleDebug :app:assembleDebugAndroidTest -DtestBuildType=debug -Pexpo.e2e=true

- 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: Run E2E tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 31
arch: x86_64
profile: pixel_5
avd-name: mova_test
force-avd-creation: true
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
disable-animations: true
script: |
# Wait for emulator to be fully ready
adb wait-for-device
adb shell input keyevent 82

# Start logcat in background to capture crash logs
mkdir -p artifacts
adb logcat -c # Clear previous logs
adb logcat *:E ReactNative:V ReactNativeJS:V > artifacts/logcat.txt 2>&1 &
LOGCAT_PID=$!

# Install the test APKs
adb install android/app/build/outputs/apk/debug/app-debug.apk
adb install android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk

# Run Detox tests
yarn detox test --configuration android.emu.debug --headless --record-logs all

# Stop logcat and save
kill $LOGCAT_PID 2>/dev/null || true

# Show last 200 lines of logcat for immediate debugging
echo "=== Last 200 lines of logcat ==="
tail -200 artifacts/logcat.txt || true

- name: Upload test artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: e2e-test-results
path: |
artifacts/
android/app/build/outputs/androidTest-results/
if-no-files-found: ignore

- name: Upload Detox logs
if: failure()
uses: actions/upload-artifact@v4
with:
name: detox-logs
path: artifacts/
if-no-files-found: ignore

- name: Stop test container
if: always()
run: |
docker logs mova-test-api || true
docker rm -f mova-test-api || true
6 changes: 5 additions & 1 deletion android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@ def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
* 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.
*/
// Check if we should use E2E entry file (for Detox tests - skips widget registration)
def useE2EEntry = (findProperty('expo.e2e') ?: System.getenv('EXPO_E2E') ?: 'false').toBoolean()

react {
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
// Use E2E entry file when building for Detox tests (skips widget registration)
entryFile = useE2EEntry ? file("$projectRoot/index.e2e.js") : file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
Expand Down
29 changes: 29 additions & 0 deletions android/app/src/androidTest/java/com/anonymous/mova/DetoxTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.anonymous.mova;

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);
}
}
14 changes: 10 additions & 4 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useDeepLinks } from "@/hooks/useDeepLinks";
import { Stack, useRouter, useSegments } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { useEffect } from "react";
import { ActivityIndicator, useColorScheme, View } from "react-native";
import { useColorScheme, View } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { MD3DarkTheme, MD3LightTheme, PaperProvider } from "react-native-paper";
import "react-native-reanimated";
Expand All @@ -14,6 +14,9 @@ import "@/services/backgroundSync";

SplashScreen.preventAutoHideAsync();

// Hide splash screen early for E2E tests to prevent idle detection issues
SplashScreen.hideAsync().catch(() => {});

function RootLayoutNav() {
const { isAuthenticated, isLoading } = useAuth();
const segments = useSegments();
Expand Down Expand Up @@ -42,11 +45,14 @@ function RootLayoutNav() {
}
}, [isLoading]);

// Use a static loading indicator instead of animated ActivityIndicator
// to allow the main thread to become idle for Detox tests
if (isLoading) {
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<ActivityIndicator size="large" />
</View>
<View
testID="loadingScreen"
style={{ flex: 1, justifyContent: "center", alignItems: "center" }}
/>
);
}

Expand Down
26 changes: 24 additions & 2 deletions e2e/helpers/test-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,19 @@ const TEST_LAUNCH_ARGS = {
/**
* Ensure a completely fresh app state
* This should be called before login in every test
* Note: Uses launchArgs to disable synchronization during launch to avoid
* Detox getting stuck waiting for the app to become idle (widget module issue)
*/
export async function ensureFreshState(): Promise<void> {
await device.launchApp({
newInstance: true,
delete: true, // Clears all app data including AsyncStorage
launchArgs: {
detoxEnableSynchronization: 0,
},
});
// Give the app a moment to initialize before re-enabling sync
await new Promise((resolve) => setTimeout(resolve, 2000));
}

/**
Expand All @@ -61,8 +68,13 @@ export async function launchAppWithAutoLogin(): Promise<void> {
await device.launchApp({
newInstance: true,
delete: true,
launchArgs: TEST_LAUNCH_ARGS,
launchArgs: {
...TEST_LAUNCH_ARGS,
detoxEnableSynchronization: 0,
},
});
// Give the app a moment to initialize before re-enabling sync
await new Promise((resolve) => setTimeout(resolve, 2000));
}

/**
Expand All @@ -71,7 +83,12 @@ export async function launchAppWithAutoLogin(): Promise<void> {
export async function launchAppPreserveState(): Promise<void> {
await device.launchApp({
newInstance: false,
launchArgs: {
detoxEnableSynchronization: 0,
},
});
// Give the app a moment to initialize
await new Promise((resolve) => setTimeout(resolve, 1000));
}

/**
Expand Down Expand Up @@ -202,8 +219,13 @@ export async function setupTestWithLoginOnce(): Promise<void> {
export async function setupTestPreserveLogin(): Promise<void> {
await device.launchApp({
newInstance: false,
launchArgs: TEST_LAUNCH_ARGS,
launchArgs: {
...TEST_LAUNCH_ARGS,
detoxEnableSynchronization: 0,
},
});
// Give the app a moment to initialize
await new Promise((resolve) => setTimeout(resolve, 1000));

await device.disableSynchronization();
try {
Expand Down
5 changes: 5 additions & 0 deletions index.e2e.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// E2E test entry point - skips widget registration to allow Detox idle detection
// The widget module prevents the main thread from going idle, causing Detox timeouts

// Import the Expo Router entry point directly without widget registration
import "expo-router/entry";
Loading
Loading