import * as vscode from "vscode"; import type * as lc from "vscode-languageclient/node"; import * as ra from "./lsp_ext"; import type { Ctx } from "./ctx"; import { startDebugSession } from "./debug"; export const prepareTestExplorer = ( ctx: Ctx, testController: vscode.TestController, client: lc.LanguageClient, ) => { let currentTestRun: vscode.TestRun | undefined; let idToTestMap: Map = new Map(); const fileToTestMap: Map = new Map(); const idToRunnableMap: Map = new Map(); testController.createRunProfile( "Run Tests", vscode.TestRunProfileKind.Run, async (request: vscode.TestRunRequest, cancelToken: vscode.CancellationToken) => { if (currentTestRun) { await client.sendNotification(ra.abortRunTest); while (currentTestRun) { await new Promise((resolve) => setTimeout(resolve, 1)); } } currentTestRun = testController.createTestRun(request); cancelToken.onCancellationRequested(async () => { await client.sendNotification(ra.abortRunTest); }); const include = request.include?.map((x) => x.id); const exclude = request.exclude?.map((x) => x.id); await client.sendRequest(ra.runTest, { include, exclude }); }, true, undefined, false, ); testController.createRunProfile( "Debug Tests", vscode.TestRunProfileKind.Debug, async (request: vscode.TestRunRequest) => { if (request.include?.length !== 1 || request.exclude?.length !== 0) { await vscode.window.showErrorMessage("You can debug only one test at a time"); return; } const id = request.include[0]!.id; const runnable = idToRunnableMap.get(id); if (!runnable) { await vscode.window.showErrorMessage("You can debug only one test at a time"); return; } await startDebugSession(ctx, runnable); }, true, undefined, false, ); const deleteTest = (item: vscode.TestItem, parentList: vscode.TestItemCollection) => { parentList.delete(item.id); idToTestMap.delete(item.id); idToRunnableMap.delete(item.id); if (item.uri) { fileToTestMap.set( item.uri.toString(), fileToTestMap.get(item.uri.toString())!.filter((t) => t.id !== item.id), ); } }; const addTest = (item: ra.TestItem) => { const parentList = item.parent ? idToTestMap.get(item.parent)!.children : testController.items; const oldTest = parentList.get(item.id); const uri = item.textDocument?.uri ? vscode.Uri.parse(item.textDocument?.uri) : undefined; const range = item.range && new vscode.Range( new vscode.Position(item.range.start.line, item.range.start.character), new vscode.Position(item.range.end.line, item.range.end.character), ); if (oldTest) { if (oldTest.uri?.toString() === uri?.toString()) { oldTest.range = range; return; } deleteTest(oldTest, parentList); } const iconToVscodeMap = { package: "package", module: "symbol-module", test: "beaker", }; const test = testController.createTestItem( item.id, `$(${iconToVscodeMap[item.kind]}) ${item.label}`, uri, ); test.range = range; test.canResolveChildren = item.canResolveChildren; idToTestMap.set(item.id, test); if (uri) { if (!fileToTestMap.has(uri.toString())) { fileToTestMap.set(uri.toString(), []); } fileToTestMap.get(uri.toString())!.push(test); } if (item.runnable) { idToRunnableMap.set(item.id, item.runnable); } parentList.add(test); }; const addTestGroup = (testsAndScope: ra.DiscoverTestResults) => { const { tests, scope, scopeFile } = testsAndScope; const testSet: Set = new Set(); for (const test of tests) { addTest(test); testSet.add(test.id); } // FIXME(hack_recover_crate_name): We eagerly resolve every test if we got a lazy top level response (detected // by checking that `scope` is empty). This is not a good thing and wastes cpu and memory unnecessarily, so we // should remove it. if (!scope && !scopeFile) { for (const test of tests) { void testController.resolveHandler!(idToTestMap.get(test.id)); } } if (scope) { const recursivelyRemove = (tests: vscode.TestItemCollection) => { for (const [, test] of tests) { if (!testSet.has(test.id)) { deleteTest(test, tests); } else { recursivelyRemove(test.children); } } }; for (const root of scope) { recursivelyRemove(idToTestMap.get(root)!.children); } } if (scopeFile) { const removeByFile = (file: vscode.Uri) => { const testsToBeRemoved = (fileToTestMap.get(file.toString()) || []).filter( (t) => !testSet.has(t.id), ); for (const test of testsToBeRemoved) { const parentList = test.parent?.children || testController.items; deleteTest(test, parentList); } }; for (const file of scopeFile) { removeByFile(vscode.Uri.parse(file.uri)); } } }; ctx.pushClientCleanup( client.onNotification(ra.discoveredTests, (results) => { addTestGroup(results); }), ); ctx.pushClientCleanup( client.onNotification(ra.endRunTest, () => { currentTestRun!.end(); currentTestRun = undefined; }), ); ctx.pushClientCleanup( client.onNotification(ra.appendOutputToRunTest, (output) => { currentTestRun!.appendOutput(`${output}\r\n`); }), ); ctx.pushClientCleanup( client.onNotification(ra.changeTestState, (results) => { const test = idToTestMap.get(results.testId)!; if (results.state.tag === "failed") { currentTestRun!.failed(test, new vscode.TestMessage(results.state.message)); } else if (results.state.tag === "passed") { currentTestRun!.passed(test); } else if (results.state.tag === "started") { currentTestRun!.started(test); } else if (results.state.tag === "skipped") { currentTestRun!.skipped(test); } else if (results.state.tag === "enqueued") { currentTestRun!.enqueued(test); } }), ); testController.resolveHandler = async (item) => { const results = await client.sendRequest(ra.discoverTest, { testId: item?.id }); addTestGroup(results); }; testController.refreshHandler = async () => { testController.items.forEach((t) => { testController.items.delete(t.id); }); idToTestMap = new Map(); await testController.resolveHandler!(undefined); }; };