Files
react-native/scripts/build-types/BuildApiSnapshot.js
T
Dawid MałeckiandFacebook GitHub Bot f529fd6ba5 Add snapshot validation to build-types (#51893)
Summary:
Pull Request resolved: https://github.com/facebook/react-native/pull/51893

This diff adds `--validate` flag that runs snapshot validation to determine if the `ReactNativeApi.d.ts` rollup has been changed (if JS public API has been touched). There was also an issue with `sortProperties` that reordered some properties (ex. in ImagePropsBase) after removing one of them (ex. accessible) which had negative impact on the displayed result.

### Motivation
Compare previous snapshot with the one built on the current revision to determine the impact of made changes on the public API surface. Display differences in human readable format using `diff` method from the `jest-diff` library.

For now `--validate` flag is not useful on its own. It should be used with `--withSnapshot` flag (which will be removed shortly and generating snapshot will be a default mechanism).

Changelog:
[General][Added] - Add `--validate` flag to `build-types` script for JS API snapshot validation.

Reviewed By: huntie

Differential Revision: D76135158

fbshipit-source-id: 53f5b142c66e3e3931961f741c3f2fab8ccdc228
2025-06-11 09:06:39 -07:00

292 lines
8.3 KiB
JavaScript

/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
* @format
* @oncall react_native
*/
import type {PluginObj} from '@babel/core';
const {PACKAGES_DIR, REACT_NATIVE_PACKAGE_DIR} = require('../consts');
const {API_EXTRACTOR_CONFIG_FILE, TYPES_OUTPUT_DIR} = require('./config');
const apiSnapshotTemplate = require('./templates/ReactNativeApi.d.ts-template.js');
const resolveCyclicImportsInDefinition = require('./transforms/resolveCyclicImportsInDefinition');
const babel = require('@babel/core');
const {
Extractor,
ExtractorConfig,
// $FlowFixMe[cannot-resolve-module]
} = require('@microsoft/api-extractor');
const {promises: fs} = require('fs');
const glob = require('glob');
const {diff} = require('jest-diff');
const path = require('path');
const prettier = require('prettier');
const osTempDir = require('temp-dir');
const {styleText} = require('util');
const inputFilesPostTransforms: $ReadOnlyArray<PluginObj<mixed>> = [
require('./transforms/renameDefaultExportedIdentifiers'),
];
const postTransforms: $ReadOnlyArray<PluginObj<mixed>> = [
require('./transforms/sortTypeDefinitions'),
require('./transforms/sortProperties'),
require('./transforms/sortUnions'),
];
async function buildAPISnapshot(validate: boolean) {
const tempDirectory = await createTempDir('react-native-js-api-snapshot');
const packages = await findPackagesWithTypedef();
await preparePackagesInTempDir(tempDirectory, packages);
await rewriteLocalImports(tempDirectory, packages);
const extractorConfig = ExtractorConfig.loadFileAndPrepare(
path.join(tempDirectory, API_EXTRACTOR_CONFIG_FILE),
);
const extractorResult = Extractor.invoke(extractorConfig, {
localBuild: true,
showVerboseMessages: true,
});
if (extractorResult.succeeded) {
const apiSnapshot = apiSnapshotTemplate(
await getCleanedUpRollup(tempDirectory),
) as string;
if (validate) {
const prevSnapshot = await fs.readFile(
path.join(REACT_NATIVE_PACKAGE_DIR, 'ReactNativeApi.d.ts'),
'utf-8',
);
const hasChanged = await validateSnapshots(prevSnapshot, apiSnapshot);
if (hasChanged) {
process.exitCode = 1;
}
} else {
await fs.writeFile(
path.join(REACT_NATIVE_PACKAGE_DIR, 'ReactNativeApi.d.ts'),
apiSnapshot,
);
}
} else {
process.exitCode = 1;
console.error(
`API Extractor failed with ${extractorResult.errorCount} errors` +
` and ${extractorResult.warningCount} warnings`,
);
}
await fs.rm(tempDirectory, {recursive: true});
}
async function validateSnapshots(
prevSnapshot: string,
newSnapshot: string,
): Promise<boolean> {
const hasChanged = newSnapshot !== prevSnapshot;
if (hasChanged) {
const options = {
aAnnotation: 'Previous Snapshot',
bAnnotation: 'New Snapshot',
expand: false,
emptyFirstOrLastLinePlaceholder: '↵',
includeChangeCounts: true,
aColor: (line: string) => styleText(['red'], line),
bColor: (line: string) => styleText(['green'], line),
};
const diffResult = diff(prevSnapshot, newSnapshot, options);
console.error(
`\n${styleText(['inverse'], ' VALIDATE ')} ReactNativeApi.d.ts has changed. Please re-run \`yarn build-types\` and commit the updated snapshot.\n\n`,
diffResult,
);
}
return hasChanged;
}
async function findPackagesWithTypedef() {
const packagesWithGeneratedTypes = glob
.sync(`${PACKAGES_DIR}/**/types_generated`, {nodir: false})
.map(typesPath =>
path.relative(PACKAGES_DIR, typesPath).split('/').slice(0, -1).join('/'),
);
const packagesWithNames = await Promise.all(
packagesWithGeneratedTypes.map(async pkg => {
const packageJsonContent = await fs.readFile(
path.join(PACKAGES_DIR, pkg, 'package.json'),
'utf-8',
);
const packageJson = JSON.parse(packageJsonContent);
return {
directory: pkg,
name: packageJson.name as string,
};
}),
);
return packagesWithNames;
}
async function preparePackagesInTempDir(
tempDirectory: string,
packages: $ReadOnlyArray<{directory: string, name: string}>,
) {
await generateConfigFiles(tempDirectory);
await Promise.all(
packages.map(async pkg => {
await copyDirectory(
path.join(PACKAGES_DIR, pkg.directory, TYPES_OUTPUT_DIR),
path.join(tempDirectory, pkg.directory),
);
}),
);
const typeDefs = glob.sync(`${tempDirectory}/**/*.d.ts`);
await Promise.all(
typeDefs.map(async file => {
const source = await fs.readFile(file, 'utf-8');
const transformed = await applyPostTransforms(
source,
inputFilesPostTransforms,
);
await fs.writeFile(file, transformed);
}),
);
}
/**
* Rewrite imports to local packages in the temp directory. We do this to
* avoid cyclic references, which API Extractor cannot process.
*/
async function rewriteLocalImports(
tempDirectory: string,
packages: $ReadOnlyArray<{directory: string, name: string}>,
) {
const definitions = glob.sync(`${tempDirectory}/**/*.d.ts`);
await Promise.all(
definitions.map(async file => {
const source = await fs.readFile(file, 'utf-8');
const fixedImports = await resolveCyclicImportsInDefinition({
packages: packages,
rootPath: tempDirectory,
sourcePath: file,
source: source,
});
await fs.writeFile(file, fixedImports);
}),
);
}
async function getCleanedUpRollup(tempDirectory: string) {
const rollupPath = path.join(
tempDirectory,
'react-native',
'dist',
'api-rollup.d.ts',
);
const sourceRollup = await fs.readFile(rollupPath, 'utf-8');
const cleanedRollup = sourceRollup
.replace(/\/\*[\s\S]*?\*\//gm, '') // Remove block comments
.replace(/\\\\.*$/gm, '') // Remove inline comments
.replace(/^\s+$/gm, '') // Clear whitespace-only lines
.replace(/\n+/gm, '\n'); // Collapse empty lines
const transformedRollup = await applyPostTransforms(
cleanedRollup,
postTransforms,
);
const formattedRollup = prettier.format(transformedRollup, {
parser: 'typescript',
});
return formattedRollup;
}
async function applyPostTransforms(
inSrc: string,
transforms: $ReadOnlyArray<PluginObj<mixed>>,
): Promise<string> {
const result = await babel.transformAsync(inSrc, {
plugins: ['@babel/plugin-syntax-typescript', ...transforms],
});
return result.code;
}
async function generateConfigFiles(tempDirectory: string) {
// generate tsconfig
const tsConfig = {
$schema: 'http://json.schemastore.org/tsconfig',
};
const outPath = path.join(tempDirectory, 'tsconfig.json');
await fs.mkdir(path.dirname(outPath), {recursive: true});
await fs.writeFile(outPath, JSON.stringify(tsConfig, null, 2));
// generate api extractor config
const apiExtractorConfig = await fs.readFile(
path.join(__dirname, 'templates', API_EXTRACTOR_CONFIG_FILE),
'utf-8',
);
const adjustedApiExtractorConfig = apiExtractorConfig.replaceAll(
'${typegen_directory}',
tempDirectory,
);
await fs.writeFile(
path.join(tempDirectory, API_EXTRACTOR_CONFIG_FILE),
adjustedApiExtractorConfig,
);
// generate basic package.json
const packageJSON = {name: 'react-native'};
await fs.writeFile(
path.join(tempDirectory, 'package.json'),
JSON.stringify(packageJSON, null, 2),
);
}
async function copyDirectory(src: string, dest: string) {
await fs.mkdir(dest, {recursive: true});
const entries = await fs.readdir(src, {withFileTypes: true});
for (let entry of entries) {
// name can only be a buffer when explicitly set as the encoding option
const fileName: string = entry.name as any;
const srcPath = path.join(src, fileName);
const destPath = path.join(dest, fileName);
if (entry.isDirectory()) {
await copyDirectory(srcPath, destPath);
} else {
await fs.copyFile(srcPath, destPath);
}
}
}
async function createTempDir(dirName: string): Promise<string> {
// $FlowExpectedError[incompatible-call] temp-dir is typed as a default export
const tempDir = path.join(osTempDir, dirName);
await fs.mkdir(tempDir, {recursive: true});
return tempDir;
}
module.exports = buildAPISnapshot;