build: copy ts-api-guardian sources (#22544)
This is an exact mirror of 750f651eca
PR Close #22544
This commit is contained in:
parent
d7e5d45f43
commit
25faf808a5
|
@ -0,0 +1,3 @@
|
|||
# Typescript API Guardian
|
||||
|
||||
Keeps track of public API surface of a typescript library.
|
|
@ -0,0 +1,3 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
require('../build/lib/cli').startCli();
|
|
@ -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))
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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
|
||||
};
|
||||
}
|
|
@ -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'}
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
export declare enum Foo {
|
||||
Alpha = 0,
|
||||
Beta = 1,
|
||||
}
|
||||
|
||||
export interface Bar {
|
||||
field: Foo.Alpha,
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
export interface Bar {
|
||||
field: Foo.Alpha,
|
||||
}
|
||||
|
||||
export declare enum Foo {
|
||||
Alpha = 0,
|
||||
Beta = 1,
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export declare type SimpleChanges<T = any> = {
|
||||
[P in keyof T]?: any;
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
export declare type SimpleChanges<T = any> = {
|
||||
[P in keyof T]?: any;
|
||||
};
|
|
@ -0,0 +1,4 @@
|
|||
import * as foo from './somewhere';
|
||||
|
||||
export declare class A extends foo.Bar {
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
export declare class A extends foo.Bar {
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { A, B } from './simple';
|
|
@ -0,0 +1 @@
|
|||
export { A as Apple } from './classes_and_interfaces';
|
|
@ -0,0 +1 @@
|
|||
export { A } from './classes_and_interfaces';
|
|
@ -0,0 +1,4 @@
|
|||
export declare class A {
|
||||
field: string;
|
||||
method(a: string): number;
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export declare const A: string;
|
||||
|
||||
export declare var B: string;
|
|
@ -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';
|
|
@ -0,0 +1 @@
|
|||
export * from './simple';
|
|
@ -0,0 +1,3 @@
|
|||
export declare const A: string;
|
||||
|
||||
export declare var B: string;
|
|
@ -0,0 +1,2 @@
|
|||
export declare const A: string;
|
||||
export declare var B: string;
|
|
@ -0,0 +1,3 @@
|
|||
export declare const A: string;
|
||||
|
||||
export declare var B: string;
|
|
@ -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;
|
|
@ -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;
|
|
@ -0,0 +1,3 @@
|
|||
export {original_symbol as __private_symbol} from './stripped_alias_original';
|
||||
export class B {
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
export class B {
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export let original_symbol: number;
|
|
@ -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>;
|
||||
}
|
|
@ -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>;
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export const __a__: string;
|
||||
export class B {
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
export class B {
|
||||
}
|
|
@ -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;
|
|
@ -0,0 +1,5 @@
|
|||
export interface A {
|
||||
c: number;
|
||||
a(arg: any[]): {[name: string]: number};
|
||||
}
|
||||
export { b } from './verify_submodule';
|
|
@ -0,0 +1,5 @@
|
|||
export interface A {
|
||||
c: number;
|
||||
a(arg: any[]): any;
|
||||
b: string;
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export declare const b: boolean;
|
|
@ -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());
|
||||
}
|
|
@ -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());
|
||||
}
|
|
@ -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';
|
||||
}
|
Loading…
Reference in New Issue