Updates to rules_nodejs 2.2.0. This is the first major release in 7 months and includes a number of features as well as breaking changes. Release notes: https://github.com/bazelbuild/rules_nodejs/releases/tag/2.0.0 Features of note for angular/angular: * stdout/stderr/exit code capture; this could be potentially be useful * TypeScript (ts_project); a simpler tsc rule that ts_library that can be used in the repo where ts_library is too heavy weight Breaking changes of note for angular/angular: * loading custom rules from npm packages: `ts_library` is no longer loaded from `@npm_bazel_typescript//:index.bzl` (which no longer exists) but is now loaded from `@npm//@bazel/typescript:index.bzl` * with the loading changes above, `load("@npm//:install_bazel_dependencies.bzl", "install_bazel_dependencies")` is no longer needed in the WORKSPACE which also means that yarn_install does not need to run unless building/testing a target that depends on @npm. In angular/angular this is a minor improvement as almost everything depends on @npm. * @angular/bazel package is also updated in this PR to support the new load location; Angular + Bazel users that require it for ng_package (ng_module is no longer needed in OSS with Angular 10) will need to load from `@npm//@angular/bazel:index.bzl`. I investigated if it was possible to maintain backward compatability for the old load location `@npm_angular_bazel` but it is not since the package itself needs to be updated to load from `@npm//@bazel/typescript:index.bzl` instead of `@npm_bazel_typescript//:index.bzl` as it depends on ts_library internals for ng_module. * runfiles.resolve will now throw instead of returning undefined to match behavior of node require Other changes in angular/angular: * integration/bazel has been updated to use both ng_module and ts_libary with use_angular_plugin=true. The latter is the recommended way for rules_nodejs users to compile Angular 10 with Ivy. Bazel + Angular ViewEngine is supported with @angular/bazel <= 9.0.5 and Angular <= 8. There is still Angular ViewEngine example on rules_nodejs https://github.com/bazelbuild/rules_nodejs/tree/stable/examples/angular_view_engine on these older versions but users that want to update to Angular 10 and are on Bazel must switch to Ivy and at that point ts_library with use_angular_plugin=true is more performant that ng_module. Angular example in rules_nodejs is configured this way as well: https://github.com/bazelbuild/rules_nodejs/tree/stable/examples/angular. As an aside, we also have an example of building Angular 10 with architect() rule directly instead of using ts_library with angular plugin: https://github.com/bazelbuild/rules_nodejs/tree/stable/examples/angular_bazel_architect. NB: ng_module is still required for angular/angular repository as it still builds ViewEngine & @angular/bazel also provides the ng_package rule. ng_module can be removed in the future if ViewEngine is no longer needed in angular repo. * JSModuleInfo provider added to ng_module. this is for forward compat for future rules_nodejs versions. PR Close #39182
		
			
				
	
	
		
			392 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			392 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			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.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;
 | |
| 
 | |
| // bazelisk requires a $HOME environment variable for its cache
 | |
| process.env['HOME'] = tmp.dirSync({keep: KEEP_TMP, unsafeCleanup: !KEEP_TMP}).name;
 | |
| 
 | |
| 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);
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Expands environment variables in a string of the form ${FOO_BAR}.
 | |
|  */
 | |
| function expandEnv(s) {
 | |
|   if (!s) return s;
 | |
|   const reg = /\$\{(\w+)\}/g;
 | |
|   return s.replace(reg, (matched) => {
 | |
|     const varName = matched.substring(2, matched.length - 1);
 | |
|     if (process.env.hasOwnProperty(varName)) {
 | |
|       return process.env[varName];
 | |
|     } else {
 | |
|       throw `Failed to expand unbound environment variable '${varName}' in '${s}'`;
 | |
|     }
 | |
|   });
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * 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();
 | |
|     this._writeNpmPackageManifest();
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * 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).map(a => expandEnv(a));
 | |
|       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)}`;
 | |
|           }
 | |
|           try {
 | |
|             // Attempt to resolve runfiles location if command is expected to
 | |
|             // be in runfiles. For example, $(rootpath @nodejs//:yarn_bin)
 | |
|             const runfilesBinary = runfiles.resolveWorkspaceRelative(binary);
 | |
|             binary = (runfilesBinary && fs.existsSync(runfilesBinary)) ? runfilesBinary : binary;
 | |
|           } catch (e) {
 | |
|             // If resolveWorkspaceRelative then command is likely a built-in
 | |
|             // such as 'mkdir' or 'rm'
 | |
|           }
 | |
|           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}`);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * @internal
 | |
|    *
 | |
|    * Write an NPM_PACKAGE_MANIFEST.json file to the test root with a mapping of
 | |
|    * the npm package mappings for this this test. Integration tests can opt
 | |
|    * to use this mappings file instead of the built-in `patch-package-json`
 | |
|    * command.
 | |
|    */
 | |
|   _writeNpmPackageManifest() {
 | |
|     if (!this.testRoot) {
 | |
|       fail(`test files not yet setup`);
 | |
|     }
 | |
|     const manifest = {};
 | |
|     for (const key of Object.keys(this.config.npmPackages)) {
 | |
|       manifest[key] = runfiles.resolveWorkspaceRelative(this.config.npmPackages[key]);
 | |
|     }
 | |
|     const manifestPath = `${this.testRoot}/NPM_PACKAGE_MANIFEST.json`;
 | |
|     fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
 | |
|     log(`npm package manifest written to ${manifestPath}`);
 | |
|   }
 | |
| }
 | |
| 
 | |
| const config = require(runfiles.resolveWorkspaceRelative(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);
 |