Skip to content

Commit 582b490

Browse files
committed
feat: Support --start-url for target firefox-android
1 parent e582e8f commit 582b490

3 files changed

Lines changed: 201 additions & 7 deletions

File tree

src/extension-runners/firefox-android.js

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
* in a Firefox for Android instance.
44
*/
55

6+
import fs from 'fs/promises';
67
import path from 'path';
78
import readline from 'readline';
89

10+
import JSZip from 'jszip';
11+
912
import { withTempDir } from '../util/temp-dir.js';
1013
import DefaultADBUtils from '../util/adb.js';
1114
import {
@@ -24,7 +27,6 @@ const ignoredParams = {
2427
keepProfileChanges: '--keep-profile-changes',
2528
browserConsole: '--browser-console',
2629
preInstall: '--pre-install',
27-
startUrl: '--start-url',
2830
args: '--args',
2931
};
3032

@@ -116,6 +118,8 @@ export class FirefoxAndroidExtensionRunner {
116118
// Connect to RDP socket on the local tcp server, install all the pushed extension
117119
// and keep track of the built and installed extension by extension sourceDir.
118120
await this.rdpInstallExtensions();
121+
122+
await this.launchStartUrlsIfNeeded();
119123
}
120124

121125
// Method exported from the IExtensionRunner interface.
@@ -569,4 +573,83 @@ export class FirefoxAndroidExtensionRunner {
569573
this.reloadableExtensions.set(extension.sourceDir, addonId);
570574
}
571575
}
576+
577+
/**
578+
* Creates a helper extension to open --start-url as needed.
579+
* Must be called after rdpInstallExtensions().
580+
*
581+
* The opened tabs could be in the background, if Fenix's homescreen is shown
582+
* when the app launches. This is a limitation of Firefox for Android.
583+
*/
584+
async launchStartUrlsIfNeeded() {
585+
const {
586+
adbUtils,
587+
selectedAdbDevice,
588+
selectedArtifactsDir,
589+
params: { startUrl },
590+
} = this;
591+
if (!startUrl?.length) {
592+
return;
593+
}
594+
const urls = Array.isArray(startUrl) ? startUrl : [startUrl];
595+
596+
log.debug(`Trying to open URLs: ${urls.join(' ')}`);
597+
598+
async function backgroundScript(urlsToOpen) {
599+
// eslint-disable-next-line no-undef
600+
const browser = globalThis.browser;
601+
for (const url of urlsToOpen) {
602+
try {
603+
await browser.tabs.create({ url });
604+
} catch (e) {
605+
// Ignore invalid URLs.
606+
Promise.reject(e);
607+
}
608+
}
609+
await browser.management.uninstallSelf();
610+
}
611+
// The extension ID and file name here are chosen to be unique. In theory
612+
// if users choose the same extension ID, this would overwrite their
613+
// extension. They should simply not choose these identifiers!
614+
const xpiFileName = 'web-ext-internal-helper-to-open-start-urls.xpi';
615+
const adbExtensionPath = `${selectedArtifactsDir}/${xpiFileName}`;
616+
const manifestJson = JSON.stringify({
617+
name: 'web-ext helper to open URLs',
618+
description: 'web-ext helper to open URLs in new tabs and self-destruct',
619+
version: '1',
620+
manifest_version: 3,
621+
background: { scripts: ['background.js'] },
622+
permissions: ['notifications'],
623+
browser_specific_settings: {
624+
gecko: { id: '@web-ext-internal-helper-to-open-start-urls' },
625+
gecko_android: {},
626+
},
627+
});
628+
const backgroundJs = `(${backgroundScript})(${JSON.stringify(urls)})`;
629+
await withTempDir(async (tmpDir) => {
630+
const zip = new JSZip();
631+
zip.file('manifest.json', manifestJson);
632+
zip.file('background.js', backgroundJs);
633+
const rawZipBytes = await zip.generateAsync({
634+
compression: 'DEFLATE',
635+
type: 'nodebuffer',
636+
});
637+
const extensionPath = path.join(tmpDir.path(), xpiFileName);
638+
await fs.writeFile(extensionPath, rawZipBytes);
639+
640+
await adbUtils.pushFile(
641+
selectedAdbDevice,
642+
extensionPath,
643+
adbExtensionPath,
644+
);
645+
});
646+
try {
647+
// this.remoteFirefox is initialized by rdpInstallExtensions.
648+
await this.remoteFirefox.installTemporaryAddon(adbExtensionPath);
649+
log.debug('Successfully installed helper extension to open URLs');
650+
} catch (e) {
651+
// Unexpected, but not a fatal error from web-ext's perspective.
652+
log.error(`Failed to open URLs via internal helper extension: ${e}`);
653+
}
654+
}
572655
}

tests/unit/helpers.js

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import path from 'path';
22
import EventEmitter from 'events';
33
import stream from 'stream';
4+
import streamConsumers from 'stream/consumers';
45
import { promisify } from 'util';
56
import { fileURLToPath, pathToFileURL } from 'url';
67

@@ -26,14 +27,15 @@ export class ZipFile {
2627
constructor() {
2728
this._zip = null;
2829
this._close = null;
30+
this._entries = null;
2931
}
3032

3133
/*
3234
* Open a zip file and return a promise that resolves to a yauzl
3335
* zipfile object.
3436
*/
35-
open(...args) {
36-
return promisify(yauzl.open)(...args).then((zip) => {
37+
open(zippath) {
38+
return promisify(yauzl.open)(zippath, { autoClose: false }).then((zip) => {
3739
this._zip = zip;
3840
this._close = new Promise((resolve) => {
3941
zip.once('close', resolve);
@@ -62,8 +64,13 @@ export class ZipFile {
6264
'Cannot operate on a falsey zip file. Call open() first.',
6365
);
6466
}
67+
if (this._entries) {
68+
throw new Error('readEach can be called only once');
69+
}
70+
this._entries = new Map();
6571

6672
this._zip.on('entry', (entry) => {
73+
this._entries.set(entry.fileName, entry);
6774
onRead(entry);
6875
});
6976

@@ -94,6 +101,32 @@ export class ZipFile {
94101
});
95102
});
96103
}
104+
105+
async getEntryByFileName(fileName) {
106+
if (!this._entries) {
107+
await this.readEach(() => {});
108+
}
109+
return this._entries.get(fileName);
110+
}
111+
112+
/**
113+
* Resolve a promise with the content of the entry as a string.
114+
*/
115+
async getAsText(fileName) {
116+
const entry = await this.getEntryByFileName(fileName);
117+
if (!entry) {
118+
throw new Error(`Entry not found in zip file: ${fileName}`);
119+
}
120+
return new Promise((resolve, reject) => {
121+
this._zip.openReadStream(entry, (err, readStream) => {
122+
if (err) {
123+
reject(err);
124+
} else {
125+
resolve(streamConsumers.text(readStream));
126+
}
127+
});
128+
});
129+
}
97130
}
98131

99132
/*

tests/unit/test-extension-runners/test.firefox-android.js

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
createFakeStdin,
1414
getFakeFirefox,
1515
getFakeRemoteFirefox,
16+
ZipFile,
1617
} from '../helpers.js';
1718

1819
// Fake result for client.installTemporaryAddon().then(installResult => ...)
@@ -35,6 +36,9 @@ const fakeRDPUnixSocketFile =
3536
const fakeRDPUnixAbstractSocketFile =
3637
'@org.mozilla.firefox/firefox-debugger-socket';
3738

39+
// The actual XPI, xpiFileName in src/extension-runners/firefox-android.js
40+
const helperXpiName = 'web-ext-internal-helper-to-open-start-urls.xpi';
41+
3842
// Reduce the waiting time during tests.
3943
FirefoxAndroidExtensionRunner.unixSocketDiscoveryRetryInterval = 0;
4044

@@ -833,6 +837,84 @@ describe('util/extension-runners/firefox-android', () => {
833837
sinon.assert.calledOnce(anotherCallback);
834838
});
835839

840+
it('opens a single URL when specified via --start-url', async () => {
841+
const { params, fakeADBUtils } = prepareSelectedDeviceAndAPKParams();
842+
params.startUrl = 'https://example.com/';
843+
844+
let pushedBackgroundJs;
845+
fakeADBUtils.pushFile = sinon.spy(async (_, localZipPath) => {
846+
if (localZipPath.includes(helperXpiName)) {
847+
const zipFile = new ZipFile();
848+
await zipFile.open(localZipPath);
849+
pushedBackgroundJs = await zipFile.getAsText('background.js');
850+
await zipFile.close();
851+
}
852+
});
853+
854+
const runnerInstance = new FirefoxAndroidExtensionRunner(params);
855+
await runnerInstance.run();
856+
857+
const { installTemporaryAddon } = runnerInstance.remoteFirefox;
858+
859+
sinon.assert.calledTwice(installTemporaryAddon);
860+
861+
sinon.assert.calledWithMatch(
862+
installTemporaryAddon,
863+
`${runnerInstance.selectedArtifactsDir}/${builtFileName}.xpi`,
864+
);
865+
866+
sinon.assert.calledWithMatch(
867+
installTemporaryAddon,
868+
`${runnerInstance.selectedArtifactsDir}/${helperXpiName}`,
869+
);
870+
sinon.assert.calledWithMatch(
871+
fakeADBUtils.pushFile,
872+
'emulator-1',
873+
sinon.match(/\bweb-ext-internal-helper-to-open-start-urls\.xpi$/),
874+
`${runnerInstance.selectedArtifactsDir}/${helperXpiName}`,
875+
);
876+
assert.include(pushedBackgroundJs, '["https://example.com/"]');
877+
});
878+
879+
it('opens a multiple URL when specified via --start-url', async () => {
880+
const { params, fakeADBUtils } = prepareSelectedDeviceAndAPKParams();
881+
params.startUrl = ['about:blank', 'http://localhost'];
882+
883+
let pushedBackgroundJs;
884+
fakeADBUtils.pushFile = sinon.spy(async (_, localZipPath) => {
885+
if (localZipPath.includes(helperXpiName)) {
886+
const zipFile = new ZipFile();
887+
await zipFile.open(localZipPath);
888+
pushedBackgroundJs = await zipFile.getAsText('background.js');
889+
await zipFile.close();
890+
}
891+
});
892+
893+
const runnerInstance = new FirefoxAndroidExtensionRunner(params);
894+
await runnerInstance.run();
895+
896+
sinon.assert.calledWithMatch(
897+
fakeADBUtils.pushFile,
898+
'emulator-1',
899+
sinon.match(/\bweb-ext-internal-helper-to-open-start-urls\.xpi$/),
900+
`${runnerInstance.selectedArtifactsDir}/${helperXpiName}`,
901+
);
902+
assert.include(pushedBackgroundJs, '["about:blank","http://localhost"]');
903+
});
904+
905+
it('does not install helper extension without --start-url', async () => {
906+
const { params } = prepareSelectedDeviceAndAPKParams();
907+
908+
const runnerInstance = new FirefoxAndroidExtensionRunner(params);
909+
await runnerInstance.run();
910+
911+
const { installTemporaryAddon } = runnerInstance.remoteFirefox;
912+
sinon.assert.calledOnce(installTemporaryAddon);
913+
const seenPath = installTemporaryAddon.firstCall.args[0];
914+
assert.include(seenPath, '.xpi');
915+
assert.notInclude(seenPath, helperXpiName);
916+
});
917+
836918
it('logs warnings on the unsupported CLI options', async () => {
837919
const params = prepareSelectedDeviceAndAPKParams();
838920

@@ -856,10 +938,6 @@ describe('util/extension-runners/firefox-android', () => {
856938
params: { preInstall: true },
857939
expectedMessage: /Android target does not support --pre-install/,
858940
},
859-
{
860-
params: { startUrl: 'http://fake-start-url.org' },
861-
expectedMessage: /Android target does not support --start-url/,
862-
},
863941
{
864942
params: { args: ['-headless=false'] },
865943
expectedMessage: /Android target does not support --args/,

0 commit comments

Comments
 (0)