285 lines
7.8 KiB
TypeScript
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,
|
|
})),
|
|
});
|
|
}
|
|
}
|