mirror of
https://github.com/microsoft/vscode-extension-samples.git
synced 2026-04-27 16:55:44 +08:00
Merge pull request #816 from microsoft/connor4312/testing-continuous-run
Add continuous run sample, and fix cancellation bug
This commit is contained in:
@ -8,7 +8,8 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/Microsoft/vscode-extension-samples",
|
||||
"enabledApiProposals": [
|
||||
"testCoverage"
|
||||
"testCoverage",
|
||||
"testContinuousRun"
|
||||
],
|
||||
"engines": {
|
||||
"vscode": "^1.68.0"
|
||||
|
||||
@ -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</* file uri */ string, (vscode.StatementCoverage | undefined)[]>();
|
||||
|
||||
const discoverTests = async (tests: Iterable<vscode.TestItem>) => {
|
||||
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<vscode.Uri>();
|
||||
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</* file uri */ string, (vscode.StatementCoverage | undefined)[]>();
|
||||
|
||||
const discoverTests = async (tests: Iterable<vscode.TestItem>) => {
|
||||
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<vscode.Uri> ) {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
66
test-provider-sample/vscode.proposed.testContinuousRun.d.ts
vendored
Normal file
66
test-provider-sample/vscode.proposed.testContinuousRun.d.ts
vendored
Normal file
@ -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> | 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> | 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user