From 27951ad148b29996d6a7b3e7e005bd235b86206f Mon Sep 17 00:00:00 2001 From: Evan Rose Date: Mon, 23 May 2022 15:05:02 -0400 Subject: [PATCH] feat: add multi-root workspace run/debug support --- .vscode/launch.json | 11 ++ package.json | 4 +- .../sample.code-workspace | 15 ++ src/config.ts | 41 ++++- src/discover.ts | 8 +- src/extension.ts | 158 +++++++++++------- src/pure/isVitestEnv.ts | 6 +- src/pure/utils.ts | 4 + src/runHandler.ts | 72 ++++++-- src/watch.ts | 32 ++-- 10 files changed, 248 insertions(+), 103 deletions(-) create mode 100644 samples/multi-root-workspace/sample.code-workspace diff --git a/.vscode/launch.json b/.vscode/launch.json index 07272ee5..b9766499 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -16,6 +16,17 @@ "outFiles": ["${workspaceFolder}/out/**/*.js"] // "preLaunchTask": "${defaultBuildTask}" }, + { + "name": "Run Extension Multi-Root Workspace Sample", + "type": "extensionHost", + "request": "launch", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}", + "${workspaceFolder}/samples/multi-root-workspace/sample.code-workspace" + ], + "outFiles": ["${workspaceFolder}/out/**/*.js"], + "preLaunchTask": "${defaultBuildTask}" + }, { "name": "Run Extension Monorepo Sample", "type": "extensionHost", diff --git a/package.json b/package.json index 125dac3d..0d3f05a2 100644 --- a/package.json +++ b/package.json @@ -118,12 +118,12 @@ "null" ], "default": null, - "scope": "machine" + "scope": "resource" }, "vitest.showFailMessages": { "description": "Get instant feedback when using Watch Mode. Pop-ups an error when a test fails.", "type": "boolean", - "scope": "resource", + "scope": "window", "default": false } } diff --git a/samples/multi-root-workspace/sample.code-workspace b/samples/multi-root-workspace/sample.code-workspace new file mode 100644 index 00000000..1d34effa --- /dev/null +++ b/samples/multi-root-workspace/sample.code-workspace @@ -0,0 +1,15 @@ +{ + "folders": [ + { + "name": "basic", + "path": "../basic" + }, + { + "name": "react", + "path": "../monorepo/packages/react" + } + ], + "settings": { + "testing.automaticallyOpenPeekView": "never" + } +} diff --git a/src/config.ts b/src/config.ts index a6d05f79..9f5a7894 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,14 +1,39 @@ import { workspace } from 'vscode' +import type { WorkspaceConfiguration, WorkspaceFolder } from 'vscode' export const extensionId = 'zxch3n.vitest-explorer' -export function getConfig() { - const config = workspace.getConfiguration('vitest') +export function getConfigValue( + rootConfig: WorkspaceConfiguration, + folderConfig: WorkspaceConfiguration, + key: string, + defaultValue: T, +): T { + return folderConfig.has(key) + ? folderConfig.get(key)! + : rootConfig.has(key) + ? rootConfig.get(key)! + : defaultValue +} + +export function getConfig(workspaceFolder: WorkspaceFolder) { + const folderConfig = workspace.getConfiguration('vitest', workspaceFolder) + const rootConfig = workspace.getConfiguration('vitest') + + const get = (key: string, defaultValue: T) => getConfigValue(rootConfig, folderConfig, key, defaultValue) + + return { + env: get>('nodeEnv', null), + commandLine: get('commandLine', undefined), + include: get('include', []), + exclude: get('exclude', []), + enable: get('enable', false), + } +} + +export function getRootConfig() { + const rootConfig = workspace.getConfiguration('vitest') + return { - env: config.get('nodeEnv') as null | Record, - commandLine: (config.get('commandLine') || undefined) as string | undefined, - include: config.get('include') as string[], - exclude: config.get('exclude') as string[], - enable: config.get('enable') as boolean, - showFailMessages: config.get('showFailMessages') as boolean, + showFailMessages: rootConfig.get('showFailMessages', false), } } diff --git a/src/discover.ts b/src/discover.ts index e9eba705..144ed29c 100644 --- a/src/discover.ts +++ b/src/discover.ts @@ -48,8 +48,8 @@ export class TestFileDiscoverer extends vscode.Disposable { const watchers = [] as vscode.FileSystemWatcher[] await Promise.all( vscode.workspace.workspaceFolders.map(async (workspaceFolder) => { - const exclude = getConfig().exclude - for (const include of getConfig().include) { + const exclude = getConfig(workspaceFolder).exclude + for (const include of getConfig(workspaceFolder).include) { const pattern = new vscode.RelativePattern( workspaceFolder.uri, include, @@ -101,8 +101,8 @@ export class TestFileDiscoverer extends vscode.Disposable { await Promise.all( vscode.workspace.workspaceFolders.map(async (workspaceFolder) => { - const exclude = getConfig().exclude - for (const include of getConfig().include) { + const exclude = getConfig(workspaceFolder).exclude + for (const include of getConfig(workspaceFolder).include) { const pattern = new vscode.RelativePattern( workspaceFolder.uri, include, diff --git a/src/extension.ts b/src/extension.ts index 8299dff2..f0dc9d13 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,8 +4,8 @@ import { effect } from '@vue/reactivity' import { extensionId, getConfig } from './config' import { TestFileDiscoverer } from './discover' import { isVitestEnv } from './pure/isVitestEnv' -import { getVitestCommand, getVitestVersion, isNodeAvailable, stringToCmd } from './pure/utils' -import { debugHandler, runHandler, updateSnapshot } from './runHandler' +import { getVitestCommand, getVitestVersion, isNodeAvailable, negate } from './pure/utils' +import { debugHandler, gatherTestItemsFromWorkspace, runHandler, updateSnapshot } from './runHandler' import { TestFile, WEAKMAP_TEST_DATA } from './TestData' import { TestWatcher } from './watch' import { Command } from './command' @@ -19,10 +19,10 @@ export async function activate(context: vscode.ExtensionContext) { ) return - if ( - !getConfig().enable - && !(await isVitestEnv(vscode.workspace.workspaceFolders[0].uri.fsPath)) - ) + const vitestEnvironmentFolders = vscode.workspace.workspaceFolders.filter(async folder => + await isVitestEnv(folder) || getConfig(folder).enable) + + if (vitestEnvironmentFolders.length === 0) return const ctrl = vscode.tests.createTestController(`${extensionId}`, 'Vitest') @@ -48,46 +48,56 @@ export async function activate(context: vscode.ExtensionContext) { } } - const vitestCmd = getVitestCommand( - vscode.workspace.workspaceFolders[0].uri.fsPath, - ) ?? { - cmd: 'npx', - args: ['vitest'], - } + const vitestRunConfigs: { + workspace: vscode.WorkspaceFolder + cmd: string + args: string[] + version: string | null + }[] = await Promise.all(vitestEnvironmentFolders.map(async (folder) => { + const cmd = getVitestCommand(folder.uri.fsPath) - const vitestVersion = await getVitestVersion(vitestCmd, getConfig().env || undefined).catch(async (e) => { - log.appendLine(e.toString()) - log.appendLine(`process.env.PATH = ${process.env.PATH}`) - log.appendLine(`vitest.nodeEnv = ${JSON.stringify(getConfig().env)}`) - let errorMsg = e.toString() - if (!isNodeAvailable(getConfig().env || undefined)) { - log.appendLine('Cannot spawn node process') - errorMsg += 'Cannot spawn node process. Please try setting vitest.nodeEnv as {"PATH": "/path/to/node"} in your settings.' - } + const version = await getVitestVersion(cmd, getConfig(folder).env || undefined).catch(async (e) => { + log.appendLine(e.toString()) + log.appendLine(`process.env.PATH = ${process.env.PATH}`) + log.appendLine(`vitest.nodeEnv = ${JSON.stringify(getConfig(folder).env)}`) + let errorMsg = e.toString() + if (!isNodeAvailable(getConfig(folder).env || undefined)) { + log.appendLine('Cannot spawn node process') + errorMsg += 'Cannot spawn node process. Please try setting vitest.nodeEnv as {"PATH": "/path/to/node"} in your settings.' + } + + vscode.window.showErrorMessage(errorMsg) + }) + + return cmd + ? { + cmd: cmd.cmd, + args: cmd.args, + workspace: folder, + version: version ?? null, + } + : { + cmd: 'npx', + args: ['vitest'], + workspace: folder, + version: version ?? null, + } + })) - vscode.window.showErrorMessage(errorMsg) + vitestRunConfigs.forEach((vitest) => { + console.log(`Vitest Workspace: [${vitest.workspace.name}] Version: ${vitest.version}`) }) - console.dir({ vitestVersion }) + const isCompatibleVitestConfig = (config: typeof vitestRunConfigs[number]) => + (config.version && semver.gte(config.version, '0.8.0')) || getConfig(config.workspace).commandLine - const customTestCmd = getConfig().commandLine - if ((vitestVersion && semver.gte(vitestVersion, '0.8.0')) || customTestCmd) { - // enable run/debug/watch tests only if vitest version >= 0.8.0 - const testWatcher: undefined | TestWatcher = registerWatchHandler( - vitestCmd ?? stringToCmd(customTestCmd!), - ctrl, - fileDiscoverer, - context, - ) - registerRunHandler(ctrl, testWatcher) - context.subscriptions.push( - vscode.commands.registerCommand(Command.UpdateSnapshot, (test) => { - updateSnapshot(ctrl, test) - }), - ) - } - else { - const msg = 'Because Vitest version < 0.8.0, run/debug/watch tests from Vitest extension disabled.\n' + vitestRunConfigs.filter(negate(isCompatibleVitestConfig)).forEach((config) => { + vscode.window.showWarningMessage(`Because Vitest version < 0.8.0 for ${config.workspace.name} ` + + ', run/debug/watch tests from Vitest extension disabled for that workspace.\n') + }) + + if (vitestRunConfigs.every(negate(isCompatibleVitestConfig))) { + const msg = 'Because Vitest version < 0.8.0 for every workspace folder, run/debug/watch tests from Vitest extension disabled.\n' context.subscriptions.push( vscode.commands.registerCommand(Command.ToggleWatching, () => { vscode.window.showWarningMessage(msg) @@ -102,6 +112,20 @@ export async function activate(context: vscode.ExtensionContext) { vscode.window.showWarningMessage(msg) } + // enable run/debug/watch tests only if vitest version >= 0.8.0 + const testWatchers = registerWatchHandlers( + vitestRunConfigs.filter(isCompatibleVitestConfig), + ctrl, + fileDiscoverer, + context, + ) ?? [] + registerRunHandler(ctrl, testWatchers) + context.subscriptions.push( + vscode.commands.registerCommand(Command.UpdateSnapshot, (test) => { + updateSnapshot(ctrl, test) + }), + ) + vscode.window.visibleTextEditors.forEach(x => fileDiscoverer.discoverTestFromDoc(ctrl, x.document), ) @@ -122,26 +146,39 @@ export async function activate(context: vscode.ExtensionContext) { ) } +function aggregateTestWatcherStatuses(testWatchers: TestWatcher[]) { + return testWatchers.reduce((aggregate, watcher) => { + return { + passed: aggregate.passed + watcher.testStatus.value.passed, + failed: aggregate.failed + watcher.testStatus.value.failed, + skipped: aggregate.skipped + watcher.testStatus.value.skipped, + } + }, { + passed: 0, + failed: 0, + skipped: 0, + }) +} + let statusBarItem: StatusBarItem -function registerWatchHandler( - vitestCmd: { cmd: string; args: string[] } | undefined, +function registerWatchHandlers( + vitestConfigs: { cmd: string; args: string[]; workspace: vscode.WorkspaceFolder }[], ctrl: vscode.TestController, fileDiscoverer: TestFileDiscoverer, context: vscode.ExtensionContext, ) { - if (!vitestCmd) - return + const testWatchers = vitestConfigs.map((vitestConfig, index) => + TestWatcher.create(ctrl, fileDiscoverer, vitestConfig, vitestConfig.workspace, index), + ) ?? [] - const testWatcher = TestWatcher.create(ctrl, fileDiscoverer, vitestCmd) statusBarItem = new StatusBarItem() effect(() => { - if (testWatcher.isRunning.value) { + if (testWatchers.some(watcher => watcher.isRunning.value)) { statusBarItem.toRunningMode() return } - - if (testWatcher.isWatching.value) { - statusBarItem.toWatchMode(testWatcher.testStatus.value) + else if (testWatchers.some(watcher => watcher.isWatching.value)) { + statusBarItem.toWatchMode(aggregateTestWatcherStatuses(testWatchers)) return } @@ -149,13 +186,13 @@ function registerWatchHandler( }) const stopWatching = () => { - testWatcher!.dispose() + testWatchers.forEach(watcher => watcher.dispose()) vscode.workspace .getConfiguration('testing') .update('automaticallyOpenPeekView', undefined) } const startWatching = () => { - testWatcher!.watch() + testWatchers.forEach(watcher => watcher.watch()) vscode.workspace .getConfiguration('testing') .update('automaticallyOpenPeekView', 'never') @@ -165,12 +202,13 @@ function registerWatchHandler( { dispose: stopWatching, }, - testWatcher, + ...testWatchers, statusBarItem, vscode.commands.registerCommand(Command.StartWatching, startWatching), vscode.commands.registerCommand(Command.StopWatching, stopWatching), vscode.commands.registerCommand(Command.ToggleWatching, () => { - if (testWatcher.isWatching.value) + const anyWatching = testWatchers.some(watcher => watcher.isWatching.value) + if (anyWatching) stopWatching() else startWatching() @@ -194,21 +232,23 @@ function registerWatchHandler( ) return - await testWatcher.watch() - testWatcher.runTests(request.include) + await Promise.all(testWatchers.map(watcher => watcher.watch())) + testWatchers.forEach((watcher) => { + watcher.runTests(gatherTestItemsFromWorkspace(request.include ?? [], watcher.workspace.uri.fsPath)) + }) } - return testWatcher + return testWatchers } function registerRunHandler( ctrl: vscode.TestController, - testWatcher?: TestWatcher, + testWatchers: TestWatcher[], ) { ctrl.createRunProfile( 'Run Tests', vscode.TestRunProfileKind.Run, - runHandler.bind(null, ctrl, testWatcher), + runHandler.bind(null, ctrl, testWatchers), true, ) diff --git a/src/pure/isVitestEnv.ts b/src/pure/isVitestEnv.ts index 3faf7449..46c5529c 100644 --- a/src/pure/isVitestEnv.ts +++ b/src/pure/isVitestEnv.ts @@ -1,9 +1,13 @@ import { existsSync } from 'fs' import path = require('path') import { readFile, readdir } from 'fs-extra' +import type { WorkspaceFolder } from 'vscode' import { getVitestPath } from './utils' -export async function isVitestEnv(projectRoot: string): Promise { +export async function isVitestEnv(projectRoot: string | WorkspaceFolder): Promise { + if (typeof projectRoot !== 'string') + return isVitestEnv(projectRoot.uri.fsPath) + if (getVitestPath(projectRoot)) return true diff --git a/src/pure/utils.ts b/src/pure/utils.ts index 89ad0471..314bfee6 100644 --- a/src/pure/utils.ts +++ b/src/pure/utils.ts @@ -181,3 +181,7 @@ export function stringToCmd(cmdStr: string): Cmd { args: list.slice(1), } } + +export function negate(func: Function): (...args: typeof func.arguments) => Boolean { + return (...args: typeof func.arguments) => !func(...args) +} diff --git a/src/runHandler.ts b/src/runHandler.ts index 3ffbe6c0..b2bd956f 100644 --- a/src/runHandler.ts +++ b/src/runHandler.ts @@ -1,4 +1,4 @@ -import { relative } from 'path' +import { isAbsolute, relative } from 'path' import { existsSync } from 'fs' import * as vscode from 'vscode' import { readFile } from 'fs-extra' @@ -25,7 +25,7 @@ import { TestWatcher } from './watch' export async function runHandler( ctrl: vscode.TestController, - watcher: TestWatcher | undefined, + watchers: TestWatcher[], request: vscode.TestRunRequest, _cancellation: vscode.CancellationToken, ) { @@ -35,19 +35,31 @@ export async function runHandler( ) return - if (TestWatcher.isWatching() && watcher) { - watcher.runTests(request.include) + if (watchers.some(watcher => TestWatcher.isWatching(watcher.id)) && watchers.length > 0) { + watchers.forEach((watcher) => { + watcher.runTests(gatherTestItemsFromWorkspace(request.include ?? [], watcher.workspace.uri.fsPath)) + }) + return } - const tests = request.include ?? gatherTestItems(ctrl.items) - const runner = new TestRunner( - vscode.workspace.workspaceFolders[0].uri.fsPath, - getVitestCommand(vscode.workspace.workspaceFolders[0].uri.fsPath), - ) - const run = ctrl.createTestRun(request) - await runTest(ctrl, runner, run, tests, 'run') + + await Promise.all(vscode.workspace.workspaceFolders.map((folder) => { + const runner = new TestRunner( + folder.uri.fsPath, + getVitestCommand(folder.uri.fsPath), + ) + + const items = request.include ?? ctrl.items + + const testForThisWorkspace = gatherTestItemsFromWorkspace(items, folder.uri.fsPath) + if (testForThisWorkspace.length) + return runTest(ctrl, runner, run, testForThisWorkspace, 'run') + + return Promise.resolve() + })) + run.end() } @@ -63,7 +75,7 @@ export async function updateSnapshot( test = testItemIdMap.get(ctrl)!.get(test.id)! const runner = new TestRunner( - vscode.workspace.workspaceFolders[0].uri.fsPath, + determineWorkspaceForTestItems([test], vscode.workspace.workspaceFolders).uri.fsPath, getVitestCommand(vscode.workspace.workspaceFolders[0].uri.fsPath), ) @@ -91,12 +103,41 @@ export async function debugHandler( run.end() } -function gatherTestItems(collection: vscode.TestItemCollection) { +function gatherTestItems(collection: readonly vscode.TestItem[] | vscode.TestItemCollection): vscode.TestItem[] { + if (Array.isArray(collection)) + return collection + const items: vscode.TestItem[] = [] collection.forEach(item => items.push(item)) return items } +function isPathASubdirectory(parent: string, testPath: string): boolean { + const relativePath = relative(parent, testPath) + return (!relativePath.startsWith('..') && !isAbsolute(relativePath)) +} + +export function gatherTestItemsFromWorkspace(collection: readonly vscode.TestItem[] | vscode.TestItemCollection, workspace: string) { + return gatherTestItems(collection).filter((item: vscode.TestItem) => item.uri && isPathASubdirectory(workspace, item.uri.fsPath)) +} + +function determineWorkspaceForTestItems(collection: readonly vscode.TestItem[] | vscode.TestItemCollection, workspaces: readonly vscode.WorkspaceFolder[]) { + if (workspaces.length === 1) + return workspaces[0] + + const testItems = gatherTestItems(collection) + + if (testItems.length < 1) + return workspaces[0] + + const workspace = workspaces.find(workspace => testItems[0].uri && isPathASubdirectory(workspace.uri.fsPath, testItems[0].uri.fsPath)) + + if (!workspace) + throw new Error('Multiple workspace roots are found; cannot deduce workspace to which these tests belong to') + + return workspace +} + type Mode = 'debug' | 'run' | 'update' async function runTest( ctrl: vscode.TestController, @@ -108,7 +149,8 @@ async function runTest( if (mode !== 'debug' && runner === undefined) throw new Error('should provide runner if not debug') - const config = getConfig() + const workspaceFolder = determineWorkspaceForTestItems(items, vscode.workspace.workspaceFolders!) + const config = getConfig(workspaceFolder) const testCaseSet: Set = new Set() const testItemIdMap = new Map() const fileItems: vscode.TestItem[] = [] @@ -157,7 +199,7 @@ async function runTest( try { if (mode === 'debug') { - out = await debugTest(vscode.workspace.workspaceFolders![0], run, items) + out = await debugTest(workspaceFolder, run, items) } else { let command diff --git a/src/watch.ts b/src/watch.ts index 985b91ad..5b7b758a 100644 --- a/src/watch.ts +++ b/src/watch.ts @@ -6,11 +6,11 @@ import { effect, ref } from '@vue/reactivity' import Fuse from 'fuse.js' import StackUtils from 'stack-utils' import type { ErrorWithDiff, File, Task } from 'vitest' -import type { TestController, TestItem, TestRun } from 'vscode' -import { Disposable, Location, Position, TestMessage, TestRunRequest, Uri, workspace } from 'vscode' +import type { TestController, TestItem, TestRun, WorkspaceFolder } from 'vscode' +import { Disposable, Location, Position, TestMessage, TestRunRequest, Uri } from 'vscode' import { Lock } from 'mighty-promise' import * as vscode from 'vscode' -import { getConfig } from './config' +import { getConfig, getRootConfig } from './config' import type { TestFileDiscoverer } from './discover' import { execWithLog } from './pure/utils' import { buildWatchClient } from './pure/watch/client' @@ -22,22 +22,24 @@ const stackUtils = new StackUtils({ }) export interface DebuggerLocation { path: string; line: number; column: number } export class TestWatcher extends Disposable { - static cache: undefined | TestWatcher - static isWatching() { - return !!this.cache?.isWatching.value + static cache: Record = {} + static isWatching(id: number) { + return !!this.cache[id]?.isWatching.value } static create( ctrl: TestController, discover: TestFileDiscoverer, vitest: { cmd: string; args: string[] }, + workspace: WorkspaceFolder, + id: number, ) { - if (this.cache) - return this.cache + if (this.cache[id]) + return this.cache[id] - TestWatcher.cache = new TestWatcher(ctrl, discover, vitest) + TestWatcher.cache[id] = new TestWatcher(id, ctrl, discover, vitest, workspace) - return TestWatcher.cache + return TestWatcher.cache[id] } public isWatching = ref(false) @@ -48,9 +50,11 @@ export class TestWatcher extends Disposable { private vitestState?: ReturnType private run: TestRun | undefined private constructor( + readonly id: number, private ctrl: TestController, private discover: TestFileDiscoverer, private vitest: { cmd: string; args: string[] }, + readonly workspace: WorkspaceFolder, ) { super(() => { this.dispose() @@ -70,10 +74,10 @@ export class TestWatcher extends Disposable { let timer: any this.process = execWithLog( this.vitest.cmd, - [...this.vitest.args, '--api', port.toString()], + [...this.vitest.args, '--api.port', port.toString()], { - cwd: workspace.workspaceFolders?.[0].uri.fsPath, - env: { ...process.env, ...getConfig().env }, + cwd: this.workspace.uri.fsPath, + env: { ...process.env, ...getConfig(this.workspace).env }, }, (line) => { logs.push(line) @@ -177,7 +181,7 @@ export class TestWatcher extends Disposable { } this.testStatus.value = { passed, failed, skipped } - if (getConfig().showFailMessages && failed > 0) + if (getRootConfig().showFailMessages && failed > 0) vscode.window.showErrorMessage(`Vitest: You have ${failed} failing Unit Test(s).`) }