Skip to content

Commit

Permalink
feat: Implement Testing Panel via custom LSP messages (#39)
Browse files Browse the repository at this point in the history
  • Loading branch information
phated authored Sep 15, 2023
1 parent 1e5f9ef commit 890b606
Show file tree
Hide file tree
Showing 3 changed files with 203 additions and 1 deletion.
3 changes: 3 additions & 0 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"recommendations": ["esbenp.prettier-vscode"]
}
5 changes: 5 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
196 changes: 195 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,57 @@
import { workspace, WorkspaceFolder, Uri, window, OutputChannel } from "vscode";
import {
workspace,
WorkspaceFolder,
Uri,
window,
tests,
TestRunProfileKind,
Range,
TestItem,
TestMessage,
TestController,
OutputChannel,
CancellationToken,
TestRunRequest,
} from "vscode";

import {
LanguageClient,
LanguageClientOptions,
ServerCapabilities,
ServerOptions,
TextDocumentFilter,
} from "vscode-languageclient/node";

import { extensionName, languageId } from "./constants";
import findNargo from "./find-nargo";

type NargoCapabilities = {
nargo?: {
tests?: {
fetch: boolean;
run: boolean;
update: boolean;
};
};
};

type NargoTests = {
package: string;
uri: string;
tests?: {
id: string;
label: string;
uri: string;
range: Range;
}[];
};

type RunTestResult = {
id: string;
result: "pass" | "fail" | "error";
message: string;
};

function globFromUri(uri: Uri, glob: string) {
// globs always need to use `/`
return `${uri.fsPath}${glob}`.replaceAll("\\", "/");
Expand Down Expand Up @@ -40,6 +82,11 @@ export default class Client extends LanguageClient {
#args: string[];
#output: OutputChannel;

// This function wasn't added until vscode 1.81.0 so fake the type
#testController: TestController & {
invalidateTestResults?: (item: TestItem) => void;
};

constructor(uri: Uri, workspaceFolder?: WorkspaceFolder) {
let outputChannel = window.createOutputChannel(extensionName, languageId);

Expand Down Expand Up @@ -78,6 +125,153 @@ export default class Client extends LanguageClient {
this.#command = command;
this.#args = args;
this.#output = outputChannel;

// TODO: Figure out how to do type-safe onNotification
this.onNotification("nargo/tests/update", (testData: NargoTests) => {
this.#updateTests(testData);
});

this.registerFeature({
fillClientCapabilities: () => {},
initialize: (capabilities: ServerCapabilities & NargoCapabilities) => {
outputChannel.appendLine(`${JSON.stringify(capabilities)}`);
if (typeof capabilities.nargo?.tests !== "undefined") {
this.#testController = tests.createTestController(
// We prefix with our ID namespace but we also tie these to the URI since they need to be unique
`NoirWorkspaceTests-${uri.toString()}`,
"Noir Workspace Tests"
);

if (capabilities.nargo.tests.fetch) {
// TODO: reload a single test if provided as the function argument
this.#testController.resolveHandler = async (test) => {
await this.#fetchTests();
};
this.#testController.refreshHandler = async (token) => {
await this.#refreshTests(token);
};
}

if (capabilities.nargo.tests.run) {
this.#testController.createRunProfile(
"Run Tests",
TestRunProfileKind.Run,
async (request, token) => {
await this.#runTest(request, token);
},
true
);
}
}
},
getState: () => {
return { kind: "static" };
},
dispose: () => {
if (this.#testController) {
this.#testController.dispose();
}
},
});
}

async #fetchTests() {
const response = await this.sendRequest<NargoTests[]>("nargo/tests", {});

response.forEach((testData) => {
this.#createTests(testData);
});
}

async #refreshTests(token: CancellationToken) {
const response = await this.sendRequest<NargoTests[]>(
"nargo/tests",
{},
token
);
response.forEach((testData) => {
this.#updateTests(testData);
});
}

async #runTest(request: TestRunRequest, token: CancellationToken) {
const run = this.#testController.createTestRun(request);
const queue: TestItem[] = [];

// Loop through all included tests, or all known tests, and add them to our queue
if (request.include) {
request.include.forEach((test) => queue.push(test));
} else {
this.#testController.items.forEach((test) => queue.push(test));
}

while (queue.length > 0 && !token.isCancellationRequested) {
const test = queue.pop()!;

// Skip tests the user asked to exclude
if (request.exclude?.includes(test)) {
continue;
}

// We don't run our test headers since they are just for grouping
// but this is fine because the test pass/fail icons are propagated upward
if (test.parent) {
// If we have these tests, the server will be able to run them with this message
const { id, result, message } = await this.sendRequest<RunTestResult>(
"nargo/tests/run",
{
id: test.id,
},
token
);

// TODO: Handle `test.id !== id`. I'm not sure if it is possible for this to happen in normal usage

if (result === "pass") {
run.passed(test);
continue;
}

if (result === "fail" || result === "error") {
run.failed(test, new TestMessage(message));
continue;
}
}

// After tests are run (if any), we add any children to the queue
test.children.forEach((test) => queue.push(test));
}

run.end();
}

#createTests(testData: NargoTests) {
let pkg = this.#testController.createTestItem(
testData.package,
testData.package
);

testData.tests.forEach((test) => {
let item = this.#testController.createTestItem(
test.id,
test.label,
Uri.parse(test.uri)
);
item.range = test.range;
pkg.children.add(item);
});

this.#testController.items.add(pkg);
}

#updateTests(testData: NargoTests) {
// This function wasn't added until vscode 1.81.0 so we check for it
if (typeof this.#testController.invalidateTestResults === "function") {
let pkg = this.#testController.items.get(testData.package);
this.#testController.invalidateTestResults(pkg);
}

this.#createTests(testData);
}

async start(): Promise<void> {
Expand Down

0 comments on commit 890b606

Please sign in to comment.