diff --git a/package.json b/package.json index 8b0fcf9..416f168 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "description": "Vitest Runner for VSCode that actually work", "publisher": "rluvaton", "engines": { - "vscode": "^1.65.0" + "vscode": "^1.68.0" }, "categories": [ "Testing", @@ -28,7 +28,9 @@ "onLanguage:javascriptreact" ], "devDependencies": { - "@types/vscode": "^1.65.0", + "@types/get-installed-path": "^4.0.1", + "@types/vscode": "^1.81.0", + "@vscode/dts": "^0.4.0", "esbuild": "^0.14.27", "prettier": "^2.6.0", "typescript": "^4.6.2", @@ -43,6 +45,7 @@ "format:write": "yarn format:check --write" }, "dependencies": { - "find-up": "^5.0.0" + "find-up": "^5.0.0", + "get-installed-path": "^4.0.8" } } diff --git a/src/codelens.ts b/src/codelens.ts deleted file mode 100644 index 0af41d9..0000000 --- a/src/codelens.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type * as ts from 'typescript'; -import * as vscode from 'vscode'; -import {convertCancellationTokenToAbortSignal} from './utils'; -import {DebugVitestCommand, RunVitestCommand} from './vscode'; -import {TestTreeBuilder} from "./test-tree/build"; -import {TestTreeNode} from './test-tree/types'; - -export class CodeLensProvider implements vscode.CodeLensProvider { - constructor(private typescript: typeof ts) { - } - - provideCodeLenses( - document: vscode.TextDocument, - token: vscode.CancellationToken - ): vscode.ProviderResult { - const ts = this.typescript; - - const text = document.getText(); - - const nodes = TestTreeBuilder.build(ts, text, convertCancellationTokenToAbortSignal(token)); - const allNodes = this.flatNodes(nodes); - - return allNodes.flatMap(testNode => { - const start = document.positionAt(testNode.position.start); - const end = document.positionAt(testNode.position.end); - - return [ - new vscode.CodeLens( - new vscode.Range(start, end), - new RunVitestCommand(testNode.name, document.fileName) - ), - new vscode.CodeLens( - new vscode.Range(start, end), - new DebugVitestCommand(testNode.name, document.fileName) - ) - ]; - }); - } - - private flatNodes(testNodes: TestTreeNode[]): TestTreeNode[] { - let nodes = [...testNodes]; - for (let i = 0; i < nodes.length; i++) { - const node = nodes[i]; - if (node.type === 'suite') { - nodes = nodes.concat(node.children); - } - } - - return nodes; - } -} diff --git a/src/index.ts b/src/index.ts index 2f4ddbf..97f1ec7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,16 +1,188 @@ -import type * as ts from 'typescript'; import * as vscode from 'vscode'; -import { CodeLensProvider } from './codelens'; -import { getVscodeTypescriptPath } from './utils'; +import * as typescript from 'typescript'; +import { TestCase, testData, TestFile } from './test-tree/sample'; +import { TestTreeBuilder } from './test-tree/build'; -export function activate(context: vscode.ExtensionContext) { - const tsPath = getVscodeTypescriptPath(vscode.env.appRoot); - const typescript = require(tsPath) as typeof ts; +export async function activate(context: vscode.ExtensionContext) { + const ctrl = vscode.tests.createTestController('vitestTestController', 'Vitest'); + context.subscriptions.push(ctrl); + + const fileChangedEmitter = new vscode.EventEmitter(); + const runHandler = (isDebug: boolean, request: vscode.TestRunRequest, cancellation: vscode.CancellationToken) => { + if (!request.continuous) { + return startTestRun(request, isDebug); + } + + const l = fileChangedEmitter.event(uri => startTestRun( + new vscode.TestRunRequest( + [getOrCreateFile(ctrl, uri).file], + undefined, + request.profile, + true + ), + isDebug + )); + cancellation.onCancellationRequested(() => l.dispose()); + }; + + const startTestRun = (request: vscode.TestRunRequest, isDebug: boolean) => { + const queue: { test: vscode.TestItem; data: TestCase }[] = []; + const run = ctrl.createTestRun(request); + // map of file uris to statements on each line: + + const discoverTests = async (tests: Iterable) => { + 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)); + } + } + }; + + 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, isDebug); + } + + run.appendOutput(`Completed ${test.id}\r\n`); + } + + run.end(); + }; + + 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, (...args) => runHandler(false, ...args), true, undefined, true); + ctrl.createRunProfile('Debug Tests', vscode.TestRunProfileKind.Debug, (...args) => runHandler(true, ...args), false, undefined, false); + + 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; + } + + const ac = new AbortController(); + + // TODO - in the future we should check if file match the test pattern in the vitest config + TestTreeBuilder.build(typescript, e.getText(), ac.signal, { + onSuite() { + ac.abort(); + }, + onTest() { + ac.abort(); + } + }); + + // No test file found + if(!ac.signal.aborted) { + return; + } + + const { file, data } = getOrCreateFile(ctrl, e.uri); + data.updateFromContents(ctrl, e, e.getText(), file); + } + + for (const document of vscode.workspace.textDocuments) { + updateNodeForDocument(document); + } context.subscriptions.push( - vscode.languages.registerCodeLensProvider( - ['typescript', 'javascript', 'typescriptreact', 'javascriptreact'], - new CodeLensProvider(typescript) - ) + 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, + // TODO - ADD SUPPORT FOR js AND spec AND tsx + pattern: new vscode.RelativePattern(workspaceFolder, '**/*.test.ts'), + })); +} + +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(({ 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; + }); +} diff --git a/src/reporter.ts b/src/reporter.ts new file mode 100644 index 0000000..ab42bf1 --- /dev/null +++ b/src/reporter.ts @@ -0,0 +1,48 @@ +import {Awaitable, Reporter, UserConsoleLog, Vitest} from "vitest"; +import {File, TaskResultPack} from "@vitest/runner"; + +export default class CustomReporter implements Reporter { + onInit(ctx: Vitest): void { + console.log('onInit', ctx) + } + + onPathsCollected(paths?: string[]) { + console.log('onPathsCollected', paths) + }; + + onCollected(files?: File[]) { + console.log('onCollected', files) + } + + onFinished(files?: File[], errors?: unknown[]) { + console.log('onFinished', files, errors) + } + + onTaskUpdate(packs: TaskResultPack[]) { + console.log('onTaskUpdate', packs) + } + + onTestRemoved(trigger?: string) { + console.log('onTestRemoved', trigger) + } + + onWatcherStart(files?: File[], errors?: unknown[]) { + console.log('onWatcherStart', files, errors) + } + + onWatcherRerun(files: string[], trigger?: string) { + console.log('onWatcherRerun', files, trigger) + } + + onServerRestart(reason?: string) { + console.log('onServerRestart', reason) + } + + onUserConsoleLog(log: UserConsoleLog) { + console.log('onUserConsoleLog', log) + } + + onProcessTimeout() { + console.log('onProcessTimeout') + } +} diff --git a/src/run.ts b/src/run.ts index 5f5db18..dd5fccf 100644 --- a/src/run.ts +++ b/src/run.ts @@ -1,7 +1,10 @@ import * as vscode from 'vscode'; import * as path from 'path'; import * as findUp from 'find-up'; +import * as fs from 'fs'; import { configFiles } from './vitest-config-files'; +import { spawn, execFile } from 'child_process'; +import { getInstalledPath } from 'get-installed-path'; function getCwd(testFile: string) { const configFilePath = findUp.sync(configFiles, { cwd: testFile }); @@ -12,7 +15,7 @@ function getCwd(testFile: string) { return path.dirname(configFilePath); } -function buildVitestArgs({ caseName, casePath, sanitize = true }: { caseName: string, casePath: string, sanitize?: boolean }) { +function buildVitestArgs({ caseName, casePath, addRoot = true, sanitize = true }: { caseName: string, casePath: string, addRoot?: boolean, sanitize?: boolean }) { let sanitizedCasePath = casePath; if (sanitize) { sanitizedCasePath = JSON.stringify(casePath); @@ -21,9 +24,11 @@ function buildVitestArgs({ caseName, casePath, sanitize = true }: { caseName: st const args = ['vitest', 'run', '--testNamePattern', caseName, sanitizedCasePath]; - const rootDir = getCwd(casePath); - if (rootDir) { - args.push('--root', rootDir); + if (addRoot) { + const rootDir = getCwd(casePath); + if (rootDir) { + args.push('--root', rootDir); + } } return args; @@ -57,6 +62,91 @@ export async function runInTerminal(text: string, filename: string) { terminal.show(); } +async function isFileExist(filename: string): Promise { + return new Promise((resolve, reject) => { + fs.access(filename, fs.constants.F_OK, (err: any) => { + if (err) { + resolve(false); + } else { + resolve(true); + } + }); + }); +} + +async function findVitestRunner(cwd: string) { + let nodeModules = await findUp.default('node_modules', { cwd, type: 'directory' }); + let vitestPath = path.join(nodeModules || cwd, '.bin', 'vitest'); + let exists = nodeModules && await isFileExist(vitestPath); + + if (!exists) { + const vitestGlobalLocation = await getInstalledPath('vitest', { local: false }); + + nodeModules = await findUp.default('node_modules', { cwd: vitestGlobalLocation, type: 'directory' }); + + vitestPath = path.join(nodeModules, '.bin', 'vitest'); + exists = nodeModules && await isFileExist(vitestPath); + } + + if (!exists) { + throw new Error('Could not find vitest'); + } + + return vitestPath; +} + +export async function executeTest(filename: string, text: string, signal: AbortSignal) { + // TODO - check about this sanitization + const vitestArgs = buildVitestArgs({ caseName: text, casePath: filename, addRoot: false, sanitize: false }); + vitestArgs.shift(); // remove `vitest` + // TODO - save file before running + // await saveFile(filename); + + // const testRun = spawn('npx', vitestArgs, { + const vitestRunner = await findVitestRunner(path.dirname(filename)); + // TODO - use the same node as the current terminal node + const testRun = execFile(vitestRunner, vitestArgs, { + cwd: getCwd(filename) || path.dirname(filename), + signal, + windowsHide: true, + env: { + ...process.env, + FORCE_COLOR: '1', + } + }); + + let res: () => void; + let rej: (err: Error) => void; + + const finishPromise = new Promise((resolve, reject) => { + res = resolve; + rej = reject; + }); + + testRun.on('error', (err) => { + console.error(err); + rej(err); + }); + + testRun.on('close', (code) => { + console.log(`child process exited with code ${code}`); + if (code === 0) { + res(); + } else { + rej(new Error(`child process exited with code ${code}`)); + } + }); + + return { + finishPromise, + stdout: testRun.stdout, + stderr: testRun.stderr, + } + + // terminal.sendText(npxArgs.join(' '), true); + // terminal.show(); +} + function buildDebugConfig( casePath: string, text: string diff --git a/src/test-tree/sample.ts b/src/test-tree/sample.ts new file mode 100644 index 0000000..98ed6af --- /dev/null +++ b/src/test-tree/sample.ts @@ -0,0 +1,127 @@ +import { TextDecoder } from 'util'; +import * as vscode from 'vscode'; +import * as typescript from 'typescript'; +import { TestTreeBuilder } from './build'; +import { TestTreeNode } from './types'; +import { executeTest } from '../run'; +import { convertCancellationTokenToAbortSignal } from '../utils'; + +const textDecoder = new TextDecoder('utf-8'); + +export type MarkdownTestData = TestFile | TestHeading | TestCase; + +export const testData = new WeakMap(); + +export const getContentFromFilesystem = async (uri: vscode.Uri): Promise<{ content: string, document?: vscode.TextDocument }> => { + try { + const [rawContent, document] = await Promise.all([ + vscode.workspace.fs.readFile(uri), + vscode.workspace.openTextDocument(uri) + ]); + const content = textDecoder.decode(rawContent); + + return { + content, + document + } + } catch (e) { + console.warn(`Error providing tests for ${uri.fsPath}`, e); + return { + content: '', + document: undefined + }; + } +}; + +export class TestFile { + public didResolve = false; + + public async updateFromDisk(controller: vscode.TestController, item: vscode.TestItem) { + try { + const { document, content } = await getContentFromFilesystem(item.uri!); + item.error = undefined; + + // TODO - remove the ! + this.updateFromContents(controller, document!, 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, document: vscode.TextDocument, content: string, item: vscode.TestItem) { + this.didResolve = true; + + const nodes = TestTreeBuilder.build(typescript, content, new AbortController().signal); + this.travel(controller, item.uri!, document, nodes, item); + } + + travel(controller: vscode.TestController, testUri: vscode.Uri, document: vscode.TextDocument, nodes: TestTreeNode[], parent: vscode.TestItem) { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + + // TODO - use path instead for unique id + const id = `${testUri.path}/${node.name}`; + const testItem = controller.createTestItem(id, node.name, testUri); + + testItem.range = new vscode.Range(document.positionAt(node.position.start), document.positionAt(node.position.end)) + + testData.set(testItem, new TestCase(node)); + switch (node.type) { + case 'test': { } + break; + case 'suite': + this.travel(controller, testUri, document, node.children, testItem); + break; + } + + parent.children.add(testItem); + } + } +} + +export class TestHeading { + constructor(public generation: number) { } +} + +export class TestCase { + private node: TestTreeNode; + + constructor(node: TestTreeNode) { + this.node = node; + } + + async run(item: vscode.TestItem, options: vscode.TestRun, isDebug: boolean): Promise { + const start = Date.now(); + await new Promise(resolve => setTimeout(resolve, 1000 + Math.random() * 1000)); + + const result = await executeTest(item.uri!.path, this.node.name, convertCancellationTokenToAbortSignal(options.token)); + options.started(item); + + // According to the docs, we should replace \n with \r\n + result.stdout?.on('data', data => { + options.appendOutput(convertLFToCRLF(data.toString())); + }); + + result.stderr?.on('data', data => { + options.appendOutput(convertLFToCRLF(data.toString())); + }); + + try { + await result.finishPromise; + const duration = Date.now() - start; + options.passed(item, duration); + } catch (e) { + console.log('error', e); + const duration = Date.now() - start; + options.failed(item, (e as any).message, duration); + } + } +} + +function convertLFToCRLF(str: string) { + return str.replace(/(? { - runInTerminal(text, filename) - } -); - -vscode.commands.registerCommand( - DebugVitestCommand.ID, - (text: string, filename: string) => { - debugInTerminal(text, filename); - } -); diff --git a/tests/cases/test.spec.ts b/tests/cases/test.spec.ts index 76337d5..3983bd6 100644 --- a/tests/cases/test.spec.ts +++ b/tests/cases/test.spec.ts @@ -3,6 +3,7 @@ import { describe, it, test, expect } from 'vitest'; describe('Test', () => { describe('sub', () => { it('sub test 1', () => { + throw new Error('test'); expect(1 + 41).toBe(42); }); }); diff --git a/tsconfig.json b/tsconfig.json index 948f943..192ab3e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,7 @@ "jsx": "preserve" }, "include": [ - "src" + "src", + "node_modules/vitest/globals.d.ts" ] -} \ No newline at end of file +}