addons-linter/tests/schema/test.firefox-schemas-import.js

1445 строки
41 KiB
JavaScript

import fs from 'fs';
import path from 'path';
import stream from 'stream';
import request from 'request';
import tar from 'tar';
import {
FLAG_PATTERN_REWRITES,
downloadUrl,
fetchSchemas,
filterSchemas,
foldSchemas,
ignoredSchemas,
importSchemas,
inner,
loadTypes,
processSchemas,
refMap,
rewriteExtend,
rewriteKey,
rewriteOptionalToRequired,
rewriteValue,
stripTrailingNullByte,
} from 'schema/firefox-schemas-import';
// Get a reference to unlinkSync so it won't get stubbed later.
const { unlinkSync } = fs;
describe('firefox schema import', () => {
function createDir(dirPath) {
fs.mkdirSync(dirPath);
}
function removeDir(dirPath) {
fs.readdirSync(dirPath).forEach(
(file) => unlinkSync(path.join(dirPath, file)));
fs.rmdirSync(dirPath);
}
describe('rewriteOptionalToRequired', () => {
it('handles non-objects ', () => {
const obj = {
foo: 'FOO',
bar: 10,
baz: ['baz', 'BAZ'],
required: [],
};
expect(rewriteOptionalToRequired(obj)).toEqual(obj);
});
it('converts optional to required', () => {
const obj = {
foo: { type: 'string', optional: true },
bar: { type: 'array', optional: false },
baz: { type: 'boolean' },
};
expect(rewriteOptionalToRequired(obj)).toEqual({
foo: { type: 'string' },
bar: { type: 'array' },
baz: { type: 'boolean' },
required: ['bar', 'baz'],
});
});
it('removes optional when everything is optional', () => {
const obj = {
foo: { type: 'string', optional: true },
bar: { type: 'array', optional: true },
baz: { type: 'boolean', optional: true },
};
expect(rewriteOptionalToRequired(obj)).toEqual({
foo: { type: 'string' },
bar: { type: 'array' },
baz: { type: 'boolean' },
required: [],
});
});
it('handles an allOf with one nested schema being optional', () => {
const obj = {
foo: { type: 'string', optional: true },
bar: {
allOf: [
{ $ref: '#/types/Whatever' },
{ optional: true, description: 'a thing' },
],
},
baz: { type: 'boolean' },
};
expect(rewriteOptionalToRequired(obj)).toEqual({
foo: { type: 'string' },
bar: {
allOf: [
{ $ref: '#/types/Whatever' },
{ description: 'a thing' },
],
},
baz: { type: 'boolean' },
required: ['baz'],
});
});
it('handles an allOf with no nested schemas being optional', () => {
const obj = {
foo: { type: 'string', optional: true },
bar: {
allOf: [
{ $ref: '#/types/Whatever' },
{ type: 'string' },
],
},
baz: { type: 'boolean' },
};
expect(rewriteOptionalToRequired(obj)).toEqual({
foo: { type: 'string' },
bar: { allOf: [
{ $ref: '#/types/Whatever' },
{ type: 'string' },
]},
baz: { type: 'boolean' },
required: ['bar', 'baz'],
});
});
});
describe('rewriteValue', () => {
it('adds required from optional', () => {
const schema = {
additionalProperties: true,
properties: {
foo: { type: 'string' },
bar: { type: 'array', optional: true },
baz: {
type: 'object',
properties: {
abc: { type: 'string', optional: true },
def: { type: 'array' },
},
},
},
};
expect(rewriteValue('MyType', schema)).toEqual({
additionalProperties: true,
properties: {
foo: { type: 'string' },
bar: { type: 'array' },
baz: {
type: 'object',
properties: {
abc: { type: 'string' },
def: { type: 'array' },
},
required: ['def'],
},
},
required: ['foo', 'baz'],
});
});
it('removes ids', () => {
expect(rewriteValue('id', 'foo')).toEqual(undefined);
});
it('removes type: any', () => {
expect(rewriteValue('type', 'any')).toEqual(undefined);
expect(rewriteValue('type', 'string')).toEqual('string');
});
describe('pattern rewriting', () => {
const originalPattern = '(?i)foo';
beforeAll(() => {
FLAG_PATTERN_REWRITES[originalPattern] = 'sup';
});
afterAll(() => {
delete FLAG_PATTERN_REWRITES[originalPattern];
});
it('throws on an unknown pattern with flags', () => {
expect(
() => rewriteValue('pattern', '(?i)^abc$')
).toThrow('pattern (?i)^abc$ must be rewritten');
});
it('rewrites known patterns', () => {
expect(rewriteValue('pattern', originalPattern)).toEqual('sup');
});
it('does not rewrite unknown patterns without flags', () => {
expect(rewriteValue('pattern', 'abc(?i)def')).toEqual('abc(?i)def');
});
});
it('updates $ref to JSON pointer', () => {
expect(rewriteValue('$ref', 'Manifest')).toEqual('#/types/Manifest');
expect(
rewriteValue('$ref', 'extension_types.Timer')
).toEqual('extension_types#/types/Timer');
});
it("doesn't update $refs that have been updated already", () => {
const $ref = 'manifest#/types/UnrecognizedProperty';
expect(rewriteValue('$ref', $ref)).toBe($ref);
});
it('handles arrays', () => {
const original = [{ type: 'string' }, { type: 'any' }, { $ref: 'Foo' }];
expect(rewriteValue('anyOf', original)).toEqual(
[{ type: 'string' }, {}, { $ref: '#/types/Foo' }]
);
});
it('writes out a required when needed', () => {
const original = {
properties: {
foo: { type: 'string', optional: true },
bar: { type: 'number' },
},
};
const expected = {
properties: { foo: { type: 'string' }, bar: { type: 'number' } },
required: ['bar'],
};
expect(rewriteValue('foo', original)).toEqual(expected);
});
it('omits required when it is empty', () => {
const original = {
properties: {
foo: { type: 'string', optional: true },
bar: { type: 'number', optional: true },
},
};
const expected = {
properties: { foo: { type: 'string' }, bar: { type: 'number' } },
};
expect(rewriteValue('foo', original)).toEqual(expected);
});
it('fixes $ref with other properties', () => {
const original = {
$ref: 'Foo',
properties: {
foo: { type: 'string', optional: true },
bar: { type: 'string' },
},
};
const expected = {
allOf: [
{ $ref: '#/types/Foo' },
{
properties: { foo: { type: 'string' }, bar: { type: 'string' } },
required: ['bar'],
},
],
};
expect(rewriteValue('foo', original)).toEqual(expected);
});
it('leaves $ref objects that only have an extra optional property', () => {
const original = { $ref: 'Foo', optional: true };
const expected = { $ref: '#/types/Foo', optional: true };
expect(rewriteValue('foo', original)).toEqual(expected);
});
it('strips UnrecognizedProperty in additionalProperties', () => {
expect(
rewriteValue('additionalProperties', { $ref: 'UnrecognizedProperty' })
).toEqual(undefined);
});
describe('known refs that are not specific', () => {
beforeEach(() => { refMap.SomeType = 'manifest#/types/SomeType'; });
afterEach(() => { delete refMap.SomeType; });
it('get rewritten to good paths', () => {
expect(rewriteValue('$ref', 'SomeType')).toEqual(
'manifest#/types/SomeType'
);
});
});
});
describe('rewriteKey', () => {
it('rewrites choices to anyOf', () => {
expect(rewriteKey('choices')).toEqual('anyOf');
});
it('leaves other values unchanged', () => {
expect(rewriteKey('properties')).toEqual('properties');
});
});
describe('rewriteObject', () => {
it('rewrites keys and values', () => {
const schema = {
type: 'any',
choices: [{ type: 'string' }, { type: 'number' }],
isUndefined: undefined,
keepMe: 'yay',
};
expect(inner.rewriteObject(schema)).toEqual(
{ anyOf: [{ type: 'string' }, { type: 'number' }], keepMe: 'yay' }
);
});
});
describe('loadTypes', () => {
it('converts the types array to an object', () => {
expect(loadTypes([
{ id: 'Foo', type: 'object' },
{ id: 'Bar', type: 'string' },
])).toEqual({
Foo: { id: 'Foo', type: 'object' },
Bar: { id: 'Bar', type: 'string' },
});
});
it('handles there not being any types', () => {
expect(loadTypes(undefined)).toEqual({});
});
});
describe('normalizeSchema', () => {
it('adds extend schemas as refs and definitions to last schema', () => {
const schemas = [
{
namespace: 'manifest',
types: [{
$extend: 'WebExtensionManifest',
choices: [
{ type: 'string', enum: ['cookies'] },
],
}],
},
{
namespace: 'cookies',
types: [
{ id: 'Cookie', type: 'string' },
{ id: 'CookieJar', type: 'object' },
],
somethingElse: 'foo',
},
];
expect(inner.normalizeSchema(schemas, 'cookies.json')).toEqual({
id: 'cookies',
types: {
Cookie: { id: 'Cookie', type: 'string' },
CookieJar: { id: 'CookieJar', type: 'object' },
},
somethingElse: 'foo',
definitions: {
WebExtensionManifest: {
choices: [ { type: 'string', enum: ['cookies'] } ],
},
},
refs: {
'cookies#/definitions/WebExtensionManifest': {
namespace: 'manifest',
type: 'WebExtensionManifest',
},
},
});
});
it('handles the manifest schema', () => {
const schemas = [
{
namespace: 'manifest',
types: [{ id: 'Permission', type: 'string' }],
},
];
expect(inner.normalizeSchema(schemas, 'manifest.json')).toEqual({
id: 'manifest',
types: { Permission: { id: 'Permission', type: 'string' } },
definitions: {},
refs: {},
});
});
it('handles manifest extensions without a schema', () => {
const schemas = [
{
namespace: 'manifest',
types: [{
$extend: 'WebExtensionManifest',
properties: {
chrome_url_overrides: {
type: 'object',
},
},
}],
},
];
expect(inner.normalizeSchema(schemas, 'url_overrides.json')).toEqual({
id: 'url_overrides',
types: {},
definitions: {
WebExtensionManifest: {
properties: {
chrome_url_overrides: { type: 'object' },
},
},
},
refs: {
'url_overrides#/definitions/WebExtensionManifest': {
namespace: 'manifest',
type: 'WebExtensionManifest',
},
},
});
});
});
describe('loadSchema', () => {
it('normalizes and rewrites the schema', () => {
sinon
.stub(inner, 'normalizeSchema')
.withArgs({ the: 'schema' })
.returns({ id: 'Foo', normalized: true });
sinon
.stub(inner, 'rewriteObject')
.withArgs({ normalized: true })
.returns({ rewritten: true });
expect(inner.loadSchema({ the: 'schema' })).toEqual(
{ id: 'Foo', rewritten: true }
);
});
it('adds a $ref for the manifest namespace', () => {
sinon
.stub(inner, 'normalizeSchema')
.withArgs({ id: 'manifest' })
.returns({ id: 'manifest', normalized: true });
sinon
.stub(inner, 'rewriteObject')
.withArgs({ normalized: true })
.returns({ rewritten: true });
expect(inner.loadSchema({ id: 'manifest' })).toEqual({
id: 'manifest',
$ref: '#/types/WebExtensionManifest',
rewritten: true,
});
});
});
describe('processSchemas', () => {
it('loads each schema and delegates to helpers', () => {
const firstSchema = [{ id: 'manifest' }];
const secondSchema = [{ id: 'manifest' }, { id: 'cookies' }];
const loadSchema = sinon.stub(inner, 'loadSchema');
loadSchema.withArgs(firstSchema).returns({ id: 'manifest', schema: 1 });
loadSchema.withArgs(secondSchema).returns({ id: 'cookies', schema: 2 });
sinon
.stub(inner, 'mergeSchemas')
.withArgs({
manifest: [{ file: 'one', schema: { id: 'manifest', schema: 1 } }],
cookies: [{ file: 'two', schema: { id: 'cookies', schema: 2 } }],
})
.returns({ mergeSchemas: 'done' });
sinon
.stub(inner, 'mapExtendToRef')
.withArgs({ mergeSchemas: 'done' })
.returns({ mapExtendToRef: 'done' });
expect(processSchemas([
{ file: 'one', schema: firstSchema },
{ file: 'two', schema: secondSchema },
])).toEqual({ mapExtendToRef: 'done' });
});
});
describe('mergeSchemas', () => {
it('merges schemas with the same namespace', () => {
const schemas = [{
file: 'foo_foo.json',
schema: [{
namespace: 'foo',
types: [{ id: 'Foo', type: 'string' }],
}],
}, {
file: 'foo_bar.json',
schema: [{
namespace: 'foo.bar',
types: [{ id: 'FooBar', type: 'number' }],
properties: { thing: {} },
}],
}, {
file: 'bar.json',
schema: [{
namespace: 'bar',
types: [{ id: 'Bar', type: 'string' }],
}],
}];
expect(processSchemas(schemas)).toEqual({
foo: {
file: 'foo.json',
schema: {
id: 'foo',
definitions: {},
refs: {},
types: {
Foo: { type: 'string' },
FooBar: { type: 'number' },
},
properties: {
bar: { properties: { thing: {} }, required: ['thing'] },
},
},
},
bar: {
file: 'bar.json',
schema: {
id: 'bar',
definitions: {},
refs: {},
types: { Bar: { type: 'string' } },
},
},
});
});
});
describe('mapExtendToRef', () => {
function deepFreeze(obj) {
if (typeof obj === 'object') {
Object.keys(obj).forEach((key) => {
const value = obj[key];
if (typeof value === 'object' &&
value !== null &&
!Object.isFrozen(value)) {
deepFreeze(value);
}
});
return Object.freeze(obj);
}
return obj;
}
it('adds the refs to the linked schema', () => {
const schemas = deepFreeze({
manifest: {
file: 'manifest.json',
schema: {
types: {
Permission: {
anyOf: [
{ type: 'string', enum: ['downloads'] },
{ type: 'string' },
],
},
WebExtensionManifest: {
properties: {
version: { type: 'number' },
},
required: ['version'],
},
},
refs: {},
},
},
cookies: {
file: 'cookies.json',
schema: {
definitions: {
Permission: { type: 'string', enum: ['cookies'] },
},
refs: {
'cookies#/definitions/Permission': {
namespace: 'manifest',
type: 'Permission',
},
},
},
},
i18n: {
file: 'i18n.json',
schema: {
definitions: {
WebExtensionManifest: {
properties: { default_locale: { type: 'string' } },
},
},
refs: {
'i18n#/definitions/WebExtensionManifest': {
namespace: 'manifest',
type: 'WebExtensionManifest',
},
},
},
},
foo: {
file: 'foo.json',
schema: {
refs: {
'foo#/definitions/WebExtensionManifest': {
namespace: 'manifest',
type: 'WebExtensionManifest',
},
},
},
},
});
expect(inner.mapExtendToRef(schemas)).toEqual({
manifest: {
file: 'manifest.json',
schema: {
types: {
Permission: {
anyOf: [
{ type: 'string', enum: ['downloads'] },
{ type: 'string' },
{ $ref: 'cookies#/definitions/Permission' },
],
},
WebExtensionManifest: {
allOf: [
{
properties: {
version: { type: 'number' },
},
required: ['version'],
},
{ $ref: 'i18n#/definitions/WebExtensionManifest' },
{ $ref: 'foo#/definitions/WebExtensionManifest' },
],
},
},
refs: {},
},
},
cookies: {
file: 'cookies.json',
schema: {
definitions: {
Permission: { type: 'string', enum: ['cookies'] },
},
refs: {
'cookies#/definitions/Permission': {
namespace: 'manifest',
type: 'Permission',
},
},
},
},
i18n: {
file: 'i18n.json',
schema: {
definitions: {
WebExtensionManifest: {
properties: { default_locale: { type: 'string' } },
},
},
refs: {
'i18n#/definitions/WebExtensionManifest': {
namespace: 'manifest',
type: 'WebExtensionManifest',
},
},
},
},
foo: {
file: 'foo.json',
schema: {
refs: {
'foo#/definitions/WebExtensionManifest': {
namespace: 'manifest',
type: 'WebExtensionManifest',
},
},
},
},
});
});
});
describe('rewriteExtend', () => {
it('moves $extend into definitions and refs', () => {
const schemas = [{
namespace: 'manifest',
types: [{
$extend: 'WebExtensionManifest',
properties: { something: { type: 'string' } },
}],
}];
const expected = {
definitions: {
WebExtensionManifest: {
properties: { something: { type: 'string' } },
},
},
refs: {
'foo#/definitions/WebExtensionManifest': {
namespace: 'manifest',
type: 'WebExtensionManifest',
},
},
types: {},
};
expect(rewriteExtend(schemas, 'foo')).toEqual(expected);
});
it('returns types in an object of types', () => {
const schemas = [{
namespace: 'manifest',
types: [{
id: 'Yo',
properties: { hey: { type: 'string' } },
}],
}];
const expected = {
definitions: {},
refs: {},
types: {
Yo: { properties: { hey: { type: 'string' } } },
},
};
expect(rewriteExtend(schemas, 'foo')).toEqual(expected);
});
it('rewrites the extend for $refs defined in the object', () => {
const original = [{
namespace: 'manifest',
types: [{
id: 'KeyName',
type: 'string',
}, {
$extend: 'WebExtensionManifest',
properties: {
browser_action: {
type: 'object',
additionalProperties: { $ref: 'UnrecognizedProperty' },
properties: { default_title: { type: 'string', optional: true } },
optional: true,
},
whatever: { $ref: 'KeyName' },
},
}],
}];
const expected = {
definitions: {
WebExtensionManifest: {
properties: {
browser_action: {
type: 'object',
additionalProperties: {
$ref: 'manifest#/types/UnrecognizedProperty',
},
properties: {
default_title: { type: 'string', optional: true },
},
optional: true,
},
whatever: { $ref: 'KeyName' },
},
},
},
refs: {
'browserAction#/definitions/WebExtensionManifest': {
namespace: 'manifest',
type: 'WebExtensionManifest',
},
},
types: { KeyName: { type: 'string' } },
};
expect(rewriteExtend(original, 'browserAction')).toEqual(expected);
});
it('throws if there is no $extend or id', () => {
const schemas = [{
namespace: 'manifest',
types: [{
properties: { uhoh: { type: 'number' } },
}],
}];
expect(
() => rewriteExtend(schemas, 'foo')
).toThrow('$extend or id is required');
});
});
describe('updateWithAddonsLinterData', () => {
function makeSchemaWithType(name, type) {
return Object.freeze({
manifest: {
file: 'manifest.json',
schema: {
types: { [name]: type },
},
},
});
}
it('updates the firefox schemas with addons-linter data', () => {
const firefoxSchemas = {
manifest: {
file: 'manifest.json',
schema: {
types: {
FirefoxSpecificProperties: {
properties: {
strict_min_version: { type: 'string' },
},
},
},
},
},
};
const ourSchemas = {
manifest: {
types: {
FirefoxSpecificProperties: {
properties: {
strict_min_version: {
default: '42a1',
description:
'Minimum version of Gecko to support. '
+ "Defaults to '42a1'. (Requires Gecko 45)",
pattern: '^[0-9]{1,3}(\\.[a-z0-9]+)+$',
},
},
},
},
},
};
const expected = {
manifest: {
file: 'manifest.json',
schema: {
types: {
FirefoxSpecificProperties: {
properties: {
strict_min_version: {
default: '42a1',
description:
'Minimum version of Gecko to support. '
+ "Defaults to '42a1'. (Requires Gecko 45)",
pattern: '^[0-9]{1,3}(\\.[a-z0-9]+)+$',
type: 'string',
},
},
},
},
},
},
};
expect(
inner.updateWithAddonsLinterData(firefoxSchemas, ourSchemas)
).toEqual(expected);
});
it('merges in new values', () => {
const original = makeSchemaWithType('FirefoxSpecificProperties', {
properties: {
update_url: { type: 'string', format: 'url' },
foo: { type: 'string' },
},
});
const linterUpdates = {
manifest: {
types: {
FirefoxSpecificProperties: {
properties: { foo: { pattern: '[fF][oO]{2}' } },
},
},
},
};
const expected = makeSchemaWithType('FirefoxSpecificProperties', {
properties: {
update_url: { type: 'string', format: 'url' },
foo: { type: 'string', pattern: '[fF][oO]{2}' },
},
});
expect(
inner.updateWithAddonsLinterData(original, linterUpdates)
).toEqual(expected);
});
it('overwrites existing values', () => {
const original = makeSchemaWithType('FirefoxSpecificProperties', {
properties: {
update_url: { type: 'string', format: 'url' },
foo: { type: 'string' },
},
});
const linterUpdates = {
manifest: {
types: {
FirefoxSpecificProperties: {
properties: { update_url: { format: 'secureUrl' } },
},
},
},
};
const expected = makeSchemaWithType('FirefoxSpecificProperties', {
properties: {
update_url: { type: 'string', format: 'secureUrl' },
foo: { type: 'string' },
},
});
expect(
inner.updateWithAddonsLinterData(original, linterUpdates)
).toEqual(expected);
});
it('extends arrays', () => {
const original = makeSchemaWithType('FirefoxSpecificProperties', {
properties: {
name: { type: 'string', enum: ['foo', 'bar'] },
},
});
const linterUpdates = {
manifest: {
types: {
FirefoxSpecificProperties: {
properties: {
name: { enum: ['baz'] },
},
},
},
},
};
const expected = makeSchemaWithType('FirefoxSpecificProperties', {
properties: {
name: { type: 'string', enum: ['foo', 'bar', 'baz'] },
},
});
expect(
inner.updateWithAddonsLinterData(original, linterUpdates)
).toEqual(expected);
});
it('updates the first item of an allOf array', () => {
const original = makeSchemaWithType('WebExtensionManifest', {
allOf: [{
properties: {
icons: {
type: 'object',
patternProperties: { '\d+': { type: 'string' } },
},
},
}, {
$ref: 'browserAction#/definitions/WebExtensionManifest',
}, {
$ref: 'commands#/definitions/WebExtensionManifest',
}],
});
const linterUpdates = {
manifest: {
types: {
WebExtensionManifest: {
allOf: [{
properties: {
icons: { additionalProperties: false },
},
}],
},
},
},
};
const expected = makeSchemaWithType('WebExtensionManifest', {
allOf: [{
properties: {
icons: {
type: 'object',
additionalProperties: false,
patternProperties: { '\d+': { type: 'string' } },
},
},
}, {
$ref: 'browserAction#/definitions/WebExtensionManifest',
}, {
$ref: 'commands#/definitions/WebExtensionManifest',
}],
});
expect(
inner.updateWithAddonsLinterData(original, linterUpdates)
).toEqual(expected);
});
it('can create a copy of a namepsace with updates', () => {
const original = {
menus: {
file: 'menus.json',
schema: {
id: 'menus',
permissions: ['menus'],
properties: { create: {} },
},
},
};
const linterUpdates = {
menus: {
file: 'contextMenus.json',
id: 'contextMenus',
permissions: ['contextMenus'],
},
};
const expected = {
...original,
contextMenus: {
file: 'contextMenus.json',
schema: {
id: 'contextMenus',
// We don't really want menus in here but it won't hurt anything.
permissions: ['menus', 'contextMenus'],
properties: { create: {} },
},
},
};
expect(
inner.updateWithAddonsLinterData(original, linterUpdates)
).toEqual(expected);
});
});
describe('from filesystem', () => {
const schemaFiles = [
'manifest.json', 'cookies.json',
];
const firefoxPath = 'tests/schema/firefox';
const ourPath = 'tests/schema/updates';
const outputPath = 'tests/schema/imported';
const expectedPath = 'tests/schema/expected';
beforeEach(() => {
createDir(outputPath);
});
afterEach(() => {
removeDir(outputPath);
});
it('imports schemas from filesystem', () => {
importSchemas(firefoxPath, ourPath, outputPath);
schemaFiles.forEach((file) => {
expect(
JSON.parse(fs.readFileSync(path.join(outputPath, file)))
).toEqual(JSON.parse(fs.readFileSync(path.join(expectedPath, file))));
});
});
it('skips native_host_manifest.json', () => {
importSchemas(firefoxPath, ourPath, outputPath);
expect(
fs.exists(path.join(expectedPath, 'native_host_manifest.json'))
).toBeFalsy();
// Dummy test to make sure we join correctly and the import
// actually worked
expect(
fs.exists(path.join(expectedPath, 'manifest.json'))
).toBeFalsy();
});
});
describe('fetchSchemas', () => {
const outputPath = 'tests/schema/imported';
const expectedTarballPath = 'tmp/FIREFOX_AURORA_54_BASE.tar.gz';
beforeEach(() => {
expect(fs.existsSync(expectedTarballPath)).toBeFalsy();
createDir(outputPath);
});
afterEach(() => {
expect(fs.existsSync(expectedTarballPath)).toBeFalsy();
removeDir(outputPath);
});
it('rejects if there is no inputPath or version', () => {
return fetchSchemas({}).then(
() => expect(false).toBeTruthy(),
(err) => expect(err.message).toEqual(
'inputPath or version is required'
));
});
it('downloads the firefox source and extracts the schemas', () => {
const cwd = 'tests/schema';
const schemaPath = 'firefox';
const tarball = tar.create({ cwd, gzip: true }, [schemaPath]);
sinon
.stub(inner, 'isBrowserSchema')
.withArgs('firefox/cookies.json')
.returns(false)
.withArgs('firefox/manifest.json')
.returns(true);
sinon
.stub(request, 'get')
.withArgs('https://hg.mozilla.org/mozilla-central/archive/FIREFOX_AURORA_54_BASE.tar.gz')
.returns(tarball);
expect(fs.readdirSync(outputPath)).toEqual([]);
return fetchSchemas({ version: 54, outputPath })
.then(() => {
expect(fs.readdirSync(outputPath)).toEqual(['manifest.json']);
});
});
it('extracts the schemas from a local file', () => {
const cwd = 'tests/schema';
const schemaPath = 'firefox';
const tarball = tar.create({ cwd, gzip: true }, [schemaPath]);
sinon
.stub(inner, 'isBrowserSchema')
.withArgs('firefox/cookies.json')
.returns(false)
.withArgs('firefox/manifest.json')
.returns(true);
sinon
.stub(fs, 'createReadStream')
.withArgs('mozilla-central.tgz')
.returns(tarball);
sinon
.stub(fs, 'unlinkSync')
.withArgs('mozilla-central.tgz')
.returns(undefined);
expect(fs.readdirSync(outputPath)).toEqual([]);
return fetchSchemas({ inputPath: 'mozilla-central.tgz', outputPath })
.then(() => {
expect(fs.readdirSync(outputPath)).toEqual(['manifest.json']);
});
});
it('handles errors when parsing the tarball', () => {
const cwd = 'tests/schema';
const schemaPath = 'firefox';
const tarball = tar.create({ cwd, gzip: true }, [schemaPath]);
sinon
.stub(fs, 'createReadStream')
.withArgs('mozilla-central.tgz')
.returns(tarball);
const extractedStream = new stream.Duplex({
read() {
this.emit('error', new Error('stream error'));
},
});
sinon
.stub(tar, 'Parse')
.returns(extractedStream);
expect(fs.readdirSync(outputPath)).toEqual([]);
return fetchSchemas({ inputPath: 'mozilla-central.tgz', outputPath })
.then(() => {
expect(true).toBeFalsy();
}, () => {
expect(true).toBeTruthy();
});
});
it('handles errors when downloading', () => {
const mockStream = new stream.Readable({
read() {
this.emit('error', new Error('stream error'));
},
});
sinon
.stub(request, 'get')
.withArgs('https://hg.mozilla.org/mozilla-central/archive/FIREFOX_AURORA_54_BASE.tar.gz')
.returns(mockStream);
expect(fs.readdirSync(outputPath)).toEqual([]);
return fetchSchemas({ version: 54, outputPath })
.then(() => {
expect(true).toBeFalsy();
}, () => {
// Manually remove the tar file since it doesn't get cleaned up.
fs.unlinkSync('tmp/FIREFOX_AURORA_54_BASE.tar.gz');
expect(true).toBeTruthy();
});
});
it('handles errors when writing the download', () => {
const cwd = 'tests/schema';
const schemaPath = 'firefox';
const tarball = tar.create({ cwd, gzip: true }, [schemaPath]);
sinon
.stub(request, 'get')
.withArgs('https://hg.mozilla.org/mozilla-central/archive/FIREFOX_AURORA_54_BASE.tar.gz')
.returns(tarball);
const mockStream = new stream.Duplex({
read() {
this.emit('error', new Error('stream error'));
},
write() {
this.emit('error', new Error('stream error'));
},
});
sinon
.stub(fs, 'createWriteStream')
.withArgs('tmp/FIREFOX_AURORA_54_BASE.tar.gz')
.returns(mockStream);
expect(fs.readdirSync(outputPath)).toEqual([]);
return fetchSchemas({ version: 54, outputPath })
.then(() => {
expect(true).toBeFalsy();
}, () => {
expect(true).toBeTruthy();
});
});
});
describe('isBrowserSchema', () => {
it('pulls in browser and toolkit schemas', () => {
const files = [
'moz/browser/components/extensions/schemas/bookmarks.json',
'moz/toolkit/components/extensions/schemas/manifest.json',
'moz/toolkit/components/extensions/schemas/Schemas.jsm',
];
expect(files.filter((f) => inner.isBrowserSchema(f))).toEqual([
'moz/browser/components/extensions/schemas/bookmarks.json',
'moz/toolkit/components/extensions/schemas/manifest.json',
]);
});
});
describe('foldSchemas', () => {
it('does not fold non-matching schemas', () => {
const schemas = [
{ namespace: 'manifest' },
{ namespace: 'omnibox' },
];
// Copy the schemas so we can verify they're unchanged and un-mutated.
const expectedSchemas = schemas.map((schema) => ({ ...schema }));
expect(foldSchemas(schemas)).toEqual(expectedSchemas);
});
it('folds matching schemas, maintaining types at top-level', () => {
const schemas = [
{ namespace: 'manifest' },
{ namespace: 'privacy.network',
properties: { networkPredictionEnabled: {} },
types: [{
id: 'IPHandlingPolicy',
type: 'string',
enum: ['default', 'disable_non_proxied_udp'],
}],
},
{ namespace: 'privacy',
permissions: ['privacy'],
properties: { foo: {} },
types: [{
$extend: 'permission',
choices: [{ type: 'string', enum: ['privacy'] }],
}],
},
{ namespace: 'privacy.websites',
properties: { thirdPartyCookiesAllowed: {} } },
];
expect(foldSchemas(schemas)).toEqual([
{ namespace: 'manifest' },
{ namespace: 'privacy',
permissions: ['privacy'],
properties: {
foo: {},
network: {
properties: { networkPredictionEnabled: {} },
},
websites: {
properties: { thirdPartyCookiesAllowed: {} },
},
},
types: [{
$extend: 'permission',
choices: [{ type: 'string', enum: ['privacy'] }],
}, {
id: 'IPHandlingPolicy',
type: 'string',
enum: ['default', 'disable_non_proxied_udp'],
}],
},
]);
});
it('handles a base schema without properties', () => {
const schemas = [
{ namespace: 'manifest' },
{ namespace: 'privacy.network',
properties: { networkPredictionEnabled: {} } },
{ namespace: 'privacy', permissions: ['privacy'] },
{ namespace: 'privacy.websites',
properties: { thirdPartyCookiesAllowed: {} } },
];
expect(foldSchemas(schemas)).toEqual([
{ namespace: 'manifest' },
{ namespace: 'privacy',
permissions: ['privacy'],
properties: {
network: {
properties: { networkPredictionEnabled: {} },
},
websites: {
properties: { thirdPartyCookiesAllowed: {} },
},
},
},
]);
});
it('handles matching schemas without a base schema', () => {
const schemas = [
{ namespace: 'manifest' },
{ namespace: 'privacy.network',
properties: { networkPredictionEnabled: {} } },
{ namespace: 'privacy.websites',
properties: { thirdPartyCookiesAllowed: {} } },
];
expect(foldSchemas(schemas)).toEqual([
{ namespace: 'manifest' },
{ namespace: 'privacy',
properties: {
network: {
properties: { networkPredictionEnabled: {} },
},
websites: {
properties: { thirdPartyCookiesAllowed: {} },
},
},
},
]);
});
it('handles a single schema', () => {
const schemas = [
{ namespace: 'alarms',
permissions: ['alarms'],
properties: {} },
];
const expectedSchemas = schemas.map((schema) => ({ ...schema }));
expect(foldSchemas(schemas)).toEqual(expectedSchemas);
});
it('throws if there is more than two levels of nesting', () => {
const schemas = [
{ namespace: 'devtools.panels.sidebars',
properties: { createSidebar: {} } },
];
expect(
() => foldSchemas(schemas)
).toThrow(/may only have one level of nesting/);
});
it('throws if there is more than one matching namespace', () => {
const schemas = Object.freeze([
Object.freeze({
namespace: 'devtools.sidebar',
properties: { createSidebar: {} },
}),
Object.freeze({
namespace: 'devtools.sidebar',
properties: { createBar: {} },
}),
]);
expect(() => foldSchemas(schemas)).toThrow(/matching namespaces/);
});
it('throws if there is more than one base namespace', () => {
const schemas = Object.freeze([
Object.freeze({
namespace: 'devtools',
properties: { createSidebar: {} },
}),
Object.freeze({
namespace: 'devtools',
properties: { createBar: {} },
}),
]);
expect(() => foldSchemas(schemas)).toThrow(/matching namespaces/);
});
});
describe('filterSchemas', () => {
beforeAll(() => {
ignoredSchemas.push('some_namespace');
});
afterAll(() => {
ignoredSchemas.pop();
});
it('removes schemas that we want to ignore', () => {
const goodSchema = Object.freeze({
namespace: 'yay',
properties: { yay: 'woo' },
});
const schemas = [
goodSchema,
{ namespace: 'some_namespace', properties: { foo: {} } },
];
expect(filterSchemas(schemas)).toEqual([goodSchema]);
});
it('does not remove anything if there are no ignored schemas', () => {
const schemas = Object.freeze([
Object.freeze({ namespace: 'alarms', permissions: ['alarms'] }),
]);
expect(filterSchemas(schemas)).toEqual(schemas);
});
});
describe('stripTrailingNullByte', () => {
it('strips a trailing null byte if present at the end', () => {
const str = 'foo\u0000';
expect(stripTrailingNullByte(str)).toEqual('foo');
});
it('returns the string unchanged if not present', () => {
const str = 'bar';
expect(stripTrailingNullByte(str)).toBe(str);
});
it('returns the string unchanged if not at the end', () => {
const str = 'b\u0000az';
expect(stripTrailingNullByte(str)).toBe(str);
});
it('handles empty strings', () => {
const str = '';
expect(stripTrailingNullByte(str)).toBe(str);
});
});
describe('downloadUrl', () => {
it('uses aurora if version is < 55', () => {
expect(downloadUrl(48)).toMatch(
/archive\/FIREFOX_AURORA_48_BASE.tar.gz$/);
expect(downloadUrl(54)).toMatch(
/archive\/FIREFOX_AURORA_54_BASE.tar.gz$/);
});
it('uses beta if version is >= 55', () => {
expect(downloadUrl(55)).toMatch(
/archive\/FIREFOX_BETA_55_BASE.tar.gz$/);
expect(downloadUrl(60)).toMatch(
/archive\/FIREFOX_BETA_60_BASE.tar.gz$/);
});
it('uses tip for nightly', () => {
expect(downloadUrl('nightly')).toMatch(
/archive\/tip.tar.gz$/);
});
});
});