/* eslint-disable @typescript-eslint/no-explicit-any */ import { BuilderContext, BuilderHandlerFn, BuilderInfo, BuilderOutput, BuilderOutputLike, BuilderProgressReport, BuilderRun, ScheduleOptions, Target, fromAsyncIterable, isBuilderOutput, } from '@angular-devkit/architect'; import { TestProjectHost } from '@angular-devkit/architect/testing'; import { Path, analytics, getSystemPath, join, json, logging, normalize, } from '@angular-devkit/core'; import { fileBufferToString, stringToFileBuffer, } from '@angular-devkit/core/src/virtual-fs/host'; import { readFileSync } from 'fs'; import { Observable, Subject, from, of } from 'rxjs'; import { catchError, finalize, first, map, mergeMap, shareReplay, } from 'rxjs/operators'; import type { Configuration } from 'webpack'; export interface TestContext { buildSuccess: (webpackConfig: Configuration) => void; } export class MyTestProjectHost extends TestProjectHost { async getFileList(dirPath: Path): Promise<string[]> { const fileList: string[] = []; const list = await this.list(dirPath).toPromise(); for (let i = 0; i < list.length; i++) { const element = list[i]; const filePath = join(dirPath, element); if (await this.isDirectory(filePath).toPromise()) { fileList.push(...(await this.getFileList(filePath))); } else { fileList.push(filePath); } } return fileList; } async importPathRename(list: string[]) { for (let i = 0; i < list.length; i++) { const element = list[i]; const content = await this.read(normalize(element)).toPromise(); let contentString = fileBufferToString(content); contentString = contentString .replace(/\/__components\//g, '/components/') .replace(/\/__pages\//g, '/pages/'); await this.write( normalize(element), stringToFileBuffer(contentString) ).toPromise(); } } async moveDir(list: string[], from: string, to: string) { for (let i = 0; i < list.length; i++) { const item = list[i]; await this.rename( normalize(join(this.root(), 'src', from, item)), normalize(join(this.root(), 'src', to, item)) ).toPromise(); } } async addPageEntry(list: string[]) { const configPath = join(normalize(this.root()), 'src', 'app.json'); const file = await this.read(configPath).toPromise(); const json = JSON.parse(fileBufferToString(file)); const entryList = list.map((item) => `pages/${item}/${item}-entry`); json.pages = entryList; await this.write( configPath, stringToFileBuffer(JSON.stringify(json)) ).toPromise(); } async addSpecEntry(list: string[]) { const configPath = join(normalize(this.root()), 'src', 'app.json'); const file = await this.read(configPath).toPromise(); const json = JSON.parse(fileBufferToString(file)); const entryList = list.map((item) => `spec/${item}/${item}-entry`); json.pages = entryList; await this.write( configPath, stringToFileBuffer(JSON.stringify(json)) ).toPromise(); } } export const workspaceRoot = join(normalize(__dirname), `../hello-world-app/`); export const host = new MyTestProjectHost(workspaceRoot); /** * @license * Copyright Google Inc. 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 */ const optionSchemaCache = new Map<string, json.schema.JsonSchema>(); export function describeBuilder<T>( builderHandler: BuilderHandlerFn<T & json.JsonObject>, options: { name?: string; schemaPath: string }, specDefinitions: (harness: JasmineBuilderHarness<T>) => void ): void { errorAndExitHook(); jasmine.DEFAULT_TIMEOUT_INTERVAL = 500 * 1000; let optionSchema = optionSchemaCache.get(options.schemaPath); if (optionSchema === undefined) { optionSchema = JSON.parse( readFileSync(options.schemaPath, 'utf8') ) as json.schema.JsonSchema; optionSchemaCache.set(options.schemaPath, optionSchema); } const harness = new JasmineBuilderHarness<T>(builderHandler, host, { builderName: options.name, optionSchema, }); describe(options.name || builderHandler.name, () => { beforeEach(() => host.initialize().toPromise()); afterEach(() => host.restore().toPromise()); specDefinitions(harness); }); } 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>, public readonly host: MyTestProjectHost, 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 ); } 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>( 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) ?? {}; } 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) .toPromise(); const { data } = await validator(options).toPromise(); return data as json.JsonObject; }, }; const context = new HarnessBuilderContext( this.builderInfo, getSystemPath(this.host.root()), contextHost, useNativeFileWatching ? undefined : this.watcherNotifier, options.testContext ); 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 this.schemaRegistry.compile(this.builderInfo.optionSchema).pipe( mergeMap((validator) => validator(targetOptions as any)), 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 of({ 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 this.execute(options).pipe(first()).toPromise(); } 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> { this.host .scopedSync() .write( normalize(path), typeof content === 'string' ? Buffer.from(content) : content ); this.watcherNotifier?.notify([ { path: getSystemPath(join(this.host.root(), path)), 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)) { this.host .scopedSync() .write( normalize(path), typeof content === 'string' ? Buffer.from(content) : content ); watchEvents?.push({ path: getSystemPath(join(this.host.root(), path)), type: 'modified', }); } if (watchEvents) { this.watcherNotifier?.notify(watchEvents); } } async removeFile(path: string): Promise<void> { this.host.scopedSync().delete(normalize(path)); this.watcherNotifier?.notify([ { path: getSystemPath(join(this.host.root(), path)), 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)); this.watcherNotifier?.notify([ { path: getSystemPath(join(this.host.root(), path)), type: 'modified' }, ]); } hasFile(path: string): boolean { return this.host.scopedSync().exists(normalize(path)); } hasFileMatch(directory: string, pattern: RegExp): boolean { return this.host .scopedSync() .list(normalize(directory)) .some((name) => pattern.test(name)); } readFile(path: string): string { const content = this.host.scopedSync().read(normalize(path)); return Buffer.from(content).toString('utf8'); } private validateProjectName(name: string): void { if (name !== this.projectName) { throw new Error(`Project "${name}" does not exist.`); } } } export class JasmineBuilderHarness<T> extends BuilderHarness<T> { expectFile(path: string): HarnessFileMatchers { return expectFile(path, this); } } export interface BuilderHarnessExecutionOptions { configuration: string; outputLogsOnFailure: boolean; outputLogsOnException: boolean; useNativeFileWatching: boolean; testContext?: TestContext; } export interface BuilderHarnessExecutionResult< T extends BuilderOutput = BuilderOutput > { result?: T; error?: Error; logs: readonly logging.LogEntry[]; } function convertBuilderOutputToObservable( output: BuilderOutputLike ): Observable<BuilderOutput> { if (isBuilderOutput(output)) { return of(output); } else if (isAsyncIterable(output)) { return fromAsyncIterable(output); } else { return from(output); } } function isAsyncIterable<T>(obj: unknown): obj is AsyncIterable<T> { return ( !!obj && typeof (obj as AsyncIterable<T>)[Symbol.asyncIterator] === 'function' ); } 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, public readonly testContext: TestContext | undefined ) { this.workspaceRoot = this.currentDirectory = basePath; } get analytics(): analytics.Analytics { // Can be undefined even though interface does not allow it return undefined as unknown as analytics.Analytics; } 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, undefined ); 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 this.output.pipe(first()).toPromise(); }, }; 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 {} } // export type Target = json.JsonObject; 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>; } export interface WorkspaceHost { getBuilderName(project: string, target: string): Promise<string>; getMetadata(project: string): Promise<json.JsonObject>; getOptions( project: string, target: string, configuration?: string ): Promise<json.JsonObject>; hasTarget(project: string, target: string): Promise<boolean>; getDefaultConfigurationName( project: string, target: string ): Promise<string | undefined>; } export type BuilderWatcherCallback = ( events: Array<{ path: string; type: 'created' | 'modified' | 'deleted'; time?: number; }> ) => void; export interface BuilderWatcherFactory { watch( files: Iterable<string>, directories: Iterable<string>, callback: BuilderWatcherCallback ): { close(): void }; } class WatcherDescriptor { constructor( readonly files: ReadonlySet<string>, readonly directories: ReadonlySet<string>, readonly callback: BuilderWatcherCallback ) {} shouldNotify(path: string): boolean { return true; } } export class WatcherNotifier implements BuilderWatcherFactory { private readonly descriptors = new Set<WatcherDescriptor>(); notify( events: Iterable<{ path: string; type: 'modified' | 'deleted' }> ): void { for (const descriptor of this.descriptors) { for (const { path } of events) { if (descriptor.shouldNotify(path)) { descriptor.callback([...events]); break; } } } } watch( files: Iterable<string>, directories: Iterable<string>, callback: BuilderWatcherCallback ): { close(): void } { const descriptor = new WatcherDescriptor( new Set(files), new Set(directories), callback ); this.descriptors.add(descriptor); return { close: () => this.descriptors.delete(descriptor) }; } } const DEFAULT_PROJECT_METADATA = { root: '.', sourceRoot: 'src', cli: { cache: { enabled: false, }, }, }; export function expectFile<T>( path: string, harness: BuilderHarness<T> ): HarnessFileMatchers { return { toExist() { const exists = harness.hasFile(path); expect(exists).toBe(true, 'Expected file to exist: ' + path); return exists; }, toNotExist() { const exists = harness.hasFile(path); expect(exists).toBe(false, 'Expected file to not exist: ' + path); return !exists; }, get content() { try { return expect(harness.readFile(path)).withContext( `With file content for '${path}'` ); } catch (e) { if (e.code !== 'ENOENT') { throw e; } // File does not exist so always fail the expectation return createFailureExpectation( expect(''), `Expected file content but file does not exist: '${path}'` ); } }, get size() { try { return expect(Buffer.byteLength(harness.readFile(path))).withContext( `With file size for '${path}'` ); } catch (e) { if (e.code !== 'ENOENT') { throw e; } // File does not exist so always fail the expectation return createFailureExpectation( expect(0), `Expected file size but file does not exist: '${path}'` ); } }, }; } export interface HarnessFileMatchers { toExist(): boolean; toNotExist(): boolean; readonly content: jasmine.ArrayLikeMatchers<string>; readonly size: jasmine.Matchers<number>; } function createFailureExpectation<T>(base: T, message: string): T { // Needed typings are not included in the Jasmine types const expectation = base as T & { expector: { addFilter(filter: { selectComparisonFunc(): () => { pass: boolean; message: string }; }): typeof expectation.expector; }; }; expectation.expector = expectation.expector.addFilter({ selectComparisonFunc() { return () => ({ pass: false, message, }); }, }); return expectation; } function errorAndExitHook() { const errorFn = console.error; console.error = function () { errorFn.apply(this, Array.from(arguments)); process.exit(100); }; }