From c20a770a77f8a627d3c5540c869a0254a1559a41 Mon Sep 17 00:00:00 2001 From: Raz Luvaton <16746759+rluvaton@users.noreply.github.com> Date: Mon, 7 Aug 2023 12:14:41 +0300 Subject: [PATCH 1/7] build test tree --- src/test-tree/build.test.ts | 287 ++++++++++++++++++++++++++++++++++++ src/test-tree/build.ts | 190 ++++++++++++++++++++++++ src/test-tree/types.ts | 18 +++ tests/cases/test.spec.ts | 6 + tsconfig.json | 5 +- 5 files changed, 504 insertions(+), 2 deletions(-) create mode 100644 src/test-tree/build.test.ts create mode 100644 src/test-tree/build.ts create mode 100644 src/test-tree/types.ts diff --git a/src/test-tree/build.test.ts b/src/test-tree/build.test.ts new file mode 100644 index 0000000..31a2130 --- /dev/null +++ b/src/test-tree/build.test.ts @@ -0,0 +1,287 @@ +import * as ts from "typescript"; +import {describe, expect, it} from "vitest"; +import {TestTreeBuilder} from "./build"; +import {TestTreeNode} from "./types"; + +describe('build', () => { + describe('simple cases', () => { + it('it', () => { + const code = `it("Test should work", () => { + expect(42).toBe(42) +});`; + + const root = TestTreeBuilder.build(ts, code, new AbortController().signal); + + expect(root).toEqual([ + { + name: "Test should work", + position: { + start: 0, + end: 57 + }, + type: "test" + } + ]) + }); + + it('test', () => { + const code = `test("Test should work", () => { + expect(42).toBe(42) +})`; + + const root = TestTreeBuilder.build(ts, code, new AbortController().signal); + + expect(root).toEqual([ + { + name: "Test should work", + position: { + start: 0, + end: 59 + }, + type: "test" + } + ]) + console.log(root) + }); + + it('it inside describe', () => { + const code = `describe('something', () => { + it("Test should work", () => { + expect(42).toBe(42) + }); +});`; + + const root = TestTreeBuilder.build(ts, code, new AbortController().signal); + + expect(root).toEqual([ + expect.objectContaining({ + children: [{ + name: "Test should work", + position: { + start: 34, + end: 99 + }, + type: "test" + }], + }) + ]) + }); + + it('test inside describe', () => { + const code = `describe('something', () => { + test("Test should work", () => { + expect(42).toBe(42) + }); +});`; + + const root = TestTreeBuilder.build(ts, code, new AbortController().signal); + + expect(root).toEqual([ + expect.objectContaining({ + children: [{ + name: "Test should work", + position: { + start: 34, + end: 101 + }, + type: "test" + }], + }) + ]) + }); + + it('describe', () => { + const code = `describe('something', () => { +});`; + + const root = TestTreeBuilder.build(ts, code, new AbortController().signal); + + expect(root).toEqual([ + { + type: "suite", + children: [], + name: "something", + position: { + start: 0, + end: 32 + }, + } + ]) + }); + + it('nested describe', () => { + const code = ` +describe('something', () => { + describe('nested', () => { + }); +});`; + + const root = TestTreeBuilder.build(ts, code, new AbortController().signal); + + expect(root).toEqual([ + { + type: "suite", + name: "something", + position: { + start: 1, + end: 72 + }, + children: [{ + type: "suite", + name: "nested", + position: { + start: 35, + end: 68 + }, + children: [] + }], + } + ]) + }); + + describe('each', () => { + it('it.each with array', () => { + const code = `it.each([1])("Test %s work", () => { + expect(42).toBe(42) +});`; + + const root = TestTreeBuilder.build(ts, code, new AbortController().signal); + + expect(root).toEqual([ + { + name: "Test .* work", + position: { + start: 0, + end: 63 + }, + type: "test", + } + ]) + }); + + it('it.each with template', () => { + const code = ` +it.each\` + value + ${1} +\`('test this $value hello', ({value}) => { + console.log(value); +});`; + + const root = TestTreeBuilder.build(ts, code, new AbortController().signal); + + expect(root).toEqual([ + { + name: "test this .* hello", + position: { + start: 1, + end: 101 + }, + type: "test", + } + ]) + }); + + // TODO - add tests for test.each and describe each + }); + }); + + it('should build complex test structure', () => { + + const code = ` +describe('my describe', () => { + describe('sub', () => { + it('sub test 1', () => { + expect(1 + 41).toBe(42); + }); + }); + + it('Should work', () => { + expect(1 + 41).toBe(42); + }); +}); + +test("Test should work", () => { + expect(42).toBe(42) +}) + +it.each([1])('test this %s', (s) => { + console.log(s); +}); + +it.each\` + value + ${1} +\`('test this $va ccasacs', ({value}) => { + console.log(value); +}); + +`; + + const root = TestTreeBuilder.build(ts, code, new AbortController().signal); + + expect(root).toEqual([ + { + name: "my describe", + position: { + start: 1, + end: 230 + }, + type: "suite", + children: [ + { + name: "sub", + position: { + start: 37, + end: 149 + }, + type: "suite", + children: [ + { + name: "sub test 1", + position: { + start: 69, + end: 141 + }, + type: "test" + } + ] + }, + { + name: "Should work", + position: { + start: 161, + end: 226 + }, + type: "test" + } + ] + }, + { + name: "Test should work", + position: { + start: 233, + end: 292 + }, + type: "test" + }, + { + name: "test this .*", + position: { + start: 294, + end: 354 + }, + type: "test" + }, + { + name: "test this .* ccasacs", + position: { + start: 357, + end: 450 + }, + type: "test" + } + ]) + console.log(root) + }); +}); diff --git a/src/test-tree/build.ts b/src/test-tree/build.ts new file mode 100644 index 0000000..8e9c5d6 --- /dev/null +++ b/src/test-tree/build.ts @@ -0,0 +1,190 @@ +import type * as TS from "typescript"; +import type {TestTreeNode} from "./types"; + +const caseText = new Set(['it', 'describe', 'test']); + +export class TestTreeBuilder { + private sourceFile: TS.SourceFile; + private ts: typeof TS; + private abortSignal: AbortSignal; + + private rootTestTreeNodes: TestTreeNode[] = []; + private testTreeNodes = new WeakMap(); + + private constructor(ts: typeof TS, codeContent: string, abortSignal: AbortSignal) { + this.ts = ts; + this.abortSignal = abortSignal; + + this.sourceFile = ts.createSourceFile( + 'dummy', + codeContent, + ts.ScriptTarget.Latest, + /* setParentNodes */ true, + ); + } + + static build(ts: typeof TS, codeContent: string, abortSignal: AbortSignal): TestTreeNode[] { + const builder = new TestTreeBuilder(ts, codeContent, abortSignal); + return builder.build(); + } + + build(): TestTreeNode[] { + this.visitor(this.sourceFile); + + return this.rootTestTreeNodes; + } + + visitor(node: TS.Node) { + if (this.abortSignal.aborted) { + return; + } + + if (this.ts.isCallExpression(node)) { + this.findPossibleTests(node); + } + this.ts.forEachChild(node, this.visitor.bind(this)); + } + + + findPossibleTests(callExpression: TS.CallExpression) { + const eachResult = this.isEach(callExpression); + if (!eachResult.isEach && !(this.ts.isIdentifier(callExpression.expression) && caseText.has(callExpression.expression.text))) { + return; + } + + const testType = eachResult.isEach ? eachResult.type : this.getTypeFromFunctionName((callExpression.expression as TS.Identifier).text); + + const args = callExpression.arguments; + if (args.length < 2) { + return; + } + + const [testName, body] = args; + if ( + !this.ts.isStringLiteralLike(testName) || + !this.ts.isFunctionLike(body) + ) { + return; + } + + let testNameText = testName.text; + + if (eachResult.isEach) { + // + testNameText = testNameText + // From https://github.com/jestjs/jest/blob/0fd5b1c37555f485c56a6ad2d6b010a72204f9f6/packages/jest-each/src/table/array.ts#L15C32-L15C47 + // (Did not find inside vitest source code) + .replace(/%[sdifjoOp#]/g, '.*') + // When using template string + .replace(/\$[a-zA-Z_0-9]+/g, '.*'); + } + + const newTestNode = getTreeNode({ + testNameText, + start: callExpression.getStart(this.sourceFile), + end: callExpression.getEnd(), + testType + }); + + + let node: TS.Node = callExpression.parent; + while (node && !this.testTreeNodes.get(node)) { + node = node.parent; + } + + let treeNode = this.testTreeNodes.get(node); + + if (!treeNode) { + this.rootTestTreeNodes.push(newTestNode); + } else if (treeNode.type === 'suite') { + treeNode.children.push(newTestNode); + } + + this.testTreeNodes.set(callExpression, newTestNode); + } + + + private isEach(callExpression: TS.CallExpression): EachResult { + let eachResult = this.isEachWithArray(callExpression); + if (eachResult.isEach) { + return eachResult; + } + + return this.isEachWithTemplate(callExpression); + } + + private isEachWithArray(callExpression: TS.CallExpression): EachResult { + if ( + !this.ts.isCallExpression(callExpression.expression) || + !this.ts.isPropertyAccessExpression(callExpression.expression.expression) || + !this.ts.isIdentifier(callExpression.expression.expression.expression) || + !this.ts.isIdentifier(callExpression.expression.expression.name) || + callExpression.expression.expression.name.text !== 'each' || + !caseText.has(callExpression.expression.expression.expression.text) + ) { + return { + isEach: false + }; + } + + return { + isEach: true, + type: this.getTypeFromFunctionName(callExpression.expression.expression.expression.text) + } + } + + private isEachWithTemplate(callExpression: TS.CallExpression): EachResult { + if ( + !this.ts.isTaggedTemplateExpression(callExpression.expression) || + !this.ts.isPropertyAccessExpression(callExpression.expression.tag) || + !this.ts.isIdentifier(callExpression.expression.tag.expression) || + !this.ts.isIdentifier(callExpression.expression.tag.name) || + callExpression.expression.tag.name.text !== 'each' || + !caseText.has(callExpression.expression.tag.expression.text) + ) { + return { + isEach: false + }; + } + + return { + isEach: true, + type: this.getTypeFromFunctionName(callExpression.expression.tag.expression.text) + } + } + + private getTypeFromFunctionName(name: string): TestTreeNode['type'] { + return name === 'describe' ? 'suite' : 'test'; + } + +} + +interface TreeNode { + testNameText: string; + start: number; + end: number; + testType: TestTreeNode["type"]; +} + +function getTreeNode({testNameText, start, end, testType}: TreeNode): TestTreeNode { + const data = { + name: testNameText, + position: { + start, + end: end, + }, + type: testType, + } as TestTreeNode; + + if(data.type === 'suite') { + data.children = []; + } + + return data; +} + +type EachResult = { isEach: false } | { + isEach: true; + type: TestTreeNode['type'] +}; + diff --git a/src/test-tree/types.ts b/src/test-tree/types.ts new file mode 100644 index 0000000..36224df --- /dev/null +++ b/src/test-tree/types.ts @@ -0,0 +1,18 @@ +export interface BaseTestTreeNode { + name: string | symbol; + position: { + start: number; + end: number; + }; +} + +export interface TestNode extends BaseTestTreeNode { + type: 'test'; +} + +export interface SuiteNode extends BaseTestTreeNode { + type: 'suite'; + children: TestTreeNode[]; +} + +export type TestTreeNode = TestNode | SuiteNode diff --git a/tests/cases/test.spec.ts b/tests/cases/test.spec.ts index 06c411e..76337d5 100644 --- a/tests/cases/test.spec.ts +++ b/tests/cases/test.spec.ts @@ -1,6 +1,12 @@ import { describe, it, test, expect } from 'vitest'; describe('Test', () => { + describe('sub', () => { + it('sub test 1', () => { + expect(1 + 41).toBe(42); + }); + }); + it('Should work', () => { 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 +} From 414ec16fcc090268e2ddce6fda8a018c1214f236 Mon Sep 17 00:00:00 2001 From: Raz Luvaton <16746759+rluvaton@users.noreply.github.com> Date: Mon, 7 Aug 2023 23:04:18 +0300 Subject: [PATCH 2/7] add test tree tests --- src/test-tree/build.test.ts | 92 +++++++++++++++++++++++++++++++++++-- src/test-tree/build.ts | 1 - 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/src/test-tree/build.test.ts b/src/test-tree/build.test.ts index 31a2130..a553fe8 100644 --- a/src/test-tree/build.test.ts +++ b/src/test-tree/build.test.ts @@ -163,7 +163,7 @@ describe('something', () => { const code = ` it.each\` value - ${1} + \${1} \`('test this $value hello', ({value}) => { console.log(value); });`; @@ -175,19 +175,102 @@ it.each\` name: "test this .* hello", position: { start: 1, - end: 101 + end: 98 + }, + type: "test", + } + ]) + }); + + it('test.each with array', () => { + const code = `test.each([1])("Test %s work", () => { + expect(42).toBe(42) +});`; + + const root = TestTreeBuilder.build(ts, code, new AbortController().signal); + + expect(root).toEqual([ + { + name: "Test .* work", + position: { + start: 0, + end: 65 + }, + type: "test", + } + ]) + }); + + it('test.each with template', () => { + const code = ` +test.each\` + value + \${1} +\`('test this $value hello', ({value}) => { + console.log(value); +});`; + + const root = TestTreeBuilder.build(ts, code, new AbortController().signal); + + expect(root).toEqual([ + { + name: "test this .* hello", + position: { + start: 1, + end: 100 }, type: "test", } ]) }); - // TODO - add tests for test.each and describe each + it('describe.each with array', () => { + const code = `describe.each([1])("Test %s work", () => { + expect(42).toBe(42) +});`; + + const root = TestTreeBuilder.build(ts, code, new AbortController().signal); + + expect(root).toEqual([ + { + name: "Test .* work", + type: "suite", + position: { + start: 0, + end: 69 + }, + children: [], + } + ]) + }); + + it('describe.each with template', () => { + const code = ` +describe.each\` + value + \${1} +\`('test this $value hello', ({value}) => { + console.log(value); +});`; + + const root = TestTreeBuilder.build(ts, code, new AbortController().signal); + + expect(root).toEqual([ + { + name: "test this .* hello", + type: "suite", + position: { + start: 1, + end: 104 + }, + children: [] + } + ]) + }); }); }); it('should build complex test structure', () => { - const code = ` describe('my describe', () => { describe('sub', () => { @@ -282,6 +365,5 @@ it.each\` type: "test" } ]) - console.log(root) }); }); diff --git a/src/test-tree/build.ts b/src/test-tree/build.ts index 8e9c5d6..4cdaf7e 100644 --- a/src/test-tree/build.ts +++ b/src/test-tree/build.ts @@ -86,7 +86,6 @@ export class TestTreeBuilder { testType }); - let node: TS.Node = callExpression.parent; while (node && !this.testTreeNodes.get(node)) { node = node.parent; From 0cdfc42ba90552e30e9c38a43de82015bdcceb06 Mon Sep 17 00:00:00 2001 From: Raz Luvaton <16746759+rluvaton@users.noreply.github.com> Date: Mon, 7 Aug 2023 23:47:51 +0300 Subject: [PATCH 3/7] move to own test tree --- src/codelens.ts | 116 +++++++---------------------------------- src/test-tree/types.ts | 2 +- src/types.ts | 5 -- src/utils.ts | 7 +++ 4 files changed, 28 insertions(+), 102 deletions(-) delete mode 100644 src/types.ts diff --git a/src/codelens.ts b/src/codelens.ts index a6e04d3..7bee563 100644 --- a/src/codelens.ts +++ b/src/codelens.ts @@ -1,79 +1,10 @@ import type * as ts from 'typescript'; import * as vscode from 'vscode'; -import { TextCase } from './types'; -import { flatMap } from './utils'; -import { RunVitestCommand, DebugVitestCommand } from './vscode'; +import {convertCancellationTokenToAbortSignal} from './utils'; +import {DebugVitestCommand, RunVitestCommand} from './vscode'; +import {TestTreeBuilder} from "./test-tree/build"; +import {TestTreeNode} from './test-tree/types'; -const caseText = new Set(['it', 'describe', 'test']); - -function tryGetVitestTestCase( - typescript: typeof ts, - callExpression: ts.CallExpression, - file: ts.SourceFile -): TextCase | undefined { - const each = isEach(typescript, callExpression); - if (!each && !(typescript.isIdentifier(callExpression.expression) && caseText.has((callExpression.expression as ts.Identifier).text))) { - return undefined; - } - - const args = callExpression.arguments; - if (args.length < 2) { - return undefined; - } - - const [testName, body] = args; - if ( - !typescript.isStringLiteralLike(testName) || - !typescript.isFunctionLike(body) - ) { - return undefined; - } - - let testNameText = testName.text; - - const start = callExpression.getStart(file); - if (each) { - // - testNameText = testNameText - // From https://github.com/jestjs/jest/blob/0fd5b1c37555f485c56a6ad2d6b010a72204f9f6/packages/jest-each/src/table/array.ts#L15C32-L15C47 - // (Did not find inside vitest source code) - .replace(/%[sdifjoOp#]/g, '.*') - // When using template string - .replace(/\$[a-zA-Z_0-9]+/g, '.*'); - } - - return { - start, - end: callExpression.getEnd(), - text: testNameText - }; -} - -function isEach(typescript: typeof ts, callExpression: ts.CallExpression) { - return isEachWithArray(typescript, callExpression) || isEachWithTemplate(typescript, callExpression); -} - -function isEachWithArray(typescript: typeof ts, callExpression: ts.CallExpression) { - return ( - typescript.isCallExpression(callExpression.expression) && - typescript.isPropertyAccessExpression(callExpression.expression.expression) && - typescript.isIdentifier(callExpression.expression.expression.expression) && - typescript.isIdentifier(callExpression.expression.expression.name) && - callExpression.expression.expression.name.text === 'each' && - caseText.has(callExpression.expression.expression.expression.text) - ); -} - -function isEachWithTemplate(typescript: typeof ts, callExpression: ts.CallExpression) { - return ( - typescript.isTaggedTemplateExpression(callExpression.expression) && - typescript.isPropertyAccessExpression(callExpression.expression.tag) && - typescript.isIdentifier(callExpression.expression.tag.expression) && - typescript.isIdentifier(callExpression.expression.tag.name) && - callExpression.expression.tag.name.text === 'each' && - caseText.has(callExpression.expression.tag.expression.text) - ); -} export class CodeLensProvider implements vscode.CodeLensProvider { constructor(private typescript: typeof ts) { @@ -86,43 +17,36 @@ export class CodeLensProvider implements vscode.CodeLensProvider { const ts = this.typescript; const text = document.getText(); - const sourceFile = ts.createSourceFile( - 'dummy', - text, - ts.ScriptTarget.Latest - ); - const testCases: TextCase[] = []; - visitor(sourceFile); + const nodes = TestTreeBuilder.build(ts, text, convertCancellationTokenToAbortSignal(token)); + const allNodes = this.flatNodes(nodes); - return flatMap(testCases, x => { - const start = document.positionAt(x.start); - const end = document.positionAt(x.end); + 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(x.text, document.fileName) + new RunVitestCommand(testNode.name, document.fileName) ), new vscode.CodeLens( new vscode.Range(start, end), - new DebugVitestCommand(x.text, document.fileName) + new DebugVitestCommand(testNode.name, document.fileName) ) ]; }); + } - function visitor(node: ts.Node) { - if (token.isCancellationRequested) { - return; - } - - if (ts.isCallExpression(node)) { - const testCase = tryGetVitestTestCase(ts, node, sourceFile); - if (testCase) { - testCases.push(testCase); - } + 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); } - ts.forEachChild(node, visitor); } + + return nodes; } } diff --git a/src/test-tree/types.ts b/src/test-tree/types.ts index 36224df..92b38e2 100644 --- a/src/test-tree/types.ts +++ b/src/test-tree/types.ts @@ -1,5 +1,5 @@ export interface BaseTestTreeNode { - name: string | symbol; + name: string; position: { start: number; end: number; diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index b74c136..0000000 --- a/src/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface TextCase { - start: number; - end: number; - text: string; -} diff --git a/src/utils.ts b/src/utils.ts index df420f1..5fb27c9 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,5 @@ import * as path from 'path'; +import {CancellationToken} from "vscode"; export function flatMap( items: readonly T[], @@ -22,3 +23,9 @@ export function getVscodeTypescriptPath(appRoot: string) { 'typescript.js' ); } + +export function convertCancellationTokenToAbortSignal(token: CancellationToken): AbortSignal { + const controller = new AbortController(); + token.onCancellationRequested(() => controller.abort()); + return controller.signal; +} From 1145bc4bf551292440c81c8bf15a69b412e153f4 Mon Sep 17 00:00:00 2001 From: Raz Luvaton <16746759+rluvaton@users.noreply.github.com> Date: Tue, 8 Aug 2023 21:52:26 +0300 Subject: [PATCH 4/7] start using the new test explorer API --- package.json | 9 +- src/index.ts | 191 +++++++++++++++++++++++++++++++++++++--- src/run.ts | 98 ++++++++++++++++++++- src/test-tree/build.ts | 36 ++++++-- src/test-tree/sample.ts | 127 ++++++++++++++++++++++++++ src/vscode.ts | 60 ++++++------- 6 files changed, 468 insertions(+), 53 deletions(-) create mode 100644 src/test-tree/sample.ts 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/index.ts b/src/index.ts index 2f4ddbf..65315f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,16 +1,187 @@ -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('mathTestController', 'Markdown Math'); + 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, + 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/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/build.ts b/src/test-tree/build.ts index 4cdaf7e..4a8e931 100644 --- a/src/test-tree/build.ts +++ b/src/test-tree/build.ts @@ -1,20 +1,31 @@ import type * as TS from "typescript"; -import type {TestTreeNode} from "./types"; +import type { SuiteNode, TestNode, TestTreeNode } from "./types"; const caseText = new Set(['it', 'describe', 'test']); +interface listeners { + onTest: (test: TestNode) => any; + onSuite: (suite: SuiteNode) => any; +} + export class TestTreeBuilder { private sourceFile: TS.SourceFile; private ts: typeof TS; private abortSignal: AbortSignal; + private onTest: listeners['onTest']; + private onSuite: listeners['onSuite']; + private rootTestTreeNodes: TestTreeNode[] = []; private testTreeNodes = new WeakMap(); - private constructor(ts: typeof TS, codeContent: string, abortSignal: AbortSignal) { + private constructor(ts: typeof TS, codeContent: string, abortSignal: AbortSignal, listeners: Partial) { this.ts = ts; this.abortSignal = abortSignal; + this.onSuite = listeners.onSuite || (() => { }); + this.onTest = listeners.onTest || (() => { }); + this.sourceFile = ts.createSourceFile( 'dummy', codeContent, @@ -23,8 +34,8 @@ export class TestTreeBuilder { ); } - static build(ts: typeof TS, codeContent: string, abortSignal: AbortSignal): TestTreeNode[] { - const builder = new TestTreeBuilder(ts, codeContent, abortSignal); + static build(ts: typeof TS, codeContent: string, abortSignal: AbortSignal, listeners: Partial = {}): TestTreeNode[] { + const builder = new TestTreeBuilder(ts, codeContent, abortSignal, listeners); return builder.build(); } @@ -86,6 +97,19 @@ export class TestTreeBuilder { testType }); + switch (newTestNode.type) { + case 'suite': + this.onSuite(newTestNode); + break; + case 'test': + this.onTest(newTestNode); + break; + } + + if(this.abortSignal.aborted) { + return; + } + let node: TS.Node = callExpression.parent; while (node && !this.testTreeNodes.get(node)) { node = node.parent; @@ -165,7 +189,7 @@ interface TreeNode { testType: TestTreeNode["type"]; } -function getTreeNode({testNameText, start, end, testType}: TreeNode): TestTreeNode { +function getTreeNode({ testNameText, start, end, testType }: TreeNode): TestTreeNode { const data = { name: testNameText, position: { @@ -175,7 +199,7 @@ function getTreeNode({testNameText, start, end, testType}: TreeNode): TestTreeNo type: testType, } as TestTreeNode; - if(data.type === 'suite') { + if (data.type === 'suite') { data.children = []; } 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( +// RunVitestCommand.ID, +// (text: string, filename: string) => { +// runInTerminal(text, filename) +// } +// ); -vscode.commands.registerCommand( - DebugVitestCommand.ID, - (text: string, filename: string) => { - debugInTerminal(text, filename); - } -); +// vscode.commands.registerCommand( +// DebugVitestCommand.ID, +// (text: string, filename: string) => { +// debugInTerminal(text, filename); +// } +// ); From ab9268969d8d826dc1a34d8beccbad29ddee1c1a Mon Sep 17 00:00:00 2001 From: Raz Luvaton <16746759+rluvaton@users.noreply.github.com> Date: Tue, 8 Aug 2023 23:28:11 +0300 Subject: [PATCH 5/7] remove codelens --- src/codelens.ts | 52 ------------------------------------------------- 1 file changed, 52 deletions(-) delete mode 100644 src/codelens.ts diff --git a/src/codelens.ts b/src/codelens.ts deleted file mode 100644 index 7bee563..0000000 --- a/src/codelens.ts +++ /dev/null @@ -1,52 +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; - } -} From edc0aae32a4c7d01b43a5a4a8799637deb6ddfe3 Mon Sep 17 00:00:00 2001 From: Raz Luvaton <16746759+rluvaton@users.noreply.github.com> Date: Tue, 8 Aug 2023 23:42:18 +0300 Subject: [PATCH 6/7] remove --- src/vscode.ts | 38 -------------------------------------- 1 file changed, 38 deletions(-) delete mode 100644 src/vscode.ts diff --git a/src/vscode.ts b/src/vscode.ts deleted file mode 100644 index 517ce31..0000000 --- a/src/vscode.ts +++ /dev/null @@ -1,38 +0,0 @@ -import * as vscode from 'vscode'; -import { debugInTerminal, runInTerminal } from './run'; - -// export class RunVitestCommand implements vscode.Command { -// static ID = 'vitest.runTest'; -// title = 'Run(Vitest)'; -// command = RunVitestCommand.ID; -// arguments?: [string, string]; - -// constructor(text: string, filename: string) { -// this.arguments = [text, filename]; -// } -// } - -// export class DebugVitestCommand implements vscode.Command { -// static ID = 'vitest.debugTest'; -// title = 'Debug(Vitest)'; -// command = DebugVitestCommand.ID; -// arguments?: [string, string]; - -// constructor(text: string, filename: string) { -// this.arguments = [text, filename]; -// } -// } - -// vscode.commands.registerCommand( -// RunVitestCommand.ID, -// (text: string, filename: string) => { -// runInTerminal(text, filename) -// } -// ); - -// vscode.commands.registerCommand( -// DebugVitestCommand.ID, -// (text: string, filename: string) => { -// debugInTerminal(text, filename); -// } -// ); From 46a326f6ebe095a7ff050f0d5171fb4cf9f57dd2 Mon Sep 17 00:00:00 2001 From: Raz Luvaton <16746759+rluvaton@users.noreply.github.com> Date: Mon, 4 Mar 2024 10:12:16 +0200 Subject: [PATCH 7/7] wip --- src/index.ts | 5 +++-- src/reporter.ts | 48 ++++++++++++++++++++++++++++++++++++++++ tests/cases/test.spec.ts | 1 + 3 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 src/reporter.ts diff --git a/src/index.ts b/src/index.ts index 65315f5..97f1ec7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ import { TestCase, testData, TestFile } from './test-tree/sample'; import { TestTreeBuilder } from './test-tree/build'; export async function activate(context: vscode.ExtensionContext) { - const ctrl = vscode.tests.createTestController('mathTestController', 'Markdown Math'); + const ctrl = vscode.tests.createTestController('vitestTestController', 'Vitest'); context.subscriptions.push(ctrl); const fileChangedEmitter = new vscode.EventEmitter(); @@ -92,7 +92,7 @@ export async function activate(context: vscode.ExtensionContext) { 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 @@ -153,6 +153,7 @@ function getWorkspaceTestPatterns() { return vscode.workspace.workspaceFolders.map(workspaceFolder => ({ workspaceFolder, + // TODO - ADD SUPPORT FOR js AND spec AND tsx pattern: new vscode.RelativePattern(workspaceFolder, '**/*.test.ts'), })); } 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/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); }); });