Skip to content
7 changes: 7 additions & 0 deletions src/cmd/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export default async function run(
firefoxApkComponent,
// Chromium CLI options.
chromiumBinary,
chromiumPref = {},
chromiumProfile,
},
{
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -191,6 +197,7 @@ export default async function run(
...commonRunnerParams,
verbose,
chromiumBinary,
customChromiumPrefs,
chromiumProfile,
};

Expand Down
14 changes: 14 additions & 0 deletions src/extension-runners/chromium.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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 {
Expand Down Expand Up @@ -598,4 +601,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,
});
}
}
12 changes: 12 additions & 0 deletions src/program.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
36 changes: 36 additions & 0 deletions src/util/expand-prefs.js
Original file line number Diff line number Diff line change
@@ -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]),
);
}
16 changes: 16 additions & 0 deletions tests/unit/test-cmd/test.run.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'] });
Expand Down
28 changes: 28 additions & 0 deletions tests/unit/test-extension-runners/test.chromium.js
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,34 @@ describe('util/extension-runners/chromium', async () => {
sinon.assert.calledOnce(fakeChromeInstance.kill);
});

describe('getPrefs', () => {
it('merges default and custom preferences from an object', () => {
const { params } = prepareExtensionRunnerParams({
params: {
customChromiumPrefs: {
'extensions.ui.developer_mode': false,
'browser.theme.color': 'dark',
},
},
});

const runnerInstance = new ChromiumExtensionRunner(params);

assert.deepEqual(runnerInstance.getPrefs(), {
extensions: {
ui: {
developer_mode: false,
},
},
browser: {
theme: {
color: 'dark',
},
},
});
});
});

describe('reloadAllExtensions', () => {
let runnerInstance;

Expand Down
68 changes: 68 additions & 0 deletions tests/unit/test-util/test.expand-prefs.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
21 changes: 21 additions & 0 deletions tests/unit/test.program.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down