diff --git a/packages/vscode/README.md b/packages/vscode/README.md index dc49bffd1..a0274c8ce 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -15,10 +15,11 @@ The extension activates automatically when your workspace contains Rstest config ## Configuration -| Setting | Type | Default | Description | -| ------------------------------ | -------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `rstest.rstestPackagePath` | string | `undefined` | The path to a `package.json` file of a Rstest executable (it's usually inside `node_modules`) in case the extension cannot find it. It will be used to resolve Rstest API paths. This should be used as a last resort fix. Supports `${workspaceFolder}` placeholder. | -| `rstest.configFileGlobPattern` | string[] | `["**/rstest.config.{mjs,ts,js,cjs,mts,cts}"]` | Glob patterns used to discover config files. | +| Setting | Type | Default | Description | +| ------------------------------ | -------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `rstest.rstestPackagePath` | `string` | `undefined` | The path to a `package.json` file of a Rstest executable (it's usually inside `node_modules`) in case the extension cannot find it. It will be used to resolve Rstest API paths. This should be used as a last resort fix. Supports `${workspaceFolder}` placeholder. | +| `rstest.configFileGlobPattern` | `string[]` | `["**/rstest.config.{mjs,ts,js,cjs,mts,cts}"]` | Glob patterns used to discover config files. | +| `rstest.testCaseCollectMethod` | `"ast" \| "runtime"` | `"ast"` | `"ast"`: Fast, only supports basic test cases.
`"runtime"`: Slow, supports all test cases, including dynamic test generation methods (each/for/extend). | ## How it works diff --git a/packages/vscode/package.json b/packages/vscode/package.json index b44f45c86..ed093aa89 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -41,6 +41,22 @@ "default": [ "**/rstest.config.{mjs,ts,js,cjs,mts,cts}" ] + }, + "rstest.testCaseCollectMethod": { + "type": "string", + "default": "ast", + "enum": [ + "ast", + "runtime" + ], + "enumItemLabels": [ + "Static AST Analyze", + "Run Test File" + ], + "enumDescriptions": [ + "Fast, only supports basic test cases.", + "Slow, supports all test cases, including dynamic test generation methods (each/for/extend)." + ] } } }, diff --git a/packages/vscode/src/config.ts b/packages/vscode/src/config.ts index bace8e7da..5c01efa7c 100644 --- a/packages/vscode/src/config.ts +++ b/packages/vscode/src/config.ts @@ -10,6 +10,10 @@ const configSchema = v.object({ configFileGlobPattern: v.fallback(v.array(v.string()), [ '**/rstest.config.{mjs,ts,js,cjs,mts,cts}', ]), + testCaseCollectMethod: v.fallback( + v.union([v.literal('ast'), v.literal('runtime')]), + 'ast', + ), }); export type ExtensionConfig = v.InferOutput; diff --git a/packages/vscode/src/master.ts b/packages/vscode/src/master.ts index ec664fbbd..653e7d5b9 100644 --- a/packages/vscode/src/master.ts +++ b/packages/vscode/src/master.ts @@ -115,6 +115,18 @@ export class RstestApi { return config; } + public async listTests(include?: string[]) { + const worker = await this.createChildProcess(); + const tests = await worker.listTests({ + rstestPath: this.resolveRstestPath(), + configFilePath: this.configFilePath, + include, + includeTaskLocation: true, + }); + worker.$close(); + return tests; + } + public async runTest({ run, token, @@ -160,6 +172,7 @@ export class RstestApi { kind === vscode.TestRunProfileKind.Coverage ? { enabled: true } : undefined, + includeTaskLocation: true, }) .finally(() => { worker.$close(); diff --git a/packages/vscode/src/project.ts b/packages/vscode/src/project.ts index 84cad3d8d..4891bf6a3 100644 --- a/packages/vscode/src/project.ts +++ b/packages/vscode/src/project.ts @@ -1,4 +1,5 @@ import path from 'node:path'; +import type { TestInfo } from '@rstest/core'; import picomatch from 'picomatch'; import { glob } from 'tinyglobby'; import * as vscode from 'vscode'; @@ -212,68 +213,116 @@ export class Project implements vscode.Disposable { return matchInclude(relativePath) && !matchExclude(relativePath); }; - const files = await glob(this.include, { - cwd: root.fsPath, - ignore: this.exclude, - absolute: true, - dot: true, - expandDirectories: false, - }).then((files) => files.map((file) => vscode.Uri.file(file))); + const watcher = watchConfigValue( + 'testCaseCollectMethod', + this.workspaceFolder, + async (method, token) => { + if (this.testItem) { + this.testItem.busy = true; + } + const files: { uri: vscode.Uri; tests?: TestInfo[] }[] = + method === 'ast' + ? // ast + await glob(this.include, { + cwd: root.fsPath, + ignore: this.exclude, + absolute: true, + dot: true, + expandDirectories: false, + }).then((files) => + files.map((file) => ({ uri: vscode.Uri.file(file) })), + ) + : // runtime + await this.api.listTests().then((files) => + files.map((file) => ({ + uri: vscode.Uri.file(file.testPath), + tests: file.tests, + })), + ); - if (this.cancellationSource.token.isCancellationRequested) return; + if (token.isCancellationRequested) return; - const visited = new Set(); - for (const uri of files) { - if (matchExclude(uri.fsPath)) continue; - this.updateOrCreateFile(uri); - visited.add(uri.toString()); - } + if (this.testItem) { + this.testItem.busy = false; + } - // remove outdated items after glob configuration changed - for (const file of this.testFiles.keys()) { - if (!visited.has(file)) { - this.testFiles.delete(file); - } - } - this.buildTree(); + const visited = new Set(); + for (const { uri, tests } of files) { + this.updateOrCreateFile(uri, tests); + visited.add(uri.toString()); + } + + // remove outdated items after glob configuration changed + for (const file of this.testFiles.keys()) { + if (!visited.has(file)) { + this.testFiles.delete(file); + } + } + this.buildTree(); + + // start watching test file change + // while createFileSystemWatcher don't support same glob syntax with tinyglobby + // we can watch all files and filter with picomatch later + const watcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(root, '**'), + ); + token.onCancellationRequested(() => watcher.dispose()); + + // TODO delay and batch run multiple files + const updateOrCreateByRuntime = (uri: vscode.Uri) => { + this.api.listTests([uri.fsPath]).then((files) => { + if (token.isCancellationRequested) return; + for (const { testPath, tests } of files) { + const uri = vscode.Uri.file(testPath); + this.updateOrCreateFile(uri, tests); + } + this.buildTree(); + }); + }; - // start watching test file change - // while createFileSystemWatcher don't support same glob syntax with tinyglobby - // we can watch all files and filter with picomatch later - const watcher = vscode.workspace.createFileSystemWatcher( - new vscode.RelativePattern(root, '**'), + watcher.onDidCreate((uri) => { + if (isInclude(uri)) { + if (method === 'ast') { + this.updateOrCreateFile(uri); + this.buildTree(); + } else { + updateOrCreateByRuntime(uri); + } + } + }); + watcher.onDidChange((uri) => { + if (isInclude(uri)) { + if (method === 'ast') { + this.updateOrCreateFile(uri); + this.buildTree(); + } else { + updateOrCreateByRuntime(uri); + } + } + }); + watcher.onDidDelete((uri) => { + if (isInclude(uri)) { + this.testFiles.delete(uri.toString()); + this.buildTree(); + } + }); + }, ); this.cancellationSource.token.onCancellationRequested(() => watcher.dispose(), ); - watcher.onDidCreate((uri) => { - if (isInclude(uri)) { - this.updateOrCreateFile(uri); - this.buildTree(); - } - }); - watcher.onDidChange((uri) => { - if (isInclude(uri)) { - this.updateOrCreateFile(uri); - this.buildTree(); - } - }); - watcher.onDidDelete((uri) => { - if (isInclude(uri)) { - this.testFiles.delete(uri.toString()); - this.buildTree(); - } - }); } // TODO pass cancellation token to updateFromDisk - private updateOrCreateFile(uri: vscode.Uri) { - const existing = this.testFiles.get(uri.toString()); - if (existing) { - existing.updateFromDisk(this.testController); - } else { - const data = new TestFile(this.api, uri); + private updateOrCreateFile(uri: vscode.Uri, tests?: TestInfo[]) { + let data = this.testFiles.get(uri.toString()); + if (!data) { + data = new TestFile(this.api, uri, this.testController); this.testFiles.set(uri.toString(), data); - data.updateFromDisk(this.testController); + } + if (tests) { + data.updateFromList(tests); + } else { + data.updateFromDisk(); } } diff --git a/packages/vscode/src/testRunReporter.ts b/packages/vscode/src/testRunReporter.ts index b803dfc4f..d0ddd0610 100644 --- a/packages/vscode/src/testRunReporter.ts +++ b/packages/vscode/src/testRunReporter.ts @@ -12,6 +12,7 @@ import { parseErrorStacktrace } from '../../core/src/utils/error'; import { logger } from './logger'; import type { Project } from './project'; import type { LogLevel } from './shared/logger'; +import { TestFile, testData } from './testTree'; export class TestRunReporter implements Reporter { constructor( @@ -61,6 +62,17 @@ export class TestRunReporter implements Reporter { this.run?.started(fileItem); } + onTestFileReady(test: TestFileInfo) { + const fileTestItem = this.project?.testFiles.get( + vscode.Uri.file(test.testPath).toString(), + )?.testItem; + if (fileTestItem) { + const data = testData.get(fileTestItem); + if (data instanceof TestFile) { + data.updateFromList(test.tests); + } + } + } onTestFileResult(test: TestFileResult) { // only update test file result when explicit run itself or parent if (this.path.length) return; diff --git a/packages/vscode/src/testTree.ts b/packages/vscode/src/testTree.ts index 2c9fa63ec..80f38d2ee 100644 --- a/packages/vscode/src/testTree.ts +++ b/packages/vscode/src/testTree.ts @@ -1,8 +1,9 @@ import { TextDecoder } from 'node:util'; +import type { TestInfo } from '@rstest/core'; import vscode from 'vscode'; +import { ROOT_SUITE_NAME } from '../../core/src/utils/constants'; import { logger } from './logger'; import type { RstestApi } from './master'; -import { parseTestFile } from './parserTest'; import type { Project, WorkspaceManager } from './project'; const textDecoder = new TextDecoder('utf-8'); @@ -53,6 +54,7 @@ export class TestFile { constructor( public api: RstestApi, public uri: vscode.Uri, + private controller: vscode.TestController, ) {} public setTestItem(item: vscode.TestItem) { @@ -60,25 +62,23 @@ export class TestFile { item.children.replace(this.children); } - public async updateFromDisk(controller: vscode.TestController) { + public async updateFromDisk() { const content = await getContentFromFilesystem(this.uri); - this.updateFromContents(controller, content); + this.updateFromContents(content); } /** * 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, - ) { + private async updateFromContents(content: string) { // Maintain a stack of ancestors to build a hierarchical tree const ancestors: { name: string; children: vscode.TestItem[] }[] = [ { name: 'ROOT', children: [] }, ]; this.didResolve = true; + const { parseTestFile } = await import('./parserTest'); parseTestFile(content, { onTest: (range, name, testType) => { const vscodeRange = new vscode.Range( @@ -88,35 +88,28 @@ export class TestFile { const parent = ancestors[ancestors.length - 1]; - const siblingsCount = parent.children.filter( - (child) => child.label === name, - ).length; + const parentNames = ancestors.slice(1).map((item) => item.name); - // generate unique id to duplicated item - let id = name; - if (siblingsCount) id = [name, siblingsCount].join('@@@@@@'); + const testItem = this.onTest( + vscodeRange, + name, + testType, + parent.children, + parentNames, + ); const isSuite = testType === 'describe' || testType === 'suite'; - const testItem = controller.createTestItem(id, name, this.uri); testData.set( testItem, new TestCase( this.api, this.uri, - ancestors.slice(1).map((item) => item.name), + parentNames, isSuite ? 'suite' : 'case', ), ); - testItem.range = vscodeRange; - - // warn about duplicated name - if (siblingsCount) testItem.error = `Duplicated ${testType} name`; - - // Set TestCase data for both describe blocks and leaf tests - parent.children.push(testItem); - if (isSuite) { const children: vscode.TestItem[] = []; // This becomes the new parent for subsequently discovered children @@ -132,6 +125,75 @@ export class TestFile { this.children = ancestors[0].children; this.testItem?.children.replace(this.children); } + + public updateFromList(tests: TestInfo[]) { + const handleChild = ( + test: TestInfo, + parent: vscode.TestItem[], + parentNames: string[], + ) => { + // vscode location is zero based + const line = (test.location?.line ?? 1) - 1; + const column = (test.location?.column ?? 1) - 1; + const range = new vscode.Range(line, column, line, column); + const testItem = this.onTest( + range, + test.name, + test.type === 'suite' ? 'suite' : 'test', + parent, + parentNames, + ); + if (test.type === 'suite') { + const children: vscode.TestItem[] = []; + test.tests.forEach((child) => { + handleChild(child, children, [...parentNames, test.name]); + }); + testItem.children.replace(children); + } + }; + const children: vscode.TestItem[] = []; + const realTests = + tests[0]?.type === 'suite' && tests[0].name === ROOT_SUITE_NAME + ? tests[0].tests + : tests; + realTests.forEach((test) => { + handleChild(test, children, []); + }); + this.children = children; + this.testItem?.children.replace(this.children); + } + + private onTest( + range: vscode.Range, + name: string, + testType: 'test' | 'it' | 'suite' | 'describe', + parent: vscode.TestItem[], + parentNames: string[], + ) { + const siblingsCount = parent.filter((child) => child.label === name).length; + + // generate unique id to duplicated item + let id = name; + if (siblingsCount) id = [name, siblingsCount].join('@@@@@@'); + + const isSuite = testType === 'describe' || testType === 'suite'; + + const testItem = this.controller.createTestItem(id, name, this.uri); + testData.set( + testItem, + new TestCase(this.api, this.uri, parentNames, isSuite ? 'suite' : 'case'), + ); + + testItem.range = range; + + // warn about duplicated name + if (siblingsCount) testItem.error = `Duplicated ${testType} name`; + + // Set TestCase data for both describe blocks and leaf tests + parent.push(testItem); + + return testItem; + } } export class TestCase { diff --git a/packages/vscode/src/worker/index.ts b/packages/vscode/src/worker/index.ts index 82254371f..b343c1b88 100644 --- a/packages/vscode/src/worker/index.ts +++ b/packages/vscode/src/worker/index.ts @@ -80,6 +80,12 @@ export class Worker { throw error; } } + + public async listTests(data: WorkerInitOptions) { + const rstest = await this.init({ ...data, command: 'list' }); + const res = await rstest.listTests({}); + return res; + } } export const masterApi = createBirpc(new Worker(), { diff --git a/packages/vscode/src/worker/reporter.ts b/packages/vscode/src/worker/reporter.ts index 088e11e91..fbe0f7ac6 100644 --- a/packages/vscode/src/worker/reporter.ts +++ b/packages/vscode/src/worker/reporter.ts @@ -11,6 +11,7 @@ import { masterApi } from '.'; export class ProgressReporter implements Reporter { onTestFileStart = masterApi.onTestFileStart.asEvent; + onTestFileReady = masterApi.onTestFileReady.asEvent; onTestFileResult = masterApi.onTestFileResult.asEvent; onTestSuiteStart = masterApi.onTestSuiteStart.asEvent; onTestSuiteResult = masterApi.onTestSuiteResult.asEvent; diff --git a/packages/vscode/tests/fixtures/workspace-1/test/each.test.ts b/packages/vscode/tests/fixtures/workspace-1/test/each.test.ts new file mode 100644 index 000000000..8a3db22b3 --- /dev/null +++ b/packages/vscode/tests/fixtures/workspace-1/test/each.test.ts @@ -0,0 +1,9 @@ +import { describe, it } from '@rstest/core'; + +describe('suite', () => { + it('case', () => {}); +}); + +describe.each([1, 2])('suite %i', (index) => { + it.each([1, 2])(`suite ${index} case %i`, () => {}); +}); diff --git a/packages/vscode/tests/suite/helpers.ts b/packages/vscode/tests/suite/helpers.ts index c3b0a58c4..f46c26ac9 100644 --- a/packages/vscode/tests/suite/helpers.ts +++ b/packages/vscode/tests/suite/helpers.ts @@ -70,6 +70,22 @@ export function getProjectItems(testController: vscode.TestController) { return getTestItems(folders[0].children); } +export function getTestItemByLabels( + collection: vscode.TestItemCollection, + labels: string[], +) { + const item = labels.reduce( + (item, label) => + item && + getTestItems(item.children).find((child) => child.label === label), + { + children: collection, + } as vscode.TestItem | undefined, + ); + assert.ok(item); + return item; +} + // Helper: recursively transform a TestItem into a label-only tree. // Children are sorted by label for stable comparisons. export function toLabelTree( diff --git a/packages/vscode/tests/suite/progress.test.ts b/packages/vscode/tests/suite/progress.test.ts index aa869483d..2fd81bb07 100644 --- a/packages/vscode/tests/suite/progress.test.ts +++ b/packages/vscode/tests/suite/progress.test.ts @@ -1,6 +1,6 @@ import * as assert from 'node:assert'; import * as vscode from 'vscode'; -import { getTestItems, waitFor } from './helpers'; +import { getTestItemByLabels, waitFor } from './helpers'; suite('Test Progress Reporting', () => { test('reports test progress with error details and snapshots', async () => { @@ -15,18 +15,9 @@ suite('Test Progress Reporting', () => { rstestInstance?.testController; assert.ok(testController, 'Test controller should be exported'); - const item = await waitFor(() => { - const item = ['test', 'progress.test.ts'].reduce( - (item, label) => - item && - getTestItems(item.children).find((child) => child.label === label), - { - children: testController.items, - } as vscode.TestItem | undefined, - ); - assert.ok(item); - return item; - }); + const item = await waitFor(() => + getTestItemByLabels(testController.items, ['test', 'progress.test.ts']), + ); const { promise, resolve } = Promise.withResolvers(); diff --git a/packages/vscode/tests/suite/runtimeList.test.ts b/packages/vscode/tests/suite/runtimeList.test.ts new file mode 100644 index 000000000..fe0c609ac --- /dev/null +++ b/packages/vscode/tests/suite/runtimeList.test.ts @@ -0,0 +1,161 @@ +import assert from 'node:assert'; +import * as vscode from 'vscode'; +import { getTestItemByLabels, toLabelTree, waitFor } from './helpers'; + +suite('Runtime list suite', () => { + test('Extension should discover test cases from runtime', async () => { + // Check if the extension is activated + const extension = vscode.extensions.getExtension('rstack.rstest'); + if (extension && !extension.isActive) { + await extension.activate(); + } + + // Get the rstest test controller that the extension should have created + const rstestInstance = extension?.exports; + const testController: vscode.TestController = + rstestInstance?.testController; + + const config = vscode.workspace.getConfiguration('rstest'); + + await waitFor(() => { + const item = getTestItemByLabels(testController.items, [ + 'test', + 'each.test.ts', + ]); + assert.deepStrictEqual(toLabelTree(item.children), [ + { + children: [ + { + label: 'case', + }, + ], + label: 'suite', + }, + { + label: 'unnamed test', + }, + { + label: 'unnamed test', + }, + ]); + }); + + // change config to runtime + await config.update('testCaseCollectMethod', 'runtime'); + await waitFor(() => { + const item = getTestItemByLabels(testController.items, [ + 'test', + 'each.test.ts', + ]); + assert.deepStrictEqual(toLabelTree(item.children), [ + { + children: [ + { + label: 'case', + }, + ], + label: 'suite', + }, + { + children: [ + { + label: 'suite 1 case 1', + }, + { + label: 'suite 1 case 2', + }, + ], + label: 'suite 1', + }, + { + children: [ + { + label: 'suite 2 case 1', + }, + { + label: 'suite 2 case 2', + }, + ], + label: 'suite 2', + }, + ]); + }); + + // restore config + await config.update('testCaseCollectMethod', undefined); + await waitFor(() => { + const item = getTestItemByLabels(testController.items, [ + 'test', + 'each.test.ts', + ]); + assert.deepStrictEqual(toLabelTree(item.children), [ + { + children: [ + { + label: 'case', + }, + ], + label: 'suite', + }, + { + label: 'unnamed test', + }, + { + label: 'unnamed test', + }, + ]); + }); + + // test list should be updated after test run + rstestInstance.startTestRun( + new vscode.TestRunRequest( + undefined, + undefined, + rstestInstance.runProfile, + ), + new vscode.CancellationTokenSource().token, + false, + ); + await waitFor( + () => { + const item = getTestItemByLabels(testController.items, [ + 'test', + 'each.test.ts', + ]); + assert.deepStrictEqual(toLabelTree(item.children), [ + { + children: [ + { + label: 'case', + }, + ], + label: 'suite', + }, + { + children: [ + { + label: 'suite 1 case 1', + }, + { + label: 'suite 1 case 2', + }, + ], + label: 'suite 1', + }, + { + children: [ + { + label: 'suite 2 case 1', + }, + { + label: 'suite 2 case 2', + }, + ], + label: 'suite 2', + }, + ]); + }, + { timeoutMs: 5000 }, + ); + }); +}); diff --git a/packages/vscode/tests/suite/workspace.test.ts b/packages/vscode/tests/suite/workspace.test.ts index f1751d87f..c97fd7548 100644 --- a/packages/vscode/tests/suite/workspace.test.ts +++ b/packages/vscode/tests/suite/workspace.test.ts @@ -26,6 +26,7 @@ suite('Workspace discover suite', () => { { label: 'test', children: [ + { label: 'each.test.ts' }, { label: 'foo.test.ts' }, { label: 'index.test.ts' }, { label: 'jsFile.spec.js' }, @@ -53,6 +54,7 @@ suite('Workspace discover suite', () => { { label: 'test', children: [ + { label: 'each.test.ts' }, { label: 'foo.test.ts' }, { label: 'index.test.ts' }, { label: 'jsFile.spec.js' }, @@ -109,6 +111,7 @@ suite('Workspace discover suite', () => { { label: 'test', children: [ + { label: 'each.test.ts' }, { label: 'foo.test.ts' }, { label: 'index.test.ts' }, { label: 'jsFile.spec.js' }, @@ -208,6 +211,7 @@ suite('Workspace discover suite', () => { { label: 'test', children: [ + { label: 'each.test.ts' }, { label: 'foo.test.ts' }, { label: 'index.test.ts' }, { label: 'jsFile.spec.js' }, @@ -252,6 +256,7 @@ suite('Workspace discover suite', () => { { label: 'test', children: [ + { label: 'each.test.ts' }, { label: 'foo.test.ts' }, { label: 'index.test.ts' }, { label: 'jsFile.spec.js' },