@travetto/test
Version:
Declarative test framework
241 lines (211 loc) • 8.07 kB
text/typescript
import { createReadStream } from 'node:fs';
import fs from 'node:fs/promises';
import readline from 'node:readline/promises';
import path from 'node:path';
import { Env, ExecUtil, Util, RuntimeIndex, Runtime, TimeUtil, JSONUtil } from '@travetto/runtime';
import { WorkPool } from '@travetto/worker';
import { Registry } from '@travetto/registry';
import type { TestConfig, TestRunInput, TestRun, TestGlobInput, TestDiffInput } from '../model/test.ts';
import type { TestRemoveEvent } from '../model/event.ts';
import type { TestConsumerShape } from '../consumer/types.ts';
import { RunnableTestConsumer } from '../consumer/types/runnable.ts';
import type { TestConsumerConfig } from './types.ts';
import { TestConsumerRegistryIndex } from '../consumer/registry-index.ts';
import { TestExecutor } from './executor.ts';
import { buildStandardTestManager } from '../worker/standard.ts';
import { SuiteRegistryIndex } from '../registry/registry-index.ts';
type RunState = {
runs: TestRun[];
removes?: TestRemoveEvent[];
};
/**
* Test Utilities for Running
*/
export class RunUtil {
/**
* Determine if a given file path is a valid test file
*/
static async isTestFile(file: string): Promise<boolean> {
const reader = readline.createInterface({ input: createReadStream(file) });
const state = { imp: false, suite: false };
for await (const line of reader) {
state.imp ||= line.includes('@travetto/test');
state.suite ||= line.includes('Suite'); // Decorator or name
if (state.imp && state.suite) {
reader.close();
return true;
}
}
return false;
}
/**
* Find all valid test files given the globs
*/
static async* getTestImports(globs?: string[]): AsyncIterable<string> {
const all = RuntimeIndex.find({
module: module => module.roles.includes('test') || module.roles.includes('std'),
folder: folder => folder === 'test',
file: file => file.role === 'test'
});
// Collect globs
if (globs?.length) {
const allFiles = new Map(all.map(file => [file.sourceFile, file]));
for await (const item of fs.glob(globs)) {
const source = Runtime.workspaceRelative(path.resolve(item));
const match = allFiles.get(source);
if (match && await this.isTestFile(match.sourceFile)) {
yield match.import;
}
}
} else {
for await (const match of all) {
if (await this.isTestFile(match.sourceFile)) {
yield match.import;
}
}
}
}
/**
* Get count of tests for a given set of globs
* @param input
*/
static async resolveGlobInput({ globs, tags, metadata }: TestGlobInput): Promise<TestRun[]> {
const digestProcess = await ExecUtil.getResult(
ExecUtil.spawnPackageCommand('trv', ['test:digest', '-o', 'json', ...globs], {
env: { ...process.env, ...Env.FORCE_COLOR.export(0), ...Env.NO_COLOR.export(true) },
}),
{ catch: true }
);
if (!digestProcess.valid) {
throw new Error(digestProcess.stderr);
}
const testFilter = tags?.length ?
Util.allowDeny<string, [TestConfig]>(
tags,
rule => rule,
(rule, core) => core.tags?.includes(rule) ?? false
) :
((): boolean => true);
const parsed: TestConfig[] = JSONUtil.parseSafe(digestProcess.stdout);
const events = parsed.filter(testFilter).reduce((runs, test) => {
if (!runs.has(test.classId)) {
runs.set(test.classId, { import: test.import, classId: test.classId, methodNames: [], runId: Util.uuid(), metadata });
}
runs.get(test.classId)!.methodNames!.push(test.methodName);
return runs;
}, new Map<string, TestRun>());
return [...events.values()].sort((a, b) => a.runId!.localeCompare(b.runId!));
}
/**
* Resolve a test diff source to ensure we are only running changed tests
*/
static async resolveDiffInput({ import: importPath, diffSource: diff, metadata }: TestDiffInput): Promise<RunState> {
// Runs, defaults to new classes
const runs: TestRun[] = [];
const addRun = (clsId: string | undefined, methods?: string[]): void => {
runs.push({ import: importPath, classId: clsId, methodNames: methods?.length ? methods : undefined, metadata });
};
const removes: TestRemoveEvent[] = [];
const removeTest = (clsId: string, methodName?: string): void => {
removes.push({ type: 'removeTest', import: importPath, classId: clsId, methodName });
};
const imported = await Registry.manualInit([importPath]);
const classes = Object.fromEntries(
imported
.filter(cls => SuiteRegistryIndex.hasConfig(cls))
.map(cls => [cls.Ⲑid, SuiteRegistryIndex.getConfig(cls)])
);
// New classes
for (const clsId of Object.keys(classes)) {
if (!diff[clsId]) {
addRun(clsId);
}
}
// Looking at Diff
for (const [clsId, config] of Object.entries(diff)) {
const local = classes[clsId];
if (!local) { // Removed classes
removeTest(clsId);
} else if (local.sourceHash !== config.sourceHash) { // Class changed or added
// Methods to run, defaults to newly added
const methods: string[] = Object.keys(local.tests ?? {}).filter(key => !config.methods[key]);
let didRemove = false;
for (const key of Object.keys(config.methods)) {
const localMethod = local.tests?.[key];
if (!localMethod) { // Test is removed
removeTest(clsId, key);
didRemove = true;
} else if (localMethod.sourceHash !== config.methods[key]) { // Method changed or added
methods.push(key);
}
}
if (!didRemove || methods.length > 0) {
addRun(clsId, methods);
}
}
}
if (runs.length === 0 && removes.length === 0) { // Re-run entire file, classes unchanged
addRun(undefined);
}
return { runs, removes };
}
/**
* Reinitialize the manifest if needed, mainly for single test runs
*/
static async reinitManifestIfNeeded(runs: TestRun[]): Promise<void> {
if (runs.length === 1) {
const [run] = runs;
const entry = RuntimeIndex.getFromImport(run.import)!;
if (entry.module !== Runtime.main.name) {
RuntimeIndex.reinitForModule(entry.module);
}
}
}
/**
* Build test consumer that wraps a given targeted consumer, and the tests to be run
*/
static async getRunnableConsumer(target: TestConsumerShape, testRuns: TestRun[]): Promise<RunnableTestConsumer> {
const consumer = new RunnableTestConsumer(target);
const testCount = testRuns.reduce((acc, cur) => acc + (cur.methodNames ? cur.methodNames.length : 0), 0);
await consumer.onStart({ testCount });
return consumer;
}
/**
* Resolve input into run state
*/
static async resolveInput(input: TestRunInput): Promise<RunState> {
if ('diffSource' in input) {
return await this.resolveDiffInput(input);
} else if ('globs' in input) {
return { runs: await this.resolveGlobInput(input) };
} else {
return { runs: [input], removes: [] };
}
}
/**
* Run tests
*/
static async runTests(consumerConfig: TestConsumerConfig, input: TestRunInput): Promise<boolean | undefined> {
const { runs, removes } = await this.resolveInput(input);
const targetConsumer = await TestConsumerRegistryIndex.getInstance(consumerConfig);
const consumer = await this.getRunnableConsumer(targetConsumer, runs);
await this.reinitManifestIfNeeded(runs);
for (const item of removes ?? []) {
consumer.onRemoveEvent(item);
}
if (runs.length === 1) {
await new TestExecutor(consumer).execute(runs[0]);
} else {
await WorkPool.run(
run => buildStandardTestManager(consumer, run),
runs,
{
idleTimeoutMillis: TimeUtil.asMillis(10, 's'),
min: 1,
max: consumerConfig.concurrency
}
);
}
return consumer.summarizeAsBoolean();
}
}