@angular/build
Version:
Official build system for Angular
561 lines (560 loc) • 25.7 kB
JavaScript
;
/**
* @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.dev/license
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.execute = execute;
exports.writeTestFiles = writeTestFiles;
const node_crypto_1 = require("node:crypto");
const fs = __importStar(require("node:fs/promises"));
const node_module_1 = require("node:module");
const path = __importStar(require("node:path"));
const tinyglobby_1 = require("tinyglobby");
const bundler_context_1 = require("../../tools/esbuild/bundler-context");
const utils_1 = require("../../tools/esbuild/utils");
const virtual_module_plugin_1 = require("../../tools/esbuild/virtual-module-plugin");
const project_metadata_1 = require("../../utils/project-metadata");
const index_1 = require("../application/index");
const results_1 = require("../application/results");
const schema_1 = require("../application/schema");
const find_tests_1 = require("./find-tests");
const localResolve = (0, node_module_1.createRequire)(__filename).resolve;
const isWindows = process.platform === 'win32';
class ApplicationBuildError extends Error {
constructor(message) {
super(message);
this.name = 'ApplicationBuildError';
}
}
const LATEST_BUILD_FILES_TOKEN = 'angularLatestBuildFiles';
class AngularAssetsMiddleware {
serveFile;
latestBuildFiles;
static $inject = ['serveFile', LATEST_BUILD_FILES_TOKEN];
static NAME = 'angular-test-assets';
constructor(serveFile, latestBuildFiles) {
this.serveFile = serveFile;
this.latestBuildFiles = latestBuildFiles;
}
handle(req, res, next) {
const url = new URL(`http://${req.headers['host']}${req.url}`);
// Remove the leading slash from the URL path and convert to platform specific.
// The latest build files will use the platform path separator.
let pathname = url.pathname.slice(1);
if (isWindows) {
pathname = pathname.replaceAll(path.posix.sep, path.win32.sep);
}
const file = this.latestBuildFiles.files[pathname];
if (!file) {
next();
return;
}
switch (file.origin) {
case 'disk':
this.serveFile(file.inputPath, undefined, res, undefined, undefined, /* doNotCache */ true);
break;
case 'memory':
// Include pathname to help with Content-Type headers.
this.serveFile(`/unused/${url.pathname}`, undefined, res, undefined, file.contents, true);
break;
}
}
static createPlugin(initialFiles) {
return {
[LATEST_BUILD_FILES_TOKEN]: ['value', { files: { ...initialFiles.files } }],
[`middleware:${AngularAssetsMiddleware.NAME}`]: [
'factory',
Object.assign((...args) => {
const inst = new AngularAssetsMiddleware(...args);
return inst.handle.bind(inst);
}, AngularAssetsMiddleware),
],
};
}
}
class AngularPolyfillsPlugin {
static $inject = ['config.files'];
static NAME = 'angular-polyfills';
static createPlugin(polyfillsFile, jasmineCleanupFiles, scriptsFiles) {
return {
// This has to be a "reporter" because reporters run _after_ frameworks
// and karma-jasmine-html-reporter injects additional scripts that may
// depend on Jasmine but aren't modules - which means that they would run
// _before_ all module code (including jasmine).
[`reporter:${AngularPolyfillsPlugin.NAME}`]: [
'factory',
Object.assign((files) => {
// The correct order is zone.js -> jasmine -> zone.js/testing.
// Jasmine has to see the patched version of the global `setTimeout`
// function so it doesn't cache the unpatched version. And /testing
// needs to see the global `jasmine` object so it can patch it.
const polyfillsIndex = 0;
files.splice(polyfillsIndex, 0, polyfillsFile);
// Insert just before test_main.js.
const zoneTestingIndex = files.findIndex((f) => {
if (typeof f === 'string') {
return false;
}
return f.pattern.endsWith('/test_main.js');
});
if (zoneTestingIndex === -1) {
throw new Error('Could not find test entrypoint file.');
}
files.splice(zoneTestingIndex, 0, jasmineCleanupFiles);
// We need to ensure that all files are served as modules, otherwise
// the order in the files list gets really confusing: Karma doesn't
// set defer on scripts, so all scripts with type=js will run first,
// even if type=module files appeared earlier in `files`.
for (const f of files) {
if (typeof f === 'string') {
throw new Error(`Unexpected string-based file: "${f}"`);
}
if (f.included === false) {
// Don't worry about files that aren't included on the initial
// page load. `type` won't affect them.
continue;
}
if (f.pattern.endsWith('.js') && 'js' === (f.type ?? 'js')) {
f.type = 'module';
}
}
// Add "scripts" option files as classic scripts
files.unshift(...scriptsFiles);
// Add browser sourcemap support as a classic script
files.unshift({
pattern: localResolve('source-map-support/browser-source-map-support.js'),
included: true,
watched: false,
});
}, AngularPolyfillsPlugin),
],
};
}
}
function injectKarmaReporter(buildOptions, buildIterator, karmaConfig, controller) {
const reporterName = 'angular-progress-notifier';
class ProgressNotifierReporter {
emitter;
latestBuildFiles;
static $inject = ['emitter', LATEST_BUILD_FILES_TOKEN];
constructor(emitter, latestBuildFiles) {
this.emitter = emitter;
this.latestBuildFiles = latestBuildFiles;
this.startWatchingBuild();
}
startWatchingBuild() {
void (async () => {
// This is effectively "for await of but skip what's already consumed".
let isDone = false; // to mark the loop condition as "not constant".
while (!isDone) {
const { done, value: buildOutput } = await buildIterator.next();
if (done) {
isDone = true;
break;
}
if (buildOutput.kind === results_1.ResultKind.Failure) {
controller.enqueue({ success: false, message: 'Build failed' });
}
else if (buildOutput.kind === results_1.ResultKind.Incremental ||
buildOutput.kind === results_1.ResultKind.Full) {
if (buildOutput.kind === results_1.ResultKind.Full) {
this.latestBuildFiles.files = buildOutput.files;
}
else {
this.latestBuildFiles.files = {
...this.latestBuildFiles.files,
...buildOutput.files,
};
}
await writeTestFiles(buildOutput.files, buildOptions.outputPath);
this.emitter.refreshFiles();
}
}
})();
}
onRunComplete = function (_browsers, results) {
if (results.exitCode === 0) {
controller.enqueue({ success: true });
}
else {
controller.enqueue({ success: false });
}
};
}
karmaConfig.reporters ??= [];
karmaConfig.reporters.push(reporterName);
karmaConfig.plugins ??= [];
karmaConfig.plugins.push({
[`reporter:${reporterName}`]: [
'factory',
Object.assign((...args) => new ProgressNotifierReporter(...args), ProgressNotifierReporter),
],
});
}
function execute(options, context, karmaOptions, transforms = {}) {
let karmaServer;
return new ReadableStream({
async start(controller) {
let init;
try {
init = await initializeApplication(options, context, karmaOptions, transforms);
}
catch (err) {
if (err instanceof ApplicationBuildError) {
controller.enqueue({ success: false, message: err.message });
controller.close();
return;
}
throw err;
}
const [karma, karmaConfig, buildOptions, buildIterator] = init;
// If `--watch` is explicitly enabled or if we are keeping the Karma
// process running, we should hook Karma into the build.
if (buildIterator) {
injectKarmaReporter(buildOptions, buildIterator, karmaConfig, controller);
}
// Close the stream once the Karma server returns.
karmaServer = new karma.Server(karmaConfig, (exitCode) => {
controller.enqueue({ success: exitCode === 0 });
controller.close();
});
await karmaServer.start();
},
async cancel() {
await karmaServer?.stop();
},
});
}
async function getProjectSourceRoot(context) {
// We have already validated that the project name is set before calling this function.
const projectName = context.target?.project;
if (!projectName) {
return context.workspaceRoot;
}
const projectMetadata = await context.getProjectMetadata(projectName);
const { projectSourceRoot } = (0, project_metadata_1.getProjectRootPaths)(context.workspaceRoot, projectMetadata);
return projectSourceRoot;
}
function normalizePolyfills(polyfills) {
if (typeof polyfills === 'string') {
polyfills = [polyfills];
}
else if (!polyfills) {
polyfills = [];
}
const jasmineGlobalEntryPoint = localResolve('./polyfills/jasmine_global.js');
const jasmineGlobalCleanupEntrypoint = localResolve('./polyfills/jasmine_global_cleanup.js');
const sourcemapEntrypoint = localResolve('./polyfills/init_sourcemaps.js');
const zoneTestingEntryPoint = 'zone.js/testing';
const polyfillsExludingZoneTesting = polyfills.filter((p) => p !== zoneTestingEntryPoint);
return [
polyfillsExludingZoneTesting.concat([jasmineGlobalEntryPoint, sourcemapEntrypoint]),
polyfillsExludingZoneTesting.length === polyfills.length
? [jasmineGlobalCleanupEntrypoint]
: [jasmineGlobalCleanupEntrypoint, zoneTestingEntryPoint],
];
}
async function collectEntrypoints(options, context, projectSourceRoot) {
// Glob for files to test.
const testFiles = await (0, find_tests_1.findTests)(options.include ?? [], options.exclude ?? [], context.workspaceRoot, projectSourceRoot);
return (0, find_tests_1.getTestEntrypoints)(testFiles, { projectSourceRoot, workspaceRoot: context.workspaceRoot });
}
// eslint-disable-next-line max-lines-per-function
async function initializeApplication(options, context, karmaOptions, transforms = {}) {
const outputPath = path.join(context.workspaceRoot, 'dist/test-out', (0, node_crypto_1.randomUUID)());
const projectSourceRoot = await getProjectSourceRoot(context);
const [karma, entryPoints] = await Promise.all([
Promise.resolve().then(() => __importStar(require('karma'))),
collectEntrypoints(options, context, projectSourceRoot),
fs.rm(outputPath, { recursive: true, force: true }),
]);
const mainName = 'test_main';
if (options.main) {
entryPoints.set(mainName, options.main);
}
else {
entryPoints.set(mainName, 'angular:test-bed-init');
}
const instrumentForCoverage = options.codeCoverage
? createInstrumentationFilter(projectSourceRoot, getInstrumentationExcludedPaths(context.workspaceRoot, options.codeCoverageExclude ?? []))
: undefined;
const [polyfills, jasmineCleanup] = normalizePolyfills(options.polyfills);
for (let idx = 0; idx < jasmineCleanup.length; ++idx) {
entryPoints.set(`jasmine-cleanup-${idx}`, jasmineCleanup[idx]);
}
const buildOptions = {
assets: options.assets,
entryPoints,
tsConfig: options.tsConfig,
outputPath,
preserveSymlinks: options.preserveSymlinks,
aot: options.aot,
index: false,
outputHashing: schema_1.OutputHashing.None,
optimization: false,
sourceMap: options.codeCoverage
? {
scripts: true,
styles: true,
vendor: true,
}
: options.sourceMap,
instrumentForCoverage,
styles: options.styles,
scripts: options.scripts,
polyfills,
webWorkerTsConfig: options.webWorkerTsConfig,
watch: options.watch ?? !karmaOptions.singleRun,
stylePreprocessorOptions: options.stylePreprocessorOptions,
inlineStyleLanguage: options.inlineStyleLanguage,
fileReplacements: options.fileReplacements,
define: options.define,
loader: options.loader,
externalDependencies: options.externalDependencies,
};
const virtualTestBedInit = (0, virtual_module_plugin_1.createVirtualModulePlugin)({
namespace: 'angular:test-bed-init',
loadContent: async () => {
const contents = [
// Initialize the Angular testing environment
`import { getTestBed } from '@angular/core/testing';`,
`import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';`,
`getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting(), {`,
` errorOnUnknownElements: true,`,
` errorOnUnknownProperties: true,`,
'});',
];
return {
contents: contents.join('\n'),
loader: 'js',
resolveDir: projectSourceRoot,
};
},
});
// Build tests with `application` builder, using test files as entry points.
const [buildOutput, buildIterator] = await first((0, index_1.buildApplicationInternal)(buildOptions, context, { codePlugins: [virtualTestBedInit] }), { cancel: !buildOptions.watch });
if (buildOutput.kind === results_1.ResultKind.Failure) {
throw new ApplicationBuildError('Build failed');
}
else if (buildOutput.kind !== results_1.ResultKind.Full) {
throw new ApplicationBuildError('A full build result is required from the application builder.');
}
// Write test files
await writeTestFiles(buildOutput.files, buildOptions.outputPath);
// We need to add this to the beginning *after* the testing framework has
// prepended its files. The output path is required for each since they are
// added later in the test process via a plugin.
const polyfillsFile = {
pattern: `${outputPath}/polyfills.js`,
included: true,
served: true,
type: 'module',
watched: false,
};
const jasmineCleanupFiles = {
pattern: `${outputPath}/jasmine-cleanup-*.js`,
included: true,
served: true,
type: 'module',
watched: false,
};
karmaOptions.basePath = outputPath;
const scriptsFiles = [];
if (options.scripts?.length) {
const outputScripts = new Set();
for (const scriptEntry of options.scripts) {
const outputName = typeof scriptEntry === 'string'
? 'scripts.js'
: `${scriptEntry.bundleName ?? 'scripts'}.js`;
if (outputScripts.has(outputName)) {
continue;
}
outputScripts.add(outputName);
scriptsFiles.push({
pattern: `${outputPath}/${outputName}`,
watched: false,
type: 'js',
});
}
}
karmaOptions.files ??= [];
karmaOptions.files.push(
// Serve global setup script.
{ pattern: `${mainName}.js`, type: 'module', watched: false },
// Serve all source maps.
{ pattern: `*.map`, included: false, watched: false },
// These are the test entrypoints.
{ pattern: `spec-*.js`, type: 'module', watched: false });
if (hasChunkOrWorkerFiles(buildOutput.files)) {
karmaOptions.files.push(
// Allow loading of chunk-* files but don't include them all on load.
{
pattern: `{chunk,worker}-*.js`,
type: 'module',
included: false,
watched: false,
});
}
if (options.styles?.length) {
// Serve CSS outputs on page load, these are the global styles.
karmaOptions.files.push({ pattern: `*.css`, type: 'css', watched: false });
}
const parsedKarmaConfig = await karma.config.parseConfig(options.karmaConfig && path.resolve(context.workspaceRoot, options.karmaConfig), transforms.karmaOptions ? transforms.karmaOptions(karmaOptions) : karmaOptions, { promiseConfig: true, throwErrors: true });
// Check for jsdom which does not support executing ESM scripts.
// If present, remove jsdom and issue a warning.
const updatedBrowsers = parsedKarmaConfig.browsers?.filter((browser) => browser !== 'jsdom');
if (parsedKarmaConfig.browsers?.length !== updatedBrowsers?.length) {
parsedKarmaConfig.browsers = updatedBrowsers;
context.logger.warn(`'jsdom' does not support ESM code execution and cannot be used for karma testing.` +
` The 'jsdom' entry has been removed from the 'browsers' option.`);
}
// Remove the webpack plugin/framework:
// Alternative would be to make the Karma plugin "smart" but that's a tall order
// with managing unneeded imports etc..
parsedKarmaConfig.plugins ??= [];
const pluginLengthBefore = parsedKarmaConfig.plugins.length;
parsedKarmaConfig.plugins = parsedKarmaConfig.plugins.filter((plugin) => {
if (typeof plugin === 'string') {
return plugin !== 'framework:@angular-devkit/build-angular';
}
return !plugin['framework:@angular-devkit/build-angular'];
});
parsedKarmaConfig.frameworks ??= [];
parsedKarmaConfig.frameworks = parsedKarmaConfig.frameworks.filter((framework) => framework !== '@angular-devkit/build-angular');
const pluginLengthAfter = parsedKarmaConfig.plugins.length;
if (pluginLengthBefore !== pluginLengthAfter) {
context.logger.warn(`Ignoring framework "@angular-devkit/build-angular" from karma config file because it's not compatible with the application builder.`);
}
parsedKarmaConfig.plugins.push(AngularAssetsMiddleware.createPlugin(buildOutput));
parsedKarmaConfig.middleware ??= [];
parsedKarmaConfig.middleware.push(AngularAssetsMiddleware.NAME);
parsedKarmaConfig.plugins.push(AngularPolyfillsPlugin.createPlugin(polyfillsFile, jasmineCleanupFiles, scriptsFiles));
parsedKarmaConfig.reporters ??= [];
parsedKarmaConfig.reporters.push(AngularPolyfillsPlugin.NAME);
// Adjust karma junit reporter outDir location to maintain previous (devkit) behavior
// The base path for the reporter was previously the workspace root.
// To keep the files in the same location, the reporter's output directory is adjusted
// to be relative to the workspace root when using junit.
if (parsedKarmaConfig.reporters?.some((reporter) => reporter === 'junit')) {
if ('junitReporter' in parsedKarmaConfig) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const junitReporterOptions = parsedKarmaConfig['junitReporter'];
if (junitReporterOptions.outputDir == undefined) {
junitReporterOptions.outputDir = context.workspaceRoot;
}
else if (typeof junitReporterOptions.outputDir === 'string' &&
!path.isAbsolute(junitReporterOptions.outputDir)) {
junitReporterOptions.outputDir = path.join(context.workspaceRoot, junitReporterOptions.outputDir);
}
}
else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
parsedKarmaConfig['junitReporter'] = {
outputDir: context.workspaceRoot,
};
}
}
// When using code-coverage, auto-add karma-coverage.
// This was done as part of the karma plugin for webpack.
if (options.codeCoverage &&
!parsedKarmaConfig.reporters?.some((r) => r === 'coverage' || r === 'coverage-istanbul')) {
parsedKarmaConfig.reporters = (parsedKarmaConfig.reporters ?? []).concat(['coverage']);
}
return [karma, parsedKarmaConfig, buildOptions, buildIterator];
}
function hasChunkOrWorkerFiles(files) {
return Object.keys(files).some((filename) => {
return /(?:^|\/)(?:worker|chunk)[^/]+\.js$/.test(filename);
});
}
async function writeTestFiles(files, testDir) {
const directoryExists = new Set();
// Writes the test related output files to disk and ensures the containing directories are present
await (0, utils_1.emitFilesToDisk)(Object.entries(files), async ([filePath, file]) => {
if (file.type !== bundler_context_1.BuildOutputFileType.Browser && file.type !== bundler_context_1.BuildOutputFileType.Media) {
return;
}
const fullFilePath = path.join(testDir, filePath);
// Ensure output subdirectories exist
const fileBasePath = path.dirname(fullFilePath);
if (fileBasePath && !directoryExists.has(fileBasePath)) {
await fs.mkdir(fileBasePath, { recursive: true });
directoryExists.add(fileBasePath);
}
if (file.origin === 'memory') {
// Write file contents
await fs.writeFile(fullFilePath, file.contents);
}
else {
// Copy file contents
await fs.copyFile(file.inputPath, fullFilePath, fs.constants.COPYFILE_FICLONE);
}
});
}
/** Returns the first item yielded by the given generator and cancels the execution. */
async function first(generator, { cancel }) {
if (!cancel) {
const iterator = generator[Symbol.asyncIterator]();
const firstValue = await iterator.next();
if (firstValue.done) {
throw new Error('Expected generator to emit at least once.');
}
return [firstValue.value, iterator];
}
for await (const value of generator) {
return [value, null];
}
throw new Error('Expected generator to emit at least once.');
}
function createInstrumentationFilter(includedBasePath, excludedPaths) {
return (request) => {
return (!excludedPaths.has(request) &&
!/\.(e2e|spec)\.tsx?$|[\\/]node_modules[\\/]/.test(request) &&
request.startsWith(includedBasePath));
};
}
function getInstrumentationExcludedPaths(root, excludedPaths) {
const excluded = new Set();
for (const excludeGlob of excludedPaths) {
const excludePath = excludeGlob[0] === '/' ? excludeGlob.slice(1) : excludeGlob;
(0, tinyglobby_1.globSync)(excludePath, { cwd: root }).forEach((p) => excluded.add(path.join(root, p)));
}
return excluded;
}