refactor(@angular-devkit/build-angular): improve accuracy of programmatic watch mode usage for esbuild builders

To better capture file changes after the initial build for the esbuild-based builders in a programmatic usage,
the file watching initialization has been moved to before the first build results are yielded. This allows tests
that execute code to change files with improved accuracy of the watch mode triggering. The application builder
now also supports aborting the watch mode programmatically. This allows tests to gracefully stop the watch mode
and more fully cleanup the test at completion.
This commit is contained in:
Charles Lyding 2023-10-03 15:35:13 -04:00 committed by Charles
parent 03a1eaf01c
commit 9c4a6be2d1
5 changed files with 86 additions and 58 deletions

View File

@ -30,6 +30,7 @@ export async function* runEsBuildBuildAction(
progress?: boolean; progress?: boolean;
deleteOutputPath?: boolean; deleteOutputPath?: boolean;
poll?: number; poll?: number;
signal?: AbortSignal;
}, },
): AsyncIterable<(ExecutionResult['outputWithFiles'] | ExecutionResult['output']) & BuilderOutput> { ): AsyncIterable<(ExecutionResult['outputWithFiles'] | ExecutionResult['output']) & BuilderOutput> {
const { const {
@ -75,22 +76,6 @@ export async function* runEsBuildBuildAction(
let result: ExecutionResult; let result: ExecutionResult;
try { try {
result = await withProgress('Building...', () => action()); result = await withProgress('Building...', () => action());
if (writeToFileSystem) {
// Write output files
await writeResultFiles(result.outputFiles, result.assetFiles, outputPath);
yield result.output;
} else {
// Requires casting due to unneeded `JsonObject` requirement. Remove once fixed.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
yield result.outputWithFiles as any;
}
// Finish if watch mode is not enabled
if (!watch) {
return;
}
} finally { } finally {
// Ensure Sass workers are shutdown if not watching // Ensure Sass workers are shutdown if not watching
if (!watch) { if (!watch) {
@ -98,52 +83,82 @@ export async function* runEsBuildBuildAction(
} }
} }
if (progress) { // Setup watcher if watch mode enabled
logger.info('Watch mode enabled. Watching for file changes...'); let watcher: import('../../tools/esbuild/watcher').BuildWatcher | undefined;
if (watch) {
if (progress) {
logger.info('Watch mode enabled. Watching for file changes...');
}
// Setup a watcher
const { createWatcher } = await import('../../tools/esbuild/watcher');
watcher = createWatcher({
polling: typeof poll === 'number',
interval: poll,
ignored: [
// Ignore the output and cache paths to avoid infinite rebuild cycles
outputPath,
cacheOptions.basePath,
// Ignore all node modules directories to avoid excessive file watchers.
// Package changes are handled below by watching manifest and lock files.
'**/node_modules/**',
'**/.*/**',
],
});
// Setup abort support
options.signal?.addEventListener('abort', () => void watcher?.close());
// Temporarily watch the entire project
watcher.add(projectRoot);
// Watch workspace for package manager changes
const packageWatchFiles = [
// manifest can affect module resolution
'package.json',
// npm lock file
'package-lock.json',
// pnpm lock file
'pnpm-lock.yaml',
// yarn lock file including Yarn PnP manifest files (https://yarnpkg.com/advanced/pnp-spec/)
'yarn.lock',
'.pnp.cjs',
'.pnp.data.json',
];
watcher.add(packageWatchFiles.map((file) => path.join(workspaceRoot, file)));
// Watch locations provided by the initial build result
watcher.add(result.watchFiles);
} }
// Setup a watcher // Output the first build results after setting up the watcher to ensure that any code executed
const { createWatcher } = await import('../../tools/esbuild/watcher'); // higher in the iterator call stack will trigger the watcher. This is particularly relevant for
const watcher = createWatcher({ // unit tests which execute the builder and modify the file system programmatically.
polling: typeof poll === 'number', if (writeToFileSystem) {
interval: poll, // Write output files
ignored: [ await writeResultFiles(result.outputFiles, result.assetFiles, outputPath);
// Ignore the output and cache paths to avoid infinite rebuild cycles
outputPath,
cacheOptions.basePath,
// Ignore all node modules directories to avoid excessive file watchers.
// Package changes are handled below by watching manifest and lock files.
'**/node_modules/**',
'**/.*/**',
],
});
// Temporarily watch the entire project yield result.output;
watcher.add(projectRoot); } else {
// Requires casting due to unneeded `JsonObject` requirement. Remove once fixed.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
yield result.outputWithFiles as any;
}
// Watch workspace for package manager changes // Finish if watch mode is not enabled
const packageWatchFiles = [ if (!watcher) {
// manifest can affect module resolution return;
'package.json', }
// npm lock file
'package-lock.json',
// pnpm lock file
'pnpm-lock.yaml',
// yarn lock file including Yarn PnP manifest files (https://yarnpkg.com/advanced/pnp-spec/)
'yarn.lock',
'.pnp.cjs',
'.pnp.data.json',
];
watcher.add(packageWatchFiles.map((file) => path.join(workspaceRoot, file)));
// Watch locations provided by the initial build result
let previousWatchFiles = new Set(result.watchFiles);
watcher.add(result.watchFiles);
// Wait for changes and rebuild as needed // Wait for changes and rebuild as needed
let previousWatchFiles = new Set(result.watchFiles);
try { try {
for await (const changes of watcher) { for await (const changes of watcher) {
if (options.signal?.aborted) {
break;
}
if (verbose) { if (verbose) {
logger.info(changes.toDebugString()); logger.info(changes.toDebugString());
} }

View File

@ -17,7 +17,8 @@ import { Schema as ApplicationBuilderOptions } from './schema';
export async function* buildApplicationInternal( export async function* buildApplicationInternal(
options: ApplicationBuilderInternalOptions, options: ApplicationBuilderInternalOptions,
context: BuilderContext, // TODO: Integrate abort signal support into builder system
context: BuilderContext & { signal?: AbortSignal },
infrastructureSettings?: { infrastructureSettings?: {
write?: boolean; write?: boolean;
}, },
@ -73,6 +74,7 @@ export async function* buildApplicationInternal(
progress: normalizedOptions.progress, progress: normalizedOptions.progress,
writeToFileSystem: infrastructureSettings?.write, writeToFileSystem: infrastructureSettings?.write,
logger: context.logger, logger: context.logger,
signal: context.signal,
}, },
); );
} }

View File

@ -75,7 +75,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
harness.expectFile('dist/main.js').content.toContain('color: indianred'); harness.expectFile('dist/main.js').content.toContain('color: indianred');
}); });
xit('updates produced stylesheet in watch mode', async () => { it('updates produced stylesheet in watch mode', async () => {
harness.useTarget('build', { harness.useTarget('build', {
...BASE_OPTIONS, ...BASE_OPTIONS,
inlineStyleLanguage: InlineStyleLanguage.Scss, inlineStyleLanguage: InlineStyleLanguage.Scss,
@ -87,8 +87,9 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
content.replace('__STYLE_MARKER__', '$primary: indianred;\\nh1 { color: $primary; }'), content.replace('__STYLE_MARKER__', '$primary: indianred;\\nh1 { color: $primary; }'),
); );
const builderAbort = new AbortController();
const buildCount = await harness const buildCount = await harness
.execute() .execute({ signal: builderAbort.signal })
.pipe( .pipe(
timeout(30000), timeout(30000),
concatMap(async ({ result }, index) => { concatMap(async ({ result }, index) => {
@ -121,10 +122,12 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
harness.expectFile('dist/main.js').content.not.toContain('color: indianred'); harness.expectFile('dist/main.js').content.not.toContain('color: indianred');
harness.expectFile('dist/main.js').content.not.toContain('color: aqua'); harness.expectFile('dist/main.js').content.not.toContain('color: aqua');
harness.expectFile('dist/main.js').content.toContain('color: blue'); harness.expectFile('dist/main.js').content.toContain('color: blue');
// Test complete - abort watch mode
builderAbort.abort();
break; break;
} }
}), }),
take(3),
count(), count(),
) )
.toPromise(); .toPromise();

View File

@ -28,4 +28,8 @@ export const BASE_OPTIONS = Object.freeze<Schema>({
// Disable optimizations // Disable optimizations
optimization: false, optimization: false,
// Enable polling (if a test enables watch mode).
// This is a workaround for bazel isolation file watch not triggering in tests.
poll: 100,
}); });

View File

@ -51,6 +51,7 @@ export interface BuilderHarnessExecutionOptions {
outputLogsOnFailure: boolean; outputLogsOnFailure: boolean;
outputLogsOnException: boolean; outputLogsOnException: boolean;
useNativeFileWatching: boolean; useNativeFileWatching: boolean;
signal: AbortSignal;
} }
/** /**
@ -235,6 +236,7 @@ export class BuilderHarness<T> {
this.builderInfo, this.builderInfo,
this.resolvePath('.'), this.resolvePath('.'),
contextHost, contextHost,
options.signal,
useNativeFileWatching ? undefined : this.watcherNotifier, useNativeFileWatching ? undefined : this.watcherNotifier,
); );
if (this.targetName !== undefined) { if (this.targetName !== undefined) {
@ -389,6 +391,7 @@ class HarnessBuilderContext implements BuilderContext {
public builder: BuilderInfo, public builder: BuilderInfo,
basePath: string, basePath: string,
private readonly contextHost: ContextHost, private readonly contextHost: ContextHost,
public readonly signal: AbortSignal | undefined,
public readonly watcherFactory: BuilderWatcherFactory | undefined, public readonly watcherFactory: BuilderWatcherFactory | undefined,
) { ) {
this.workspaceRoot = this.currentDirectory = basePath; this.workspaceRoot = this.currentDirectory = basePath;
@ -442,6 +445,7 @@ class HarnessBuilderContext implements BuilderContext {
info, info,
this.workspaceRoot, this.workspaceRoot,
this.contextHost, this.contextHost,
this.signal,
this.watcherFactory, this.watcherFactory,
); );
context.target = target; context.target = target;