diff --git a/test-provider-sample/package.json b/test-provider-sample/package.json index d81fbc9e..5be9da2d 100644 --- a/test-provider-sample/package.json +++ b/test-provider-sample/package.json @@ -8,7 +8,8 @@ "license": "MIT", "repository": "https://github.com/Microsoft/vscode-extension-samples", "enabledApiProposals": [ - "testCoverage" + "testCoverage", + "testContinuousRun" ], "engines": { "vscode": "^1.68.0" diff --git a/test-provider-sample/src/extension.ts b/test-provider-sample/src/extension.ts index 93bbd3cf..d0c2a292 100644 --- a/test-provider-sample/src/extension.ts +++ b/test-provider-sample/src/extension.ts @@ -1,186 +1,207 @@ -import * as vscode from 'vscode'; -import { getContentFromFilesystem, MarkdownTestData, TestCase, testData, TestFile } from './testTree'; - -export async function activate(context: vscode.ExtensionContext) { - const ctrl = vscode.tests.createTestController('mathTestController', 'Markdown Math'); - context.subscriptions.push(ctrl); - - const runHandler = (request: vscode.TestRunRequest, cancellation: vscode.CancellationToken) => { - const queue: { test: vscode.TestItem; data: TestCase }[] = []; - const run = ctrl.createTestRun(request); - // map of file uris to statements on each line: - const coveredLines = new Map(); - - const discoverTests = async (tests: Iterable) => { - for (const test of tests) { - if (request.exclude?.includes(test)) { - continue; - } - - const data = testData.get(test); - if (data instanceof TestCase) { - run.enqueued(test); - queue.push({ test, data }); - } else { - if (data instanceof TestFile && !data.didResolve) { - await data.updateFromDisk(ctrl, test); - } - - await discoverTests(gatherTestItems(test.children)); - } - - if (test.uri && !coveredLines.has(test.uri.toString())) { - try { - const lines = (await getContentFromFilesystem(test.uri)).split('\n'); - coveredLines.set( - test.uri.toString(), - lines.map((lineText, lineNo) => - lineText.trim().length ? new vscode.StatementCoverage(0, new vscode.Position(lineNo, 0)) : undefined - ) - ); - } catch { - // ignored - } - } - } - }; - - const runTestQueue = async () => { - for (const { test, data } of queue) { - run.appendOutput(`Running ${test.id}\r\n`); - if (cancellation.isCancellationRequested) { - run.skipped(test); - } else { - run.started(test); - await data.run(test, run); - } - - const lineNo = test.range!.start.line; - const fileCoverage = coveredLines.get(test.uri!.toString()); - if (fileCoverage) { - fileCoverage[lineNo]!.executionCount++; - } - - run.appendOutput(`Completed ${test.id}\r\n`); - } - - run.end(); - }; - - run.coverageProvider = { - provideFileCoverage() { - const coverage: vscode.FileCoverage[] = []; - for (const [uri, statements] of coveredLines) { - coverage.push( - vscode.FileCoverage.fromDetails( - vscode.Uri.parse(uri), - statements.filter((s): s is vscode.StatementCoverage => !!s) - ) - ); - } - - return coverage; - }, - }; - - discoverTests(request.include ?? gatherTestItems(ctrl.items)).then(runTestQueue); - }; - - ctrl.refreshHandler = async () => { - await Promise.all(getWorkspaceTestPatterns().map(({ pattern }) => findInitialFiles(ctrl, pattern))); - }; - - ctrl.createRunProfile('Run Tests', vscode.TestRunProfileKind.Run, runHandler, true); - - ctrl.resolveHandler = async item => { - if (!item) { - context.subscriptions.push(...startWatchingWorkspace(ctrl)); - return; - } - - const data = testData.get(item); - if (data instanceof TestFile) { - await data.updateFromDisk(ctrl, item); - } - }; - - function updateNodeForDocument(e: vscode.TextDocument) { - if (e.uri.scheme !== 'file') { - return; - } - - if (!e.uri.path.endsWith('.md')) { - return; - } - - const { file, data } = getOrCreateFile(ctrl, e.uri); - data.updateFromContents(ctrl, e.getText(), file); - } - - for (const document of vscode.workspace.textDocuments) { - updateNodeForDocument(document); - } - - context.subscriptions.push( - vscode.workspace.onDidOpenTextDocument(updateNodeForDocument), - vscode.workspace.onDidChangeTextDocument(e => updateNodeForDocument(e.document)), - ); -} - -function getOrCreateFile(controller: vscode.TestController, uri: vscode.Uri) { - const existing = controller.items.get(uri.toString()); - if (existing) { - return { file: existing, data: testData.get(existing) as TestFile }; - } - - const file = controller.createTestItem(uri.toString(), uri.path.split('/').pop()!, uri); - controller.items.add(file); - - const data = new TestFile(); - testData.set(file, data); - - file.canResolveChildren = true; - return { file, data }; -} - -function gatherTestItems(collection: vscode.TestItemCollection) { - const items: vscode.TestItem[] = []; - collection.forEach(item => items.push(item)); - return items; -} - -function getWorkspaceTestPatterns() { - if (!vscode.workspace.workspaceFolders) { - return []; - } - - return vscode.workspace.workspaceFolders.map(workspaceFolder => ({ - workspaceFolder, - pattern: new vscode.RelativePattern(workspaceFolder, '**/*.md'), - })); -} - -async function findInitialFiles(controller: vscode.TestController, pattern: vscode.GlobPattern) { - for (const file of await vscode.workspace.findFiles(pattern)) { - getOrCreateFile(controller, file); - } -} - -function startWatchingWorkspace(controller: vscode.TestController) { - return getWorkspaceTestPatterns().map(({ workspaceFolder, pattern }) => { - const watcher = vscode.workspace.createFileSystemWatcher(pattern); - - watcher.onDidCreate(uri => getOrCreateFile(controller, uri)); - watcher.onDidChange(uri => { - const { file, data } = getOrCreateFile(controller, uri); - if (data.didResolve) { - data.updateFromDisk(controller, file); - } - }); - watcher.onDidDelete(uri => controller.items.delete(uri.toString())); - - findInitialFiles(controller, pattern); - - return watcher; - }); -} +import * as vscode from 'vscode'; +import { getContentFromFilesystem, MarkdownTestData, TestCase, testData, TestFile } from './testTree'; + +export async function activate(context: vscode.ExtensionContext) { + const ctrl = vscode.tests.createTestController('mathTestController', 'Markdown Math'); + context.subscriptions.push(ctrl); + + const fileChangedEmitter = new vscode.EventEmitter(); + const runHandler = (request: vscode.TestRunRequest2, cancellation: vscode.CancellationToken) => { + if (!request.continuous) { + return startTestRun(request); + } + + const l = fileChangedEmitter.event(uri => startTestRun( + new vscode.TestRunRequest2( + [getOrCreateFile(ctrl, uri).file], + undefined, + request.profile, + true + ), + )); + cancellation.onCancellationRequested(() => l.dispose()); + }; + + const startTestRun = (request: vscode.TestRunRequest) => { + const queue: { test: vscode.TestItem; data: TestCase }[] = []; + const run = ctrl.createTestRun(request); + // map of file uris to statements on each line: + const coveredLines = new Map(); + + const discoverTests = async (tests: Iterable) => { + for (const test of tests) { + if (request.exclude?.includes(test)) { + continue; + } + + const data = testData.get(test); + if (data instanceof TestCase) { + run.enqueued(test); + queue.push({ test, data }); + } else { + if (data instanceof TestFile && !data.didResolve) { + await data.updateFromDisk(ctrl, test); + } + + await discoverTests(gatherTestItems(test.children)); + } + + if (test.uri && !coveredLines.has(test.uri.toString())) { + try { + const lines = (await getContentFromFilesystem(test.uri)).split('\n'); + coveredLines.set( + test.uri.toString(), + lines.map((lineText, lineNo) => + lineText.trim().length ? new vscode.StatementCoverage(0, new vscode.Position(lineNo, 0)) : undefined + ) + ); + } catch { + // ignored + } + } + } + }; + + const runTestQueue = async () => { + for (const { test, data } of queue) { + run.appendOutput(`Running ${test.id}\r\n`); + if (run.token.isCancellationRequested) { + run.skipped(test); + } else { + run.started(test); + await data.run(test, run); + } + + const lineNo = test.range!.start.line; + const fileCoverage = coveredLines.get(test.uri!.toString()); + if (fileCoverage) { + fileCoverage[lineNo]!.executionCount++; + } + + run.appendOutput(`Completed ${test.id}\r\n`); + } + + run.end(); + }; + + run.coverageProvider = { + provideFileCoverage() { + const coverage: vscode.FileCoverage[] = []; + for (const [uri, statements] of coveredLines) { + coverage.push( + vscode.FileCoverage.fromDetails( + vscode.Uri.parse(uri), + statements.filter((s): s is vscode.StatementCoverage => !!s) + ) + ); + } + + return coverage; + }, + }; + + discoverTests(request.include ?? gatherTestItems(ctrl.items)).then(runTestQueue); + }; + + ctrl.refreshHandler = async () => { + await Promise.all(getWorkspaceTestPatterns().map(({ pattern }) => findInitialFiles(ctrl, pattern))); + }; + + ctrl.createRunProfile('Run Tests', vscode.TestRunProfileKind.Run, runHandler, true, undefined, true); + + ctrl.resolveHandler = async item => { + if (!item) { + context.subscriptions.push(...startWatchingWorkspace(ctrl, fileChangedEmitter)); + return; + } + + const data = testData.get(item); + if (data instanceof TestFile) { + await data.updateFromDisk(ctrl, item); + } + }; + + function updateNodeForDocument(e: vscode.TextDocument) { + if (e.uri.scheme !== 'file') { + return; + } + + if (!e.uri.path.endsWith('.md')) { + return; + } + + const { file, data } = getOrCreateFile(ctrl, e.uri); + data.updateFromContents(ctrl, e.getText(), file); + } + + for (const document of vscode.workspace.textDocuments) { + updateNodeForDocument(document); + } + + context.subscriptions.push( + vscode.workspace.onDidOpenTextDocument(updateNodeForDocument), + vscode.workspace.onDidChangeTextDocument(e => updateNodeForDocument(e.document)), + ); +} + +function getOrCreateFile(controller: vscode.TestController, uri: vscode.Uri) { + const existing = controller.items.get(uri.toString()); + if (existing) { + return { file: existing, data: testData.get(existing) as TestFile }; + } + + const file = controller.createTestItem(uri.toString(), uri.path.split('/').pop()!, uri); + controller.items.add(file); + + const data = new TestFile(); + testData.set(file, data); + + file.canResolveChildren = true; + return { file, data }; +} + +function gatherTestItems(collection: vscode.TestItemCollection) { + const items: vscode.TestItem[] = []; + collection.forEach(item => items.push(item)); + return items; +} + +function getWorkspaceTestPatterns() { + if (!vscode.workspace.workspaceFolders) { + return []; + } + + return vscode.workspace.workspaceFolders.map(workspaceFolder => ({ + workspaceFolder, + pattern: new vscode.RelativePattern(workspaceFolder, '**/*.md'), + })); +} + +async function findInitialFiles(controller: vscode.TestController, pattern: vscode.GlobPattern) { + for (const file of await vscode.workspace.findFiles(pattern)) { + getOrCreateFile(controller, file); + } +} + +function startWatchingWorkspace(controller: vscode.TestController, fileChangedEmitter: vscode.EventEmitter ) { + return getWorkspaceTestPatterns().map(({ workspaceFolder, pattern }) => { + const watcher = vscode.workspace.createFileSystemWatcher(pattern); + + watcher.onDidCreate(uri => { + getOrCreateFile(controller, uri); + fileChangedEmitter.fire(uri); + }); + watcher.onDidChange(async uri => { + const { file, data } = getOrCreateFile(controller, uri); + if (data.didResolve) { + await data.updateFromDisk(controller, file); + } + fileChangedEmitter.fire(uri); + }); + watcher.onDidDelete(uri => controller.items.delete(uri.toString())); + + findInitialFiles(controller, pattern); + + return watcher; + }); +} diff --git a/test-provider-sample/vscode.proposed.testContinuousRun.d.ts b/test-provider-sample/vscode.proposed.testContinuousRun.d.ts new file mode 100644 index 00000000..bb624013 --- /dev/null +++ b/test-provider-sample/vscode.proposed.testContinuousRun.d.ts @@ -0,0 +1,66 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + export interface TestRunProfile { + /** + * Whether this profile supports continuous running of requests. If so, + * then {@link TestRunRequest.continuous} may be set to `true`. Defaults + * to false. + */ + supportsContinuousRun: boolean; + + /** + * Handler called to start a test run. When invoked, the function should call + * {@link TestController.createTestRun} at least once, and all test runs + * associated with the request should be created before the function returns + * or the returned promise is resolved. + * + * If {@link supportsContinuousRun} is set, then {@link TestRunRequest2.continuous} + * may be `true`. In this case, the profile should observe changes to + * source code and create new test runs by calling {@link TestController.createTestRun}, + * until the cancellation is requested on the `token`. + * + * @param request Request information for the test run. + * @param cancellationToken Token that signals the used asked to abort the + * test run. If cancellation is requested on this token, all {@link TestRun} + * instances associated with the request will be + * automatically cancelled as well. + */ + runHandler: (request: TestRunRequest, token: CancellationToken) => Thenable | void; + } + + export interface TestController { + /** + * Creates a profile used for running tests. Extensions must create + * at least one profile in order for tests to be run. + * @param label A human-readable label for this profile. + * @param kind Configures what kind of execution this profile manages. + * @param runHandler Function called to start a test run. + * @param isDefault Whether this is the default action for its kind. + * @param tag Profile test tag. + * @param supportsContinuousRun Whether the profile supports continuous running. + * @returns An instance of a {@link TestRunProfile}, which is automatically + * associated with this controller. + */ + createRunProfile(label: string, kind: TestRunProfileKind, runHandler: (request: TestRunRequest, token: CancellationToken) => Thenable | void, isDefault?: boolean, tag?: TestTag, supportsContinuousRun?: boolean): TestRunProfile; + } + + export class TestRunRequest2 extends TestRunRequest { + /** + * Whether the profile should run continuously as source code changes. Only + * relevant for profiles that set {@link TestRunProfile.supportsContinuousRun}. + */ + readonly continuous?: boolean; + + /** + * @param tests Array of specific tests to run, or undefined to run all tests + * @param exclude An array of tests to exclude from the run. + * @param profile The run profile used for this request. + * @param continuous Whether to run tests continuously as source changes. + */ + constructor(include?: readonly TestItem[], exclude?: readonly TestItem[], profile?: TestRunProfile, continuous?: boolean); + } +}