diff --git a/test-provider-sample/src/extension.ts b/test-provider-sample/src/extension.ts index 3ae12d51..03af5dbb 100644 --- a/test-provider-sample/src/extension.ts +++ b/test-provider-sample/src/extension.ts @@ -1,9 +1,9 @@ import * as vscode from 'vscode'; -import { MathTestProvider } from './testProvider'; +import { MathTestController } from './testController'; export function activate(context: vscode.ExtensionContext) { context.subscriptions.push( - vscode.test.registerTestProvider(new MathTestProvider()), + vscode.test.registerTestController(new MathTestController()), vscode.commands.registerCommand('test-provider-sample.runTests', async tests => { await vscode.test.runTests({ tests: tests instanceof Array ? tests : [tests], debug: false }); diff --git a/test-provider-sample/src/testController.ts b/test-provider-sample/src/testController.ts new file mode 100644 index 00000000..a06e2dde --- /dev/null +++ b/test-provider-sample/src/testController.ts @@ -0,0 +1,317 @@ +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) { + return DocumentTestRoot.create(document); + } + + /** + * @inheritdoc + */ + public runTests(request: vscode.TestRunRequest, cancellation: vscode.CancellationToken) { + const run = vscode.test.createTestRunTask(request); + const runTests = async (tests: Iterable>) => { + for (const test of tests) { + if (request.exclude?.includes(test)) { + continue; + } + + if (test instanceof TestCase) { + 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.run(run); + } + run.appendOutput(`Completed ${test.id}\r\n`); + } else { + if (test.data instanceof TestFile && test.children.size === 0) { + await test.data.refresh(); + } + + await runTests(test.children.values()); + } + } + }; + + runTests(request.tests); + } +} + + +class WorkspaceTestRoot { + public static create(workspaceFolder: vscode.WorkspaceFolder) { + const item = vscode.test.createTestItem( + { id: 'mdtests', 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', 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 instanceof TestHeading) { + ancestors.push(existing); + existing.data.generation = thisGeneration; + } else { + 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) { + return vscode.test.createTestItem({ + id: `mktests/${parent.uri.toString()}/${label}`, + label, + uri: parent.uri, + }, new TestHeading(generation)); + } + + 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.TestRunTask): Promise { + await new Promise(resolve => setTimeout(resolve, 200 + Math.random() * 3000)); + const start = Date.now(); + const actual = this.evaluate(); + const duration = Date.now() - start; + + if (actual === this.expected) { + options.setState(this.item, vscode.TestResultState.Passed); + } 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/testProvider.ts b/test-provider-sample/src/testProvider.ts deleted file mode 100644 index 2945b00b..00000000 --- a/test-provider-sample/src/testProvider.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { TextDecoder } from 'util'; -import * as vscode from 'vscode'; -import { parseMarkdown } from './parser'; - -const textDecoder = new TextDecoder('utf-8'); - -type MarkdownTestItem = WorkspaceTestRoot | DocumentTestRoot | TestFile | TestHeading | TestCase; - -export class MathTestProvider implements vscode.TestProvider { - /** - * @inheritdoc - */ - public provideWorkspaceTestRoot(workspaceFolder: vscode.WorkspaceFolder) { - return new WorkspaceTestRoot(workspaceFolder); - } - - /** - * @inheritdoc - */ - public provideDocumentTestRoot(document: vscode.TextDocument) { - return new DocumentTestRoot(document); - } - - /** - * @inheritdoc - */ - public async runTests(run: vscode.TestRunOptions, cancellation: vscode.CancellationToken) { - const runTests = async (tests: Iterable) => { - for (const test of tests) { - if (run.exclude?.includes(test)) { - continue; - } - - if (test instanceof TestCase) { - 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.run(run); - } - run.appendOutput(`Completed ${test.id}\r\n`); - } else { - if (test instanceof TestFile && test.children.size === 0) { - await test.refresh(); - } - - await runTests(test.children); - } - } - }; - - await runTests(run.tests); - } -} - -class WorkspaceTestRoot extends vscode.TestItem { - public readonly parent = undefined; - - constructor(private readonly workspaceFolder: vscode.WorkspaceFolder) { - super('markdowntests', 'Markdown Tests', workspaceFolder.uri, true); - } - - public discoverChildren(progress: vscode.Progress<{ busy: boolean }>, token: vscode.CancellationToken) { - const pattern = new vscode.RelativePattern(this.workspaceFolder, '**/*.md'); - const watcher = vscode.workspace.createFileSystemWatcher(pattern); - watcher.onDidCreate(uri => - this.children.add(new TestFile(uri, this, getContentFromFilesystem)) - ); - watcher.onDidChange(uri => this.children.get(uri.toString())?.refresh()); - watcher.onDidDelete(uri => this.children.delete(uri.toString())); - token.onCancellationRequested(() => watcher.dispose()); - - vscode.workspace.findFiles(pattern).then(files => { - for (const file of files) { - this.children.add(new TestFile(file, this, getContentFromFilesystem)); - } - - progress.report({ busy: false }); - }); - } -} - -class DocumentTestRoot extends vscode.TestItem { - public readonly parent = undefined; - - constructor(private readonly document: vscode.TextDocument) { - super('markdowntests', 'Markdown Tests', document.uri, true); - } - - public discoverChildren(progress: vscode.Progress<{ busy: boolean }>, token: vscode.CancellationToken) { - const file = new TestFile(this.document.uri, this, () => - Promise.resolve(this.document.getText()) - ); - this.children.add(file); - - const changeListener = vscode.workspace.onDidChangeTextDocument(e => { - if (e.document === this.document) { - file.refresh(); - } - }); - - token.onCancellationRequested(() => changeListener.dispose()); - file.refresh().then(() => progress.report({ busy: false })); - } -} - -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 ''; - } -}; - -type Operator = '+' | '-' | '*' | '/'; - -class TestFile extends vscode.TestItem { - constructor( - uri: vscode.Uri, - public parent: WorkspaceTestRoot | DocumentTestRoot, - private readonly getContent: (uri: vscode.Uri) => Promise, - ) { - super(uri.toString(), uri.path.split('/').pop()!, uri, true); - } - - public discoverChildren(progress: vscode.Progress<{ busy: boolean }>, token: vscode.CancellationToken) { - this.refresh().then(() => progress.report({ busy: false })); - } - - /** - * 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: (TestFile | TestHeading)[] = [this]; - const thisGeneration = generationCounter++; - - parseMarkdown(await this.getContent(this.uri), { - onTest: (range, a, operator, b, expected) => { - const parent = ancestors[ancestors.length - 1]; - const tcase = new TestCase(Number(a), operator as Operator, Number(b), Number(expected), range, thisGeneration, parent); - - const existing = parent.children.get(tcase.id); - if (existing) { - existing.generation = thisGeneration; - } else { - parent.children.add(tcase); - } - }, - onHeading: (range, name, depth) => { - while (ancestors.length > depth) { - ancestors.pop(); - } - - const parent = ancestors[ancestors.length - 1]; - const thead = new TestHeading(name, range, thisGeneration, parent); - const existing = parent.children.get(thead.id); - if (existing instanceof TestHeading) { - ancestors.push(existing); - existing.generation = thisGeneration; - } else { - parent.children.add(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: (TestHeading | TestFile)[] = [this]; - for (const parent of queue) { - for (const child of parent.children) { - if (child.generation < thisGeneration) { - parent.children.delete(child); - } else if (child instanceof TestHeading) { - queue.push(child); - } - } - } - } -} - -class TestHeading extends vscode.TestItem { - public readonly level: number = this.parent instanceof TestFile ? 1 : this.parent.level + 1; - - constructor( - label: string, - range: vscode.Range, - public generation: number, - public readonly parent: TestFile | TestHeading, - ) { - super(`markdown/${parent.uri.toString()}/${label}`, label, parent.uri, true); - } -} - -class TestCase extends vscode.TestItem { - constructor( - private readonly a: number, - private readonly operator: Operator, - private readonly b: number, - private readonly expected: number, - public readonly range: vscode.Range, - public generation: number, - public readonly parent: TestHeading | TestFile, - ) { - super(`markdown/${parent.uri.toString()}/${a} ${operator} ${b} = ${expected}`, `${a} ${operator} ${b} = ${expected}`, parent.uri, false); - } - - async run(options: vscode.TestRunOptions): Promise { - await new Promise(resolve => setTimeout(resolve, 200 + Math.random() * 3000)); - const start = Date.now(); - const actual = this.evaluate(); - const duration = Date.now() - start; - - if (actual === this.expected) { - options.setState(this, vscode.TestResultState.Passed); - } else { - const message = vscode.TestMessage.diff(`Expected ${this.label}`, String(this.expected), String(actual)); - message.location = new vscode.Location(this.uri, this.range); - options.appendMessage(this, message); - options.setState(this, 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; - } - } -}