Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { type ExecutorContext, serializeJson } from '@nx/devkit';
import {
CompilerState,
ConsoleMessageId,
Extractor,
type ICompilerStateCreateOptions,
type IExtractorInvokeOptions,
type ExtractorConfig,
type ExtractorMessage,
type ExtractorResult,
} from '@microsoft/api-extractor';
import { basename, join } from 'node:path';
Expand Down Expand Up @@ -40,6 +44,14 @@ const _context: ExecutorContext = {

const execSyncMock = execSync as jest.Mock;

// The real thing would build a TS program over the fixtures, which the mocked `Extractor.invoke` never reads.
const compilerStateStub = { program: {} } as unknown as CompilerState;
let compilerStateCreateSpy: jest.SpyInstance<CompilerState, [ExtractorConfig, ICompilerStateCreateOptions?]>;

beforeEach(() => {
compilerStateCreateSpy = jest.spyOn(CompilerState, 'create').mockReturnValue(compilerStateStub);
});

function cleanup() {
// Remove all contents of the fixtures directory but keep the directory itself
const entries = readdirSync(fixturesRootDir, { withFileTypes: true });
Expand Down Expand Up @@ -192,6 +204,8 @@ describe('GenerateApi Executor', () => {
const actualLocalBuildValue = isCI() ? false : true;

expect(extractorArgs).toEqual({
compilerState: compilerStateStub,
messageCallback: expect.any(Function),
localBuild: actualLocalBuildValue,
showDiagnostics: false,
showVerboseMessages: true,
Expand Down Expand Up @@ -224,6 +238,8 @@ describe('GenerateApi Executor', () => {

expect(extractorConfig).toEqual(expect.any(Object));
expect(extractorArgs).toEqual({
compilerState: compilerStateStub,
messageCallback: expect.any(Function),
localBuild: false,
showDiagnostics: true,
showVerboseMessages: true,
Expand Down Expand Up @@ -492,4 +508,60 @@ describe('GenerateApi Executor – export subpath resolution', () => {
expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(1 + 1 + subDirs.length);
expect(output.success).toBe(true);
});

it('creates one compiler state for all entry points and reuses it for every invocation', async () => {
const subDirs = ['alpha', 'beta'];
const { context } = prepareExportFixture({ wildcardSubDirs: subDirs, namedExports: ['utils'] });

const capturedConfigs: ExtractorConfig[] = [];
const capturedStates: (CompilerState | undefined)[] = [];
jest.spyOn(Extractor, 'invoke').mockImplementation((cfg, invokeOptions) => {
capturedConfigs.push(cfg);
capturedStates.push(invokeOptions?.compilerState);
return { succeeded: true } as ExtractorResult;
});

const output = await executor({ ...options, exportSubpaths: true }, context);

expect(compilerStateCreateSpy).toHaveBeenCalledTimes(1);

const [primaryConfig, createOptions] = compilerStateCreateSpy.mock.calls[0];
expect(primaryConfig).toBe(capturedConfigs[0]);
expect(createOptions?.additionalEntryPoints).toEqual(
capturedConfigs.slice(1).map(cfg => cfg.mainEntryPointFilePath),
);

expect(capturedStates).toEqual(new Array(1 + 1 + subDirs.length).fill(compilerStateStub));
expect(output.success).toBe(true);
});

it('reports repeated api-extractor console notices only once across invocations', async () => {
const { context } = prepareExportFixture({ namedExports: ['utils'] });

const capturedCallbacks: NonNullable<IExtractorInvokeOptions['messageCallback']>[] = [];
jest.spyOn(Extractor, 'invoke').mockImplementation((_config, invokeOptions) => {
capturedCallbacks.push(invokeOptions!.messageCallback!);
return { succeeded: true } as ExtractorResult;
});

await executor({ ...options, exportSubpaths: true }, context);

expect(capturedCallbacks).toHaveLength(2);
expect(capturedCallbacks[0]).toBe(capturedCallbacks[1]);

const messages = {
firstPreamble: { messageId: ConsoleMessageId.Preamble, handled: false } as ExtractorMessage,
secondPreamble: { messageId: ConsoleMessageId.Preamble, handled: false } as ExtractorMessage,
apiReport: { messageId: ConsoleMessageId.ApiReportCopied, handled: false } as ExtractorMessage,
};

capturedCallbacks[0](messages.firstPreamble);
capturedCallbacks[1](messages.secondPreamble);
capturedCallbacks[1](messages.apiReport);

expect(messages.firstPreamble.handled).toBe(false);
expect(messages.secondPreamble.handled).toBe(true);
// unrelated console messages keep api-extractor's default handling
expect(messages.apiReport.handled).toBe(false);
});
});
169 changes: 128 additions & 41 deletions tools/workspace-plugin/src/executors/generate-api/executor.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { join, relative } from 'node:path';
import { execSync } from 'node:child_process';
import { type ExecutorContext, type PromiseExecutor, logger, parseJson } from '@nx/devkit';
import { Extractor, ExtractorConfig, type IConfigFile } from '@microsoft/api-extractor';
import {
CompilerState,
ConsoleMessageId,
Extractor,
ExtractorConfig,
type ExtractorMessage,
type IConfigFile,
} from '@microsoft/api-extractor';

import type { GenerateApiExecutorSchema } from './schema';
import type { PackageJson, TsConfig } from '../../types';
Expand All @@ -28,30 +35,82 @@ export default runExecutor;

export interface NormalizedOptions extends ReturnType<typeof normalizeOptions> {}

type ConfigSource = { configPath: string } | { configObject: IConfigFile };

async function runGenerateApi(options: NormalizedOptions, context: ExecutorContext): Promise<boolean> {
if (!generateTypeDeclarations(options)) {
return false;
}

// Run primary api-extractor config
if (!apiExtractor({ configPath: options.config }, options, context)) {
return false;
}
const configSources: ConfigSource[] = [{ configPath: options.config }];

// Expand export subpaths and run api-extractor for each resolved entry
// Expand export subpaths into one api-extractor config per resolved entry
if (options.exportSubpaths.enabled) {
const subpathConfigs = getExportSubpathConfigs(options);
for (const configObject of subpathConfigs) {
verboseLog(`Running api-extractor for export subpath entry: ${configObject.mainEntryPointFilePath}`);
if (!apiExtractor({ configObject }, options, context)) {
return false;
}
for (const configObject of getExportSubpathConfigs(options)) {
verboseLog(`Resolved api-extractor config for export subpath entry: ${configObject.mainEntryPointFilePath}`);
configSources.push({ configObject });
}
}

const extractorConfigs = configSources.map(configSource => prepareExtractorConfig(configSource, options));
const compilerState = createCompilerState(extractorConfigs);
const messageCallback = createConsoleMessageDeduper();

for (const [index, extractorConfig] of extractorConfigs.entries()) {
const invoked = invokeExtractor(
{
extractorConfig,
compilerState,
messageCallback,
progress: { current: index + 1, total: extractorConfigs.length },
},
options,
context,
);

if (!invoked) {
return false;
}
}

return true;
}

/**
* api-extractor repeats its compiler version notices on every invocation, so keep only the first of each.
*/
function createConsoleMessageDeduper() {
const dedupedMessageIds: string[] = [ConsoleMessageId.Preamble, ConsoleMessageId.CompilerVersionNotice];
const alreadyReported = new Set<string>();

return (message: ExtractorMessage) => {
if (!dedupedMessageIds.includes(message.messageId)) {
return;
}

if (alreadyReported.has(message.messageId)) {
message.handled = true;
return;
}

alreadyReported.add(message.messageId);
};
}

/**
* Every config compiles with the same tsconfig, so one TS program can serve all entry points
* instead of api-extractor creating a new one per invocation.
*/
function createCompilerState(extractorConfigs: ExtractorConfig[]): CompilerState {
const [primaryConfig, ...subpathConfigs] = extractorConfigs;

verboseLog(`Creating shared api-extractor compiler state for ${extractorConfigs.length} entry point(s)`);

return CompilerState.create(primaryConfig, {
additionalEntryPoints: subpathConfigs.map(config => config.mainEntryPointFilePath),
});
}

function normalizeOptions(schema: GenerateApiExecutorSchema, context: ExecutorContext) {
const defaults = {
config: '{projectRoot}/config/api-extractor.json',
Expand Down Expand Up @@ -116,42 +175,20 @@ function generateTypeDeclarations(options: NormalizedOptions) {
}
}

function apiExtractor(
configSource: { configPath: string } | { configObject: IConfigFile },
options: NormalizedOptions,
context: ExecutorContext,
) {
/**
* Loads, parses, customizes and prepares the api-extractor config for the API Extractor API.
*/
function prepareExtractorConfig(configSource: ConfigSource, options: NormalizedOptions): ExtractorConfig {
const { rawConfig, fullPath } = resolveConfigSource();

// Load,parse,customize and prepare the api-extractor.json file for API Extractor API
customizeExtractorConfig(rawConfig);
const extractorConfig = ExtractorConfig.prepare({

return ExtractorConfig.prepare({
configObject: rawConfig,
configObjectFullPath: fullPath,
packageJsonFullPath: options.packageJsonPath,
});

// Invoke API Extractor
const extractorResult = Extractor.invoke(extractorConfig, {
// Equivalent to the "--local" command-line parameter
localBuild: options.local,

// Equivalent to the "--verbose" command-line parameter
showVerboseMessages: context.isVerbose,
showDiagnostics: options.diagnostics,
});

if (extractorResult.succeeded) {
verboseLog(`API Extractor completed successfully`);
return true;
}

logger.error(
`API Extractor completed with ${extractorResult.errorCount} errors` +
` and ${extractorResult.warningCount} warnings`,
);
return false;

/**
* Resolves the config source into a raw IConfigFile and the full path used for token resolution.
* File-based sources are loaded from disk; programmatic configs reuse the primary config path.
Expand Down Expand Up @@ -182,6 +219,56 @@ function apiExtractor(
}
}

function invokeExtractor(
params: {
extractorConfig: ExtractorConfig;
compilerState: CompilerState;
messageCallback: (message: ExtractorMessage) => void;
progress: { current: number; total: number };
},
options: NormalizedOptions,
context: ExecutorContext,
) {
const { extractorConfig, compilerState, messageCallback, progress } = params;

logEntryPoint();

const extractorResult = Extractor.invoke(extractorConfig, {
compilerState,
messageCallback,

// Equivalent to the "--local" command-line parameter
localBuild: options.local,

// Equivalent to the "--verbose" command-line parameter
showVerboseMessages: context.isVerbose,
showDiagnostics: options.diagnostics,
});

if (extractorResult.succeeded) {
verboseLog(`API Extractor completed successfully`);
return true;
}

logger.error(
`API Extractor completed with ${extractorResult.errorCount} errors` +
` and ${extractorResult.warningCount} warnings`,
);
return false;

function logEntryPoint() {
const outputPath = extractorConfig.untrimmedFilePath || extractorConfig.mainEntryPointFilePath;
const label = relative(options.projectAbsolutePath, outputPath);

if (progress.total === 1) {
verboseLog(`Generating API for ${label}`);
return;
}

logger.info(`[${progress.current}/${progress.total}] Generating API for ${label}`);
}
}

function getTsConfigForApiExtractor(options: {
tsConfig: TsConfig;
packageJson: PackageJson;
Expand Down
Loading