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
114 changes: 114 additions & 0 deletions .github/workflows/test-e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,120 @@ jobs:
path: ~/screenrecord.mp4
retention-days: 7

test-tvos:
name: Test on tvOS
runs-on: macos-26
needs: build
timeout-minutes: 45

env:
MAESTRO_DRIVER_STARTUP_TIMEOUT: 240000 # 240s
MAESTRO_CLI_LOG_PATTERN_CONSOLE: '%d{HH:mm:ss.SSS} [%5level] %logger.%method: %msg%n'

steps:
- name: Clone repository (only needed for the e2e directory)
uses: actions/checkout@v6

- name: Set up JDK
uses: actions/setup-java@v5
with:
distribution: zulu
java-version: 17

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: yarn
cache-dependency-path: e2e/tvos_demo_app/yarn.lock

- name: Cache CocoaPods
uses: actions/cache@v4
with:
path: e2e/tvos_demo_app/ios/Pods
key: tvos-demo-pods-${{ runner.os }}-${{ hashFiles('e2e/tvos_demo_app/yarn.lock') }}

- name: Build tvos_demo_app
working-directory: ${{ github.workspace }}/e2e/tvos_demo_app
run: |
yarn install --frozen-lockfile
EXPO_TV=1 npx expo prebuild --clean
xcodebuild \
-workspace ios/MaestroTVDemo.xcworkspace \
-scheme MaestroTVDemo \
-configuration Release \
-sdk appletvsimulator \
-derivedDataPath build \
-quiet
mkdir -p ${{ github.workspace }}/e2e/apps
cp -r build/Build/Products/Release-appletvsimulator/MaestroTVDemo.app ${{ github.workspace }}/e2e/apps/tvos_demo_app.app

- name: Download artifacts
uses: actions/download-artifact@v7
with:
name: maestro-cli-jdk17-run_id${{ github.run_id }}

- name: Add Maestro CLI executable to PATH
run: |
unzip maestro.zip -d maestro_extracted
echo "$PWD/maestro_extracted/maestro/bin" >> $GITHUB_PATH

- name: Check if Maestro CLI executable starts up
run: |
maestro --help
maestro --version

- name: Boot Apple TV Simulator
run: |
xcrun simctl list runtimes
export RUNTIME="tvOS26.1"
export DEVICE_TYPE="Apple TV 4K (3rd generation)"
./.github/scripts/boot_simulator.sh

- name: Install tvOS demo app
working-directory: ${{ github.workspace }}/e2e
run: ./install_apps tvos

- name: Start screen recording
run: |
xcrun simctl io booted recordVideo --codec h264 ~/screenrecord.mp4 &
echo $! > ~/screenrecord.pid

- name: Run tests
working-directory: ${{ github.workspace }}/e2e
timeout-minutes: 20
run: ./run_tests tvos

- name: Stop screen recording
if: success() || failure()
run: kill -SIGINT "$(cat ~/screenrecord.pid)"

- name: Upload ~/.maestro artifacts
uses: actions/upload-artifact@v6
if: success() || failure()
with:
name: maestro-root-dir-tvos
path: ~/.maestro
retention-days: 7
include-hidden-files: true

- name: Upload xctest runner logs
uses: actions/upload-artifact@v6
if: success() || failure()
with:
name: xctest_runner_logs_tvos
path: ~/Library/Logs/maestro/xctest_runner_logs
retention-days: 7
include-hidden-files: true

- name: Upload screen recording of Simulator
uses: actions/upload-artifact@v6
if: success() || failure()
with:
name: maestro-screenrecord-tvos.mp4
path: ~/screenrecord.mp4
retention-days: 7

test-ios-xctest-runner:
name: Test on iOS (XCTest Runner only)
if: false # Disabled: This needs be fixed, not working yet.
Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,7 @@ bin
maestro-orchestra/src/test/resources/media/assets/*

# Local files
local/
local/

# Maestro iOS relative DerivedData
maestro-ios-xctest-runner/DerivedData/
10 changes: 6 additions & 4 deletions e2e/install_apps
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ set -eu
[ "$(basename "$PWD")" = "e2e" ] || { echo "must be run from e2e directory" && exit 1; }

platform="${1:-}"
if [ "$platform" != "android" ] && [ "$platform" != "ios" ]; then
echo "usage: $0 <android|ios>"
if [ "$platform" != "android" ] && [ "$platform" != "ios" ] && [ "$platform" != "tvos" ]; then
echo "usage: $0 <android|ios|tvos>"
exit 1
fi

command -v adb >/dev/null 2>&1 || { echo "adb is required" && exit 1; }
if [ "$platform" = "android" ]; then
command -v adb >/dev/null 2>&1 || { echo "adb is required" && exit 1; }
fi

for file in ./apps/*; do
filename="$(basename "$file")"
Expand All @@ -24,7 +26,7 @@ for file in ./apps/*; do
if [ "$platform" = android ] && [ "$extension" = "apk" ]; then
echo "install $filename"
adb install -r "$file" >/dev/null || echo "adb: could not install $filename"
elif [ "$platform" = ios ] && [ "$extension" = "app" ] && [ "$(uname)" = "Darwin" ]; then
elif { [ "$platform" = ios ] || [ "$platform" = tvos ]; } && [ "$extension" = "app" ] && [ "$(uname)" = "Darwin" ]; then
echo "install $filename"
xcrun simctl install booted "$file" || echo "xcrun: could not install $filename"
fi
Expand Down
16 changes: 11 additions & 5 deletions e2e/run_tests
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ _h3() { printf "==> [%s] [%s] => %s\n" "$1" "$2" "$3"; }
cloud="android_device_configuration,ios_device_configuration" # Maestro Cloud specific tests
platform="${1:-}"
case "$platform" in
android) exclude_tags="ios,web,$cloud" ;;
ios) exclude_tags="android,$cloud,web" ;;
web) exclude_tags="android,ios,$cloud" ;;
*) echo "usage: $0 <android|ios|web>"; exit 1 ;;
android) exclude_tags="ios,tvos,web,$cloud" ;;
ios) exclude_tags="android,tvos,$cloud,web" ;;
tvos) exclude_tags="android,ios,$cloud,web" ;;
web) exclude_tags="android,ios,tvos,$cloud" ;;
*) echo "usage: $0 <android|ios|tvos|web>"; exit 1 ;;
esac

app_filter="${MAESTRO_APP:-}"
Expand Down Expand Up @@ -149,6 +150,11 @@ else
*) app_name="$(basename "$workspace_dir")" ;;
esac

case $app_name in
tvos_settings|tvos_demo_app) [ "$platform" != "tvos" ] && continue ;;
*) [ "$platform" = "tvos" ] && continue ;;
esac

case $app_name in
# demo_app has OOM issues on GHA
demo_app) [ "$platform" = "ios" ] && continue ;;
Expand All @@ -160,7 +166,7 @@ else
_run_passing "$app_name" "$workspace_dir"

case "$app_name" in
wikipedia|simple_web_view) ;;
wikipedia|simple_web_view|tvos_settings|tvos_demo_app) ;;
*) _run_failing "$app_name" "$workspace_dir" ;;
esac
done
Expand Down
24 changes: 24 additions & 0 deletions e2e/tvos_demo_app/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
node_modules/
.expo/
.yarn
.yarnrc.yml
dist/
ios/
android/
npm-debug.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/

# macOS
.DS_Store

# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
# The following patterns were generated by expo-cli

expo-env.d.ts
# @end expo-cli
168 changes: 168 additions & 0 deletions e2e/tvos_demo_app/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import {NavigationContainer} from '@react-navigation/native';
import {createNativeStackNavigator} from '@react-navigation/native-stack';
import {useEffect, useRef, useState} from 'react';
import {Pressable, Text, TextInput, View} from 'react-native';

const Stack = createNativeStackNavigator();

export default function App() {
return (
<NavigationContainer>
<Stack.Navigator screenOptions={{headerShown: false}}>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="Navigation" component={NavigationScreen} />
<Stack.Screen name="TextInput" component={TextInputScreen} />
<Stack.Screen name="Focus" component={FocusScreen} />
</Stack.Navigator>
</NavigationContainer>
);
}

// ---------------------------------------------------------------------------
// Home Screen — vertical menu of buttons that navigate to each test screen
// ---------------------------------------------------------------------------

const HomeScreen = ({navigation}: any) => {
return (
<View style={styles.screen}>
<Text style={styles.title}>Home</Text>
<View style={{gap: 20}}>
<CustomButton onPress={() => navigation.navigate('Navigation')}>
Navigation Test
</CustomButton>
<CustomButton onPress={() => navigation.navigate('TextInput')}>
Text Input Test
</CustomButton>
<CustomButton onPress={() => navigation.navigate('Focus')}>
Focus Test
</CustomButton>
</View>
</View>
);
};

// ---------------------------------------------------------------------------
// Navigation Screen — 2x2 grid for testing directional D-pad navigation
// ---------------------------------------------------------------------------

const NavigationScreen = () => {
return (
<View style={styles.screen}>
<Text style={styles.title}>Navigation Test</Text>
<View style={{gap: 20}}>
<View style={{flexDirection: 'row', gap: 20}}>
<CustomButton style={{flex: 1}}>Top Left</CustomButton>
<CustomButton style={{flex: 1}}>Top Right</CustomButton>
</View>
<View style={{flexDirection: 'row', gap: 20}}>
<CustomButton style={{flex: 1}}>Bottom Left</CustomButton>
<CustomButton style={{flex: 1}}>Bottom Right</CustomButton>
</View>
</View>
</View>
);
};

// ---------------------------------------------------------------------------
// Text Input Screen — text field + label showing current text
// ---------------------------------------------------------------------------

const TextInputScreen = () => {
const [text, setText] = useState('');

return (
<View style={styles.screen}>
<Text style={styles.title}>Text Input Test</Text>
<TextInput
style={styles.textInput}
value={text}
onChangeText={setText}
placeholder="Type here..."
placeholderTextColor="#888"
/>
<Text style={styles.label} accessibilityLabel={`Typed: ${text}`}>
Typed: {text}
</Text>
</View>
);
};

// ---------------------------------------------------------------------------
// Focus Screen — two buttons; Button 2 receives programmatic focus on mount
// ---------------------------------------------------------------------------

const FocusScreen = () => {
const secondButtonRef = useRef<View>(null);

useEffect(() => {
(secondButtonRef.current as any)?.requestTVFocus?.();
}, []);

return (
<View style={styles.screen}>
<Text style={styles.title}>Focus Test</Text>
<View style={{gap: 20}}>
<CustomButton>Button 1</CustomButton>
<CustomButton ref={secondButtonRef}>Button 2</CustomButton>
</View>
</View>
);
};

// ---------------------------------------------------------------------------
// Shared components & styles
// ---------------------------------------------------------------------------

import {forwardRef} from 'react';

const CustomButton = forwardRef<View, any>(
({children, onPress, style, ...props}, ref) => {
const [focused, setFocused] = useState(false);

return (
<Pressable
ref={ref}
onPress={onPress}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
style={[
{
backgroundColor: 'darkblue',
borderWidth: 5,
borderColor: focused ? 'white' : 'transparent',
padding: 20,
},
style,
]}
{...props}>
<Text style={{color: 'white', fontSize: 24}}>{children}</Text>
</Pressable>
);
},
);

const styles = {
screen: {
flex: 1,
padding: 40,
backgroundColor: 'black',
} as const,
title: {
color: 'white',
fontSize: 48,
fontWeight: 'bold' as const,
marginBottom: 40,
},
textInput: {
borderWidth: 2,
borderColor: '#444',
color: 'white',
fontSize: 24,
padding: 16,
marginBottom: 20,
},
label: {
color: '#ccc',
fontSize: 24,
},
};
Loading
Loading