diff --git a/.github/workflows/emulator.yml b/.github/workflows/emulator.yml new file mode 100644 index 00000000..b6186505 --- /dev/null +++ b/.github/workflows/emulator.yml @@ -0,0 +1,98 @@ +name: Test nix-on-droid in an emulator +on: + pull_request: + push: + schedule: + - cron: 0 0 * * 1 + +jobs: + emulate: + runs-on: ubuntu-latest + timeout-minutes: 30 + + strategy: + fail-fast: false + matrix: + api-level: [29, 34] # lowest working ATM, highest working ATM + way: + - bootstrap_flakes + - bootstrap_channels + - poke_around + - test_channels_uiautomator + - test_channels_shell + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Nix / enable KVM + uses: DeterminateSystems/nix-installer-action@main + + - name: Setup cachix + uses: cachix/cachix-action@v14 + with: + name: nix-on-droid + signingKey: "${{ secrets.CACHIX_SIGNING_KEY }}" + + - name: Build droidctl + run: nix build 'github:t184256/droidctl' --out-link droidctl + + - name: Build zipball, channel tarball and flake to inject + run: | + rm -rf n-o-d + mkdir -p n-o-d + git -C . archive --format=tar.gz --prefix n-o-d/ HEAD > n-o-d/archive.tar.gz + ARCHES=x86_64 nix run '.#deploy' -- file:///data/local/tmp/n-o-d/archive.tar.gz n-o-d/ + + - name: Gradle cache + uses: gradle/actions/setup-gradle@v3 + + - name: Configure AVD cache + uses: actions/cache@v4 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-${{ matrix.api-level }} + + - name: Create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + target: default + arch: x86_64 + api-level: ${{ matrix.api-level }} + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: false + script: echo "Generated AVD snapshot for caching." + + - name: Test way=${{ matrix.way}} api-level=${{ matrix.api-level }} + uses: reactivecircus/android-emulator-runner@v2 + with: + target: default + arch: x86_64 + api-level: ${{ matrix.api-level }} + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + script: > + adb shell 'rm -rf /data/local/tmp/n-o-d' && + adb push n-o-d /data/local/tmp/ && + adb shell 'cd /data/local/tmp/n-o-d && tar xzof archive.tar.gz && mv n-o-d unpacked' && + cd tests/emulator && + adb shell settings put secure enabled_accessibility_services com.google.android.marvin.talkback/com.google.android.marvin.talkback.TalkBackService && + nix run 'github:t184256/droidctl' -- run ${{ matrix.way }}.py + + - name: Upload screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: screenshots-${{ matrix.way }}-${{ matrix.api-level }} + path: tests/emulator/screenshots + if-no-files-found: warn # 'error' or 'ignore' are also available + +# TODO: push to cachix from device +# - name: Push to cachix +# if: always() && github.event_name != 'pull_request' +# run: ... diff --git a/.gitignore b/.gitignore index dea04aa6..d8dfe4c8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /source.tar.gz result result-* +**/__pycache__ diff --git a/tests/emulator/bootstrap_channels.py b/tests/emulator/bootstrap_channels.py new file mode 100644 index 00000000..d4548188 --- /dev/null +++ b/tests/emulator/bootstrap_channels.py @@ -0,0 +1,43 @@ +from common import screenshot, wait_for, APK, BOOTSTRAP_URL + +import time + + +def run(d): + nod = d.app('com.termux.nix', url=APK) + nod.permissions.allow_notifications() + nod.launch() + + wait_for(d, 'Bootstrap zipball location') + d.ui(className='android.widget.EditText').set_text(BOOTSTRAP_URL) + screenshot(d, 'entered-url') + for i in range(2): + if 'text="OK"' not in d.ui.dump_hierarchy(): + d.ui.press('back') + time.sleep(.5) + else: + break + screenshot(d, 'entered-url-back') + d.ui(text='OK').click() + screenshot(d, 'ok-clicked') + + wait_for(d, 'Welcome to Nix-on-Droid!') + screenshot(d, 'bootstrap-begins') + wait_for(d, 'Do you want to set it up with flakes? (y/N)') + d.ui.press('enter') + wait_for(d, 'Setting up Nix-on-Droid with channels...') + + wait_for(d, 'Installing and updating nix-channels...') + wait_for(d, 'unpacking channels...') + wait_for(d, 'Installing first Nix-on-Droid generation...', timeout=600) + wait_for(d, 'Copying default Nix-on-Droid config...', timeout=180) + wait_for(d, 'Congratulations!') + wait_for(d, 'See config file for further information.') + wait_for(d, 'bash-5.2$') + screenshot(d, 'bootstrap-ends') + + d('input text "echo smoke-test | base64"') + d.ui.press('enter') + wait_for(d, 'c21va2UtdGVzdAo=') + + screenshot(d, 'success-bootstrap-channels') diff --git a/tests/emulator/bootstrap_flakes.py b/tests/emulator/bootstrap_flakes.py new file mode 100644 index 00000000..c1c157f2 --- /dev/null +++ b/tests/emulator/bootstrap_flakes.py @@ -0,0 +1,43 @@ +from common import screenshot, wait_for, APK, BOOTSTRAP_URL + +import time + + +def run(d): + nod = d.app('com.termux.nix', url=APK) + nod.permissions.allow_notifications() + nod.launch() + + wait_for(d, 'Bootstrap zipball location') + d.ui(className='android.widget.EditText').set_text(BOOTSTRAP_URL) + screenshot(d, 'entered-url') + for i in range(2): + if 'text="OK"' not in d.ui.dump_hierarchy(): + d.ui.press('back') + time.sleep(.5) + else: + break + screenshot(d, 'entered-url-back') + d.ui(text='OK').click() + screenshot(d, 'ok-clicked') + + wait_for(d, 'Welcome to Nix-on-Droid!') + screenshot(d, 'bootstrap-begins') + wait_for(d, 'Do you want to set it up with flakes? (y/N)') + d('input text y') + d.ui.press('enter') + wait_for(d, 'Setting up Nix-on-Droid with flakes...') + + wait_for(d, 'Installing flake from default template...') + wait_for(d, 'Overriding input urls / arch in flake...') + wait_for(d, 'Installing first Nix-on-Droid generation...', timeout=600) + wait_for(d, 'Building activation package') + wait_for(d, 'Congratulations!', timeout=900) + wait_for(d, 'bash-5.2$') + screenshot(d, 'bootstrap-ends') + + d('input text "echo smoke-test | base64"') # remove + d.ui.press('enter') + wait_for(d, 'c21va2UtdGVzdAo=') + + screenshot(d, 'success-bootstrap-flakes') diff --git a/tests/emulator/common.py b/tests/emulator/common.py new file mode 100644 index 00000000..c18c5445 --- /dev/null +++ b/tests/emulator/common.py @@ -0,0 +1,35 @@ +import os +import sys +import time + +SERVER = 'https://nix-on-droid.unboiled.info' +# Just use F-Droid through fdroidctl later when F-Droid has x86_64 builds +APK = f'{SERVER}/com.termux.nix_188035-x86_64.apk' +BOOTSTRAP_URL = 'file:///data/local/tmp/n-o-d' + + +def screenshot(d, suffix=''): + os.makedirs('screenshots', exist_ok=True) + fname_base = f'screenshots/{time.time()}-{suffix}' + d.ui.screenshot(f'{fname_base}.png') + with open(f'{fname_base}.xml', 'w') as f: + f.write(d.ui.dump_hierarchy()) + print(f'screenshotted: {fname_base}.{{png,xml}}') + + +def wait_for(d, on_screen_text, timeout=90, critical=True): + start = time.time() + last_displayed_time = None + while (elapsed := time.time() - start) < timeout: + display_time = int(timeout - elapsed) + if display_time != last_displayed_time: + print(f'waiting for `{on_screen_text}`: {display_time}s...') + last_displayed_time = display_time + if on_screen_text in d.ui.dump_hierarchy(): + print(f'found: {on_screen_text} after {elapsed:.1f}s') + return + time.sleep(.75) + print(f'NOT FOUND: {on_screen_text} after {timeout}s') + screenshot(d, suffix='error') + if critical: + sys.exit(1) diff --git a/tests/emulator/on_device_tests.py b/tests/emulator/on_device_tests.py new file mode 100644 index 00000000..4496e79c --- /dev/null +++ b/tests/emulator/on_device_tests.py @@ -0,0 +1,33 @@ +from common import screenshot, wait_for + + +def run(d): + wait_for(d, 'bash-5.2$') + + d('input text "nix-on-droid on-device-test"') + d.ui.press('enter') + wait_for(d, 'These semi-automated tests are destructive', timeout=180) + wait_for(d, 'Proceeding will wreck your installation.') + wait_for(d, 'Do you still wish to proceed?') + d('input text "I do"') + d.ui.press('enter') + screenshot(d, 'tests-started') + + d.ui.open_notification() + d.ui(text='Nix').right(resourceId='android:id/expand_button').click() + screenshot(d, 'notification_expanded') + d.ui(description='Acquire wakelock').click() + screenshot(d, 'wakelock_acquired') + d.ui(description='Release wakelock').wait() + screenshot(d, 'gotta-go-back') + d.ui.press('back') + screenshot(d, 'went-back') + + if 'text="Allow"' in d.ui.dump_hierarchy(): + d.ui(text='Allow').click() + elif 'text="ALLOW"' in d.ui.dump_hierarchy(): + d.ui(text='ALLOW').click() + screenshot(d, 'tests-running') + + wait_for(d, 'tests, 0 failures in', timeout=1800) + screenshot(d, 'tests-finished') diff --git a/tests/emulator/poke_around.py b/tests/emulator/poke_around.py new file mode 100644 index 00000000..8adc3d85 --- /dev/null +++ b/tests/emulator/poke_around.py @@ -0,0 +1,54 @@ +import bootstrap_channels + +from common import screenshot, wait_for + + +def run(d): + bootstrap_channels.run(d) + + d('input text "zip"') + d.ui.press('enter') + wait_for(d, 'bash: zip: command not found') + screenshot(d, 'no-zip') + + # Smoke-test nix-shell + change config + apply config + d('input text "nix-shell -p gnumake -p gnused"') + d.ui.press('enter') + wait_for(d, '[nix-shell:~]$') + d('input text "make"') + d.ui.press('enter') + wait_for(d, 'No targets specified and no makefile found.') + screenshot(d, 'nix-shell-with-make-and-sed') + # Change config and apply it + d('input text \'sed -i "s|#zip|zip|g" .config/nixpkgs/nix-on-droid.nix\'') + d.ui.press('enter') + d('input text "exit"') + d.ui.press('enter') + screenshot(d, 'pre-switch') + d('input text "nix-on-droid switch"') + d.ui.press('enter') + screenshot(d, 'post-switch') + + # Verify zip is there + d('input text "zip -v | head -n2"') + d.ui.press('enter') + wait_for(d, 'This is Zip') + screenshot(d, 'zip-appears') + + # Re-login and make sure login is still operational + + d('input text "exit"') + d.ui.press('enter') + + nod = d.app('com.termux.nix') + nod.launch() + screenshot(d, 're-login') + wait_for(d, 'Installing new login-inner...') + wait_for(d, 'bash-5.2$') + screenshot(d, 're-login-done') + + # And verify zip is still there + d('input text "zip -v | head -n2"') + d.ui.press('enter') + wait_for(d, 'This is Zip') + screenshot(d, 'zip-is-still-there') diff --git a/tests/emulator/test_channels_shell.py b/tests/emulator/test_channels_shell.py new file mode 100644 index 00000000..6cb40772 --- /dev/null +++ b/tests/emulator/test_channels_shell.py @@ -0,0 +1,43 @@ +import bootstrap_channels +import subprocess +import sys + +from common import screenshot, wait_for + +STD = '/data/data/com.termux.nix/files/home/.cache/nix-on-droid-self-test' + + +def run(d): + bootstrap_channels.run(d) + + # re-login for variety. the other on-device-test (uiautomator one) does not + d('input text "exit"') + screenshot(d, 'pre-relogin') + d.ui.press('enter') + + nod = d.app('com.termux.nix') + nod.launch() + d.ui.press('enter') + screenshot(d, 'post-relogin') + wait_for(d, 'bash-5.2$') + + # run tests in a way that'd display progress in CI + user = d.su('stat -c %U /data/data/com.termux.nix').output.strip() + # WARNING: assumes `su 0` style `su` that doesn't support -c from now on + print(f'{user=}') + sys.stdout.flush() + sys.stderr.flush() + for cmd in [ + 'id', + f'mkdir -p {STD}', + f'touch {STD}/confirmation-granted', + ('/data/data/com.termux.nix/files/usr/bin/login echo test'), + ('/data/data/com.termux.nix/files/usr/bin/login id'), + ('cd /data/data/com.termux.nix/files/home; ' + 'pwd; ' + 'id; ' + '/data/data/com.termux.nix/files/usr/bin/login ' + ' nix-on-droid on-device-test') + ]: + subprocess.run(['adb', 'shell', 'su', '0', 'su', user, + 'sh', '-c', f"'{cmd}'"]) diff --git a/tests/emulator/test_channels_uiautomator.py b/tests/emulator/test_channels_uiautomator.py new file mode 100644 index 00000000..06be7c34 --- /dev/null +++ b/tests/emulator/test_channels_uiautomator.py @@ -0,0 +1,7 @@ +import bootstrap_channels +import on_device_tests + + +def run(d): + bootstrap_channels.run(d) + on_device_tests.run(d)