packages/@aws-cdk/integ-runner/lib/runner/integration-tests.ts (182 lines of code) (raw):
import * as path from 'path';
import * as fs from 'fs-extra';
const CDK_OUTDIR_PREFIX = 'cdk-integ.out';
/**
* Represents a single integration test
*
* This type is a data-only structure, so it can trivially be passed to workers.
* Derived attributes are calculated using the `IntegTest` class.
*/
export interface IntegTestInfo {
/**
* Path to the file to run
*
* Path is relative to the current working directory.
*/
readonly fileName: string;
/**
* The root directory we discovered this test from
*
* Path is relative to the current working directory.
*/
readonly discoveryRoot: string;
/**
* The CLI command used to run this test.
* If it contains {filePath}, the test file names will be substituted at that place in the command for each run.
*
* @default - test run command will be `node {filePath}`
*/
readonly appCommand?: string;
/**
* true if this test is running in watch mode
*
* @default false
*/
readonly watch?: boolean;
}
/**
* Derived information for IntegTests
*/
export class IntegTest {
/**
* The name of the file to run
*
* Path is relative to the current working directory.
*/
public readonly fileName: string;
/**
* Relative path to the file to run
*
* Relative from the "discovery root".
*/
public readonly discoveryRelativeFileName: string;
/**
* The absolute path to the file
*/
public readonly absoluteFileName: string;
/**
* The normalized name of the test. This name
* will be the same regardless of what directory the tool
* is run from.
*/
public readonly normalizedTestName: string;
/**
* Directory the test is in
*/
public readonly directory: string;
/**
* Display name for the test
*
* Depends on the discovery directory.
*
* Looks like `integ.mytest` or `package/test/integ.mytest`.
*/
public readonly testName: string;
/**
* Path of the snapshot directory for this test
*/
public readonly snapshotDir: string;
/**
* Path to the temporary output directory for this test
*/
public readonly temporaryOutputDir: string;
/**
* The CLI command used to run this test.
* If it contains {filePath}, the test file names will be substituted at that place in the command for each run.
*
* @default - test run command will be `node {filePath}`
*/
readonly appCommand: string;
constructor(public readonly info: IntegTestInfo) {
this.appCommand = info.appCommand ?? 'node {filePath}';
this.absoluteFileName = path.resolve(info.fileName);
this.fileName = path.relative(process.cwd(), info.fileName);
const parsed = path.parse(this.fileName);
this.discoveryRelativeFileName = path.relative(info.discoveryRoot, info.fileName);
// if `--watch` then we need the directory to be the cwd
this.directory = info.watch ? process.cwd() : parsed.dir;
// if we are running in a package directory then just use the fileName
// as the testname, but if we are running in a parent directory with
// multiple packages then use the directory/filename as the testname
//
// Looks either like `integ.mytest` or `package/test/integ.mytest`.
const relDiscoveryRoot = path.relative(process.cwd(), info.discoveryRoot);
this.testName = this.directory === path.join(relDiscoveryRoot, 'test') || this.directory === path.join(relDiscoveryRoot)
? parsed.name
: path.join(path.relative(this.info.discoveryRoot, parsed.dir), parsed.name);
this.normalizedTestName = parsed.name;
this.snapshotDir = path.join(parsed.dir, `${parsed.base}.snapshot`);
this.temporaryOutputDir = path.join(parsed.dir, `${CDK_OUTDIR_PREFIX}.${parsed.base}.snapshot`);
}
/**
* Whether this test matches the user-given name
*
* We are very lenient here. A name matches if it matches:
*
* - The CWD-relative filename
* - The discovery root-relative filename
* - The suite name
* - The absolute filename
*/
public matches(name: string) {
return [
this.fileName,
this.discoveryRelativeFileName,
this.testName,
this.absoluteFileName,
].includes(name);
}
}
/**
* Configuration options how integration test files are discovered
*/
export interface IntegrationTestsDiscoveryOptions {
/**
* If this is set to true then the list of tests
* provided will be excluded
*
* @default false
*/
readonly exclude?: boolean;
/**
* List of tests to include (or exclude if `exclude=true`)
*
* @default - all matched files
*/
readonly tests?: string[];
/**
* A map of of the app commands to run integration tests with,
* and the regex patterns matching the integration test files each app command.
*
* If the app command contains {filePath}, the test file names will be substituted at that place in the command for each run.
*/
readonly testCases: {
[app: string]: string[];
};
}
/**
* Returns the name of the Python executable for the current OS
*/
function pythonExecutable() {
let python = 'python3';
if (process.platform === 'win32') {
python = 'python';
}
return python;
}
/**
* Discover integration tests
*/
export class IntegrationTests {
constructor(private readonly directory: string) {
}
/**
* Get integration tests discovery options from CLI options
*/
public async fromCliOptions(options: {
app?: string;
exclude?: boolean;
language?: string[];
testRegex?: string[];
tests?: string[];
}): Promise<IntegTest[]> {
const baseOptions = {
tests: options.tests,
exclude: options.exclude,
};
// Explicitly set both, app and test-regex
if (options.app && options.testRegex) {
return this.discover({
testCases: {
[options.app]: options.testRegex,
},
...baseOptions,
});
}
// Use the selected presets
if (!options.app && !options.testRegex) {
// Only case with multiple languages, i.e. the only time we need to check the special case
const ignoreUncompiledTypeScript = options.language?.includes('javascript') && options.language?.includes('typescript');
return this.discover({
testCases: this.getLanguagePresets(options.language),
...baseOptions,
}, ignoreUncompiledTypeScript);
}
// Only one of app or test-regex is set, with a single preset selected
// => override either app or test-regex
if (options.language?.length === 1) {
const [presetApp, presetTestRegex] = this.getLanguagePreset(options.language[0]);
return this.discover({
testCases: {
[options.app ?? presetApp]: options.testRegex ?? presetTestRegex,
},
...baseOptions,
});
}
// Only one of app or test-regex is set, with multiple presets
// => impossible to resolve
const option = options.app ? '--app' : '--test-regex';
throw new Error(`Only a single "--language" can be used with "${option}". Alternatively provide both "--app" and "--test-regex" to fully customize the configuration.`);
}
/**
* Get the default configuration for a language
*/
private getLanguagePreset(language: string) {
const languagePresets: {
[language: string]: [string, string[]];
} = {
javascript: ['node {filePath}', ['^integ\\..*\\.js$']],
typescript: ['node -r ts-node/register {filePath}', ['^integ\\.(?!.*\\.d\\.ts$).*\\.ts$']],
python: [`${pythonExecutable()} {filePath}`, ['^integ_.*\\.py$']],
go: ['go run {filePath}', ['^integ_.*\\.go$']],
};
return languagePresets[language];
}
/**
* Get the config for all selected languages
*/
private getLanguagePresets(languages: string[] = []) {
return Object.fromEntries(
languages
.map(language => this.getLanguagePreset(language))
.filter(Boolean),
);
}
/**
* If the user provides a list of tests, these can either be a list of tests to include or a list of tests to exclude.
*
* - If it is a list of tests to include then we discover all available tests and check whether they have provided valid tests.
* If they have provided a test name that we don't find, then we write out that error message.
* - If it is a list of tests to exclude, then we discover all available tests and filter out the tests that were provided by the user.
*/
private filterTests(discoveredTests: IntegTest[], requestedTests?: string[], exclude?: boolean): IntegTest[] {
if (!requestedTests) {
return discoveredTests;
}
const allTests = discoveredTests.filter(t => {
const matches = requestedTests.some(pattern => t.matches(pattern));
return matches !== !!exclude; // Looks weird but is equal to (matches && !exclude) || (!matches && exclude)
});
// If not excluding, all patterns must have matched at least one test
if (!exclude) {
const unmatchedPatterns = requestedTests.filter(pattern => !discoveredTests.some(t => t.matches(pattern)));
for (const unmatched of unmatchedPatterns) {
process.stderr.write(`No such integ test: ${unmatched}\n`);
}
if (unmatchedPatterns.length > 0) {
process.stderr.write(`Available tests: ${discoveredTests.map(t => t.discoveryRelativeFileName).join(' ')}\n`);
return [];
}
}
return allTests;
}
/**
* Takes an optional list of tests to look for, otherwise
* it will look for all tests from the directory
*
* @param tests Tests to include or exclude, undefined means include all tests.
* @param exclude Whether the 'tests' list is inclusive or exclusive (inclusive by default).
*/
private async discover(options: IntegrationTestsDiscoveryOptions, ignoreUncompiledTypeScript: boolean = false): Promise<IntegTest[]> {
const files = await this.readTree();
const testCases = Object.entries(options.testCases)
.flatMap(([appCommand, patterns]) => files
.filter(fileName => patterns.some((pattern) => {
const regex = new RegExp(pattern);
return regex.test(fileName) || regex.test(path.basename(fileName));
}))
.map(fileName => new IntegTest({
discoveryRoot: this.directory,
fileName,
appCommand,
})),
);
const discoveredTests = ignoreUncompiledTypeScript ? this.filterUncompiledTypeScript(testCases) : testCases;
return this.filterTests(discoveredTests, options.tests, options.exclude);
}
private filterUncompiledTypeScript(testCases: IntegTest[]): IntegTest[] {
const jsTestCases = testCases.filter(t => t.fileName.endsWith('.js'));
return testCases
// Remove all TypeScript test cases (ending in .ts)
// for which a compiled version is present (same name, ending in .js)
.filter((tsCandidate) => {
if (!tsCandidate.fileName.endsWith('.ts')) {
return true;
}
return jsTestCases.findIndex(jsTest => jsTest.testName === tsCandidate.testName) === -1;
});
}
private async readTree(): Promise<string[]> {
const ret = new Array<string>();
async function recurse(dir: string) {
const files = await fs.readdir(dir);
for (const file of files) {
const fullPath = path.join(dir, file);
const statf = await fs.stat(fullPath);
if (statf.isFile()) {
ret.push(fullPath);
}
if (statf.isDirectory()) {
await recurse(fullPath);
}
}
}
await recurse(this.directory);
return ret;
}
}