diff --git a/src/cmd/run.js b/src/cmd/run.js index d07d8df6dd..56e8f72815 100644 --- a/src/cmd/run.js +++ b/src/cmd/run.js @@ -51,6 +51,7 @@ export default async function run( firefoxApkComponent, // Chromium CLI options. chromiumBinary, + chromiumPref = {}, chromiumProfile, }, { @@ -84,6 +85,11 @@ export default async function run( // Create an alias for --pref since it has been transformed into an // object containing one or more preferences. const customPrefs = pref; + + // Create an alias for --chromium-pref since it has been transformed into an + // object containing one or more preferences. + const customChromiumPrefs = chromiumPref; + const manifestData = await getValidatedManifest(sourceDir); const profileDir = firefoxProfile || chromiumProfile; @@ -191,6 +197,7 @@ export default async function run( ...commonRunnerParams, verbose, chromiumBinary, + customChromiumPrefs, chromiumProfile, }; diff --git a/src/extension-runners/chromium.js b/src/extension-runners/chromium.js index a2f91d813c..f04faff69a 100644 --- a/src/extension-runners/chromium.js +++ b/src/extension-runners/chromium.js @@ -15,6 +15,7 @@ import { createLogger } from '../util/logger.js'; import { TempDir } from '../util/temp-dir.js'; import isDirectory from '../util/is-directory.js'; import fileExists from '../util/file-exists.js'; +import expandPrefs from '../util/expand-prefs.js'; const log = createLogger(import.meta.url); @@ -28,6 +29,8 @@ export const DEFAULT_CHROME_FLAGS = ChromeLauncher.defaultFlags().filter( (flag) => !EXCLUDED_CHROME_FLAGS.includes(flag), ); +const DEFAULT_PREFS = { 'extensions.ui.developer_mode': true }; + // This is a client for the Chrome Devtools protocol. The methods and results // are documented at https://chromedevtools.github.io/devtools-protocol/tot/ class ChromeDevtoolsProtocolClient { @@ -331,6 +334,7 @@ export class ChromiumExtensionRunner { logLevel: this.params.verbose ? 'verbose' : 'silent', // Ignore default flags to keep the extension enabled. ignoreDefaultFlags: true, + prefs: this.getPrefs(), }); this.cdp = new ChromeDevtoolsProtocolClient(this.chromiumInstance); @@ -598,4 +602,15 @@ export class ChromiumExtensionRunner { } } } + + /** + * Returns a deep preferences object based on a set of flat preferences, like + * "extensions.ui.developer_mode". + */ + getPrefs() { + return expandPrefs({ + ...DEFAULT_PREFS, + ...this.params.customChromiumPrefs, + }); + } } diff --git a/src/program.js b/src/program.js index 5daca429ba..d7c1414c3e 100644 --- a/src/program.js +++ b/src/program.js @@ -619,6 +619,18 @@ Example: $0 --help run. demandOption: false, type: 'string', }, + 'chromium-pref': { + describe: + 'Launch chromium with a custom preference ' + + '(example: --chromium-pref=browser.theme.follows_system_colors=false). ' + + 'You can repeat this option to set more than one ' + + 'preference.', + demandOption: false, + requiresArg: true, + type: 'array', + coerce: (arg) => + arg != null ? coerceCLICustomPreference(arg) : undefined, + }, 'chromium-profile': { describe: 'Path to a custom Chromium profile', demandOption: false, diff --git a/src/util/expand-prefs.js b/src/util/expand-prefs.js new file mode 100644 index 0000000000..58018e4bfe --- /dev/null +++ b/src/util/expand-prefs.js @@ -0,0 +1,36 @@ +/** + * Given an object where the keys are the flattened path to a + * preference, and the value is the value to set at that path, return + * an object where the paths are fully expanded. + */ +export default function expandPrefs(prefs) { + const prefsMap = new Map(); + + for (const [key, value] of Object.entries(prefs)) { + let submap = prefsMap; + const props = key.split('.'); + const lastProp = props.pop(); + + for (const prop of props) { + if (!submap.has(prop)) { + submap.set(prop, new Map()); + } + + submap = submap.get(prop); + + if (!(submap instanceof Map)) { + throw new Error( + `Cannot set ${key} because a value already exists at ${prop}`, + ); + } + } + submap.set(lastProp, value); + } + return mapToObject(prefsMap); +} + +function mapToObject(map) { + return Object.fromEntries( + Array.from(map, ([k, v]) => [k, v instanceof Map ? mapToObject(v) : v]), + ); +} diff --git a/tests/unit/test-cmd/test.run.js b/tests/unit/test-cmd/test.run.js index 19f5f88e4c..05595877bc 100644 --- a/tests/unit/test-cmd/test.run.js +++ b/tests/unit/test-cmd/test.run.js @@ -341,6 +341,22 @@ describe('run', () => { ); }); + it('provides chromium custom preferences to the Chromium runner params', async () => { + const fakeChromiumPref = { 'extensions.ui.developer_mode': false }; + const cmd = await prepareRun(); + await cmd.run({ + target: ['chromium'], + chromiumPref: fakeChromiumPref, + }); + + sinon.assert.calledWithMatch(chromiumRunnerStub, { + customChromiumPrefs: sinon.match.object, + }); + + const { customChromiumPrefs } = chromiumRunnerStub.firstCall.args[0]; + assert.strictEqual(customChromiumPrefs, fakeChromiumPref); + }); + it('creates multiple extension runners', async () => { const cmd = await prepareRun(); await cmd.run({ target: ['firefox-android', 'firefox-desktop'] }); diff --git a/tests/unit/test-extension-runners/test.chromium.js b/tests/unit/test-extension-runners/test.chromium.js index c3a22a116f..cfa5b3a81c 100644 --- a/tests/unit/test-extension-runners/test.chromium.js +++ b/tests/unit/test-extension-runners/test.chromium.js @@ -102,6 +102,22 @@ describe('util/extension-runners/chromium', async () => { assert.notInclude(DEFAULT_CHROME_FLAGS, '--disable-extensions'); }); + it('check if default extensions.ui.developer_mode is true', async () => { + const { params } = prepareExtensionRunnerParams(); + const runnerInstance = new ChromiumExtensionRunner(params); + await runnerInstance.run(); + sinon.assert.calledWithMatch(params.chromiumLaunch, { + prefs: { + extensions: { + ui: { + developer_mode: true, + }, + }, + }, + }); + await runnerInstance.exit(); + }); + it('installs and runs the extension', async () => { const { params, fakeChromeInstance } = prepareExtensionRunnerParams(); const runnerInstance = new ChromiumExtensionRunner(params); @@ -120,6 +136,13 @@ describe('util/extension-runners/chromium', async () => { '--disable-blink-features=AutomationControlled', ], startingUrl: undefined, + prefs: { + extensions: { + ui: { + developer_mode: true, + }, + }, + }, }); await runnerInstance.exit(); @@ -602,6 +625,41 @@ describe('util/extension-runners/chromium', async () => { sinon.assert.calledOnce(fakeChromeInstance.kill); }); + describe('getPrefs', () => { + it('merges default and custom preferences from an object and passes them to the launcher', async () => { + const { params } = prepareExtensionRunnerParams({ + params: { + customChromiumPrefs: { + 'extensions.ui.developer_mode': false, + 'browser.theme.color': 'dark', + }, + }, + }); + + const runnerInstance = new ChromiumExtensionRunner(params); + + const expectedPrefs = { + extensions: { + ui: { + developer_mode: false, + }, + }, + browser: { + theme: { + color: 'dark', + }, + }, + }; + + assert.deepEqual(runnerInstance.getPrefs(), expectedPrefs); + + await runnerInstance.run(); + sinon.assert.calledWithMatch(params.chromiumLaunch, { + prefs: expectedPrefs, + }); + }); + }); + describe('reloadAllExtensions', () => { let runnerInstance; diff --git a/tests/unit/test-util/test.expand-prefs.js b/tests/unit/test-util/test.expand-prefs.js new file mode 100644 index 0000000000..7e74d8da8c --- /dev/null +++ b/tests/unit/test-util/test.expand-prefs.js @@ -0,0 +1,68 @@ +import { assert, expect } from 'chai'; +import { describe, it } from 'mocha'; + +import expandPrefs from '../../../src/util/expand-prefs.js'; + +describe('utils/expand-prefs', () => { + it('expands dot-deliminated preferences into a deep object', () => { + const input = { + a: 'a', + 'b.c': 'c', + 'd.e.f': 'f', + }; + const expected = { + a: 'a', + b: { + c: 'c', + }, + d: { + e: { + f: 'f', + }, + }, + }; + const actual = expandPrefs(input); + + assert.deepEqual(actual, expected); + }); + + it("doesn't pollute the object prototype", () => { + const call = 'overriden'; + const input = { + 'hasOwnProperty.call': call, + }; + const expected = { + hasOwnProperty: { + call, + }, + }; + const actual = expandPrefs(input); + + assert.notEqual(Object.prototype.hasOwnProperty.call, call); + assert.deepEqual(actual, expected); + }); + + it('throws an error when setting the child property of an already set parent', () => { + const input = { + a: 'a', + 'a.b': 'b', + }; + + expect(() => expandPrefs(input)).to.throw( + 'Cannot set a.b because a value already exists at a', + ); + }); + + it('allows overriding a parent even if a child has already been set', () => { + const input = { + 'a.b': 'b', + a: 'a', + }; + const expected = { + a: 'a', + }; + const actual = expandPrefs(input); + + assert.deepEqual(actual, expected); + }); +}); diff --git a/tests/unit/test.program.js b/tests/unit/test.program.js index 7eebe2f2e3..e159aec3e8 100644 --- a/tests/unit/test.program.js +++ b/tests/unit/test.program.js @@ -614,6 +614,27 @@ describe('program.main', () => { }); }); + it('converts chromium custom preferences into an object', () => { + const fakeCommands = fake(commands, { + run: () => Promise.resolve(), + }); + return execProgram( + [ + 'run', + '--chromium-pref', + 'extensions.ui.developer_mode=false', + '--chromium-pref', + 'browser.theme.color=dark', + ], + { commands: fakeCommands }, + ).then(() => { + const { chromiumPref } = fakeCommands.run.firstCall.args[0]; + assert.isObject(chromiumPref); + assert.strictEqual(chromiumPref['extensions.ui.developer_mode'], false); + assert.strictEqual(chromiumPref['browser.theme.color'], 'dark'); + }); + }); + it('passes shouldExitProgram option to commands', () => { const fakeCommands = fake(commands, { lint: () => Promise.resolve(),