angular-cli/tests/legacy-cli/e2e_runner.ts
Paul Gschwendtner 5fd1cb56ab build: update dev-infra and rework windows native testing
As part of go/ng:windows-dev-future, we are changing how our
infrastructure supports Windows build & testing. Clearly:

- we will still support contributors on Windows, and we believe we will
  be improving and streamlining the experience here
- we will continue testing the Angular CLI for our Windows users. We are
  aware of the many Windows users using the `ng` CLI.

What is changing? We are no longer actively working towards a Bazel infrastructure
that supports native Windows building and testing. There are currently
two ways to contribute to Angular on Windows. That is via WSL, or via
e.g. native Windows cmd.exe, with Git Bash on top. We acknowledge that
the latter worked sometimes, but we also realize it very often breaks as
nobody on our team uses, verifies it, and it introduces extra complexity
because Bazel on Windows is quite disconnected from Linux/Mac (e.g. no
sandboxing). Going forward, to improve our team's effectiveness, and
improve our stability guarantees for Windows (and Windows contributors),
we are actively discouraging the use of Git Bash for contributing to
Angular; but instead ask for WSL to be used. I can speak as one of the
few long-term team members that have worked on Windows (without WSL) most
of my time, that WSL is great and the contributing experience is much
smoother and also easier to "guide". It's a positive change because we
won't be suggesting "two ways to contribute on Windows", where in
reality one is very brittle and can break at any time!

---

For testing of the Angular CLI: We will continue to maintain the
capability to cross-compile via Bazel with Windows as the target
platform. This allows us to build the e2e tests for Windows, and run
them natively outside WSL to ensure native Windows `ng` CLI testing!
This is what this change mostly does.

Notably, two things are missing here and will be followed up:

- caching of the e2e tests on Windows is not properly functioning yet.
- caching of the WSL node modules + nvm is not working properly yet.

Other than that, we are seeing very similar timing and results of the
Windows tests, so this change unblocks our `rules_js` migration.
2025-03-03 21:44:50 +01:00

405 lines
14 KiB
TypeScript

import { parseArgs } from 'node:util';
import { createConsoleLogger } from '../../packages/angular_devkit/core/node';
import colors from 'ansi-colors';
import glob from 'fast-glob';
import * as path from 'node: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 'node:path';
import { findFreePort } from './e2e/utils/network';
import { extractFile } from './e2e/utils/tar';
import { realpathSync } from 'node:fs';
import { PkgInfo } from './e2e/utils/packages';
import { rm } from 'node:fs/promises';
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.
* --package-manager Package manager to use.
* --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 parsed = parseArgs({
allowPositionals: true,
options: {
'debug': { type: 'boolean', default: !!process.env.BUILD_WORKSPACE_DIRECTORY },
'esbuild': { type: 'boolean' },
'glob': { type: 'string', default: process.env.TESTBRIDGE_TEST_ONLY },
'ignore': { type: 'string', multiple: true },
'ng-snapshots': { type: 'boolean' },
'ng-tag': { type: 'string' },
'ng-version': { type: 'string' },
'noglobal': { type: 'boolean' },
'noproject': { type: 'boolean' },
'nosilent': { type: 'boolean' },
'package': { type: 'string', multiple: true, default: ['./dist/_*.tgz'] },
'package-manager': { type: 'string', default: 'npm' },
'reuse': { type: 'string' },
'tmpdir': { type: 'string' },
'verbose': { type: 'boolean' },
'nb-shards': { type: 'string' },
'shard': { type: 'string' },
},
});
const argv = {
...parsed.values,
_: parsed.positionals,
'nb-shards':
parsed.values['nb-shards'] ??
(Number(process.env.E2E_SHARD_TOTAL ?? 1) * Number(process.env.TEST_TOTAL_SHARDS ?? 1) || 1),
shard:
parsed.values.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['package-manager'] !== 'yarn') ||
(fileName.startsWith('npm-') && argv['package-manager'] !== 'npm')
) {
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, '')
);
});
});
console.log(`Running with shard configuration:`);
console.log(`Total shards: ${nbShards}, current shard: ${shardId}`);
// 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`);
console.log(`Without sharding, there were ${tests.length} tests found.`);
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['package-manager']);
// 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.CHROME_PATH = path.resolve(process.env.CHROME_PATH!);
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 cleanTestProject();
}
async function cleanTestProject() {
await gitClean();
const testProject = join(getGlobalVariable('projects-root'), 'test-project');
// Note: Dist directory is not cleared between tests, as `git clean`
// doesn't delete it.
await rm(join(testProject, 'dist/'), { recursive: true, force: true });
}
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((pkg) => extractFile(pkg, './package.json')),
);
return pkgs.reduce(
(all, pkg, i) => {
const json = pkgJsons[i].toString('utf8');
const { name, version } = JSON.parse(json) as { name: string; version: string };
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 },
);
}