Skip to content

Commit

Permalink
feat(bazel): add support for custom environment variables in integrat…
Browse files Browse the repository at this point in the history
…ion tests

Adds support for custom environment variables in integration tests. The
environment variable dictionary can use location expansion. Also there
is a special placeholder called `<TMP>` that can be used to acquire a
temporary directory. This is useful when setting up tools like BAZELISK,
which requires a `HOME` environment variable as an example.
  • Loading branch information
devversion committed Oct 15, 2021
1 parent f8a3346 commit fd4a854
Show file tree
Hide file tree
Showing 7 changed files with 185 additions and 26 deletions.
38 changes: 36 additions & 2 deletions bazel/integration/index.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,30 @@ def _serialize_file(file):

return struct(path = file.path, shortPath = file.short_path)

def _serialize_and_expand_location(ctx, value):
"""Expands Bazel make location expressions for the given value. Returns a JSON
serializable dictionary matching the `BazelExpandedValue` type in the test runner."""
new_value = ctx.expand_location(value, targets = ctx.attr.data)

return {
"value": new_value,
"containsExpandedValue": new_value != value,
}

def _split_and_expand_command(ctx, command):
"""Splits a command into the binary and its arguments. Also Bazel locations are expanded."""
expanded_command = ctx.expand_location(command, targets = ctx.attr.data)
return expanded_command.split(" ", 1)
return [_serialize_and_expand_location(v) for v in command.split(" ", 1)]

def _serialize_and_expand_environment(ctx, environment_dict):
"""Converts the given environment dictionary into a JSON-serializable dictionary
that will work with the test runner."""
result = {}

for variable_name in environment_dict:
value = environment_dict[variable_name]
result[variable_name] = _serialize_and_expand_location(ctx, value)

return result

def _unwrap_label_keyed_mappings(dict, description):
"""Unwraps a label-keyed dictionary used for expressing mappings into a JSON-serializable
Expand Down Expand Up @@ -51,6 +71,7 @@ def _integration_test_config_impl(ctx):
testPackage = ctx.label.package,
testFiles = [_serialize_file(f) for f in ctx.files.srcs],
commands = [_split_and_expand_command(ctx, c) for c in ctx.attr.commands],
environment = _serialize_and_expand_environment(ctx, ctx.attr.environment),
npmPackageMappings = npmPackageMappings,
toolMappings = toolMappings,
)
Expand Down Expand Up @@ -105,6 +126,17 @@ _integration_test_config = rule(
This allows for binaries like `node` to be made available to the integration test
using the `PATH` environment variable.""",
),
"environment": attr.string_dict(
doc = """
Dictionary of environment variables and their values. This allows for custom
environment variables to be set when integration commands are invoked.
The environment variable values can use Bazel make location expansion similar
to the `commands` attribute. Additionally, values of `<TMP>` are replaced with
a unique temporary directory. This can be useful when providing `HOME` for
bazelisk or puppeteer as as an example.
""",
),
},
)

Expand All @@ -114,6 +146,7 @@ def integration_test(
commands,
npm_packages = {},
tool_mappings = {},
environment = {},
data = [],
tags = [],
**kwargs):
Expand All @@ -129,6 +162,7 @@ def integration_test(
commands = commands,
npm_packages = npm_packages,
tool_mappings = tool_mappings,
environment = environment,
tags = tags,
)

Expand Down
12 changes: 12 additions & 0 deletions bazel/integration/test_runner/bazel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@ export interface BazelFileInfo {
shortPath: string;
}

/**
* Interface describing a Bazel-expanded value. A integration command for example could
* use a Bazel location expansion to resolve a binary. Such resolved values are captured in
* a structure like this.
*/
export interface BazelExpandedValue {
/** Actual value, with expanded Make expressions if it contained any. */
value: string;
/** Whether the value contains an expanded value. */
containsExpandedValue: boolean;
}

/** Resolves the specified Bazel file to an absolute disk path. */
export function resolveBazelFile(file: BazelFileInfo): string {
return runfiles.resolveWorkspaceRelative(file.shortPath);
Expand Down
14 changes: 14 additions & 0 deletions bazel/integration/test_runner/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* @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
*/

/**
* Placeholder that can be used in the test environment dictionary to reserve a
* temporary directory. A temporary directory can be useful when running tools
* like Bazelisk which need a `HOME` directory for example.
*/
export const ENVIRONMENT_TMP_PLACEHOLDER = '<TMP>';
8 changes: 6 additions & 2 deletions bazel/integration/test_runner/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import {TestRunner} from './runner';
import {BazelFileInfo, runfiles} from './bazel';
import {BazelExpandedValue, BazelFileInfo, runfiles} from './bazel';
import * as fs from 'fs';
import {debug} from './debug';

Expand All @@ -20,7 +20,8 @@ interface TestConfig {
testFiles: BazelFileInfo[];
npmPackageMappings: Record<string, BazelFileInfo>;
toolMappings: Record<string, BazelFileInfo>;
commands: [[binary: string, ...args: string[]]];
commands: [[binary: BazelExpandedValue, ...args: BazelExpandedValue[]]];
environment: Record<string, BazelExpandedValue>;
}

/** Main command line entry-point for the integration test runner. */
Expand All @@ -32,12 +33,15 @@ async function main(): Promise<void> {
const configContent = await fs.promises.readFile(configPath, 'utf8');
const config = JSON.parse(configContent) as TestConfig;

debug('Fetched test config:', config);

const runner = new TestRunner(
config.testFiles,
config.testPackage,
config.toolMappings,
config.npmPackageMappings,
config.commands,
config.environment,
);

await runner.run();
Expand Down
78 changes: 56 additions & 22 deletions bazel/integration/test_runner/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,20 @@ import {
readPackageJsonContents,
updateMappingsForPackageJson,
} from './package_json';
import {BazelFileInfo, resolveBazelFile, resolveBinaryWithRunfiles} from './bazel';
import {
BazelExpandedValue,
BazelFileInfo,
resolveBazelFile,
resolveBinaryWithRunfiles,
} from './bazel';
import {debug} from './debug';
import {
expandEnvironmentVariableSubstitutions,
getBinaryPassThroughScript,
prependToPathVariable,
runCommandInChildProcess,
} from './process_utils';
import {ENVIRONMENT_TMP_PLACEHOLDER} from './constants';

/**
* Test runner that takes a set of files within a Bazel package and copies the files
Expand All @@ -38,18 +44,20 @@ export class TestRunner {
private readonly testPackage: string,
private readonly toolMappings: Record<string, BazelFileInfo>,
private readonly npmPackageMappings: Record<string, BazelFileInfo>,
private readonly commands: [[binary: string, ...args: string[]]],
private readonly commands: [[binary: BazelExpandedValue, ...args: BazelExpandedValue[]]],
private readonly environment: Record<string, BazelExpandedValue>,
) {}

async run() {
const tmpDir = await this._getTmpDirectoryPath();
const toolMappings = await this._setupToolMappingsForTest(tmpDir);
const testDir = await this._getTestTmpDirectoryPath();
const toolMappings = await this._setupToolMappingsForTest(testDir);
const testEnv = await this._createTestProcessEnvironment(testDir, toolMappings.binDir);

console.info(`Running test in: ${path.normalize(tmpDir)}`);
console.info(`Running test in: ${path.normalize(testDir)}`);

await this._copyTestFilesToDirectory(tmpDir);
await this._patchPackageJsonIfNeeded(tmpDir);
await this._runTestCommands(tmpDir, toolMappings.binDir);
await this._copyTestFilesToDirectory(testDir);
await this._patchPackageJsonIfNeeded(testDir);
await this._runTestCommands(testDir, testEnv);
}

/**
Expand All @@ -59,7 +67,7 @@ export class TestRunner {
* In case this test does not run as part of `bazel test`, a system-temporary directory
* is being created, although not being cleaned up to allow for debugging.
*/
private async _getTmpDirectoryPath(): Promise<string> {
private async _getTestTmpDirectoryPath(): Promise<string> {
// Bazel provides a temporary test directory itself when it executes a test. We prefer
// this when the integration test runs with `bazel test`. In other cases we want to
// provide a temporary directory that can be used for manually jumping into the
Expand Down Expand Up @@ -167,24 +175,50 @@ export class TestRunner {
}

/**
* Runs the test commands sequentially in the test directory. An additional directory
* that is added to the command process `$PATH` environment variables can be specified.
* Creates the test process environment. The specified tools bin directory
* will be added to the environment `PATH` variable.
*
* @throws An error if any of the configured commands did not complete successfully.
* User-specified environment variables can use Bazel location expansion as well
* as a special placeholder for acquiring a temporary directory for the test.
*/
private async _runTestCommands(
private async _createTestProcessEnvironment(
testDir: string,
additionalPathDirectory: string | null,
): Promise<void> {
const commandPath =
additionalPathDirectory === null
? process.env.PATH
: prependToPathVariable(additionalPathDirectory, process.env.PATH ?? '');
const commandEnv = {...process.env, PATH: commandPath};
toolsBinDir: string,
): Promise<NodeJS.ProcessEnv> {
const testEnv: NodeJS.ProcessEnv = {...process.env};
let i = 0;

for (let [variableName, value] of Object.entries(this.environment)) {
let envValue: string = value.value;

if (value.containsExpandedValue) {
envValue = await resolveBinaryWithRunfiles(envValue);
} else if (envValue === ENVIRONMENT_TMP_PLACEHOLDER) {
envValue = path.join(testDir, `.tmp-env-${i++}`);
await fs.promises.mkdir(envValue);
}

testEnv[variableName] = envValue;
}

const commandPath = prependToPathVariable(toolsBinDir, testEnv.PATH ?? '');
return {...testEnv, PATH: commandPath};
}

/**
* Runs the test commands sequentially in the test directory with the given test
* environment applied to the child process executing the command.
*
* @throws An error if any of the configured commands did not complete successfully.
*/
private async _runTestCommands(testDir: string, commandEnv: NodeJS.ProcessEnv): Promise<void> {
for (const [binary, ...args] of this.commands) {
const resolvedBinary = await resolveBinaryWithRunfiles(binary);
const evaluatedArgs = expandEnvironmentVariableSubstitutions(args);
// Only resolve the binary if it contains an expanded value. In other cases we would
// not want to resolve through runfiles to avoid accidentally unexpected resolution.
const resolvedBinary = binary.containsExpandedValue
? await resolveBinaryWithRunfiles(binary.value)
: binary.value;
const evaluatedArgs = expandEnvironmentVariableSubstitutions(args.map((v) => v.value));
const success = await runCommandInChildProcess(
resolvedBinary,
evaluatedArgs,
Expand Down
22 changes: 22 additions & 0 deletions bazel/integration/tests/custom_env_variables/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
load("//bazel/integration:index.bzl", "integration_test")

integration_test(
name = "test",
srcs = [
"test.js",
],
commands = [
"node ./test.js",
],
data = ["@npm//:node_modules/semver/package.json"],
environment = {
"CUSTOM_VAR": "yes!",
"RESOLVED_BIN": "$(rootpath @npm//:node_modules/semver/package.json)",
"MANIFEST_PATH": "external/npm/node_modules/semver/package.json",
"BAZELISK_HOME": "<TMP>",
"BAZELISK_HOME_2": "<TMP>",
},
tool_mappings = {
"@nodejs//:node_bin": "node",
},
)
39 changes: 39 additions & 0 deletions bazel/integration/tests/custom_env_variables/test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
const fs = require('fs');

if (process.env.CUSTOM_VAR !== 'yes!') {
console.error('The expected `CUSTOM_VAR` environment variable is not set.');
process.exit(1);
}

if (process.env.RESOLVED_BIN === undefined) {
console.error('The expected `RESOLVED_BIN` variable is not set.');
process.exit(1);
}

if (require(process.env.RESOLVED_BIN).name !== 'semver') {
console.error('The `RESOLVED_BIN` file did not resolve to the "package.json" of "semver".');
process.exit(1);
}

if (process.env.MANIFEST_PATH !== 'external/npm/node_modules/semver/package.json') {
console.error('Expected `MANIFEST_PATH` to remain untouched as it has not not been expanded.');
process.exit(1);
}

const bazeliskHome = process.env.BAZELISK_HOME;
const bazeliskHome_2 = process.env.BAZELISK_HOME_2;

if (!fs.statSync(bazeliskHome).isDirectory()) {
console.error('Expected `BAZELISK_HOME` environment variable to point to a temp directory.');
process.exit(1);
}

if (!fs.statSync(bazeliskHome_2).isDirectory()) {
console.error('Expected `BAZELISK_HOME_2` environment variable to point to a temp directory.');
process.exit(1);
}

if (bazeliskHome === bazeliskHome_2) {
console.error('Expected the bazelisk home variables to point to different temp directories.');
process.exit(1);
}

0 comments on commit fd4a854

Please sign in to comment.