From a29fd5f53ba5d881ffecb4a9cc8e16d8e26d646b Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 18 Jun 2021 12:16:40 -0700 Subject: [PATCH] test-provider-sample: update api --- test-provider-sample/package.json | 1 + test-provider-sample/src/extension.ts | 126 +++++++- test-provider-sample/src/testController.ts | 337 --------------------- test-provider-sample/src/testTree.ts | 149 +++++++++ 4 files changed, 269 insertions(+), 344 deletions(-) delete mode 100644 test-provider-sample/src/testController.ts create mode 100644 test-provider-sample/src/testTree.ts diff --git a/test-provider-sample/package.json b/test-provider-sample/package.json index d24f83af..62c494cb 100644 --- a/test-provider-sample/package.json +++ b/test-provider-sample/package.json @@ -5,6 +5,7 @@ "version": "0.0.1", "publisher": "vscode-samples", "repository": "https://github.com/Microsoft/vscode-extension-samples", + "enableProposedApi": true, "engines": { "vscode": "^1.51.0" }, diff --git a/test-provider-sample/src/extension.ts b/test-provider-sample/src/extension.ts index 03af5dbb..a6132bce 100644 --- a/test-provider-sample/src/extension.ts +++ b/test-provider-sample/src/extension.ts @@ -1,13 +1,125 @@ import * as vscode from 'vscode'; -import { MathTestController } from './testController'; +import { MarkdownTestData, TestCase, TestFile } from './testTree'; export function activate(context: vscode.ExtensionContext) { - context.subscriptions.push( - vscode.test.registerTestController(new MathTestController()), + const ctrl = vscode.test.createTestController('mathTestController'); + context.subscriptions.push(ctrl); - vscode.commands.registerCommand('test-provider-sample.runTests', async tests => { - await vscode.test.runTests({ tests: tests instanceof Array ? tests : [tests], debug: false }); - vscode.window.showInformationMessage('Test run complete'); - }), + // All VS Code tests are in a tree, starting at the automatically created "root". + // We'll give it a label, and set its status so that VS Code will call + // `resolveChildrenHandler` when the test explorer is opened. + ctrl.root.label = 'Markdown Math'; + ctrl.root.status = vscode.TestItemStatus.Pending; + + ctrl.runHandler = (request: vscode.TestRunRequest, cancellation: vscode.CancellationToken) => { + const queue: vscode.TestItem[] = []; + const run = ctrl.createTestRun(request); + const discoverTests = async (tests: Iterable>) => { + for (const test of tests) { + if (request.exclude?.includes(test)) { + continue; + } + + if (test.data instanceof TestCase) { + run.setState(test, vscode.TestResultState.Queued); + queue.push(test as vscode.TestItem); + } else { + if (test.data instanceof TestFile && test.status === vscode.TestItemStatus.Pending) { + await test.data.updateFromDisk(ctrl, test); + } + + await discoverTests(test.children.values()); + } + } + }; + + const runTestQueue = async () => { + for (const test of queue) { + run.appendOutput(`Running ${test.id}\r\n`); + if (cancellation.isCancellationRequested) { + run.setState(test, vscode.TestResultState.Skipped); + } else { + run.setState(test, vscode.TestResultState.Running); + await test.data.run(test, run); + } + run.appendOutput(`Completed ${test.id}\r\n`); + } + + run.end(); + }; + + discoverTests(request.tests).then(runTestQueue); + }; + + ctrl.resolveChildrenHandler = (item, token) => { + if (item === ctrl.root) { + startWatchingWorkspace(ctrl, token); + } else if (item.data instanceof TestFile) { + item.data.updateFromDisk(ctrl, item); + } + }; + + function updateNodeForDocument(e: vscode.TextDocument) { + if (!e.uri.path.endsWith('.md')) { + return; + } + + const node = getOrCreateFile(ctrl, e.uri); + node.data.updateFromContents(ctrl, e.getText(), node); + } + + 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): vscode.TestItem { + const existing = controller.root.children.get(uri.toString()); + if (existing) { + return existing; + } + + const file = controller.createTestItem( + uri.toString(), + uri.path.split('/').pop()!, + controller.root, + uri, + new TestFile() + ); + + file.status = vscode.TestItemStatus.Pending; + return file; +} + +function startWatchingWorkspace(controller: vscode.TestController, token: vscode.CancellationToken) { + if (!vscode.workspace.workspaceFolders) { + return; + } + + for (const workspaceFolder of vscode.workspace.workspaceFolders) { + const pattern = new vscode.RelativePattern(workspaceFolder, '**/*.md'); + const watcher = vscode.workspace.createFileSystemWatcher(pattern); + const contentChange = new vscode.EventEmitter(); + + watcher.onDidCreate(uri => getOrCreateFile(controller, uri)); + watcher.onDidChange(uri => contentChange.fire(uri)); + watcher.onDidDelete(uri => controller.root.children.get(uri.toString())?.dispose()); + token.onCancellationRequested(() => { + controller.root.status = vscode.TestItemStatus.Pending; + watcher.dispose(); + }); + + vscode.workspace.findFiles(pattern).then(files => { + for (const file of files) { + getOrCreateFile(controller, file); + } + + controller.root.status = vscode.TestItemStatus.Resolved; + }); + } +} diff --git a/test-provider-sample/src/testController.ts b/test-provider-sample/src/testController.ts deleted file mode 100644 index ffccf10b..00000000 --- a/test-provider-sample/src/testController.ts +++ /dev/null @@ -1,337 +0,0 @@ -import { TextDecoder } from 'util'; -import * as vscode from 'vscode'; -import { parseMarkdown } from './parser'; - -const textDecoder = new TextDecoder('utf-8'); - -type MarkdownTestData = WorkspaceTestRoot | DocumentTestRoot | TestFile | TestHeading | TestCase; - -export class MathTestController implements vscode.TestController { - /** - * @inheritdoc - */ - public createWorkspaceTestRoot(workspaceFolder: vscode.WorkspaceFolder) { - return WorkspaceTestRoot.create(workspaceFolder); - } - - /** - * @inheritdoc - */ - public createDocumentTestRoot(document: vscode.TextDocument) { - if (!document.uri.path.endsWith('.md')) { - return; - } - - return DocumentTestRoot.create(document); - } - - /** - * @inheritdoc - */ - public runTests(request: vscode.TestRunRequest, cancellation: vscode.CancellationToken) { - const run = vscode.test.createTestRun(request); - const queue: vscode.TestItem[] = []; - const discoverTests = async (tests: Iterable>) => { - for (const test of tests) { - if (request.exclude?.includes(test)) { - continue; - } - - if (test.data instanceof TestCase) { - run.setState(test, vscode.TestResultState.Queued); - queue.push(test as vscode.TestItem); - } else { - if (test.data instanceof TestFile && test.children.size === 0) { - await test.data.refresh(); - } - - await discoverTests(test.children.values()); - } - } - }; - - const runTestQueue = async () => { - for (const test of queue) { - run.appendOutput(`Running ${test.id}\r\n`); - if (cancellation.isCancellationRequested) { - run.setState(test, vscode.TestResultState.Skipped); - } else { - run.setState(test, vscode.TestResultState.Running); - await test.data.run(run); - } - run.appendOutput(`Completed ${test.id}\r\n`); - } - - run.end(); - }; - - discoverTests(request.tests).then(runTestQueue); - } -} - - -class WorkspaceTestRoot { - public static create(workspaceFolder: vscode.WorkspaceFolder) { - const item = vscode.test.createTestItem( - { id: `mdtests ${workspaceFolder.uri}`, label: 'Markdown Tests', uri: workspaceFolder.uri }, - new WorkspaceTestRoot(workspaceFolder) - ); - - item.status = vscode.TestItemStatus.Pending; - item.resolveHandler = token => { - const pattern = new vscode.RelativePattern(workspaceFolder, '**/*.md'); - const watcher = vscode.workspace.createFileSystemWatcher(pattern); - const contentChange = new vscode.EventEmitter(); - - watcher.onDidCreate(uri => - item.addChild(TestFile.create(uri, getContentFromFilesystem, contentChange.event)) - ); - watcher.onDidChange(uri => contentChange.fire(uri)); - watcher.onDidDelete(uri => item.children.get(uri.toString())?.dispose()); - token.onCancellationRequested(() => { - item.status = vscode.TestItemStatus.Pending; - watcher.dispose(); - }); - - vscode.workspace.findFiles(pattern).then(files => { - for (const file of files) { - item.addChild(TestFile.create(file, getContentFromFilesystem, contentChange.event)); - } - - item.status = vscode.TestItemStatus.Resolved; - }); - }; - - return item; - } - - constructor(public readonly workspaceFolder: vscode.WorkspaceFolder) { } -} - -class DocumentTestRoot { - public static create(document: vscode.TextDocument) { - const item = vscode.test.createTestItem( - { id: `mdtests ${document.uri}`, label: 'Markdown Tests', uri: document.uri }, - new DocumentTestRoot() - ); - - item.status = vscode.TestItemStatus.Pending; - item.resolveHandler = token => { - const contentChange = new vscode.EventEmitter(); - const changeListener = vscode.workspace.onDidChangeTextDocument(e => { - contentChange.fire(e.document.uri); - }); - - const file = TestFile.create(document.uri, () => Promise.resolve(document.getText()), contentChange.event); - item.addChild(file); - - token.onCancellationRequested(() => { - changeListener.dispose(); - item.status = vscode.TestItemStatus.Pending; - }); - - item.status = vscode.TestItemStatus.Resolved; - }; - - return item; - } -} - -let generationCounter = 0; - -type ContentGetter = (uri: vscode.Uri) => Promise; - -const getContentFromFilesystem: ContentGetter = async uri => { - try { - const rawContent = await vscode.workspace.fs.readFile(uri); - return textDecoder.decode(rawContent); - } catch (e) { - console.warn(`Error providing tests for ${uri.fsPath}`, e); - return ''; - } -}; - -class TestFile { - public static create(uri: vscode.Uri, getContent: ContentGetter, onContentChange: vscode.Event) { - const item = vscode.test.createTestItem({ - id: uri.toString(), - label: uri.path.split('/').pop()!, - uri, - }); - - item.data = new TestFile(uri, getContent, item); - item.status = vscode.TestItemStatus.Pending; - item.resolveHandler = token => { - const doRefresh = () => { - item.data.refresh().then(() => { - if (!token.isCancellationRequested) { - item.status = vscode.TestItemStatus.Resolved; - } - }); - }; - - const listener = onContentChange(uri => { - if (uri.toString() === uri.toString()) { - doRefresh(); - } - }); - - token.onCancellationRequested(() => { - item.status = vscode.TestItemStatus.Pending; - listener.dispose(); - }); - - doRefresh(); - }; - - return item; - } - - constructor( - private readonly uri: vscode.Uri, - private readonly getContent: ContentGetter, - private readonly item: vscode.TestItem, - ) { - } - - /** - * Parses the tests from the input text, and updates the tests contained - * by this file to be those from the text, - */ - public async refresh() { - const ancestors: (vscode.TestItem | vscode.TestItem)[] = [this.item]; - const thisGeneration = generationCounter++; - - parseMarkdown(await this.getContent(this.uri), { - onTest: (range, a, operator, b, expected) => { - const parent = ancestors[ancestors.length - 1]; - const tcase = TestCase.create(Number(a), operator as Operator, Number(b), Number(expected), range, thisGeneration, parent); - - const existing = parent.children.get(tcase.id); - if (existing) { - existing.data.generation = thisGeneration; - } else { - parent.addChild(tcase); - } - }, - onHeading: (range, name, depth) => { - while (ancestors.length > depth) { - ancestors.pop(); - } - - const parent = ancestors[ancestors.length - 1]; - const thead = TestHeading.create(name, range, thisGeneration, parent); - const existing = parent.children.get(thead.id); - if (existing && existing.data instanceof TestHeading) { - ancestors.push(existing); - existing.data.generation = thisGeneration; - } else { - existing?.dispose(); - parent.addChild(thead); - ancestors.push(thead); - } - } - }); - - this.prune(thisGeneration); - } - - /** - * Removes tests that were deleted from the source. Each test suite and case - * has a 'generation' counter which is updated each time we discover it. This - * is called after discovery is finished to remove any children who are no - * longer in this generation. - */ - private prune(thisGeneration: number) { - const queue: (vscode.TestItem)[] = [this.item]; - for (const parent of queue) { - for (const child of parent.children.values()) { - if (child.data.generation < thisGeneration) { - child.dispose(); - } else if (child.data instanceof TestHeading) { - queue.push(child as vscode.TestItem); - } - } - } - } -} - -class TestHeading { - public static create(label: string, range: vscode.Range, generation: number, parent: vscode.TestItem) { - const item = vscode.test.createTestItem({ - id: `mktests/${parent.uri!.toString()}/${label}`, - label, - uri: parent.uri, - }, new TestHeading(generation)); - - item.range = range; - return item; - } - - protected constructor(public generation: number) { } -} - -type Operator = '+' | '-' | '*' | '/'; - -class TestCase { - public static create( - a: number, - operator: Operator, - b: number, - expected: number, - range: vscode.Range, - generation: number, - parent: vscode.TestItem, - ) { - const label = `${a} ${operator} ${b} = ${expected}`; - const item = vscode.test.createTestItem({ - id: `mktests/${parent.uri!.toString()}/${label}`, - label, - uri: parent.uri, - }); - - item.data = new TestCase(a, operator, b, expected, item, generation); - item.range = range; - return item; - } - - protected constructor( - private readonly a: number, - private readonly operator: Operator, - private readonly b: number, - private readonly expected: number, - private readonly item: vscode.TestItem, - public generation: number, - ) { } - - async run(options: vscode.TestRun): Promise { - const start = Date.now(); - await new Promise(resolve => setTimeout(resolve, 1000 + Math.random() * 1000)); - const actual = this.evaluate(); - const duration = Date.now() - start; - - console.log('run', this.item.label); - - if (actual === this.expected) { - options.setState(this.item, vscode.TestResultState.Passed, duration); - } else { - const message = vscode.TestMessage.diff(`Expected ${this.item.label}`, String(this.expected), String(actual)); - message.location = new vscode.Location(this.item.uri!, this.item.range!); - options.appendMessage(this.item, message); - options.setState(this.item, vscode.TestResultState.Failed, duration); - } - } - - private evaluate() { - switch (this.operator) { - case '-': - return this.a - this.b; - case '+': - return this.a + this.b; - case '/': - return Math.floor(this.a / this.b); - case '*': - return this.a * this.b; - } - } -} diff --git a/test-provider-sample/src/testTree.ts b/test-provider-sample/src/testTree.ts new file mode 100644 index 00000000..b82be0bc --- /dev/null +++ b/test-provider-sample/src/testTree.ts @@ -0,0 +1,149 @@ +import { TextDecoder } from 'util'; +import * as vscode from 'vscode'; +import { parseMarkdown } from './parser'; + +const textDecoder = new TextDecoder('utf-8'); + +export type MarkdownTestData = TestFile | TestHeading | TestCase; + +let generationCounter = 0; + +const getContentFromFilesystem = async (uri: vscode.Uri) => { + try { + const rawContent = await vscode.workspace.fs.readFile(uri); + return textDecoder.decode(rawContent); + } catch (e) { + console.warn(`Error providing tests for ${uri.fsPath}`, e); + return ''; + } +}; + +export class TestFile { + public async updateFromDisk(controller: vscode.TestController, item: vscode.TestItem) { + try { + const content = await getContentFromFilesystem(item.uri!); + item.error = undefined; + this.updateFromContents(controller, content, item); + } catch (e) { + item.error = e.stack; + } + } + + /** + * Parses the tests from the input text, and updates the tests contained + * by this file to be those from the text, + */ + public updateFromContents(controller: vscode.TestController, content: string, item: vscode.TestItem) { + const ancestors: (vscode.TestItem | vscode.TestItem)[] = [item]; + const thisGeneration = generationCounter++; + + parseMarkdown(content, { + onTest: (range, a, operator, b, expected) => { + const parent = ancestors[ancestors.length - 1]; + const data = new TestCase(a, operator as Operator, b, expected, thisGeneration); + const id = `${item.uri}/${data.getLabel()}`; + + const existing = parent.children.get(id); + if (existing) { + existing.data.generation = thisGeneration; + existing.range = range; + } else { + const tcase = controller.createTestItem(id, data.getLabel(), parent, item.uri, data); + tcase.range = range; + } + }, + + onHeading: (range, name, depth) => { + while (ancestors.length > depth) { + ancestors.pop(); + } + + const parent = ancestors[ancestors.length - 1]; + const id = `${item.uri}/${name}`; + const existing = parent.children.get(id); + + if (existing && existing.data instanceof TestHeading) { + ancestors.push(existing); + existing.data.generation = thisGeneration; + existing.range = range; + } else { + existing?.dispose(); + const thead = controller.createTestItem(id, name, parent, item.uri, new TestHeading(thisGeneration)); + thead.range = range; + ancestors.push(thead); + } + }, + }); + + this.prune(item, thisGeneration); + item.status = vscode.TestItemStatus.Resolved; + } + + /** + * Removes tests that were deleted from the source. Each test suite and case + * has a 'generation' counter which is updated each time we discover it. This + * is called after discovery is finished to remove any children who are no + * longer in this generation. + */ + private prune(item: vscode.TestItem, thisGeneration: number) { + const queue: vscode.TestItem[] = [item]; + for (const parent of queue) { + for (const child of parent.children.values()) { + if (child.data.generation < thisGeneration) { + child.dispose(); + } else if (child.data instanceof TestHeading) { + queue.push(child); + } + } + } + } +} + +export class TestHeading { + constructor(public generation: number) {} +} + +type Operator = '+' | '-' | '*' | '/'; + +export class TestCase { + constructor( + private readonly a: number, + private readonly operator: Operator, + private readonly b: number, + private readonly expected: number, + public generation: number + ) {} + + getLabel() { + return `${this.a} ${this.operator} ${this.b} = ${this.expected}`; + } + + async run(item: vscode.TestItem, options: vscode.TestRun): Promise { + const start = Date.now(); + await new Promise(resolve => setTimeout(resolve, 1000 + Math.random() * 1000)); + const actual = this.evaluate(); + const duration = Date.now() - start; + + if (actual === this.expected) { + options.setState(item, vscode.TestResultState.Passed, duration); + } else { + const message = vscode.TestMessage.diff(`Expected ${item.label}`, String(this.expected), String(actual)); + message.location = new vscode.Location(item.uri!, item.range!); + options.appendMessage(item, message); + options.setState(item, vscode.TestResultState.Failed, duration); + } + } + + private evaluate() { + switch (this.operator) { + case '-': + return this.a - this.b; + case '+': + return this.a + this.b; + case '/': + return Math.floor(this.a / this.b); + case '*': + return this.a * this.b; + } + } +}