Alan Agius 8095268fa4 build: update to rxjs 7
G3 is now using RXJS version 7 which makes it possible for the CLI to also be updated to RXJS 7.

NB: this change does not remove all usages of the deprecated APIs.

Closes #24371
2023-02-16 14:59:40 +00:00

501 lines
14 KiB
TypeScript

/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {
BuilderContext,
BuilderHandlerFn,
BuilderInfo,
BuilderOutput,
BuilderOutputLike,
BuilderProgressReport,
BuilderRun,
ScheduleOptions,
Target,
fromAsyncIterable,
isBuilderOutput,
} from '@angular-devkit/architect';
import { WorkspaceHost } from '@angular-devkit/architect/node';
import { TestProjectHost } from '@angular-devkit/architect/testing';
import { getSystemPath, json, logging } from '@angular-devkit/core';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import fs from 'node:fs/promises';
import { dirname, join } from 'node:path';
import {
Observable,
Subject,
catchError,
finalize,
firstValueFrom,
lastValueFrom,
map,
mergeMap,
from as observableFrom,
of as observableOf,
shareReplay,
} from 'rxjs';
import { BuilderWatcherFactory, WatcherNotifier } from './file-watching';
export interface BuilderHarnessExecutionResult<T extends BuilderOutput = BuilderOutput> {
result?: T;
error?: Error;
logs: readonly logging.LogEntry[];
}
export interface BuilderHarnessExecutionOptions {
configuration: string;
outputLogsOnFailure: boolean;
outputLogsOnException: boolean;
useNativeFileWatching: boolean;
}
/**
* The default set of fields provided to all builders executed via the BuilderHarness.
* `root` and `sourceRoot` are required for most Angular builders to function.
* `cli.cache.enabled` set to false provides improved test isolation guarantees by disabling
* the Webpack caching.
*/
const DEFAULT_PROJECT_METADATA = {
root: '.',
sourceRoot: 'src',
cli: {
cache: {
enabled: false,
},
},
};
export class BuilderHarness<T> {
private readonly builderInfo: BuilderInfo;
private schemaRegistry = new json.schema.CoreSchemaRegistry();
private projectName = 'test';
private projectMetadata: Record<string, unknown> = DEFAULT_PROJECT_METADATA;
private targetName?: string;
private options = new Map<string | null, T>();
private builderTargets = new Map<
string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ handler: BuilderHandlerFn<any>; info: BuilderInfo; options: json.JsonObject }
>();
private watcherNotifier?: WatcherNotifier;
constructor(
private readonly builderHandler: BuilderHandlerFn<T & json.JsonObject>,
private readonly host: TestProjectHost,
builderInfo?: Partial<BuilderInfo>,
) {
// Generate default pseudo builder info for test purposes
this.builderInfo = {
builderName: builderHandler.name,
description: '',
optionSchema: true,
...builderInfo,
};
this.schemaRegistry.addPostTransform(json.schema.transforms.addUndefinedDefaults);
}
private resolvePath(path: string): string {
return join(getSystemPath(this.host.root()), path);
}
useProject(name: string, metadata: Record<string, unknown> = {}): this {
if (!name) {
throw new Error('Project name cannot be an empty string.');
}
this.projectName = name;
this.projectMetadata = metadata;
return this;
}
useTarget(name: string, baseOptions: T): this {
if (!name) {
throw new Error('Target name cannot be an empty string.');
}
this.targetName = name;
this.options.set(null, baseOptions);
return this;
}
withConfiguration(configuration: string, options: T): this {
this.options.set(configuration, options);
return this;
}
withBuilderTarget<O extends object>(
target: string,
handler: BuilderHandlerFn<O & json.JsonObject>,
options?: O,
info?: Partial<BuilderInfo>,
): this {
this.builderTargets.set(target, {
handler,
options: options || {},
info: { builderName: handler.name, description: '', optionSchema: true, ...info },
});
return this;
}
execute(
options: Partial<BuilderHarnessExecutionOptions> = {},
): Observable<BuilderHarnessExecutionResult> {
const {
configuration,
outputLogsOnException = true,
outputLogsOnFailure = true,
useNativeFileWatching = false,
} = options;
const targetOptions = {
...this.options.get(null),
...((configuration && this.options.get(configuration)) ?? {}),
};
if (!useNativeFileWatching) {
if (this.watcherNotifier) {
throw new Error('Only one harness execution at a time is supported.');
}
this.watcherNotifier = new WatcherNotifier();
}
const contextHost: ContextHost = {
findBuilderByTarget: async (project, target) => {
this.validateProjectName(project);
if (target === this.targetName) {
return {
info: this.builderInfo,
handler: this.builderHandler as BuilderHandlerFn<json.JsonObject>,
};
}
const builderTarget = this.builderTargets.get(target);
if (builderTarget) {
return { info: builderTarget.info, handler: builderTarget.handler };
}
throw new Error('Project target does not exist.');
},
async getBuilderName(project, target) {
return (await this.findBuilderByTarget(project, target)).info.builderName;
},
getMetadata: async (project) => {
this.validateProjectName(project);
return this.projectMetadata as json.JsonObject;
},
getOptions: async (project, target, configuration) => {
this.validateProjectName(project);
if (target === this.targetName) {
return (this.options.get(configuration ?? null) ?? {}) as json.JsonObject;
} else if (configuration !== undefined) {
// Harness builder targets currently do not support configurations
return {};
} else {
return (this.builderTargets.get(target)?.options || {}) as json.JsonObject;
}
},
hasTarget: async (project, target) => {
this.validateProjectName(project);
return this.targetName === target || this.builderTargets.has(target);
},
getDefaultConfigurationName: async (_project, _target) => {
return undefined;
},
validate: async (options, builderName) => {
let schema;
if (builderName === this.builderInfo.builderName) {
schema = this.builderInfo.optionSchema;
} else {
for (const [, value] of this.builderTargets) {
if (value.info.builderName === builderName) {
schema = value.info.optionSchema;
break;
}
}
}
const validator = await this.schemaRegistry.compile(schema ?? true);
const { data } = await validator(options);
return data as json.JsonObject;
},
};
const context = new HarnessBuilderContext(
this.builderInfo,
this.resolvePath('.'),
contextHost,
useNativeFileWatching ? undefined : this.watcherNotifier,
);
if (this.targetName !== undefined) {
context.target = {
project: this.projectName,
target: this.targetName,
configuration: configuration as string,
};
}
const logs: logging.LogEntry[] = [];
context.logger.subscribe((e) => logs.push(e));
return observableFrom(this.schemaRegistry.compile(this.builderInfo.optionSchema)).pipe(
mergeMap((validator) => validator(targetOptions)),
map((validationResult) => validationResult.data),
mergeMap((data) =>
convertBuilderOutputToObservable(this.builderHandler(data as T & json.JsonObject, context)),
),
map((buildResult) => ({ result: buildResult, error: undefined })),
catchError((error) => {
if (outputLogsOnException) {
// eslint-disable-next-line no-console
console.error(logs.map((entry) => entry.message).join('\n'));
// eslint-disable-next-line no-console
console.error(error);
}
return observableOf({ result: undefined, error });
}),
map(({ result, error }) => {
if (outputLogsOnFailure && result?.success === false && logs.length > 0) {
// eslint-disable-next-line no-console
console.error(logs.map((entry) => entry.message).join('\n'));
}
// Capture current logs and clear for next
const currentLogs = logs.slice();
logs.length = 0;
return { result, error, logs: currentLogs };
}),
finalize(() => {
this.watcherNotifier = undefined;
for (const teardown of context.teardowns) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
teardown();
}
}),
);
}
async executeOnce(
options?: Partial<BuilderHarnessExecutionOptions>,
): Promise<BuilderHarnessExecutionResult> {
// Return the first result
return firstValueFrom(this.execute(options));
}
async appendToFile(path: string, content: string): Promise<void> {
await this.writeFile(path, this.readFile(path).concat(content));
}
async writeFile(path: string, content: string | Buffer): Promise<void> {
const fullPath = this.resolvePath(path);
await fs.mkdir(dirname(fullPath), { recursive: true });
await fs.writeFile(fullPath, content, 'utf-8');
this.watcherNotifier?.notify([{ path: fullPath, type: 'modified' }]);
}
async writeFiles(files: Record<string, string | Buffer>): Promise<void> {
const watchEvents = this.watcherNotifier
? ([] as { path: string; type: 'modified' | 'deleted' }[])
: undefined;
for (const [path, content] of Object.entries(files)) {
const fullPath = this.resolvePath(path);
await fs.mkdir(dirname(fullPath), { recursive: true });
await fs.writeFile(fullPath, content, 'utf-8');
watchEvents?.push({ path: fullPath, type: 'modified' });
}
if (watchEvents) {
this.watcherNotifier?.notify(watchEvents);
}
}
async removeFile(path: string): Promise<void> {
const fullPath = this.resolvePath(path);
await fs.unlink(fullPath);
this.watcherNotifier?.notify([{ path: fullPath, type: 'deleted' }]);
}
async modifyFile(
path: string,
modifier: (content: string) => string | Promise<string>,
): Promise<void> {
const content = this.readFile(path);
await this.writeFile(path, await modifier(content));
}
hasFile(path: string): boolean {
const fullPath = this.resolvePath(path);
return existsSync(fullPath);
}
hasFileMatch(directory: string, pattern: RegExp): boolean {
const fullPath = this.resolvePath(directory);
return readdirSync(fullPath).some((name) => pattern.test(name));
}
readFile(path: string): string {
const fullPath = this.resolvePath(path);
return readFileSync(fullPath, 'utf-8');
}
private validateProjectName(name: string): void {
if (name !== this.projectName) {
throw new Error(`Project "${name}" does not exist.`);
}
}
}
interface ContextHost extends WorkspaceHost {
findBuilderByTarget(
project: string,
target: string,
): Promise<{ info: BuilderInfo; handler: BuilderHandlerFn<json.JsonObject> }>;
validate(options: json.JsonObject, builderName: string): Promise<json.JsonObject>;
}
class HarnessBuilderContext implements BuilderContext {
id = Math.trunc(Math.random() * 1000000);
logger = new logging.Logger(`builder-harness-${this.id}`);
workspaceRoot: string;
currentDirectory: string;
target?: Target;
teardowns: (() => Promise<void> | void)[] = [];
constructor(
public builder: BuilderInfo,
basePath: string,
private readonly contextHost: ContextHost,
public readonly watcherFactory: BuilderWatcherFactory | undefined,
) {
this.workspaceRoot = this.currentDirectory = basePath;
}
addTeardown(teardown: () => Promise<void> | void): void {
this.teardowns.push(teardown);
}
async getBuilderNameForTarget(target: Target): Promise<string> {
return this.contextHost.getBuilderName(target.project, target.target);
}
async getProjectMetadata(targetOrName: Target | string): Promise<json.JsonObject> {
const project = typeof targetOrName === 'string' ? targetOrName : targetOrName.project;
return this.contextHost.getMetadata(project);
}
async getTargetOptions(target: Target): Promise<json.JsonObject> {
return this.contextHost.getOptions(target.project, target.target, target.configuration);
}
// Unused by builders in this package
async scheduleBuilder(
builderName: string,
options?: json.JsonObject,
scheduleOptions?: ScheduleOptions,
): Promise<BuilderRun> {
throw new Error('Not Implemented.');
}
async scheduleTarget(
target: Target,
overrides?: json.JsonObject,
scheduleOptions?: ScheduleOptions,
): Promise<BuilderRun> {
const { info, handler } = await this.contextHost.findBuilderByTarget(
target.project,
target.target,
);
const targetOptions = await this.validateOptions(
{
...(await this.getTargetOptions(target)),
...overrides,
},
info.builderName,
);
const context = new HarnessBuilderContext(
info,
this.workspaceRoot,
this.contextHost,
this.watcherFactory,
);
context.target = target;
context.logger = scheduleOptions?.logger || this.logger.createChild('');
const progressSubject = new Subject<BuilderProgressReport>();
const output = convertBuilderOutputToObservable(handler(targetOptions, context));
const run: BuilderRun = {
id: context.id,
info,
progress: progressSubject.asObservable(),
async stop() {
for (const teardown of context.teardowns) {
await teardown();
}
progressSubject.complete();
},
output: output.pipe(shareReplay()),
get result() {
return firstValueFrom(this.output);
},
get lastOutput() {
return lastValueFrom(this.output);
},
};
return run;
}
async validateOptions<T extends json.JsonObject = json.JsonObject>(
options: json.JsonObject,
builderName: string,
): Promise<T> {
return this.contextHost.validate(options, builderName) as unknown as T;
}
// Unused report methods
reportRunning(): void {}
reportStatus(): void {}
reportProgress(): void {}
}
function isAsyncIterable<T>(obj: unknown): obj is AsyncIterable<T> {
return !!obj && typeof (obj as AsyncIterable<T>)[Symbol.asyncIterator] === 'function';
}
function convertBuilderOutputToObservable(output: BuilderOutputLike): Observable<BuilderOutput> {
if (isBuilderOutput(output)) {
return observableOf(output);
} else if (isAsyncIterable(output)) {
return fromAsyncIterable(output);
} else {
return observableFrom(output);
}
}