Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 63 additions & 4 deletions src/cmd/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type {ExtensionManifest} from '../util/manifest';
import type {FileFilterCreatorFn} from '../util/file-filter';

const log = createLogger(__filename);
const DEFAULT_FILENAME_TEMPLATE = '{name}-{version}.zip';


export function safeFileName(name: string): string {
Expand All @@ -43,7 +44,8 @@ export type PackageCreatorParams = {|
fileFilter: FileFilter,
artifactsDir: string,
overwriteDest: boolean,
showReadyMessage: boolean
showReadyMessage: boolean,
filename?: string,
|};

export type LocalizedNameParams = {|
Expand Down Expand Up @@ -104,6 +106,55 @@ export async function getDefaultLocalizedName(
return Promise.resolve(extensionName);
}

// https://stackoverflow.com/a/22129960
export function getStringPropertyValue(
prop: string,
obj: Object,
): string {
const properties = prop.split('.');
const value = properties.reduce((prev, curr) => prev && prev[curr], obj);
if (!['string', 'number'].includes(typeof value)) {
throw new UsageError(
`Manifest key "${prop}" is missing or has an invalid type: ${value}`
);
}
const stringValue = `${value}`;
if (!stringValue.length) {
throw new UsageError(`Manifest key "${prop}" value is an empty string`);
}
return stringValue;
}

function getPackageNameFromTemplate(
filenameTemplate: string,
manifestData: ExtensionManifest
): string {
const packageName = filenameTemplate.replace(
/{([A-Za-z0-9._]+?)}/g,
(match, manifestProperty) => {
return getStringPropertyValue(manifestProperty, manifestData);
}
);

// Validate the resulting packageName string, after interpolating the manifest property
// specified in the template string.
const parsed = path.parse(packageName);
if (parsed.dir) {
throw new UsageError(
`Invalid filename template "${filenameTemplate}". ` +
`Filename "${packageName}" should not contain a path`
);
}
if (!['.zip', '.xpi'].includes(parsed.ext)) {
throw new UsageError(
`Invalid filename template "${filenameTemplate}". ` +
`Filename "${packageName}" should have a zip or xpi extension`
);
}

return packageName;
}

export type PackageCreatorFn =
(params: PackageCreatorParams) => Promise<ExtensionBuildResult>;

Expand All @@ -115,6 +166,7 @@ export async function defaultPackageCreator(
artifactsDir,
overwriteDest,
showReadyMessage,
filename = DEFAULT_FILENAME_TEMPLATE,
}: PackageCreatorParams,
{
eventToPromise = defaultEventToPromise,
Expand All @@ -132,7 +184,7 @@ export async function defaultPackageCreator(
filter: (...args) => fileFilter.wantFile(...args),
});

let extensionName: string = manifestData.name;
let filenameTemplate = filename;

let {default_locale} = manifestData;
if (default_locale) {
Expand All @@ -142,12 +194,16 @@ export async function defaultPackageCreator(
default_locale, 'messages.json'
);
log.debug('Manifest declared default_locale, localizing extension name');
extensionName = await getDefaultLocalizedName({
const extensionName = await getDefaultLocalizedName({
messageFile, manifestData,
});
// allow for a localized `{name}`, without mutating `manifestData`
filenameTemplate = filenameTemplate.replace(/{name}/g, extensionName);
}

const packageName = safeFileName(
`${extensionName}-${manifestData.version}.zip`);
getPackageNameFromTemplate(filenameTemplate, manifestData)
);
const extensionPath = path.join(artifactsDir, packageName);

// Added 'wx' flags to avoid overwriting of existing package.
Expand Down Expand Up @@ -187,6 +243,7 @@ export type BuildCmdParams = {|
asNeeded?: boolean,
overwriteDest?: boolean,
ignoreFiles?: Array<string>,
filename?: string,
|};

export type BuildCmdOptions = {|
Expand All @@ -206,6 +263,7 @@ export default async function build(
asNeeded = false,
overwriteDest = false,
ignoreFiles = [],
filename = DEFAULT_FILENAME_TEMPLATE,
}: BuildCmdParams,
{
manifestData,
Expand All @@ -231,6 +289,7 @@ export default async function build(
artifactsDir,
overwriteDest,
showReadyMessage,
filename,
});

await prepareArtifactsDir(artifactsDir);
Expand Down
21 changes: 21 additions & 0 deletions src/program.js
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,15 @@ type MainParams = {
runOptions?: Object,
}

export function throwUsageErrorIfArray(errorMessage: string): any {
return (value: any): any => {
if (Array.isArray(value)) {
throw new UsageError(errorMessage);
}
return value;
};
}

export function main(
absolutePackageDir: string,
{
Expand Down Expand Up @@ -429,6 +438,18 @@ Example: $0 --help run.
default: true,
type: 'boolean',
},
'filename': {
alias: 'n',
describe: 'Name of the created extension package file.',
default: undefined,
normalize: false,
demandOption: false,
requiresArg: true,
type: 'string',
coerce: throwUsageErrorIfArray(
'Multiple --filename/-n option are not allowed'
),
},
});

program
Expand Down
12 changes: 12 additions & 0 deletions tests/functional/test.cli.build.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* @flow */
import {describe, it} from 'mocha';
import {assert} from 'chai';

import {
minimalAddonPath, withTempAddonDir, execWebExt, reportCommandErrors,
Expand All @@ -23,4 +24,15 @@ describe('web-ext build', () => {
});
})
);

it('throws an error on multiple -n',
() => withTempAddonDir({addonPath: minimalAddonPath}, (srcDir, tmpDir) => {
const argv = ['build', '-n', 'foo', '-n', 'bar'];
const cmd = execWebExt(argv, {cwd: tmpDir});
return cmd.waitForExit.then(({exitCode, stderr}) => {
assert.notEqual(exitCode, 0);
assert.match(stderr, /Multiple --filename\/-n option are not allowed/);
});
})
);
});
86 changes: 86 additions & 0 deletions tests/unit/test-cmd/test.build.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import defaultEventToPromise from 'event-to-promise';
import build, {
safeFileName,
getDefaultLocalizedName,
getStringPropertyValue,
defaultPackageCreator,
} from '../../../src/cmd/build';
import {FileFilter} from '../../../src/util/file-filter';
Expand Down Expand Up @@ -72,6 +73,40 @@ describe('build', () => {
});
});

it('throws on missing manifest properties in filename template', () => {
return withTempDir(
(tmpDir) =>
build({
sourceDir: fixturePath('minimal-web-ext'),
artifactsDir: tmpDir.path(),
filename: '{missingKey}-{version}.xpi',
})
.then(makeSureItFails())
.catch((error) => {
log.info(error);
Copy link
Member

Choose a reason for hiding this comment

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

Nit, these long.info(error) are not really needed, I just noticed that we did already have one which got merge in a old patch, but not a big deal. We will have to rewrite all these tests into async functions at some point (to make them a bit more readable), and so don't worry about that.

assert.instanceOf(error, UsageError);
assert.match(
error.message,
/Manifest key "missingKey" is missing/);
})
);
});

it('gives the correct custom name to an extension', () => {
return withTempDir(
(tmpDir) =>
build({
sourceDir: fixturePath('minimal-web-ext'),
artifactsDir: tmpDir.path(),
filename: '{applications.gecko.id}-{version}.xpi',
})
.then((buildResult) => {
assert.match(path.basename(buildResult.extensionPath),
/minimal-example_web-ext-test-suite-1.0\.xpi$/);
})
);
});

it('gives the correct name to a localized extension', () => {
return withTempDir(
(tmpDir) =>
Expand All @@ -86,6 +121,44 @@ describe('build', () => {
);
});

it('throws an error if the filename contains a path', () => {
Copy link
Member

Choose a reason for hiding this comment

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

Nit, it would probably good to have also one test that verifies that if an interpolated part of the template is a string that looks like a path then we still throw this kind of UsageError (which is actually the case with the version in this patch, it would be mostly an additional test to have more coverage in case of a refactoring), but we can deal with it in a follow up.

return withTempDir(
(tmpDir) =>
build({
sourceDir: fixturePath('minimal-web-ext'),
artifactsDir: tmpDir.path(),
filename: 'foo/{version}.xpi',
})
.then(makeSureItFails())
.catch((error) => {
log.info(error);
assert.instanceOf(error, UsageError);
assert.match(
error.message,
/Filename "foo\/1.0.xpi" should not contain a path/);
})
);
});

it('throws an error if the filename doesn\'t end in .xpi or .zip', () => {
return withTempDir(
(tmpDir) =>
build({
sourceDir: fixturePath('minimal-web-ext'),
artifactsDir: tmpDir.path(),
filename: '{version}.unknown-ext',
})
.then(makeSureItFails())
.catch((error) => {
log.info(error);
assert.instanceOf(error, UsageError);
assert.match(
error.message,
/Filename "1.0.unknown-ext" should have a zip or xpi extension/);
})
);
});

it('accept a dash in the default_locale field', () => {
return withTempDir(
(tmpDir) =>
Expand Down Expand Up @@ -509,4 +582,17 @@ describe('build', () => {

});

describe('getStringPropertyValue', () => {

it('accepts the value 0', () => {
assert.equal(getStringPropertyValue('foo', {foo: 0}), '0');
});

it('throws an error if string value is empty', () => {
assert.throws(() => getStringPropertyValue('foo', {foo: ''}),
UsageError, /Manifest key "foo" value is an empty string/);
});

});

});
16 changes: 15 additions & 1 deletion tests/unit/test.program.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import sinon, {spy} from 'sinon';
import {assert} from 'chai';

import {applyConfigToArgv} from '../../src/config';
import {defaultVersionGetter, main, Program} from '../../src/program';
import {
defaultVersionGetter,
main,
Program,
throwUsageErrorIfArray,
} from '../../src/program';
import commands from '../../src/cmd';
import {
onlyInstancesOf,
Expand Down Expand Up @@ -858,3 +863,12 @@ describe('program.defaultVersionGetter', () => {
commit);
});
});

describe('program.throwUsageErrorIfArray', () => {
const errorMessage = 'This is the expected error message';
const innerFn = throwUsageErrorIfArray(errorMessage);

it('throws UsageError on array', () => {
assert.throws(() => innerFn(['foo', 'bar']), UsageError, errorMessage);
});
});