build: copy ts-api-guardian sources (#22544)

This is an exact mirror of 750f651eca

PR Close #22544
This commit is contained in:
Alex Eagle 2018-03-02 14:19:01 -08:00
parent d7e5d45f43
commit 25faf808a5
46 changed files with 1654 additions and 0 deletions

View File

@ -0,0 +1,3 @@
# Typescript API Guardian
Keeps track of public API surface of a typescript library.

View File

@ -0,0 +1,3 @@
#!/usr/bin/env node
require('../build/lib/cli').startCli();

View File

@ -0,0 +1,189 @@
import chalk from 'chalk';
import * as minimist from 'minimist';
import {ParsedArgs} from 'minimist';
import * as path from 'path';
import {SerializationOptions, generateGoldenFile, verifyAgainstGoldenFile} from './main';
// Examples:
//
// ```sh
// # Generate one declaration file
// ts-api-guardian --out api_guard.d.ts index.d.ts
//
// # Generate multiple declaration files // # (output location like typescript)
// ts-api-guardian --outDir api_guard [--rootDir .] core/index.d.ts core/testing.d.ts
//
// # Print usage
// ts-api-guardian --help
//
// # Check against one declaration file
// ts-api-guardian --verify api_guard.d.ts index.d.ts
//
// # Check against multiple declaration files
// ts-api-guardian --verifyDir api_guard [--rootDir .] core/index.d.ts core/testing.d.ts
// ```
const CMD = 'ts-api-guardian';
export function startCli() {
const {argv, mode, errors} = parseArguments(process.argv.slice(2));
const options: SerializationOptions = {
stripExportPattern: argv['stripExportPattern'],
allowModuleIdentifiers: [].concat(argv['allowModuleIdentifiers']),
onStabilityMissing: argv['onStabilityMissing'] || 'none'
};
if (['warn', 'error', 'none'].indexOf(options.onStabilityMissing) < 0) {
throw new Error(
'Argument for "--onStabilityMissing" option must be one of: "warn", "error", "none"');
}
for (const error of errors) {
console.warn(error);
}
if (mode === 'help') {
printUsageAndExit(!!errors.length);
} else {
const targets = generateFileNamePairs(argv, mode);
if (mode === 'out') {
for (const {entrypoint, goldenFile} of targets) {
generateGoldenFile(entrypoint, goldenFile, options);
}
} else { // mode === 'verify'
let hasDiff = false;
for (const {entrypoint, goldenFile} of targets) {
const diff = verifyAgainstGoldenFile(entrypoint, goldenFile, options);
if (diff) {
hasDiff = true;
const lines = diff.split('\n');
if (lines.length) {
lines.pop(); // Remove trailing newline
}
for (const line of lines) {
const chalkMap = {'-': chalk.red, '+': chalk.green, '@': chalk.cyan};
const chalkFunc = chalkMap[line[0]] || chalk.reset;
console.log(chalkFunc(line));
}
}
}
if (hasDiff) {
process.exit(1);
}
}
}
}
export function parseArguments(input: string[]):
{argv: ParsedArgs, mode: string, errors?: string[]} {
let help = false;
const errors = [];
const argv = minimist(input, {
string: [
'out', 'outDir', 'verify', 'verifyDir', 'rootDir', 'stripExportPattern',
'allowModuleIdentifiers', 'onStabilityMissing'
],
boolean: [
'help',
// Options used by chalk automagically
'color', 'no-color'
],
alias: {'outFile': 'out', 'verifyFile': 'verify'},
unknown: option => {
if (option[0] === '-') {
errors.push(`Unknown option: ${option}`);
help = true;
return false; // do not add to argv._
} else {
return true; // add to argv._
}
}
});
help = help || argv['help'];
if (help) {
return {argv, mode: 'help', errors};
}
let modes: string[] = [];
if (argv['out']) {
modes.push('out');
}
if (argv['outDir']) {
modes.push('out');
}
if (argv['verify']) {
modes.push('verify');
}
if (argv['verifyDir']) {
modes.push('verify');
}
if (!argv._.length) {
errors.push('No input file specified.');
modes = ['help'];
} else if (modes.length !== 1) {
errors.push('Specify either --out[Dir] or --verify[Dir]');
modes = ['help'];
} else if (argv._.length > 1 && !argv['outDir'] && !argv['verifyDir']) {
errors.push(`More than one input specified. Use --${modes[0]}Dir instead.`);
modes = ['help'];
}
return {argv, mode: modes[0], errors};
}
function printUsageAndExit(error = false) {
const print = error ? console.warn.bind(console) : console.log.bind(console);
print(`Usage: ${CMD} [options] <file ...>
${CMD} --out <output file> <entrypoint .d.ts file>
${CMD} --outDir <output dir> [--rootDir .] <entrypoint .d.ts files>
${CMD} --verify <golden file> <entrypoint .d.ts file>
${CMD} --verifyDir <golden file dir> [--rootDir .] <entrypoint .d.ts files>
Options:
--help Show this usage message
--out <file> Write golden output to file
--outDir <dir> Write golden file structure to directory
--verify <file> Read golden input from file
--verifyDir <dir> Read golden file structure from directory
--rootDir <dir> Specify the root directory of input files
--stripExportPattern <regexp> Do not output exports matching the pattern
--allowModuleIdentifiers <identifier>
Whitelist identifier for "* as foo" imports
--onStabilityMissing <warn|error|none>
Warn or error if an export has no stability
annotation`);
process.exit(error ? 1 : 0);
}
export function generateFileNamePairs(
argv: ParsedArgs, mode: string): {entrypoint: string, goldenFile: string}[] {
if (argv[mode]) {
return [{entrypoint: argv._[0], goldenFile: argv[mode]}];
} else { // argv[mode + 'Dir']
let rootDir = argv['rootDir'] || '.';
const goldenDir = argv[mode + 'Dir'];
return argv._.map(fileName => {
return {
entrypoint: fileName,
goldenFile: path.join(goldenDir, path.relative(rootDir, fileName))
};
});
}
}

View File

@ -0,0 +1,37 @@
import {createPatch} from 'diff';
import * as fs from 'fs';
import * as path from 'path';
import {SerializationOptions, publicApi} from './serializer';
export {SerializationOptions, publicApi} from './serializer';
export function generateGoldenFile(
entrypoint: string, outFile: string, options: SerializationOptions = {}): void {
const output = publicApi(entrypoint, options);
ensureDirectory(path.dirname(outFile));
fs.writeFileSync(outFile, output);
}
export function verifyAgainstGoldenFile(
entrypoint: string, goldenFile: string, options: SerializationOptions = {}): string {
const actual = publicApi(entrypoint, options);
const expected = fs.readFileSync(goldenFile).toString();
if (actual === expected) {
return '';
} else {
const patch = createPatch(goldenFile, expected, actual, 'Golden file', 'Generated API');
// Remove the header of the patch
const start = patch.indexOf('\n', patch.indexOf('\n') + 1) + 1;
return patch.substring(start);
}
}
function ensureDirectory(dir: string) {
if (!fs.existsSync(dir)) {
ensureDirectory(path.dirname(dir));
fs.mkdirSync(dir);
}
}

View File

@ -0,0 +1,338 @@
import * as path from 'path';
import * as ts from 'typescript';
const baseTsOptions: ts.CompilerOptions = {
// We don't want symbols from external modules to be resolved, so we use the
// classic algorithm.
moduleResolution: ts.ModuleResolutionKind.Classic
};
export interface SerializationOptions {
/**
* Removes all exports matching the regular expression.
*/
stripExportPattern?: RegExp;
/**
* Whitelists these identifiers as modules in the output. For example,
* ```
* import * as angular from './angularjs';
*
* export class Foo extends angular.Bar {}
* ```
* will produce `export class Foo extends angular.Bar {}` and requires
* whitelisting angular.
*/
allowModuleIdentifiers?: string[];
/**
* Warns or errors if stability annotations are missing on an export.
* Supports experimental, stable and deprecated.
*/
onStabilityMissing?: DiagnosticSeverity;
}
export type DiagnosticSeverity = 'warn' | 'error' | 'none';
export function publicApi(fileName: string, options: SerializationOptions = {}): string {
return publicApiInternal(ts.createCompilerHost(baseTsOptions), fileName, baseTsOptions, options);
}
export function publicApiInternal(
host: ts.CompilerHost, fileName: string, tsOptions: ts.CompilerOptions,
options: SerializationOptions = {}): string {
const entrypoint = path.normalize(fileName);
if (!entrypoint.match(/\.d\.ts$/)) {
throw new Error(`Source file "${fileName}" is not a declaration file`);
}
const program = ts.createProgram([entrypoint], tsOptions, host);
return new ResolvedDeclarationEmitter(program, entrypoint, options).emit();
}
interface Diagnostic {
type: DiagnosticSeverity;
message: string;
}
class ResolvedDeclarationEmitter {
private program: ts.Program;
private fileName: string;
private typeChecker: ts.TypeChecker;
private options: SerializationOptions;
private diagnostics: Diagnostic[];
constructor(program: ts.Program, fileName: string, options: SerializationOptions) {
this.program = program;
this.fileName = fileName;
this.options = options;
this.diagnostics = [];
this.typeChecker = this.program.getTypeChecker();
}
emit(): string {
const sourceFile = this.program.getSourceFiles().find(sf => sf.fileName === this.fileName);
if (!sourceFile) {
throw new Error(`Source file "${this.fileName}" not found`);
}
let output = '';
const resolvedSymbols = this.getResolvedSymbols(sourceFile);
// Sort all symbols so that the output is more deterministic
resolvedSymbols.sort(symbolCompareFunction);
for (const symbol of resolvedSymbols) {
if (this.options.stripExportPattern && symbol.name.match(this.options.stripExportPattern)) {
continue;
}
let decl: ts.Node = symbol.valueDeclaration || symbol.declarations && symbol.declarations[0];
if (!decl) {
this.diagnostics.push({
type: 'warn',
message: `${sourceFile.fileName}: error: No declaration found for symbol "${symbol.name}"`
});
continue;
}
// The declaration node may not be a complete statement, e.g. for var/const
// symbols. We need to find the complete export statement by traversing
// upwards.
while (!hasModifier(decl, ts.SyntaxKind.ExportKeyword) && decl.parent) {
decl = decl.parent;
}
if (hasModifier(decl, ts.SyntaxKind.ExportKeyword)) {
// Make an empty line between two exports
if (output) {
output += '\n';
}
// Print stability annotation
const sourceText = decl.getSourceFile().text;
const trivia = sourceText.substr(decl.pos, decl.getLeadingTriviaWidth());
const match = stabilityAnnotationPattern.exec(trivia);
if (match) {
output += `/** @${match[1]} */\n`;
} else if (['warn', 'error'].indexOf(this.options.onStabilityMissing) >= 0) {
this.diagnostics.push({
type: this.options.onStabilityMissing,
message: createErrorMessage(
decl, `No stability annotation found for symbol "${symbol.name}"`)
});
}
output += stripEmptyLines(this.emitNode(decl)) + '\n';
} else {
// This may happen for symbols re-exported from external modules.
this.diagnostics.push({
type: 'warn',
message:
createErrorMessage(decl, `No export declaration found for symbol "${symbol.name}"`)
});
}
}
if (this.diagnostics.length) {
const message = this.diagnostics.map(d => d.message).join('\n');
console.warn(message);
if (this.diagnostics.some(d => d.type === 'error')) {
throw new Error(message);
}
}
return output;
}
private getResolvedSymbols(sourceFile: ts.SourceFile): ts.Symbol[] {
const ms = (<any>sourceFile).symbol;
const rawSymbols = ms ? (this.typeChecker.getExportsOfModule(ms) || []) : [];
return rawSymbols.map(s => {
if (s.flags & ts.SymbolFlags.Alias) {
const resolvedSymbol = this.typeChecker.getAliasedSymbol(s);
// This will happen, e.g. for symbols re-exported from external modules.
if (!resolvedSymbol.valueDeclaration && !resolvedSymbol.declarations) {
return s;
}
if (resolvedSymbol.name !== s.name) {
if (this.options.stripExportPattern && s.name.match(this.options.stripExportPattern)) {
return s;
}
throw new Error(
`Symbol "${resolvedSymbol.name}" was aliased as "${s.name}". ` +
`Aliases are not supported."`);
}
return resolvedSymbol;
} else {
return s;
}
});
}
emitNode(node: ts.Node) {
if (hasModifier(node, ts.SyntaxKind.PrivateKeyword)) {
return '';
}
const firstQualifier: ts.Identifier = getFirstQualifier(node);
if (firstQualifier) {
let isAllowed = false;
// Try to resolve the qualifier.
const resolvedSymbol = this.typeChecker.getSymbolAtLocation(firstQualifier);
if (resolvedSymbol && resolvedSymbol.declarations.length > 0) {
// If the qualifier can be resolved, and it's not a namespaced import, then it should be allowed.
isAllowed = resolvedSymbol.declarations.every(decl => decl.kind !== ts.SyntaxKind.NamespaceImport);
}
// If it is not allowed otherwise, it's allowed if it's on the list of allowed identifiers.
isAllowed = isAllowed || !(!this.options.allowModuleIdentifiers ||
this.options.allowModuleIdentifiers.indexOf(firstQualifier.text) < 0);
if (!isAllowed) {
this.diagnostics.push({
type: 'error',
message: createErrorMessage(
firstQualifier,
`Module identifier "${firstQualifier.text}" is not allowed. Remove it ` +
`from source or whitelist it via --allowModuleIdentifiers.`)
});
}
}
let children = node.getChildren();
const sourceText = node.getSourceFile().text;
if (children.length) {
// Sort declarations under a class or an interface
if (node.kind === ts.SyntaxKind.SyntaxList) {
switch (node.parent && node.parent.kind) {
case ts.SyntaxKind.ClassDeclaration:
case ts.SyntaxKind.InterfaceDeclaration: {
// There can be multiple SyntaxLists under a class or an interface,
// since SyntaxList is just an arbitrary data structure generated
// by Node#getChildren(). We need to check that we are sorting the
// right list.
if (children.every(node => node.kind in memberDeclarationOrder)) {
children = children.slice();
children.sort((a: ts.NamedDeclaration, b: ts.NamedDeclaration) => {
// Static after normal
return compareFunction(
hasModifier(a, ts.SyntaxKind.StaticKeyword),
hasModifier(b, ts.SyntaxKind.StaticKeyword)
) ||
// Our predefined order
compareFunction(
memberDeclarationOrder[a.kind], memberDeclarationOrder[b.kind]) ||
// Alphebetical order
// We need safe dereferencing due to edge cases, e.g. having two call signatures
compareFunction((a.name || a).getText(), (b.name || b).getText());
});
}
break;
}
}
}
let output = children
.filter(x => x.kind !== ts.SyntaxKind.JSDocComment)
.map(n => this.emitNode(n))
.join('');
// Print stability annotation for fields
if (node.kind in memberDeclarationOrder) {
const trivia = sourceText.substr(node.pos, node.getLeadingTriviaWidth());
const match = stabilityAnnotationPattern.exec(trivia);
if (match) {
// Add the annotation after the leading whitespace
output = output.replace(/^(\n\s*)/, `$1/** @${match[1]} */ `);
}
}
return output;
} else {
const ranges = ts.getLeadingCommentRanges(sourceText, node.pos);
let tail = node.pos;
for (const range of ranges || []) {
if (range.end > tail) {
tail = range.end;
}
}
return sourceText.substring(tail, node.end);
}
}
}
function symbolCompareFunction(a: ts.Symbol, b: ts.Symbol) {
return a.name.localeCompare(b.name);
}
function compareFunction<T>(a: T, b: T) {
return a === b ? 0 : a > b ? 1 : -1;
}
const memberDeclarationOrder = {
[ts.SyntaxKind.PropertySignature]: 0,
[ts.SyntaxKind.PropertyDeclaration]: 0,
[ts.SyntaxKind.GetAccessor]: 0,
[ts.SyntaxKind.SetAccessor]: 0,
[ts.SyntaxKind.CallSignature]: 1,
[ts.SyntaxKind.Constructor]: 2,
[ts.SyntaxKind.ConstructSignature]: 2,
[ts.SyntaxKind.IndexSignature]: 3,
[ts.SyntaxKind.MethodSignature]: 4,
[ts.SyntaxKind.MethodDeclaration]: 4
};
const stabilityAnnotationPattern = /@(experimental|stable|deprecated)\b/;
function stripEmptyLines(text: string): string {
return text.split('\n').filter(x => !!x.length).join('\n');
}
/**
* Returns the first qualifier if the input node is a dotted expression.
*/
function getFirstQualifier(node: ts.Node): ts.Identifier {
switch (node.kind) {
case ts.SyntaxKind.PropertyAccessExpression: {
// For expression position
let lhs = node;
do {
lhs = (<ts.PropertyAccessExpression>lhs).expression;
} while (lhs && lhs.kind !== ts.SyntaxKind.Identifier);
return <ts.Identifier>lhs;
}
case ts.SyntaxKind.TypeReference: {
// For type position
let lhs: ts.Node = (<ts.TypeReferenceNode>node).typeName;
do {
lhs = (<ts.QualifiedName>lhs).left;
} while (lhs && lhs.kind !== ts.SyntaxKind.Identifier);
return <ts.Identifier>lhs;
}
default:
return null;
}
}
function createErrorMessage(node: ts.Node, message: string): string {
const sourceFile = node.getSourceFile();
let position;
if (sourceFile) {
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
position = `${sourceFile.fileName}(${line + 1},${character + 1})`;
} else {
position = '<unknown>';
}
return `${position}: error: ${message}`;
}
function hasModifier(node: ts.Node, modifierKind: ts.SyntaxKind): boolean {
return !!node.modifiers && node.modifiers.some(x => x.kind === modifierKind);
}

View File

@ -0,0 +1,60 @@
{
"name": "ts-api-guardian",
"version": "0.3.0",
"description": "Guards the API of TypeScript libraries!",
"main": "build/lib/main.js",
"typings": "build/definitions/main.d.ts",
"bin": {
"ts-api-guardian": "./bin/ts-api-guardian"
},
"directories": {
"test": "test"
},
"peerDependencies": {
"typescript": "^2.6.1"
},
"dependencies": {
"chalk": "^2.3.1",
"diff": "^3.4.0",
"minimist": "^1.2.0"
},
"devDependencies": {
"@types/chai": "^4.1.2",
"@types/diff": "^3.2.2",
"@types/minimist": "^1.2.0",
"@types/mocha": "^2.2.48",
"@types/node": "^0.12.15",
"chai": "^4.1.2",
"clang-format": "^1.0.25",
"gulp": "^3.8.11",
"gulp-clang-format": "^1.0.25",
"gulp-mocha": "^5.0.0",
"gulp-sourcemaps": "^2.6.4",
"gulp-typescript": "^4.0.1",
"gulp-util": "^3.0.8",
"merge2": "^1.2.1",
"source-map": "^0.7.1",
"source-map-support": "^0.5.3",
"typescript": "~2.6.2"
},
"scripts": {
"prepublish": "gulp compile",
"test": "gulp test.unit"
},
"repository": {},
"keywords": [
"typescript"
],
"contributors": [
"Alan Agius <alan.agius4@gmail.com> (https://github.com/alan-agius4/)",
"Alex Eagle <alexeagle@google.com> (https://angular.io/)",
"Martin Probst <martinprobst@google.com> (https://angular.io/)",
"Victor Savkin<vsavkin@google.com> (https://victorsavkin.com)",
"Igor Minar<iminar@google.com> (https://angular.io/)"
],
"license": "MIT",
"bugs": {
"url": "https://github.com/angular/ts-api-guardian/issues"
},
"homepage": "https://github.com/angular/ts-api-guardian"
}

View File

@ -0,0 +1,138 @@
import chai = require('chai');
import * as child_process from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import {assertFileEqual, unlinkRecursively} from './helpers';
const BINARY = path.resolve(__dirname, '../../bin/ts-api-guardian');
describe('cli: e2e test', () => {
const outDir = path.resolve(__dirname, '../../build/tmp');
beforeEach(() => {
if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir);
}
});
afterEach(() => { unlinkRecursively(outDir); });
it('should print usage without any argument', () => {
const {stderr} = execute([]);
chai.assert.match(stderr, /Usage/);
});
it('should show help message with --help', () => {
const {stdout} = execute(['--help']);
chai.assert.match(stdout, /Usage/);
});
it('should generate golden file with --out', () => {
const simpleFile = path.join(outDir, 'simple.d.ts');
const {status} = execute(['--out', simpleFile, 'test/fixtures/simple.d.ts']);
chai.assert.equal(status, 0);
assertFileEqual(simpleFile, 'test/fixtures/simple_expected.d.ts');
});
it('should verify golden file with --verify and exit cleanly on no difference', () => {
const {stdout, status} =
execute(['--verify', 'test/fixtures/simple_expected.d.ts', 'test/fixtures/simple.d.ts']);
chai.assert.equal(stdout, '');
chai.assert.equal(status, 0);
});
it('should verify golden file with --verify and exit with error on difference', () => {
const {stdout, status} = execute(
['--verify', 'test/fixtures/verify_expected.d.ts', 'test/fixtures/verify_entrypoint.d.ts']);
chai.assert.equal(stdout, fs.readFileSync('test/fixtures/verify.patch').toString());
chai.assert.equal(status, 1);
});
it('should generate multiple golden files with --outDir and --rootDir', () => {
const {status} = execute([
'--outDir', outDir, '--rootDir', 'test/fixtures', 'test/fixtures/simple.d.ts',
'test/fixtures/sorting.d.ts'
]);
chai.assert.equal(status, 0);
assertFileEqual(path.join(outDir, 'simple.d.ts'), 'test/fixtures/simple_expected.d.ts');
assertFileEqual(path.join(outDir, 'sorting.d.ts'), 'test/fixtures/sorting_expected.d.ts');
});
it('should verify multiple golden files with --verifyDir and --rootDir', () => {
copyFile('test/fixtures/simple_expected.d.ts', path.join(outDir, 'simple.d.ts'));
copyFile('test/fixtures/sorting_expected.d.ts', path.join(outDir, 'sorting.d.ts'));
const {stdout, status} = execute([
'--verifyDir', outDir, '--rootDir', 'test/fixtures', 'test/fixtures/simple.d.ts',
'test/fixtures/sorting.d.ts'
]);
chai.assert.equal(stdout, '');
chai.assert.equal(status, 0);
});
it('should generate respecting --stripExportPattern', () => {
const {stdout, status} = execute([
'--out', path.join(outDir, 'underscored.d.ts'), '--stripExportPattern', '^__.*',
'test/fixtures/underscored.d.ts'
]);
chai.assert.equal(status, 0);
assertFileEqual(
path.join(outDir, 'underscored.d.ts'), 'test/fixtures/underscored_expected.d.ts');
});
it('should not throw for aliased stripped exports', () => {
const {stdout, status} = execute([
'--out', path.join(outDir, 'stripped_alias.d.ts'), '--stripExportPattern', '^__.*',
'test/fixtures/stripped_alias.d.ts'
]);
chai.assert.equal(status, 0);
assertFileEqual(
path.join(outDir, 'stripped_alias.d.ts'), 'test/fixtures/stripped_alias_expected.d.ts');
});
it('should verify respecting --stripExportPattern', () => {
const {stdout, status} = execute([
'--verify', 'test/fixtures/underscored_expected.d.ts', 'test/fixtures/underscored.d.ts',
'--stripExportPattern', '^__.*'
]);
chai.assert.equal(stdout, '');
chai.assert.equal(status, 0);
});
it('should respect --allowModuleIdentifiers', () => {
const {stdout, status} = execute([
'--verify', 'test/fixtures/module_identifier_expected.d.ts', '--allowModuleIdentifiers',
'foo', 'test/fixtures/module_identifier.d.ts'
]);
chai.assert.equal(stdout, '');
chai.assert.equal(status, 0);
});
it('should respect --onStabilityMissing', () => {
const {stdout, stderr, status} = execute([
'--verify', 'test/fixtures/simple_expected.d.ts', '--onStabilityMissing', 'warn',
'test/fixtures/simple.d.ts'
]);
chai.assert.equal(stdout, '');
chai.assert.equal(
stderr,
'test/fixtures/simple.d.ts(1,1): error: No stability annotation found for symbol "A"\n' +
'test/fixtures/simple.d.ts(2,1): error: No stability annotation found for symbol "B"\n');
chai.assert.equal(status, 0);
});
});
function copyFile(sourceFile: string, targetFile: string) {
fs.writeFileSync(targetFile, fs.readFileSync(sourceFile));
}
function execute(args: string[]): {stdout: string, stderr: string, status: number} {
const output = child_process.spawnSync(BINARY, args);
chai.assert(!output.error, 'Child process failed or timed out');
chai.assert(!output.signal, `Child process killed by signal ${output.signal}`);
return {
stdout: output.stdout.toString(),
stderr: output.stderr.toString(),
status: output.status
};
}

View File

@ -0,0 +1,86 @@
import chai = require('chai');
import * as ts from 'typescript';
import {parseArguments, generateFileNamePairs} from '../lib/cli';
describe('cli: parseArguments', () => {
it('should show usage with error when supplied with no arguments', () => {
const {argv, mode, errors} = parseArguments([]);
chai.assert.equal(mode, 'help');
chai.assert.deepEqual(errors, ['No input file specified.']);
});
it('should show usage without error when supplied with --help', () => {
const {argv, mode, errors} = parseArguments(['--help']);
chai.assert.equal(mode, 'help');
chai.assert.deepEqual(errors, []);
});
it('should show usage with error when supplied with none of --out/verify[Dir]', () => {
const {argv, mode, errors} = parseArguments(['input.d.ts']);
chai.assert.equal(mode, 'help');
chai.assert.deepEqual(errors, ['Specify either --out[Dir] or --verify[Dir]']);
});
it('should show usage with error when supplied with both of --out/verify[Dir]', () => {
const {argv, mode, errors} =
parseArguments(['--out', 'out.d.ts', '--verifyDir', 'golden.d.ts', 'input.d.ts']);
chai.assert.equal(mode, 'help');
chai.assert.deepEqual(errors, ['Specify either --out[Dir] or --verify[Dir]']);
});
it('should show usage with error when supplied without input file', () => {
const {argv, mode, errors} = parseArguments(['--out', 'output.d.ts']);
chai.assert.equal(mode, 'help');
chai.assert.deepEqual(errors, ['No input file specified.']);
});
it('should show usage with error when supplied without input file', () => {
const {argv, mode, errors} =
parseArguments(['--out', 'output.d.ts', 'first.d.ts', 'second.d.ts']);
chai.assert.equal(mode, 'help');
chai.assert.deepEqual(errors, ['More than one input specified. Use --outDir instead.']);
});
it('should use out mode when supplied with --out', () => {
const {argv, mode, errors} = parseArguments(['--out', 'out.d.ts', 'input.d.ts']);
chai.assert.equal(argv['out'], 'out.d.ts');
chai.assert.deepEqual(argv._, ['input.d.ts']);
chai.assert.equal(mode, 'out');
chai.assert.deepEqual(errors, []);
});
it('should use verify mode when supplied with --verify', () => {
const {argv, mode, errors} = parseArguments(['--verify', 'out.d.ts', 'input.d.ts']);
chai.assert.equal(argv['verify'], 'out.d.ts');
chai.assert.deepEqual(argv._, ['input.d.ts']);
chai.assert.equal(mode, 'verify');
chai.assert.deepEqual(errors, []);
});
});
describe('cli: generateFileNamePairs', () => {
it('should generate one file pair in one-file mode', () => {
chai.assert.deepEqual(
generateFileNamePairs({_: ['input.d.ts'], out: 'output.d.ts'}, 'out'),
[{entrypoint: 'input.d.ts', goldenFile: 'output.d.ts'}]);
});
it('should generate file pairs in multi-file mode according to current directory', () => {
chai.assert.deepEqual(
generateFileNamePairs({_: ['src/first.d.ts', 'src/second.d.ts'], outDir: 'bank'}, 'out'), [
{entrypoint: 'src/first.d.ts', goldenFile: 'bank/src/first.d.ts'},
{entrypoint: 'src/second.d.ts', goldenFile: 'bank/src/second.d.ts'}
]);
});
it('should generate file pairs according to rootDir if provided', () => {
chai.assert.deepEqual(
generateFileNamePairs(
{_: ['src/first.d.ts', 'src/second.d.ts'], outDir: 'bank', rootDir: 'src'}, 'out'),
[
{entrypoint: 'src/first.d.ts', goldenFile: 'bank/first.d.ts'},
{entrypoint: 'src/second.d.ts', goldenFile: 'bank/second.d.ts'}
]);
});
});

View File

@ -0,0 +1,14 @@
export declare class A {
field: string;
method(a: string): number;
}
export interface B {
field: A;
}
export declare class C {
private privateProp;
propWithDefault: number;
protected protectedProp: number;
someProp: string;
constructor(someProp: string, propWithDefault: number, privateProp: any, protectedProp: number);
}

View File

@ -0,0 +1,15 @@
export declare class A {
field: string;
method(a: string): number;
}
export interface B {
field: A;
}
export declare class C {
propWithDefault: number;
protected protectedProp: number;
someProp: string;
constructor(someProp: string, propWithDefault: number, privateProp: any, protectedProp: number);
}

View File

View File

View File

@ -0,0 +1,8 @@
export declare enum Foo {
Alpha = 0,
Beta = 1,
}
export interface Bar {
field: Foo.Alpha,
}

View File

@ -0,0 +1,8 @@
export interface Bar {
field: Foo.Alpha,
}
export declare enum Foo {
Alpha = 0,
Beta = 1,
}

View File

@ -0,0 +1,3 @@
export declare type SimpleChanges<T = any> = {
[P in keyof T]?: any;
};

View File

@ -0,0 +1,3 @@
export declare type SimpleChanges<T = any> = {
[P in keyof T]?: any;
};

View File

@ -0,0 +1,4 @@
import * as foo from './somewhere';
export declare class A extends foo.Bar {
}

View File

@ -0,0 +1,2 @@
export declare class A extends foo.Bar {
}

View File

@ -0,0 +1 @@
export { A, B } from './simple';

View File

@ -0,0 +1 @@
export { A as Apple } from './classes_and_interfaces';

View File

@ -0,0 +1 @@
export { A } from './classes_and_interfaces';

View File

@ -0,0 +1,4 @@
export declare class A {
field: string;
method(a: string): number;
}

View File

@ -0,0 +1,3 @@
export declare const A: string;
export declare var B: string;

View File

@ -0,0 +1,5 @@
/**
* We want to ensure that external modules are not resolved. Typescript happens
* to be conveniently available in our environment.
*/
export { CompilerHost } from 'typescript';

View File

@ -0,0 +1 @@
export * from './simple';

View File

@ -0,0 +1,3 @@
export declare const A: string;
export declare var B: string;

View File

@ -0,0 +1,2 @@
export declare const A: string;
export declare var B: string;

View File

@ -0,0 +1,3 @@
export declare const A: string;
export declare var B: string;

View File

@ -0,0 +1,11 @@
export declare type E = string;
export interface D {
e: number;
}
export declare var e: C;
export declare class C {
e: number;
d: string;
}
export declare function b(): boolean;
export declare const a: string;

View File

@ -0,0 +1,16 @@
export declare const a: string;
export declare function b(): boolean;
export declare class C {
d: string;
e: number;
}
export interface D {
e: number;
}
export declare var e: C;
export declare type E = string;

View File

@ -0,0 +1,3 @@
export {original_symbol as __private_symbol} from './stripped_alias_original';
export class B {
}

View File

@ -0,0 +1,2 @@
export class B {
}

View File

@ -0,0 +1 @@
export let original_symbol: number;

View File

@ -0,0 +1,7 @@
export class UsesTypeLiterals {
a: number | undefined;
b: number | null;
c: number | true;
d: number | null | undefined;
e: Array<string | null | undefined>;
}

View File

@ -0,0 +1,7 @@
export class UsesTypeLiterals {
a: number | undefined;
b: number | null;
c: number | true;
d: number | null | undefined;
e: Array<string | null | undefined>;
}

View File

@ -0,0 +1,3 @@
export const __a__: string;
export class B {
}

View File

@ -0,0 +1,2 @@
export class B {
}

View File

@ -0,0 +1,11 @@
--- test/fixtures/verify_expected.d.ts Golden file
+++ test/fixtures/verify_expected.d.ts Generated API
@@ -1,5 +1,6 @@
export interface A {
c: number;
- a(arg: any[]): any;
- b: string;
+ a(arg: any[]): {[name: string]: number};
}
+
+export declare const b: boolean;

View File

@ -0,0 +1,5 @@
export interface A {
c: number;
a(arg: any[]): {[name: string]: number};
}
export { b } from './verify_submodule';

View File

@ -0,0 +1,5 @@
export interface A {
c: number;
a(arg: any[]): any;
b: string;
}

View File

@ -0,0 +1 @@
export declare const b: boolean;

View File

@ -0,0 +1,19 @@
import * as chai from 'chai';
import * as fs from 'fs';
import * as path from 'path';
export function unlinkRecursively(file: string) {
if (fs.statSync(file).isDirectory()) {
for (const f of fs.readdirSync(file)) {
unlinkRecursively(path.join(file, f));
}
fs.rmdirSync(file);
} else {
fs.unlinkSync(file);
}
}
export function assertFileEqual(actualFile: string, expectedFile: string) {
chai.assert.equal(
fs.readFileSync(actualFile).toString(), fs.readFileSync(expectedFile).toString());
}

View File

@ -0,0 +1,132 @@
import * as chai from 'chai';
import * as fs from 'fs';
import * as path from 'path';
import * as main from '../lib/main';
import {assertFileEqual, unlinkRecursively} from './helpers';
describe('integration test: public api', () => {
let _warn = null;
let warnings: string[] = [];
beforeEach(() => {
_warn = console.warn;
console.warn = (...args: string[]) => warnings.push(args.join(' '));
});
afterEach(() => {
console.warn = _warn;
warnings = [];
_warn = null;
});
it('should handle empty files',
() => { check('test/fixtures/empty.d.ts', 'test/fixtures/empty_expected.d.ts'); });
it('should include symbols',
() => { check('test/fixtures/simple.d.ts', 'test/fixtures/simple_expected.d.ts'); });
it('should include symbols reexported explicitly',
() => { check('test/fixtures/reexported.d.ts', 'test/fixtures/reexported_expected.d.ts'); });
it('should include symbols reexported with *', () => {
check('test/fixtures/reexported_star.d.ts', 'test/fixtures/reexported_star_expected.d.ts');
});
it('should include members of classes and interfaces', () => {
check(
'test/fixtures/classes_and_interfaces.d.ts',
'test/fixtures/classes_and_interfaces_expected.d.ts');
});
it('should include members reexported classes', () => {
check(
'test/fixtures/reexported_classes.d.ts', 'test/fixtures/reexported_classes_expected.d.ts');
});
it('should remove reexported external symbols', () => {
check('test/fixtures/reexported_extern.d.ts', 'test/fixtures/reexported_extern_expected.d.ts');
chai.assert.deepEqual(warnings, [
'test/fixtures/reexported_extern.d.ts(5,1): error: No export declaration found for symbol "CompilerHost"'
]);
});
it('should support type literals', () => {
check('test/fixtures/type_literals.d.ts', 'test/fixtures/type_literals_expected.d.ts');
});
it('should allow enums as types', () => {
check('test/fixtures/enum_as_type.d.ts', 'test/fixtures/enum_as_type_expected.d.ts');
});
it('should throw on passing a .ts file as an input', () => {
chai.assert.throws(() => {
main.publicApi('test/fixtures/empty.ts');
}, 'Source file "test/fixtures/empty.ts" is not a declaration file');
});
it('should respect serialization options', () => {
check(
'test/fixtures/underscored.d.ts', 'test/fixtures/underscored_expected.d.ts',
{stripExportPattern: /^__.*/});
});
});
describe('integration test: generateGoldenFile', () => {
const outDir = path.resolve(__dirname, '../../build/tmp');
const outFile = path.join(outDir, 'out.d.ts');
const deepOutFile = path.join(outDir, 'a/b/c/out.d.ts');
beforeEach(() => {
if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir);
}
});
afterEach(() => { unlinkRecursively(outDir); });
it('should generate a golden file', () => {
main.generateGoldenFile('test/fixtures/reexported_classes.d.ts', outFile);
assertFileEqual(outFile, 'test/fixtures/reexported_classes_expected.d.ts');
});
it('should generate a golden file with any ancestor directory created', () => {
main.generateGoldenFile('test/fixtures/reexported_classes.d.ts', deepOutFile);
assertFileEqual(deepOutFile, 'test/fixtures/reexported_classes_expected.d.ts');
});
it('should respect serialization options', () => {
main.generateGoldenFile(
'test/fixtures/underscored.d.ts', outFile, {stripExportPattern: /^__.*/});
assertFileEqual(outFile, 'test/fixtures/underscored_expected.d.ts');
});
it('should generate a golden file with keyof', () => {
main.generateGoldenFile('test/fixtures/keyof.d.ts', outFile);
assertFileEqual(outFile, 'test/fixtures/keyof_expected.d.ts');
});
});
describe('integration test: verifyAgainstGoldenFile', () => {
it('should check an entrypoint against a golden file on equal', () => {
const diff = main.verifyAgainstGoldenFile(
'test/fixtures/reexported_classes.d.ts', 'test/fixtures/reexported_classes_expected.d.ts');
chai.assert.equal(diff, '');
});
it('should check an entrypoint against a golden file with proper diff message', () => {
const diff = main.verifyAgainstGoldenFile(
'test/fixtures/verify_entrypoint.d.ts', 'test/fixtures/verify_expected.d.ts');
chai.assert.equal(diff, fs.readFileSync('test/fixtures/verify.patch').toString());
});
it('should respect serialization options', () => {
const diff = main.verifyAgainstGoldenFile(
'test/fixtures/underscored.d.ts', 'test/fixtures/underscored_expected.d.ts',
{stripExportPattern: /^__.*/});
chai.assert.equal(diff, '');
});
});
function check(sourceFile: string, expectedFile: string, options: main.SerializationOptions = {}) {
chai.assert.equal(main.publicApi(sourceFile, options), fs.readFileSync(expectedFile).toString());
}

View File

@ -0,0 +1,494 @@
import * as chai from 'chai';
import * as ts from 'typescript';
import { publicApiInternal, SerializationOptions } from '../lib/serializer';
const classesAndInterfaces = `
export declare class A {
field: string;
method(a: string): number;
}
export interface B {
field: A;
}
export declare class C {
someProp: string;
propWithDefault: number;
private privateProp;
protected protectedProp: number;
constructor(someProp: string, propWithDefault: number, privateProp: any, protectedProp: number);
}
`;
describe('unit test', () => {
let _warn = null;
let warnings: string[] = [];
beforeEach(() => {
_warn = console.warn;
console.warn = (...args: string[]) => warnings.push(args.join(' '));
});
afterEach(() => {
console.warn = _warn;
warnings = [];
_warn = null;
});
it('should ignore private methods', () => {
const input = `
export declare class A {
fa(): void;
protected fb(): void;
private fc();
}
`;
const expected = `
export declare class A {
fa(): void;
protected fb(): void;
}
`;
check({ 'file.d.ts': input }, expected);
});
it('should ignore private props', () => {
const input = `
export declare class A {
fa: any;
protected fb: any;
private fc;
}
`;
const expected = `
export declare class A {
fa: any;
protected fb: any;
}
`;
check({ 'file.d.ts': input }, expected);
});
it('should support imports without capturing imports', () => {
const input = `
import {A} from './classes_and_interfaces';
export declare class C {
field: A;
}
`;
const expected = `
export declare class C {
field: A;
}
`;
check({ 'classes_and_interfaces.d.ts': classesAndInterfaces, 'file.d.ts': input }, expected);
});
it('should throw on aliased reexports', () => {
const input = `
export { A as Apple } from './classes_and_interfaces';
`;
checkThrows(
{ 'classes_and_interfaces.d.ts': classesAndInterfaces, 'file.d.ts': input },
'Symbol "A" was aliased as "Apple". Aliases are not supported.');
});
it('should remove reexported external symbols', () => {
const input = `
export { Foo } from 'some-external-module-that-cannot-be-resolved';
`;
const expected = `
`;
check({ 'classes_and_interfaces.d.ts': classesAndInterfaces, 'file.d.ts': input }, expected);
chai.assert.deepEqual(
warnings, ['file.d.ts(1,1): error: No export declaration found for symbol "Foo"']);
});
it('should sort exports', () => {
const input = `
export declare type E = string;
export interface D {
e: number;
}
export declare var e: C;
export declare class C {
e: number;
d: string;
}
export declare function b(): boolean;
export declare const a: string;
`;
const expected = `
export declare const a: string;
export declare function b(): boolean;
export declare class C {
d: string;
e: number;
}
export interface D {
e: number;
}
export declare var e: C;
export declare type E = string;
`;
check({ 'file.d.ts': input }, expected);
});
it('should sort class members', () => {
const input = `
export class A {
f: number;
static foo(): void;
c: string;
static a: boolean;
constructor();
static bar(): void;
}
`;
const expected = `
export class A {
c: string;
f: number;
constructor();
static a: boolean;
static bar(): void;
static foo(): void;
}
`;
check({ 'file.d.ts': input }, expected);
});
it('should sort interface members', () => {
const input = `
export interface A {
(): void;
[key: string]: any;
c(): void;
a: number;
new (): Object;
}
`;
const expected = `
export interface A {
a: number;
(): void;
new (): Object;
[key: string]: any;
c(): void;
}
`;
check({ 'file.d.ts': input }, expected);
});
it('should sort class members including readonly', () => {
const input = `
export declare class DebugNode {
private _debugContext;
nativeNode: any;
listeners: any[];
parent: any | null;
constructor(nativeNode: any, parent: DebugNode | null, _debugContext: any);
readonly injector: any;
readonly componentInstance: any;
readonly context: any;
readonly references: {
[key: string]: any;
};
readonly providerTokens: any[];
}
`;
const expected = `
export declare class DebugNode {
readonly componentInstance: any;
readonly context: any;
readonly injector: any;
listeners: any[];
nativeNode: any;
parent: any | null;
readonly providerTokens: any[];
readonly references: {
[key: string]: any;
};
constructor(nativeNode: any, parent: DebugNode | null, _debugContext: any);
}
`;
check({ 'file.d.ts': input }, expected);
});
it('should sort two call signatures', () => {
const input = `
export interface A {
(b: number): void;
(a: number): void;
}
`;
const expected = `
export interface A {
(a: number): void;
(b: number): void;
}
`;
check({ 'file.d.ts': input }, expected);
});
it('should sort exports including re-exports', () => {
const submodule = `
export declare var e: C;
export declare class C {
e: number;
d: string;
}
`;
const input = `
export * from './submodule';
export declare type E = string;
export interface D {
e: number;
}
export declare function b(): boolean;
export declare const a: string;
`;
const expected = `
export declare const a: string;
export declare function b(): boolean;
export declare class C {
d: string;
e: number;
}
export interface D {
e: number;
}
export declare var e: C;
export declare type E = string;
`;
check({ 'submodule.d.ts': submodule, 'file.d.ts': input }, expected);
});
it('should remove module comments', () => {
const input = `
/**
* An amazing module.
* @module
*/
/**
* Foo function.
*/
export declare function foo(): boolean;
export declare const bar: number;
`;
const expected = `
export declare const bar: number;
export declare function foo(): boolean;
`;
check({ 'file.d.ts': input }, expected);
});
it('should remove class and field comments', () => {
const input = `
/**
* Does something really cool.
*/
export declare class A {
/**
* A very interesting getter.
*/
b: string;
/**
* A very useful field.
*/
name: string;
}
`;
const expected = `
export declare class A {
b: string;
name: string;
}
`;
check({ 'file.d.ts': input }, expected);
});
it('should skip symbols matching specified pattern', () => {
const input = `
export const __a__: string;
export class B {
}
`;
const expected = `
export class B {
}
`;
check({ 'file.d.ts': input }, expected, { stripExportPattern: /^__.*/ });
});
it('should throw on using non-whitelisted module imports in expression position', () => {
const input = `
import * as foo from './foo';
export declare class A extends foo.A {
}
`;
checkThrows(
{ 'file.d.ts': input }, 'file.d.ts(2,32): error: Module identifier "foo" is not allowed. ' +
'Remove it from source or whitelist it via --allowModuleIdentifiers.');
});
it('should throw on using non-whitelisted module imports in type position', () => {
const input = `
import * as foo from './foo';
export type A = foo.A;
`;
checkThrows(
{ 'file.d.ts': input }, 'file.d.ts(2,17): error: Module identifier "foo" is not allowed. ' +
'Remove it from source or whitelist it via --allowModuleIdentifiers.');
});
it('should not throw on using whitelisted module imports', () => {
const input = `
import * as foo from './foo';
export declare class A extends foo.A {
}
`;
const expected = `
export declare class A extends foo.A {
}
`;
check({ 'file.d.ts': input }, expected, { allowModuleIdentifiers: ['foo'] });
});
it('should not throw if non-whitelisted module imports are not written', () => {
const input = `
import * as foo from './foo';
export declare class A {
}
`;
const expected = `
export declare class A {
}
`;
check({ 'file.d.ts': input }, expected);
});
it('should keep stability annotations of exports in docstrings', () => {
const input = `
/**
* @deprecated This is useless now
*/
export declare class A {
}
/**
* @experimental
*/
export declare const b: string;
/**
* @stable
*/
export declare var c: number;
`;
const expected = `
/** @deprecated */
export declare class A {
}
/** @experimental */
export declare const b: string;
/** @stable */
export declare var c: number;
`;
check({ 'file.d.ts': input }, expected);
});
it('should keep stability annotations of fields in docstrings', () => {
const input = `
export declare class A {
/**
* @stable
*/
value: number;
/**
* @experimental
*/
constructor();
/**
* @deprecated
*/
foo(): void;
}
`;
const expected = `
export declare class A {
/** @stable */ value: number;
/** @experimental */ constructor();
/** @deprecated */ foo(): void;
}
`;
check({ 'file.d.ts': input }, expected);
});
it('should warn on onStabilityMissing: warn', () => {
const input = `
export declare class A {
constructor();
}
`;
const expected = `
export declare class A {
constructor();
}
`;
check({ 'file.d.ts': input }, expected, { onStabilityMissing: 'warn' });
chai.assert.deepEqual(
warnings, ['file.d.ts(1,1): error: No stability annotation found for symbol "A"']);
});
});
function getMockHost(files: { [name: string]: string }): ts.CompilerHost {
return {
getSourceFile: (sourceName, languageVersion) => {
if (!files[sourceName]) return undefined;
return ts.createSourceFile(
sourceName, stripExtraIndentation(files[sourceName]), languageVersion, true);
},
writeFile: (name, text, writeByteOrderMark) => { },
fileExists: (filename) => !!files[filename],
readFile: (filename) => stripExtraIndentation(files[filename]),
getDefaultLibFileName: () => 'lib.ts',
useCaseSensitiveFileNames: () => true,
getCanonicalFileName: (filename) => filename,
getCurrentDirectory: () => './',
getNewLine: () => '\n',
getDirectories: () => []
};
}
function check(
files: { [name: string]: string }, expected: string, options: SerializationOptions = {}) {
const actual = publicApiInternal(getMockHost(files), 'file.d.ts', {}, options);
chai.assert.equal(actual.trim(), stripExtraIndentation(expected).trim());
}
function checkThrows(files: { [name: string]: string }, error: string) {
chai.assert.throws(() => { publicApiInternal(getMockHost(files), 'file.d.ts', {}); }, error);
}
function stripExtraIndentation(text: string) {
let lines = text.split('\n');
// Ignore first and last new line
lines = lines.slice(1, lines.length - 1);
const commonIndent = lines.reduce((min, line) => {
const indent = /^( *)/.exec(line)[1].length;
// Ignore empty line
return line.length ? Math.min(min, indent) : min;
}, text.length);
return lines.map(line => line.substr(commonIndent)).join('\n') + '\n';
}