diff --git a/.gitignore b/.gitignore index 00d88ab5c..f0fdddf58 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,8 @@ test-temp-* !.vscode/extensions.json !.vscode/launch.json !.vscode/tasks.json +!.vscode/extensions/ +!.vscode/extensions/** .idea/ .nx/ .history/ diff --git a/.vscode/extensions/rslib-problem-matcher/package.json b/.vscode/extensions/rslib-problem-matcher/package.json new file mode 100644 index 000000000..7efd12741 --- /dev/null +++ b/.vscode/extensions/rslib-problem-matcher/package.json @@ -0,0 +1,22 @@ +{ + "name": "rslib-problem-matcher", + "version": "0.0.1", + "engines": { + "vscode": "^1.97.0" + }, + "contributes": { + "problemMatchers": [ + { + "name": "rslib-watch", + "background": { + "activeOnStart": true, + "beginsPattern": "build started...", + "endsPattern": "build complete, watching for changes..." + }, + "pattern": { + "regexp": "build failed in" + } + } + ] + } +} diff --git a/.vscode/launch.json b/.vscode/launch.json index 7c2d9bcb5..965f61c00 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -15,8 +15,9 @@ "${workspaceFolder}/packages/vscode/sample" ], "outFiles": ["${workspaceFolder}/packages/vscode/dist/**/*.js"], - "preLaunchTask": "npm: watch:local", - "autoAttachChildProcesses": true + "preLaunchTask": "vscode dev", + // only enable this on demand, this causes test run slowly, and child process sourcemap not working + "autoAttachChildProcesses": false } ] } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index c38a6bbe7..0f0572dbd 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -4,27 +4,42 @@ "version": "2.0.0", "tasks": [ { + "label": "core dev", + "type": "npm", + "script": "dev", + "path": "packages/core", + "problemMatcher": "$rslib-watch", + "isBackground": true, + "group": { + "kind": "build" + } + }, + { + "label": "coverage-istanbul dev", + "type": "npm", + "script": "dev", + "path": "packages/coverage-istanbul", + "problemMatcher": "$rslib-watch", + "isBackground": true, + "dependsOn": ["core dev"], + "dependsOrder": "parallel", + "group": { + "kind": "build" + } + }, + { + "label": "vscode dev", "type": "npm", "script": "watch:local", "path": "packages/vscode", - "problemMatcher": { - // tells vscode when the build task is running - "background": { - "activeOnStart": true, - "beginsPattern": "build started|building", - "endsPattern": "built in|build complete" - }, - // although we don't care about it, pattern is required to implement problemMatcher - "pattern": { "regexp": "build failed in" } - }, + "problemMatcher": "$rslib-watch", "isBackground": true, - "presentation": { - "reveal": "never" - }, "group": { "kind": "build", "isDefault": true - } + }, + "dependsOn": ["core dev", "coverage-istanbul dev"], + "dependsOrder": "parallel" } ] } diff --git a/e2e/bail/index.test.ts b/e2e/bail/index.test.ts index b74187a16..e7a4f76ab 100644 --- a/e2e/bail/index.test.ts +++ b/e2e/bail/index.test.ts @@ -22,7 +22,9 @@ describe('test bail option', () => { expectLog(/Test run aborted/); - const logs = cli.stdout.split('\n').filter((log) => log.includes('Tests')); + const logs = cli.stdout + .split('\n') + .filter((log) => log.startsWith(' Tests')); // `Tests 1 failed | 1 passed (2)` => 2 const totalCount = Number(logs[0]!.match(/\((\d+)\)/)?.[1]); expect(totalCount).toBe(2); @@ -41,7 +43,9 @@ describe('test bail option', () => { await expectExecFailed(); - const logs = cli.stdout.split('\n').filter((log) => log.includes('Tests')); + const logs = cli.stdout + .split('\n') + .filter((log) => log.startsWith(' Tests')); // `Tests 1 failed | 1 passed (2)` => 2 const totalCount = Number(logs[0]!.match(/\((\d+)\)/)?.[1]); expect(totalCount).toBe(2); @@ -60,7 +64,9 @@ describe('test bail option', () => { await expectExecFailed(); - const logs = cli.stdout.split('\n').filter((log) => log.includes('Tests')); + const logs = cli.stdout + .split('\n') + .filter((log) => log.startsWith(' Tests')); // `Tests 1 failed | 2 passed (3)` => 3 const totalCount = Number(logs[0]!.match(/\((\d+)\)/)?.[1]); expect(totalCount).toBe(3); diff --git a/e2e/basic/test/filePath.test.ts b/e2e/basic/test/filePath.test.ts index 1284ddda5..820903424 100644 --- a/e2e/basic/test/filePath.test.ts +++ b/e2e/basic/test/filePath.test.ts @@ -16,22 +16,20 @@ describe('current URL', () => { expect( pathe .normalize(__filename) - .endsWith('/rstest/e2e/basic/test/filePath.test.ts'), + .endsWith('/e2e/basic/test/filePath.test.ts'), ).toBe(true); }); it('__dirname', () => { expect(__dirname.startsWith('file://')).toBe(false); - expect( - pathe.normalize(__dirname).endsWith('/rstest/e2e/basic/test'), - ).toBe(true); + expect(pathe.normalize(__dirname).endsWith('/e2e/basic/test')).toBe(true); }); it('import.meta.url', () => { expect(import.meta.url.startsWith('file://')).toBe(true); - expect( - import.meta.url.endsWith('/rstest/e2e/basic/test/filePath.test.ts'), - ).toBe(true); + expect(import.meta.url.endsWith('/e2e/basic/test/filePath.test.ts')).toBe( + true, + ); }); }); }); diff --git a/e2e/basic/test/meta.test.ts b/e2e/basic/test/meta.test.ts index 5756df067..c9fa726bb 100644 --- a/e2e/basic/test/meta.test.ts +++ b/e2e/basic/test/meta.test.ts @@ -4,11 +4,9 @@ import { aDirName, aFileName, aMetaDirname, aMetaFileName } from '../src/meta'; describe('import.meta', () => { it('should get test file meta correctly', async () => { + expect(pathe.normalize(__dirname).endsWith('/e2e/basic/test')).toBeTruthy(); expect( - pathe.normalize(__dirname).endsWith('/rstest/e2e/basic/test'), - ).toBeTruthy(); - expect( - pathe.normalize(__filename).endsWith('/basic/test/meta.test.ts'), + pathe.normalize(__filename).endsWith('/e2e/basic/test/meta.test.ts'), ).toBeTruthy(); }); diff --git a/packages/core/LICENSE.md b/packages/core/LICENSE.md index 87fe3f7d5..bdf63935d 100644 --- a/packages/core/LICENSE.md +++ b/packages/core/LICENSE.md @@ -943,7 +943,7 @@ Licensed under MIT license in the repository at https://github.com/chaijs/loupe. ### magic-string -Licensed under MIT license in the repository at https://github.com/rich-harris/magic-string.git. +Licensed under MIT license in the repository at git+https://github.com/Rich-Harris/magic-string.git. > Copyright 2018 Rich Harris > diff --git a/packages/core/package.json b/packages/core/package.json index 9d9fcae23..619eb4168 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -71,6 +71,7 @@ "@types/babel__code-frame": "^7.0.6", "@types/istanbul-reports": "^3.0.4", "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-lib-report": "^3.0.3", "@types/jsdom": "^21.1.7", "@types/sinonjs__fake-timers": "^8.1.5", "@types/source-map-support": "^0.5.10", diff --git a/packages/core/src/pool/forks.ts b/packages/core/src/pool/forks.ts index 77fa0c02a..a757efb77 100644 --- a/packages/core/src/pool/forks.ts +++ b/packages/core/src/pool/forks.ts @@ -82,6 +82,11 @@ export const createForksPool = (poolOptions: { const pool = new Tinypool(options); + const destroy = pool.destroy.bind(pool); + + // FIXME It seems that there are still some edge cases where the worker is not killed when the parent process exits. + process.on('SIGTERM', destroy); + return { name: 'forks', runTest: async ({ options, rpcMethods }: RunWorkerOptions) => { @@ -100,6 +105,9 @@ export const createForksPool = (poolOptions: { cleanup(); } }, - close: () => pool.destroy(), + close: () => { + process.off('SIGTERM', destroy); + return destroy(); + }, }; }; diff --git a/packages/core/src/pool/index.ts b/packages/core/src/pool/index.ts index 643df22c1..623218692 100644 --- a/packages/core/src/pool/index.ts +++ b/packages/core/src/pool/index.ts @@ -84,7 +84,7 @@ const getRuntimeConfig = (context: ProjectContext): RuntimeConfig => { disableConsoleIntercept, testEnvironment, isolate, - coverage, + coverage: { ...coverage, reporters: [] }, // reporters may be functions so remove it snapshotFormat, logHeapUsage, bail, diff --git a/packages/core/src/types/coverage.ts b/packages/core/src/types/coverage.ts index a774beefb..4f938d613 100644 --- a/packages/core/src/types/coverage.ts +++ b/packages/core/src/types/coverage.ts @@ -5,6 +5,7 @@ import type { FileCoverageData, Totals, } from 'istanbul-lib-coverage'; +import type { ReportBase } from 'istanbul-lib-report'; import type { ReportOptions } from 'istanbul-reports'; type ReportWithOptions = @@ -83,7 +84,7 @@ export type CoverageOptions = { * The reporters to use for coverage collection. * @default ['text', 'html', 'clover', 'json'] */ - reporters?: (keyof ReportOptions | ReportWithOptions)[]; + reporters?: (keyof ReportOptions | ReportWithOptions | ReportBase)[]; /** * The directory to store coverage reports. diff --git a/packages/coverage-istanbul/package.json b/packages/coverage-istanbul/package.json index 8c6ca0508..da45b659b 100644 --- a/packages/coverage-istanbul/package.json +++ b/packages/coverage-istanbul/package.json @@ -17,7 +17,7 @@ ], "scripts": { "build": "rslib build", - "dev": "rslib build --watch" + "dev": "cross-env SOURCEMAP=true rslib build --watch" }, "peerDependencies": { "@rstest/core": "workspace:~" diff --git a/packages/coverage-istanbul/rslib.config.ts b/packages/coverage-istanbul/rslib.config.ts index 75a1f2011..22c993157 100644 --- a/packages/coverage-istanbul/rslib.config.ts +++ b/packages/coverage-istanbul/rslib.config.ts @@ -6,6 +6,9 @@ export default defineConfig({ format: 'esm', syntax: ['node 18'], dts: true, + output: { + sourceMap: process.env.SOURCEMAP === 'true', + }, }, ], }); diff --git a/packages/coverage-istanbul/src/provider.ts b/packages/coverage-istanbul/src/provider.ts index 45e2ee5bc..6174fbdd4 100644 --- a/packages/coverage-istanbul/src/provider.ts +++ b/packages/coverage-istanbul/src/provider.ts @@ -109,11 +109,15 @@ export class CoverageProvider implements RstestCoverageProvider { }); const reportersList = this.options.reporters; for (const reporter of reportersList) { - const [reporterName, reporterOptions] = Array.isArray(reporter) - ? reporter - : [reporter, {}]; - const report = reports.create(reporterName, reporterOptions); - report.execute(context); + if (typeof reporter === 'object' && 'execute' in reporter) { + reporter.execute(context); + } else { + const [reporterName, reporterOptions] = Array.isArray(reporter) + ? reporter + : [reporter, {}]; + const report = reports.create(reporterName, reporterOptions); + report.execute(context); + } } } catch (error) { console.error('Failed to generate coverage reports:', error); diff --git a/packages/coverage-istanbul/tsconfig.json b/packages/coverage-istanbul/tsconfig.json index 6dcf8e281..98d959af2 100644 --- a/packages/coverage-istanbul/tsconfig.json +++ b/packages/coverage-istanbul/tsconfig.json @@ -5,7 +5,8 @@ "baseUrl": "./", "rootDir": "src", "composite": true, - "isolatedDeclarations": true + "isolatedDeclarations": true, + "declarationMap": true }, "include": ["src"] } diff --git a/packages/vscode/README.md b/packages/vscode/README.md index 52cc4c037..dc49bffd1 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -15,10 +15,10 @@ The extension activates automatically when your workspace contains Rstest config ## Configuration -| Setting | Type | Default | Description | -| ---------------------------- | -------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `rstest.testFileGlobPattern` | string[] | `["**/*.test.*", "**/*.spec.*"]` | Glob pattern(s) used to discover test files in the workspace. | -| `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. | +| 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. | ## How it works diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 1d289c7af..c50bfe1df 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -27,20 +27,6 @@ "configuration": { "title": "Rstest", "properties": { - "rstest.testFileGlobPattern": { - "markdownDescription": "Glob patterns used to discover test files. Must be an array of strings.", - "scope": "resource", - "type": "array", - "items": { - "type": "string" - }, - "default": [ - "**/*.{test,spec}.[jt]s", - "**/*.{test,spec}.[cm][jt]s", - "**/*.{test,spec}.[jt]sx", - "**/*.{test,spec}.[cm][jt]sx" - ] - }, "rstest.rstestPackagePath": { "markdownDescription": "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.", "scope": "resource", @@ -105,19 +91,22 @@ "@rsbuild/core": "1.6.12-canary-20251204065915", "@rslib/core": "0.18.3", "@rstest/core": "workspace:*", + "@rstest/coverage-istanbul": "workspace:*", "@swc/core": "^1.15.3", - "@types/glob": "^7.2.0", + "@types/istanbul-lib-report": "^3.0.3", "@types/mocha": "^10.0.10", "@types/node": "^22.16.5", + "@types/picomatch": "^4.0.2", "@types/vscode": "1.97.0", "@vscode/test-cli": "^0.0.12", "@vscode/test-electron": "^2.5.2", "@vscode/vsce": "3.7.1", "birpc": "2.9.0", "core-js-pure": "^3.47.0", - "glob": "^7.2.3", "mocha": "^11.7.5", "ovsx": "^0.10.7", + "picomatch": "^4.0.3", + "tinyglobby": "^0.2.15", "typescript": "^5.9.3", "valibot": "^1.2.0" } diff --git a/packages/vscode/src/config.ts b/packages/vscode/src/config.ts index 9f51d63c6..bace8e7da 100644 --- a/packages/vscode/src/config.ts +++ b/packages/vscode/src/config.ts @@ -4,14 +4,6 @@ import vscode from 'vscode'; // Centralized configuration types for the extension. // Add new keys here to extend configuration in a type-safe way. const configSchema = v.object({ - // Glob patterns that determine which files are considered tests. - // Must be an array of strings. - testFileGlobPattern: v.fallback(v.array(v.string()), [ - '**/*.{test,spec}.[jt]s', - '**/*.{test,spec}.[cm][jt]s', - '**/*.{test,spec}.[jt]sx', - '**/*.{test,spec}.[cm][jt]sx', - ]), // The path to a package.json file of a Rstest executable. // Used as a last resort if the extension cannot auto-detect @rstest/core. rstestPackagePath: v.fallback(v.optional(v.string()), undefined), diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 7e7d4da2e..d04602559 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -1,11 +1,13 @@ import vscode from 'vscode'; import { logger } from './logger'; -import { WorkspaceManager } from './project'; +import { runningWorkers } from './master'; +import { Project, WorkspaceManager } from './project'; +import { RstestFileCoverage } from './testRunReporter'; import { gatherTestItems, - getContentFromFilesystem, TestCase, TestFile, + TestFolder, testData, } from './testTree'; @@ -14,6 +16,12 @@ export async function activate(context: vscode.ExtensionContext) { return rstest; } +export function deactivate() { + for (const worker of runningWorkers) { + worker.$close(); + } +} + class Rstest { private ctrl: vscode.TestController; private workspaces = new Map(); @@ -40,7 +48,7 @@ class Rstest { this.runProfile = this.ctrl.createRunProfile( 'Run Tests', vscode.TestRunProfileKind.Run, - (request) => this.startTestRun(request), + this.startTestRun, true, undefined, false, @@ -51,14 +59,24 @@ class Rstest { (params: { test: vscode.TestItem; message: vscode.TestMessage }) => this.startTestRun( new vscode.TestRunRequest([params.test], undefined, this.runProfile), + new vscode.CancellationTokenSource().token, true, ), ); + this.ctrl.createRunProfile( + 'Debug Tests', + vscode.TestRunProfileKind.Debug, + this.startTestRun, + true, + undefined, + false, + ); + this.coverageProfile = this.ctrl.createRunProfile( - 'Run with Coverage', + 'Run Tests with Coverage', vscode.TestRunProfileKind.Coverage, - (request) => this.startTestRun(request), + this.startTestRun, true, undefined, false, @@ -66,11 +84,8 @@ class Rstest { this.coverageProfile.loadDetailedCoverage = async (_testRun, coverage) => { if (coverage instanceof RstestFileCoverage) { - return coverage.coveredLines.filter( - (l): l is vscode.StatementCoverage => !!l, - ); + return coverage.details; } - return []; }; } @@ -85,6 +100,7 @@ class Rstest { for (const workspace of vscode.workspace.workspaceFolders || []) { this.handleAddWorkspace(workspace); } + this.refreshAllWorkspaces(); // start watching workspace change if (!this.workspaceWatcher) { this.workspaceWatcher = vscode.workspace.onDidChangeWorkspaceFolders( @@ -95,6 +111,7 @@ class Rstest { for (const removed of e.removed) { this.handleRemoveWorkspace(removed); } + this.refreshAllWorkspaces(); }, ); } @@ -118,29 +135,41 @@ class Rstest { this.workspaces.delete(workspaceFolder.uri.toString()); } - private startTestRun = ( + private refreshAllWorkspaces() { + this.ctrl.items.replace([]); + for (const workspace of this.workspaces.values()) { + workspace.refresh(vscode.workspace.workspaceFolders?.length === 1); + } + } + + private startTestRun = async ( request: vscode.TestRunRequest, + token: vscode.CancellationToken, updateSnapshot?: boolean, run = this.ctrl.createTestRun(request), ) => { - // map of file uris to statements on each line: - const coveredLines = new Map< - /* file uri */ string, - (vscode.StatementCoverage | undefined)[] - >(); - const enqueuedTests = (tests: readonly vscode.TestItem[]) => { for (const test of tests) { if (request.exclude?.includes(test)) { continue; } - run.enqueued(test); + const data = testData.get(test); + if (data instanceof TestFile || data instanceof TestCase) { + run.enqueued(test); + } enqueuedTests(gatherTestItems(test.children, false)); } }; enqueuedTests(request.include ?? gatherTestItems(this.ctrl.items, false)); + const commonOptions = { + run, + token, + updateSnapshot, + kind: request.profile?.kind, + }; + const discoverTests = async (tests: readonly vscode.TestItem[]) => { for (const test of tests) { if (request.exclude?.includes(test)) { @@ -148,69 +177,60 @@ class Rstest { } const data = testData.get(test); - if (data instanceof TestCase) { - run.started(test); - await data.run(test, run, updateSnapshot); - } else if (data instanceof TestFile) { - if (!data.didResolve) { - await data.updateFromDisk(this.ctrl, test); - enqueuedTests(gatherTestItems(test.children, false)); - } - - // Run all tests for this file at once - run.started(test); - await data.run(test, run, updateSnapshot, this.ctrl); - } else { - // Process child tests - await discoverTests(gatherTestItems(test.children, false)); - } - - if ( - test.uri && - !coveredLines.has(test.uri.toString()) && - request.profile?.kind === vscode.TestRunProfileKind.Coverage - ) { - try { - const lines = (await getContentFromFilesystem(test.uri)).split( - '\n', - ); - coveredLines.set( - test.uri.toString(), - lines.map((lineText, lineNo) => - lineText.trim().length - ? new vscode.StatementCoverage( - 0, - new vscode.Position(lineNo, 0), - ) - : undefined, - ), - ); - } catch { - // ignored + if (data instanceof WorkspaceManager) { + if (data.projects.size === 1) { + const project = data.projects.values().next().value!; + await project.api.runTest({ + ...commonOptions, + }); + } else { + await discoverTests(gatherTestItems(test.children, false)); } + } else if (data instanceof Project) { + await data.api.runTest({ + ...commonOptions, + }); + } else if (data instanceof TestFolder) { + await data.api.runTest({ + ...commonOptions, + fileFilter: data.uri.fsPath, + }); + } else if (data instanceof TestFile) { + await data.api.runTest({ + ...commonOptions, + fileFilter: data.uri.fsPath, + }); + } else if (data instanceof TestCase) { + await data.api.runTest({ + ...commonOptions, + fileFilter: data.uri.fsPath, + testCaseNamePath: data.parentNames.concat(test.label), + isSuite: data.type === 'suite', + }); } } }; - discoverTests(request.include ?? gatherTestItems(this.ctrl.items, false)) - .catch((error) => { - logger.error('Error running tests:', error); - }) - .finally(() => run.end()); - }; -} - -class RstestFileCoverage extends vscode.FileCoverage { - constructor( - uri: string, - public readonly coveredLines: (vscode.StatementCoverage | undefined)[], - ) { - super(vscode.Uri.parse(uri), new vscode.TestCoverageCount(0, 0)); - for (const line of coveredLines) { - if (line) { - this.statementCoverage.covered += line.executed ? 1 : 0; - this.statementCoverage.total++; + try { + if (!request.include?.length) { + if (this.workspaces.size === 1) { + const workspace = this.workspaces.values().next().value!; + if (workspace.projects.size === 1) { + const project = workspace.projects.values().next().value!; + await project.api.runTest({ + ...commonOptions, + }); + return; + } + } } + await discoverTests( + request.include ?? gatherTestItems(this.ctrl.items, false), + ); + } catch (error) { + logger.error('Error running tests:', error); + } finally { + run.end(); } - } + }; } diff --git a/packages/vscode/src/master.ts b/packages/vscode/src/master.ts index 51f452ba2..ec664fbbd 100644 --- a/packages/vscode/src/master.ts +++ b/packages/vscode/src/master.ts @@ -1,19 +1,17 @@ import { type ChildProcess, spawn } from 'node:child_process'; -import { randomUUID } from 'node:crypto'; import path, { dirname } from 'node:path'; -import { createBirpc } from 'birpc'; +import { type BirpcReturn, createBirpc } from 'birpc'; import regexpEscape from 'core-js-pure/actual/regexp/escape'; import vscode from 'vscode'; import { getConfigValue } from './config'; import { logger } from './logger'; -import type { LogLevel } from './shared/logger'; +import type { Project } from './project'; import { TestRunReporter } from './testRunReporter'; -import type { WorkerRunTestData } from './types'; -import { promiseWithTimeout } from './utils'; import type { Worker } from './worker'; +export const runningWorkers = new Set>(); + export class RstestApi { - public worker: Pick | null = null; private childProcess: ChildProcess | null = null; private versionMismatchWarned = false; @@ -21,6 +19,7 @@ export class RstestApi { private workspace: vscode.WorkspaceFolder, private cwd: string, private configFilePath: string, + private project: Project, ) {} private resolveRstestPath(): string { @@ -106,46 +105,80 @@ export class RstestApi { } } - public async runTest( - item: vscode.TestItem, - run: vscode.TestRun, - updateSnapshot?: boolean, - ) { - if (this.worker) { - const testRunReporter = new TestRunReporter(run, item); + public async getNormalizedConfig() { + const worker = await this.createChildProcess(); + const config = await worker.getNormalizedConfig({ + rstestPath: this.resolveRstestPath(), + configFilePath: this.configFilePath, + }); + worker.$close(); + return config; + } - const testNamePattern = regexpEscape( - testRunReporter.getTestItemPath().join(' '), - ); - const data: WorkerRunTestData = { - runId: randomUUID(), - fileFilters: [item.uri!.fsPath], - testNamePattern: testNamePattern - ? new RegExp(`^${testNamePattern}$`) - : undefined, - updateSnapshot, - }; + public async runTest({ + run, + token, + updateSnapshot, + fileFilter, + testCaseNamePath, + isSuite, + kind, + }: { + run: vscode.TestRun; + token: vscode.CancellationToken; + updateSnapshot?: boolean; + fileFilter?: string; + testCaseNamePath?: string[]; + isSuite?: boolean; + kind?: vscode.TestRunProfileKind; + }) { + const testRunReporter = new TestRunReporter( + run, + this.project, + testCaseNamePath, + ); - this.testRunReporters.set(data.runId, testRunReporter); + const worker = await this.createChildProcess( + testRunReporter, + kind === vscode.TestRunProfileKind.Debug, + run, + ); + token.onCancellationRequested(() => worker.$close()); - const isTestFile = !testNamePattern?.length; - await promiseWithTimeout( - this.worker.runTest(data), - isTestFile ? 30_000 : 10_000, // longer timeout for test file - new Error(`Test execution timed out for ${item.label}`), - ).finally(() => { - this.testRunReporters.delete(data.runId); + await worker + .runTest({ + fileFilters: fileFilter ? [fileFilter] : undefined, + testNamePattern: testCaseNamePath + ? new RegExp( + `^${regexpEscape(testCaseNamePath.join(' '))}${isSuite ? ' ' : '$'}`, + ) + : undefined, + update: updateSnapshot, + configFilePath: this.configFilePath, + rstestPath: this.resolveRstestPath(), + coverage: + kind === vscode.TestRunProfileKind.Coverage + ? { enabled: true } + : undefined, + }) + .finally(() => { + worker.$close(); }); - } } - public async createChildProcess() { + public async createChildProcess( + testRunReporter = new TestRunReporter(), + startDebugging?: boolean, + testRun?: vscode.TestRun, + ) { const rstestPath = this.resolveRstestPath(); if (!rstestPath) { - logger.error('Failed to resolve rstest path'); - return; + throw new Error('Failed to resolve rstest path'); } const execArgv: string[] = []; + if (startDebugging) { + execArgv.push('--inspect-wait'); + } const workerPath = path.resolve(__dirname, 'worker.js'); logger.debug('Spawning worker process', { workerPath, @@ -155,13 +188,36 @@ export class RstestApi { stdio: ['pipe', 'pipe', 'pipe', 'ipc'], serialization: 'advanced', env: { + // same as packages/core/src/cli/prepare.ts + // if (!process.env.NODE_ENV) process.env.NODE_ENV = 'test' + NODE_ENV: 'test', ...process.env, - TEST: 'true', + // process.env.RSTEST = 'true'; + RSTEST: 'true', FORCE_COLOR: '1', }, }); this.childProcess = rstestProcess; + if (startDebugging) { + const startedDebugging = await vscode.debug.startDebugging( + this.workspace, + { + type: 'node', + name: 'Rstest Debug', + request: 'attach', + processId: rstestProcess.pid, + }, + { testRun }, + ); + if (!startedDebugging) { + rstestProcess.kill(); + throw new Error( + `Failed to attach debugger to test worker process (PID: ${rstestProcess.pid})`, + ); + } + } + rstestProcess.stdout?.on('data', (d) => { const content = d.toString(); logger.debug('[worker stdout]', content.trimEnd()); @@ -172,18 +228,20 @@ export class RstestApi { logger.error('[worker stderr]', content.trimEnd()); }); - this.worker = createBirpc(this, { + const worker = createBirpc(testRunReporter, { // use this.childProcess to catch post is called after process killed post: (data) => this.childProcess?.send(data), on: (fn) => rstestProcess.on('message', fn), bind: 'functions', + timeout: 600_000, + off: () => { + rstestProcess.kill(); + runningWorkers.delete(worker); + }, }); - await this.worker.initRstest({ - root: this.cwd, - rstestPath, - configFilePath: this.configFilePath, - }); + runningWorkers.add(worker); + logger.debug('Sent init payload to worker', { root: this.cwd, rstestPath, @@ -193,26 +251,12 @@ export class RstestApi { rstestProcess.on('exit', (code, signal) => { logger.debug('Worker process exited', { code, signal }); }); - } - async log(level: LogLevel, message: string) { - logger[level](message); + return worker; } public dispose() { this.childProcess?.kill(); this.childProcess = null; } - - private testRunReporters = new Map(); - - async onTestProgress( - runId: string, - event: T, - param: Parameters>[0], - ) { - const reporter = this.testRunReporters.get(runId); - // @ts-expect-error - reporter?.[event]?.call(reporter, param); - } } diff --git a/packages/vscode/src/project.ts b/packages/vscode/src/project.ts index 8f85ce0a2..84cad3d8d 100644 --- a/packages/vscode/src/project.ts +++ b/packages/vscode/src/project.ts @@ -1,31 +1,43 @@ import path from 'node:path'; +import picomatch from 'picomatch'; +import { glob } from 'tinyglobby'; import * as vscode from 'vscode'; import { watchConfigValue } from './config'; import { RstestApi } from './master'; -import { TestFile, testData, testItemType } from './testTree'; -import { shouldIgnoreUri } from './utils'; +import { TestFile, TestFolder, testData } from './testTree'; export class WorkspaceManager implements vscode.Disposable { - private projects = new Map(); + public projects = new Map(); private workspacePath: string; - private testItem: vscode.TestItem; + private testItem?: vscode.TestItem; private configValueWatcher: vscode.Disposable; constructor( private workspaceFolder: vscode.WorkspaceFolder, private testController: vscode.TestController, ) { this.workspacePath = workspaceFolder.uri.toString(); - this.testItem = testController.createTestItem( - this.workspacePath, - workspaceFolder.name, - workspaceFolder.uri, - ); - testItemType.set(this.testItem, 'workspace'); - testController.items.add(this.testItem); this.configValueWatcher = this.startWatchingWorkspace(); } + // if this is the only one workspace, skip create test item + public refresh(isOnlyOne: boolean) { + if (isOnlyOne) { + if (this.testItem) { + this.testItem = undefined; + } + } else { + if (!this.testItem) { + this.testItem = this.testController.createTestItem( + this.workspacePath, + this.workspaceFolder.name, + this.workspaceFolder.uri, + ); + testData.set(this.testItem, this); + } + this.testController.items.add(this.testItem); + } + this.refreshAllProject(); + } public dispose() { - this.testController.items.delete(this.workspacePath); for (const project of this.projects.values()) { project.dispose(); } @@ -66,18 +78,30 @@ export class WorkspaceManager implements vscode.Disposable { this.projects.delete(configFilePath); } } + this.refreshAllProject(); // start watching config file create and delete event for (const pattern of patterns) { const watcher = vscode.workspace.createFileSystemWatcher( pattern, false, - true, // we don't care about config file content now, so ignore change event + false, false, ); token.onCancellationRequested(() => watcher.dispose()); - watcher.onDidCreate((file) => this.handleAddConfigFile(file)); - watcher.onDidDelete((file) => this.handleRemoveConfigFile(file)); + watcher.onDidCreate((file) => { + this.handleAddConfigFile(file); + this.refreshAllProject(); + }); + watcher.onDidDelete((file) => { + this.handleRemoveConfigFile(file); + this.refreshAllProject(); + }); + watcher.onDidChange((file) => { + this.handleRemoveConfigFile(file); + this.handleAddConfigFile(file); + this.refreshAllProject(); + }); } }, ); @@ -89,7 +113,7 @@ export class WorkspaceManager implements vscode.Disposable { this.workspaceFolder, configFileUri, this.testController, - this.testItem, + this.testItem?.children ?? this.testController.items, ); this.projects.set(configFilePath, project); } @@ -100,130 +124,228 @@ export class WorkspaceManager implements vscode.Disposable { project.dispose(); this.projects.delete(configFilePath); } + private refreshAllProject() { + const collection = this.testItem?.children ?? this.testController.items; + collection.replace([]); + for (const project of this.projects.values()) { + project.refresh(this.projects.size === 1, collection); + } + } } // There is already a concept of 'project' in rstest, so we might consider changing its name here. export class Project implements vscode.Disposable { api: RstestApi; root: vscode.Uri; - projectTestItem: vscode.TestItem; - configValueWatcher: vscode.Disposable; + testItem?: vscode.TestItem; + cancellationSource: vscode.CancellationTokenSource; + include: string[] = []; + exclude: string[] = []; + testFiles = new Map(); constructor( - workspaceFolder: vscode.WorkspaceFolder, + private workspaceFolder: vscode.WorkspaceFolder, private configFileUri: vscode.Uri, private testController: vscode.TestController, - private workspaceTestItem: vscode.TestItem, + public parentCollection: vscode.TestItemCollection, ) { - // TODO get root from config + // use dirname of config file as default root this.root = configFileUri.with({ path: path.dirname(configFileUri.path) }); this.api = new RstestApi( workspaceFolder, - this.root.fsPath, + path.dirname(configFileUri.fsPath), configFileUri.fsPath, + this, ); + this.cancellationSource = new vscode.CancellationTokenSource(); - // TODO skip createTestItem if there is only one configFile in the workspace and it is located in the root directory - this.projectTestItem = testController.createTestItem( - configFileUri.toString(), - path.relative(workspaceFolder.uri.path, configFileUri.path), - configFileUri, - ); - testItemType.set(this.projectTestItem, 'project'); - workspaceTestItem.children.add(this.projectTestItem); - // TODO catch and set error - this.api.createChildProcess(); + this.api.getNormalizedConfig().then((config) => { + if (this.cancellationSource.token.isCancellationRequested) return; + this.root = vscode.Uri.file(config.root); + this.include = config.include; + this.exclude = config.exclude; + this.startWatchingWorkspace(this.root); + }); + } - this.configValueWatcher = this.startWatchingWorkspace(); + public refresh( + isOnlyOne: boolean, + parentCollection: vscode.TestItemCollection, + ) { + this.parentCollection = parentCollection; + const configFileName = path.relative( + this.workspaceFolder.uri.fsPath, + this.configFileUri.fsPath, + ); + // if this is the only one project, and placed at root of workspace, + // and matches normal config file name, skip create test item + const skipCreateTestItem = + isOnlyOne && configFileName.match(/^rstest\.config\.[mc]?[tj]s$/); + if (skipCreateTestItem) { + if (this.testItem) { + this.testItem = undefined; + } + } else { + if (!this.testItem) { + this.testItem = this.testController.createTestItem( + this.configFileUri.toString(), + path.relative(this.workspaceFolder.uri.path, this.configFileUri.path), + this.configFileUri, + ); + testData.set(this.testItem, this); + } + this.parentCollection.add(this.testItem); + } + this.buildTree(); } dispose() { - this.workspaceTestItem.children.delete(this.configFileUri.toString()); - this.configValueWatcher.dispose(); this.api.dispose(); + this.cancellationSource.cancel(); } - private startWatchingWorkspace() { - // TODO read config from config file, or scan files with rstest internal api directly - return watchConfigValue( - 'testFileGlobPattern', - this.root, - async (globs, token) => { - const patterns = globs.map( - (glob) => new vscode.RelativePattern(this.root, glob), - ); + get collection() { + return this.testItem?.children || this.parentCollection; + } + private async startWatchingWorkspace(root: vscode.Uri) { + const matchInclude = picomatch(this.include); + const matchExclude = picomatch(this.exclude); + const isInclude = (uri: vscode.Uri) => { + const relativePath = path.relative(root.fsPath, uri.fsPath); + return matchInclude(relativePath) && !matchExclude(relativePath); + }; - const files = ( - await Promise.all( - patterns.map((pattern) => - vscode.workspace.findFiles( - pattern, - '**/node_modules/**', - undefined, - token, - ), - ), - ) - ).flat(); + 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 visited = new Set(); - for (const uri of files) { - if (shouldIgnoreUri(uri)) continue; - this.updateOrCreateFile(uri); - visited.add(uri.toString()); - } + if (this.cancellationSource.token.isCancellationRequested) return; - // remove outdated items after glob configuration changed - this.projectTestItem.children.forEach((testItem) => { - if (!visited.has(testItem.id)) { - this.projectTestItem.children.delete(testItem.id); - } - }); + const visited = new Set(); + for (const uri of files) { + if (matchExclude(uri.fsPath)) continue; + this.updateOrCreateFile(uri); + visited.add(uri.toString()); + } - // start watching test file change - for (const pattern of patterns) { - const watcher = vscode.workspace.createFileSystemWatcher( - pattern, - false, - false, - 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(); - token.onCancellationRequested(() => watcher.dispose()); - watcher.onDidCreate((uri) => { - if (shouldIgnoreUri(uri)) return; - this.updateOrCreateFile(uri); - }); - watcher.onDidChange((uri) => { - if (shouldIgnoreUri(uri)) return; - this.updateOrCreateFile(uri); - }); - watcher.onDidDelete((uri) => { - this.projectTestItem.children.delete(uri.toString()); - }); - } - }, + // 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, '**'), + ); + 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.projectTestItem.children.get(uri.toString()); + const existing = this.testFiles.get(uri.toString()); if (existing) { - (testData.get(existing) as TestFile).updateFromDisk( - this.testController, - existing, - ); + existing.updateFromDisk(this.testController); } else { - const file = this.testController.createTestItem( + const data = new TestFile(this.api, uri); + this.testFiles.set(uri.toString(), data); + data.updateFromDisk(this.testController); + } + } + + private buildTree() { + type NestedRecord = { [K: string]: NestedRecord }; + + const tree: NestedRecord = {}; + for (const [uriString] of this.testFiles) { + path + .relative(this.root.fsPath, vscode.Uri.parse(uriString).fsPath) + .split(path.sep) + // biome-ignore lint/suspicious/noAssignInExpressions: just simple shorthand + .reduce((tree, segment) => (tree[segment] ||= {}), tree); + } + + const handleTreeItem = ( + key: string, + value: NestedRecord, + mergedParents: string[], + parents: string[], + collection: vscode.TestItemCollection, + ) => { + const uri = vscode.Uri.file( + [this.root.fsPath, ...parents, key].join(path.sep), + ); + const children = Object.entries(value); + + if (children.length === 1) { + // if folder's only child is folder, merge them into one node + const onlyChild = children[0]; + const [childKey, childValue] = onlyChild; + const childIsFolder = Object.entries(childValue).length !== 0; + if (childIsFolder) { + handleTreeItem( + childKey, + childValue, + [...mergedParents, key], + [...parents, key], + collection, + ); + return; + } + } + const item = this.testController.createTestItem( uri.toString(), - path.basename(uri.path), + [...mergedParents, key].join(path.sep), uri, ); - testItemType.set(file, 'file'); - this.projectTestItem.children.add(file); + collection.add(item); - const data = new TestFile(this.api); - testData.set(file, data); - data.updateFromDisk(this.testController, file); + const file = this.testFiles.get(uri.toString()); + if (file) { + file.setTestItem(item); + testData.set(item, file); + } else { + testData.set(item, new TestFolder(this.api, uri)); + } + + for (const [childKey, childValue] of children) { + handleTreeItem( + childKey, + childValue, + [], + [...parents, key], + item.children, + ); + } + }; - file.canResolveChildren = true; + this.collection.replace([]); + for (const [childKey, childValue] of Object.entries(tree)) { + handleTreeItem(childKey, childValue, [], [], this.collection); } } } diff --git a/packages/vscode/src/testRunReporter.ts b/packages/vscode/src/testRunReporter.ts index 5c6849026..6b69e1eda 100644 --- a/packages/vscode/src/testRunReporter.ts +++ b/packages/vscode/src/testRunReporter.ts @@ -10,39 +10,23 @@ import vscode from 'vscode'; import { ROOT_SUITE_NAME } from '../../core/src/utils/constants'; import { parseErrorStacktrace } from '../../core/src/utils/error'; import { logger } from './logger'; -import { testItemType } from './testTree'; +import type { Project } from './project'; +import type { LogLevel } from './shared/logger'; export class TestRunReporter implements Reporter { - private fileItem: vscode.TestItem; - private path: string[]; constructor( - private run: vscode.TestRun, - private testItem: vscode.TestItem, - ) { - let fileItem: vscode.TestItem | undefined = testItem; - const path: string[] = []; - - while ( - fileItem && - (testItemType.get(fileItem) === 'suite' || - testItemType.get(fileItem) === 'case') - ) { - path.unshift(fileItem.label); - fileItem = fileItem.parent; - } - - if (!fileItem) throw new Error('Cannot find test file'); + private run?: vscode.TestRun, + private project?: Project, + private path: string[] = [], + ) {} - this.fileItem = fileItem; - this.path = path; - } - public getTestItemPath() { - return this.path; + public async log(level: LogLevel, message: string) { + logger[level](message); } // pipe default reporter output to vscode test results panel onOutput(message: string) { - this.run.appendOutput(message.replaceAll('\n', '\r\n')); + this.run?.appendOutput(message.replaceAll('\n', '\r\n')); } private generatePath(value: TestCaseInfo | TestSuiteInfo | TestResult) { @@ -51,9 +35,12 @@ export class TestRunReporter implements Reporter { : [...(value.parentNames || []), value.name]; } private findTestItem(value: TestCaseInfo | TestSuiteInfo | TestResult) { + const fileItem = this.project?.testFiles.get( + vscode.Uri.file(value.testPath).toString(), + )?.testItem; return this.generatePath(value).reduce( (item, name) => item?.children.get(name), - this.fileItem, + fileItem, ); } /** check whether current running suite/case contains reported suite/case */ @@ -63,23 +50,36 @@ export class TestRunReporter implements Reporter { return this.path.every((name, index) => path[index] === name); } - onTestFileStart(_test: TestFileInfo) { - this.run.started(this.testItem); + onTestFileStart(test: TestFileInfo) { + // only update test file result when explicit run itself or parent + if (this.path.length) return; + + const fileItem = this.project?.testFiles.get( + vscode.Uri.file(test.testPath).toString(), + )?.testItem; + if (!fileItem) return; + + this.run?.started(fileItem); } onTestFileResult(test: TestFileResult) { - // only update test file result when explicit run it + // only update test file result when explicit run itself or parent if (this.path.length) return; + const fileItem = this.project?.testFiles.get( + vscode.Uri.file(test.testPath).toString(), + )?.testItem; + if (!fileItem) return; + switch (test.status) { case 'todo': case 'skip': - this.run.skipped(this.fileItem); + this.run?.skipped(fileItem); break; case 'pass': - this.run.passed(this.fileItem, test.duration); + this.run?.passed(fileItem, test.duration); break; case 'fail': - this.run.failed(this.fileItem, [], test.duration); + this.run?.failed(fileItem, [], test.duration); break; } } @@ -101,7 +101,7 @@ export class TestRunReporter implements Reporter { logger.error('Cannot find testItem', test); return; } - this.run.started(testItem); + this.run?.started(testItem); } async onTestCaseResult(result: TestResult) { // if reported result is not belongs to current testItem, only update result when there's some suite before/after hooks error @@ -117,16 +117,16 @@ export class TestRunReporter implements Reporter { switch (result.status) { case 'pass': { - this.run.passed(testItem, result.duration); + this.run?.passed(testItem, result.duration); break; } case 'skip': case 'todo': { - this.run.skipped(testItem); + this.run?.skipped(testItem); break; } case 'fail': { - this.run.failed( + this.run?.failed( testItem, await Promise.all( (result.errors || []).map(async (error) => @@ -140,6 +140,52 @@ export class TestRunReporter implements Reporter { } } + async onCoverage( + uri: string, + statementCoverage: vscode.TestCoverageCount, + branchCoverage?: vscode.TestCoverageCount, + declarationCoverage?: vscode.TestCoverageCount, + details?: vscode.FileCoverageDetail[], + ) { + this.run?.addCoverage( + new RstestFileCoverage( + vscode.Uri.file(uri), + statementCoverage, + branchCoverage, + declarationCoverage, + details?.map((detail) => { + const mapLocation = (location: vscode.Position | vscode.Range) => { + if ('start' in location) + return new vscode.Range( + location.start.line, + location.start.character, + location.end.line, + location.end.character, + ); + return new vscode.Position(location.line, location.character); + }; + return 'name' in detail + ? new vscode.DeclarationCoverage( + detail.name, + detail.executed, + mapLocation(detail.location), + ) + : new vscode.StatementCoverage( + detail.executed, + mapLocation(detail.location), + detail.branches.map( + (branch) => + new vscode.BranchCoverage( + branch.executed, + branch.location && mapLocation(branch.location), + ), + ), + ); + }), + ), + ); + } + private async createError( error: NonNullable[number], testPath: string, @@ -188,3 +234,15 @@ export class TestRunReporter implements Reporter { return message; } } + +export class RstestFileCoverage extends vscode.FileCoverage { + constructor( + uri: vscode.Uri, + statementCoverage: vscode.TestCoverageCount, + branchCoverage?: vscode.TestCoverageCount, + declarationCoverage?: vscode.TestCoverageCount, + public readonly details: vscode.FileCoverageDetail[] = [], + ) { + super(uri, statementCoverage, branchCoverage, declarationCoverage); + } +} diff --git a/packages/vscode/src/testTree.ts b/packages/vscode/src/testTree.ts index 464e9f3fe..2c9fa63ec 100644 --- a/packages/vscode/src/testTree.ts +++ b/packages/vscode/src/testTree.ts @@ -3,14 +3,13 @@ import vscode from 'vscode'; 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'); -export const testData = new WeakMap(); - -export const testItemType = new WeakMap< +export const testData = new WeakMap< vscode.TestItem, - 'workspace' | 'project' | 'folder' | 'file' | 'suite' | 'case' + WorkspaceManager | Project | TestFolder | TestFile | TestCase >(); export const getContentFromFilesystem = async (uri: vscode.Uri) => { @@ -39,22 +38,31 @@ export function gatherTestItems( return items; } +export class TestFolder { + constructor( + public api: RstestApi, + public uri: vscode.Uri, + ) {} +} + export class TestFile { public didResolve = false; + public testItem?: vscode.TestItem; + private children: vscode.TestItem[] = []; - constructor(private api: RstestApi) {} + constructor( + public api: RstestApi, + public uri: vscode.Uri, + ) {} - public async updateFromDisk( - controller: vscode.TestController, - item: vscode.TestItem, - ) { - try { - const content = await getContentFromFilesystem(item.uri!); - item.error = undefined; - this.updateFromContents(controller, content, item); - } catch (e) { - item.error = (e as Error).stack; - } + public setTestItem(item: vscode.TestItem) { + this.testItem = item; + item.children.replace(this.children); + } + + public async updateFromDisk(controller: vscode.TestController) { + const content = await getContentFromFilesystem(this.uri); + this.updateFromContents(controller, content); } /** @@ -64,10 +72,11 @@ export class TestFile { public updateFromContents( controller: vscode.TestController, content: string, - item: vscode.TestItem, ) { // Maintain a stack of ancestors to build a hierarchical tree - const ancestors = [{ item, children: [] as vscode.TestItem[] }]; + const ancestors: { name: string; children: vscode.TestItem[] }[] = [ + { name: 'ROOT', children: [] }, + ]; this.didResolve = true; parseTestFile(content, { @@ -79,8 +88,6 @@ export class TestFile { const parent = ancestors[ancestors.length - 1]; - const testCase = new TestCase(this.api); - const siblingsCount = parent.children.filter( (child) => child.label === name, ).length; @@ -89,7 +96,18 @@ export class TestFile { let id = name; if (siblingsCount) id = [name, siblingsCount].join('@@@@@@'); - const testItem = controller.createTestItem(id, name, item.uri); + 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), + isSuite ? 'suite' : 'case', + ), + ); testItem.range = vscodeRange; @@ -97,77 +115,30 @@ export class TestFile { if (siblingsCount) testItem.error = `Duplicated ${testType} name`; // Set TestCase data for both describe blocks and leaf tests - testData.set(testItem, testCase); parent.children.push(testItem); - if (testType === 'describe' || testType === 'suite') { - testItemType.set(testItem, 'suite'); - - const suite = { item: testItem, children: [] }; + if (isSuite) { + const children: vscode.TestItem[] = []; // This becomes the new parent for subsequently discovered children - ancestors.push(suite); + ancestors.push({ name, children: children }); return () => { // Assign children to suite and pop from stack - suite.item.children.replace(suite.children); + testItem.children.replace(children); ancestors.pop(); }; } - - testItemType.set(testItem, 'case'); }, }); - - // Assign children to root item - ancestors[0].item.children.replace(ancestors[0].children); - } - - async run( - item: vscode.TestItem, - run: vscode.TestRun, - updateSnapshot?: boolean, - controller?: vscode.TestController, - ): Promise { - if (!this.didResolve && controller) { - await this.updateFromDisk(controller, item); - } - - // Match messaging and behavior from extension.ts - // run.appendOutput(`Running all tests in file ${item.id}\r\n`); - - try { - await this.api.runTest(item, run, updateSnapshot); - } catch (error: any) { - run.failed( - item, - new vscode.TestMessage( - `Error running file tests: ${error.message || String(error)}`, - ), - ); - // Skip all child tests in case of error - for (const child of gatherTestItems(item.children)) { - run.skipped(child); - } - } + this.children = ancestors[0].children; + this.testItem?.children.replace(this.children); } } export class TestCase { - constructor(private api: RstestApi) {} - - async run( - item: vscode.TestItem, - run: vscode.TestRun, - updateSnapshot?: boolean, - ): Promise { - try { - await this.api.runTest(item, run, updateSnapshot); - } catch (error: any) { - run.failed( - item, - new vscode.TestMessage( - `Error running test: ${error.message || String(error)}`, - ), - ); - } - } + constructor( + public api: RstestApi, + public uri: vscode.Uri, + public parentNames: string[], + public type: 'suite' | 'case', + ) {} } diff --git a/packages/vscode/src/types.ts b/packages/vscode/src/types.ts index d2c686ba2..73e09b298 100644 --- a/packages/vscode/src/types.ts +++ b/packages/vscode/src/types.ts @@ -1,23 +1,9 @@ -import type { TestFileResult, TestResult } from '@rstest/core'; +import type { RstestConfig } from '@rstest/core'; //#region master -> worker -export type WorkerInitData = { - rstestPath: string; - root: string; +export type WorkerInitOptions = RstestConfig & { configFilePath: string; + fileFilters?: string[]; + rstestPath: string; + command?: 'run' | 'list'; }; - -export type WorkerRunTestData = { - runId: string; - fileFilters: string[]; - testNamePattern?: string | RegExp; - updateSnapshot?: boolean; -}; -// #endregion - -//#region worker -> master -export type WorkerEventFinish = { - testResults: TestResult[]; - testFileResults?: TestFileResult[]; -}; -//#endregion diff --git a/packages/vscode/src/worker/index.ts b/packages/vscode/src/worker/index.ts index d545e7554..82254371f 100644 --- a/packages/vscode/src/worker/index.ts +++ b/packages/vscode/src/worker/index.ts @@ -1,11 +1,9 @@ import { pathToFileURL } from 'node:url'; import { createBirpc } from 'birpc'; -import type { RstestApi } from '../master'; -import type { WorkerInitData, WorkerRunTestData } from '../types'; +import type { TestRunReporter } from '../testRunReporter'; +import type { WorkerInitOptions } from '../types'; import { logger } from './logger'; -import { ProgressLogger, ProgressReporter } from './reporter'; - -type CommonOptions = Parameters[0]; +import { CoverageReporter, ProgressLogger, ProgressReporter } from './reporter'; // fix ESM import path issue on windows // Only URLs with a scheme in: file, data, and node are supported by the default ESM loader. @@ -14,82 +12,77 @@ const normalizeImportPath = (path: string) => { }; export class Worker { - public rstestPath!: string; - public root!: string; - public configFilePath!: string; - - public async runTest(data: WorkerRunTestData) { - logger.debug('Received runTest request', JSON.stringify(data, null, 2)); - try { - const rstest = await this.createRstest(data.runId, data.updateSnapshot); - rstest.context.fileFilters = data.fileFilters; - rstest.context.normalizedConfig.testNamePattern = data.testNamePattern; - const res = await rstest.runTests(); - logger.debug( - 'Test run completed', - JSON.stringify({ runId: data.runId, result: res }, null, 2), - ); - } catch (error) { - logger.error('Test run failed', error); - throw error; - } - } - - public async initRstest(data: WorkerInitData) { - this.rstestPath = data.rstestPath; - this.root = data.root; - this.configFilePath = data.configFilePath; - logger.debug('Initialized worker context', { - root: this.root, - rstestPath: this.rstestPath, - }); - } - - public async createRstest(runId: string, updateSnapshot?: boolean) { + private async init({ + configFilePath, + fileFilters, + rstestPath, + command = 'run', + ...overrideConfig + }: WorkerInitOptions) { const rstestModule = (await import( - normalizeImportPath(this.rstestPath) + normalizeImportPath(rstestPath) )) as typeof import('@rstest/core'); logger.debug('Loaded Rstest module'); const { createRstest, initCli } = rstestModule; - const commonOptions: CommonOptions = { - root: this.root, - config: this.configFilePath, - }; - - const initializedOptions = await initCli(commonOptions); - logger.debug('commonOptions', JSON.stringify(commonOptions, null, 2)); - const { config, configFilePath, projects } = initializedOptions; - logger.debug( - 'initializedOptions', - JSON.stringify(initializedOptions, null, 2), - ); + const initializedOptions = await initCli({ + config: configFilePath, + }); + const { projects, config: initializedConfig } = initializedOptions; + logger.debug('initializedOptions', initializedOptions); const rstest = createRstest( { config: { - ...config, - update: updateSnapshot, + ...initializedConfig, + ...overrideConfig, reporters: [ - new ProgressReporter(runId), - ['default', { logger: new ProgressLogger(runId) }], + new ProgressReporter(), + ['default', { logger: new ProgressLogger() }], ], + coverage: { + ...initializedConfig.coverage, + ...overrideConfig.coverage, + }, }, configFilePath, projects, }, - 'run', - [], + command, + fileFilters ?? [], ); return rstest; } + + public async getNormalizedConfig(options: WorkerInitOptions) { + const rstest = await this.init(options); + return { + root: rstest.context.normalizedConfig.root, + include: rstest.context.normalizedConfig.include, + exclude: rstest.context.normalizedConfig.exclude.patterns, + }; + } + + public async runTest(data: WorkerInitOptions) { + logger.debug('Received runTest request', JSON.stringify(data, null, 2)); + try { + const rstest = await this.init(data); + if (data.coverage?.enabled) { + rstest.context.normalizedConfig.coverage.reporters.push( + new CoverageReporter(), + ); + } + const res = await rstest.runTests(); + logger.debug('Test run completed', { result: res }); + } catch (error) { + logger.error('Test run failed', error); + throw error; + } + } } -export const masterApi = createBirpc< - Pick, - Worker ->(new Worker(), { +export const masterApi = createBirpc(new Worker(), { post: (data) => process.send?.(data), on: (fn) => process.on('message', fn), bind: 'functions', diff --git a/packages/vscode/src/worker/logger.ts b/packages/vscode/src/worker/logger.ts index fb471065c..612c6cb66 100644 --- a/packages/vscode/src/worker/logger.ts +++ b/packages/vscode/src/worker/logger.ts @@ -6,7 +6,7 @@ class WorkerLogger extends BaseLogger { super('worker'); } protected log(level: LogLevel, message: string) { - masterApi.log(level, message); + masterApi.log.asEvent(level, message); } } diff --git a/packages/vscode/src/worker/reporter.ts b/packages/vscode/src/worker/reporter.ts index 3ae3f9dd1..088e11e91 100644 --- a/packages/vscode/src/worker/reporter.ts +++ b/packages/vscode/src/worker/reporter.ts @@ -1,35 +1,28 @@ import { Writable } from 'node:stream'; import type { Reporter } from '@rstest/core'; +import type { + Context, + ReportBase, + ReportNode, + Visitor, +} from 'istanbul-lib-report'; +import type vscode from 'vscode'; import { masterApi } from '.'; export class ProgressReporter implements Reporter { - constructor(private runId: string) {} - onTestFileStart: Reporter['onTestFileStart'] = (param) => { - masterApi.onTestProgress(this.runId, 'onTestFileStart', param); - }; - onTestFileResult: Reporter['onTestFileResult'] = (param) => { - masterApi.onTestProgress(this.runId, 'onTestFileResult', param); - }; - onTestSuiteStart: Reporter['onTestSuiteStart'] = (param) => { - masterApi.onTestProgress(this.runId, 'onTestSuiteStart', param); - }; - onTestSuiteResult: Reporter['onTestSuiteResult'] = (param) => { - masterApi.onTestProgress(this.runId, 'onTestSuiteResult', param); - }; - onTestCaseStart: Reporter['onTestCaseStart'] = (param) => { - masterApi.onTestProgress(this.runId, 'onTestCaseStart', param); - }; - onTestCaseResult: Reporter['onTestCaseResult'] = (param) => { - masterApi.onTestProgress(this.runId, 'onTestCaseResult', param); - }; + onTestFileStart = masterApi.onTestFileStart.asEvent; + onTestFileResult = masterApi.onTestFileResult.asEvent; + onTestSuiteStart = masterApi.onTestSuiteStart.asEvent; + onTestSuiteResult = masterApi.onTestSuiteResult.asEvent; + onTestCaseStart = masterApi.onTestCaseStart.asEvent; + onTestCaseResult = masterApi.onTestCaseResult.asEvent; } export class ProgressLogger { - constructor(private runId: string) {} outputStream = new Writable({ decodeStrings: false, write: (chunk, _encoding, cb) => { - masterApi.onTestProgress(this.runId, 'onOutput', chunk); + masterApi.onOutput.asEvent(chunk); cb(null); }, }); @@ -38,3 +31,68 @@ export class ProgressLogger { }); getColumns = () => Number.POSITIVE_INFINITY; } + +export class CoverageReporter + // implements ReportBase instead of extend it, to prevent bundle istanbul-lib-report into output + implements ReportBase, Partial> +{ + // https://github.com/istanbuljs/istanbuljs/blob/28ffdbc314596bdcb3007e85d30a62372602b262/packages/istanbul-lib-report/lib/report-base.js#L11-L13 + execute(context: Context) { + context.getTree().visit(this, context); + } + + onDetail(root: ReportNode) { + const summary = root.getCoverageSummary(false); + const coverage = root.getFileCoverage(); + + const details: vscode.FileCoverageDetail[] = []; + + /** map istanbul range to vscode range */ + const mapRange = ( + range: (typeof coverage.statementMap)[string], + ): vscode.Range => + ({ + // TODO why line maybe zero? + start: { + line: (range.start.line || 1) - 1, + character: range.start.column, + }, + end: { line: (range.end.line || 1) - 1, character: range.end.column }, + }) as vscode.Range; + + for (const [key, branchMapping] of Object.entries(coverage.branchMap)) { + details.push({ + executed: coverage.b[key].some(Boolean), + location: mapRange(branchMapping.loc), + branches: branchMapping.locations.map((location, index) => ({ + executed: coverage.b[key][index], + location: mapRange(location), + })), + } satisfies vscode.StatementCoverage); + } + + for (const [key, functionMapping] of Object.entries(coverage.fnMap)) { + details.push({ + name: functionMapping.name, + executed: coverage.f[key] || 0, + location: mapRange(functionMapping.loc), + } satisfies vscode.DeclarationCoverage); + } + + for (const [key, statementRange] of Object.entries(coverage.statementMap)) { + details.push({ + branches: [], + executed: coverage.s[key] || 0, + location: mapRange(statementRange), + } satisfies vscode.StatementCoverage); + } + + masterApi.onCoverage( + coverage.path, + summary.statements, + summary.branches, + summary.functions, + details, + ); + } +} diff --git a/packages/vscode/tests/suite/config.test.ts b/packages/vscode/tests/suite/config.test.ts deleted file mode 100644 index 53b8400e8..000000000 --- a/packages/vscode/tests/suite/config.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import * as assert from 'node:assert'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import * as vscode from 'vscode'; -import { delay, getProjectItems, waitFor } from './helpers'; - -suite('Configuration Integration', () => { - test('respects rstest.testFileGlobPattern (array-only)', async () => { - const extension = vscode.extensions.getExtension('rstack.rstest'); - assert.ok(extension, 'Extension should be present'); - if (extension && !extension.isActive) { - await extension.activate(); - } - - const rstestInstance: any = extension?.exports; - const testController: vscode.TestController = - rstestInstance?.testController; - assert.ok(testController, 'Test controller should be exported'); - - const folders = vscode.workspace.workspaceFolders; - assert.ok(folders && folders.length > 0, 'Workspace folder is required'); - - const config = vscode.workspace.getConfiguration('rstest'); - - try { - await waitFor(() => { - const defaultRootsSpec = getProjectItems(testController); - assert.equal( - defaultRootsSpec.length, - 6, - 'Should discover all test files', - ); - }); - - // 1) Only discover *.spec.js/ts files - await config.update( - 'testFileGlobPattern', - ['**/*.spec.[jt]s'], - vscode.ConfigurationTarget.Workspace, - ); - - await waitFor(() => { - const rootsSpec = getProjectItems(testController); - assert.ok(rootsSpec.length >= 1, 'Should discover spec files'); - assert.ok( - rootsSpec.some((it) => it.id.endsWith('/test/jsFile.spec.js')), - 'Should include jsFile.spec.js', - ); - assert.ok( - !rootsSpec.some((it) => it.id.endsWith('/test/jsFile.spec.js.txt')), - 'Should not include jsFile.spec.js.txt', - ); - // Ensure no duplicate non-spec-only additions by counting unique suffixes - assert.ok( - !rootsSpec.some((it) => it.id.endsWith('/test/foo.test.ts')), - 'Should not include foo.test.ts when only *.spec.* is configured', - ); - }); - - // 2) Only discover *.test.* files - await config.update( - 'testFileGlobPattern', - ['**/*.test.*'], - vscode.ConfigurationTarget.Workspace, - ); - - await waitFor(() => { - const rootsTest = getProjectItems(testController); - assert.ok(rootsTest.length >= 1, 'Should discover test files'); - assert.ok( - rootsTest.some((it) => it.id.endsWith('/test/foo.test.ts')), - 'Should include foo.test.ts', - ); - assert.ok( - rootsTest.some((it) => it.id.endsWith('/test/index.test.ts')), - 'Should include index.test.ts', - ); - assert.ok( - rootsTest.some((it) => it.id.endsWith('/test/tsxFile.test.tsx')), - 'Should include tsxFile.test.tsx', - ); - assert.ok( - rootsTest.some((it) => it.id.endsWith('/test/jsxFile.test.jsx')), - 'Should include jsxFile.test.jsx', - ); - assert.ok( - !rootsTest.some((it) => it.id.endsWith('/test/jsFile.spec.js')), - 'Should not include jsFile.spec.js when only *.test.* is configured', - ); - }); - } finally { - // restore previous setting - await config.update( - 'testFileGlobPattern', - undefined, - vscode.ConfigurationTarget.Workspace, - ); - await delay(200); - // Clean up test artifacts - const fixturesVscodeDir = path.join(folders[0].uri.fsPath, '.vscode'); - if (fs.existsSync(fixturesVscodeDir)) { - fs.rmSync(fixturesVscodeDir, { recursive: true, force: true }); - } - } - }); -}); diff --git a/packages/vscode/tests/suite/helpers.ts b/packages/vscode/tests/suite/helpers.ts index 0aefcdbda..c3b0a58c4 100644 --- a/packages/vscode/tests/suite/helpers.ts +++ b/packages/vscode/tests/suite/helpers.ts @@ -65,26 +65,26 @@ export function getTestItems(collection: vscode.TestItemCollection) { } export function getProjectItems(testController: vscode.TestController) { - const workspaces = getTestItems(testController.items); - assert.equal(workspaces.length, 1); - const projects = getTestItems(workspaces[0].children); - assert.equal(projects.length, 1); - return getTestItems(projects[0].children); + const folders = getTestItems(testController.items); + assert.equal(folders.length, 1); + return getTestItems(folders[0].children); } // Helper: recursively transform a TestItem into a label-only tree. // Children are sorted by label for stable comparisons. export function toLabelTree( collection: vscode.TestItemCollection, - maxDepth = Number.POSITIVE_INFINITY, + fileOnly?: boolean, ): { label: string; children?: { label: string; children?: any[] }[]; }[] { - if (maxDepth === 0) return []; const nodes: { label: string; children?: any[] }[] = []; collection.forEach((child) => { - const children = toLabelTree(child.children, maxDepth - 1); + const children = + child.label.match(/\.(test|spec)\.[cm]?[jt]sx?/) && fileOnly + ? [] + : toLabelTree(child.children, fileOnly); // normalize to linux path style const label = child.label.replaceAll(path.sep, '/'); nodes.push(children.length ? { label, children } : { label }); diff --git a/packages/vscode/tests/suite/index.test.ts b/packages/vscode/tests/suite/index.test.ts index a230a76c5..43ad76cef 100644 --- a/packages/vscode/tests/suite/index.test.ts +++ b/packages/vscode/tests/suite/index.test.ts @@ -1,26 +1,10 @@ import * as assert from 'node:assert'; import * as vscode from 'vscode'; -import { getProjectItems, getTestItems } from './helpers'; +import { getProjectItems, toLabelTree } from './helpers'; suite('Extension Test Suite', () => { vscode.window.showInformationMessage('Start all tests.'); - // Helper: recursively transform a TestItem into a label-only tree. - // Children are sorted by label for stable comparisons. - function toLabelTree(item: vscode.TestItem): { - label: string; - children?: { label: string; children?: any[] }[]; - } { - const nodes: { label: string; children?: any[] }[] = []; - item.children.forEach((child) => { - nodes.push(toLabelTree(child)); - }); - nodes.sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0)); - return nodes.length - ? { label: item.label, children: nodes } - : { label: item.label }; - } - test('Extension should discover test items', async () => { // Wait for the extension to activate and discover tests await new Promise((resolve) => setTimeout(resolve, 2000)); @@ -82,12 +66,6 @@ suite('Extension Test Suite', () => { 'Test controller should have discovered test items', ); - const workspaceItems = getTestItems(testController.items); - assert.equal(workspaceItems[0].label, 'workspace-1'); - - const projectItems = getTestItems(workspaceItems[0].children); - assert.equal(projectItems[0].label, 'rstest.config.ts'); - const itemsArray = getProjectItems(testController); const foo = itemsArray.find((it) => it.id.endsWith('/test/foo.test.ts')); @@ -106,46 +84,40 @@ suite('Extension Test Suite', () => { assert.ok(foo, 'foo.test.ts should be discovered'); // Validate foo.test.ts structure via label-only tree - const fooTree = toLabelTree(foo!); - assert.deepStrictEqual(fooTree, { - label: 'foo.test.ts', - children: [ - { - label: 'l1', - children: [ - { - label: 'l2', - children: [ - { - label: 'l3', - children: [ - { label: 'should also return "foo1"' }, - { label: 'should return "foo1"' }, - ], - }, - { label: 'should also return "foo"' }, - { label: 'should return "foo"' }, - ], - }, - ], - }, - ], - }); + const fooTree = toLabelTree(foo.children); + assert.deepStrictEqual(fooTree, [ + { + label: 'l1', + children: [ + { + label: 'l2', + children: [ + { + label: 'l3', + children: [ + { label: 'should also return "foo1"' }, + { label: 'should return "foo1"' }, + ], + }, + { label: 'should also return "foo"' }, + { label: 'should return "foo"' }, + ], + }, + ], + }, + ]); assert.ok(index, 'index.test.ts should be discovered'); - const indexTree = toLabelTree(index!); - assert.deepStrictEqual(indexTree, { - label: 'index.test.ts', - children: [ - { - label: 'Index', - children: [ - { label: 'should add two numbers correctly' }, - { label: 'should test source code correctly' }, - ], - }, - ], - }); + const indexTree = toLabelTree(index.children); + assert.deepStrictEqual(indexTree, [ + { + label: 'Index', + children: [ + { label: 'should add two numbers correctly' }, + { label: 'should test source code correctly' }, + ], + }, + ]); assert.ok(jsSpec, 'jsFile.spec.js should be discovered'); assert.ok(jsxFile, 'tsxFile.test.tsx should be discovered'); diff --git a/packages/vscode/tests/suite/index.ts b/packages/vscode/tests/suite/index.ts index 27a647219..10f180508 100644 --- a/packages/vscode/tests/suite/index.ts +++ b/packages/vscode/tests/suite/index.ts @@ -1,8 +1,8 @@ import path from 'node:path'; -import glob from 'glob'; import Mocha from 'mocha'; +import { glob } from 'tinyglobby'; -export function run(): Promise { +export async function run() { // Force color output process.env.FORCE_COLOR = '1'; process.env.NO_COLOR = ''; @@ -16,29 +16,20 @@ export function run(): Promise { const testsRoot = path.resolve(__dirname, '..'); - return new Promise((c, e) => { - glob('**/**.test.js', { cwd: testsRoot }, (err, files) => { - if (err) { - return e(err); - } + const files = await glob('**/**.test.js', { cwd: testsRoot }); - // Add files to the test suite - for (const f of files) { - mocha.addFile(path.resolve(testsRoot, f)); - } + // Add files to the test suite + for (const f of files) { + mocha.addFile(path.resolve(testsRoot, f)); + } - try { - // Run the mocha test - mocha.run((failures) => { - if (failures > 0) { - e(new Error(`${failures} tests failed.`)); - } else { - c(); - } - }); - } catch (err) { - console.error(err); - e(err); + // Run the mocha test + return new Promise((resolve, reject) => { + mocha.run((failures) => { + if (failures > 0) { + reject(new Error(`${failures} tests failed.`)); + } else { + resolve(null); } }); }); diff --git a/packages/vscode/tests/suite/progress.test.ts b/packages/vscode/tests/suite/progress.test.ts index 6c8a503d5..aa869483d 100644 --- a/packages/vscode/tests/suite/progress.test.ts +++ b/packages/vscode/tests/suite/progress.test.ts @@ -16,11 +16,7 @@ suite('Test Progress Reporting', () => { assert.ok(testController, 'Test controller should be exported'); const item = await waitFor(() => { - const item = [ - 'workspace-1', - 'rstest.config.ts', - 'progress.test.ts', - ].reduce( + const item = ['test', 'progress.test.ts'].reduce( (item, label) => item && getTestItems(item.children).find((child) => child.label === label), @@ -75,6 +71,7 @@ suite('Test Progress Reporting', () => { rstestInstance.startTestRun( new vscode.TestRunRequest([item], undefined, rstestInstance.runProfile), + new vscode.CancellationTokenSource().token, false, mockRun, ); diff --git a/packages/vscode/tests/suite/workspace.test.ts b/packages/vscode/tests/suite/workspace.test.ts index 134428f15..f1751d87f 100644 --- a/packages/vscode/tests/suite/workspace.test.ts +++ b/packages/vscode/tests/suite/workspace.test.ts @@ -22,21 +22,16 @@ suite('Workspace discover suite', () => { // initial workspaces await waitFor(() => { - assert.deepStrictEqual(toLabelTree(testController.items, 3), [ + assert.deepStrictEqual(toLabelTree(testController.items, true), [ { - label: 'workspace-1', + label: 'test', children: [ - { - label: 'rstest.config.ts', - children: [ - { label: 'foo.test.ts' }, - { label: 'index.test.ts' }, - { label: 'jsFile.spec.js' }, - { label: 'jsxFile.test.jsx' }, - { label: 'progress.test.ts' }, - { label: 'tsxFile.test.tsx' }, - ], - }, + { label: 'foo.test.ts' }, + { label: 'index.test.ts' }, + { label: 'jsFile.spec.js' }, + { label: 'jsxFile.test.jsx' }, + { label: 'progress.test.ts' }, + { label: 'tsxFile.test.tsx' }, ], }, ]); @@ -51,12 +46,12 @@ suite('Workspace discover suite', () => { }, ); await waitFor(() => { - assert.deepStrictEqual(toLabelTree(testController.items, 3), [ + assert.deepStrictEqual(toLabelTree(testController.items, true), [ { label: 'workspace-1', children: [ { - label: 'rstest.config.ts', + label: 'test', children: [ { label: 'foo.test.ts' }, { label: 'index.test.ts' }, @@ -73,11 +68,21 @@ suite('Workspace discover suite', () => { children: [ { label: 'folder/project-2/rstest.config.ts', - children: [{ label: 'foo.test.ts' }], + children: [ + { + label: 'test', + children: [{ label: 'foo.test.ts' }], + }, + ], }, { label: 'project-1/rstest.config.ts', - children: [{ label: 'foo.test.ts' }], + children: [ + { + label: 'test', + children: [{ label: 'foo.test.ts' }], + }, + ], }, ], }, @@ -97,12 +102,12 @@ suite('Workspace discover suite', () => { path.resolve(fixturesRoot, 'workspace-2/folder/project-2/bar.config.ts'), ); await waitFor(() => { - assert.deepStrictEqual(toLabelTree(testController.items, 3), [ + assert.deepStrictEqual(toLabelTree(testController.items, true), [ { label: 'workspace-1', children: [ { - label: 'rstest.config.ts', + label: 'test', children: [ { label: 'foo.test.ts' }, { label: 'index.test.ts' }, @@ -125,7 +130,7 @@ suite('Workspace discover suite', () => { '**/foo.config.{mjs,ts,js,cjs,mts,cts}', ]); await waitFor(() => { - assert.deepStrictEqual(toLabelTree(testController.items, 3), [ + assert.deepStrictEqual(toLabelTree(testController.items, true), [ { label: 'workspace-1', }, @@ -134,7 +139,12 @@ suite('Workspace discover suite', () => { children: [ { label: 'project-1/foo.config.ts', - children: [{ label: 'foo.test.ts' }], + children: [ + { + label: 'test', + children: [{ label: 'foo.test.ts' }], + }, + ], }, ], }, @@ -147,7 +157,7 @@ suite('Workspace discover suite', () => { path.resolve(fixturesRoot, 'workspace-2/folder/project-2/foo.config.ts'), ); await waitFor(() => { - assert.deepStrictEqual(toLabelTree(testController.items, 3), [ + assert.deepStrictEqual(toLabelTree(testController.items, true), [ { label: 'workspace-1', }, @@ -156,11 +166,21 @@ suite('Workspace discover suite', () => { children: [ { label: 'folder/project-2/foo.config.ts', - children: [{ label: 'foo.test.ts' }], + children: [ + { + label: 'test', + children: [{ label: 'foo.test.ts' }], + }, + ], }, { label: 'project-1/foo.config.ts', - children: [{ label: 'foo.test.ts' }], + children: [ + { + label: 'test', + children: [{ label: 'foo.test.ts' }], + }, + ], }, ], }, @@ -181,12 +201,12 @@ suite('Workspace discover suite', () => { ); await config.update('configFileGlobPattern', undefined); await waitFor(() => { - assert.deepStrictEqual(toLabelTree(testController.items, 3), [ + assert.deepStrictEqual(toLabelTree(testController.items, true), [ { label: 'workspace-1', children: [ { - label: 'rstest.config.ts', + label: 'test', children: [ { label: 'foo.test.ts' }, { label: 'index.test.ts' }, @@ -203,11 +223,21 @@ suite('Workspace discover suite', () => { children: [ { label: 'folder/project-2/rstest.config.ts', - children: [{ label: 'foo.test.ts' }], + children: [ + { + label: 'test', + children: [{ label: 'foo.test.ts' }], + }, + ], }, { label: 'project-1/rstest.config.ts', - children: [{ label: 'foo.test.ts' }], + children: [ + { + label: 'test', + children: [{ label: 'foo.test.ts' }], + }, + ], }, ], }, @@ -218,13 +248,16 @@ suite('Workspace discover suite', () => { vscode.workspace.updateWorkspaceFolders(1, 1); await waitFor(() => { - assert.deepStrictEqual(toLabelTree(testController.items, 2), [ + assert.deepStrictEqual(toLabelTree(testController.items, true), [ { - label: 'workspace-1', + label: 'test', children: [ - { - label: 'rstest.config.ts', - }, + { label: 'foo.test.ts' }, + { label: 'index.test.ts' }, + { label: 'jsFile.spec.js' }, + { label: 'jsxFile.test.jsx' }, + { label: 'progress.test.ts' }, + { label: 'tsxFile.test.tsx' }, ], }, ]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97495ae66..74e3e2213 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -448,6 +448,9 @@ importers: '@types/istanbul-lib-coverage': specifier: ^2.0.6 version: 2.0.6 + '@types/istanbul-lib-report': + specifier: ^3.0.3 + version: 3.0.3 '@types/istanbul-reports': specifier: ^3.0.4 version: 3.0.4 @@ -584,18 +587,24 @@ importers: '@rstest/core': specifier: workspace:* version: link:../core + '@rstest/coverage-istanbul': + specifier: workspace:* + version: link:../coverage-istanbul '@swc/core': specifier: ^1.15.3 version: 1.15.3(@swc/helpers@0.5.17) - '@types/glob': - specifier: ^7.2.0 - version: 7.2.0 + '@types/istanbul-lib-report': + specifier: ^3.0.3 + version: 3.0.3 '@types/mocha': specifier: ^10.0.10 version: 10.0.10 '@types/node': specifier: ^22.16.5 version: 22.18.6 + '@types/picomatch': + specifier: ^4.0.2 + version: 4.0.2 '@types/vscode': specifier: 1.97.0 version: 1.97.0 @@ -614,15 +623,18 @@ importers: core-js-pure: specifier: ^3.47.0 version: 3.47.0 - glob: - specifier: ^7.2.3 - version: 7.2.3 mocha: specifier: ^11.7.5 version: 11.7.5 ovsx: specifier: ^0.10.7 version: 0.10.7 + picomatch: + specifier: ^4.0.3 + version: 4.0.3 + tinyglobby: + specifier: ^0.2.15 + version: 0.2.15 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -2292,9 +2304,6 @@ packages: '@types/fs-extra@11.0.4': resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==} - '@types/glob@7.2.0': - resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==} - '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -2337,10 +2346,6 @@ packages: '@types/mdx@2.0.13': resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} - '@types/minimatch@6.0.0': - resolution: {integrity: sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA==} - deprecated: This is a stub types definition. minimatch provides its own type definitions, so you do not need this installed. - '@types/mocha@10.0.10': resolution: {integrity: sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==} @@ -3544,9 +3549,6 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3614,10 +3616,6 @@ packages: engines: {node: 20 || >=22} hasBin: true - glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported - globalthis@1.0.4: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} @@ -3798,10 +3796,6 @@ packages: resolution: {integrity: sha512-XPdx9Dq4t9Qk1mTMbWONJqU7boCoumEH7fRET37HX5+khDUl3J2W6PdALxhILYlIYx2amlwYcRPp28p0tSiojg==} engines: {node: '>=18'} - inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -4695,10 +4689,6 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -7987,11 +7977,6 @@ snapshots: '@types/jsonfile': 6.1.4 '@types/node': 22.18.6 - '@types/glob@7.2.0': - dependencies: - '@types/minimatch': 6.0.0 - '@types/node': 22.18.6 - '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -8046,10 +8031,6 @@ snapshots: '@types/mdx@2.0.13': {} - '@types/minimatch@6.0.0': - dependencies: - minimatch: 10.0.3 - '@types/mocha@10.0.10': {} '@types/ms@2.1.0': {} @@ -9338,8 +9319,6 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 - fs.realpath@1.0.0: {} - fsevents@2.3.3: optional: true @@ -9408,15 +9387,6 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 2.0.0 - glob@7.2.3: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - globalthis@1.0.4: dependencies: define-properties: 1.2.1 @@ -9683,11 +9653,6 @@ snapshots: index-to-position@1.1.0: {} - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - inherits@2.0.4: {} ini@1.3.8: {} @@ -10915,8 +10880,6 @@ snapshots: path-exists@4.0.0: {} - path-is-absolute@1.0.1: {} - path-key@3.1.1: {} path-parse@1.0.7: {}