From 6379fcf00a013f3695d18b5281888f671424e37a Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Mon, 21 Mar 2022 16:37:52 +0100 Subject: [PATCH] feat(bazel): api-golden test should leverage package exports information for finding entries Instead of looking for nested `package.json` files which currently only exist for workaround-reasons in APF v13, we should consult the package NodeJS exports. This will help with: https://github.com/angular/angular/pull/45405. --- bazel/api-golden/find_entry_points.ts | 56 +++++++++++--------------- bazel/api-golden/index_npm_packages.ts | 16 ++++---- bazel/api-golden/test_api_report.ts | 22 ++++++---- 3 files changed, 46 insertions(+), 48 deletions(-) diff --git a/bazel/api-golden/find_entry_points.ts b/bazel/api-golden/find_entry_points.ts index 3343639fe..44c89244c 100644 --- a/bazel/api-golden/find_entry_points.ts +++ b/bazel/api-golden/find_entry_points.ts @@ -6,57 +6,47 @@ * found in the LICENSE file at https://angular.io/license */ -import {lstatSync, readdirSync, readFileSync} from 'fs'; -import {dirname, join} from 'path'; +import {join, normalize} from 'path'; + +import {readFileSync} from 'fs'; /** Interface describing a resolved NPM package entry point. */ export interface PackageEntryPoint { typesEntryPointPath: string; - packageJsonPath: string; + subpath: string; + moduleName: string; } /** Interface describing contents of a `package.json`. */ interface PackageJson { - types?: string; - typings?: string; + name: string; + exports?: Record; } /** Finds all entry points within a given NPM package directory. */ -export function findEntryPointsWithinNpmPackage(dirPath: string): PackageEntryPoint[] { +export function findEntryPointsWithinNpmPackage( + dirPath: string, + packageJsonPath: string, +): PackageEntryPoint[] { + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as PackageJson; const entryPoints: PackageEntryPoint[] = []; - for (const packageJsonFilePath of findPackageJsonFilesInDirectory(dirPath)) { - const packageJson = JSON.parse(readFileSync(packageJsonFilePath, 'utf8')) as PackageJson; - const typesFile = packageJson.types || packageJson.typings; + if (packageJson.exports === undefined) { + throw new Error( + `Expected top-level "package.json" in "${dirPath}" to declare entry-points ` + + `through conditional exports.`, + ); + } - if (typesFile) { + for (const [subpath, conditions] of Object.entries(packageJson.exports)) { + if (conditions.types !== undefined) { entryPoints.push({ - packageJsonPath: packageJsonFilePath, - typesEntryPointPath: join(dirname(packageJsonFilePath), typesFile), + subpath, + moduleName: normalize(join(packageJson.name, subpath)).replace(/\\/g, '/'), + typesEntryPointPath: join(dirPath, conditions.types), }); } } return entryPoints; } - -/** Determine if the provided path is a directory. */ -function isDirectory(dirPath: string) { - try { - return lstatSync(dirPath).isDirectory(); - } catch { - return false; - } -} - -/** Finds all `package.json` files within a directory. */ -function* findPackageJsonFilesInDirectory(directoryPath: string): IterableIterator { - for (const fileName of readdirSync(directoryPath)) { - const fullPath = join(directoryPath, fileName); - if (isDirectory(fullPath)) { - yield* findPackageJsonFilesInDirectory(fullPath); - } else if (fileName === 'package.json') { - yield fullPath; - } - } -} diff --git a/bazel/api-golden/index_npm_packages.ts b/bazel/api-golden/index_npm_packages.ts index db0022376..1b8340f23 100644 --- a/bazel/api-golden/index_npm_packages.ts +++ b/bazel/api-golden/index_npm_packages.ts @@ -6,11 +6,11 @@ * found in the LICENSE file at https://angular.io/license */ -import {runfiles} from '@bazel/runfiles'; import * as chalk from 'chalk'; -import {join, relative} from 'path'; import {findEntryPointsWithinNpmPackage} from './find_entry_points'; +import {join} from 'path'; +import {runfiles} from '@bazel/runfiles'; import {testApiGolden} from './test_api_report'; /** @@ -25,18 +25,17 @@ async function main( stripExportPattern: RegExp, typeNames: string[], ) { - const entryPoints = findEntryPointsWithinNpmPackage(npmPackageDir); + const packageJsonPath = join(npmPackageDir, 'package.json'); + const entryPoints = findEntryPointsWithinNpmPackage(npmPackageDir, packageJsonPath); const outdatedGoldens: string[] = []; let allTestsSucceeding = true; - for (const {packageJsonPath, typesEntryPointPath} of entryPoints) { - const pkgRelativeName = relative(npmPackageDir, typesEntryPointPath); + for (const {subpath, moduleName, typesEntryPointPath} of entryPoints) { // API extractor generates API reports as markdown files. For each types // entry-point we maintain a separate golden file. These golden files are - // based on the name of the entry-point `.d.ts` file in the NPM package, - // but with the proper `.md` file extension. + // based on the name of the defining NodeJS exports subpath in the NPM package, // See: https://api-extractor.com/pages/overview/demo_api_report/. - const goldenName = pkgRelativeName.replace(/\.d\.ts$/, '.md'); + const goldenName = join(subpath, 'index.md'); const goldenFilePath = join(goldenDir, goldenName); const {succeeded, apiReportChanged} = await testApiGolden( @@ -46,6 +45,7 @@ async function main( stripExportPattern, typeNames, packageJsonPath, + moduleName, ); // Keep track of outdated goldens. diff --git a/bazel/api-golden/test_api_report.ts b/bazel/api-golden/test_api_report.ts index 3d6206c67..2c9356892 100644 --- a/bazel/api-golden/test_api_report.ts +++ b/bazel/api-golden/test_api_report.ts @@ -6,7 +6,6 @@ * found in the LICENSE file at https://angular.io/license */ -import {runfiles} from '@bazel/runfiles'; import { ConsoleMessageId, Extractor, @@ -17,9 +16,11 @@ import { ExtractorResult, IConfigFile, } from '@microsoft/api-extractor'; +import {basename, dirname} from 'path'; + import {AstModule} from '@microsoft/api-extractor/lib/analyzer/AstModule'; import {ExportAnalyzer} from '@microsoft/api-extractor/lib/analyzer/ExportAnalyzer'; -import {basename, dirname} from 'path'; +import {runfiles} from '@bazel/runfiles'; /** * Original definition of the `ExportAnalyzer#fetchAstModuleExportInfo` method. @@ -42,6 +43,9 @@ const _origFetchAstModuleExportInfo = ExportAnalyzer.prototype.fetchAstModuleExp * @param packageJsonPath Optional path to a `package.json` file that contains the entry * point. Note that the `package.json` is currently only used by `api-extractor` to determine * the package name displayed within the API golden. + * @param customPackageName A custom package name to be provided for the API report. This can be + * useful when the specified `package.json` is describing the whole package but the API report + * is scoped to a specific subpath/entry-point. */ export async function testApiGolden( goldenFilePath: string, @@ -50,6 +54,7 @@ export async function testApiGolden( stripExportPattern: RegExp, typeNames: string[] = [], packageJsonPath = resolveWorkspacePackageJsonPath(), + customPackageName?: string, ): Promise { // If no `TEST_TMPDIR` is defined, then this script runs using `bazel run`. We use // the runfile directory as temporary directory for API extractor. @@ -90,11 +95,14 @@ export async function testApiGolden( // compatible with the API extractor. This is a workaround for a bug in api-extractor. // TODO remove once https://github.com/microsoft/rushstack/issues/2774 is resolved. const packageJson = require(packageJsonPath); - const packageNameSegments = packageJson.name.split('/'); - const packageName = - packageNameSegments.length === 1 - ? packageNameSegments[0] - : `${packageNameSegments[0]}/${packageNameSegments.slice(1).join('_')}`; + const packageNameSegments = (customPackageName ?? packageJson.name).split('/'); + const isScopedPackage = packageNameSegments[0][0] === '@' && packageNameSegments.length > 1; + // API extractor allows one-slash when the package uses the scoped-package convention. + const slashConversionStartIndex = isScopedPackage ? 1 : 0; + const normalizedRest = packageNameSegments.slice(slashConversionStartIndex).join('_'); + const packageName = isScopedPackage + ? `${packageNameSegments[0]}/${normalizedRest}` + : normalizedRest; const extractorConfig = ExtractorConfig.prepare({ configObject,