testing: update to new apis

This commit is contained in:
Connor Peet
2021-04-14 17:11:17 -07:00
parent b338bca265
commit 3e08e5a1bc
3 changed files with 319 additions and 252 deletions

View File

@ -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 });

View File

@ -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<MarkdownTestData> {
/**
* @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<MarkdownTestData>, cancellation: vscode.CancellationToken) {
const run = vscode.test.createTestRunTask(request);
const runTests = async (tests: Iterable<vscode.TestItem<MarkdownTestData>>) => {
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<WorkspaceTestRoot, TestFile>(
{ 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<vscode.Uri>();
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<DocumentTestRoot, TestFile>(
{ id: 'mdtests', label: 'Markdown Tests', uri: document.uri },
new DocumentTestRoot()
);
item.status = vscode.TestItemStatus.Pending;
item.resolveHandler = token => {
const contentChange = new vscode.EventEmitter<vscode.Uri>();
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<string>;
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<vscode.Uri>) {
const item = vscode.test.createTestItem<TestFile>({
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<TestFile>,
) {
}
/**
* 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<TestFile> | vscode.TestItem<TestHeading>)[] = [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<TestHeading | TestFile, TestHeading | TestCase>)[] = [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<TestHeading>);
}
}
}
}
}
class TestHeading {
public static create(label: string, range: vscode.Range, generation: number, parent: vscode.TestItem<TestFile | TestHeading>) {
return vscode.test.createTestItem<TestHeading, TestHeading | TestCase>({
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<TestHeading | TestFile>,
) {
const label = `${a} ${operator} ${b} = ${expected}`;
const item = vscode.test.createTestItem<TestCase, never>({
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<TestCase>,
public generation: number,
) { }
async run(options: vscode.TestRunTask<MarkdownTestData>): Promise<void> {
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;
}
}
}

View File

@ -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<MarkdownTestItem> {
/**
* @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<MarkdownTestItem>, cancellation: vscode.CancellationToken) {
const runTests = async (tests: Iterable<MarkdownTestItem>) => {
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<TestFile> {
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<TestFile> {
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<TestHeading | TestCase> {
constructor(
uri: vscode.Uri,
public parent: WorkspaceTestRoot | DocumentTestRoot,
private readonly getContent: (uri: vscode.Uri) => Promise<string>,
) {
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<TestHeading | TestCase> {
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<void> {
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;
}
}
}