mirror of
https://github.com/angular/angular-cli.git
synced 2025-05-16 02:24:10 +08:00
feat(@angular-devkit/build-angular): karma+esbuild+watch
This introduces support for `--watch` when using the application builder. It's tested as far as the relevant test case is concerned. But I wouldn't be surprised if there's still some rough corners.
This commit is contained in:
parent
32dd2f2dba
commit
dcbdca85c7
@ -8,6 +8,7 @@
|
||||
|
||||
import { BuildOutputFileType } from '@angular/build';
|
||||
import {
|
||||
ApplicationBuilderInternalOptions,
|
||||
ResultFile,
|
||||
ResultKind,
|
||||
buildApplicationInternal,
|
||||
@ -19,13 +20,18 @@ import glob from 'fast-glob';
|
||||
import * as fs from 'fs/promises';
|
||||
import type { Config, ConfigOptions, InlinePluginDef } from 'karma';
|
||||
import * as path from 'path';
|
||||
import { Observable, catchError, defaultIfEmpty, from, of, switchMap } from 'rxjs';
|
||||
import { Observable, Subscriber, catchError, defaultIfEmpty, from, of, switchMap } from 'rxjs';
|
||||
import { Configuration } from 'webpack';
|
||||
import { ExecutionTransformer } from '../../transforms';
|
||||
import { OutputHashing } from '../browser-esbuild/schema';
|
||||
import { findTests } from './find-tests';
|
||||
import { Schema as KarmaBuilderOptions } from './schema';
|
||||
|
||||
interface BuildOptions extends ApplicationBuilderInternalOptions {
|
||||
// We know that it's always a string since we set it.
|
||||
outputPath: string;
|
||||
}
|
||||
|
||||
class ApplicationBuildError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
@ -33,6 +39,76 @@ class ApplicationBuildError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function injectKarmaReporter(
|
||||
context: BuilderContext,
|
||||
buildOptions: BuildOptions,
|
||||
karmaConfig: Config & ConfigOptions,
|
||||
subscriber: Subscriber<BuilderOutput>,
|
||||
) {
|
||||
const reporterName = 'angular-progress-notifier';
|
||||
|
||||
interface RunCompleteInfo {
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
interface KarmaEmitter {
|
||||
refreshFiles(): void;
|
||||
}
|
||||
|
||||
class ProgressNotifierReporter {
|
||||
static $inject = ['emitter'];
|
||||
|
||||
constructor(private readonly emitter: KarmaEmitter) {
|
||||
this.startWatchingBuild();
|
||||
}
|
||||
|
||||
private startWatchingBuild() {
|
||||
void (async () => {
|
||||
for await (const buildOutput of buildApplicationInternal(
|
||||
{
|
||||
...buildOptions,
|
||||
watch: true,
|
||||
},
|
||||
context,
|
||||
)) {
|
||||
if (buildOutput.kind === ResultKind.Failure) {
|
||||
subscriber.next({ success: false, message: 'Build failed' });
|
||||
} else if (
|
||||
buildOutput.kind === ResultKind.Incremental ||
|
||||
buildOutput.kind === ResultKind.Full
|
||||
) {
|
||||
await writeTestFiles(buildOutput.files, buildOptions.outputPath);
|
||||
this.emitter.refreshFiles();
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
onRunComplete = function (_browsers: unknown, results: RunCompleteInfo) {
|
||||
if (results.exitCode === 0) {
|
||||
subscriber.next({ success: true });
|
||||
} else {
|
||||
subscriber.next({ success: false });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
karmaConfig.reporters ??= [];
|
||||
karmaConfig.reporters.push(reporterName);
|
||||
|
||||
karmaConfig.plugins ??= [];
|
||||
karmaConfig.plugins.push({
|
||||
[`reporter:${reporterName}`]: [
|
||||
'factory',
|
||||
Object.assign(
|
||||
(...args: ConstructorParameters<typeof ProgressNotifierReporter>) =>
|
||||
new ProgressNotifierReporter(...args),
|
||||
ProgressNotifierReporter,
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export function execute(
|
||||
options: KarmaBuilderOptions,
|
||||
context: BuilderContext,
|
||||
@ -45,8 +121,12 @@ export function execute(
|
||||
): Observable<BuilderOutput> {
|
||||
return from(initializeApplication(options, context, karmaOptions, transforms)).pipe(
|
||||
switchMap(
|
||||
([karma, karmaConfig]) =>
|
||||
([karma, karmaConfig, buildOptions]) =>
|
||||
new Observable<BuilderOutput>((subscriber) => {
|
||||
if (options.watch) {
|
||||
injectKarmaReporter(context, buildOptions, karmaConfig, subscriber);
|
||||
}
|
||||
|
||||
// Complete the observable once the Karma server returns.
|
||||
const karmaServer = new karma.Server(karmaConfig as Config, (exitCode) => {
|
||||
subscriber.next({ success: exitCode === 0 });
|
||||
@ -122,24 +202,22 @@ async function initializeApplication(
|
||||
webpackConfiguration?: ExecutionTransformer<Configuration>;
|
||||
karmaOptions?: (options: ConfigOptions) => ConfigOptions;
|
||||
} = {},
|
||||
): Promise<[typeof import('karma'), Config & ConfigOptions]> {
|
||||
): Promise<[typeof import('karma'), Config & ConfigOptions, BuildOptions]> {
|
||||
if (transforms.webpackConfiguration) {
|
||||
context.logger.warn(
|
||||
`This build is using the application builder but transforms.webpackConfiguration was provided. The transform will be ignored.`,
|
||||
);
|
||||
}
|
||||
|
||||
const testDir = path.join(context.workspaceRoot, 'dist/test-out', randomUUID());
|
||||
const outputPath = path.join(context.workspaceRoot, 'dist/test-out', randomUUID());
|
||||
const projectSourceRoot = await getProjectSourceRoot(context);
|
||||
|
||||
const [karma, entryPoints] = await Promise.all([
|
||||
import('karma'),
|
||||
collectEntrypoints(options, context, projectSourceRoot),
|
||||
fs.rm(testDir, { recursive: true, force: true }),
|
||||
fs.rm(outputPath, { recursive: true, force: true }),
|
||||
]);
|
||||
|
||||
const outputPath = testDir;
|
||||
|
||||
const instrumentForCoverage = options.codeCoverage
|
||||
? createInstrumentationFilter(
|
||||
projectSourceRoot,
|
||||
@ -147,30 +225,27 @@ async function initializeApplication(
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const buildOptions: BuildOptions = {
|
||||
entryPoints,
|
||||
tsConfig: options.tsConfig,
|
||||
outputPath,
|
||||
aot: false,
|
||||
index: false,
|
||||
outputHashing: OutputHashing.None,
|
||||
optimization: false,
|
||||
sourceMap: {
|
||||
scripts: true,
|
||||
styles: true,
|
||||
vendor: true,
|
||||
},
|
||||
instrumentForCoverage,
|
||||
styles: options.styles,
|
||||
polyfills: normalizePolyfills(options.polyfills),
|
||||
webWorkerTsConfig: options.webWorkerTsConfig,
|
||||
};
|
||||
|
||||
// Build tests with `application` builder, using test files as entry points.
|
||||
const buildOutput = await first(
|
||||
buildApplicationInternal(
|
||||
{
|
||||
entryPoints,
|
||||
tsConfig: options.tsConfig,
|
||||
outputPath,
|
||||
aot: false,
|
||||
index: false,
|
||||
outputHashing: OutputHashing.None,
|
||||
optimization: false,
|
||||
sourceMap: {
|
||||
scripts: true,
|
||||
styles: true,
|
||||
vendor: true,
|
||||
},
|
||||
instrumentForCoverage,
|
||||
styles: options.styles,
|
||||
polyfills: normalizePolyfills(options.polyfills),
|
||||
webWorkerTsConfig: options.webWorkerTsConfig,
|
||||
},
|
||||
context,
|
||||
),
|
||||
);
|
||||
const buildOutput = await first(buildApplicationInternal(buildOptions, context));
|
||||
if (buildOutput.kind === ResultKind.Failure) {
|
||||
throw new ApplicationBuildError('Build failed');
|
||||
} else if (buildOutput.kind !== ResultKind.Full) {
|
||||
@ -180,24 +255,24 @@ async function initializeApplication(
|
||||
}
|
||||
|
||||
// Write test files
|
||||
await writeTestFiles(buildOutput.files, testDir);
|
||||
await writeTestFiles(buildOutput.files, buildOptions.outputPath);
|
||||
|
||||
karmaOptions.files ??= [];
|
||||
karmaOptions.files.push(
|
||||
// Serve polyfills first.
|
||||
{ pattern: `${testDir}/polyfills.js`, type: 'module' },
|
||||
{ pattern: `${outputPath}/polyfills.js`, type: 'module' },
|
||||
// Allow loading of chunk-* files but don't include them all on load.
|
||||
{ pattern: `${testDir}/{chunk,worker}-*.js`, type: 'module', included: false },
|
||||
{ pattern: `${outputPath}/{chunk,worker}-*.js`, type: 'module', included: false },
|
||||
);
|
||||
|
||||
karmaOptions.files.push(
|
||||
// Serve remaining JS on page load, these are the test entrypoints.
|
||||
{ pattern: `${testDir}/*.js`, type: 'module' },
|
||||
{ pattern: `${outputPath}/*.js`, type: 'module' },
|
||||
);
|
||||
|
||||
if (options.styles?.length) {
|
||||
// Serve CSS outputs on page load, these are the global styles.
|
||||
karmaOptions.files.push({ pattern: `${testDir}/*.css`, type: 'css' });
|
||||
karmaOptions.files.push({ pattern: `${outputPath}/*.css`, type: 'css' });
|
||||
}
|
||||
|
||||
const parsedKarmaConfig: Config & ConfigOptions = await karma.config.parseConfig(
|
||||
@ -238,7 +313,7 @@ async function initializeApplication(
|
||||
parsedKarmaConfig.reporters = (parsedKarmaConfig.reporters ?? []).concat(['coverage']);
|
||||
}
|
||||
|
||||
return [karma, parsedKarmaConfig];
|
||||
return [karma, parsedKarmaConfig, buildOptions];
|
||||
}
|
||||
|
||||
export async function writeTestFiles(files: Record<string, ResultFile>, testDir: string) {
|
||||
|
@ -9,15 +9,10 @@
|
||||
import { concatMap, count, debounceTime, take, timeout } from 'rxjs';
|
||||
import { execute } from '../../index';
|
||||
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
|
||||
import { BuilderOutput } from '@angular-devkit/architect';
|
||||
|
||||
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget, isApplicationBuilder) => {
|
||||
describe('Behavior: "Rebuilds"', () => {
|
||||
if (isApplicationBuilder) {
|
||||
beforeEach(() => {
|
||||
pending('--watch not implemented yet for application builder');
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupTarget(harness);
|
||||
});
|
||||
@ -30,37 +25,48 @@ describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget, isAppli
|
||||
|
||||
const goodFile = await harness.readFile('src/app/app.component.spec.ts');
|
||||
|
||||
interface OutputCheck {
|
||||
(result: BuilderOutput | undefined): Promise<void>;
|
||||
}
|
||||
|
||||
const expectedSequence: OutputCheck[] = [
|
||||
async (result) => {
|
||||
// Karma run should succeed.
|
||||
// Add a compilation error.
|
||||
expect(result?.success).toBeTrue();
|
||||
// Add an syntax error to a non-main file.
|
||||
await harness.appendToFile('src/app/app.component.spec.ts', `error`);
|
||||
},
|
||||
async (result) => {
|
||||
expect(result?.success).toBeFalse();
|
||||
await harness.writeFile('src/app/app.component.spec.ts', goodFile);
|
||||
},
|
||||
async (result) => {
|
||||
expect(result?.success).toBeTrue();
|
||||
},
|
||||
];
|
||||
if (isApplicationBuilder) {
|
||||
expectedSequence.unshift(async (result) => {
|
||||
// This is the initial Karma run, it should succeed.
|
||||
// For simplicity, we trigger a run the first time we build in watch mode.
|
||||
expect(result?.success).toBeTrue();
|
||||
});
|
||||
}
|
||||
|
||||
const buildCount = await harness
|
||||
.execute({ outputLogsOnFailure: false })
|
||||
.pipe(
|
||||
timeout(60000),
|
||||
debounceTime(500),
|
||||
concatMap(async ({ result }, index) => {
|
||||
switch (index) {
|
||||
case 0:
|
||||
// Karma run should succeed.
|
||||
// Add a compilation error.
|
||||
expect(result?.success).toBeTrue();
|
||||
// Add an syntax error to a non-main file.
|
||||
await harness.appendToFile('src/app/app.component.spec.ts', `error`);
|
||||
break;
|
||||
|
||||
case 1:
|
||||
expect(result?.success).toBeFalse();
|
||||
await harness.writeFile('src/app/app.component.spec.ts', goodFile);
|
||||
break;
|
||||
|
||||
case 2:
|
||||
expect(result?.success).toBeTrue();
|
||||
break;
|
||||
}
|
||||
await expectedSequence[index](result);
|
||||
}),
|
||||
take(3),
|
||||
take(expectedSequence.length),
|
||||
count(),
|
||||
)
|
||||
.toPromise();
|
||||
|
||||
expect(buildCount).toBe(3);
|
||||
expect(buildCount).toBe(expectedSequence.length);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user