protobufjs-loader
Version:
Webpack loader to translate .proto definitions to ProtoBuf.js modules
489 lines (435 loc) • 16.1 kB
JavaScript
const { assert } = require('chai');
const fs = require('fs');
const path = require('path');
const tmp = require('tmp');
const UglifyJS = require('uglify-js');
const glob = require('glob');
const compile = require('./helpers/compile');
/**
* Minifies the given JS string using ugilfy-js, so we can
* consistently compare generated outputs using relatively compact
* strings.
*
* @type { (contents: string) => string }
*/
const minify = (contents) => {
const result = UglifyJS.minify(contents, {
compress: {
// This avoids some larger structural changes in the minified
// code during compression.
inline: false,
},
// Don't mangle function/variable names.
mangle: false,
// Don't remove names of functions which aren't referenced by name
// somewhere.
keep_fnames: true,
});
if (result.error) {
throw result.error;
}
return result.code;
};
/**
* Promisified glob function for convenience.
*
* @type { (globStr: string) => Promise<string[]> }
*/
const globPromise = (globStr) =>
new Promise((resolve, reject) => {
glob(globStr, (err, files) => {
if (err) {
reject(err);
} else {
resolve(files);
}
});
});
/**
* Promisified read-file-as-string function for convenience.
*
* @type { (filename: string | fs.PathLike) => Promise<string> }
*/
const readFileAsString = (filename) =>
new Promise((resolve, reject) => {
fs.readFile(filename, (err, content) => {
if (err) {
reject(err);
} else {
resolve(content.toString());
}
});
});
describe('protobufjs-loader', function () {
before(async function () {
// The first time the compiler gets run (e.g. in a CI environment),
// some additional packages will be installed in the
// background. This can take awhile and trigger a timeout, so we do
// it here explicitly first.
this.timeout(10000);
await compile('basic');
});
describe('with JSON / reflection', function () {
beforeEach(function () {
this.opts = {
target: 'json-module',
};
});
it('should compile to a JSON representation', async function () {
const { inspect } = await compile('basic', this.opts);
const contents = minify(inspect.arguments[0]);
const innerString =
'addJSON({foo:{nested:{Bar:{fields:{baz:{type:"string",id:1}}}}}})})';
assert.include(contents, innerString);
});
});
describe('with static code', function () {
it('should compile static code by default', async function () {
const { inspect } = await compile('basic');
const contents = minify(inspect.arguments[0]);
assert.include(contents, 'foo.Bar=function(){');
});
it('should compile static code when the option is set explicitly', async function () {
const { inspect } = await compile('basic', { target: 'static-module' });
const contents = minify(inspect.arguments[0]);
assert.include(contents, 'foo.Bar=function(){');
});
describe('with typescript compilation', function () {
beforeEach(async function () {
// Typescript generation requires that we write files directly
// when the loader is invoked, rather than passing content
// upstream to webpack for later assembly.
//
// To avoid polluting the local file system with these
// compiled definitions, we perform the compilation in a tmp
// directory.
const fixturesPath = path.resolve(__dirname, 'fixtures');
const [tmpDir, cleanup] = await new Promise((resolve, reject) => {
tmp.dir((err, tmpDirResult, cleanupResult) => {
if (err) {
reject(err);
} else {
resolve([tmpDirResult, cleanupResult]);
}
});
});
this.tmpDir = tmpDir;
this.cleanup = cleanup;
const files = await globPromise(
path.join(fixturesPath, '**', '*.proto')
);
await Promise.all(
files.map((file) => {
const targetPath = path.join(
tmpDir,
path.relative(fixturesPath, file)
);
return new Promise((resolve, reject) => {
// Create subdirectories if necessary.
fs.mkdir(
path.dirname(targetPath),
{ recursive: true },
(mkdirErr) => {
if (mkdirErr) {
reject(mkdirErr);
} else {
fs.copyFile(file, targetPath, (copyErr) => {
if (copyErr) {
reject(copyErr);
} else {
resolve(undefined);
}
});
}
}
);
});
})
);
const target = path.resolve(__dirname, '..', 'node_modules');
const link = path.join(tmpDir, 'node_modules');
/** @type { Promise<void> } */
const symlinkNodeModulesPromise = new Promise((resolve, reject) => {
fs.symlink(target, link, (symlinkErr) => {
if (symlinkErr) {
reject(symlinkErr);
} else {
resolve();
}
});
});
await symlinkNodeModulesPromise;
});
afterEach(function () {
if (this.cleanup) {
this.cleanup();
}
});
it('should not compile typescript by default', async function () {
await compile(path.join(this.tmpDir, 'basic'));
const files = await globPromise(path.join(this.tmpDir, '*.d.ts'));
assert.equal(0, files.length);
});
it('should compile typescript when enabled', async function () {
await compile(path.join(this.tmpDir, 'basic'), { pbts: true });
// By default, definitions should just be siblings of their
// associated .proto file.
const files = await globPromise(path.join(this.tmpDir, '**', '*.d.ts'));
const expectedDefinitionsFile = path.join(
this.tmpDir,
'basic.proto.d.ts'
);
assert.sameMembers([expectedDefinitionsFile], files);
const declarations = await readFileAsString(expectedDefinitionsFile);
assert.include(declarations, 'public baz: string;');
assert.include(declarations, 'public static decodeDelimited');
});
it('should compile nearly-empty declarations if typescript compilation is enabled for JSON output', async function () {
await compile(path.join(this.tmpDir, 'basic'), {
target: 'json-module',
pbts: true,
});
const files = await globPromise(path.join(this.tmpDir, '*.d.ts'));
const expectedDefinitionsFile = path.join(
this.tmpDir,
'basic.proto.d.ts'
);
assert.sameMembers([expectedDefinitionsFile], files);
const declarations = await readFileAsString(expectedDefinitionsFile);
// Make sure the main protobufjs import shows up.
assert.include(
declarations,
'import * as $protobuf from "protobufjs";'
);
// Some versions of protobufjs-cli will also include
// additional imports. Make sure all non-empty lines are
// imports.
declarations.split('\n').forEach((line) => {
if (line.trim().length !== 0) {
assert.include(line, 'import');
}
});
});
it('should pass arguments to pbts', async function () {
await compile(path.join(this.tmpDir, 'basic'), {
pbts: {
args: ['-n', 'testModuleName'],
},
});
const files = await globPromise(path.join(this.tmpDir, '*.d.ts'));
const expectedDeclarationFile = path.join(
this.tmpDir,
'basic.proto.d.ts'
);
assert.sameMembers([expectedDeclarationFile], files);
const declarations = await readFileAsString(expectedDeclarationFile);
assert.include(declarations, 'public baz: string;');
assert.include(declarations, 'public static decodeDelimited');
assert.include(declarations, 'declare namespace testModuleName');
});
describe('with custom declaration output locations', function () {
/**
* Helper function to assert that declarations for the basic
* fixture can be saved to a custom location. Allows providing
* either a plain string location, or a promise resolving to
* the location.
*
* Return a promise resolving to true as a simple sanity check
* that all assertions completed successfully.
*
* @type { (tmpDir: string, location: string | Promise<string>) => Promise<boolean> }
*/
const assertSavesDeclarationToCustomLocation = async (
tmpDir,
location
) => {
let outputInvocationCount = 0;
/**
* @type { (input: string) => string | Promise<string> }
*/
const output = (input) => {
outputInvocationCount += 1;
assert.equal(
fs.realpathSync(input),
fs.realpathSync(path.join(tmpDir, 'basic.proto'))
);
return location;
};
await compile(path.join(tmpDir, 'basic'), {
pbts: {
output,
},
});
assert.equal(outputInvocationCount, 1);
// Wait for the result if necessary.
const locationStr = await Promise.resolve(location);
const content = await readFileAsString(locationStr);
assert.include(content, 'class Bar implements IBar');
return true;
};
it('should save a declaration file to a synchronously-generated location', async function () {
const [altTmpDir, cleanup] = await new Promise((resolve, reject) => {
tmp.dir((err, altTmpDirResult, cleanupResult) => {
if (err) {
reject(err);
} else {
resolve([altTmpDirResult, cleanupResult]);
}
});
});
const result = await assertSavesDeclarationToCustomLocation(
this.tmpDir,
path.join(altTmpDir, 'alt.d.ts')
);
assert.isTrue(result);
cleanup();
});
it('should save a declaration file to an asynchronously-generated location', async function () {
const [altTmpDir, cleanup] = await new Promise((resolve, reject) => {
tmp.dir((err, altTmpDirResult, cleanupResult) => {
if (err) {
reject(err);
} else {
resolve([altTmpDirResult, cleanupResult]);
}
});
});
const result = await assertSavesDeclarationToCustomLocation(
this.tmpDir,
new Promise((resolve) => {
setTimeout(() => {
resolve(path.join(altTmpDir, 'alt.d.ts'));
}, 5);
})
);
assert.isTrue(result);
cleanup();
});
});
describe('with imports', function () {
it('should compile imported definitions', async function () {
await compile(path.join(this.tmpDir, 'import'), {
paths: [this.tmpDir],
pbts: true,
});
const files = await globPromise(
path.join(this.tmpDir, '**', '*.d.ts')
);
const expectedDeclarationFile = path.join(
this.tmpDir,
'import.proto.d.ts'
);
assert.sameMembers([expectedDeclarationFile], files);
const declarations = await readFileAsString(expectedDeclarationFile);
// Check that declarations from the top-level `import`
// fixture are present.
assert.include(declarations, 'class NotBar implements INotBar {');
// Check that declarations from the imported `basic`
// fixture are present.
assert.include(declarations, 'class Bar implements IBar');
// Check that declarations imported from the
// subdirectory are present.
assert.include(declarations, 'class Baz implements IBaz');
assert.include(declarations, 'namespace sub');
});
});
});
});
describe('with an invalid protobuf file', function () {
it('should throw a compilation error', async function () {
let didError = false;
try {
await compile('invalid');
} catch (err) {
didError = true;
assert.include(`${err}`, "illegal token 'invalid'");
}
assert.isTrue(didError);
});
});
describe('with command line options', function () {
it('should pass command line options to the pbjs call', async function () {
const { inspect } = await compile('basic', { pbjsArgs: ['--no-encode'] });
const contents = minify(inspect.arguments[0]);
// Sanity check
const innerString = 'Bar.decode=function decode(reader,length)';
assert.include(contents, innerString);
assert.notInclude(contents, 'encode');
});
});
describe('with imports', function () {
beforeEach(function () {
this.innerString =
'.addJSON({foo:{nested:{NotBar:{fields:{bar:{type:"Bar",id:1}}},Bar:{fields:{baz:{type:"string",id:1}}},sub:{nested:{Baz:{fields:{id:{type:"int32",id:1}}}}}}}})});';
});
it('should respect the webpack paths configuration', async function () {
const { innerString } = this;
const { inspect } = await compile(
'import',
{
target: 'json-module',
},
{
resolve: {
modules: ['node_modules', path.resolve(__dirname, 'fixtures')],
},
}
);
const contents = minify(inspect.arguments[0]);
assert.include(contents, innerString);
});
it('should respect an explicit paths configuration', async function () {
const { innerString } = this;
const { inspect } = await compile('import', {
target: 'json-module',
paths: [path.resolve(__dirname, 'fixtures')],
});
const contents = minify(inspect.arguments[0]);
assert.include(contents, innerString);
});
it('should add the imports as dependencies', async function () {
const { inspect } = await compile('import', {
paths: [path.resolve(__dirname, 'fixtures')],
});
assert.sameMembers(inspect.context.getDependencies(), [
// The main proto file should be included.
path.resolve(__dirname, 'fixtures', 'import.proto'),
// Imported files should also be included.
path.resolve(__dirname, 'fixtures', 'basic.proto'),
path.resolve(__dirname, 'fixtures', 'sub', 'baz.proto'),
]);
});
it('should fail when the import is not found', async function () {
let didError = false;
try {
await compile('import', {
target: 'json-module',
// No include paths provided, so the 'import' fixture should
// fail to compile.
});
} catch (err) {
didError = true;
// The exact error that comes back from protobufjs differs
// depending on the package version, so we have to just check
// the webpack-specific portion of the error message.
assert.include(`${err}`, 'ModuleBuildError: Module build failed');
}
assert.isTrue(didError);
});
});
describe('with invalid options', function () {
it('should fail if unrecognized properties are added', async function () {
let didError = false;
try {
await compile('basic', {
target: 'json-module',
foo: true,
});
} catch (err) {
didError = true;
assert.include(`${err}`, "configuration has an unknown property 'foo'");
}
assert.isTrue(didError);
});
});
});