From 25faf808a51ee547ec7787030d41c7b8b03eaf4f Mon Sep 17 00:00:00 2001 From: Alex Eagle Date: Fri, 2 Mar 2018 14:19:01 -0800 Subject: [PATCH] build: copy ts-api-guardian sources (#22544) This is an exact mirror of https://github.com/angular/ts-api-guardian/tree/750f651eca4d57a4a7ebcc12c19d0beea1a62202 PR Close #22544 --- tools/ts-api-guardian/README.md | 3 + tools/ts-api-guardian/bin/ts-api-guardian | 3 + tools/ts-api-guardian/lib/cli.ts | 189 +++++++ tools/ts-api-guardian/lib/main.ts | 37 ++ tools/ts-api-guardian/lib/serializer.ts | 338 ++++++++++++ tools/ts-api-guardian/package.json | 60 +++ tools/ts-api-guardian/test/cli_e2e_test.ts | 138 +++++ tools/ts-api-guardian/test/cli_unit_test.ts | 86 +++ .../test/fixtures/classes_and_interfaces.d.ts | 14 + .../classes_and_interfaces_expected.d.ts | 15 + .../ts-api-guardian/test/fixtures/empty.d.ts | 0 tools/ts-api-guardian/test/fixtures/empty.ts | 0 .../test/fixtures/empty_expected.d.ts | 0 .../test/fixtures/enum_as_type.d.ts | 8 + .../test/fixtures/enum_as_type_expected.d.ts | 8 + .../ts-api-guardian/test/fixtures/keyof.d.ts | 3 + .../test/fixtures/keyof_expected.d.ts | 3 + .../test/fixtures/module_identifier.d.ts | 4 + .../fixtures/module_identifier_expected.d.ts | 2 + .../test/fixtures/reexported.d.ts | 1 + .../test/fixtures/reexported_aliased.d.ts | 1 + .../test/fixtures/reexported_classes.d.ts | 1 + .../fixtures/reexported_classes_expected.d.ts | 4 + .../test/fixtures/reexported_expected.d.ts | 3 + .../test/fixtures/reexported_extern.d.ts | 5 + .../fixtures/reexported_extern_expected.d.ts | 0 .../test/fixtures/reexported_star.d.ts | 1 + .../fixtures/reexported_star_expected.d.ts | 3 + .../ts-api-guardian/test/fixtures/simple.d.ts | 2 + .../test/fixtures/simple_expected.d.ts | 3 + .../test/fixtures/sorting.d.ts | 11 + .../test/fixtures/sorting_expected.d.ts | 16 + .../test/fixtures/stripped_alias.d.ts | 3 + .../fixtures/stripped_alias_expected.d.ts | 2 + .../fixtures/stripped_alias_original.d.ts | 1 + .../test/fixtures/type_literals.d.ts | 7 + .../test/fixtures/type_literals_expected.d.ts | 7 + .../test/fixtures/underscored.d.ts | 3 + .../test/fixtures/underscored_expected.d.ts | 2 + .../test/fixtures/verify.patch | 11 + .../test/fixtures/verify_entrypoint.d.ts | 5 + .../test/fixtures/verify_expected.d.ts | 5 + .../test/fixtures/verify_submodule.d.ts | 1 + tools/ts-api-guardian/test/helpers.ts | 19 + .../ts-api-guardian/test/integration_test.ts | 132 +++++ tools/ts-api-guardian/test/unit_test.ts | 494 ++++++++++++++++++ 46 files changed, 1654 insertions(+) create mode 100644 tools/ts-api-guardian/README.md create mode 100755 tools/ts-api-guardian/bin/ts-api-guardian create mode 100644 tools/ts-api-guardian/lib/cli.ts create mode 100644 tools/ts-api-guardian/lib/main.ts create mode 100644 tools/ts-api-guardian/lib/serializer.ts create mode 100644 tools/ts-api-guardian/package.json create mode 100644 tools/ts-api-guardian/test/cli_e2e_test.ts create mode 100644 tools/ts-api-guardian/test/cli_unit_test.ts create mode 100644 tools/ts-api-guardian/test/fixtures/classes_and_interfaces.d.ts create mode 100644 tools/ts-api-guardian/test/fixtures/classes_and_interfaces_expected.d.ts create mode 100644 tools/ts-api-guardian/test/fixtures/empty.d.ts create mode 100644 tools/ts-api-guardian/test/fixtures/empty.ts create mode 100644 tools/ts-api-guardian/test/fixtures/empty_expected.d.ts create mode 100644 tools/ts-api-guardian/test/fixtures/enum_as_type.d.ts create mode 100644 tools/ts-api-guardian/test/fixtures/enum_as_type_expected.d.ts create mode 100644 tools/ts-api-guardian/test/fixtures/keyof.d.ts create mode 100644 tools/ts-api-guardian/test/fixtures/keyof_expected.d.ts create mode 100644 tools/ts-api-guardian/test/fixtures/module_identifier.d.ts create mode 100644 tools/ts-api-guardian/test/fixtures/module_identifier_expected.d.ts create mode 100644 tools/ts-api-guardian/test/fixtures/reexported.d.ts create mode 100644 tools/ts-api-guardian/test/fixtures/reexported_aliased.d.ts create mode 100644 tools/ts-api-guardian/test/fixtures/reexported_classes.d.ts create mode 100644 tools/ts-api-guardian/test/fixtures/reexported_classes_expected.d.ts create mode 100644 tools/ts-api-guardian/test/fixtures/reexported_expected.d.ts create mode 100644 tools/ts-api-guardian/test/fixtures/reexported_extern.d.ts create mode 100644 tools/ts-api-guardian/test/fixtures/reexported_extern_expected.d.ts create mode 100644 tools/ts-api-guardian/test/fixtures/reexported_star.d.ts create mode 100644 tools/ts-api-guardian/test/fixtures/reexported_star_expected.d.ts create mode 100644 tools/ts-api-guardian/test/fixtures/simple.d.ts create mode 100644 tools/ts-api-guardian/test/fixtures/simple_expected.d.ts create mode 100644 tools/ts-api-guardian/test/fixtures/sorting.d.ts create mode 100644 tools/ts-api-guardian/test/fixtures/sorting_expected.d.ts create mode 100644 tools/ts-api-guardian/test/fixtures/stripped_alias.d.ts create mode 100644 tools/ts-api-guardian/test/fixtures/stripped_alias_expected.d.ts create mode 100644 tools/ts-api-guardian/test/fixtures/stripped_alias_original.d.ts create mode 100644 tools/ts-api-guardian/test/fixtures/type_literals.d.ts create mode 100644 tools/ts-api-guardian/test/fixtures/type_literals_expected.d.ts create mode 100644 tools/ts-api-guardian/test/fixtures/underscored.d.ts create mode 100644 tools/ts-api-guardian/test/fixtures/underscored_expected.d.ts create mode 100644 tools/ts-api-guardian/test/fixtures/verify.patch create mode 100644 tools/ts-api-guardian/test/fixtures/verify_entrypoint.d.ts create mode 100644 tools/ts-api-guardian/test/fixtures/verify_expected.d.ts create mode 100644 tools/ts-api-guardian/test/fixtures/verify_submodule.d.ts create mode 100644 tools/ts-api-guardian/test/helpers.ts create mode 100644 tools/ts-api-guardian/test/integration_test.ts create mode 100644 tools/ts-api-guardian/test/unit_test.ts diff --git a/tools/ts-api-guardian/README.md b/tools/ts-api-guardian/README.md new file mode 100644 index 0000000000..0de5176121 --- /dev/null +++ b/tools/ts-api-guardian/README.md @@ -0,0 +1,3 @@ +# Typescript API Guardian + +Keeps track of public API surface of a typescript library. diff --git a/tools/ts-api-guardian/bin/ts-api-guardian b/tools/ts-api-guardian/bin/ts-api-guardian new file mode 100755 index 0000000000..4dc9563ac2 --- /dev/null +++ b/tools/ts-api-guardian/bin/ts-api-guardian @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +require('../build/lib/cli').startCli(); diff --git a/tools/ts-api-guardian/lib/cli.ts b/tools/ts-api-guardian/lib/cli.ts new file mode 100644 index 0000000000..8b0c513e92 --- /dev/null +++ b/tools/ts-api-guardian/lib/cli.ts @@ -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] + ${CMD} --out + ${CMD} --outDir [--rootDir .] + + ${CMD} --verify + ${CMD} --verifyDir [--rootDir .] + +Options: + --help Show this usage message + + --out Write golden output to file + --outDir Write golden file structure to directory + + --verify Read golden input from file + --verifyDir Read golden file structure from directory + + --rootDir Specify the root directory of input files + + --stripExportPattern Do not output exports matching the pattern + --allowModuleIdentifiers + Whitelist identifier for "* as foo" imports + --onStabilityMissing + 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)) + }; + }); + } +} diff --git a/tools/ts-api-guardian/lib/main.ts b/tools/ts-api-guardian/lib/main.ts new file mode 100644 index 0000000000..86aa5f86f7 --- /dev/null +++ b/tools/ts-api-guardian/lib/main.ts @@ -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); + } +} diff --git a/tools/ts-api-guardian/lib/serializer.ts b/tools/ts-api-guardian/lib/serializer.ts new file mode 100644 index 0000000000..bb9bda2665 --- /dev/null +++ b/tools/ts-api-guardian/lib/serializer.ts @@ -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 = (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(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 = (lhs).expression; + } while (lhs && lhs.kind !== ts.SyntaxKind.Identifier); + + return lhs; + } + case ts.SyntaxKind.TypeReference: { + // For type position + let lhs: ts.Node = (node).typeName; + do { + lhs = (lhs).left; + } while (lhs && lhs.kind !== ts.SyntaxKind.Identifier); + + return 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 = ''; + } + + return `${position}: error: ${message}`; +} + +function hasModifier(node: ts.Node, modifierKind: ts.SyntaxKind): boolean { + return !!node.modifiers && node.modifiers.some(x => x.kind === modifierKind); +} diff --git a/tools/ts-api-guardian/package.json b/tools/ts-api-guardian/package.json new file mode 100644 index 0000000000..7873bf0494 --- /dev/null +++ b/tools/ts-api-guardian/package.json @@ -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 (https://github.com/alan-agius4/)", + "Alex Eagle (https://angular.io/)", + "Martin Probst (https://angular.io/)", + "Victor Savkin (https://victorsavkin.com)", + "Igor Minar (https://angular.io/)" + ], + "license": "MIT", + "bugs": { + "url": "https://github.com/angular/ts-api-guardian/issues" + }, + "homepage": "https://github.com/angular/ts-api-guardian" +} diff --git a/tools/ts-api-guardian/test/cli_e2e_test.ts b/tools/ts-api-guardian/test/cli_e2e_test.ts new file mode 100644 index 0000000000..81fb4b10b6 --- /dev/null +++ b/tools/ts-api-guardian/test/cli_e2e_test.ts @@ -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 + }; +} diff --git a/tools/ts-api-guardian/test/cli_unit_test.ts b/tools/ts-api-guardian/test/cli_unit_test.ts new file mode 100644 index 0000000000..8e55cf476a --- /dev/null +++ b/tools/ts-api-guardian/test/cli_unit_test.ts @@ -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'} + ]); + }); +}); diff --git a/tools/ts-api-guardian/test/fixtures/classes_and_interfaces.d.ts b/tools/ts-api-guardian/test/fixtures/classes_and_interfaces.d.ts new file mode 100644 index 0000000000..485e4ef31a --- /dev/null +++ b/tools/ts-api-guardian/test/fixtures/classes_and_interfaces.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); +} diff --git a/tools/ts-api-guardian/test/fixtures/classes_and_interfaces_expected.d.ts b/tools/ts-api-guardian/test/fixtures/classes_and_interfaces_expected.d.ts new file mode 100644 index 0000000000..6c37af1a01 --- /dev/null +++ b/tools/ts-api-guardian/test/fixtures/classes_and_interfaces_expected.d.ts @@ -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); +} diff --git a/tools/ts-api-guardian/test/fixtures/empty.d.ts b/tools/ts-api-guardian/test/fixtures/empty.d.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tools/ts-api-guardian/test/fixtures/empty.ts b/tools/ts-api-guardian/test/fixtures/empty.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tools/ts-api-guardian/test/fixtures/empty_expected.d.ts b/tools/ts-api-guardian/test/fixtures/empty_expected.d.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tools/ts-api-guardian/test/fixtures/enum_as_type.d.ts b/tools/ts-api-guardian/test/fixtures/enum_as_type.d.ts new file mode 100644 index 0000000000..db4a3c65e7 --- /dev/null +++ b/tools/ts-api-guardian/test/fixtures/enum_as_type.d.ts @@ -0,0 +1,8 @@ +export declare enum Foo { + Alpha = 0, + Beta = 1, +} + +export interface Bar { + field: Foo.Alpha, +} \ No newline at end of file diff --git a/tools/ts-api-guardian/test/fixtures/enum_as_type_expected.d.ts b/tools/ts-api-guardian/test/fixtures/enum_as_type_expected.d.ts new file mode 100644 index 0000000000..63e8c6ba32 --- /dev/null +++ b/tools/ts-api-guardian/test/fixtures/enum_as_type_expected.d.ts @@ -0,0 +1,8 @@ +export interface Bar { + field: Foo.Alpha, +} + +export declare enum Foo { + Alpha = 0, + Beta = 1, +} diff --git a/tools/ts-api-guardian/test/fixtures/keyof.d.ts b/tools/ts-api-guardian/test/fixtures/keyof.d.ts new file mode 100644 index 0000000000..c2b6c1749c --- /dev/null +++ b/tools/ts-api-guardian/test/fixtures/keyof.d.ts @@ -0,0 +1,3 @@ +export declare type SimpleChanges = { + [P in keyof T]?: any; +}; \ No newline at end of file diff --git a/tools/ts-api-guardian/test/fixtures/keyof_expected.d.ts b/tools/ts-api-guardian/test/fixtures/keyof_expected.d.ts new file mode 100644 index 0000000000..b044702368 --- /dev/null +++ b/tools/ts-api-guardian/test/fixtures/keyof_expected.d.ts @@ -0,0 +1,3 @@ +export declare type SimpleChanges = { + [P in keyof T]?: any; +}; diff --git a/tools/ts-api-guardian/test/fixtures/module_identifier.d.ts b/tools/ts-api-guardian/test/fixtures/module_identifier.d.ts new file mode 100644 index 0000000000..fa50910435 --- /dev/null +++ b/tools/ts-api-guardian/test/fixtures/module_identifier.d.ts @@ -0,0 +1,4 @@ +import * as foo from './somewhere'; + +export declare class A extends foo.Bar { +} diff --git a/tools/ts-api-guardian/test/fixtures/module_identifier_expected.d.ts b/tools/ts-api-guardian/test/fixtures/module_identifier_expected.d.ts new file mode 100644 index 0000000000..174b833508 --- /dev/null +++ b/tools/ts-api-guardian/test/fixtures/module_identifier_expected.d.ts @@ -0,0 +1,2 @@ +export declare class A extends foo.Bar { +} diff --git a/tools/ts-api-guardian/test/fixtures/reexported.d.ts b/tools/ts-api-guardian/test/fixtures/reexported.d.ts new file mode 100644 index 0000000000..b583f44e4f --- /dev/null +++ b/tools/ts-api-guardian/test/fixtures/reexported.d.ts @@ -0,0 +1 @@ +export { A, B } from './simple'; diff --git a/tools/ts-api-guardian/test/fixtures/reexported_aliased.d.ts b/tools/ts-api-guardian/test/fixtures/reexported_aliased.d.ts new file mode 100644 index 0000000000..058677bf1b --- /dev/null +++ b/tools/ts-api-guardian/test/fixtures/reexported_aliased.d.ts @@ -0,0 +1 @@ +export { A as Apple } from './classes_and_interfaces'; diff --git a/tools/ts-api-guardian/test/fixtures/reexported_classes.d.ts b/tools/ts-api-guardian/test/fixtures/reexported_classes.d.ts new file mode 100644 index 0000000000..01a7a03a10 --- /dev/null +++ b/tools/ts-api-guardian/test/fixtures/reexported_classes.d.ts @@ -0,0 +1 @@ +export { A } from './classes_and_interfaces'; diff --git a/tools/ts-api-guardian/test/fixtures/reexported_classes_expected.d.ts b/tools/ts-api-guardian/test/fixtures/reexported_classes_expected.d.ts new file mode 100644 index 0000000000..b8c5750924 --- /dev/null +++ b/tools/ts-api-guardian/test/fixtures/reexported_classes_expected.d.ts @@ -0,0 +1,4 @@ +export declare class A { + field: string; + method(a: string): number; +} diff --git a/tools/ts-api-guardian/test/fixtures/reexported_expected.d.ts b/tools/ts-api-guardian/test/fixtures/reexported_expected.d.ts new file mode 100644 index 0000000000..7020ff3c3f --- /dev/null +++ b/tools/ts-api-guardian/test/fixtures/reexported_expected.d.ts @@ -0,0 +1,3 @@ +export declare const A: string; + +export declare var B: string; diff --git a/tools/ts-api-guardian/test/fixtures/reexported_extern.d.ts b/tools/ts-api-guardian/test/fixtures/reexported_extern.d.ts new file mode 100644 index 0000000000..0c0d859899 --- /dev/null +++ b/tools/ts-api-guardian/test/fixtures/reexported_extern.d.ts @@ -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'; diff --git a/tools/ts-api-guardian/test/fixtures/reexported_extern_expected.d.ts b/tools/ts-api-guardian/test/fixtures/reexported_extern_expected.d.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tools/ts-api-guardian/test/fixtures/reexported_star.d.ts b/tools/ts-api-guardian/test/fixtures/reexported_star.d.ts new file mode 100644 index 0000000000..882a36dbe8 --- /dev/null +++ b/tools/ts-api-guardian/test/fixtures/reexported_star.d.ts @@ -0,0 +1 @@ +export * from './simple'; diff --git a/tools/ts-api-guardian/test/fixtures/reexported_star_expected.d.ts b/tools/ts-api-guardian/test/fixtures/reexported_star_expected.d.ts new file mode 100644 index 0000000000..7020ff3c3f --- /dev/null +++ b/tools/ts-api-guardian/test/fixtures/reexported_star_expected.d.ts @@ -0,0 +1,3 @@ +export declare const A: string; + +export declare var B: string; diff --git a/tools/ts-api-guardian/test/fixtures/simple.d.ts b/tools/ts-api-guardian/test/fixtures/simple.d.ts new file mode 100644 index 0000000000..191760e7a0 --- /dev/null +++ b/tools/ts-api-guardian/test/fixtures/simple.d.ts @@ -0,0 +1,2 @@ +export declare const A: string; +export declare var B: string; diff --git a/tools/ts-api-guardian/test/fixtures/simple_expected.d.ts b/tools/ts-api-guardian/test/fixtures/simple_expected.d.ts new file mode 100644 index 0000000000..7020ff3c3f --- /dev/null +++ b/tools/ts-api-guardian/test/fixtures/simple_expected.d.ts @@ -0,0 +1,3 @@ +export declare const A: string; + +export declare var B: string; diff --git a/tools/ts-api-guardian/test/fixtures/sorting.d.ts b/tools/ts-api-guardian/test/fixtures/sorting.d.ts new file mode 100644 index 0000000000..58b42c6515 --- /dev/null +++ b/tools/ts-api-guardian/test/fixtures/sorting.d.ts @@ -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; diff --git a/tools/ts-api-guardian/test/fixtures/sorting_expected.d.ts b/tools/ts-api-guardian/test/fixtures/sorting_expected.d.ts new file mode 100644 index 0000000000..d3921f8d91 --- /dev/null +++ b/tools/ts-api-guardian/test/fixtures/sorting_expected.d.ts @@ -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; diff --git a/tools/ts-api-guardian/test/fixtures/stripped_alias.d.ts b/tools/ts-api-guardian/test/fixtures/stripped_alias.d.ts new file mode 100644 index 0000000000..df39a7eb0d --- /dev/null +++ b/tools/ts-api-guardian/test/fixtures/stripped_alias.d.ts @@ -0,0 +1,3 @@ +export {original_symbol as __private_symbol} from './stripped_alias_original'; +export class B { +} \ No newline at end of file diff --git a/tools/ts-api-guardian/test/fixtures/stripped_alias_expected.d.ts b/tools/ts-api-guardian/test/fixtures/stripped_alias_expected.d.ts new file mode 100644 index 0000000000..94fa3c9c47 --- /dev/null +++ b/tools/ts-api-guardian/test/fixtures/stripped_alias_expected.d.ts @@ -0,0 +1,2 @@ +export class B { +} diff --git a/tools/ts-api-guardian/test/fixtures/stripped_alias_original.d.ts b/tools/ts-api-guardian/test/fixtures/stripped_alias_original.d.ts new file mode 100644 index 0000000000..76536ae835 --- /dev/null +++ b/tools/ts-api-guardian/test/fixtures/stripped_alias_original.d.ts @@ -0,0 +1 @@ +export let original_symbol: number; \ No newline at end of file diff --git a/tools/ts-api-guardian/test/fixtures/type_literals.d.ts b/tools/ts-api-guardian/test/fixtures/type_literals.d.ts new file mode 100644 index 0000000000..1a2e41b591 --- /dev/null +++ b/tools/ts-api-guardian/test/fixtures/type_literals.d.ts @@ -0,0 +1,7 @@ +export class UsesTypeLiterals { + a: number | undefined; + b: number | null; + c: number | true; + d: number | null | undefined; + e: Array; +} \ No newline at end of file diff --git a/tools/ts-api-guardian/test/fixtures/type_literals_expected.d.ts b/tools/ts-api-guardian/test/fixtures/type_literals_expected.d.ts new file mode 100644 index 0000000000..ce70fa6fa3 --- /dev/null +++ b/tools/ts-api-guardian/test/fixtures/type_literals_expected.d.ts @@ -0,0 +1,7 @@ +export class UsesTypeLiterals { + a: number | undefined; + b: number | null; + c: number | true; + d: number | null | undefined; + e: Array; +} diff --git a/tools/ts-api-guardian/test/fixtures/underscored.d.ts b/tools/ts-api-guardian/test/fixtures/underscored.d.ts new file mode 100644 index 0000000000..ee6bfd8eab --- /dev/null +++ b/tools/ts-api-guardian/test/fixtures/underscored.d.ts @@ -0,0 +1,3 @@ +export const __a__: string; +export class B { +} diff --git a/tools/ts-api-guardian/test/fixtures/underscored_expected.d.ts b/tools/ts-api-guardian/test/fixtures/underscored_expected.d.ts new file mode 100644 index 0000000000..94fa3c9c47 --- /dev/null +++ b/tools/ts-api-guardian/test/fixtures/underscored_expected.d.ts @@ -0,0 +1,2 @@ +export class B { +} diff --git a/tools/ts-api-guardian/test/fixtures/verify.patch b/tools/ts-api-guardian/test/fixtures/verify.patch new file mode 100644 index 0000000000..bb23ff2ee2 --- /dev/null +++ b/tools/ts-api-guardian/test/fixtures/verify.patch @@ -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; diff --git a/tools/ts-api-guardian/test/fixtures/verify_entrypoint.d.ts b/tools/ts-api-guardian/test/fixtures/verify_entrypoint.d.ts new file mode 100644 index 0000000000..07a6c15ae8 --- /dev/null +++ b/tools/ts-api-guardian/test/fixtures/verify_entrypoint.d.ts @@ -0,0 +1,5 @@ +export interface A { + c: number; + a(arg: any[]): {[name: string]: number}; +} +export { b } from './verify_submodule'; diff --git a/tools/ts-api-guardian/test/fixtures/verify_expected.d.ts b/tools/ts-api-guardian/test/fixtures/verify_expected.d.ts new file mode 100644 index 0000000000..c7d5188ec4 --- /dev/null +++ b/tools/ts-api-guardian/test/fixtures/verify_expected.d.ts @@ -0,0 +1,5 @@ +export interface A { + c: number; + a(arg: any[]): any; + b: string; +} diff --git a/tools/ts-api-guardian/test/fixtures/verify_submodule.d.ts b/tools/ts-api-guardian/test/fixtures/verify_submodule.d.ts new file mode 100644 index 0000000000..a9f8f857a2 --- /dev/null +++ b/tools/ts-api-guardian/test/fixtures/verify_submodule.d.ts @@ -0,0 +1 @@ +export declare const b: boolean; diff --git a/tools/ts-api-guardian/test/helpers.ts b/tools/ts-api-guardian/test/helpers.ts new file mode 100644 index 0000000000..c97fdb7860 --- /dev/null +++ b/tools/ts-api-guardian/test/helpers.ts @@ -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()); +} diff --git a/tools/ts-api-guardian/test/integration_test.ts b/tools/ts-api-guardian/test/integration_test.ts new file mode 100644 index 0000000000..1b836e604b --- /dev/null +++ b/tools/ts-api-guardian/test/integration_test.ts @@ -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()); +} diff --git a/tools/ts-api-guardian/test/unit_test.ts b/tools/ts-api-guardian/test/unit_test.ts new file mode 100644 index 0000000000..36bc8f199f --- /dev/null +++ b/tools/ts-api-guardian/test/unit_test.ts @@ -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'; +}