From fe804514671c58c4109e2356edc756359e08b19d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Mangeonjean?= Date: Tue, 5 Dec 2023 19:51:41 +0100 Subject: [PATCH] feat(demo): demo testing service override --- demo/package-lock.json | 30 ++- demo/package.json | 1 + demo/src/features/filesystem.ts | 12 +- .../features/testProviderExample/extension.ts | 224 ++++++++++++++++++ .../features/testProviderExample/parser.ts | 29 +++ .../features/testProviderExample/testTree.ts | 126 ++++++++++ demo/src/features/testing.ts | 3 + demo/src/main.ts | 1 + demo/src/setup.ts | 7 +- 9 files changed, 421 insertions(+), 12 deletions(-) create mode 100644 demo/src/features/testProviderExample/extension.ts create mode 100644 demo/src/features/testProviderExample/parser.ts create mode 100644 demo/src/features/testProviderExample/testTree.ts create mode 100644 demo/src/features/testing.ts diff --git a/demo/package-lock.json b/demo/package-lock.json index a58c94bb..4a7f438d 100644 --- a/demo/package-lock.json +++ b/demo/package-lock.json @@ -123,6 +123,7 @@ "@codingame/monaco-vscode-storage-service-override": "file:../dist/service-override-storage", "@codingame/monaco-vscode-swift-default-extension": "file:../dist/default-extension-swift", "@codingame/monaco-vscode-terminal-service-override": "file:../dist/service-override-terminal", + "@codingame/monaco-vscode-testing-service-override": "file:../dist/service-override-testing", "@codingame/monaco-vscode-textmate-service-override": "file:../dist/service-override-textmate", "@codingame/monaco-vscode-theme-abyss-default-extension": "file:../dist/default-extension-theme-abyss", "@codingame/monaco-vscode-theme-defaults-default-extension": "file:../dist/default-extension-theme-defaults", @@ -1021,11 +1022,6 @@ "version": "0.0.0-semantic-release", "license": "MIT", "dependencies": { - "@codingame/monaco-vscode-environment-service-override": "0.0.0-semantic-release", - "@codingame/monaco-vscode-extensions-service-override": "0.0.0-semantic-release", - "@codingame/monaco-vscode-files-service-override": "0.0.0-semantic-release", - "@codingame/monaco-vscode-layout-service-override": "0.0.0-semantic-release", - "@codingame/monaco-vscode-quickaccess-service-override": "0.0.0-semantic-release", "monaco-editor": "0.44.0", "vscode": "npm:@codingame/monaco-vscode-api@^0.0.0-semantic-release", "vscode-semver": "npm:semver@=5.5.0" @@ -1180,11 +1176,6 @@ "version": "0.0.0-semantic-release", "license": "MIT", "dependencies": { - "@codingame/monaco-vscode-environment-service-override": "0.0.0-semantic-release", - "@codingame/monaco-vscode-extensions-service-override": "0.0.0-semantic-release", - "@codingame/monaco-vscode-files-service-override": "0.0.0-semantic-release", - "@codingame/monaco-vscode-layout-service-override": "0.0.0-semantic-release", - "@codingame/monaco-vscode-quickaccess-service-override": "0.0.0-semantic-release", "monaco-editor": "0.44.0", "vscode": "npm:@codingame/monaco-vscode-api@^0.0.0-semantic-release" } @@ -1233,6 +1224,21 @@ "xterm-addon-webgl": "0.17.0-beta.26" } }, + "../dist/service-override-testing": { + "name": "@codingame/monaco-vscode-testing-service-override", + "version": "0.0.0-semantic-release", + "license": "MIT", + "dependencies": { + "monaco-editor": "0.44.0", + "vscode": "npm:@codingame/monaco-vscode-api@^0.0.0-semantic-release", + "xterm-addon-canvas": "0.6.0-beta.27", + "xterm-addon-image": "0.6.0-beta.21", + "xterm-addon-search": "0.14.0-beta.27", + "xterm-addon-serialize": "0.12.0-beta.26", + "xterm-addon-unicode11": "0.7.0-beta.26", + "xterm-addon-webgl": "0.17.0-beta.26" + } + }, "../dist/service-override-textmate": { "name": "@codingame/monaco-vscode-textmate-service-override", "version": "0.0.0-semantic-release", @@ -1894,6 +1900,10 @@ "resolved": "../dist/service-override-terminal", "link": true }, + "node_modules/@codingame/monaco-vscode-testing-service-override": { + "resolved": "../dist/service-override-testing", + "link": true + }, "node_modules/@codingame/monaco-vscode-textmate-service-override": { "resolved": "../dist/service-override-textmate", "link": true diff --git a/demo/package.json b/demo/package.json index 3db5c86c..75e11ffe 100644 --- a/demo/package.json +++ b/demo/package.json @@ -78,6 +78,7 @@ "@codingame/monaco-vscode-keybindings-service-override": "file:../dist/service-override-keybindings", "@codingame/monaco-vscode-language-detection-worker-service-override": "file:../dist/service-override-language-detection-worker", "@codingame/monaco-vscode-working-copy-service-override": "file:../dist/service-override-working-copy", + "@codingame/monaco-vscode-testing-service-override": "file:../dist/service-override-testing", "@codingame/monaco-vscode-language-pack-cs": "file:../dist/vscode-language-pack-cs", "@codingame/monaco-vscode-language-pack-de": "file:../dist/vscode-language-pack-de", "@codingame/monaco-vscode-language-pack-es": "file:../dist/vscode-language-pack-es", diff --git a/demo/src/features/filesystem.ts b/demo/src/features/filesystem.ts index b50e608c..9a1affb4 100644 --- a/demo/src/features/filesystem.ts +++ b/demo/src/features/filesystem.ts @@ -57,7 +57,17 @@ $$ \\leq \\left( \\sum_{k=1}^n a_k^2 \\right) \\left( \\sum_{k=1}^n b_k^2 \\right) -$$` +$$ + +# Easy Math + +2 + 2 = 4 // this test will pass +2 + 2 = 5 // this test will fail + +# Harder Math + +230230 + 5819123 = 6049353 +` )) fileSystemProvider.registerFile(new RegisteredMemoryFile(vscode.Uri.file('/tmp/test.customeditor'), ` diff --git a/demo/src/features/testProviderExample/extension.ts b/demo/src/features/testProviderExample/extension.ts new file mode 100644 index 00000000..199d26d8 --- /dev/null +++ b/demo/src/features/testProviderExample/extension.ts @@ -0,0 +1,224 @@ +import * as vscode from 'vscode' +import { getContentFromFilesystem, TestCase, testData, TestFile } from './testTree' + +const ctrl = vscode.tests.createTestController('mathTestController', 'Markdown Math') + +const fileChangedEmitter = new vscode.EventEmitter() +const watchingTests = new Map() +fileChangedEmitter.event(uri => { + if (watchingTests.has('ALL')) { + startTestRun(new vscode.TestRunRequest(undefined, undefined, watchingTests.get('ALL'), true)) + return + } + + const include: vscode.TestItem[] = [] + let profile: vscode.TestRunProfile | undefined + for (const [item, thisProfile] of watchingTests) { + const cast = item as vscode.TestItem + if (cast.uri?.toString() === uri.toString()) { + include.push(cast) + profile = thisProfile + } + } + + if (include.length > 0) { + startTestRun(new vscode.TestRunRequest(include, undefined, profile, true)) + } +}) + +const runHandler = (request: vscode.TestRunRequest, cancellation: vscode.CancellationToken) => { + if (!(request.continuous ?? false)) { + return startTestRun(request) + } + + if (request.include === undefined) { + watchingTests.set('ALL', request.profile) + cancellation.onCancellationRequested(() => watchingTests.delete('ALL')) + } else { + request.include.forEach(item => watchingTests.set(item, request.profile)) + cancellation.onCancellationRequested(() => request.include!.forEach(item => watchingTests.delete(item))) + } +} + +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: + type OptionalStatementCoverage = vscode.StatementCoverage | undefined + const coveredLines = new Map() + + const discoverTests = async (tests: Iterable) => { + for (const test of tests) { + if (request.exclude?.includes(test) ?? false) { + 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 != null && !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 > 0 ? 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()) + const lineInfo = fileCoverage?.[lineNo] + if (lineInfo != null) { + lineInfo.executionCount++ + } + + run.appendOutput(`Completed ${test.id}\r\n`) + } + + 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 != null) + ) + ) + } + + return coverage + } + } + + run.end() + } + + void 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 == null) { + 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) +} + +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 != null) { + 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 == null) { + 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(({ 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())) + + void findInitialFiles(controller, pattern) + + return watcher + }) +} diff --git a/demo/src/features/testProviderExample/parser.ts b/demo/src/features/testProviderExample/parser.ts new file mode 100644 index 00000000..38fe5d1e --- /dev/null +++ b/demo/src/features/testProviderExample/parser.ts @@ -0,0 +1,29 @@ +import * as vscode from 'vscode' + +const testRe = /^([0-9]+)\s*([+*/-])\s*([0-9]+)\s*=\s*([0-9]+)/ +const headingRe = /^(#+)\s*(.+)$/ + +export const parseMarkdown = (text: string, events: { + onTest(range: vscode.Range, a: number, operator: string, b: number, expected: number): void + onHeading(range: vscode.Range, name: string, depth: number): void +}): void => { + const lines = text.split('\n') + + for (let lineNo = 0; lineNo < lines.length; lineNo++) { + const line = lines[lineNo] + const test = testRe.exec(line) + if (test != null) { + const [, a, operator, b, expected] = test + const range = new vscode.Range(new vscode.Position(lineNo, 0), new vscode.Position(lineNo, test[0].length)) + events.onTest(range, Number(a), operator, Number(b), Number(expected)) + continue + } + + const heading = headingRe.exec(line) + if (heading != null) { + const [, pounds, name] = heading + const range = new vscode.Range(new vscode.Position(lineNo, 0), new vscode.Position(lineNo, line.length)) + events.onHeading(range, name, pounds.length) + } + } +} diff --git a/demo/src/features/testProviderExample/testTree.ts b/demo/src/features/testProviderExample/testTree.ts new file mode 100644 index 00000000..27e984f1 --- /dev/null +++ b/demo/src/features/testProviderExample/testTree.ts @@ -0,0 +1,126 @@ +import * as vscode from 'vscode' +import { parseMarkdown } from './parser' + +const textDecoder = new TextDecoder('utf-8') + +export type MarkdownTestData = TestFile | TestHeading | TestCase + +export const testData = new WeakMap() + +let generationCounter = 0 + +export const getContentFromFilesystem = async (uri: vscode.Uri): Promise => { + 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 didResolve = false + + public async updateFromDisk (controller: vscode.TestController, item: vscode.TestItem): Promise { + try { + const content = await getContentFromFilesystem(item.uri!) + item.error = undefined + this.updateFromContents(controller, content, item) + } catch (e) { + item.error = (e as Error).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): void { + const ancestors = [{ item, children: [] as vscode.TestItem[] }] + const thisGeneration = generationCounter++ + this.didResolve = true + + const ascend = (depth: number) => { + while (ancestors.length > depth) { + const finished = ancestors.pop()! + finished.item.children.replace(finished.children) + } + } + + 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 tcase = controller.createTestItem(id, data.getLabel(), item.uri) + testData.set(tcase, data) + tcase.range = range + parent.children.push(tcase) + }, + + onHeading: (range, name, depth) => { + ascend(depth) + const parent = ancestors[ancestors.length - 1] + const id = `${item.uri}/${name}` + + const thead = controller.createTestItem(id, name, item.uri) + thead.range = range + testData.set(thead, new TestHeading(thisGeneration)) + parent.children.push(thead) + ancestors.push({ item: thead, children: [] }) + } + }) + + ascend(0) // finish and assign children for all remaining items + } +} + +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 (): string { + 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.passed(item, 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.failed(item, message, 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/demo/src/features/testing.ts b/demo/src/features/testing.ts new file mode 100644 index 00000000..edae8f71 --- /dev/null +++ b/demo/src/features/testing.ts @@ -0,0 +1,3 @@ +import '../setup' // import setup file to wait for services initialization + +await import('./testProviderExample/extension') diff --git a/demo/src/main.ts b/demo/src/main.ts index d906c5fb..0621e7e6 100644 --- a/demo/src/main.ts +++ b/demo/src/main.ts @@ -19,6 +19,7 @@ import './features/intellisense' import './features/notifications' import './features/terminal' import './features/scm' +import './features/testing' import '@codingame/monaco-vscode-clojure-default-extension' import '@codingame/monaco-vscode-coffeescript-default-extension' import '@codingame/monaco-vscode-cpp-default-extension' diff --git a/demo/src/setup.ts b/demo/src/setup.ts index 33152303..3be7d249 100644 --- a/demo/src/setup.ts +++ b/demo/src/setup.ts @@ -40,6 +40,7 @@ import getWorkspaceTrustOverride from '@codingame/monaco-vscode-workspace-trust- import getLogServiceOverride from '@codingame/monaco-vscode-log-service-override' import { createIndexedDBProviders, initFile } from '@codingame/monaco-vscode-files-service-override' import getWorkingCopyServiceOverride from '@codingame/monaco-vscode-working-copy-service-override' +import getTestingServiceOverride from '@codingame/monaco-vscode-testing-service-override' import * as monaco from 'monaco-editor' import { TerminalBackend } from './features/terminal' import { openNewCodeEditor } from './features/editor' @@ -136,7 +137,8 @@ await initializeMonacoService({ ...getEnvironmentServiceOverride(), ...getWorkspaceTrustOverride(), ...getWorkingCopyServiceOverride(), - ...getScmServiceOverride() + ...getScmServiceOverride(), + ...getTestingServiceOverride() }, document.body, { remoteAuthority, enableWorkspaceTrust: true, @@ -160,6 +162,9 @@ await initializeMonacoService({ editors: [{ uri: monaco.Uri.file('/tmp/test.js'), viewColumn: 1 + }, { + uri: monaco.Uri.file('/tmp/test.md'), + viewColumn: 1 }, { uri: monaco.Uri.file('/tmp/test.css'), viewColumn: 2