Repository: ReactiveCircus/android-emulator-runner (js)

Issue:
Option to choose emulator build version
It seems that each `emulator` version breaks something new. 29.3.0 was out recently (today?) and I'm now fighting against system exceptions, segmentation faults or emulator not starting at all.

It would be very nice to be able to choose a specific emulator version that proved to be "stable". See here for linux: https://github.com/mmcc007/test_emulators/blob/master/.travis.yml#L242-L245

```
      curl -fo emulator.zip "https://dl.google.com/android/repository/emulator-linux-$emulator_version.zip"
      rm -rf "${ANDROID_HOME}/emulator"
      unzip -q emulator.zip -d "${ANDROID_HOME}"
      rm -f emulator.zip
```

On macOS we should replace `emulator-linux-` with `emulator-darwin-` in the URL but it should work. This feature would let for example go back to v29.2.11.0 by passing the build number 6031357. 


Pull request description:
Add support for pinning emulator build
Resolves #11 

Currently the `sdkmanager` installs the latest emulator binary from the stable channel, which means  when a new release of the emulator has issues, projects using this action will start breaking.

Pinning the emulator binary to a specific version requires a list of `[emulator binary version : download url mappings]` which isn't currently available ([feature request](https://issuetracker.google.com/issues/145943301)).

Therefore as a temporary solution we allow users to provide the emulator build number as an input and we'll use this to derive the download url - `emulator-darwin-<build-number>` and download the emulator binary directly instead of using the `sdkmanager`.

This is a temporary solution for users who need to pin the emulator build or when stable release breaks again in the future. In a future we'll add a proper `emulator-version` input once the mappings are available.

Patch:
diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml
index 6196a34..914fdd3 100644
--- a/.github/workflows/workflow.yml
+++ b/.github/workflows/workflow.yml
@@ -37,7 +37,7 @@ jobs:
         target: google_apis
         arch: x86
         profile: Nexus 6
-        emulator-options: -no-window -no-snapshot -noaudio -no-boot-anim -camera-back none
+        emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -camera-back none
         disable-animations: true
         script: |
           ./gradlew help
diff --git a/README.md b/README.md
index a30b370..0b78c40 100644
--- a/README.md
+++ b/README.md
@@ -77,8 +77,9 @@ jobs:
 | `target` | Optional | `default` | Target of the system image - `default` or `google_apis`. |
 | `arch` | Optional | `x86` | CPU architecture of the system image - `x86` or `x86_64`. |
 | `profile` | Optional | N/A | Hardware profile used for creating the AVD - e.g. `Nexus 6`. For a list of all profiles available, run `$ANDROID_HOME/tools/bin/avdmanager list` and refer to the results under "Available Android Virtual Devices". |
-| `emulator-options` | Optional | See below | Command-line options used when launching the emulator (replacing all default options) - e.g. `-no-snapshot -camera-back emulated`. |
+| `emulator-options` | Optional | See below | Command-line options used when launching the emulator (replacing all default options) - e.g. `-no-window -no-snapshot -camera-back emulated`. |
 | `disable-animations` | Optional | `true` | Whether to disable animations - `true` or `false`. |
+| `emulator-build` | Optional | N/A | Build number of a specific version of the emulator binary to use e.g. `6061023` for emulator v29.3.0.0. |
 | `script` | Required | N/A | Custom script to run - e.g. to run Android instrumented tests on the emulator: `./gradlew connectedCheck` |
 
-Default `emulator-options`: `-no-window -no-snapshot -noaudio -no-boot-anim`.
+Default `emulator-options`: `-no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim`.
diff --git a/action.yml b/action.yml
index b5f349e..e71bb01 100644
--- a/action.yml
+++ b/action.yml
@@ -17,11 +17,13 @@ inputs:
   profile:
     description: 'Hardware profile used for creating the AVD - e.g. `Nexus 6`.'
   emulator-options:
-    description: 'command-line options used when launching the emulator - e.g. `-no-snapshot -camera-back emulated`.'
-    default: '-no-window -no-snapshot -noaudio -no-boot-anim'
+    description: 'command-line options used when launching the emulator - e.g. `-no-window -no-snapshot -camera-back emulated`.'
+    default: '-no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim'
   disable-animations:
     description: 'whether to disable animations - true or false'
     default: 'true'
+  emulator-build:
+    description: 'build number of a specific version of the emulator binary to use e.g. `6061023` for emulator v29.3.0.0.'
   script:
     description: 'custom script to run - e.g. `./gradlew connectedCheck`'
     required: true
diff --git a/lib/input-validator.js b/lib/input-validator.js
index a05c57b..32a59bd 100644
--- a/lib/input-validator.js
+++ b/lib/input-validator.js
@@ -30,3 +30,9 @@ function checkDisableAnimations(disableAnimations) {
     }
 }
 exports.checkDisableAnimations = checkDisableAnimations;
+function checkEmulatorBuild(emulatorBuild) {
+    if (isNaN(Number(emulatorBuild)) || !Number.isInteger(Number(emulatorBuild))) {
+        throw new Error(`Unexpected emulator build: '${emulatorBuild}'.`);
+    }
+}
+exports.checkEmulatorBuild = checkEmulatorBuild;
diff --git a/lib/main.js b/lib/main.js
index 423cfee..91e9aea 100644
--- a/lib/main.js
+++ b/lib/main.js
@@ -52,6 +52,13 @@ function run() {
             input_validator_1.checkDisableAnimations(disableAnimationsInput);
             const disableAnimations = disableAnimationsInput === 'true';
             console.log(`disable animations: ${disableAnimations}`);
+            // emulator build
+            const emulatorBuildInput = core.getInput('emulator-build');
+            if (emulatorBuildInput) {
+                input_validator_1.checkEmulatorBuild(emulatorBuildInput);
+                console.log(`using emulator build: ${emulatorBuildInput}`);
+            }
+            const emulatorBuild = !emulatorBuildInput ? undefined : emulatorBuildInput;
             // custom script to run
             const scriptInput = core.getInput('script', { required: true });
             const scripts = script_parser_1.parseScript(scriptInput);
@@ -59,9 +66,9 @@ function run() {
             scripts.forEach((script) => __awaiter(this, void 0, void 0, function* () {
                 console.log(`${script}`);
             }));
+            // install SDK
+            yield sdk_installer_1.installAndroidSdk(apiLevel, target, arch, emulatorBuild);
             try {
-                // install SDK
-                yield sdk_installer_1.installAndroidSdk(apiLevel, target, arch);
                 // launch an emulator
                 yield emulator_manager_1.launchEmulator(apiLevel, target, arch, profile, emulatorOptions, disableAnimations);
             }
diff --git a/lib/sdk-installer.js b/lib/sdk-installer.js
index 856b1b6..66a43c4 100644
--- a/lib/sdk-installer.js
+++ b/lib/sdk-installer.js
@@ -21,11 +21,22 @@ const BUILD_TOOLS_VERSION = '29.0.2';
  * Installs & updates the Android SDK for the macOS platform, including SDK platform for the chosen API level, latest build tools, platform tools, Android Emulator,
  * and the system image for the chosen API level, CPU arch, and target.
  */
-function installAndroidSdk(apiLevel, target, arch) {
+function installAndroidSdk(apiLevel, target, arch, emulatorBuild) {
     return __awaiter(this, void 0, void 0, function* () {
         const sdkmangerPath = `${process.env.ANDROID_HOME}/tools/bin/sdkmanager`;
-        console.log('Installing latest build tools, platform tools, platform, and emulator.');
-        yield exec.exec(`sh -c \\"${sdkmangerPath} --install 'build-tools;${BUILD_TOOLS_VERSION}' platform-tools 'platforms;android-${apiLevel}' emulator > /dev/null"`);
+        console.log('Installing latest build tools, platform tools, and platform.');
+        yield exec.exec(`sh -c \\"${sdkmangerPath} --install 'build-tools;${BUILD_TOOLS_VERSION}' platform-tools 'platforms;android-${apiLevel}' > /dev/null"`);
+        if (emulatorBuild) {
+            console.log(`Installing emulator build ${emulatorBuild}.`);
+            yield exec.exec(`curl -fo emulator.zip https://dl.google.com/android/repository/emulator-darwin-${emulatorBuild}.zip`);
+            yield exec.exec(`rm -rf ${process.env.ANDROID_HOME}/emulator`);
+            yield exec.exec(`unzip -q emulator.zip -d ${process.env.ANDROID_HOME}`);
+            yield exec.exec(`rm -f emulator.zip`);
+        }
+        else {
+            console.log('Installing latest emulator.');
+            yield exec.exec(`sh -c \\"${sdkmangerPath} --install emulator > /dev/null"`);
+        }
         console.log('Installing system images.');
         yield exec.exec(`sh -c \\"${sdkmangerPath} --install 'system-images;android-${apiLevel};${target};${arch}' > /dev/null"`);
     });
diff --git a/src/input-validator.ts b/src/input-validator.ts
index 1aae608..176f7b7 100644
--- a/src/input-validator.ts
+++ b/src/input-validator.ts
@@ -28,3 +28,9 @@ export function checkDisableAnimations(disableAnimations: string): void {
     throw new Error(`Input for input.disable-animations should be either 'true' or 'false'.`);
   }
 }
+
+export function checkEmulatorBuild(emulatorBuild: string): void {
+  if (isNaN(Number(emulatorBuild)) || !Number.isInteger(Number(emulatorBuild))) {
+    throw new Error(`Unexpected emulator build: '${emulatorBuild}'.`);
+  }
+}
diff --git a/src/main.ts b/src/main.ts
index f00bb45..09dc777 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,6 +1,6 @@
 import * as core from '@actions/core';
 import { installAndroidSdk } from './sdk-installer';
-import { checkApiLevel, checkTarget, checkArch, checkDisableAnimations } from './input-validator';
+import { checkApiLevel, checkTarget, checkArch, checkDisableAnimations, checkEmulatorBuild } from './input-validator';
 import { launchEmulator, killEmulator } from './emulator-manager';
 import * as exec from '@actions/exec';
 import { parseScript } from './script-parser';
@@ -42,6 +42,14 @@ async function run() {
     const disableAnimations = disableAnimationsInput === 'true';
     console.log(`disable animations: ${disableAnimations}`);
 
+    // emulator build
+    const emulatorBuildInput = core.getInput('emulator-build');
+    if (emulatorBuildInput) {
+      checkEmulatorBuild(emulatorBuildInput);
+      console.log(`using emulator build: ${emulatorBuildInput}`);
+    }
+    const emulatorBuild = !emulatorBuildInput ? undefined : emulatorBuildInput;
+
     // custom script to run
     const scriptInput = core.getInput('script', { required: true });
     const scripts = parseScript(scriptInput);
@@ -50,10 +58,10 @@ async function run() {
       console.log(`${script}`);
     });
 
-    try {
-      // install SDK
-      await installAndroidSdk(apiLevel, target, arch);
+    // install SDK
+    await installAndroidSdk(apiLevel, target, arch, emulatorBuild);
 
+    try {
       // launch an emulator
       await launchEmulator(apiLevel, target, arch, profile, emulatorOptions, disableAnimations);
     } catch (error) {
diff --git a/src/sdk-installer.ts b/src/sdk-installer.ts
index 7a94d2b..58e2e13 100644
--- a/src/sdk-installer.ts
+++ b/src/sdk-installer.ts
@@ -6,10 +6,20 @@ const BUILD_TOOLS_VERSION = '29.0.2';
  * Installs & updates the Android SDK for the macOS platform, including SDK platform for the chosen API level, latest build tools, platform tools, Android Emulator,
  * and the system image for the chosen API level, CPU arch, and target.
  */
-export async function installAndroidSdk(apiLevel: number, target: string, arch: string): Promise<void> {
+export async function installAndroidSdk(apiLevel: number, target: string, arch: string, emulatorBuild?: string): Promise<void> {
   const sdkmangerPath = `${process.env.ANDROID_HOME}/tools/bin/sdkmanager`;
-  console.log('Installing latest build tools, platform tools, platform, and emulator.');
-  await exec.exec(`sh -c \\"${sdkmangerPath} --install 'build-tools;${BUILD_TOOLS_VERSION}' platform-tools 'platforms;android-${apiLevel}' emulator > /dev/null"`);
+  console.log('Installing latest build tools, platform tools, and platform.');
+  await exec.exec(`sh -c \\"${sdkmangerPath} --install 'build-tools;${BUILD_TOOLS_VERSION}' platform-tools 'platforms;android-${apiLevel}' > /dev/null"`);
+  if (emulatorBuild) {
+    console.log(`Installing emulator build ${emulatorBuild}.`);
+    await exec.exec(`curl -fo emulator.zip https://dl.google.com/android/repository/emulator-darwin-${emulatorBuild}.zip`);
+    await exec.exec(`rm -rf ${process.env.ANDROID_HOME}/emulator`);
+    await exec.exec(`unzip -q emulator.zip -d ${process.env.ANDROID_HOME}`);
+    await exec.exec(`rm -f emulator.zip`);
+  } else {
+    console.log('Installing latest emulator.');
+    await exec.exec(`sh -c \\"${sdkmangerPath} --install emulator > /dev/null"`);
+  }
   console.log('Installing system images.');
   await exec.exec(`sh -c \\"${sdkmangerPath} --install 'system-images;android-${apiLevel};${target};${arch}' > /dev/null"`);
 }


Test patch:
diff --git a/__tests__/input-validator.test.ts b/__tests__/input-validator.test.ts
index 813684e..baffc0f 100644
--- a/__tests__/input-validator.test.ts
+++ b/__tests__/input-validator.test.ts
@@ -97,3 +97,26 @@ describe('disable-animations validator tests', () => {
     expect(func2).not.toThrow();
   });
 });
+
+describe('emulator-build validator tests', () => {
+  it('Throws if emulator-build is not a number', () => {
+    const func = () => {
+      validator.checkEmulatorBuild('abc123');
+    };
+    expect(func).toThrowError(`Unexpected emulator build: 'abc123'.`);
+  });
+
+  it('Throws if emulator-build is not an integer', () => {
+    const func = () => {
+      validator.checkEmulatorBuild('123.123');
+    };
+    expect(func).toThrowError(`Unexpected emulator build: '123.123'.`);
+  });
+
+  it('Validates successfully with valid emulator-build', () => {
+    const func = () => {
+      validator.checkEmulatorBuild('6061023');
+    };
+    expect(func).not.toThrow();
+  });
+});