diff --git a/src/extension-runners/firefox-android.js b/src/extension-runners/firefox-android.js index 3f68523b31..6cc11dfcc2 100644 --- a/src/extension-runners/firefox-android.js +++ b/src/extension-runners/firefox-android.js @@ -3,9 +3,12 @@ * in a Firefox for Android instance. */ +import fs from 'fs/promises'; import path from 'path'; import readline from 'readline'; +import JSZip from 'jszip'; + import { withTempDir } from '../util/temp-dir.js'; import DefaultADBUtils from '../util/adb.js'; import { @@ -24,7 +27,6 @@ const ignoredParams = { keepProfileChanges: '--keep-profile-changes', browserConsole: '--browser-console', preInstall: '--pre-install', - startUrl: '--start-url', args: '--args', }; @@ -116,6 +118,8 @@ export class FirefoxAndroidExtensionRunner { // Connect to RDP socket on the local tcp server, install all the pushed extension // and keep track of the built and installed extension by extension sourceDir. await this.rdpInstallExtensions(); + + await this.launchStartUrlsIfNeeded(); } // Method exported from the IExtensionRunner interface. @@ -569,4 +573,83 @@ export class FirefoxAndroidExtensionRunner { this.reloadableExtensions.set(extension.sourceDir, addonId); } } + + /** + * Creates a helper extension to open --start-url as needed. + * Must be called after rdpInstallExtensions(). + * + * The opened tabs could be in the background, if Fenix's homescreen is shown + * when the app launches. This is a limitation of Firefox for Android. + */ + async launchStartUrlsIfNeeded() { + const { + adbUtils, + selectedAdbDevice, + selectedArtifactsDir, + params: { startUrl }, + } = this; + if (!startUrl?.length) { + return; + } + const urls = Array.isArray(startUrl) ? startUrl : [startUrl]; + + log.debug(`Trying to open URLs: ${urls.join(' ')}`); + + async function backgroundScript(urlsToOpen) { + // eslint-disable-next-line no-undef + const browser = globalThis.browser; + for (const url of urlsToOpen) { + try { + await browser.tabs.create({ url }); + } catch (e) { + // Ignore invalid URLs. + Promise.reject(e); + } + } + await browser.management.uninstallSelf(); + } + // The extension ID and file name here are chosen to be unique. In theory + // if users choose the same extension ID, this would overwrite their + // extension. They should simply not choose these identifiers! + const xpiFileName = 'web-ext-internal-helper-to-open-start-urls.xpi'; + const adbExtensionPath = `${selectedArtifactsDir}/${xpiFileName}`; + const manifestJson = JSON.stringify({ + name: 'web-ext helper to open URLs', + description: 'web-ext helper to open URLs in new tabs and self-destruct', + version: '1', + manifest_version: 3, + background: { scripts: ['background.js'] }, + permissions: ['notifications'], + browser_specific_settings: { + gecko: { id: '@web-ext-internal-helper-to-open-start-urls' }, + gecko_android: {}, + }, + }); + const backgroundJs = `(${backgroundScript})(${JSON.stringify(urls)})`; + await withTempDir(async (tmpDir) => { + const zip = new JSZip(); + zip.file('manifest.json', manifestJson); + zip.file('background.js', backgroundJs); + const rawZipBytes = await zip.generateAsync({ + compression: 'DEFLATE', + type: 'nodebuffer', + }); + const extensionPath = path.join(tmpDir.path(), xpiFileName); + await fs.writeFile(extensionPath, rawZipBytes); + + await adbUtils.pushFile( + selectedAdbDevice, + extensionPath, + adbExtensionPath, + ); + }); + try { + // this.remoteFirefox is initialized by rdpInstallExtensions. + await this.remoteFirefox.installTemporaryAddon(adbExtensionPath); + log.debug('Successfully installed helper extension to open URLs'); + } catch (e) { + // Unexpected, but not a fatal error from web-ext's perspective. + log.error(`Failed to open URLs via internal helper extension: ${e}`); + } + } } diff --git a/tests/unit/helpers.js b/tests/unit/helpers.js index 708cb9507d..e34d57ef50 100644 --- a/tests/unit/helpers.js +++ b/tests/unit/helpers.js @@ -1,6 +1,7 @@ import path from 'path'; import EventEmitter from 'events'; import stream from 'stream'; +import streamConsumers from 'stream/consumers'; import { promisify } from 'util'; import { fileURLToPath, pathToFileURL } from 'url'; @@ -26,14 +27,15 @@ export class ZipFile { constructor() { this._zip = null; this._close = null; + this._entries = null; } /* * Open a zip file and return a promise that resolves to a yauzl * zipfile object. */ - open(...args) { - return promisify(yauzl.open)(...args).then((zip) => { + open(zippath) { + return promisify(yauzl.open)(zippath, { autoClose: false }).then((zip) => { this._zip = zip; this._close = new Promise((resolve) => { zip.once('close', resolve); @@ -62,8 +64,13 @@ export class ZipFile { 'Cannot operate on a falsey zip file. Call open() first.', ); } + if (this._entries) { + throw new Error('readEach can be called only once'); + } + this._entries = new Map(); this._zip.on('entry', (entry) => { + this._entries.set(entry.fileName, entry); onRead(entry); }); @@ -94,6 +101,32 @@ export class ZipFile { }); }); } + + async getEntryByFileName(fileName) { + if (!this._entries) { + await this.readEach(() => {}); + } + return this._entries.get(fileName); + } + + /** + * Resolve a promise with the content of the entry as a string. + */ + async getAsText(fileName) { + const entry = await this.getEntryByFileName(fileName); + if (!entry) { + throw new Error(`Entry not found in zip file: ${fileName}`); + } + return new Promise((resolve, reject) => { + this._zip.openReadStream(entry, (err, readStream) => { + if (err) { + reject(err); + } else { + resolve(streamConsumers.text(readStream)); + } + }); + }); + } } /* diff --git a/tests/unit/test-extension-runners/test.firefox-android.js b/tests/unit/test-extension-runners/test.firefox-android.js index 7f168646c2..29f6db1ad6 100644 --- a/tests/unit/test-extension-runners/test.firefox-android.js +++ b/tests/unit/test-extension-runners/test.firefox-android.js @@ -13,6 +13,7 @@ import { createFakeStdin, getFakeFirefox, getFakeRemoteFirefox, + ZipFile, } from '../helpers.js'; // Fake result for client.installTemporaryAddon().then(installResult => ...) @@ -35,6 +36,9 @@ const fakeRDPUnixSocketFile = const fakeRDPUnixAbstractSocketFile = '@org.mozilla.firefox/firefox-debugger-socket'; +// The actual XPI, xpiFileName in src/extension-runners/firefox-android.js +const helperXpiName = 'web-ext-internal-helper-to-open-start-urls.xpi'; + // Reduce the waiting time during tests. FirefoxAndroidExtensionRunner.unixSocketDiscoveryRetryInterval = 0; @@ -833,6 +837,84 @@ describe('util/extension-runners/firefox-android', () => { sinon.assert.calledOnce(anotherCallback); }); + it('opens a single URL when specified via --start-url', async () => { + const { params, fakeADBUtils } = prepareSelectedDeviceAndAPKParams(); + params.startUrl = 'https://example.com/'; + + let pushedBackgroundJs; + fakeADBUtils.pushFile = sinon.spy(async (_, localZipPath) => { + if (localZipPath.includes(helperXpiName)) { + const zipFile = new ZipFile(); + await zipFile.open(localZipPath); + pushedBackgroundJs = await zipFile.getAsText('background.js'); + await zipFile.close(); + } + }); + + const runnerInstance = new FirefoxAndroidExtensionRunner(params); + await runnerInstance.run(); + + const { installTemporaryAddon } = runnerInstance.remoteFirefox; + + sinon.assert.calledTwice(installTemporaryAddon); + + sinon.assert.calledWithMatch( + installTemporaryAddon, + `${runnerInstance.selectedArtifactsDir}/${builtFileName}.xpi`, + ); + + sinon.assert.calledWithMatch( + installTemporaryAddon, + `${runnerInstance.selectedArtifactsDir}/${helperXpiName}`, + ); + sinon.assert.calledWithMatch( + fakeADBUtils.pushFile, + 'emulator-1', + sinon.match(/\bweb-ext-internal-helper-to-open-start-urls\.xpi$/), + `${runnerInstance.selectedArtifactsDir}/${helperXpiName}`, + ); + assert.include(pushedBackgroundJs, '["https://example.com/"]'); + }); + + it('opens a multiple URL when specified via --start-url', async () => { + const { params, fakeADBUtils } = prepareSelectedDeviceAndAPKParams(); + params.startUrl = ['about:blank', 'http://localhost']; + + let pushedBackgroundJs; + fakeADBUtils.pushFile = sinon.spy(async (_, localZipPath) => { + if (localZipPath.includes(helperXpiName)) { + const zipFile = new ZipFile(); + await zipFile.open(localZipPath); + pushedBackgroundJs = await zipFile.getAsText('background.js'); + await zipFile.close(); + } + }); + + const runnerInstance = new FirefoxAndroidExtensionRunner(params); + await runnerInstance.run(); + + sinon.assert.calledWithMatch( + fakeADBUtils.pushFile, + 'emulator-1', + sinon.match(/\bweb-ext-internal-helper-to-open-start-urls\.xpi$/), + `${runnerInstance.selectedArtifactsDir}/${helperXpiName}`, + ); + assert.include(pushedBackgroundJs, '["about:blank","http://localhost"]'); + }); + + it('does not install helper extension without --start-url', async () => { + const { params } = prepareSelectedDeviceAndAPKParams(); + + const runnerInstance = new FirefoxAndroidExtensionRunner(params); + await runnerInstance.run(); + + const { installTemporaryAddon } = runnerInstance.remoteFirefox; + sinon.assert.calledOnce(installTemporaryAddon); + const seenPath = installTemporaryAddon.firstCall.args[0]; + assert.include(seenPath, '.xpi'); + assert.notInclude(seenPath, helperXpiName); + }); + it('logs warnings on the unsupported CLI options', async () => { const params = prepareSelectedDeviceAndAPKParams(); @@ -856,10 +938,6 @@ describe('util/extension-runners/firefox-android', () => { 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/,