From eb4e242ecd24e70b2bb503b6bded9dec5c382cc7 Mon Sep 17 00:00:00 2001 From: Blaine Bublitz Date: Fri, 1 Sep 2023 14:41:27 -0700 Subject: [PATCH 1/3] feat: Implement Testing Panel via custom LSP messages --- src/client.ts | 151 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 150 insertions(+), 1 deletion(-) diff --git a/src/client.ts b/src/client.ts index c2428fc..5252106 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,4 +1,16 @@ -import { workspace, WorkspaceFolder, Uri, window, OutputChannel } from "vscode"; +import { + workspace, + WorkspaceFolder, + Uri, + window, + tests, + TestRunProfileKind, + Range, + TestItem, + TestMessage, + TestController, + OutputChannel, +} from "vscode"; import { LanguageClient, @@ -10,6 +22,23 @@ import { import { extensionName, languageId } from "./constants"; import findNargo from "./find-nargo"; +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("\\", "/"); @@ -40,6 +69,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); @@ -78,6 +112,120 @@ 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.#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" + ); + + this.#testController.resolveHandler = async (test) => { + const response = await this.sendRequest("nargo/tests", {}); + // TODO: reload a single test + response.forEach((testData) => { + this.#createTests(testData); + }); + }; + this.#testController.refreshHandler = async (token) => { + const response = await this.sendRequest( + "nargo/tests", + {}, + token + ); + response.forEach((testData) => { + this.#updateTests(testData); + }); + }; + + this.#testController.createRunProfile( + "Run Tests", + TestRunProfileKind.Run, + async (request, token) => { + 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) { + const { id, result, message } = + await this.sendRequest( + "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(); + }, + true + ); + } + + #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 { @@ -89,6 +237,7 @@ export default class Client extends LanguageClient { } async dispose(timeout?: number): Promise { + await this.#testController.dispose(); await super.dispose(timeout); } } From 18b010a91891dfc4f38a5ccbe27e6f9ced747345 Mon Sep 17 00:00:00 2001 From: Blaine Bublitz Date: Fri, 1 Sep 2023 14:41:44 -0700 Subject: [PATCH 2/3] chore: Configure the vscode workspace --- .vscode/extensions.json | 3 +++ .vscode/settings.json | 5 +++++ 2 files changed, 8 insertions(+) create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..c83e263 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["esbenp.prettier-vscode"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..8ce4949 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } +} From aea3636dfdff8a7e495da49a055f818b9a9b865b Mon Sep 17 00:00:00 2001 From: Blaine Bublitz Date: Fri, 1 Sep 2023 15:32:15 -0700 Subject: [PATCH 3/3] register as a feature for compat with older LSP --- src/client.ts | 189 +++++++++++++++++++++++++++++++------------------- 1 file changed, 117 insertions(+), 72 deletions(-) diff --git a/src/client.ts b/src/client.ts index 5252106..53042bb 100644 --- a/src/client.ts +++ b/src/client.ts @@ -10,11 +10,14 @@ import { TestMessage, TestController, OutputChannel, + CancellationToken, + TestRunRequest, } from "vscode"; import { LanguageClient, LanguageClientOptions, + ServerCapabilities, ServerOptions, TextDocumentFilter, } from "vscode-languageclient/node"; @@ -22,6 +25,16 @@ import { 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; @@ -118,85 +131,118 @@ export default class Client extends LanguageClient { this.#updateTests(testData); }); - 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" - ); - - this.#testController.resolveHandler = async (test) => { - const response = await this.sendRequest("nargo/tests", {}); - // TODO: reload a single test - response.forEach((testData) => { - this.#createTests(testData); - }); - }; - this.#testController.refreshHandler = async (token) => { - const response = await this.sendRequest( - "nargo/tests", - {}, - token - ); - response.forEach((testData) => { - 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); + }; + } - this.#testController.createRunProfile( - "Run Tests", - TestRunProfileKind.Run, - async (request, token) => { - 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)); + 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(); + } + }, + }); + } - while (queue.length > 0 && !token.isCancellationRequested) { - const test = queue.pop()!; + async #fetchTests() { + const response = await this.sendRequest("nargo/tests", {}); - // Skip tests the user asked to exclude - if (request.exclude?.includes(test)) { - continue; - } + response.forEach((testData) => { + this.#createTests(testData); + }); + } - // 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) { - const { id, result, message } = - await this.sendRequest( - "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; - } - } + async #refreshTests(token: CancellationToken) { + const response = await this.sendRequest( + "nargo/tests", + {}, + token + ); + response.forEach((testData) => { + this.#updateTests(testData); + }); + } + + async #runTest(request: TestRunRequest, token: CancellationToken) { + const run = this.#testController.createTestRun(request); + const queue: TestItem[] = []; - // After tests are run (if any), we add any children to the queue - test.children.forEach((test) => queue.push(test)); + // 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( + "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; } - run.end(); - }, - true - ); + 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) { @@ -237,7 +283,6 @@ export default class Client extends LanguageClient { } async dispose(timeout?: number): Promise { - await this.#testController.dispose(); await super.dispose(timeout); } }