Merge branch 'budtmo:master' into master

This commit is contained in:
Fabian 2025-07-20 13:03:42 +04:00 committed by GitHub
commit 6bb2e1cde7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 231 additions and 130 deletions

View File

@ -7,7 +7,7 @@ on:
jobs:
run_test:
runs-on: ubuntu-20.04
runs-on: ubuntu-24.04
strategy:
matrix:
python-version: ["3.8"]
@ -15,23 +15,11 @@ jobs:
- name: Checkout the repo
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
cd cli
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Run test
run: |
cd cli && nosetests -v
- name: Run unit-test
run: script -e -c "./app.sh test emulator test 11.0 && sudo mv tmp/* ."
release_base:
runs-on: ubuntu-20.04
runs-on: ubuntu-24.04
needs: run_test
steps:
- name: Checkout the repo
@ -47,7 +35,7 @@ jobs:
docker logout
release_emulator:
runs-on: ubuntu-20.04
runs-on: ubuntu-24.04
needs: release_base
strategy:
matrix:
@ -56,9 +44,18 @@ jobs:
- name: Checkout the repo
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Get release version
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Set up extension
run: |
echo "${{ secrets.extension }}" > extension.sh
chmod 700 extension.sh
shell: bash
- name: Build and push emulator image ${{ matrix.android }} (${RELEASE_VERSION})
run: |
docker login -u=${{secrets.DOCKER_USERNAME}} -p=${{secrets.DOCKER_PASSWORD}}
@ -66,15 +63,24 @@ jobs:
docker logout
release_genymotion:
runs-on: ubuntu-20.04
runs-on: ubuntu-24.04
needs: release_base
steps:
- name: Checkout the repo
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Get release version
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Set up extension
run: |
echo "${{ secrets.extension }}" > extension.sh
chmod 700 extension.sh
shell: bash
- name: Build and push genymotion image (${RELEASE_VERSION})
run: |
docker login -u=${{secrets.DOCKER_USERNAME}} -p=${{secrets.DOCKER_PASSWORD}}

32
.github/workflows/test-on-demand.yml vendored Normal file
View File

@ -0,0 +1,32 @@
name: Run Test on Demand
on:
workflow_dispatch:
jobs:
build_and_test:
runs-on: ubuntu-24.04
steps:
- name: Checkout the repo
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set up extension
run: |
echo "${{ secrets.extension }}" > extension.sh
chmod 700 extension.sh
shell: bash
- name: Build base image
run: script -e -c "./app.sh build base test"
- name: Build sample image
run: script -e -c "./app.sh build emulator test 11.0"
- name: Run unit-test
run: script -e -c "./app.sh test emulator test 11.0 && sudo mv tmp/* ."
- name: Publish test result
run: bash <(curl -s https://codecov.io/bash)

View File

@ -4,21 +4,31 @@ on:
push:
branches:
- master
pull_request:
branches:
- master
jobs:
build_and_test:
runs-on: ubuntu-20.04
runs-on: ubuntu-24.04
if: github.event_name == 'push' && github.ref == 'refs/heads/master' && github.actor == 'budtmo'
steps:
- name: Checkout the repo
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set up extension
run: |
echo "${{ secrets.extension }}" > extension.sh
chmod 700 extension.sh
shell: bash
- name: Build base image
run: script -e -c "./app.sh build base test"
- name: Build emulator image and run unit-test
- name: Build sample image
run: script -e -c "./app.sh build emulator test 11.0"
- name: Run unit-test
run: script -e -c "./app.sh test emulator test 11.0 && sudo mv tmp/* ."
- name: Publish test result

2
.gitignore vendored
View File

@ -17,4 +17,4 @@ tmp/
# Dev-files
n*.txt
test-*.sh
e*.sh

View File

@ -45,6 +45,7 @@ Phone | Nexus 5
Phone | Nexus One
Phone | Nexus S
Tablet | Nexus 7
Tablet | Pixel C
Requirements
------------

26
app.sh
View File

@ -81,9 +81,15 @@ echo "${IMAGE_NAME_SPECIFIC_RELEASE} or ${IMAGE_NAME_LATEST} "
function build() {
# autopep8 --recursive --exclude=.git,__pycache__,venv --max-line-length=120 --in-place .
cmd="docker build -t ${IMAGE_NAME_SPECIFIC_RELEASE} --build-arg DOCKER_ANDROID_VERSION=${r_v} "
cmd="docker build --no-cache -t ${IMAGE_NAME_SPECIFIC_RELEASE} --build-arg DOCKER_ANDROID_VERSION=${r_v} "
if [ -n "${a_v}" ]; then
cmd+="--build-arg EMULATOR_ANDROID_VERSION=${a_v} --build-arg EMULATOR_API_LEVEL=${a_l} "
DOCKER_BUILDKIT=1
cmd="${cmd} --secret id=extension,src=extension.sh --build-arg EMULATOR_ANDROID_VERSION=${a_v} --build-arg EMULATOR_API_LEVEL=${a_l} "
fi
if [[ "${p}" == *"genymotion"* ]]; then
DOCKER_BUILDKIT=1
cmd="${cmd} --secret id=extension,src=extension.sh "
fi
cmd+="-f ${FOLDER_PATH} ."
@ -97,18 +103,14 @@ function build() {
}
function test() {
cli_path="/home/androidusr/docker-android/cli"
results_path="test-results"
tmp_folder="tmp"
tmp_folder="/app/tmp"
mkdir -p tmp
build
docker run -it --rm --name test --entrypoint /bin/bash \
-v $PWD/${tmp_folder}:${cli_path}/${tmp_folder} ${IMAGE_NAME_SPECIFIC_RELEASE} \
-c "cd ${cli_path} && sudo rm -rf ${tmp_folder}/* && \
nosetests -v && sudo mv .coverage ${tmp_folder} && \
sudo cp -r ${results_path}/* ${tmp_folder} && sudo chown -R 1300:1301 ${tmp_folder} &&
sudo chmod a+x -R ${tmp_folder}"
docker run -it --rm -v "$PWD":/app -w /app python:3.12-slim bash \
-c "cd cli && rm -rf ${tmp_folder}/* && \
pip install --upgrade pip && pip install -r requirements.txt && \
PYTHONPATH=src pytest -v && mv test-results/* ${tmp_folder}/ && chown -R 1300:1301 ${tmp_folder} && \
chmod a+x -R ${tmp_folder}"
}
function push() {

View File

@ -1,6 +1,9 @@
autopep8==2.3.1
click==8.1.8
coverage==7.6.1
mock==5.1.0
nose==1.3.7
autopep8==2.3.2
click==8.2.1
coverage==7.9.2
mock==5.2.0
pytest==8.4.1
pytest-cov==6.2.1
pytest-xdist==3.8.0
requests==2.32.3
setuptools==80.9.0

View File

@ -1,10 +1,7 @@
[nosetests]
cover-xml=true
cover-xml-file=test-results/coverage.xml
with-coverage=true
cover-package=src
cover-erase=true
with-xunit=true
xunit-file=test-results/xunit.xml
cover-html=true
cover-html-dir=test-results/coverage
[tool:pytest]
addopts =
--cov=src
--cov-report=html:test-results/html
--cov-report=xml:test-results/coverage.xml
--junit-xml=test-results/junit-report.xml
testpaths = src/tests

View File

@ -1,6 +1,6 @@
import os
from setuptools import setup
from setuptools import setup, find_packages
app_version = os.getenv("DOCKER_ANDROID_VERSION", "test-version")
@ -10,12 +10,14 @@ with open("requirements.txt", "r") as f:
setup(
name="docker-android",
version=app_version,
version="0.1",
url="https://github.com/budtmo/docker-android",
description="CLI for docker-android",
author="Budi Utomo",
author_email="budtmo.os@gmail.com",
install_requires=reqs,
packages=find_packages(where="src"),
package_dir={"": "src"},
py_modules=["cli", "docker-android"],
entry_points={"console_scripts": "docker-android=src.app:cli"}
)
entry_points={"console_scripts": "docker-android=app:cli"}
)

View File

@ -8,14 +8,14 @@ import os
from enum import Enum
from src.application import Application
from src.device import DeviceType
from src.device.emulator import Emulator
from src.device.geny_aws import GenyAWS
from src.device.geny_saas import GenySAAS
from src.helper import convert_str_to_bool, get_env_value_or_raise
from src.constants import ENV
from src.logger import log
from application import Application
from device import DeviceType
from device.emulator import Emulator
from device.geny_aws import GenyAWS
from device.geny_saas import GenySAAS
from helper import convert_str_to_bool, get_env_value_or_raise
from constants import ENV
from logger import log
log.init()
logger = logging.getLogger("App")

View File

@ -30,6 +30,7 @@ EMULATOR_IMG_TYPE = "EMULATOR_IMG_TYPE"
EMULATOR_NAME = "EMULATOR_NAME"
EMULATOR_NO_SKIN = "EMULATOR_NO_SKIN"
EMULATOR_SYS_IMG = "EMULATOR_SYS_IMG"
EMULATOR_CONFIG_PATH = "EMULATOR_CONFIG_PATH"
# Device (Genymotion - General)
GENYMOTION_TEMPLATE_PATH = "GENYMOTION_TEMPLATE_PATH"
@ -37,6 +38,7 @@ GENYMOTION_TEMPLATE_PATH = "GENYMOTION_TEMPLATE_PATH"
# Device (Geny_SAAS)
GENY_SAAS_USER = "GENY_SAAS_USER"
GENY_SAAS_PASS = "GENY_SAAS_PASS"
GENY_AUTH_TOKEN = "GENY_AUTH_TOKEN"
GENY_SAAS_TEMPLATE_FILE_NAME = "saas.json"
# Device (Geny_AWS)

View File

@ -9,8 +9,8 @@ import time
from abc import ABC, abstractmethod
from enum import Enum
from src.helper import convert_str_to_bool, get_env_value_or_raise
from src.constants import DEVICE, ENV
from helper import convert_str_to_bool, get_env_value_or_raise
from constants import DEVICE, ENV
class DeviceType(Enum):

View File

@ -5,9 +5,9 @@ import time
from enum import Enum
from src.device import Device, DeviceType
from src.helper import convert_str_to_bool, get_env_value_or_raise, symlink_force
from src.constants import ENV, UTF8
from device import Device, DeviceType
from helper import convert_str_to_bool, get_env_value_or_raise, symlink_force
from constants import ENV, UTF8
class Emulator(Device):
@ -22,7 +22,8 @@ class Emulator(Device):
"Samsung Galaxy S7 Edge",
"Samsung Galaxy S8",
"Samsung Galaxy S9",
"Samsung Galaxy S10"
"Samsung Galaxy S10",
"Pixel C"
)
API_LEVEL = {
@ -108,6 +109,30 @@ class Emulator(Device):
symlink_force(path_device_profile_source, self.path_device_profile_target)
self.logger.info("Samsung device profile is linked")
def _use_override_config(self) -> None:
override_confg_path = os.getenv(ENV.EMULATOR_CONFIG_PATH)
if override_confg_path is None:
self.logger.info(f"The environment variable 'EMULATOR_CONFIG_PATH' is not set")
return
self.logger.info(f"Environment variable 'EMULATOR_CONFIG_PATH' found: {override_confg_path}")
if not os.path.isfile(override_confg_path):
self.logger.warning(f"Source file '{override_confg_path}' does not exist.")
return
if not os.access(override_confg_path, os.R_OK):
self.logger.warning(f"Source file '{override_confg_path}' is not readable.")
return
try:
with open(override_confg_path, 'r') as src, open(self.path_emulator_config, 'a') as dst:
dst.write(src.read())
self.logger.info(f"Content from '{override_confg_path}' successfully appended to '{self.path_emulator_config}'.")
except Exception as e:
self.logger.error(f"An error occurred while copying file content: {e}")
def _add_skin(self) -> None:
device_skin_path = os.path.join(
self.path_emulator_skins, "{fn}".format(fn=self.file_name))
@ -132,6 +157,7 @@ class Emulator(Device):
self.logger.info(f"Command to create emulator: '{creation_cmd}'")
subprocess.check_call(creation_cmd, shell=True)
self._add_skin()
self._use_override_config()
self.logger.info(f"{self.device_type} is created!")
def change_permission(self) -> None:

View File

@ -5,9 +5,9 @@ import shutil
import subprocess
import time
from src.device import Genymotion, DeviceType
from src.helper import get_env_value_or_raise
from src.constants import ENV, UTF8
from device import Genymotion, DeviceType
from helper import get_env_value_or_raise
from constants import ENV, UTF8
class GenyAWS(Genymotion):

View File

@ -1,9 +1,10 @@
import logging
import os
import subprocess
from src.device import Genymotion, DeviceType
from src.helper import get_env_value_or_raise
from src.constants import ENV, UTF8
from device import Genymotion, DeviceType
from helper import get_env_value_or_raise
from constants import ENV, UTF8
class GenySAAS(Genymotion):
@ -14,9 +15,13 @@ class GenySAAS(Genymotion):
self.created_devices = []
def login(self) -> None:
user = get_env_value_or_raise(ENV.GENY_SAAS_USER)
password = get_env_value_or_raise(ENV.GENY_SAAS_PASS)
subprocess.check_call(f"gmsaas auth login {user} {password} > /dev/null 2>&1", shell=True)
if os.getenv(ENV.GENY_AUTH_TOKEN):
auth_token = get_env_value_or_raise(ENV.GENY_AUTH_TOKEN)
subprocess.check_call(f"gmsaas auth token {auth_token} > /dev/null 2>&1", shell=True)
else:
user = get_env_value_or_raise(ENV.GENY_SAAS_USER)
password = get_env_value_or_raise(ENV.GENY_SAAS_PASS)
subprocess.check_call(f"gmsaas auth login {user} {password} > /dev/null 2>&1", shell=True)
self.logger.info("successfully logged in!")
def create(self) -> None:
@ -68,5 +73,8 @@ class GenySAAS(Genymotion):
for n, i in d.items():
subprocess.check_call(f"gmsaas instances stop {i}", shell=True)
self.logger.info(f"device '{n}' is successfully removed!")
subprocess.check_call("gmsaas auth logout", shell=True)
if os.getenv(ENV.GENY_AUTH_TOKEN):
subprocess.check_call("gmsaas auth reset", shell=True)
else:
subprocess.check_call("gmsaas auth logout", shell=True)
self.logger.info("successfully logged out!")

View File

@ -1,6 +1,6 @@
import logging.config
from src.logger import LOGGING_FILE
from logger import LOGGING_FILE
def init():

View File

@ -1,7 +1,7 @@
import os
from src.constants import ENV
from src.tests import BaseTest
from constants import ENV
from tests import BaseTest
class BaseDeviceTest(BaseTest):

View File

@ -1,5 +1,5 @@
from src.device import Device
from src.tests.device import BaseDeviceTest
from device import Device
from tests.device import BaseDeviceTest
class TestDevice(BaseDeviceTest):

View File

@ -1,7 +1,7 @@
import mock
from src.device.emulator import Emulator
from src.tests.device import BaseDeviceTest
from device.emulator import Emulator
from tests.device import BaseDeviceTest
class TestEmulator(BaseDeviceTest):
@ -52,29 +52,6 @@ class TestEmulator(BaseDeviceTest):
def test_initialisation_device_exists(self):
self.assertEqual(self.emu.is_initialized(), True)
@mock.patch("src.device.Device.set_status")
@mock.patch("src.device.emulator.Emulator._add_profile")
@mock.patch("subprocess.check_call")
@mock.patch("src.device.emulator.Emulator._add_skin")
@mock.patch("src.device.emulator.Emulator.is_initialized", mock.MagicMock(return_value=False))
def test_create_device_not_exist(self, mocked_status, mocked_profile, mocked_subprocess, mocked_skin):
self.emu.create()
self.assertEqual(mocked_status.called, True)
self.assertEqual(mocked_profile.called, True)
self.assertEqual(mocked_subprocess.called, True)
self.assertEqual(mocked_skin.called, True)
@mock.patch("src.device.Device.set_status")
@mock.patch("src.device.emulator.Emulator._add_profile")
@mock.patch("subprocess.check_call")
@mock.patch("src.device.emulator.Emulator._add_skin")
@mock.patch("src.device.emulator.Emulator.is_initialized", mock.MagicMock(return_value=True))
def test_create_device_exists(self, mocked_status, mocked_profile, mocked_subprocess, mocked_skin):
self.emu.create()
self.assertEqual(mocked_status.called, False)
self.assertEqual(mocked_profile.called, False)
self.assertEqual(mocked_subprocess.called, False)
def test_check_adb_command(self):
with mock.patch("subprocess.check_output", mock.MagicMock(return_value="1".encode("utf-8"))):
self.emu.check_adb_command(
@ -85,3 +62,25 @@ class TestEmulator(BaseDeviceTest):
with self.assertRaises(RuntimeError):
self.emu.check_adb_command(
self.emu.ReadinessCheck.BOOTED, "mocked_command", "1", 3, 0)
def test_use_override_config_no_env(self):
with mock.patch("os.getenv", return_value=None):
self.emu._use_override_config()
def test_use_override_config_file_not_exist(self):
with mock.patch("os.getenv", return_value="mock/path/to/config"):
with mock.patch("os.path.isfile", return_value=False):
self.emu._use_override_config()
def test_use_override_config_file_not_readable(self):
with mock.patch("os.getenv", return_value="mock/path/to/config"):
with mock.patch("os.path.isfile", return_value=True):
with mock.patch("os.access", return_value=False):
self.emu._use_override_config()
def test_use_override_config_malformed_content(self):
with mock.patch("os.getenv", return_value="mock/path/to/config"):
with mock.patch("os.path.isfile", return_value=True):
with mock.patch("os.access", return_value=True):
with mock.patch("builtins.open", mock.mock_open(read_data="malformed data")):
self.emu._use_override_config()

View File

@ -1,8 +1,8 @@
import os
import mock
from src.helper import convert_str_to_bool, get_env_value_or_raise, symlink_force
from src.tests import BaseTest
from helper import convert_str_to_bool, get_env_value_or_raise, symlink_force
from tests import BaseTest
class TestHelperMethods(BaseTest):
@ -45,10 +45,6 @@ class TestHelperMethods(BaseTest):
with self.assertRaises(RuntimeError):
get_env_value_or_raise("env_key02")
def test_get_env_value_with_invalid_format(self):
with mock.patch("src.logger"):
get_env_value_or_raise(True)
def test_symlink(self):
s = os.path.join("source.txt")
t = os.path.join("target_file.txt")

View File

@ -1,6 +1,7 @@
FROM appium/appium:v2.15.0-p1
FROM appium/appium:v2.19.0-p0
LABEL maintainer "Budi Utomo <budtmo.os@gmail.com>"
ARG AUTHORS="Budi Utomo"
LABEL author="${AUTHORS} <budtmo.os@gmail.com>"
USER root

View File

@ -83,8 +83,8 @@ ENV APP_PATH=${WORK_PATH}/${SCRIPT_PATH}
RUN mkdir -p ${APP_PATH}
COPY mixins ${APP_PATH}/mixins
COPY cli ${APP_PATH}/cli
RUN chown -R 1300:1301 ${APP_PATH} \
&& pip install --quiet -e ${APP_PATH}/cli
RUN --mount=type=secret,id=extension,dst=/tmp/extension.sh \
bash /tmp/extension.sh
#===================
# Configure OpenBox

View File

@ -1,12 +1,7 @@
ARG DOCKER_ANDROID_VERSION
FROM budtmo/docker-android:base_${DOCKER_ANDROID_VERSION}
#===================================================
# Install Genymotion CLI
# (for user management and deployment on Geny Cloud)
#===================================================
ENV GMSAAS_CLI_VERSION="1.7.1"
RUN pip install gmsaas==${GMSAAS_CLI_VERSION}
ENV GMSAAS_CLI_VERSION="1.14.1"
#================
# Cloud Packages
@ -40,8 +35,8 @@ ENV APP_PATH=${WORK_PATH}/${SCRIPT_PATH}
RUN mkdir -p ${APP_PATH}
COPY mixins ${APP_PATH}/mixins
COPY cli ${APP_PATH}/cli
RUN chown -R 1300:1301 ${APP_PATH} \
&& pip install --quiet -e ${APP_PATH}/cli
RUN --mount=type=secret,id=extension,dst=/tmp/extension.sh \
bash /tmp/extension.sh
#===================================
# Create Genymotion Template folder

View File

@ -57,5 +57,18 @@ Possible environment variable to configure the Emulator:
The user can also pass needed arguments to android emulator through environment variable ***EMULATOR_ADDITIONAL_ARGS***. Please check [this page](https://developer.android.com/studio/run/emulator-commandline) for possible arguments that can be passed.
EMULATOR - OVERRIDE CONFIG FILE
-------------------------------
To utilize this feature, ensure the following setup:
- Set the config file path into environment variable EMULATOR_CONFIG_PATH, eg. `/tmp/emulator-override-config.ini` (the file name doesn't matter)
- Mount the file as Docker volume (parameter `-v` for Docker run command) to the path as you set into ENV
Example:
```shell
docker run -d -p 6080:6080 -e /tmp/emulator-override-config.ini -v /path/on/your/machine/emulator-override-config.ini:/tmp/emulator-override-config.ini -e EMULATOR_DEVICE="Samsung Galaxy S10" -e WEB_VNC=true --device /dev/kvm --name android-container budtmo/docker-android:emulator_14.0
```
[<- BACK TO README](../README.md)

View File

@ -59,6 +59,7 @@ Phone | Nexus 5
Phone | Nexus One
Phone | Nexus S
Tablet | Nexus 7
Tablet | Pixel C
Proxy

View File

@ -1,3 +1,9 @@
```
export USER="xxx"
export PASS="xxx"
docker run -d -p 4723:4723 -v ${PWD}/example/genycloud/saas.json:/home/androidusr/genymotion_template/saas.json -e DEVICE_TYPE=geny_saas -e GENY_SAAS_USER=${USER} -e GENY_SAAS_PASS=${PASS} -e APPIUM=true --name android-container budtmo/docker-android:genymotion
```
Genymotion Cloud
----------------
@ -9,12 +15,13 @@ You can use Genymotion Android virtual devices in the cloud. They are available
Use [saas.json](../example/genymotion/saas.json) to define the devices that you want to use. You can specify the port on which the device will start so you don't need to change the device name in your tests every time you need to run those tests. Then run following command
```
export USER="xxx"
export PASS="xxx"
export AUTH_TOKEN="xxx"
docker run -d -p 4723:4723 -v ${PWD}/example/genycloud/saas.json:/home/androidusr/genymotion_template/saas.json -e DEVICE_TYPE=geny_saas -e GENY_SAAS_USER=${USER} -e GENY_SAAS_PASS=${PASS} -e APPIUM=true --name android-container budtmo/docker-android:genymotion
docker run -d -p 4723:4723 -v ${PWD}/example/genycloud/saas.json:/home/androidusr/genymotion_template/saas.json -e DEVICE_TYPE=geny_saas -e GENY_AUTH_TOKEN=${AUTH_TOKEN} -e APPIUM=true --name android-container budtmo/docker-android:genymotion
```
Genymotion has deprecated credential based login since gmsaas 1.10.0, but if necessary, you can still provide them using `-e GENY_SAAS_USER=${USER} -e GENY_SAAS_PASS=${PASS}` instead of `-e GENY_AUTH_TOKEN=${AUTH_TOKEN}`.
The deployed device(s) are automatically connected with adb inside docker container. Stopping the emulator will remove all deployed device(s) on Genymotion SaaS and user will be logged out at the end.
```