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$/); }); }); });