angular-cli/tests/legacy-cli/e2e_runner.ts
Charles Lyding ee5763dcac refactor(@angular-devkit/build-angular): use fast-glob for file searching support
The file searching within the build system (both Webpack and esbuild) now use the
`fast-glob` package for globbing which provides a small performance improvement.
Since the assets option in particular is within the critical path of the buil pipeline,
the performance benefit from the switch will be most prevalent in asset heavy projects.
As an example, the Angular Material documentation site saw the asset discovery time
reduced by over half with the switch. `fast-glob` is also the package used by Vite
which provides additional benefit by ensuring that the Angular CLI behavior matches
that of the newly integrated Vite development server.
2023-05-31 14:54:13 -04:00

389 lines
13 KiB
TypeScript

import { createConsoleLogger } from '../../packages/angular_devkit/core/node';
import colors from 'ansi-colors';
import glob from 'fast-glob';
import yargsParser from 'yargs-parser';
import * as path from 'path';
import { getGlobalVariable, setGlobalVariable } from './e2e/utils/env';
import { gitClean } from './e2e/utils/git';
import { createNpmRegistry } from './e2e/utils/registry';
import { launchTestProcess } from './e2e/utils/process';
import { delimiter, dirname, join } from 'path';
import { findFreePort } from './e2e/utils/network';
import { extractFile } from './e2e/utils/tar';
import { realpathSync } from 'fs';
import { PkgInfo } from './e2e/utils/packages';
Error.stackTraceLimit = Infinity;
// tslint:disable:no-global-tslint-disable no-console
/**
* Here's a short description of those flags:
* --debug If a test fails, block the thread so the temporary directory isn't deleted.
* --noproject Skip creating a project or using one.
* --noglobal Skip linking your local @angular/cli directory. Can save a few seconds.
* --nosilent Never silence ng commands.
* --ng-tag=TAG Use a specific tag for build snapshots. Similar to ng-snapshots but point to a
* tag instead of using the latest `main`.
* --ng-snapshots Install angular snapshot builds in the test project.
* --glob Run tests matching this glob pattern (relative to tests/e2e/).
* --ignore Ignore tests matching this glob pattern.
* --reuse=/path Use a path instead of create a new project. That project should have been
* created, and npm installed. Ideally you want a project created by a previous
* run of e2e.
* --nb-shards Total number of shards that this is part of. Default is 2 if --shard is
* passed in.
* --shard Index of this processes' shard.
* --tmpdir=path Override temporary directory to use for new projects.
* --yarn Use yarn as package manager.
* --package=path An npm package to be published before running tests
*
* If unnamed flags are passed in, the list of tests will be filtered to include only those passed.
*/
const argv = yargsParser(process.argv.slice(2), {
boolean: [
'debug',
'esbuild',
'ng-snapshots',
'noglobal',
'nosilent',
'noproject',
'verbose',
'yarn',
],
string: ['devkit', 'glob', 'ignore', 'reuse', 'ng-tag', 'tmpdir', 'ng-version'],
number: ['nb-shards', 'shard'],
array: ['package'],
configuration: {
'camel-case-expansion': false,
'dot-notation': false,
},
default: {
'package': ['./dist/_*.tgz'],
'debug': !!process.env.BUILD_WORKSPACE_DIRECTORY,
'glob': process.env.TESTBRIDGE_TEST_ONLY,
'nb-shards':
Number(process.env.E2E_SHARD_TOTAL ?? 1) * Number(process.env.TEST_TOTAL_SHARDS ?? 1) || 1,
'shard':
process.env.E2E_SHARD_INDEX === undefined && process.env.TEST_SHARD_INDEX === undefined
? undefined
: Number(process.env.E2E_SHARD_INDEX ?? 0) * Number(process.env.TEST_TOTAL_SHARDS ?? 1) +
Number(process.env.TEST_SHARD_INDEX ?? 0),
},
});
/**
* Set the error code of the process to 255. This is to ensure that if something forces node
* to exit without finishing properly, the error code will be 255. Right now that code is not used.
*
* - 1 When tests succeed we already call `process.exit(0)`, so this doesn't change any correct
* behaviour.
*
* One such case that would force node <= v6 to exit with code 0, is a Promise that doesn't resolve.
*/
process.exitCode = 255;
/**
* Mark this process as the main e2e_runner
*/
process.env.LEGACY_CLI_RUNNER = '1';
/**
* Add external git toolchain onto PATH
*/
if (process.env.GIT_BIN) {
process.env.PATH = process.env.PATH! + delimiter + dirname(process.env.GIT_BIN!);
}
/**
* Add external browser toolchains onto PATH
*/
if (process.env.CHROME_BIN) {
process.env.PATH = process.env.PATH! + delimiter + dirname(process.env.CHROME_BIN!);
}
const logger = createConsoleLogger(argv.verbose, process.stdout, process.stderr, {
info: (s) => s,
debug: (s) => s,
warn: (s) => colors.bold.yellow(s),
error: (s) => colors.bold.red(s),
fatal: (s) => colors.bold.red(s),
});
const logStack = [logger];
function lastLogger() {
return logStack[logStack.length - 1];
}
// Under bazel the compiled file (.js) and types (.d.ts) are available.
const SRC_FILE_EXT_RE = /\.js$/;
const testGlob = argv.glob?.replace(/\.ts$/, '.js') || `tests/**/*.js`;
const e2eRoot = path.join(__dirname, 'e2e');
const allSetups = glob.sync(`setup/**/*.js`, { cwd: e2eRoot }).sort();
const allInitializers = glob.sync(`initialize/**/*.js`, { cwd: e2eRoot }).sort();
const allTests = glob
.sync(testGlob, { cwd: e2eRoot, ignore: argv.ignore })
// Replace windows slashes.
.map((name) => name.replace(/\\/g, '/'))
.filter((name) => {
if (name.endsWith('/setup.js')) {
return false;
}
if (!SRC_FILE_EXT_RE.test(name)) {
return false;
}
// The below is to exclude specific tests that are not intented to run for the current package manager.
// This is also important as without the trickery the tests that take the longest ex: update.ts (2.5mins)
// will be executed on the same shard.
const fileName = path.basename(name);
if (
(fileName.startsWith('yarn-') && !argv.yarn) ||
(fileName.startsWith('npm-') && argv.yarn)
) {
return false;
}
return true;
})
.sort();
const shardId = argv['shard'] !== undefined ? Number(argv['shard']) : null;
const nbShards = shardId === null ? 1 : Number(argv['nb-shards']);
const tests = allTests.filter((name) => {
// Check for naming tests on command line.
if (argv._.length == 0) {
return true;
}
return argv._.some((argName) => {
return (
path.join(process.cwd(), argName + '') == path.join(__dirname, 'e2e', name) ||
argName == name ||
argName == name.replace(SRC_FILE_EXT_RE, '')
);
});
});
// Remove tests that are not part of this shard.
const testsToRun = tests.filter((name, i) => shardId === null || i % nbShards == shardId);
if (testsToRun.length === 0) {
if (shardId !== null && tests.length <= shardId) {
console.log(`No tests to run on shard ${shardId}, exiting.`);
process.exit(0);
} else {
console.log(`No tests would be ran, aborting.`);
process.exit(1);
}
}
if (shardId !== null) {
console.log(`Running shard ${shardId} of ${nbShards}`);
}
/**
* Load all the files from the e2e, filter and sort them and build a promise of their default
* export.
*/
if (testsToRun.length == allTests.length) {
console.log(`Running ${testsToRun.length} tests`);
} else {
console.log(`Running ${testsToRun.length} tests (${allTests.length} total)`);
}
console.log(['Tests:', ...testsToRun].join('\n '));
setGlobalVariable('argv', argv);
setGlobalVariable('package-manager', argv.yarn ? 'yarn' : 'npm');
// Use the chrome supplied by bazel or the puppeteer chrome and webdriver-manager driver outside.
// This is needed by karma-chrome-launcher, protractor etc.
// https://github.com/karma-runner/karma-chrome-launcher#headless-chromium-with-puppeteer
//
// Resolve from relative paths to absolute paths within the bazel runfiles tree
// so subprocesses spawned in a different working directory can still find them.
process.env.CHROME_BIN = path.resolve(process.env.CHROME_BIN!);
process.env.CHROMEDRIVER_BIN = path.resolve(process.env.CHROMEDRIVER_BIN!);
Promise.all([findFreePort(), findFreePort(), findPackageTars()])
.then(async ([httpPort, httpsPort, packageTars]) => {
setGlobalVariable('package-registry', 'http://localhost:' + httpPort);
setGlobalVariable('package-secure-registry', 'http://localhost:' + httpsPort);
setGlobalVariable('package-tars', packageTars);
// NPM registries for the lifetime of the test execution
const registryProcess = await createNpmRegistry(httpPort, httpPort);
const secureRegistryProcess = await createNpmRegistry(httpPort, httpsPort, true);
try {
await runSteps(runSetup, allSetups, 'setup');
await runSteps(runInitializer, allInitializers, 'initializer');
await runSteps(runTest, testsToRun, 'test');
if (shardId !== null) {
console.log(colors.green(`Done shard ${shardId} of ${nbShards}.`));
} else {
console.log(colors.green('Done.'));
}
process.exitCode = 0;
} catch (err) {
if (err instanceof Error) {
console.log('\n');
console.error(colors.red(err.message));
if (err.stack) {
console.error(colors.red(err.stack));
}
} else {
console.error(colors.red(String(err)));
}
if (argv.debug) {
console.log(`Current Directory: ${process.cwd()}`);
console.log('Will loop forever while you debug... CTRL-C to quit.');
/* eslint-disable no-constant-condition */
while (1) {
// That's right!
}
}
process.exitCode = 1;
} finally {
registryProcess.kill();
secureRegistryProcess.kill();
}
})
.catch((err) => {
console.error(colors.red(`Unkown Error: ${err}`));
process.exitCode = 1;
});
async function runSteps(
run: (name: string) => Promise<void> | void,
steps: string[],
type: 'setup' | 'test' | 'initializer',
) {
const capsType = type[0].toUpperCase() + type.slice(1);
for (const [stepIndex, relativeName] of steps.entries()) {
// Make sure this is a windows compatible path.
let absoluteName = path.join(e2eRoot, relativeName).replace(SRC_FILE_EXT_RE, '');
if (/^win/.test(process.platform)) {
absoluteName = absoluteName.replace(/\\/g, path.posix.sep);
}
const name = relativeName.replace(SRC_FILE_EXT_RE, '');
const start = Date.now();
printHeader(name, stepIndex, steps.length, type);
// Run the test function with the current file on the logStack.
logStack.push(lastLogger().createChild(absoluteName));
try {
await run(absoluteName);
} catch (e) {
console.log('\n');
console.error(colors.red(`${capsType} "${name}" failed...`));
throw e;
} finally {
logStack.pop();
}
console.log('----');
printFooter(name, type, start);
}
}
function runSetup(absoluteName: string): Promise<void> {
const module = require(absoluteName);
return (typeof module === 'function' ? module : module.default)();
}
/**
* Run a file from the projects root directory in a subprocess via launchTestProcess().
*/
function runInitializer(absoluteName: string): Promise<void> {
process.chdir(getGlobalVariable('projects-root'));
return launchTestProcess(absoluteName);
}
/**
* Run a file from the main 'test-project' directory in a subprocess via launchTestProcess().
*/
async function runTest(absoluteName: string): Promise<void> {
process.chdir(join(getGlobalVariable('projects-root'), 'test-project'));
await launchTestProcess(absoluteName);
await gitClean();
}
function printHeader(
testName: string,
testIndex: number,
count: number,
type: 'setup' | 'initializer' | 'test',
) {
const text = `${testIndex + 1} of ${count}`;
const fullIndex = testIndex * nbShards + (shardId ?? 0) + 1;
const shard =
shardId === null || type !== 'test'
? ''
: colors.yellow(` [${shardId}:${nbShards}]` + colors.bold(` (${fullIndex}/${tests.length})`));
console.log(
colors.green(
`Running ${type} "${colors.bold.blue(testName)}" (${colors.bold.white(text)}${shard})...`,
),
);
}
function printFooter(testName: string, type: 'setup' | 'initializer' | 'test', startTime: number) {
const capsType = type[0].toUpperCase() + type.slice(1);
// Round to hundredth of a second.
const t = Math.round((Date.now() - startTime) / 10) / 100;
console.log(
colors.green(`${capsType} "${colors.bold.blue(testName)}" took `) +
colors.bold.blue('' + t) +
colors.green('s...'),
);
console.log('');
}
// Collect the packages passed as arguments and return as {package-name => pkg-path}
async function findPackageTars(): Promise<{ [pkg: string]: PkgInfo }> {
const pkgs: string[] = (getGlobalVariable('argv').package as string[]).flatMap((p) =>
glob.sync(p),
);
const pkgJsons = await Promise.all(
pkgs
.map((pkg) => realpathSync(pkg))
.map(async (pkg) => {
try {
return await extractFile(pkg, './package/package.json');
} catch (e) {
// TODO(bazel): currently the bazel npm packaging does not contain the standard npm ./package directory
return await extractFile(pkg, './package.json');
}
}),
);
return pkgs.reduce((all, pkg, i) => {
const json = pkgJsons[i].toString('utf8');
const { name, version } = JSON.parse(json);
if (!name) {
throw new Error(`Package ${pkg} - package.json name/version not found`);
}
all[name] = { path: realpathSync(pkg), name, version };
return all;
}, {} as { [pkg: string]: PkgInfo });
}