diff --git a/src/cmd/run.js b/src/cmd/run.js index be640c8407..d2d8548ee2 100644 --- a/src/cmd/run.js +++ b/src/cmd/run.js @@ -48,6 +48,7 @@ export default async function run( firefoxApkComponent, // Chromium CLI options. chromiumBinary, + chromiumPref, chromiumProfile, }, { @@ -80,6 +81,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; @@ -187,6 +193,7 @@ export default async function run( ...commonRunnerParams, chromiumBinary, chromiumProfile, + customChromiumPrefs, }; const chromiumRunner = await createExtensionRunner({ diff --git a/src/extension-runners/chromium.js b/src/extension-runners/chromium.js index f6337fc181..b57ca72d94 100644 --- a/src/extension-runners/chromium.js +++ b/src/extension-runners/chromium.js @@ -17,6 +17,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); @@ -26,6 +27,10 @@ export const DEFAULT_CHROME_FLAGS = ChromeLauncher.defaultFlags().filter( (flag) => !EXCLUDED_CHROME_FLAGS.includes(flag), ); +const DEFAULT_PREFS = { + 'extensions.ui.developer_mode': true, +}; + /** * Implements an IExtensionRunner which manages a Chromium instance. */ @@ -210,6 +215,7 @@ export class ChromiumExtensionRunner { userDataDir, // Ignore default flags to keep the extension enabled. ignoreDefaultFlags: true, + prefs: this.getPrefs(), }); this.chromiumInstance.process.once('close', () => { @@ -414,4 +420,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 0e0d5a0c0c..2438d561f2 100644 --- a/src/program.js +++ b/src/program.js @@ -14,6 +14,7 @@ import { consoleStream as defaultLogStream, } from './util/logger.js'; import { coerceCLICustomPreference } from './firefox/preferences.js'; +import { coerceCLICustomChromiumPreference } from './util/chromium-preferences.js'; import { checkForUpdates as defaultUpdateChecker } from './util/updates.js'; import { discoverConfigFiles as defaultConfigDiscovery, @@ -616,6 +617,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 ? coerceCLICustomChromiumPreference(arg) : undefined, + }, 'chromium-profile': { describe: 'Path to a custom Chromium profile', demandOption: false, diff --git a/src/util/chromium-preferences.js b/src/util/chromium-preferences.js new file mode 100644 index 0000000000..9fe0e19e3f --- /dev/null +++ b/src/util/chromium-preferences.js @@ -0,0 +1,33 @@ +import { UsageError } from '../errors.js'; + +export function coerceCLICustomChromiumPreference(cliPrefs) { + const customPrefs = {}; + + for (const pref of cliPrefs) { + const prefsAry = pref.split('='); + + if (prefsAry.length < 2) { + throw new UsageError( + `Incomplete custom preference: "${pref}". ` + + 'Syntax expected: "prefname=prefvalue".', + ); + } + + const key = prefsAry[0]; + let value = prefsAry.slice(1).join('='); + + if (/[^\w_.]/.test(key)) { + throw new UsageError(`Invalid custom preference name: ${key}`); + } + + if (value === `${parseInt(value)}`) { + value = parseInt(value, 10); + } else if (value === 'true' || value === 'false') { + value = value === 'true'; + } + + customPrefs[`${key}`] = value; + } + + return customPrefs; +} diff --git a/src/util/expand-prefs.js b/src/util/expand-prefs.js new file mode 100644 index 0000000000..70fe95dd48 --- /dev/null +++ b/src/util/expand-prefs.js @@ -0,0 +1,32 @@ +/** + * 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-extension-runners/test.chromium.js b/tests/unit/test-extension-runners/test.chromium.js index 6228a62d31..5cec447c72 100644 --- a/tests/unit/test-extension-runners/test.chromium.js +++ b/tests/unit/test-extension-runners/test.chromium.js @@ -47,7 +47,7 @@ function prepareExtensionRunnerParams({ params } = {}) { describe('util/extension-runners/chromium', async () => { it('uses the expected chrome flags', () => { - // Flags from chrome-launcher v0.14.0 + // Flags from chrome-launcher v1.1.2 const expectedFlags = [ '--disable-features=Translate,OptimizationHints,MediaRouter,DialMediaRouteProvider,CalculateNativeWinOcclusion,InterestFeedContentSuggestions,CertificateTransparencyComponentUpdater,AutofillServerCommunication,PrivacySandboxSettings4', '--disable-component-extensions-with-background-pages', @@ -619,6 +619,56 @@ describe('util/extension-runners/chromium', async () => { }), ); + it('does pass default prefs to chrome', async () => { + const { params } = prepareExtensionRunnerParams(); + + const runnerInstance = new ChromiumExtensionRunner(params); + await runnerInstance.run(); + + sinon.assert.calledOnce(params.chromiumLaunch); + sinon.assert.calledWithMatch(params.chromiumLaunch, { + prefs: { + extensions: { + ui: { + developer_mode: true, + }, + }, + }, + }); + + await runnerInstance.exit(); + }); + + it('does pass custom prefs to chrome', async () => { + const { params } = prepareExtensionRunnerParams({ + params: { + customChromiumPrefs: { + 'download.default_directory': '/some/directory', + 'extensions.ui.developer_mode': false, + }, + }, + }); + + const runnerInstance = new ChromiumExtensionRunner(params); + await runnerInstance.run(); + + sinon.assert.calledOnce(params.chromiumLaunch); + sinon.assert.calledWithMatch(params.chromiumLaunch, { + prefs: { + download: { + default_directory: '/some/directory', + }, + extensions: { + ui: { + developer_mode: false, + }, + }, + }, + }); + + await runnerInstance.exit(); + }); + describe('reloadAllExtensions', () => { let runnerInstance; let wsClient; 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 15635d36cf..cffb0f5b07 100644 --- a/tests/unit/test.program.js +++ b/tests/unit/test.program.js @@ -627,6 +627,27 @@ describe('program.main', () => { }); }); + it('converts custom chromium preferences into an object', () => { + const fakeCommands = fake(commands, { + run: () => Promise.resolve(), + }); + return execProgram( + [ + 'run', + '--chromium-pref', + 'prop=true', + '--chromium-pref', + 'prop2=value2', + ], + { commands: fakeCommands }, + ).then(() => { + const { chromiumPref } = fakeCommands.run.firstCall.args[0]; + assert.isObject(chromiumPref); + assert.equal(chromiumPref.prop, true); + assert.equal(chromiumPref.prop2, 'value2'); + }); + }); + it('passes shouldExitProgram option to commands', () => { const fakeCommands = fake(commands, { lint: () => Promise.resolve(),