Skip to content
Merged
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
Expand Up @@ -8,6 +8,7 @@

import { BuildOutputFileType } from '@angular/build';
import {
ApplicationBuilderInternalOptions,
ResultFile,
ResultKind,
buildApplicationInternal,
Expand All @@ -19,20 +20,95 @@ 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);
this.name = 'ApplicationBuildError';
}
}

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,
Expand All @@ -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 });
Expand Down Expand Up @@ -122,55 +202,50 @@ 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,
getInstrumentationExcludedPaths(context.workspaceRoot, options.codeCoverageExclude ?? []),
)
: 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) {
Expand All @@ -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(
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand All @@ -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);
});
});
});