build: add npm_integration_test && angular_integration_test (#33927)
* it's tricky to get out of the runfiles tree with `bazel test` as `BUILD_WORKSPACE_DIRECTORY` is not set but I employed a trick to read the `DO_NOT_BUILD_HERE` file that is one level up from `execroot` and that contains the workspace directory. This is experimental and if `bazel test //:test.debug` fails than `bazel run` is still guaranteed to work as `BUILD_WORKSPACE_DIRECTORY` will be set in that context * test //integration:bazel_test and //integration:bazel-schematics_test exclusively * run "exclusive" and "manual" bazel-in-bazel integration tests in their own CI job as they take 8m+ to execute ``` //integration:bazel-schematics_test PASSED in 317.2s //integration:bazel_test PASSED in 167.8s ``` * Skip all integration tests that are now handled by angular_integration_test except the tests that are tracked for payload size; these are: - cli-hello-world* - hello_world__closure * add & pin @babel deps as newer versions of babel break //packages/localize/src/tools/test:test @babel/core dep had to be pinned to 7.6.4 or else //packages/localize/src/tools/test:test failed. Also //packages/localize uses @babel/generator, @babel/template, @babel/traverse & @babel/types so these deps were added to package.json as they were not being hoisted anymore from @babel/core transitive. NB: integration/hello_world__systemjs_umd test must run with systemjs 0.20.0 NB: systemjs must be at 0.18.10 for legacy saucelabs job to pass NB: With Bazel 2.0, the glob for the files to test `"integration/bazel/**"` is empty if integation/bazel is in .bazelignore. This glob worked under these conditions with 1.1.0. I did not bother testing with 1.2.x as not having integration/bazel in .bazelignore is correct. PR Close #33927
This commit is contained in:

committed by
Miško Hevery

parent
e084835fb1
commit
dde68ff954
335
tools/npm_integration_test/test_runner.js
Normal file
335
tools/npm_integration_test/test_runner.js
Normal file
@ -0,0 +1,335 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. 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.io/license
|
||||
*/
|
||||
|
||||
const spawnSync = require('child_process').spawnSync;
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const tmp = require('tmp');
|
||||
const runfiles = require(process.env['BAZEL_NODE_RUNFILES_HELPER']);
|
||||
|
||||
const VERBOSE_LOGS = !!process.env['VERBOSE_LOGS'];
|
||||
|
||||
// Set to true if you want the /tmp folder created to persist after running `bazel test`
|
||||
const KEEP_TMP = false;
|
||||
|
||||
function fail(...m) {
|
||||
console.error();
|
||||
console.error(`[${path.basename(__filename)}]`);
|
||||
console.error('error:', ...m);
|
||||
console.error();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function log(...m) {
|
||||
console.error(`[${path.basename(__filename)}]`, ...m);
|
||||
}
|
||||
|
||||
function log_verbose(...m) {
|
||||
if (VERBOSE_LOGS) log(...m);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new directory and any necessary subdirectories
|
||||
* if they do not exist.
|
||||
*/
|
||||
function mkdirp(p) {
|
||||
if (!fs.existsSync(p)) {
|
||||
mkdirp(path.dirname(p));
|
||||
fs.mkdirSync(p);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given path exists and is a file.
|
||||
* Note: fs.statSync() is used which resolves symlinks.
|
||||
*/
|
||||
function isFile(p) {
|
||||
return fs.existsSync(p) && fs.statSync(p).isFile();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a given path is an executable file.
|
||||
*/
|
||||
function isExecutable(p) {
|
||||
try {
|
||||
fs.accessSync(p, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given two arrays returns the longest common slice.
|
||||
*/
|
||||
function commonSlice(a, b) {
|
||||
const p = a.length < b.length ? [a, b] : [b, a];
|
||||
for (let i = 0; i < p[0].length; ++i) {
|
||||
if (p[0][i] !== p[1][i]) {
|
||||
return p[0].slice(0, i);
|
||||
}
|
||||
}
|
||||
return p[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a list of files, the root directory is returned
|
||||
*/
|
||||
function rootDirectory(files) {
|
||||
let root = path.dirname(files[0]).replace(/\\/g, '/').split('/');
|
||||
for (f of files) {
|
||||
root = commonSlice(root, path.dirname(f).replace(/\\/g, '/').split('/'));
|
||||
if (!root.length) break;
|
||||
}
|
||||
if (!root.length) {
|
||||
fail(`not all test files are under the same root!`);
|
||||
}
|
||||
return root.join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to copy a list of files under a common root to a destination folder.
|
||||
*/
|
||||
function copy(files, root, to) {
|
||||
for (src of files) {
|
||||
if (!src.startsWith(root)) {
|
||||
fail(`file to copy ${src} is not under root ${root}`);
|
||||
}
|
||||
if (isFile(src)) {
|
||||
const rel = src.slice(root.length + 1);
|
||||
if (rel.startsWith('node_modules/')) {
|
||||
// don't copy nested node_modules
|
||||
continue;
|
||||
}
|
||||
const dest = `${to}/${rel}`;
|
||||
mkdirp(path.dirname(dest));
|
||||
fs.copyFileSync(src, dest);
|
||||
// Set file permissions to set for files copied to tmp folders.
|
||||
// These are needed as files copied out of bazel-bin will have
|
||||
// restrictive permissions that may break tests.
|
||||
fs.chmodSync(dest, isExecutable(src) ? '755' : '644');
|
||||
log_verbose(`copied file ${src} -> ${dest}`);
|
||||
} else {
|
||||
fail('directories in test_files not supported');
|
||||
}
|
||||
}
|
||||
return to;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to copy a list of files to a tmp folder based on their common root.
|
||||
*/
|
||||
function copyToTmp(files) {
|
||||
const resolved = files.map(f => runfiles.resolveWorkspaceRelative(f));
|
||||
return copy(
|
||||
resolved, rootDirectory(resolved),
|
||||
tmp.dirSync({keep: KEEP_TMP, unsafeCleanup: !KEEP_TMP}).name);
|
||||
}
|
||||
|
||||
/**
|
||||
* TestRunner handles setting up the integration test and executing
|
||||
* the test commands based on the config.
|
||||
*/
|
||||
class TestRunner {
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
this.successful = 0;
|
||||
this._setupTestFiles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all test commands in the integration test.
|
||||
* Returns on first failure.
|
||||
*/
|
||||
run() {
|
||||
for (const command of this.config.commands) {
|
||||
// TODO: handle a quoted binary path that contains a space such as "/path to/binary"
|
||||
// and quoted arguments that contain spaces
|
||||
const split = command.split(' ');
|
||||
let binary = split[0];
|
||||
const args = split.slice(1);
|
||||
switch (binary) {
|
||||
case 'patch-package-json': {
|
||||
let packageJsonFile = 'package.json';
|
||||
if (args.length > 0) {
|
||||
packageJsonFile = args[0];
|
||||
}
|
||||
log(`running test command ${this.successful+1} of ${this.config.commands.length}: patching '${packageJsonFile}' in '${this.testRoot}'`);
|
||||
this._patchPackageJson(packageJsonFile);
|
||||
} break;
|
||||
|
||||
default: {
|
||||
if (binary.startsWith('external/')) {
|
||||
binary = `../${binary.substring('external/'.length)}`;
|
||||
}
|
||||
const runfilesBinary = runfiles.resolveWorkspaceRelative(binary);
|
||||
binary = fs.existsSync(runfilesBinary) ? runfilesBinary : binary;
|
||||
log(`running test command ${this.successful+1} of ${this.config.commands.length}: '${binary} ${args.join(' ')}' in '${this.testRoot}'`);
|
||||
const spawnedProcess = spawnSync(binary, args, {cwd: this.testRoot, stdio: 'inherit'});
|
||||
if (spawnedProcess.error) {
|
||||
fail(
|
||||
`test command ${testRunner.successful+1} '${binary} ${args.join(' ')}' failed with ${spawnedProcess.error.code}`);
|
||||
}
|
||||
if (spawnedProcess.status) {
|
||||
log(`test command ${testRunner.successful+1} '${binary} ${args.join(' ')}' failed with status code ${spawnedProcess.status}`);
|
||||
return spawnedProcess.status;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.successful++;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* Patch the specified package.json file with the npmPackages passed in the config.
|
||||
*/
|
||||
_patchPackageJson(packageJsonFile) {
|
||||
const packageJson = `${this.testRoot}/${packageJsonFile}`;
|
||||
if (!isFile(packageJson)) {
|
||||
fail(`no ${packageJsonFile} file found at test root ${this.testRoot}`);
|
||||
}
|
||||
const contents = JSON.parse(fs.readFileSync(packageJson, {encoding: 'utf-8'}));
|
||||
let replacements = 0;
|
||||
// replace npm packages
|
||||
for (const key of Object.keys(this.config.npmPackages)) {
|
||||
const path = runfiles.resolveWorkspaceRelative(this.config.npmPackages[key]);
|
||||
const replacement = `file:${path}`;
|
||||
if (contents.dependencies && contents.dependencies[key]) {
|
||||
replacements++;
|
||||
contents.dependencies[key] = replacement;
|
||||
log(`overriding dependencies['${key}'] npm package with 'file:${path}' in package.json file`);
|
||||
}
|
||||
if (contents.devDependencies && contents.devDependencies[key]) {
|
||||
replacements++;
|
||||
contents.devDependencies[key] = replacement;
|
||||
log(`overriding devDependencies['${key}'] npm package with 'file:${path}' in package.json file`);
|
||||
}
|
||||
if (contents.resolutions && contents.resolutions[key]) {
|
||||
replacements++;
|
||||
contents.resolutions[key] = replacement;
|
||||
log(`overriding resolutions['${key}'] npm package with 'file:${path}' in package.json file`);
|
||||
}
|
||||
// TODO: handle other formats for resolutions such as `some-package/${key}` or
|
||||
// `some-package/**/${key}`
|
||||
const altKey = `**/${key}`;
|
||||
if (contents.resolutions && contents.resolutions[altKey]) {
|
||||
replacements++;
|
||||
contents.resolutions[altKey] = replacement;
|
||||
log(`overriding resolutions['${altKey}'] npm package with 'file:${path}' in package.json file`);
|
||||
}
|
||||
}
|
||||
// check packages that must be replaced
|
||||
const failedPackages = [];
|
||||
for (const key of this.config.checkNpmPackages) {
|
||||
if (contents.dependencies && contents.dependencies[key] &&
|
||||
(!contents.dependencies[key].startsWith('file:') ||
|
||||
contents.dependencies[key].startsWith('file:.'))) {
|
||||
failedPackages.push(key);
|
||||
} else if (
|
||||
contents.devDependencies && contents.devDependencies[key] &&
|
||||
(!contents.devDependencies[key].startsWith('file:') ||
|
||||
contents.devDependencies[key].startsWith('file:.'))) {
|
||||
failedPackages.push(key);
|
||||
}
|
||||
}
|
||||
const contentsEncoded = JSON.stringify(contents, null, 2);
|
||||
log(`package.json file:\n${contentsEncoded}`);
|
||||
if (failedPackages.length) {
|
||||
fail(
|
||||
`expected replacements of npm packages ${JSON.stringify(failedPackages)} not found; add these to the npm_packages attribute`);
|
||||
}
|
||||
if (replacements) {
|
||||
fs.writeFileSync(packageJson, contentsEncoded);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* Copy all the test files to a tmp folder so they are sandboxed for the test
|
||||
* and so that changes made to source files such as patching package.json
|
||||
* do not persist in the user's workspace.
|
||||
*
|
||||
* In debug mode, do not copy any file but set the testRoot to the user's workspace.
|
||||
*/
|
||||
_setupTestFiles() {
|
||||
if (!this.config.testFiles.length) {
|
||||
fail(`no test files`);
|
||||
}
|
||||
if (this.config.debug) {
|
||||
// Setup the test in the test files root directory
|
||||
const root = rootDirectory(this.config.testFiles);
|
||||
if (path.isAbsolute(root)) {
|
||||
fail(`root directory of Bazel test files should not be an absolute path but got '${root}'`);
|
||||
}
|
||||
if (root.startsWith(`../`)) {
|
||||
fail(`debug mode only available with test files in the root workspace`);
|
||||
}
|
||||
let workspaceDirectory = process.env['BUILD_WORKSPACE_DIRECTORY'];
|
||||
if (!workspaceDirectory) {
|
||||
// bazel test
|
||||
const runfilesPath = process.env['RUNFILES'];
|
||||
if (!runfilesPath) {
|
||||
fail(`RUNFILES environment variable is not set`);
|
||||
}
|
||||
// read the contents of the output_base/DO_NOT_BUILD_HERE file to get
|
||||
// the workspace directory
|
||||
const index = runfilesPath.search(/[\\/]execroot[\\/]/);
|
||||
if (index === -1) {
|
||||
fail(`no /execroot/ in runfiles path`);
|
||||
}
|
||||
const outputBase = runfilesPath.substr(0, index);
|
||||
workspaceDirectory =
|
||||
fs.readFileSync(`${outputBase}/DO_NOT_BUILD_HERE`, {encoding: 'utf-8'});
|
||||
}
|
||||
this.testRoot = `${workspaceDirectory}/${root}`;
|
||||
log(`configuring test in-place under ${this.testRoot}`);
|
||||
} else {
|
||||
this.testRoot = copyToTmp(this.config.testFiles);
|
||||
log(`test files from '${rootDirectory(this.config.testFiles)}' copied to tmp folder ${this.testRoot}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const config = require(process.argv[2]);
|
||||
|
||||
// set env vars passed from --define
|
||||
for (const k of Object.keys(config.envVars)) {
|
||||
const v = config.envVars[k];
|
||||
process.env[k] = v;
|
||||
log_verbose(`set environment variable ${k}='${v}'`);
|
||||
}
|
||||
|
||||
log_verbose(`env: ${JSON.stringify(process.env, null, 2)}`);
|
||||
log_verbose(`config: ${JSON.stringify(config, null, 2)}`);
|
||||
log(`running in ${process.cwd()}`);
|
||||
|
||||
const testRunner = new TestRunner(config);
|
||||
const result = testRunner.run();
|
||||
log(`${testRunner.successful} of ${config.commands.length} test commands successful`);
|
||||
if (result) {
|
||||
if (!config.debug) {
|
||||
log(`to run this integration test in debug mode:
|
||||
|
||||
bazel run ${process.env['TEST_TARGET']}.debug
|
||||
|
||||
in debug mode the integration test will be run out of the workspace folder`);
|
||||
}
|
||||
}
|
||||
if (config.debug) {
|
||||
log(`this integration test may be re-run manually from the '${testRunner.testRoot}' folder
|
||||
|
||||
for example,
|
||||
|
||||
cd ${testRunner.testRoot}
|
||||
yarn test`);
|
||||
}
|
||||
process.exit(result);
|
Reference in New Issue
Block a user