Files
vscode-codeql/extensions/ql-vscode/src/query-testing/test-adapter.ts
2023-05-26 10:15:13 +01:00

285 lines
7.8 KiB
TypeScript

import { dirname, extname } from "path";
import * as vscode from "vscode";
import {
TestAdapter,
TestLoadStartedEvent,
TestLoadFinishedEvent,
TestRunStartedEvent,
TestRunFinishedEvent,
TestSuiteEvent,
TestEvent,
TestSuiteInfo,
TestInfo,
TestHub,
} from "vscode-test-adapter-api";
import { TestAdapterRegistrar } from "vscode-test-adapter-util";
import { QLTestDiscovery } from "./qltest-discovery";
import { Event, EventEmitter, CancellationTokenSource } from "vscode";
import { DisposableObject } from "../pure/disposable-object";
import { CodeQLCliServer, TestCompleted } from "../codeql-cli/cli";
import { testLogger } from "../common";
import { TestRunner } from "./test-runner";
import {
FileTreeDirectory,
FileTreeLeaf,
FileTreeNode,
} from "../common/file-tree-nodes";
/**
* Get the full path of the `.expected` file for the specified QL test.
* @param testPath The full path to the test file.
*/
export function getExpectedFile(testPath: string): string {
return getTestOutputFile(testPath, ".expected");
}
/**
* Get the full path of the `.actual` file for the specified QL test.
* @param testPath The full path to the test file.
*/
export function getActualFile(testPath: string): string {
return getTestOutputFile(testPath, ".actual");
}
/**
* Get the directory containing the specified QL test.
* @param testPath The full path to the test file.
*/
export function getTestDirectory(testPath: string): string {
return dirname(testPath);
}
/**
* Gets the the full path to a particular output file of the specified QL test.
* @param testPath The full path to the QL test.
* @param extension The file extension of the output file.
*/
function getTestOutputFile(testPath: string, extension: string): string {
return changeExtension(testPath, extension);
}
/**
* A factory service that creates `QLTestAdapter` objects for workspace folders on demand.
*/
export class QLTestAdapterFactory extends DisposableObject {
constructor(
testHub: TestHub,
testRunner: TestRunner,
cliServer: CodeQLCliServer,
) {
super();
// this will register a QLTestAdapter for each WorkspaceFolder
this.push(
new TestAdapterRegistrar(
testHub,
(workspaceFolder) =>
new QLTestAdapter(workspaceFolder, testRunner, cliServer),
),
);
}
}
/**
* Change the file extension of the specified path.
* @param p The original file path.
* @param ext The new extension, including the `.`.
*/
function changeExtension(p: string, ext: string): string {
return p.slice(0, -extname(p).length) + ext;
}
/**
* Test adapter for QL tests.
*/
export class QLTestAdapter extends DisposableObject implements TestAdapter {
private readonly qlTestDiscovery: QLTestDiscovery;
private readonly _tests = this.push(
new EventEmitter<TestLoadStartedEvent | TestLoadFinishedEvent>(),
);
private readonly _testStates = this.push(
new EventEmitter<
TestRunStartedEvent | TestRunFinishedEvent | TestSuiteEvent | TestEvent
>(),
);
private readonly _autorun = this.push(new EventEmitter<void>());
private runningTask?: vscode.CancellationTokenSource = undefined;
constructor(
public readonly workspaceFolder: vscode.WorkspaceFolder,
private readonly testRunner: TestRunner,
cliServer: CodeQLCliServer,
) {
super();
this.qlTestDiscovery = this.push(
new QLTestDiscovery(workspaceFolder, cliServer),
);
void this.qlTestDiscovery.refresh();
this.push(this.qlTestDiscovery.onDidChangeTests(this.discoverTests, this));
}
public get tests(): Event<TestLoadStartedEvent | TestLoadFinishedEvent> {
return this._tests.event;
}
public get testStates(): Event<
TestRunStartedEvent | TestRunFinishedEvent | TestSuiteEvent | TestEvent
> {
return this._testStates.event;
}
public get autorun(): Event<void> | undefined {
return this._autorun.event;
}
private static createTestOrSuiteInfos(
testNodes: readonly FileTreeNode[],
): Array<TestSuiteInfo | TestInfo> {
return testNodes.map((childNode) => {
return QLTestAdapter.createTestOrSuiteInfo(childNode);
});
}
private static createTestOrSuiteInfo(
testNode: FileTreeNode,
): TestSuiteInfo | TestInfo {
if (testNode instanceof FileTreeLeaf) {
return QLTestAdapter.createTestInfo(testNode);
} else if (testNode instanceof FileTreeDirectory) {
return QLTestAdapter.createTestSuiteInfo(testNode, testNode.name);
} else {
throw new Error("Unexpected test type.");
}
}
private static createTestInfo(testFile: FileTreeLeaf): TestInfo {
return {
type: "test",
id: testFile.path,
label: testFile.name,
tooltip: testFile.path,
file: testFile.path,
};
}
private static createTestSuiteInfo(
testDirectory: FileTreeDirectory,
label: string,
): TestSuiteInfo {
return {
type: "suite",
id: testDirectory.path,
label,
children: QLTestAdapter.createTestOrSuiteInfos(testDirectory.children),
tooltip: testDirectory.path,
};
}
public async load(): Promise<void> {
this.discoverTests();
}
private discoverTests(): void {
this._tests.fire({ type: "started" } as TestLoadStartedEvent);
const testDirectory = this.qlTestDiscovery.testDirectory;
let testSuite: TestSuiteInfo | undefined;
if (testDirectory?.children.length) {
const children = QLTestAdapter.createTestOrSuiteInfos(
testDirectory.children,
);
testSuite = {
type: "suite",
label: "CodeQL",
id: testDirectory.path,
children,
};
}
this._tests.fire({
type: "finished",
suite: testSuite,
} as TestLoadFinishedEvent);
}
public async run(tests: string[]): Promise<void> {
if (this.runningTask !== undefined) {
throw new Error("Tests already running.");
}
testLogger.outputChannel.clear();
testLogger.outputChannel.show(true);
this.runningTask = this.track(new CancellationTokenSource());
const token = this.runningTask.token;
this._testStates.fire({
type: "started",
tests,
} as TestRunStartedEvent);
await this.testRunner.run(tests, testLogger, token, (event) =>
this.processTestEvent(event),
);
this._testStates.fire({ type: "finished" } as TestRunFinishedEvent);
this.clearTask();
}
private clearTask(): void {
if (this.runningTask !== undefined) {
const runningTask = this.runningTask;
this.runningTask = undefined;
this.disposeAndStopTracking(runningTask);
}
}
public cancel(): void {
if (this.runningTask !== undefined) {
void testLogger.log("Cancelling test run...");
this.runningTask.cancel();
this.clearTask();
}
}
private async processTestEvent(event: TestCompleted): Promise<void> {
const state = event.pass
? "passed"
: event.messages?.length
? "errored"
: "failed";
let message: string | undefined;
if (event.failureDescription || event.diff?.length) {
message =
event.failureStage === "RESULT"
? [
"",
`${state}: ${event.test}`,
event.failureDescription || event.diff?.join("\n"),
"",
].join("\n")
: [
"",
`${event.failureStage?.toLowerCase() ?? "unknown stage"} error: ${
event.test
}`,
event.failureDescription ||
`${event.messages[0].severity}: ${event.messages[0].message}`,
"",
].join("\n");
void testLogger.log(message);
}
this._testStates.fire({
type: "test",
state,
test: event.test,
message,
decorations: event.messages?.map((msg) => ({
line: msg.position.line,
message: msg.message,
})),
});
}
}