diff --git a/src/api.ts b/src/api.ts index 120a26fb..bfcea1b7 100644 --- a/src/api.ts +++ b/src/api.ts @@ -126,6 +126,14 @@ export class VitestFolderAPI extends VitestReporter { await this.meta.rpc.cancelRun(this.id) } + async watchTests(files?: string[], testNamePattern?: string) { + await this.meta.rpc.watchTests(this.id, files?.map(normalize), testNamePattern) + } + + async unwatchTests() { + await this.meta.rpc.unwatchTests(this.id) + } + stopInspect() { return this.meta.rpc.stopInspect() } diff --git a/src/api/rpc.ts b/src/api/rpc.ts index cf869353..792ec2f8 100644 --- a/src/api/rpc.ts +++ b/src/api/rpc.ts @@ -4,12 +4,15 @@ import { type BirpcReturn, createBirpc } from 'birpc' import type { File, TaskResultPack, UserConsoleLog } from 'vitest' export interface BirpcMethods { - getFiles: (configFle: string) => Promise<[project: string, file: string][]> - collectTests: (configFile: string, testFile: string) => Promise - cancelRun: (configFile: string) => Promise - runTests: (configFile: string, files?: string[], testNamePattern?: string) => Promise + getFiles: (id: string) => Promise<[project: string, file: string][]> + collectTests: (id: string, testFile: string) => Promise + cancelRun: (id: string) => Promise + runTests: (id: string, files?: string[], testNamePattern?: string) => Promise isTestFile: (file: string) => Promise + watchTests: (id: string, files?: string[], testNamePattern?: string) => Promise + unwatchTests: (id: string) => Promise + startInspect: (port: number) => void stopInspect: () => void close: () => void diff --git a/src/debug/startSession.ts b/src/debug/startSession.ts index f58f5a19..50ba7b80 100644 --- a/src/debug/startSession.ts +++ b/src/debug/startSession.ts @@ -62,7 +62,7 @@ export async function startDebugSession( mainSession = undefined api.stopInspect() // Vitest has 60s of waiting for RPC, and it never resolves when running with debugger, so we manually stop all runs - runner.endTestRuns() + // runner.endTestRuns() }, 100) }) diff --git a/src/runner/runner.ts b/src/runner/runner.ts index a21db120..2fb2d8d8 100644 --- a/src/runner/runner.ts +++ b/src/runner/runner.ts @@ -3,18 +3,23 @@ import stripAnsi from 'strip-ansi' import * as vscode from 'vscode' import { getTasks } from '@vitest/ws-client' import type { ErrorWithDiff, ParsedStack, Task, TaskResult } from 'vitest' -import { basename, normalize } from 'pathe' -import { type TestData, TestFolder, getTestData } from '../testTreeData' +import { basename, dirname, normalize } from 'pathe' +import { type TestData, TestFile, TestFolder, getTestData } from '../testTreeData' import type { TestTree } from '../testTree' import type { VitestFolderAPI } from '../api' import { log } from '../log' import { type DebugSessionAPI, startDebugSession } from '../debug/startSession' +import { TestRunData } from './testRunData' export class TestRunner extends vscode.Disposable { - private testRun?: vscode.TestRun private debug?: DebugSessionAPI - private testRunRequests = new Set() + private continuousRequests = new Set() + private simpleTestRunRequest: vscode.TestRunRequest | null = null + + // TODO: doesn't support "projects" - run every project because Vitest doesn't support + // granular filters yet (coming in Vitest 1.4.1) + private testRunsByFile = new Map() constructor( private readonly controller: vscode.TestController, @@ -23,7 +28,10 @@ export class TestRunner extends vscode.Disposable { ) { super(() => { api.clearListeners() - this.endTestRuns() + this.testRunsByFile.clear() + this.simpleTestRunRequest = null + this.continuousRequests.clear() + this.api.cancelRun() }) api.onWatcherRerun(files => this.startTestRun(files)) @@ -35,7 +43,12 @@ export class TestRunner extends vscode.Disposable { log.error('Cannot find task during onTaskUpdate', testId) return } - this.markResult(test.item, result) + const testRun = this.getTestRunByData(test) + // there is no test run for collected tests + if (!testRun) + return + + this.markResult(testRun, test.item, result) }) }) @@ -43,31 +56,33 @@ export class TestRunner extends vscode.Disposable { if (!files) return files.forEach(file => this.tree.collectFile(this.api, file)) - const run = this.testRun - if (!run) - return this.forEachTask(files, (task, data) => { + const testRun = this.getTestRunByData(data) + if (!testRun) + return if (task.mode === 'skip' || task.mode === 'todo') - run.skipped(data.item) + testRun.skipped(data.item) else - this.markResult(data.item, task.result, task) + this.markResult(testRun, data.item, task.result, task) }) }) api.onFinished((files = []) => { files.forEach((file) => { - const data = this.tree.getTestDataByTask(file) - if (data) - this.markResult(data.item, file.result, file) + const data = this.tree.getTestDataByTask(file) as TestFile | undefined + const testRun = data && this.getTestRunByData(data) + if (testRun && data) { + this.markResult(testRun, data.item, file.result, file) + this.endTestRun(testRun) + } }) - - this.endTestRuns() }) api.onConsoleLog(({ content, taskId }) => { const data = taskId ? tree.getTestDataByTaskId(taskId) : undefined - if (this.testRun) { - this.testRun.appendOutput( + const testRun = data && this.getTestRunByData(data) + if (testRun) { + testRun.appendOutput( content.replace(/(? { + this.continuousRequests.delete(request) + if (!this.continuousRequests.size) + this.api.unwatchTests() + }) + + if (!request.include?.length) { + await this.api.watchTests() + } + else { + const include = [...this.continuousRequests].map(r => r.include || []).flat() + const files = getTestFiles(include) + const testNamePatern = formatTestPattern(include) + await this.api.watchTests(files, testNamePatern) + } + } + public async runTests(request: vscode.TestRunRequest, token: vscode.CancellationToken) { - this.testRunRequests.add(request) + // if request is continuous, we just mark it and wait for the changes to files + // users can also click on "run" button to trigger the run + if (request.continuous) + return await this.watchContinuousTests(request, token) + + this.simpleTestRunRequest = request + token.onCancellationRequested(() => { + this.simpleTestRunRequest = null this.api.cancelRun() - this.testRunRequests.delete(request) - this.endTestRuns() }) - const tests = [...this.testRunRequests.values()].flatMap(r => r.include || []) + const tests = request.include || [] if (!tests.length) { log.info(`Running all tests in ${basename(this.api.workspaceFolder.uri.fsPath)}`) @@ -114,51 +154,113 @@ export class TestRunner extends vscode.Disposable { await this.api.runFiles(files, testNamePatern) } - if (!request.continuous) - this.testRunRequests.delete(request) + this.simpleTestRunRequest = null + } + + private getTestRunByData(data: TestData): vscode.TestRun | null { + if (data instanceof TestFolder) + return null + if (data instanceof TestFile) + return this.testRunsByFile.get(data.filepath) || null + + if ('file' in data) + return this.getTestRunByData(data.file) + return null } - private enqueueTests(testRun: vscode.TestRun, tests: vscode.TestItemCollection) { - for (const [_, item] of tests) { - if (item.children.size) { - this.enqueueTests(testRun, item.children) + private isFileIncluded(file: string, include: readonly vscode.TestItem[] | vscode.TestItemCollection) { + for (const _item of include) { + const item = 'id' in _item ? _item : _item[1] + const data = getTestData(item) + if (data instanceof TestFile) { + if (data.filepath === file) + return true + } + else if (data instanceof TestFolder) { + if (this.isFileIncluded(file, item.children)) + return true } else { - // enqueue only tests themselves, not folders - // they will be queued automatically if children are enqueued - testRun.enqueued(item) + if (data.file.filepath === file) + return true } } + return false } - private startTestRun(_files: string[]) { - // TODO: refactor to use different requests, otherwise test run doesn't mark the result value! - const currentRequest = this.testRunRequests.values().next().value as vscode.TestRunRequest | undefined - if (currentRequest) { - // report only if continuous mode is enabled or this is the first run - if (!this.testRun || currentRequest.continuous) { - const testName = currentRequest.include?.length === 1 ? currentRequest.include[0].label : undefined - const name = currentRequest.include?.length ? testName : 'Running all tests' - this.testRun = this.controller.createTestRun(currentRequest, name) - if (currentRequest.include) { - currentRequest.include.forEach((testItem) => { - this.enqueueTests(this.testRun!, testItem.children) - }) - } - else { - const workspaceFolderPath = normalize(this.api.workspaceFolder.uri.fsPath) - this.enqueueTests( - this.testRun, - this.tree.getOrCreateFolderTestItem(this.api, workspaceFolderPath).children, - ) - } + private getTestFilesInFolder(path: string) { + function getFiles(folder: vscode.TestItem): string[] { + const files: string[] = [] + for (const [_, item] of folder.children) { + const data = getTestData(item) + if (data instanceof TestFile) + files.push(data.filepath) + else if (data instanceof TestFolder) + files.push(...getFiles(item)) + } + return files + } + + const folder = this.tree.getOrCreateFolderTestItem(this.api, path) + return getFiles(folder) + } + + private createContinuousRequest() { + if (!this.continuousRequests.size) + return null + const include = [] + let primaryRequest: vscode.TestRunRequest | null = null + for (const request of this.continuousRequests) { + if (!request.include?.length) + return request + if (!primaryRequest) + primaryRequest = request + include.push(...request.include) + } + return new vscode.TestRunRequest( + include, + undefined, + primaryRequest?.profile, + true, + ) + } + + private startTestRun(files: string[], primaryRequest?: vscode.TestRunRequest) { + const request = primaryRequest || this.simpleTestRunRequest || this.createContinuousRequest() + + if (!request) + return + + for (const file of files) { + if (file[file.length - 1] === '/') { + const files = this.getTestFilesInFolder(file) + this.startTestRun(files, request) + continue } + + // during test collection, we don't have test runs + if (request.include && !this.isFileIncluded(file, request.include)) + continue + + const testRun = this.testRunsByFile.get(file) + if (testRun) + continue + + const base = basename(file) + const dir = basename(dirname(file)) + const name = `${dir}${path.sep}${base}` + const run = this.controller.createTestRun(request, name) + + TestRunData.register(run, file, request) + + this.testRunsByFile.set(file, run) } } - public endTestRuns() { - this.testRun?.end() - this.testRun = undefined + public endTestRun(run: vscode.TestRun) { + const data = TestRunData.get(run) + this.testRunsByFile.delete(data.file) + run.end() } private forEachTask(tasks: Task[], fn: (task: Task, test: TestData) => void) { @@ -172,11 +274,9 @@ export class TestRunner extends vscode.Disposable { }) } - private markResult(test: vscode.TestItem, result?: TaskResult, task?: Task) { - if (!this.testRun) - return + private markResult(testRun: vscode.TestRun, test: vscode.TestItem, result?: TaskResult, task?: Task) { if (!result) { - this.testRun.started(test) + testRun.started(test) return } switch (result.state) { @@ -189,25 +289,25 @@ export class TestRunner extends vscode.Disposable { if (!errors) return test.error = errors.map(e => e.message.toString()).join('\n') - this.testRun.errored(test, errors, result.duration) + testRun.errored(test, errors, result.duration) return } const errors = result.errors?.map(err => testMessageForTestError(test, err), ) || [] - this.testRun.failed(test, errors, result.duration) + testRun.failed(test, errors, result.duration) break } case 'pass': - this.testRun.passed(test, result.duration) + testRun.passed(test, result.duration) break case 'todo': case 'skip': - this.testRun.skipped(test) + testRun.skipped(test) break case 'only': case 'run': - this.testRun.started(test) + testRun.started(test) break default: { const _never: never = result.state @@ -271,7 +371,7 @@ function getTestFiles(tests: readonly vscode.TestItem[]) { return Array.from( new Set(tests.map((test) => { const data = getTestData(test) - const fsPath = test.uri!.fsPath + const fsPath = normalize(test.uri!.fsPath) if (data instanceof TestFolder) return `${fsPath}/` return fsPath diff --git a/src/runner/testRunData.ts b/src/runner/testRunData.ts new file mode 100644 index 00000000..91b2f220 --- /dev/null +++ b/src/runner/testRunData.ts @@ -0,0 +1,23 @@ +import type * as vscode from 'vscode' + +const WEAK_TEST_RUNS_DATA = new WeakMap() + +export class TestRunData { + private constructor( + public readonly run: vscode.TestRun, + public readonly file: string, + public readonly request: vscode.TestRunRequest, + ) {} + + static register( + run: vscode.TestRun, + file: string, + request: vscode.TestRunRequest, + ) { + return WEAK_TEST_RUNS_DATA.set(run, new TestRunData(run, file, request)) + } + + static get(run: vscode.TestRun) { + return WEAK_TEST_RUNS_DATA.get(run)! + } +} diff --git a/src/testTree.ts b/src/testTree.ts index b65ba1b3..956fffd6 100644 --- a/src/testTree.ts +++ b/src/testTree.ts @@ -128,6 +128,7 @@ export class TestTree extends vscode.Disposable { testFileItem, normalizedFile, api, + project, ) return testFileItem @@ -248,7 +249,8 @@ export class TestTree extends vscode.Disposable { this.recursiveDelete(fileTestItem) } else { - this.collectTasks(api.tag, file.tasks, fileTestItem) + const data = getTestData(fileTestItem) as TestFile + this.collectTasks(api.tag, data, file.tasks, fileTestItem) if (file.result?.errors) { const error = file.result.errors.map(error => error.stack || error.message).join('\n') fileTestItem.error = error @@ -257,7 +259,7 @@ export class TestTree extends vscode.Disposable { } } - collectTasks(tag: vscode.TestTag, tasks: Task[], item: vscode.TestItem) { + collectTasks(tag: vscode.TestTag, fileData: TestFile, tasks: Task[], item: vscode.TestItem) { for (const task of tasks) { const testItem = this.flatTestItems.get(task.id) || this.controller.createTestItem( task.id, @@ -275,9 +277,9 @@ export class TestTree extends vscode.Disposable { this.flatTestItems.set(task.id, testItem) item.children.add(testItem) if (task.type === 'suite') - TestSuite.register(testItem) + TestSuite.register(testItem, fileData) else if (task.type === 'test' || task.type === 'custom') - TestCase.register(testItem) + TestCase.register(testItem, fileData) if (task.result?.errors) { const error = task.result.errors.map(error => error.stack).join('\n') @@ -285,7 +287,7 @@ export class TestTree extends vscode.Disposable { } if ('tasks' in task) - this.collectTasks(tag, task.tasks, testItem) + this.collectTasks(tag, fileData, task.tasks, testItem) } // remove tasks that are no longer present diff --git a/src/testTreeData.ts b/src/testTreeData.ts index 8f8adba0..fc185299 100644 --- a/src/testTreeData.ts +++ b/src/testTreeData.ts @@ -29,14 +29,16 @@ export class TestFile { public readonly item: vscode.TestItem, public readonly filepath: string, public readonly api: VitestFolderAPI, + public readonly project: string, ) {} public static register( item: vscode.TestItem, filepath: string, api: VitestFolderAPI, + project: string, ) { - return addTestData(item, new TestFile(item, filepath, api)) + return addTestData(item, new TestFile(item, filepath, api, project)) } } @@ -46,14 +48,14 @@ class TaskName { ) {} getTestNamePattern() { - const patterns = [this.data.item.label] + const patterns = [escapeRegex(this.data.item.label)] let iter = this.data.item.parent while (iter) { // if we reached test file, then stop const data = getTestData(iter) if (data instanceof TestFile || data instanceof TestFolder) break - patterns.push(iter.label) + patterns.push(escapeRegex(iter.label)) iter = iter.parent } // vitest's test task name starts with ' ' of root suite @@ -67,12 +69,13 @@ export class TestCase { private constructor( public readonly item: vscode.TestItem, + public readonly file: TestFile, ) { this.nameResolver = new TaskName(this) } - public static register(item: vscode.TestItem) { - return addTestData(item, new TestCase(item)) + public static register(item: vscode.TestItem, file: TestFile) { + return addTestData(item, new TestCase(item, file)) } getTestNamePattern() { @@ -85,15 +88,20 @@ export class TestSuite { private constructor( public readonly item: vscode.TestItem, + public readonly file: TestFile, ) { this.nameResolver = new TaskName(this) } - public static register(item: vscode.TestItem) { - return addTestData(item, new TestSuite(item)) + public static register(item: vscode.TestItem, file: TestFile) { + return addTestData(item, new TestSuite(item, file)) } getTestNamePattern() { return `^${this.nameResolver.getTestNamePattern()}` } } + +function escapeRegex(str: string) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} diff --git a/src/worker/actions.ts b/src/worker/actions.ts index 34c23f1c..af91ceb1 100644 --- a/src/worker/actions.ts +++ b/src/worker/actions.ts @@ -6,16 +6,69 @@ const _require = require export function createWorkerMethods(vitest: Vitest[]): BirpcMethods { let debuggerEnabled = false - const vitestByFolder = vitest.reduce((acc, vitest) => { + const vitestById = vitest.reduce((acc, vitest) => { acc[getId(vitest)] = vitest return acc }, {} as Record) - const vitestEntries = Object.entries(vitestByFolder) + + const watchStateById: Record = {} + + vitest.forEach((vitest) => { + const id = getId(vitest) + // @ts-expect-error modifying a private property + const originalScheduleRerun = vitest.scheduleRerun.bind(vitest) + // @ts-expect-error modifying a private property + vitest.scheduleRerun = async function (files: string[]) { + // disable reruning on changes outside of the test files for now + const tests = this.changedTests + for (const file of files) { + if (!tests.has(file)) + return + } + const state = watchStateById[id] + // no continuous files for this Vitest instance, just collect tests + if (!state) { + vitest.configOverride.testNamePattern = /$a/ + return await originalScheduleRerun.call(this, files) + } + + vitest.configOverride.testNamePattern = state.testNamePattern ? new RegExp(state.testNamePattern) : undefined + if (state.watchEveryFile) + return originalScheduleRerun.call(this, files) + + const allowedTests = state.files + const testFilesToRun = new Set(tests) + // remove tests that are not watched + tests.forEach((file) => { + if (!allowedTests.includes(file)) + testFilesToRun.delete(file) + }) + + // only collect tests, but don't run them + if (!testFilesToRun.size) + vitest.configOverride.testNamePattern = /$a/ + + return originalScheduleRerun.call(this, files) + } + }) + + const vitestEntries = Object.entries(vitestById) function getId(vitest: Vitest) { return vitest.server.config.configFile || vitest.config.workspace || vitest.config.root } + async function rerunTests(vitest: Vitest, files: string[]) { + await vitest.report('onWatcherRerun', files) + await vitest.runFiles(files.flatMap(file => vitest.getProjectsByTestFile(file)), false) + + await vitest.report('onWatcherStart', vitest.state.getFiles(files)) + } + async function runTests(vitest: Vitest, files: string[], testNamePattern?: string) { const cwd = process.cwd() process.chdir(dirname(getId(vitest))) @@ -23,15 +76,14 @@ export function createWorkerMethods(vitest: Vitest[]): BirpcMethods { vitest.configOverride.testNamePattern = testNamePattern ? new RegExp(testNamePattern) : undefined if (!debuggerEnabled) { - await vitest.rerunFiles(files) + await rerunTests(vitest, files) } else { for (const file of files) - await vitest.rerunFiles([file]) + await rerunTests(vitest, [file]) } } finally { - vitest.configOverride.testNamePattern = /$a/ // don't "run" tests on change, but still collect them process.chdir(cwd) } } @@ -45,17 +97,37 @@ export function createWorkerMethods(vitest: Vitest[]): BirpcMethods { } return { - async collectTests(config: string, testFile: string) { - const vitest = vitestByFolder[config] + async watchTests(id: string, files, testNamePattern) { + const vitest = vitestById[id] + if (!vitest) + throw new Error(`Vitest instance not found with id: ${id}`) + watchStateById[id] = { + files: files || [], + watchEveryFile: !files, + testNamePattern, + } + }, + async unwatchTests(id) { + const vitest = vitestById[id] + if (!vitest) + throw new Error(`Vitest instance not found with id: ${id}`) + watchStateById[id] = null + }, + async collectTests(id: string, testFile: string) { + const vitest = vitestById[id] await runTests(vitest, [testFile], '$a') + vitest.configOverride.testNamePattern = undefined }, - async cancelRun(config: string) { - await vitestByFolder[config]?.cancelCurrentRun('keyboard-input') + async cancelRun(id: string) { + const vitest = vitestById[id] + if (!vitest) + throw new Error(`Vitest instance with id "${id}" not found.`) + await vitest.cancelCurrentRun('keyboard-input') }, - async runTests(config, files, testNamePattern) { - const vitest = vitestByFolder[config] + async runTests(id, files, testNamePattern) { + const vitest = vitestById[id] if (!vitest) - throw new Error(`Vitest instance not found for config: ${config}`) + throw new Error(`Vitest instance not found for id: ${id}`) if (testNamePattern) { await runTests(vitest, files || vitest.state.getFilepaths(), testNamePattern) @@ -65,8 +137,8 @@ export function createWorkerMethods(vitest: Vitest[]): BirpcMethods { await runTests(vitest, specs.map(([_, spec]) => spec)) } }, - async getFiles(config: string) { - const vitest = vitestByFolder[config] + async getFiles(id: string) { + const vitest = vitestById[id] const files = await globTestFiles(vitest) // reset cached test files list vitest.projects.forEach((project) => { diff --git a/test/TestData.test.ts b/test/TestData.test.ts index ce722fa4..a42c5657 100644 --- a/test/TestData.test.ts +++ b/test/TestData.test.ts @@ -19,6 +19,7 @@ describe('TestData', () => { testItem, filepath, null as any, // not used yet + '', ) const suiteItem = ctrl.createTestItem( `${filepath}_1`, @@ -49,13 +50,13 @@ describe('TestData', () => { suiteItem.children.add(testItem2) suiteItem.children.add(testItem3) - const suite = TestSuite.register(suiteItem) + const suite = TestSuite.register(suiteItem, file) expect(suite.getTestNamePattern()).to.equal('^\\s?describe') - const test1 = TestCase.register(testItem1) - const test2 = TestCase.register(testItem2) - const test3 = TestCase.register(testItem3) + const test1 = TestCase.register(testItem1, file) + const test2 = TestCase.register(testItem2, file) + const test3 = TestCase.register(testItem3, file) expect(test1.item.parent).to.exist