Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add --chromium-pref flag #2912

Open
wants to merge 17 commits into
base: master
Choose a base branch
from
7 changes: 7 additions & 0 deletions src/cmd/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export default async function run(
firefoxApkComponent,
// Chromium CLI options.
chromiumBinary,
chromiumPref,
chromiumProfile,
},
{
Expand Down Expand Up @@ -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 };
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that you basically copied the previous few lines. The logic there did not completely make sense, I've sent a PR to fix that up: #3218

Let's simplify to the following:

Suggested change
const customChromiumPrefs = { ...chromiumPref };
const customChromiumPrefs = chromiumPref;


const manifestData = await getValidatedManifest(sourceDir);

const profileDir = firefoxProfile || chromiumProfile;
Expand Down Expand Up @@ -187,6 +193,7 @@ export default async function run(
...commonRunnerParams,
chromiumBinary,
chromiumProfile,
customChromiumPrefs,
};

const chromiumRunner = await createExtensionRunner({
Expand Down
17 changes: 17 additions & 0 deletions src/extension-runners/chromium.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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.
*/
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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 || {}),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spread syntax with ...undefined and ...null works, you don't need to fall back to || {}.

Suggested change
...(this.params.customChromiumPrefs || {}),
...this.params.customChromiumPrefs,

});
}
}
13 changes: 13 additions & 0 deletions src/program.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
33 changes: 33 additions & 0 deletions src/util/chromium-preferences.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { UsageError } from '../errors.js';

export function coerceCLICustomChromiumPreference(cliPrefs) {
const customPrefs = {};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const customPrefs = {};
const customPrefs = new Map();

Suggesting a few changes to avoid prototype pollution.


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)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any point in this check or can it be dropped?

\w means [A-Za-z0-9_], so what your code here does is rejecting anything other than alphanumeric characters and period.

It is stricter than the actual values permitted by Chromium's Preferences keys, which is quite permissive actually. For example it can contain URLs (including : characters which are rejected by your current code), e.g. as seen at:

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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
customPrefs[`${key}`] = value;
customPrefs.set(`${key}`, value);

}

return customPrefs;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return customPrefs;
return Object.fromEntries(customPrefs);

}
32 changes: 32 additions & 0 deletions src/util/expand-prefs.js
Original file line number Diff line number Diff line change
@@ -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]),
);
}
52 changes: 51 additions & 1 deletion tests/unit/test-extension-runners/test.chromium.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's split the test in two: that a custom pref can be passed, and that a default pref can be overridden.

We want to make sure that custom prefs are merged with the default prefs, and not changed. The current implementation is correct, but the test as constructed would still pass if the implementation failed to merge, which is a sign of an incomplete test.

},
},
});

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;
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", () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test case here is a specific one that failed the original library that you proposed. Let's also prepend a test for a more obvious test case:

const input = {
  '__proto__.x': 1
};
// Using JSON.parse because an object literal with __proto__
// would be mistaken for an object with a different prototype.
const expected = JSON.parse('{"__proto__":{"x":1}}');

Besides the obvious comparison like above, you should also add a test Object.getPrototypeOf(actual) and confirm that it is Object.prototype.

And let's also have the same test, but with the following:

const input = JSON.parse('{"__proto__":[]}');
const expected = JSON.parse('{"__proto__":[]}');

const call = 'overriden';
const input = {
'hasOwnProperty.call': call,
};
const expected = {
hasOwnProperty: {
call,
},
};
const actual = expandPrefs(input);

assert.notEqual(Object.prototype.hasOwnProperty.call, call);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of notEqual, save the original value before modifying the test, then do a strict comparison. A unit test that tests for equality is stronger than one that tests for inequality.

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 @@ -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(),
Expand Down