/* @flow */

import EventEmitter from 'events';

import { assert } from 'chai';
import { describe, it } from 'mocha';
import deepcopy from 'deepcopy';
import * as sinon from 'sinon';

import { consoleStream } from '../../../src/util/logger.js';
import { FirefoxAndroidExtensionRunner } from '../../../src/extension-runners/firefox-android.js';
import type { FirefoxAndroidExtensionRunnerParams } from '../../../src/extension-runners/firefox-android';
import { UsageError, WebExtError } from '../../../src/errors.js';
import {
  basicManifest,
  createFakeStdin,
  getFakeFirefox,
  getFakeRemoteFirefox,
} from '../helpers.js';

// Fake result for client.installTemporaryAddon().then(installResult => ...)
const tempInstallResult = {
  addon: { id: 'some-addon@test-suite' },
};

// Fake missing addon id result for client.installTemporaryAddon
const tempInstallResultMissingAddonId = {
  addon: { id: null },
};

const builtFileName = 'built-ext-filename';

const fakeBuiltExtensionPath = `/fake/extensionPath/${builtFileName}.zip`;

const fakeRDPUnixSocketFile =
  '/data/data/org.mozilla.firefox/firefox-debugger-socket';

const fakeRDPUnixAbstractSocketFile =
  '@org.mozilla.firefox/firefox-debugger-socket';

type PrepareParams = {
  params?: Object,
  debuggerPort?: number,
  fakeFirefoxApp?: Object,
  fakeRemoteFirefox?: Object,
  fakeADBUtils?: Object,
};

// Reduce the waiting time during tests.
FirefoxAndroidExtensionRunner.unixSocketDiscoveryRetryInterval = 0;

function prepareExtensionRunnerParams({
  debuggerPort,
  fakeFirefoxApp,
  fakeRemoteFirefox,
  fakeADBUtils,
  params,
}: PrepareParams = {}) {
  const fakeRemoteFirefoxClient = new EventEmitter();
  const remoteFirefox = getFakeRemoteFirefox({
    installTemporaryAddon: sinon.spy(() => Promise.resolve(tempInstallResult)),
    ...fakeRemoteFirefox,
  });
  remoteFirefox.client = fakeRemoteFirefoxClient;

  // $FlowIgnore: allow overriden params for testing purpose.
  const runnerParams: FirefoxAndroidExtensionRunnerParams = {
    extensions: [
      {
        sourceDir: '/fake/sourceDir',
        manifestData: deepcopy(basicManifest),
      },
    ],
    keepProfileChanges: false,
    browserConsole: false,
    startUrl: undefined,
    firefoxBinary: 'firefox',
    preInstall: false,
    firefoxApp: getFakeFirefox(
      {
        ...fakeFirefoxApp,
      },
      debuggerPort
    ),
    ADBUtils: sinon.spy(function () {
      return fakeADBUtils;
    }),
    firefoxClient: sinon.spy(() => {
      return Promise.resolve(remoteFirefox);
    }),
    desktopNotifications: sinon.spy(() => {}),
    // $FlowIgnore: Allow to mock stdin with an EventEmitter for testing purpose.
    stdin: new EventEmitter(),
    ...(params || {}),
  };

  return {
    remoteFirefox,
    params: runnerParams,
  };
}

function prepareSelectedDeviceAndAPKParams(
  overriddenProperties = {},
  adbOverrides = {}
) {
  const fakeADBUtils = {
    discoverDevices: sinon.spy(() =>
      Promise.resolve(['emulator-1', 'emulator-2'])
    ),
    discoverInstalledFirefoxAPKs: sinon.spy(() =>
      Promise.resolve([
        'org.mozilla.fennec',
        'org.mozilla.firefox',
        'org.mozilla.fenix',
        'org.mozilla.fenix.nightly',
        'org.mozilla.reference.browser',
      ])
    ),
    getAndroidVersionNumber: sinon.spy(() => Promise.resolve(20)),
    amForceStopAPK: sinon.spy(() => Promise.resolve()),
    discoverRDPUnixSocket: sinon.spy(() =>
      Promise.resolve(fakeRDPUnixSocketFile)
    ),
    getOrCreateArtifactsDir: sinon.spy(() =>
      Promise.resolve('/fake/artifacts-dir/')
    ),
    runShellCommand: sinon.spy(() => Promise.resolve('')),
    pushFile: sinon.spy(() => Promise.resolve()),
    startFirefoxAPK: sinon.spy(() => Promise.resolve()),
    setupForward: sinon.spy(() => Promise.resolve()),
    clearArtifactsDir: sinon.spy(() => Promise.resolve()),
    detectOrRemoveOldArtifacts: sinon.spy(() => Promise.resolve(true)),
    setUserAbortDiscovery: sinon.spy(() => {}),
    ensureRequiredAPKRuntimePermissions: sinon.spy(() => Promise.resolve()),
    ...adbOverrides,
  };

  const { params } = prepareExtensionRunnerParams({
    params: {
      adbDevice: 'emulator-1',
      firefoxApk: 'org.mozilla.firefox',
      buildSourceDir: sinon.spy(() =>
        Promise.resolve({
          extensionPath: fakeBuiltExtensionPath,
        })
      ),
    },
    fakeADBUtils,
    fakeFirefoxApp: {
      createProfile: sinon.spy(() => {
        return Promise.resolve({ profileDir: '/path/to/fake/profile' });
      }),
    },
    ...overriddenProperties,
  });

  return { params, fakeADBUtils };
}

describe('util/extension-runners/firefox-android', () => {
  describe('raises an UsageError when:', () => {
    async function testUsageError(prepareTestParams, testExceptionCallback) {
      const { params } = prepareExtensionRunnerParams(prepareTestParams);
      const runnerInstance = new FirefoxAndroidExtensionRunner(params);

      let actualException;

      try {
        await runnerInstance.run();
      } catch (error) {
        actualException = error;
      }

      return testExceptionCallback({ actualException });
    }

    it('does not find any android device', async () => {
      const fakeADBUtils = {
        discoverDevices: sinon.spy(() => Promise.resolve([])),
      };
      await testUsageError({ fakeADBUtils }, ({ actualException }) => {
        sinon.assert.calledOnce(fakeADBUtils.discoverDevices);

        assert.instanceOf(actualException, UsageError);
        assert.match(
          actualException && actualException.message,
          /No Android device found/
        );
      });
    });

    it('does not know which is the selected android device', async () => {
      const fakeADBUtils = {
        discoverDevices: sinon.spy(() =>
          Promise.resolve(['emulator-1', 'emulator-2'])
        ),
      };
      await testUsageError({ fakeADBUtils }, ({ actualException }) => {
        sinon.assert.calledOnce(fakeADBUtils.discoverDevices);

        assert.instanceOf(actualException, UsageError);
        assert.match(
          actualException && actualException.message,
          /Select an android device using --android-device/
        );
      });
    });

    it('does not find the selected android device', async () => {
      const fakeADBUtils = {
        discoverDevices: sinon.spy(() =>
          Promise.resolve(['emulator-1', 'emulator-2'])
        ),
      };

      await testUsageError(
        {
          params: {
            adbDevice: 'emulator-3',
          },
          fakeADBUtils,
        },
        ({ actualException }) => {
          sinon.assert.calledOnce(fakeADBUtils.discoverDevices);

          assert.instanceOf(actualException, UsageError);
          assert.match(
            actualException && actualException.message,
            /Android device emulator-3 was not found in list:/
          );
        }
      );
    });

    it('does not find a valid Firefox apk', async () => {
      const fakeADBUtils = {
        discoverDevices: sinon.spy(() =>
          Promise.resolve(['emulator-1', 'emulator-2'])
        ),
        discoverInstalledFirefoxAPKs: sinon.spy(() => Promise.resolve([])),
      };

      await testUsageError(
        {
          params: {
            adbDevice: 'emulator-1',
          },
          fakeADBUtils,
        },
        ({ actualException }) => {
          sinon.assert.calledOnce(fakeADBUtils.discoverDevices);
          sinon.assert.calledOnce(fakeADBUtils.discoverInstalledFirefoxAPKs);

          assert.instanceOf(actualException, UsageError);
          assert.match(
            actualException && actualException.message,
            /No Firefox packages were found on the selected Android device/
          );
        }
      );
    });

    it('does not know which Firefox apk to use', async () => {
      const fakeADBUtils = {
        discoverDevices: sinon.spy(() =>
          Promise.resolve(['emulator-1', 'emulator-2'])
        ),
        discoverInstalledFirefoxAPKs: sinon.spy(() =>
          Promise.resolve(['org.mozilla.fennec', 'org.mozilla.firefox'])
        ),
      };

      await testUsageError(
        {
          params: {
            adbDevice: 'emulator-1',
          },
          fakeADBUtils,
        },
        ({ actualException }) => {
          sinon.assert.calledOnce(fakeADBUtils.discoverDevices);
          sinon.assert.calledOnce(fakeADBUtils.discoverInstalledFirefoxAPKs);

          assert.instanceOf(actualException, UsageError);
          assert.match(
            actualException && actualException.message,
            /Select one of the packages using --firefox-apk/
          );
        }
      );
    });

    it('cannot find the Firefox apk selected using --firefox-apk value', async () => {
      const fakeADBUtils = {
        discoverDevices: sinon.spy(() =>
          Promise.resolve(['emulator-1', 'emulator-2'])
        ),
        discoverInstalledFirefoxAPKs: sinon.spy(() =>
          Promise.resolve(['org.mozilla.fennec', 'org.mozilla.firefox'])
        ),
      };

      await testUsageError(
        {
          params: {
            adbDevice: 'emulator-1',
            firefoxApk: 'org.mozilla.f',
          },
          fakeADBUtils,
        },
        ({ actualException }) => {
          sinon.assert.calledOnce(fakeADBUtils.discoverDevices);
          sinon.assert.calledOnce(fakeADBUtils.discoverInstalledFirefoxAPKs);

          assert.instanceOf(actualException, UsageError);
          assert.match(
            actualException && actualException.message,
            /Package org.mozilla.f was not found in list:/
          );
        }
      );
    });
  });

  describe('a valid device and Firefox apk has been selected:', () => {
    it('does select a Firefox apk if only one has been found', async () => {
      const { params, fakeADBUtils } = prepareSelectedDeviceAndAPKParams();

      fakeADBUtils.discoverInstalledFirefoxAPKs = sinon.spy(() =>
        Promise.resolve(['org.mozilla.firefox'])
      );

      delete params.firefoxApk;

      const runnerInstance = new FirefoxAndroidExtensionRunner(params);

      await runnerInstance.run();

      sinon.assert.calledWithMatch(
        fakeADBUtils.amForceStopAPK,
        'emulator-1',
        'org.mozilla.firefox'
      );
    });

    it(
      'stops any running instances of the selected Firefox apk ' +
        'and then starts it on the temporary profile',
      async () => {
        const { params, fakeADBUtils } = prepareSelectedDeviceAndAPKParams();

        const runnerInstance = new FirefoxAndroidExtensionRunner(params);
        await runnerInstance.run();

        sinon.assert.calledWithMatch(
          fakeADBUtils.amForceStopAPK,
          'emulator-1',
          'org.mozilla.firefox'
        );

        sinon.assert.calledWithMatch(
          fakeADBUtils.startFirefoxAPK,
          'emulator-1',
          'org.mozilla.firefox',
          undefined,
          runnerInstance.getDeviceProfileDir()
        );

        sinon.assert.callOrder(
          fakeADBUtils.amForceStopAPK,
          fakeADBUtils.startFirefoxAPK
        );
      }
    );

    it('does check for existing artifacts dirs', async () => {
      const adbOverrides = {
        getOrCreateArtifactsDir: sinon.spy(() =>
          Promise.resolve('/data/local/tmp/web-ext-dir')
        ),
        detectOrRemoveOldArtifacts: sinon.spy(() => Promise.resolve(false)),
      };
      const overriddenProperties = {
        params: {
          adbDevice: 'emulator-1',
          firefoxApk: 'org.mozilla.firefox',
          buildSourceDir: sinon.spy(() =>
            Promise.resolve({
              extensionPath: fakeBuiltExtensionPath,
            })
          ),
          adbRemoveOldArtifacts: false,
        },
      };
      const { params, fakeADBUtils } = prepareSelectedDeviceAndAPKParams(
        overriddenProperties,
        adbOverrides
      );

      const runnerInstance = new FirefoxAndroidExtensionRunner(params);
      await runnerInstance.run();

      sinon.assert.calledWithMatch(
        fakeADBUtils.detectOrRemoveOldArtifacts,
        'emulator-1',
        false
      );

      // Ensure the old artifacts are checked or removed after stopping the
      // apk and before creating the new artifacts dir.
      sinon.assert.callOrder(
        fakeADBUtils.amForceStopAPK,
        fakeADBUtils.detectOrRemoveOldArtifacts,
        fakeADBUtils.getOrCreateArtifactsDir
      );
    });

    it('does optionally remove older artifacts dirs', async () => {
      const adbOverrides = {
        getOrCreateArtifactsDir: sinon.spy(() =>
          Promise.resolve('/data/local/tmp/web-ext-dir')
        ),
        detectOrRemoveOldArtifacts: sinon.spy(() => Promise.resolve(true)),
      };
      const overriddenProperties = {
        params: {
          adbDevice: 'emulator-1',
          firefoxApk: 'org.mozilla.firefox',
          buildSourceDir: sinon.spy(() =>
            Promise.resolve({
              extensionPath: fakeBuiltExtensionPath,
            })
          ),
          adbRemoveOldArtifacts: true,
        },
      };
      const { params, fakeADBUtils } = prepareSelectedDeviceAndAPKParams(
        overriddenProperties,
        adbOverrides
      );

      const runnerInstance = new FirefoxAndroidExtensionRunner(params);
      await runnerInstance.run();

      sinon.assert.calledWithMatch(
        fakeADBUtils.detectOrRemoveOldArtifacts,
        'emulator-1',
        true
      );

      // Ensure the old artifacts are checked or removed after stopping the
      // apk and before creating the new artifacts dir.
      sinon.assert.callOrder(
        fakeADBUtils.amForceStopAPK,
        fakeADBUtils.detectOrRemoveOldArtifacts,
        fakeADBUtils.getOrCreateArtifactsDir
      );
    });

    it('does run a specific apk component if specific', async () => {
      const { params, fakeADBUtils } = prepareSelectedDeviceAndAPKParams();

      const runnerInstance = new FirefoxAndroidExtensionRunner({
        ...params,
        firefoxApkComponent: 'CustomView',
      });
      await runnerInstance.run();
      sinon.assert.calledWithMatch(
        fakeADBUtils.startFirefoxAPK,
        'emulator-1',
        'org.mozilla.firefox',
        'CustomView',
        runnerInstance.getDeviceProfileDir()
      );
    });

    it('supports custom prefs via --pref', async () => {
      const fakeFirefoxApp = {
        createProfile: sinon.spy(() => {
          return Promise.resolve({ profileDir: '/path/to/fake/profile' });
        }),
      };
      const { params } = prepareSelectedDeviceAndAPKParams({
        fakeFirefoxApp,
      });

      // cmd/run.js maps --pref to customPrefs.
      params.customPrefs = { 'some.pref.name': 123 };

      const runnerInstance = new FirefoxAndroidExtensionRunner(params);
      await runnerInstance.run();

      sinon.assert.calledWithMatch(fakeFirefoxApp.createProfile, {
        app: 'fennec',
        customPrefs: { 'some.pref.name': 123 },
      });
    });

    it('builds and pushes the extension xpi to the android device', async () => {
      const { params, fakeADBUtils } = prepareSelectedDeviceAndAPKParams();
      const { buildSourceDir, extensions } = params;

      const runnerInstance = new FirefoxAndroidExtensionRunner(params);
      await runnerInstance.run();

      sinon.assert.calledWithMatch(buildSourceDir, extensions[0].sourceDir);

      sinon.assert.calledWithMatch(
        fakeADBUtils.pushFile,
        'emulator-1',
        fakeBuiltExtensionPath,
        `${runnerInstance.selectedArtifactsDir}/${builtFileName}.xpi`
      );

      sinon.assert.callOrder(buildSourceDir, fakeADBUtils.pushFile);
    });

    it('discovers the RDP unix socket and forward it on a local tcp port', async () => {
      const { params, fakeADBUtils } = prepareSelectedDeviceAndAPKParams();

      const runnerInstance = new FirefoxAndroidExtensionRunner(params);
      await runnerInstance.run();

      sinon.assert.calledWithMatch(
        fakeADBUtils.discoverRDPUnixSocket,
        'emulator-1',
        'org.mozilla.firefox'
      );

      sinon.assert.calledWithMatch(
        fakeADBUtils.setupForward,
        'emulator-1',
        `localfilesystem:${runnerInstance.selectedRDPSocketFile}`,
        `tcp:${runnerInstance.selectedTCPPort}`
      );

      sinon.assert.callOrder(
        fakeADBUtils.discoverRDPUnixSocket,
        fakeADBUtils.setupForward
      );
    });

    it('discovers the RDP abstract unix socket and forward it on', async () => {
      const { params, fakeADBUtils } = prepareSelectedDeviceAndAPKParams(
        {},
        {
          discoverRDPUnixSocket: sinon.spy(() =>
            Promise.resolve(fakeRDPUnixAbstractSocketFile)
          ),
        }
      );

      const runnerInstance = new FirefoxAndroidExtensionRunner(params);
      await runnerInstance.run();

      sinon.assert.calledWithMatch(
        fakeADBUtils.discoverRDPUnixSocket,
        'emulator-1',
        'org.mozilla.firefox'
      );

      sinon.assert.calledWithMatch(
        fakeADBUtils.setupForward,
        'emulator-1',
        'localabstract:org.mozilla.firefox/firefox-debugger-socket',
        `tcp:${runnerInstance.selectedTCPPort}`
      );

      sinon.assert.callOrder(
        fakeADBUtils.discoverRDPUnixSocket,
        fakeADBUtils.setupForward
      );
    });

    it('installs the build extension as a temporarily installed addon', async () => {
      const { params, fakeADBUtils } = prepareSelectedDeviceAndAPKParams();
      const { firefoxClient } = params;

      const runnerInstance = new FirefoxAndroidExtensionRunner(params);
      await runnerInstance.run();

      // Test that the android extension runner connects to the
      // remote debugging server on the tcp port that has been
      // chosen to forward the android device RDP unix socket file.

      sinon.assert.calledWithMatch(
        fakeADBUtils.setupForward,
        'emulator-1',
        `localfilesystem:${runnerInstance.selectedRDPSocketFile}`,
        `tcp:${runnerInstance.selectedTCPPort}`
      );

      sinon.assert.calledWithMatch(firefoxClient, {
        port: runnerInstance.selectedTCPPort,
      });

      // $FlowIgnore: ignore method-unbinding, sinon does just check the spy properties.
      const { installTemporaryAddon } = runnerInstance.remoteFirefox;

      sinon.assert.calledWithMatch(
        installTemporaryAddon,
        `${runnerInstance.selectedArtifactsDir}/${builtFileName}.xpi`
      );

      sinon.assert.callOrder(
        fakeADBUtils.setupForward,
        firefoxClient,
        installTemporaryAddon
      );
    });

    it('raises an error on addonId missing from installTemporaryAddon result', async () => {
      const { params } = prepareSelectedDeviceAndAPKParams({
        fakeRemoteFirefox: {
          installTemporaryAddon: sinon.spy(() =>
            Promise.resolve(tempInstallResultMissingAddonId)
          ),
        },
      });

      const expectedErrorMessage =
        /Received an empty addonId from remoteFirefox.installTemporaryAddon/;

      const runnerInstance = new FirefoxAndroidExtensionRunner(params);
      await runnerInstance
        .run()
        .catch((error) => error)
        .then((error) => {
          assert.instanceOf(error, WebExtError);
          assert.match(error && error.message, expectedErrorMessage);
        });
    });

    it('reloads all reloadable extensions when reloadAllExtensions is called', async () => {
      const { params } = prepareSelectedDeviceAndAPKParams();

      const runnerInstance = new FirefoxAndroidExtensionRunner(params);
      await runnerInstance.run();

      await runnerInstance.reloadAllExtensions();

      // $FlowIgnore: ignore method-unbinding, sinon does just check the spy properties.
      sinon.assert.calledOnce(runnerInstance.remoteFirefox.reloadAddon);
    });

    it('reloads an extension by sourceDir', async () => {
      const { params } = prepareSelectedDeviceAndAPKParams();

      const runnerInstance = new FirefoxAndroidExtensionRunner(params);
      await runnerInstance.run();

      await runnerInstance.reloadExtensionBySourceDir(
        params.extensions[0].sourceDir
      );

      // $FlowIgnore: ignore method-unbinding, sinon does just check the spy properties.
      sinon.assert.calledOnce(runnerInstance.remoteFirefox.reloadAddon);
    });

    it('resolves to an array of WebExtError if the extension is not reloadable', async () => {
      const { params } = prepareSelectedDeviceAndAPKParams();

      const runnerInstance = new FirefoxAndroidExtensionRunner(params);
      await runnerInstance.run();

      const results = await runnerInstance.reloadExtensionBySourceDir(
        '/non-existent/source-dir'
      );

      const error = results[0].reloadError;
      assert.instanceOf(error, WebExtError);
      assert.equal(
        error && error.message,
        'Extension not reloadable: no addonId has been mapped to ' +
          '"/non-existent/source-dir"'
      );

      // $FlowIgnore: ignore method-unbinding, sinon does just check the spy properties.
      sinon.assert.notCalled(runnerInstance.remoteFirefox.reloadAddon);
    });

    it('resolves an AllExtensionsReloadError if any extension fails to reload', async () => {
      const { params } = prepareSelectedDeviceAndAPKParams({
        fakeRemoteFirefox: {
          reloadAddon: sinon.spy(() => Promise.reject(Error('Reload failure'))),
        },
      });

      const runnerInstance = new FirefoxAndroidExtensionRunner(params);
      await runnerInstance.run();

      const results = await runnerInstance.reloadAllExtensions();

      const error = results[0].reloadError;
      assert.instanceOf(error, WebExtError);

      const { sourceDir } = params.extensions[0];
      assert.ok(
        error &&
          error.message.includes(
            `Error on extension loaded from ${sourceDir}: `
          )
      );

      // $FlowIgnore: ignore method-unbinding, sinon does just check the spy properties.
      sinon.assert.called(runnerInstance.remoteFirefox.reloadAddon);
    });

    it('cleans the android device state when the exit method is called', async () => {
      const { params, fakeADBUtils } = prepareSelectedDeviceAndAPKParams();

      const runnerInstance = new FirefoxAndroidExtensionRunner(params);
      const cleanupCallback = sinon.spy(() => {
        throw new Error('cleanup callback error');
      });
      const anotherCallback = sinon.spy();

      runnerInstance.registerCleanup(cleanupCallback);
      runnerInstance.registerCleanup(anotherCallback);

      await runnerInstance.run();
      await runnerInstance.exit();

      sinon.assert.calledWithMatch(
        fakeADBUtils.amForceStopAPK,
        'emulator-1',
        params.firefoxApk
      );

      assert.isString(runnerInstance.selectedArtifactsDir);
      assert.equal(runnerInstance.selectedArtifactsDir, '/fake/artifacts-dir/');

      sinon.assert.calledWithMatch(
        fakeADBUtils.clearArtifactsDir,
        'emulator-1'
      );
    });

    it('allows user to exit while waiting for the Android Firefox Debugger', async () => {
      const { params, fakeADBUtils } = prepareSelectedDeviceAndAPKParams();

      fakeADBUtils.discoverRDPUnixSocket = sinon.spy(async () => {
        fakeStdin.emit('keypress', 'c', { name: 'c', ctrl: true });

        sinon.assert.calledOnce(fakeADBUtils.setUserAbortDiscovery);
        sinon.assert.calledWith(fakeADBUtils.setUserAbortDiscovery);

        // Reject the expected error, if all the assertion passes.
        throw new UsageError('fake user exit');
      });

      const fakeStdin = createFakeStdin();

      params.stdin = fakeStdin;

      let actualError;

      try {
        const runnerInstance = new FirefoxAndroidExtensionRunner(params);
        await runnerInstance.run();
      } catch (error) {
        actualError = error;
      } finally {
        fakeStdin.emit('keypress', 'c', { name: 'c', ctrl: true });
      }

      assert.instanceOf(actualError, UsageError);
      assert.match(actualError && actualError.message, /fake user exit/);
    });

    it('rejects on Android Firefox Debugger discovery timeouts', async () => {
      const { params, fakeADBUtils } = prepareSelectedDeviceAndAPKParams();

      fakeADBUtils.discoverRDPUnixSocket = sinon.spy(() => {
        return Promise.reject(new WebExtError('fake timeout'));
      });

      params.adbDiscoveryTimeout = 0;

      let actualError;

      try {
        const runnerInstance = new FirefoxAndroidExtensionRunner(params);
        await runnerInstance.run();
      } catch (error) {
        actualError = error;
      }

      assert.instanceOf(actualError, WebExtError);
      assert.match(actualError && actualError.message, /fake timeout/);
    });

    it('rejects if an extension has never been uploaded on the device', async () => {
      const { params } = prepareSelectedDeviceAndAPKParams();

      const fakeFirefoxClient = params.firefoxClient;

      let actualError;
      let runnerInstance;

      params.firefoxClient = sinon.spy((firefoxClientParams) => {
        // Clear the map of uploaded extensions to fake a missing one.
        runnerInstance.adbExtensionsPathBySourceDir.clear();
        return fakeFirefoxClient(firefoxClientParams);
      });

      try {
        runnerInstance = new FirefoxAndroidExtensionRunner(params);
        await runnerInstance.run();
      } catch (error) {
        actualError = error;
      }

      assert.instanceOf(actualError, WebExtError);
      assert.match(
        actualError && actualError.message,
        /ADB extension path for "(.*)" was unexpectedly empty/
      );
    });

    it('calls the callback registered on cleanup when firefox closes', async () => {
      const { params, fakeADBUtils } = prepareSelectedDeviceAndAPKParams();

      const runnerInstance = new FirefoxAndroidExtensionRunner(params);
      const cleanupCallback = sinon.spy(() => {
        throw new Error('cleanup callback error');
      });
      const anotherCallback = sinon.spy();

      let finalCallback = () => {};

      const waitFinalCallback = new Promise((resolve) => {
        finalCallback = () => resolve();
      });

      runnerInstance.registerCleanup(cleanupCallback);
      runnerInstance.registerCleanup(anotherCallback);
      runnerInstance.registerCleanup(finalCallback);

      await runnerInstance.run();

      runnerInstance.remoteFirefox.client.emit('end');

      await waitFinalCallback;

      sinon.assert.calledWithMatch(
        fakeADBUtils.amForceStopAPK,
        'emulator-1',
        params.firefoxApk
      );

      sinon.assert.calledOnce(cleanupCallback);
      sinon.assert.calledOnce(anotherCallback);
    });

    it('raises an error when unable to find an android version number', async () => {
      async function expectInvalidVersionError(version: any) {
        const { params, fakeADBUtils } = prepareSelectedDeviceAndAPKParams();

        fakeADBUtils.getAndroidVersionNumber = sinon.spy(() => {
          return version;
        });

        const runnerInstance = new FirefoxAndroidExtensionRunner(params);
        const promise = runnerInstance.run();

        const expectedMsg = `Invalid Android version: ${version}`;
        await assert.isRejected(promise, WebExtError);
        await assert.isRejected(promise, expectedMsg);
      }

      await expectInvalidVersionError(undefined);
      await expectInvalidVersionError(NaN);
    });

    it('does not check granted android permissions on Android <= 21', async () => {
      async function expectNoGrantedPermissionDiscovery(version) {
        const { params, fakeADBUtils } = prepareSelectedDeviceAndAPKParams();

        fakeADBUtils.getAndroidVersionNumber = sinon.spy(() => {
          return Promise.resolve(version);
        });

        const runnerInstance = new FirefoxAndroidExtensionRunner(params);

        await runnerInstance.run();

        sinon.assert.calledWithMatch(
          fakeADBUtils.getAndroidVersionNumber,
          'emulator-1'
        );

        sinon.assert.notCalled(
          fakeADBUtils.ensureRequiredAPKRuntimePermissions
        );
      }

      // KitKat (Android 4.4).
      await expectNoGrantedPermissionDiscovery(19);
      await expectNoGrantedPermissionDiscovery(21);
      // Lollipop versions (Android 5.0 and 5.1).
      await expectNoGrantedPermissionDiscovery(22);
    });

    it('checks the granted android permissions on Android >= 23', async () => {
      async function testGrantedPermissionDiscovery(version) {
        const { params, fakeADBUtils } = prepareSelectedDeviceAndAPKParams();

        fakeADBUtils.getAndroidVersionNumber = sinon.spy(() => {
          return Promise.resolve(version);
        });

        const runnerInstance = new FirefoxAndroidExtensionRunner(params);

        await runnerInstance.run();

        sinon.assert.calledWithMatch(
          fakeADBUtils.getAndroidVersionNumber,
          'emulator-1'
        );

        sinon.assert.calledWithMatch(
          fakeADBUtils.ensureRequiredAPKRuntimePermissions,
          'emulator-1',
          'org.mozilla.firefox',
          ['android.permission.READ_EXTERNAL_STORAGE']
        );

        sinon.assert.callOrder(
          fakeADBUtils.getAndroidVersionNumber,
          fakeADBUtils.ensureRequiredAPKRuntimePermissions
        );
      }

      // Marshmallow (Android 6.0)
      await testGrantedPermissionDiscovery(23);
      // Nougat versions (Android 7.0 and 7.1.1)
      await testGrantedPermissionDiscovery(24);
      await testGrantedPermissionDiscovery(25);
    });

    it('logs warnings on the unsupported CLI options', async () => {
      const params = prepareSelectedDeviceAndAPKParams();

      consoleStream.startCapturing();

      const optionsWarningTestCases = [
        {
          params: { profilePath: '/fake/dir' },
          expectedMessage: /Android target does not support --profile-path/,
        },
        {
          params: { keepProfileChanges: true },
          expectedMessage:
            /Android target does not support --keep-profile-changes/,
        },
        {
          params: { browserConsole: true },
          expectedMessage: /Android target does not support --browser-console/,
        },
        {
          params: { preInstall: true },
          expectedMessage: /Android target does not support --pre-install/,
        },
        {
          params: { startUrl: 'http://fake-start-url.org' },
          expectedMessage: /Android target does not support --start-url/,
        },
        {
          params: { args: ['-headless=false'] },
          expectedMessage: /Android target does not support --args/,
        },
      ];

      for (const testCase of optionsWarningTestCases) {
        const runnerOptions = { ...params, ...testCase.params };
        // $FlowIgnore: allow use of inexact object literal for testing purpose.
        new FirefoxAndroidExtensionRunner(runnerOptions); // eslint-disable-line no-new

        assert.match(
          consoleStream.capturedMessages[0],
          testCase.expectedMessage
        );

        consoleStream.flushCapturedLogs();
      }

      consoleStream.stopCapturing();
    });
  });
});
