From d721e7af541dc6bf1289892c99abad59c09c3046 Mon Sep 17 00:00:00 2001 From: claneo Date: Tue, 11 Nov 2025 12:49:42 +0800 Subject: [PATCH 1/3] feat(vscode): support workspaces and monorepo --- .vscode/launch.json | 2 +- .vscode/settings.json | 1 + .vscode/tasks.json | 2 +- packages/vscode/package.json | 14 +- packages/vscode/src/config.ts | 95 +++---- packages/vscode/src/extension.ts | 239 ++++-------------- packages/vscode/src/master.ts | 68 ++--- packages/vscode/src/project.ts | 226 +++++++++++++++++ packages/vscode/src/testTree.ts | 81 +----- packages/vscode/src/types.ts | 3 +- packages/vscode/src/utils.ts | 32 +-- packages/vscode/src/worker/index.ts | 11 +- .../tests/fixtures/fixtures.code-workspace | 9 + .../{ => workspace-1}/rstest.config.ts | 0 .../fixtures/{ => workspace-1}/src/foo.ts | 0 .../fixtures/{ => workspace-1}/src/index.ts | 0 .../{ => workspace-1}/test/foo.test.ts | 0 .../{ => workspace-1}/test/index.test.ts | 0 .../{ => workspace-1}/test/jsFile.spec.js | 0 .../{ => workspace-1}/test/jsFile.spec.js.txt | 0 .../{ => workspace-1}/test/jsxFile.test.jsx | 0 .../{ => workspace-1}/test/tsxFile.test.tsx | 0 .../fixtures/{ => workspace-1}/tsconfig.json | 0 .../folder/project-2/rstest.config.ts | 3 + .../folder/project-2/test/foo.test.ts | 0 .../workspace-2/project-1/rstest.config.ts | 3 + .../workspace-2/project-1/test/foo.test.ts | 0 packages/vscode/tests/runTest.ts | 5 +- packages/vscode/tests/suite/config.test.ts | 69 +---- packages/vscode/tests/suite/helpers.ts | 68 +++++ packages/vscode/tests/suite/index.test.ts | 52 +--- packages/vscode/tests/suite/workspace.test.ts | 229 +++++++++++++++++ pnpm-lock.yaml | 15 ++ 33 files changed, 765 insertions(+), 462 deletions(-) create mode 100644 packages/vscode/src/project.ts create mode 100644 packages/vscode/tests/fixtures/fixtures.code-workspace rename packages/vscode/tests/fixtures/{ => workspace-1}/rstest.config.ts (100%) rename packages/vscode/tests/fixtures/{ => workspace-1}/src/foo.ts (100%) rename packages/vscode/tests/fixtures/{ => workspace-1}/src/index.ts (100%) rename packages/vscode/tests/fixtures/{ => workspace-1}/test/foo.test.ts (100%) rename packages/vscode/tests/fixtures/{ => workspace-1}/test/index.test.ts (100%) rename packages/vscode/tests/fixtures/{ => workspace-1}/test/jsFile.spec.js (100%) rename packages/vscode/tests/fixtures/{ => workspace-1}/test/jsFile.spec.js.txt (100%) rename packages/vscode/tests/fixtures/{ => workspace-1}/test/jsxFile.test.jsx (100%) rename packages/vscode/tests/fixtures/{ => workspace-1}/test/tsxFile.test.tsx (100%) rename packages/vscode/tests/fixtures/{ => workspace-1}/tsconfig.json (100%) create mode 100644 packages/vscode/tests/fixtures/workspace-2/folder/project-2/rstest.config.ts create mode 100644 packages/vscode/tests/fixtures/workspace-2/folder/project-2/test/foo.test.ts create mode 100644 packages/vscode/tests/fixtures/workspace-2/project-1/rstest.config.ts create mode 100644 packages/vscode/tests/fixtures/workspace-2/project-1/test/foo.test.ts create mode 100644 packages/vscode/tests/suite/workspace.test.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index b607f255..7c2d9bcb 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -15,7 +15,7 @@ "${workspaceFolder}/packages/vscode/sample" ], "outFiles": ["${workspaceFolder}/packages/vscode/dist/**/*.js"], - "preLaunchTask": "npm: build:local", + "preLaunchTask": "npm: watch:local", "autoAttachChildProcesses": true } ] diff --git a/.vscode/settings.json b/.vscode/settings.json index 7e7b7536..970f25af 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,6 +14,7 @@ "**/.DS_Store": true }, "editor.codeActionsOnSave": { + "source.organizeImports": "never", "source.organizeImports.biome": "explicit" }, "[javascript]": { diff --git a/.vscode/tasks.json b/.vscode/tasks.json index d77a53a6..15b85d40 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -5,7 +5,7 @@ "tasks": [ { "type": "npm", - "script": "build:local", + "script": "watch:local", "path": "packages/vscode", "problemMatcher": "$tsc-watch", "isBackground": true, diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 9601386d..9d422acc 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -45,6 +45,16 @@ "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", "type": "string" + }, + "rstest.configFileGlobPattern": { + "description": "Glob patterns used to discover config files. Must be an array of strings.", + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "**/rstest.config.{mjs,ts,js,cjs,mts,cts}" + ] } } } @@ -59,6 +69,7 @@ "test": "npm run test:unit && npm run test:e2e", "typecheck": "tsc --noEmit", "watch": "rslib build --watch", + "watch:local": "cross-env SOURCEMAP=true rslib build --watch", "package:vsce": "npm run build && vsce package" }, "devDependencies": { @@ -77,6 +88,7 @@ "glob": "^7.2.3", "mocha": "^11.7.4", "ovsx": "^0.10.6", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "valibot": "^1.1.0" } } diff --git a/packages/vscode/src/config.ts b/packages/vscode/src/config.ts index e18282f2..9f51d63c 100644 --- a/packages/vscode/src/config.ts +++ b/packages/vscode/src/config.ts @@ -1,66 +1,69 @@ +import * as v from 'valibot'; import vscode from 'vscode'; // Centralized configuration types for the extension. // Add new keys here to extend configuration in a type-safe way. -export type ExtensionConfig = { +const configSchema = v.object({ // Glob patterns that determine which files are considered tests. // Must be an array of strings. - testFileGlobPattern: string[]; - // 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?: string; -}; - -export const defaultConfig: ExtensionConfig = { - // https://code.visualstudio.com/docs/editor/glob-patterns - testFileGlobPattern: [ + 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), + configFileGlobPattern: v.fallback(v.array(v.string()), [ + '**/rstest.config.{mjs,ts,js,cjs,mts,cts}', + ]), +}); + +export type ExtensionConfig = v.InferOutput; -// Type-safe getter for a single config value with priority: -// workspaceFolder > workspace > user (global) > default. +// Type-safe getter for a single config value export function getConfigValue( key: K, - folder?: vscode.WorkspaceFolder, + scope?: vscode.ConfigurationScope | null, ): ExtensionConfig[K] { - const section = vscode.workspace.getConfiguration('rstest', folder); - const inspected = section.inspect(key); - - // Priority order (highest first): folder, workspace, user, default - const value = - inspected?.workspaceFolderValue ?? - inspected?.workspaceValue ?? - inspected?.globalValue ?? - inspected?.defaultValue ?? - defaultConfig[key]; - - if (key === 'testFileGlobPattern') { - const v = value as unknown; - return (isStringArray(v) ? v : defaultConfig[key]) as ExtensionConfig[K]; - } - - if (key === 'rstestPackagePath') { - const v = value as unknown; - return ( - typeof v === 'string' && v.trim().length > 0 ? v : undefined - ) as ExtensionConfig[K]; - } - - return value as ExtensionConfig[K]; + const value = vscode.workspace.getConfiguration('rstest', scope).get(key); + return v.parse(configSchema.entries[key], value) as ExtensionConfig[K]; } -function isStringArray(v: unknown): v is string[] { - return Array.isArray(v) && v.every((x) => typeof x === 'string'); +// Convenience to get a full, normalized config object at the given scope. +export function getConfig( + scope?: vscode.ConfigurationScope | null, +): ExtensionConfig { + return Object.fromEntries( + Object.keys(configSchema.entries).map((key) => [ + key, + getConfigValue(key as keyof ExtensionConfig, scope), + ]), + ) as ExtensionConfig; } -// Convenience to get a full, normalized config object at the given scope. -export function getConfig(folder?: vscode.WorkspaceFolder): ExtensionConfig { +export function watchConfigValue( + key: K, + scope: vscode.ConfigurationScope | null | undefined, + listener: ( + value: ExtensionConfig[K], + token: vscode.CancellationToken, + ) => void, +): vscode.Disposable { + let cancelSource = new vscode.CancellationTokenSource(); + listener(getConfigValue(key, scope), cancelSource.token); + const disposable = vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration(`rstest.${key}`, scope ?? undefined)) { + cancelSource.cancel(); + cancelSource = new vscode.CancellationTokenSource(); + listener(getConfigValue(key, scope), cancelSource.token); + } + }); return { - testFileGlobPattern: getConfigValue('testFileGlobPattern', folder), - rstestPackagePath: getConfigValue('rstestPackagePath', folder), - } satisfies ExtensionConfig; + dispose: () => { + disposable.dispose(); + cancelSource.cancel(); + }, + }; } diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 95b9ed1b..b55a4bb0 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -1,35 +1,24 @@ import vscode from 'vscode'; import { logger } from './logger'; -import { RstestApi } from './master'; +import { WorkspaceManager } from './project'; import { gatherTestItems, getContentFromFilesystem, - scanAllTestFiles, TestCase, TestFile, testData, } from './testTree'; -import { - getWorkspaceTestPatterns, - isTestFile, - shouldIgnorePath, -} from './utils'; export async function activate(context: vscode.ExtensionContext) { const rstest = new Rstest(context); - await rstest.initialize(); return rstest; } class Rstest { private context: vscode.ExtensionContext; private ctrl: vscode.TestController; - private fileChangedEmitter: vscode.EventEmitter; - private watchingTests: Map< - vscode.TestItem | 'ALL', - vscode.TestRunProfile | undefined - >; - private api: RstestApi; + private workspaces = new Map(); + private workspaceWatcher?: vscode.Disposable; // Add getter to access the test controller for testing get testController() { @@ -41,70 +30,8 @@ class Rstest { this.ctrl = vscode.tests.createTestController('rstest', 'Rstest'); context.subscriptions.push(this.ctrl); - this.fileChangedEmitter = new vscode.EventEmitter(); - this.watchingTests = new Map< - vscode.TestItem | 'ALL', - vscode.TestRunProfile | undefined - >(); - - this.setupEventHandlers(context); + this.startScanWorkspaces(); this.setupTestController(); - this.api = new RstestApi(); - this.api.createChildProcess(); - } - - async initialize() { - await scanAllTestFiles(this.ctrl); - } - - private setupEventHandlers(context: vscode.ExtensionContext) { - this.fileChangedEmitter.event((uri) => { - if (this.watchingTests.has('ALL')) { - this.startTestRun( - new vscode.TestRunRequest( - undefined, - undefined, - this.watchingTests.get('ALL'), - true, - ), - ); - return; - } - - const include: vscode.TestItem[] = []; - let profile: vscode.TestRunProfile | undefined; - for (const [item, thisProfile] of this.watchingTests) { - const cast = item as vscode.TestItem; - if (cast.uri?.toString() === uri.toString()) { - include.push(cast); - profile = thisProfile; - } - } - - if (include.length) { - this.startTestRun( - new vscode.TestRunRequest(include, undefined, profile, true), - ); - } - }); - - for (const document of vscode.workspace.textDocuments) { - this.updateNodeForDocument(document); - } - - context.subscriptions.push( - vscode.workspace.onDidOpenTextDocument((document) => - this.updateNodeForDocument(document), - ), - vscode.workspace.onDidChangeTextDocument((e) => - this.updateNodeForDocument(e.document), - ), - vscode.workspace.onDidDeleteFiles((e) => { - for (const uri of e.files) { - this.ctrl.items.delete(uri.toString()); - } - }), - ); } private setupTestController() { @@ -122,21 +49,13 @@ class Rstest { return this.startTestRun(request); }; - this.ctrl.refreshHandler = async () => { - await Promise.all( - getWorkspaceTestPatterns().map(({ pattern }) => { - return findInitialFiles(this.ctrl, pattern); - }), - ); - }; - const _runProfile = this.ctrl.createRunProfile( 'Run Tests', vscode.TestRunProfileKind.Run, runHandler, true, undefined, - true, + false, ); const coverageProfile = this.ctrl.createRunProfile( @@ -145,7 +64,7 @@ class Rstest { runHandler, true, undefined, - true, + false, ); coverageProfile.loadDetailedCoverage = async (_testRun, coverage) => { @@ -157,23 +76,49 @@ class Rstest { return []; }; + } - this.ctrl.resolveHandler = async (item) => { - if (!item) { - // this.initialize(this.context); - this.context.subscriptions.push( - ...startWatchingWorkspace(this.ctrl, this.fileChangedEmitter), - ); - // Ensure all test files are discovered and parsed at startup - // await scanAllTestFiles(this.ctrl); - return; - } + private startScanWorkspaces() { + // dispose previous data on refresh + for (const [workspacePath, workspace] of this.workspaces) { + workspace.dispose(); + this.workspaces.delete(workspacePath); + } + // collect all workspaces + for (const workspace of vscode.workspace.workspaceFolders || []) { + this.handleAddWorkspace(workspace); + } + // start watching workspace change + if (!this.workspaceWatcher) { + this.workspaceWatcher = vscode.workspace.onDidChangeWorkspaceFolders( + (e) => { + for (const added of e.added) { + this.handleAddWorkspace(added); + } + for (const removed of e.removed) { + this.handleRemoveWorkspace(removed); + } + }, + ); + } + } - const data = testData.get(item); - if (data instanceof TestFile) { - await data.updateFromDisk(this.ctrl, item); - } - }; + private async handleAddWorkspace(workspaceFolder: vscode.WorkspaceFolder) { + // ignore virtual file system + if (workspaceFolder.uri.scheme !== 'file') return; + + this.workspaces.set( + workspaceFolder.uri.toString(), + new WorkspaceManager(workspaceFolder, this.ctrl), + ); + } + + private handleRemoveWorkspace(workspaceFolder: vscode.WorkspaceFolder) { + const workspacePath = workspaceFolder.uri.toString(); + const workspace = this.workspaces.get(workspacePath); + if (!workspace) return; + workspace.dispose(); + this.workspaces.delete(workspaceFolder.uri.toString()); } private startTestRun = (request: vscode.TestRunRequest) => { @@ -195,7 +140,7 @@ class Rstest { if (data instanceof TestCase) { run.enqueued(test); run.started(test); - await data.run(test, run, this.api); + await data.run(test, run); run.appendOutput(`Completed ${test.id}\r\n`); } else if (data instanceof TestFile) { if (!data.didResolve) { @@ -205,10 +150,10 @@ class Rstest { // Run all tests for this file at once run.enqueued(test); run.started(test); - await data.run(test, run, this.api, this.ctrl); + await data.run(test, run, this.ctrl); } else { // Process child tests - await discoverTests(gatherTestItems(test.children)); + await discoverTests(gatherTestItems(test.children, false)); } if ( @@ -238,95 +183,13 @@ class Rstest { } }; - discoverTests(request.include ?? gatherTestItems(this.ctrl.items)) + discoverTests(request.include ?? gatherTestItems(this.ctrl.items, false)) .then(() => run.end()) .catch((error) => { logger.error('Error running tests:', error); run.end(); }); }; - - private updateNodeForDocument(e: vscode.TextDocument) { - if (e.uri.scheme !== 'file') { - return; - } - - if (isTestFilePath(e.uri)) { - const { file, data } = getOrCreateFile(this.ctrl, e.uri); - data.updateFromContents(this.ctrl, e.getText(), file); - } - - return; - } -} - -function getOrCreateFile(controller: vscode.TestController, uri: vscode.Uri) { - const existing = controller.items.get(uri.toString()); - if (existing) { - return { file: existing, data: testData.get(existing) as TestFile }; - } - - const file = controller.createTestItem( - uri.toString(), - uri.path.split('/').pop()!, - uri, - ); - controller.items.add(file); - - const data = new TestFile(); - testData.set(file, data); - - file.canResolveChildren = true; - return { file, data }; -} - -// gatherTestItems is provided by testTree.ts - -async function findInitialFiles( - controller: vscode.TestController, - pattern: vscode.GlobPattern, -) { - for (const file of await vscode.workspace.findFiles(pattern)) { - const path = file.fsPath.toString(); - const shouldIgnore = shouldIgnorePath(path); - if (!shouldIgnore) { - getOrCreateFile(controller, file); - } - } -} - -function startWatchingWorkspace( - controller: vscode.TestController, - fileChangedEmitter: vscode.EventEmitter, -) { - return getWorkspaceTestPatterns().map(({ pattern }) => { - const watcher = vscode.workspace.createFileSystemWatcher(pattern); - - watcher.onDidCreate((uri) => { - getOrCreateFile(controller, uri); - fileChangedEmitter.fire(uri); - }); - - watcher.onDidChange(async (uri) => { - const { file, data } = getOrCreateFile(controller, uri); - if (data.didResolve) { - await data.updateFromDisk(controller, file); - } - fileChangedEmitter.fire(uri); - }); - - watcher.onDidDelete((uri) => { - controller.items.delete(uri.toString()); - }); - - findInitialFiles(controller, pattern); - - return watcher; - }); -} - -function isTestFilePath(uri: vscode.Uri): boolean { - return isTestFile(uri.path.split('/').pop() || uri.path); } class RstestFileCoverage extends vscode.FileCoverage { diff --git a/packages/vscode/src/master.ts b/packages/vscode/src/master.ts index 31bda8e9..bfeb09bc 100644 --- a/packages/vscode/src/master.ts +++ b/packages/vscode/src/master.ts @@ -1,4 +1,4 @@ -import { spawn } from 'node:child_process'; +import { type ChildProcess, spawn } from 'node:child_process'; import path, { dirname } from 'node:path'; import { createBirpc } from 'birpc'; import vscode from 'vscode'; @@ -11,21 +11,22 @@ import type { Worker } from './worker'; export class RstestApi { public worker: Pick | null = null; + private childProcess: ChildProcess | null = null; private versionMismatchWarned = false; - public resolveRstestPath(): { cwd: string; rstestPath: string }[] { + constructor( + private workspace: vscode.WorkspaceFolder, + private cwd: string, + private configFilePath: string, + ) {} + + private resolveRstestPath(): string { // TODO: support Yarn PnP try { - // TODO: use 0 temporarily. - const workspace = vscode.workspace.workspaceFolders?.[0]; - if (!workspace) { - throw new Error('No workspace found'); - } - // Check if user configured a custom package path (last resort fix) let configuredPackagePath = getConfigValue( 'rstestPackagePath', - workspace, + this.workspace, ); if (configuredPackagePath) { @@ -33,7 +34,7 @@ export class RstestApi { configuredPackagePath = configuredPackagePath.replace( // biome-ignore lint: This is a VS Code config placeholder string '${workspaceFolder}', - workspace.uri.fsPath, + this.workspace.uri.fsPath, ); // Validate that the path points to package.json if (!configuredPackagePath.endsWith('package.json')) { @@ -44,7 +45,7 @@ export class RstestApi { // User provided a custom path to package.json configuredPackagePath = path.isAbsolute(configuredPackagePath) ? configuredPackagePath - : path.resolve(workspace.uri.fsPath, configuredPackagePath); + : path.resolve(this.workspace.uri.fsPath, configuredPackagePath); logger.debug( 'Using configured rstestPackagePath:', @@ -55,7 +56,7 @@ export class RstestApi { const nodeExport = require.resolve( configuredPackagePath ? dirname(configuredPackagePath) : '@rstest/core', { - paths: [workspace.uri.fsPath], + paths: [this.cwd], }, ); @@ -64,7 +65,7 @@ export class RstestApi { corePackageJsonPath = require.resolve( configuredPackagePath || '@rstest/core/package.json', { - paths: [workspace.uri.fsPath], + paths: [this.cwd], }, ); } catch (e) { @@ -72,7 +73,7 @@ export class RstestApi { 'Failed to resolve @rstest/core/package.json. Please upgrade @rstest/core to the latest version.', ); logger.error('Failed to resolve @rstest/core/package.json', e); - return []; + return ''; } const corePackageJson = require(corePackageJsonPath) as { version?: string; @@ -95,12 +96,7 @@ export class RstestApi { ); } - return [ - { - cwd: workspace.uri.fsPath, - rstestPath: nodeExport, - }, - ]; + return nodeExport; } catch (e) { vscode.window.showErrorMessage((e as any).toString()); throw e; @@ -141,19 +137,18 @@ export class RstestApi { } public async createChildProcess() { - const { cwd, rstestPath } = this.resolveRstestPath()[0]; - if (!cwd || !rstestPath) { - logger.error('Failed to resolve rstest path or cwd'); + const rstestPath = this.resolveRstestPath(); + if (!rstestPath) { + logger.error('Failed to resolve rstest path'); return; } - const execArgv: string[] = []; const workerPath = path.resolve(__dirname, 'worker.js'); logger.debug('Spawning worker process', { workerPath, }); const rstestProcess = spawn('node', [...execArgv, workerPath], { - cwd, + cwd: this.cwd, stdio: ['pipe', 'pipe', 'pipe', 'ipc'], serialization: 'advanced', env: { @@ -161,6 +156,7 @@ export class RstestApi { TEST: 'true', }, }); + this.childProcess = rstestProcess; rstestProcess.stdout?.on('data', (d) => { const content = d.toString(); @@ -173,22 +169,34 @@ export class RstestApi { }); this.worker = createBirpc(this, { - post: (data) => rstestProcess.send(data), + // 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', }); - await this.worker.initRstest({ cwd, rstestPath }); - logger.debug('Sent init payload to worker', { cwd, rstestPath }); + await this.worker.initRstest({ + root: this.cwd, + rstestPath, + configFilePath: this.configFilePath, + }); + logger.debug('Sent init payload to worker', { + root: this.cwd, + rstestPath, + configFilePath: this.configFilePath, + }); rstestProcess.on('exit', (code, signal) => { logger.debug('Worker process exited', { code, signal }); }); } - public async createRstestWorker() {} - async log(level: LogLevel, message: string) { logger[level](message); } + + public dispose() { + this.childProcess?.kill(); + this.childProcess = null; + } } diff --git a/packages/vscode/src/project.ts b/packages/vscode/src/project.ts new file mode 100644 index 00000000..d4a26e0f --- /dev/null +++ b/packages/vscode/src/project.ts @@ -0,0 +1,226 @@ +import path from 'node:path'; +import * as vscode from 'vscode'; +import { watchConfigValue } from './config'; +import { RstestApi } from './master'; +import { TestFile, testData } from './testTree'; +import { shouldIgnoreUri } from './utils'; + +export class WorkspaceManager implements vscode.Disposable { + private projects = new Map(); + private workspacePath: string; + 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, + ); + testController.items.add(this.testItem); + this.configValueWatcher = this.startWatchingWorkspace(); + } + public dispose() { + this.testController.items.delete(this.workspacePath); + for (const project of this.projects.values()) { + project.dispose(); + } + this.configValueWatcher.dispose(); + } + private startWatchingWorkspace() { + return watchConfigValue( + 'configFileGlobPattern', + this.workspaceFolder, + async (globs, token) => { + const patterns = globs.map( + (glob) => new vscode.RelativePattern(this.workspaceFolder, glob), + ); + + // find all config file + const files = ( + await Promise.all( + patterns.map((pattern) => + vscode.workspace.findFiles( + pattern, + '**/node_modules/**', + undefined, + token, + ), + ), + ) + ).flat(); + + const visited = new Set(); + for (const file of files) { + this.handleAddConfigFile(file); + visited.add(file.toString()); + } + // remove outdated items after glob configuration changed + for (const [configFilePath, project] of this.projects) { + if (!visited.has(configFilePath)) { + project.dispose(); + this.projects.delete(configFilePath); + } + } + + // 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, + ); + token.onCancellationRequested(() => watcher.dispose()); + watcher.onDidCreate((file) => this.handleAddConfigFile(file)); + watcher.onDidDelete((file) => this.handleRemoveConfigFile(file)); + } + }, + ); + } + private handleAddConfigFile(configFileUri: vscode.Uri) { + const configFilePath = configFileUri.toString(); + if (this.projects.has(configFilePath)) return; + const project = new Project( + this.workspaceFolder, + configFileUri, + this.testController, + this.testItem, + ); + this.projects.set(configFilePath, project); + } + private handleRemoveConfigFile(configFileUri: vscode.Uri) { + const configFilePath = configFileUri.toString(); + const project = this.projects.get(configFilePath); + if (!project) return; + project.dispose(); + this.projects.delete(configFilePath); + } +} + +// 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; + constructor( + workspaceFolder: vscode.WorkspaceFolder, + private configFileUri: vscode.Uri, + private testController: vscode.TestController, + private workspaceTestItem: vscode.TestItem, + ) { + // TODO get root from config + this.root = configFileUri.with({ path: path.dirname(configFileUri.path) }); + this.api = new RstestApi( + workspaceFolder, + this.root.fsPath, + configFileUri.fsPath, + ); + + // 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, + ); + workspaceTestItem.children.add(this.projectTestItem); + // TODO catch and set error + this.api.createChildProcess(); + + this.configValueWatcher = this.startWatchingWorkspace(); + } + dispose() { + this.workspaceTestItem.children.delete(this.configFileUri.toString()); + this.configValueWatcher.dispose(); + this.api.dispose(); + } + 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), + ); + + const files = ( + await Promise.all( + patterns.map((pattern) => + vscode.workspace.findFiles( + pattern, + '**/node_modules/**', + undefined, + token, + ), + ), + ) + ).flat(); + + const visited = new Set(); + for (const uri of files) { + if (shouldIgnoreUri(uri)) continue; + this.updateOrCreateFile(uri); + visited.add(uri.toString()); + } + + // remove outdated items after glob configuration changed + this.projectTestItem.children.forEach((testItem) => { + if (!visited.has(testItem.id)) { + this.projectTestItem.children.delete(testItem.id); + } + }); + + // start watching test file change + for (const pattern of patterns) { + const watcher = vscode.workspace.createFileSystemWatcher( + pattern, + false, + false, + false, + ); + + 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()); + }); + } + }, + ); + } + // TODO pass cancellation token to updateFromDisk + private updateOrCreateFile(uri: vscode.Uri) { + const existing = this.projectTestItem.children.get(uri.toString()); + if (existing) { + (testData.get(existing) as TestFile).updateFromDisk( + this.testController, + existing, + ); + } else { + const file = this.testController.createTestItem( + uri.toString(), + path.basename(uri.path), + uri, + ); + this.projectTestItem.children.add(file); + + const data = new TestFile(this.api); + testData.set(file, data); + data.updateFromDisk(this.testController, file); + + file.canResolveChildren = true; + } + } +} diff --git a/packages/vscode/src/testTree.ts b/packages/vscode/src/testTree.ts index 0c6a7611..77f812df 100644 --- a/packages/vscode/src/testTree.ts +++ b/packages/vscode/src/testTree.ts @@ -4,7 +4,6 @@ import vscode from 'vscode'; import { logger } from './logger'; import type { RstestApi } from './master'; import { parseTestFile } from './parserTest'; -import { getWorkspaceTestPatterns, shouldIgnorePath } from './utils'; const textDecoder = new TextDecoder('utf-8'); @@ -22,11 +21,14 @@ export const getContentFromFilesystem = async (uri: vscode.Uri) => { } }; -export function gatherTestItems(collection: vscode.TestItemCollection) { +export function gatherTestItems( + collection: vscode.TestItemCollection, + recursive = true, +) { const items: vscode.TestItem[] = []; collection.forEach((item) => { items.push(item); - if (item.children.size > 0) { + if (recursive && item.children.size > 0) { gatherTestItems(item.children).forEach((child) => { items.push(child); }); @@ -264,67 +266,11 @@ const applyResultsToTestCases = ( }; }; -/** - * Scans the workspace for all test files and ensures they exist as root - * TestItems on the provided controller. Also parses their contents so the - * initial tree includes discovered tests without requiring files to open. - */ -export async function scanAllTestFiles( - controller: vscode.TestController, -): Promise { - const patterns = getWorkspaceTestPatterns(); - if (!patterns.length) return; - - const uris = new Set(); - - // Collect and dedupe all matching files across workspace folders - for (const { pattern } of patterns) { - const found = await vscode.workspace.findFiles(pattern); - for (const f of found) { - const shouldIgnore = shouldIgnorePath(f.fsPath); - if (!shouldIgnore) { - uris.add(f.toString()); - } - } - } - - const tasks: Promise[] = []; - - for (const uriStr of uris) { - const uri = vscode.Uri.parse(uriStr); - let item = controller.items.get(uriStr); - let fileData: TestFile | undefined; - - if (!item) { - item = controller.createTestItem( - uriStr, - uri.path.split('/').pop() || uriStr, - uri, - ); - controller.items.add(item); - fileData = new TestFile(); - testData.set(item, fileData); - item.canResolveChildren = true; - } else { - const data = testData.get(item); - if (data instanceof TestFile) { - fileData = data; - } else { - fileData = new TestFile(); - testData.set(item, fileData); - } - } - - // Parse immediately so children are available in the tree - tasks.push(fileData.updateFromDisk(controller, item)); - } - - await Promise.all(tasks); -} - export class TestFile { public didResolve = false; + constructor(private api: RstestApi) {} + public async updateFromDisk( controller: vscode.TestController, item: vscode.TestItem, @@ -386,6 +332,7 @@ export class TestFile { testType, vscodeRange, name, + this.api, ); const testItem = controller.createTestItem( @@ -412,7 +359,6 @@ export class TestFile { async run( item: vscode.TestItem, run: vscode.TestRun, - api: RstestApi, controller?: vscode.TestController, ): Promise { if (!this.didResolve && controller) { @@ -423,7 +369,7 @@ export class TestFile { run.appendOutput(`Running all tests in file ${item.id}\r\n`); try { - const rstestResults = await api.runFileTests(item); + const rstestResults = await this.api.runFileTests(item); const results = rstestResults?.testResults ?? []; const { @@ -484,6 +430,7 @@ export class TestCase { public testType: string, private range: vscode.Range, private name: string, + private api: RstestApi, ) {} getId() { @@ -494,16 +441,12 @@ export class TestCase { return this.name; } - async run( - item: vscode.TestItem, - run: vscode.TestRun, - api: RstestApi, - ): Promise { + async run(item: vscode.TestItem, run: vscode.TestRun): Promise { // Match messaging and behavior from extension.ts run.appendOutput(`Running test case ${item.id}\r\n`); try { - const rstestResults = await api.runTest(item); + const rstestResults = await this.api.runTest(item); if (rstestResults && rstestResults.testResults.length > 0) { const { diff --git a/packages/vscode/src/types.ts b/packages/vscode/src/types.ts index 932a6193..65195e8d 100644 --- a/packages/vscode/src/types.ts +++ b/packages/vscode/src/types.ts @@ -3,7 +3,8 @@ import type { TestFileResult, TestResult } from '@rstest/core'; //#region master -> worker export type WorkerInitData = { rstestPath: string; - cwd: string; + root: string; + configFilePath: string; }; export type WorkerRunTestData = { diff --git a/packages/vscode/src/utils.ts b/packages/vscode/src/utils.ts index babc3f71..8ae7673d 100644 --- a/packages/vscode/src/utils.ts +++ b/packages/vscode/src/utils.ts @@ -1,11 +1,11 @@ -import vscode from 'vscode'; -import { getConfigValue } from './config'; +import type * as vscode from 'vscode'; -export function shouldIgnorePath(path: string) { +export function shouldIgnoreUri(uri: vscode.Uri) { return ( - path.includes('/node_modules/') || - path.includes('/.git/') || - path.endsWith('.git') + uri.scheme !== 'file' || + uri.path.includes('/node_modules/') || + uri.path.includes('/.git/') || + uri.path.endsWith('.git') ); } @@ -14,26 +14,6 @@ export function isTestFile(filename: string): boolean { return regex.test(filename); } -export type WorkspaceTestPattern = { - workspaceFolder: vscode.WorkspaceFolder; - pattern: vscode.GlobPattern; -}; - -export function getWorkspaceTestPatterns(): WorkspaceTestPattern[] { - const folders = vscode.workspace.workspaceFolders; - if (!folders) { - return []; - } - - return folders.flatMap((workspaceFolder) => { - const globs = getConfigValue('testFileGlobPattern', workspaceFolder); - return globs.map((glob) => ({ - workspaceFolder, - pattern: new vscode.RelativePattern(workspaceFolder, glob), - })); - }); -} - export function promiseWithTimeout( promise: Promise, timeout: number, diff --git a/packages/vscode/src/worker/index.ts b/packages/vscode/src/worker/index.ts index 239727ab..d7a78e6a 100644 --- a/packages/vscode/src/worker/index.ts +++ b/packages/vscode/src/worker/index.ts @@ -19,7 +19,8 @@ const normalizeImportPath = (path: string) => { export class Worker { public rstestPath!: string; - public cwd!: string; + public root!: string; + public configFilePath!: string; public async runTest(data: WorkerRunTestData) { logger.debug('Received runTest request', JSON.stringify(data, null, 2)); @@ -45,9 +46,10 @@ export class Worker { public async initRstest(data: WorkerInitData) { this.rstestPath = data.rstestPath; - this.cwd = data.cwd; + this.root = data.root; + this.configFilePath = data.configFilePath; logger.debug('Initialized worker context', { - cwd: this.cwd, + root: this.root, rstestPath: this.rstestPath, }); } @@ -62,7 +64,8 @@ export class Worker { const { createRstest, initCli } = rstestModule; const commonOptions: CommonOptions = { - root: this.cwd, + root: this.root, + config: this.configFilePath, }; const initializedOptions = await initCli(commonOptions); diff --git a/packages/vscode/tests/fixtures/fixtures.code-workspace b/packages/vscode/tests/fixtures/fixtures.code-workspace new file mode 100644 index 00000000..397c1ac2 --- /dev/null +++ b/packages/vscode/tests/fixtures/fixtures.code-workspace @@ -0,0 +1,9 @@ +{ + "folders": [ + { + "path": "workspace-1" + } + ], + "settings": { + } +} diff --git a/packages/vscode/tests/fixtures/rstest.config.ts b/packages/vscode/tests/fixtures/workspace-1/rstest.config.ts similarity index 100% rename from packages/vscode/tests/fixtures/rstest.config.ts rename to packages/vscode/tests/fixtures/workspace-1/rstest.config.ts diff --git a/packages/vscode/tests/fixtures/src/foo.ts b/packages/vscode/tests/fixtures/workspace-1/src/foo.ts similarity index 100% rename from packages/vscode/tests/fixtures/src/foo.ts rename to packages/vscode/tests/fixtures/workspace-1/src/foo.ts diff --git a/packages/vscode/tests/fixtures/src/index.ts b/packages/vscode/tests/fixtures/workspace-1/src/index.ts similarity index 100% rename from packages/vscode/tests/fixtures/src/index.ts rename to packages/vscode/tests/fixtures/workspace-1/src/index.ts diff --git a/packages/vscode/tests/fixtures/test/foo.test.ts b/packages/vscode/tests/fixtures/workspace-1/test/foo.test.ts similarity index 100% rename from packages/vscode/tests/fixtures/test/foo.test.ts rename to packages/vscode/tests/fixtures/workspace-1/test/foo.test.ts diff --git a/packages/vscode/tests/fixtures/test/index.test.ts b/packages/vscode/tests/fixtures/workspace-1/test/index.test.ts similarity index 100% rename from packages/vscode/tests/fixtures/test/index.test.ts rename to packages/vscode/tests/fixtures/workspace-1/test/index.test.ts diff --git a/packages/vscode/tests/fixtures/test/jsFile.spec.js b/packages/vscode/tests/fixtures/workspace-1/test/jsFile.spec.js similarity index 100% rename from packages/vscode/tests/fixtures/test/jsFile.spec.js rename to packages/vscode/tests/fixtures/workspace-1/test/jsFile.spec.js diff --git a/packages/vscode/tests/fixtures/test/jsFile.spec.js.txt b/packages/vscode/tests/fixtures/workspace-1/test/jsFile.spec.js.txt similarity index 100% rename from packages/vscode/tests/fixtures/test/jsFile.spec.js.txt rename to packages/vscode/tests/fixtures/workspace-1/test/jsFile.spec.js.txt diff --git a/packages/vscode/tests/fixtures/test/jsxFile.test.jsx b/packages/vscode/tests/fixtures/workspace-1/test/jsxFile.test.jsx similarity index 100% rename from packages/vscode/tests/fixtures/test/jsxFile.test.jsx rename to packages/vscode/tests/fixtures/workspace-1/test/jsxFile.test.jsx diff --git a/packages/vscode/tests/fixtures/test/tsxFile.test.tsx b/packages/vscode/tests/fixtures/workspace-1/test/tsxFile.test.tsx similarity index 100% rename from packages/vscode/tests/fixtures/test/tsxFile.test.tsx rename to packages/vscode/tests/fixtures/workspace-1/test/tsxFile.test.tsx diff --git a/packages/vscode/tests/fixtures/tsconfig.json b/packages/vscode/tests/fixtures/workspace-1/tsconfig.json similarity index 100% rename from packages/vscode/tests/fixtures/tsconfig.json rename to packages/vscode/tests/fixtures/workspace-1/tsconfig.json diff --git a/packages/vscode/tests/fixtures/workspace-2/folder/project-2/rstest.config.ts b/packages/vscode/tests/fixtures/workspace-2/folder/project-2/rstest.config.ts new file mode 100644 index 00000000..9ee3cbab --- /dev/null +++ b/packages/vscode/tests/fixtures/workspace-2/folder/project-2/rstest.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({}); diff --git a/packages/vscode/tests/fixtures/workspace-2/folder/project-2/test/foo.test.ts b/packages/vscode/tests/fixtures/workspace-2/folder/project-2/test/foo.test.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/vscode/tests/fixtures/workspace-2/project-1/rstest.config.ts b/packages/vscode/tests/fixtures/workspace-2/project-1/rstest.config.ts new file mode 100644 index 00000000..9ee3cbab --- /dev/null +++ b/packages/vscode/tests/fixtures/workspace-2/project-1/rstest.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({}); diff --git a/packages/vscode/tests/fixtures/workspace-2/project-1/test/foo.test.ts b/packages/vscode/tests/fixtures/workspace-2/project-1/test/foo.test.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/vscode/tests/runTest.ts b/packages/vscode/tests/runTest.ts index 97a1aeea..a8cf39a3 100644 --- a/packages/vscode/tests/runTest.ts +++ b/packages/vscode/tests/runTest.ts @@ -13,7 +13,10 @@ async function main() { // Open this folder as the workspace in the Extension Host during tests // Note: __dirname points to tests-dist at runtime, so resolve back to tests/fixtures - const workspacePath = path.resolve(__dirname, '../tests/fixtures'); + const workspacePath = path.resolve( + __dirname, + '../tests/fixtures/fixtures.code-workspace', + ); // Download VS Code, unzip it and run the integration test await runTests({ diff --git a/packages/vscode/tests/suite/config.test.ts b/packages/vscode/tests/suite/config.test.ts index 4c5250e6..c2091441 100644 --- a/packages/vscode/tests/suite/config.test.ts +++ b/packages/vscode/tests/suite/config.test.ts @@ -2,17 +2,9 @@ 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 } from './helpers'; +import { delay, getProjectItems } from './helpers'; suite('Configuration Integration', () => { - function clearController(ctrl: vscode.TestController) { - const ids: string[] = []; - ctrl.items.forEach((item) => { - ids.push(item.id); - }); - for (const id of ids) ctrl.items.delete(id); - } - test('respects rstest.testFileGlobPattern (array-only)', async () => { const extension = vscode.extensions.getExtension('rstack.rstest'); assert.ok(extension, 'Extension should be present'); @@ -29,16 +21,11 @@ suite('Configuration Integration', () => { assert.ok(folders && folders.length > 0, 'Workspace folder is required'); const config = vscode.workspace.getConfiguration('rstest'); - const prev = config.get('testFileGlobPattern'); try { - await testController.resolveHandler?.(undefined as any); - await delay(200); + await delay(500); - const defaultRootsSpec: vscode.TestItem[] = []; - testController.items.forEach((it) => { - defaultRootsSpec.push(it); - }); + const defaultRootsSpec = getProjectItems(testController); assert.equal( defaultRootsSpec.length, 5, @@ -52,32 +39,21 @@ suite('Configuration Integration', () => { vscode.ConfigurationTarget.Workspace, ); - clearController(testController); - await testController.resolveHandler?.(undefined as any); await delay(500); - const rootsSpec: vscode.TestItem[] = []; - testController.items.forEach((it) => { - rootsSpec.push(it); - }); + const rootsSpec = getProjectItems(testController); assert.ok(rootsSpec.length >= 1, 'Should discover spec files'); assert.ok( - rootsSpec.some((it) => - it.id.endsWith('/tests/fixtures/test/jsFile.spec.js'), - ), + rootsSpec.some((it) => it.id.endsWith('/test/jsFile.spec.js')), 'Should include jsFile.spec.js', ); assert.ok( - !rootsSpec.some((it) => - it.id.endsWith('/tests/fixtures/test/jsFile.spec.js.txt'), - ), + !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('/tests/fixtures/test/foo.test.ts'), - ), + !rootsSpec.some((it) => it.id.endsWith('/test/foo.test.ts')), 'Should not include foo.test.ts when only *.spec.* is configured', ); @@ -88,54 +64,37 @@ suite('Configuration Integration', () => { vscode.ConfigurationTarget.Workspace, ); - clearController(testController); - await testController.resolveHandler?.(undefined as any); await delay(500); - const rootsTest: vscode.TestItem[] = []; - testController.items.forEach((it) => { - rootsTest.push(it); - }); + const rootsTest = getProjectItems(testController); assert.ok(rootsTest.length >= 1, 'Should discover test files'); assert.ok( - rootsTest.some((it) => - it.id.endsWith('/tests/fixtures/test/foo.test.ts'), - ), + rootsTest.some((it) => it.id.endsWith('/test/foo.test.ts')), 'Should include foo.test.ts', ); assert.ok( - rootsTest.some((it) => - it.id.endsWith('/tests/fixtures/test/index.test.ts'), - ), + rootsTest.some((it) => it.id.endsWith('/test/index.test.ts')), 'Should include index.test.ts', ); assert.ok( - rootsTest.some((it) => - it.id.endsWith('/tests/fixtures/test/tsxFile.test.tsx'), - ), + rootsTest.some((it) => it.id.endsWith('/test/tsxFile.test.tsx')), 'Should include tsxFile.test.tsx', ); assert.ok( - rootsTest.some((it) => - it.id.endsWith('/tests/fixtures/test/jsxFile.test.jsx'), - ), + rootsTest.some((it) => it.id.endsWith('/test/jsxFile.test.jsx')), 'Should include jsxFile.test.jsx', ); assert.ok( - !rootsTest.some((it) => - it.id.endsWith('/tests/fixtures/test/jsFile.spec.js'), - ), + !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', - (prev as any) ?? undefined, + undefined, vscode.ConfigurationTarget.Workspace, ); - clearController(testController); - await testController.resolveHandler?.(undefined as any); await delay(200); // Clean up test artifacts const fixturesVscodeDir = path.join(folders[0].uri.fsPath, '.vscode'); diff --git a/packages/vscode/tests/suite/helpers.ts b/packages/vscode/tests/suite/helpers.ts index a4453f00..029ccfe8 100644 --- a/packages/vscode/tests/suite/helpers.ts +++ b/packages/vscode/tests/suite/helpers.ts @@ -1,3 +1,6 @@ +import assert from 'node:assert'; +import type * as vscode from 'vscode'; + export async function delay(ms: number) { await new Promise((resolve) => setTimeout(resolve, ms)); } @@ -25,3 +28,68 @@ export async function waitForConfigValue({ return value; } + +export function waitFor( + cb: () => T, + { + timeoutMs = 2000, + pollMs = 25, + }: { + timeoutMs?: number; + pollMs?: number; + } = {}, +) { + return new Promise((resolve, reject) => { + const start = Date.now(); + const interval = setInterval(() => { + try { + resolve(cb()); + clearInterval(interval); + } catch (error) { + if (Date.now() - start > timeoutMs) { + reject(error); + clearInterval(interval); + } + } + }, pollMs); + }); +} + +export function getTestItems(collection: vscode.TestItemCollection) { + const items: vscode.TestItem[] = []; + collection.forEach((item) => { + items.push(item); + }); + return items; +} + +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); +} + +// 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, +): { + 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); + nodes.push( + children.length + ? { label: child.label, children } + : { label: child.label }, + ); + }); + nodes.sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0)); + return nodes; +} diff --git a/packages/vscode/tests/suite/index.test.ts b/packages/vscode/tests/suite/index.test.ts index 07a0c77f..91dbb7af 100644 --- a/packages/vscode/tests/suite/index.test.ts +++ b/packages/vscode/tests/suite/index.test.ts @@ -1,5 +1,6 @@ import * as assert from 'node:assert'; import * as vscode from 'vscode'; +import { delay, getProjectItems, getTestItems } from './helpers'; suite('Extension Test Suite', () => { vscode.window.showInformationMessage('Start all tests.'); @@ -36,15 +37,6 @@ suite('Extension Test Suite', () => { 'Should open the fixtures workspace', ); - // Open the foo.test.ts file from fixtures - const fooTestUri = vscode.Uri.joinPath( - workspaceFolders[0].uri, - 'test', - 'foo.test.ts', - ); - const document = await vscode.workspace.openTextDocument(fooTestUri); - await vscode.window.showTextDocument(document); - // Check if the extension is activated const extension = vscode.extensions.getExtension('rstack.rstest'); if (extension && !extension.isActive) { @@ -90,35 +82,26 @@ suite('Extension Test Suite', () => { 'Test controller should have discovered test items', ); - const itemsArray: vscode.TestItem[] = []; - testController.items.forEach((item) => { - itemsArray.push(item); - }); + const workspaceItems = getTestItems(testController.items); + assert.equal(workspaceItems[0].label, 'workspace-1'); - const foo = itemsArray.find((it) => - it.id.endsWith( - '/rstest/packages/vscode/tests/fixtures/test/foo.test.ts', - ), - ); + 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')); const index = itemsArray.find((it) => - it.id.endsWith( - '/rstest/packages/vscode/tests/fixtures/test/index.test.ts', - ), + it.id.endsWith('/test/index.test.ts'), ); const jsSpec = itemsArray.find((it) => - it.id.endsWith( - '/rstest/packages/vscode/tests/fixtures/test/jsFile.spec.js', - ), + it.id.endsWith('/test/jsFile.spec.js'), ); const jsxFile = itemsArray.find((it) => - it.id.endsWith( - '/rstest/packages/vscode/tests/fixtures/test/tsxFile.test.tsx', - ), + it.id.endsWith('/test/tsxFile.test.tsx'), ); const tsxFile = itemsArray.find((it) => - it.id.endsWith( - '/rstest/packages/vscode/tests/fixtures/test/tsxFile.test.tsx', - ), + it.id.endsWith('/test/tsxFile.test.tsx'), ); assert.ok(foo, 'foo.test.ts should be discovered'); @@ -149,15 +132,6 @@ suite('Extension Test Suite', () => { ], }); - // Open the index.test.ts file from fixtures - const indexTestUri = vscode.Uri.joinPath( - workspaceFolders[0].uri, - 'test', - 'index.test.ts', - ); - const document2 = await vscode.workspace.openTextDocument(indexTestUri); - await vscode.window.showTextDocument(document2); - await new Promise((resolve) => setTimeout(resolve, 1000)); assert.ok(index, 'index.test.ts should be discovered'); const indexTree = toLabelTree(index!); assert.deepStrictEqual(indexTree, { diff --git a/packages/vscode/tests/suite/workspace.test.ts b/packages/vscode/tests/suite/workspace.test.ts new file mode 100644 index 00000000..a06e4d7a --- /dev/null +++ b/packages/vscode/tests/suite/workspace.test.ts @@ -0,0 +1,229 @@ +import assert from 'node:assert'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import * as vscode from 'vscode'; +import { toLabelTree, waitFor } from './helpers'; + +suite('Workspace discover suite', () => { + test('Extension should discover workspaces and projects', 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'); + const fixturesRoot = path.resolve(__dirname, '../../tests/fixtures'); + + // initial workspaces + await waitFor(() => { + assert.deepStrictEqual(toLabelTree(testController.items, 3), [ + { + label: 'workspace-1', + children: [ + { + label: 'rstest.config.ts', + children: [ + { label: 'foo.test.ts' }, + { label: 'index.test.ts' }, + { label: 'jsFile.spec.js' }, + { label: 'jsxFile.test.jsx' }, + { label: 'tsxFile.test.tsx' }, + ], + }, + ], + }, + ]); + }); + + // add workspace-2 + vscode.workspace.updateWorkspaceFolders( + vscode.workspace.workspaceFolders?.length || 0, + 0, + { + uri: vscode.Uri.file(path.resolve(fixturesRoot, 'workspace-2')), + }, + ); + await waitFor(() => { + assert.deepStrictEqual(toLabelTree(testController.items, 3), [ + { + label: 'workspace-1', + children: [ + { + label: 'rstest.config.ts', + children: [ + { label: 'foo.test.ts' }, + { label: 'index.test.ts' }, + { label: 'jsFile.spec.js' }, + { label: 'jsxFile.test.jsx' }, + { label: 'tsxFile.test.tsx' }, + ], + }, + ], + }, + { + label: 'workspace-2', + children: [ + { + label: 'folder/project-2/rstest.config.ts', + children: [{ label: 'foo.test.ts' }], + }, + { + label: 'project-1/rstest.config.ts', + children: [{ label: 'foo.test.ts' }], + }, + ], + }, + ]); + }); + + // remove config file + await fs.rename( + path.resolve(fixturesRoot, 'workspace-2/project-1/rstest.config.ts'), + path.resolve(fixturesRoot, 'workspace-2/project-1/foo.config.ts'), + ); + await fs.rename( + path.resolve( + fixturesRoot, + 'workspace-2/folder/project-2/rstest.config.ts', + ), + path.resolve(fixturesRoot, 'workspace-2/folder/project-2/bar.config.ts'), + ); + await waitFor(() => { + assert.deepStrictEqual(toLabelTree(testController.items, 3), [ + { + label: 'workspace-1', + children: [ + { + label: 'rstest.config.ts', + children: [ + { label: 'foo.test.ts' }, + { label: 'index.test.ts' }, + { label: 'jsFile.spec.js' }, + { label: 'jsxFile.test.jsx' }, + { label: 'tsxFile.test.tsx' }, + ], + }, + ], + }, + { + label: 'workspace-2', + }, + ]); + }); + + // change configFileGlobPattern + await config.update('configFileGlobPattern', [ + '**/foo.config.{mjs,ts,js,cjs,mts,cts}', + ]); + await waitFor(() => { + assert.deepStrictEqual(toLabelTree(testController.items, 3), [ + { + label: 'workspace-1', + }, + { + label: 'workspace-2', + children: [ + { + label: 'project-1/foo.config.ts', + children: [{ label: 'foo.test.ts' }], + }, + ], + }, + ]); + }); + + // add config file + await fs.rename( + path.resolve(fixturesRoot, 'workspace-2/folder/project-2/bar.config.ts'), + path.resolve(fixturesRoot, 'workspace-2/folder/project-2/foo.config.ts'), + ); + await waitFor(() => { + assert.deepStrictEqual(toLabelTree(testController.items, 3), [ + { + label: 'workspace-1', + }, + { + label: 'workspace-2', + children: [ + { + label: 'folder/project-2/foo.config.ts', + children: [{ label: 'foo.test.ts' }], + }, + { + label: 'project-1/foo.config.ts', + children: [{ label: 'foo.test.ts' }], + }, + ], + }, + ]); + }); + + // restore config file and configFileGlobPattern + await fs.rename( + path.resolve(fixturesRoot, 'workspace-2/project-1/foo.config.ts'), + path.resolve(fixturesRoot, 'workspace-2/project-1/rstest.config.ts'), + ); + await fs.rename( + path.resolve(fixturesRoot, 'workspace-2/folder/project-2/foo.config.ts'), + path.resolve( + fixturesRoot, + 'workspace-2/folder/project-2/rstest.config.ts', + ), + ); + await config.update('configFileGlobPattern', undefined); + await waitFor(() => { + assert.deepStrictEqual(toLabelTree(testController.items, 3), [ + { + label: 'workspace-1', + children: [ + { + label: 'rstest.config.ts', + children: [ + { label: 'foo.test.ts' }, + { label: 'index.test.ts' }, + { label: 'jsFile.spec.js' }, + { label: 'jsxFile.test.jsx' }, + { label: 'tsxFile.test.tsx' }, + ], + }, + ], + }, + { + label: 'workspace-2', + children: [ + { + label: 'folder/project-2/rstest.config.ts', + children: [{ label: 'foo.test.ts' }], + }, + { + label: 'project-1/rstest.config.ts', + children: [{ label: 'foo.test.ts' }], + }, + ], + }, + ]); + }); + + // remove workspace-2 + vscode.workspace.updateWorkspaceFolders(1, 1); + + await waitFor(() => { + assert.deepStrictEqual(toLabelTree(testController.items, 2), [ + { + label: 'workspace-1', + children: [ + { + label: 'rstest.config.ts', + }, + ], + }, + ]); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c7e90e6..acf58425 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -620,6 +620,9 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 + valibot: + specifier: ^1.1.0 + version: 1.1.0(typescript@5.9.3) scripts/tsconfig: {} @@ -5778,6 +5781,14 @@ packages: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} + valibot@1.1.0: + resolution: {integrity: sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -12011,6 +12022,10 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 + valibot@1.1.0(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 From 2d7542b4e34cf33a3e953c39cc688fc8e798feea Mon Sep 17 00:00:00 2001 From: claneo Date: Mon, 24 Nov 2025 10:56:56 +0800 Subject: [PATCH 2/3] fix windows test --- packages/vscode/tests/suite/helpers.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/vscode/tests/suite/helpers.ts b/packages/vscode/tests/suite/helpers.ts index 029ccfe8..1815af43 100644 --- a/packages/vscode/tests/suite/helpers.ts +++ b/packages/vscode/tests/suite/helpers.ts @@ -1,4 +1,5 @@ import assert from 'node:assert'; +import path from 'node:path'; import type * as vscode from 'vscode'; export async function delay(ms: number) { @@ -84,11 +85,9 @@ export function toLabelTree( const nodes: { label: string; children?: any[] }[] = []; collection.forEach((child) => { const children = toLabelTree(child.children, maxDepth - 1); - nodes.push( - children.length - ? { label: child.label, children } - : { label: child.label }, - ); + // normalize to linux path style + const label = child.label.replaceAll(path.sep, '/'); + nodes.push(children.length ? { label, children } : { label }); }); nodes.sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0)); return nodes; From 919feddcfe111f802b25bc29022b60632cfedd89 Mon Sep 17 00:00:00 2001 From: claneo Date: Tue, 25 Nov 2025 20:20:08 +0800 Subject: [PATCH 3/3] add back refreshHandler --- packages/vscode/src/extension.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index b55a4bb0..17d1a267 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -49,6 +49,8 @@ class Rstest { return this.startTestRun(request); }; + this.ctrl.refreshHandler = () => this.startScanWorkspaces(); + const _runProfile = this.ctrl.createRunProfile( 'Run Tests', vscode.TestRunProfileKind.Run,