From bd3dc7b622542c10890ff9ef55a33d533abc24d5 Mon Sep 17 00:00:00 2001 From: Benny Neugebauer Date: Thu, 6 Aug 2026 16:51:16 +0200 Subject: [PATCH 1/3] fix: run gutter tests with the most specific config when several configs claim the same file File items now collect the tags of every config that claims them (folder items already did), and run profiles are registered for all discovered configs before any process spawns, deepest config first. VS Code invokes the first registered profile that matches the item, so the most specific config becomes the effective default. Fixes #799 --- packages/extension/src/extension.ts | 51 ++++++++++++++++ packages/extension/src/testTree.ts | 6 +- pnpm-lock.yaml | 6 ++ samples/shared-base-config/README.md | 15 +++++ samples/shared-base-config/package.json | 12 ++++ .../packages/foo/test/which-config.test.ts | 7 +++ .../packages/foo/vitest.config.ts | 13 ++++ .../shared-base-config/vitest.config.base.ts | 11 ++++ test/e2e/shared-base-config.test.ts | 60 +++++++++++++++++++ 9 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 samples/shared-base-config/README.md create mode 100644 samples/shared-base-config/package.json create mode 100644 samples/shared-base-config/packages/foo/test/which-config.test.ts create mode 100644 samples/shared-base-config/packages/foo/vitest.config.ts create mode 100644 samples/shared-base-config/vitest.config.base.ts create mode 100644 test/e2e/shared-base-config.test.ts diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 0de203e1..55e6e935 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -1,5 +1,6 @@ import type { VitestAPI } from './api' import type { VitestProcessAPI } from './apiProcess' +import type { VitestPackage } from './spawn/pkg' import { basename, normalize, relative } from 'pathe' import * as vscode from 'vscode' import { version } from '../../../package.json' @@ -128,6 +129,7 @@ class VitestExtension { const previousRunProfiles = this.runProfiles this.runProfiles = new Map() + const resolvedConfigs = new Set() try { await this.api?.dispose() @@ -140,6 +142,8 @@ class VitestExtension { profile.dispose() } + this.registerRunProfiles([...workspaces, ...configs]) + this.api = await resolveVitestAPI( workspaces, configs, @@ -149,6 +153,7 @@ class VitestExtension { return } + resolvedConfigs.add(vitest.id) this.testTree.watchTestFilesInWorkspace(vitest, files) this.setupProcessAPI(vitest) @@ -165,6 +170,15 @@ class VitestExtension { return } finally { this.testController.items.delete(this.loadingTestItem.id) + + // dispose profiles of configs that never resolved + for (const [key, profile] of this.runProfiles) { + const configId = key.slice(0, key.lastIndexOf(':')) + if (!resolvedConfigs.has(configId)) { + profile.dispose() + this.runProfiles.delete(key) + } + } } this.api.processes.forEach((process) => { @@ -193,6 +207,43 @@ class VitestExtension { }) } + private registerRunProfiles(packages: VitestPackage[]) { + // when several profiles can run the same test item, vscode invokes the one + // that was registered first, so the most specific config has to go first (#799) + const sortedPackages = [...packages].sort((a, b) => { + return normalize(b.id).split('/').length - normalize(a.id).split('/').length + }) + + const kinds: [string, vscode.TestRunProfileKind, boolean][] = [ + ['run', vscode.TestRunProfileKind.Run, true], + // continuous run is not supported for debug and coverage + ['debug', vscode.TestRunProfileKind.Debug, false], + ['coverage', vscode.TestRunProfileKind.Coverage, false], + ] + + for (const pkg of sortedPackages) { + const id = normalize(pkg.id) + const tag = new vscode.TestTag(pkg.prefix) + for (const [name, kind, supportsContinuousRun] of kinds) { + const key = `${id}:${name}` + if (this.runProfiles.has(key)) { + continue + } + const profile = this.testController.createRunProfile( + pkg.prefix, + kind, + () => { + log.error('Run handler is not defined') + }, + true, + tag, + supportsContinuousRun, + ) + this.runProfiles.set(key, profile) + } + } + } + private setupProcessAPI(vitest: VitestProcessAPI) { // Register collection listener so test tree gets notified when tests are collected vitest.onCollected((file) => { diff --git a/packages/extension/src/testTree.ts b/packages/extension/src/testTree.ts index 8a85b84d..42ba4b64 100644 --- a/packages/extension/src/testTree.ts +++ b/packages/extension/src/testTree.ts @@ -146,7 +146,11 @@ export class TestTree extends vscode.Disposable { const normalizedFile = normalize(file) const fileId = `${normalizedFile}${project}` const cached = this.fileItems.get(fileId) - if (cached) return cached + if (cached) { + // the file can be included in several configs (#799) + if (!cached.tags.includes(api.tag)) cached.tags = [...cached.tags, api.tag] + return cached + } const fileUri = vscode.Uri.file(resolve(file)) const parentItem = this.getOrCreateFolderTestItem(api, dirname(file)) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c0423f7..f1d17595 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -551,6 +551,12 @@ importers: specifier: catalog:latest version: 4.1.0(@types/node@24.10.1)(@vitest/browser-playwright@4.1.0)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) + samples/shared-base-config: + devDependencies: + vitest: + specifier: catalog:latest + version: 4.1.0(@types/node@24.10.1)(@vitest/browser-playwright@4.1.0)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) + samples/vue: dependencies: vue: diff --git a/samples/shared-base-config/README.md b/samples/shared-base-config/README.md new file mode 100644 index 00000000..dfaf4826 --- /dev/null +++ b/samples/shared-base-config/README.md @@ -0,0 +1,15 @@ +# Shared base config + +Reproduces [#799](https://github.com/vitest-dev/vscode/issues/799): a shared +base config (`vitest.config.base.ts`) matches the extension's `configGlob` and +claims the same test files as the package-level config that merges it. + +``` +vitest.config.base.ts CONFIG_NAME=base +packages/foo/vitest.config.ts mergeConfig(base) + CONFIG_NAME=leaf +packages/foo/test/which-config.test.ts passes only with CONFIG_NAME=leaf +``` + +The gutter "Run Test" button must execute the test with the most specific +config (`packages/foo/vitest.config.ts`). Covered by +`test/e2e/shared-base-config.test.ts`. diff --git a/samples/shared-base-config/package.json b/samples/shared-base-config/package.json new file mode 100644 index 00000000..b59dcd82 --- /dev/null +++ b/samples/shared-base-config/package.json @@ -0,0 +1,12 @@ +{ + "name": "@vitest/vscode-sample-shared-base-config", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "test": "vitest" + }, + "devDependencies": { + "vitest": "catalog:latest" + } +} diff --git a/samples/shared-base-config/packages/foo/test/which-config.test.ts b/samples/shared-base-config/packages/foo/test/which-config.test.ts new file mode 100644 index 00000000..273749da --- /dev/null +++ b/samples/shared-base-config/packages/foo/test/which-config.test.ts @@ -0,0 +1,7 @@ +import { expect, it } from 'vitest' + +// only passes when run with packages/foo/vitest.config.ts, +// not with the discovered root vitest.config.base.ts (#799) +it('runs with the leaf config', () => { + expect(process.env.CONFIG_NAME).toBe('leaf') +}) diff --git a/samples/shared-base-config/packages/foo/vitest.config.ts b/samples/shared-base-config/packages/foo/vitest.config.ts new file mode 100644 index 00000000..63c17515 --- /dev/null +++ b/samples/shared-base-config/packages/foo/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import baseConfig from '../../vitest.config.base' + +export default mergeConfig( + baseConfig, + defineConfig({ + test: { + env: { + CONFIG_NAME: 'leaf', + }, + }, + }), +) diff --git a/samples/shared-base-config/vitest.config.base.ts b/samples/shared-base-config/vitest.config.base.ts new file mode 100644 index 00000000..b17967b7 --- /dev/null +++ b/samples/shared-base-config/vitest.config.base.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config' + +// shared base config that is only meant to be merged into other configs, +// but still matches the extension's configGlob (#799) +export default defineConfig({ + test: { + env: { + CONFIG_NAME: 'base', + }, + }, +}) diff --git a/test/e2e/shared-base-config.test.ts b/test/e2e/shared-base-config.test.ts new file mode 100644 index 00000000..4302cddc --- /dev/null +++ b/test/e2e/shared-base-config.test.ts @@ -0,0 +1,60 @@ +import { readFileSync } from 'node:fs' +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' +import { beforeAll, vi } from 'vitest' +import { test } from './utils/helper' + +// Vitest extension doesn't work with CI flag +beforeAll(() => { + delete process.env.CI + delete process.env.GITHUB_ACTIONS +}) + +async function openFileViaQuickOpen(page: Page, fileName: string) { + await page.keyboard.press('ControlOrMeta+P') + await page.keyboard.type(fileName) + // pressing Enter would race the async population of the result list + const row = page.locator('.quick-input-widget .monaco-list-row', { hasText: fileName }).first() + await row.waitFor({ state: 'visible', timeout: 30_000 }) + await row.click() + await page + .locator('.tabs-container .tab', { hasText: fileName }) + .first() + .waitFor({ state: 'visible', timeout: 30_000 }) +} + +// https://github.com/vitest-dev/vscode/issues/799 +test('gutter run uses the most specific config, not the shared base config', async ({ + launch, + logPath, +}) => { + const { page, tester } = await launch({ + workspacePath: './samples/shared-base-config', + }) + + // wait until both discovered configs are resolved + await vi.waitUntil( + () => { + try { + const log = readFileSync(logPath, 'utf-8') + return ( + log.includes('Watching vitest.config.base.ts') && + log.includes('Watching packages/foo/vitest.config.ts') + ) + } catch { + return false + } + }, + { timeout: 60_000 }, + ) + + await openFileViaQuickOpen(page, 'which-config.test.ts') + + const glyph = page.locator('.glyph-margin-widgets .testing-run-glyph').first() + await glyph.waitFor({ state: 'visible', timeout: 60_000 }) + await glyph.click() + + // the test only passes under packages/foo/vitest.config.ts; + // if the shared base config ran it instead, the summary shows 0/1 + await expect(tester.tree.getResultsLocator()).toHaveText('1/1', { timeout: 30_000 }) +}) From db7b8c6f9f8dee2da2f845ef4d18892c6baaed3e Mon Sep 17 00:00:00 2001 From: Benny Neugebauer Date: Fri, 7 Aug 2026 11:11:57 +0200 Subject: [PATCH 2/3] fix: explain config profile for tag errors --- packages/extension/src/extension.ts | 51 ------------------- packages/extension/src/profileError.ts | 14 +++++ packages/extension/src/runner.ts | 15 ++++-- packages/extension/src/testTree.ts | 9 +++- samples/shared-base-config/README.md | 13 ++--- .../packages/foo/test/tagged.test.ts | 5 ++ .../packages/foo/test/which-config.test.ts | 7 --- .../packages/foo/vitest.config.ts | 9 ++-- .../shared-base-config/vitest.config.base.ts | 8 ++- test/e2e/shared-base-config.test.ts | 37 +++++--------- test/unit/profileError.test.ts | 23 +++++++++ 11 files changed, 88 insertions(+), 103 deletions(-) create mode 100644 packages/extension/src/profileError.ts create mode 100644 samples/shared-base-config/packages/foo/test/tagged.test.ts delete mode 100644 samples/shared-base-config/packages/foo/test/which-config.test.ts create mode 100644 test/unit/profileError.test.ts diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 55e6e935..0de203e1 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -1,6 +1,5 @@ import type { VitestAPI } from './api' import type { VitestProcessAPI } from './apiProcess' -import type { VitestPackage } from './spawn/pkg' import { basename, normalize, relative } from 'pathe' import * as vscode from 'vscode' import { version } from '../../../package.json' @@ -129,7 +128,6 @@ class VitestExtension { const previousRunProfiles = this.runProfiles this.runProfiles = new Map() - const resolvedConfigs = new Set() try { await this.api?.dispose() @@ -142,8 +140,6 @@ class VitestExtension { profile.dispose() } - this.registerRunProfiles([...workspaces, ...configs]) - this.api = await resolveVitestAPI( workspaces, configs, @@ -153,7 +149,6 @@ class VitestExtension { return } - resolvedConfigs.add(vitest.id) this.testTree.watchTestFilesInWorkspace(vitest, files) this.setupProcessAPI(vitest) @@ -170,15 +165,6 @@ class VitestExtension { return } finally { this.testController.items.delete(this.loadingTestItem.id) - - // dispose profiles of configs that never resolved - for (const [key, profile] of this.runProfiles) { - const configId = key.slice(0, key.lastIndexOf(':')) - if (!resolvedConfigs.has(configId)) { - profile.dispose() - this.runProfiles.delete(key) - } - } } this.api.processes.forEach((process) => { @@ -207,43 +193,6 @@ class VitestExtension { }) } - private registerRunProfiles(packages: VitestPackage[]) { - // when several profiles can run the same test item, vscode invokes the one - // that was registered first, so the most specific config has to go first (#799) - const sortedPackages = [...packages].sort((a, b) => { - return normalize(b.id).split('/').length - normalize(a.id).split('/').length - }) - - const kinds: [string, vscode.TestRunProfileKind, boolean][] = [ - ['run', vscode.TestRunProfileKind.Run, true], - // continuous run is not supported for debug and coverage - ['debug', vscode.TestRunProfileKind.Debug, false], - ['coverage', vscode.TestRunProfileKind.Coverage, false], - ] - - for (const pkg of sortedPackages) { - const id = normalize(pkg.id) - const tag = new vscode.TestTag(pkg.prefix) - for (const [name, kind, supportsContinuousRun] of kinds) { - const key = `${id}:${name}` - if (this.runProfiles.has(key)) { - continue - } - const profile = this.testController.createRunProfile( - pkg.prefix, - kind, - () => { - log.error('Run handler is not defined') - }, - true, - tag, - supportsContinuousRun, - ) - this.runProfiles.set(key, profile) - } - } - } - private setupProcessAPI(vitest: VitestProcessAPI) { // Register collection listener so test tree gets notified when tests are collected vitest.onCollected((file) => { diff --git a/packages/extension/src/profileError.ts b/packages/extension/src/profileError.ts new file mode 100644 index 00000000..8137834f --- /dev/null +++ b/packages/extension/src/profileError.ts @@ -0,0 +1,14 @@ +const missingTagsMessage = 'define any "tags"' + +export function addProfileHint(message: string, profile: string): string { + if (!message.includes('Vitest config') || !message.includes(missingTagsMessage)) { + return message + } + + return [ + message, + '', + `Vitest used the "${profile}" profile for this test.`, + 'If another config defines this tag, select its profile with "Execute Using Profile..." or disable this one with "Vitest: Toggle Configs".', + ].join('\n') +} diff --git a/packages/extension/src/runner.ts b/packages/extension/src/runner.ts index 93ee93ce..e97530ee 100644 --- a/packages/extension/src/runner.ts +++ b/packages/extension/src/runner.ts @@ -14,6 +14,7 @@ import * as vscode from 'vscode' import { getConfig } from './config' import { coverageContext } from './coverage' import { log } from './log' +import { addProfileHint } from './profileError' import { getTestData, TestCase, TestFile, TestFolder, TestSuite } from './testTreeData' import { getErrorMessage, showVitestError } from './utils' @@ -276,7 +277,9 @@ export class TestRunner extends vscode.Disposable { switch (result.state) { case 'fail': { const errors = - result.errors?.map((err) => testMessageForTestError(test, err as TestError)) || [] + result.errors?.map((err) => + testMessageForTestError(test, err as TestError, this.api.prefix), + ) || [] if (!errors.length) { log.verbose?.(`Test failed, but no errors found for "${test.label}"`) return @@ -321,7 +324,9 @@ export class TestRunner extends vscode.Disposable { } // errors in a suite are stored only if it happens during discovery - const errors = result.errors?.map((err) => testMessageForTestError(test, err as TestError)) + const errors = result.errors?.map((err) => + testMessageForTestError(test, err as TestError, this.api.prefix), + ) if (!errors?.length) { log.verbose?.(`No errors found for "${test.label}"`) return @@ -502,18 +507,20 @@ function setTestErrors(test: vscode.TestItem, errors: TestError[] | undefined) { function testMessageForTestError( testItem: vscode.TestItem, error: TestError | undefined, + profile: string, ): vscode.TestMessage { if (!error) return new vscode.TestMessage('Unknown error') let testMessage + const message = addProfileHint(getErrorMessage(error), profile) if ( error.actual != null && error.expected != null && error.actual !== 'undefined' && error.expected !== 'undefined' ) - testMessage = vscode.TestMessage.diff(getErrorMessage(error), error.expected, error.actual) - else testMessage = new vscode.TestMessage(getErrorMessage(error)) + testMessage = vscode.TestMessage.diff(message, error.expected, error.actual) + else testMessage = new vscode.TestMessage(message) setMessageStackFramesFromErrorStacks(testMessage, error.stacks) diff --git a/packages/extension/src/testTree.ts b/packages/extension/src/testTree.ts index 42ba4b64..bd1aee9c 100644 --- a/packages/extension/src/testTree.ts +++ b/packages/extension/src/testTree.ts @@ -9,6 +9,7 @@ import { resolve } from 'node:path' import { basename, dirname, normalize } from 'pathe' import * as vscode from 'vscode' import { log } from './log' +import { addProfileHint } from './profileError' import { getTestData, TestCase, TestFile, TestFolder, TestSuite } from './testTreeData' import { ExtensionWatcher } from './watcher' @@ -318,7 +319,9 @@ export class TestTree extends vscode.Disposable { 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') + const error = file.result.errors + .map((error) => addProfileHint(error.stack || error.message, api.prefix)) + .join('\n') fileTestItem.error = error log.error(`Error in ${file.filepath}`, error) } else if (!file.tasks.length) { @@ -448,7 +451,9 @@ export class TestTree extends vscode.Disposable { // errors during collection are not test failures, they need to be // displayed as errors in the tree if (task.result?.errors) { - const error = task.result.errors.map((error) => error.stack).join('\n') + const error = task.result.errors + .map((error) => addProfileHint(error.stack || error.message, tag.id)) + .join('\n') testItem.error = error } diff --git a/samples/shared-base-config/README.md b/samples/shared-base-config/README.md index dfaf4826..f34da55c 100644 --- a/samples/shared-base-config/README.md +++ b/samples/shared-base-config/README.md @@ -5,11 +5,12 @@ base config (`vitest.config.base.ts`) matches the extension's `configGlob` and claims the same test files as the package-level config that merges it. ``` -vitest.config.base.ts CONFIG_NAME=base -packages/foo/vitest.config.ts mergeConfig(base) + CONFIG_NAME=leaf -packages/foo/test/which-config.test.ts passes only with CONFIG_NAME=leaf +vitest.config.base.ts shared config with no tags +packages/foo/vitest.config.ts mergeConfig(base) + leaf tag definition +packages/foo/test/tagged.test.ts test using the leaf tag ``` -The gutter "Run Test" button must execute the test with the most specific -config (`packages/foo/vitest.config.ts`). Covered by -`test/e2e/shared-base-config.test.ts`. +The extension resolves the shallow root config first, so opening the file +collects the test with that config and reproduces the missing-tags error. The +end-to-end test verifies that the error identifies the selected profile and +explains how to choose another one. diff --git a/samples/shared-base-config/packages/foo/test/tagged.test.ts b/samples/shared-base-config/packages/foo/test/tagged.test.ts new file mode 100644 index 00000000..385b2de4 --- /dev/null +++ b/samples/shared-base-config/packages/foo/test/tagged.test.ts @@ -0,0 +1,5 @@ +import { expect, it } from 'vitest' + +it('runs with the leaf tag', { tags: ['leaf'] }, () => { + expect(true).toBe(true) +}) diff --git a/samples/shared-base-config/packages/foo/test/which-config.test.ts b/samples/shared-base-config/packages/foo/test/which-config.test.ts deleted file mode 100644 index 273749da..00000000 --- a/samples/shared-base-config/packages/foo/test/which-config.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { expect, it } from 'vitest' - -// only passes when run with packages/foo/vitest.config.ts, -// not with the discovered root vitest.config.base.ts (#799) -it('runs with the leaf config', () => { - expect(process.env.CONFIG_NAME).toBe('leaf') -}) diff --git a/samples/shared-base-config/packages/foo/vitest.config.ts b/samples/shared-base-config/packages/foo/vitest.config.ts index 63c17515..2e15e016 100644 --- a/samples/shared-base-config/packages/foo/vitest.config.ts +++ b/samples/shared-base-config/packages/foo/vitest.config.ts @@ -5,9 +5,12 @@ export default mergeConfig( baseConfig, defineConfig({ test: { - env: { - CONFIG_NAME: 'leaf', - }, + tags: [ + { + description: 'Defined only in the package config.', + name: 'leaf', + }, + ], }, }), ) diff --git a/samples/shared-base-config/vitest.config.base.ts b/samples/shared-base-config/vitest.config.base.ts index b17967b7..5484df18 100644 --- a/samples/shared-base-config/vitest.config.base.ts +++ b/samples/shared-base-config/vitest.config.base.ts @@ -1,11 +1,9 @@ import { defineConfig } from 'vitest/config' -// shared base config that is only meant to be merged into other configs, -// but still matches the extension's configGlob (#799) +// Shared base config that is only meant to be merged into other configs, +// but still matches the extension's configGlob and defines no tags (#799). export default defineConfig({ test: { - env: { - CONFIG_NAME: 'base', - }, + environment: 'node', }, }) diff --git a/test/e2e/shared-base-config.test.ts b/test/e2e/shared-base-config.test.ts index 4302cddc..e6ccba62 100644 --- a/test/e2e/shared-base-config.test.ts +++ b/test/e2e/shared-base-config.test.ts @@ -1,6 +1,4 @@ import { readFileSync } from 'node:fs' -import type { Page } from '@playwright/test' -import { expect } from '@playwright/test' import { beforeAll, vi } from 'vitest' import { test } from './utils/helper' @@ -10,25 +8,12 @@ beforeAll(() => { delete process.env.GITHUB_ACTIONS }) -async function openFileViaQuickOpen(page: Page, fileName: string) { - await page.keyboard.press('ControlOrMeta+P') - await page.keyboard.type(fileName) - // pressing Enter would race the async population of the result list - const row = page.locator('.quick-input-widget .monaco-list-row', { hasText: fileName }).first() - await row.waitFor({ state: 'visible', timeout: 30_000 }) - await row.click() - await page - .locator('.tabs-container .tab', { hasText: fileName }) - .first() - .waitFor({ state: 'visible', timeout: 30_000 }) -} - // https://github.com/vitest-dev/vscode/issues/799 -test('gutter run uses the most specific config, not the shared base config', async ({ +test('collection identifies the selected profile when configs overlap', async ({ launch, logPath, }) => { - const { page, tester } = await launch({ + const { tester } = await launch({ workspacePath: './samples/shared-base-config', }) @@ -48,13 +33,15 @@ test('gutter run uses the most specific config, not the shared base config', asy { timeout: 60_000 }, ) - await openFileViaQuickOpen(page, 'which-config.test.ts') - - const glyph = page.locator('.glyph-margin-widgets .testing-run-glyph').first() - await glyph.waitFor({ state: 'visible', timeout: 60_000 }) - await glyph.click() + await tester.tree.expand('packages/foo/test/tagged.test.ts') - // the test only passes under packages/foo/vitest.config.ts; - // if the shared base config ran it instead, the summary shows 0/1 - await expect(tester.tree.getResultsLocator()).toHaveText('1/1', { timeout: 30_000 }) + // Opening the file collects it through the first, root config. The tag exists + // only in the package config, so collection fails before a gutter action exists. + await vi.waitUntil( + () => + readFileSync(logPath, 'utf-8').includes( + 'Vitest used the "shared-base-config:vitest.config.base.ts" profile for this test.', + ), + { timeout: 30_000 }, + ) }) diff --git a/test/unit/profileError.test.ts b/test/unit/profileError.test.ts new file mode 100644 index 00000000..a84214eb --- /dev/null +++ b/test/unit/profileError.test.ts @@ -0,0 +1,23 @@ +import { expect } from 'chai' +import { addProfileHint } from '../../packages/extension/src/profileError' + +describe('profile error hint', () => { + it('identifies the profile used for a missing tags configuration error', () => { + const error = + 'Error: The Vitest config does\'t define any "tags", cannot apply "integration" tag for this test.' + + expect(addProfileHint(error, 'workspace:vitest.config.base.ts')).to.equal( + [ + error, + '', + 'Vitest used the "workspace:vitest.config.base.ts" profile for this test.', + 'If another config defines this tag, select its profile with "Execute Using Profile..." or disable this one with "Vitest: Toggle Configs".', + ].join('\n'), + ) + }) + + it('does not change unrelated errors', () => { + const error = 'AssertionError: expected 1 to equal 2' + expect(addProfileHint(error, 'workspace:vitest.config.ts')).to.equal(error) + }) +}) From f2348036d2407becff6601d93d9ed48aa7002a0f Mon Sep 17 00:00:00 2001 From: Benny Neugebauer Date: Fri, 7 Aug 2026 11:24:37 +0200 Subject: [PATCH 3/3] refactor: remove profile error hint --- packages/extension/src/profileError.ts | 14 ------ packages/extension/src/runner.ts | 15 ++---- packages/extension/src/testTree.ts | 9 +--- pnpm-lock.yaml | 6 --- samples/shared-base-config/README.md | 16 ------- samples/shared-base-config/package.json | 12 ----- .../packages/foo/test/tagged.test.ts | 5 -- .../packages/foo/vitest.config.ts | 16 ------- .../shared-base-config/vitest.config.base.ts | 9 ---- test/e2e/shared-base-config.test.ts | 47 ------------------- test/unit/profileError.test.ts | 23 --------- test/unit/testTree.test.ts | 44 +++++++++++++++++ 12 files changed, 50 insertions(+), 166 deletions(-) delete mode 100644 packages/extension/src/profileError.ts delete mode 100644 samples/shared-base-config/README.md delete mode 100644 samples/shared-base-config/package.json delete mode 100644 samples/shared-base-config/packages/foo/test/tagged.test.ts delete mode 100644 samples/shared-base-config/packages/foo/vitest.config.ts delete mode 100644 samples/shared-base-config/vitest.config.base.ts delete mode 100644 test/e2e/shared-base-config.test.ts delete mode 100644 test/unit/profileError.test.ts create mode 100644 test/unit/testTree.test.ts diff --git a/packages/extension/src/profileError.ts b/packages/extension/src/profileError.ts deleted file mode 100644 index 8137834f..00000000 --- a/packages/extension/src/profileError.ts +++ /dev/null @@ -1,14 +0,0 @@ -const missingTagsMessage = 'define any "tags"' - -export function addProfileHint(message: string, profile: string): string { - if (!message.includes('Vitest config') || !message.includes(missingTagsMessage)) { - return message - } - - return [ - message, - '', - `Vitest used the "${profile}" profile for this test.`, - 'If another config defines this tag, select its profile with "Execute Using Profile..." or disable this one with "Vitest: Toggle Configs".', - ].join('\n') -} diff --git a/packages/extension/src/runner.ts b/packages/extension/src/runner.ts index e97530ee..93ee93ce 100644 --- a/packages/extension/src/runner.ts +++ b/packages/extension/src/runner.ts @@ -14,7 +14,6 @@ import * as vscode from 'vscode' import { getConfig } from './config' import { coverageContext } from './coverage' import { log } from './log' -import { addProfileHint } from './profileError' import { getTestData, TestCase, TestFile, TestFolder, TestSuite } from './testTreeData' import { getErrorMessage, showVitestError } from './utils' @@ -277,9 +276,7 @@ export class TestRunner extends vscode.Disposable { switch (result.state) { case 'fail': { const errors = - result.errors?.map((err) => - testMessageForTestError(test, err as TestError, this.api.prefix), - ) || [] + result.errors?.map((err) => testMessageForTestError(test, err as TestError)) || [] if (!errors.length) { log.verbose?.(`Test failed, but no errors found for "${test.label}"`) return @@ -324,9 +321,7 @@ export class TestRunner extends vscode.Disposable { } // errors in a suite are stored only if it happens during discovery - const errors = result.errors?.map((err) => - testMessageForTestError(test, err as TestError, this.api.prefix), - ) + const errors = result.errors?.map((err) => testMessageForTestError(test, err as TestError)) if (!errors?.length) { log.verbose?.(`No errors found for "${test.label}"`) return @@ -507,20 +502,18 @@ function setTestErrors(test: vscode.TestItem, errors: TestError[] | undefined) { function testMessageForTestError( testItem: vscode.TestItem, error: TestError | undefined, - profile: string, ): vscode.TestMessage { if (!error) return new vscode.TestMessage('Unknown error') let testMessage - const message = addProfileHint(getErrorMessage(error), profile) if ( error.actual != null && error.expected != null && error.actual !== 'undefined' && error.expected !== 'undefined' ) - testMessage = vscode.TestMessage.diff(message, error.expected, error.actual) - else testMessage = new vscode.TestMessage(message) + testMessage = vscode.TestMessage.diff(getErrorMessage(error), error.expected, error.actual) + else testMessage = new vscode.TestMessage(getErrorMessage(error)) setMessageStackFramesFromErrorStacks(testMessage, error.stacks) diff --git a/packages/extension/src/testTree.ts b/packages/extension/src/testTree.ts index bd1aee9c..42ba4b64 100644 --- a/packages/extension/src/testTree.ts +++ b/packages/extension/src/testTree.ts @@ -9,7 +9,6 @@ import { resolve } from 'node:path' import { basename, dirname, normalize } from 'pathe' import * as vscode from 'vscode' import { log } from './log' -import { addProfileHint } from './profileError' import { getTestData, TestCase, TestFile, TestFolder, TestSuite } from './testTreeData' import { ExtensionWatcher } from './watcher' @@ -319,9 +318,7 @@ export class TestTree extends vscode.Disposable { 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) => addProfileHint(error.stack || error.message, api.prefix)) - .join('\n') + const error = file.result.errors.map((error) => error.stack || error.message).join('\n') fileTestItem.error = error log.error(`Error in ${file.filepath}`, error) } else if (!file.tasks.length) { @@ -451,9 +448,7 @@ export class TestTree extends vscode.Disposable { // errors during collection are not test failures, they need to be // displayed as errors in the tree if (task.result?.errors) { - const error = task.result.errors - .map((error) => addProfileHint(error.stack || error.message, tag.id)) - .join('\n') + const error = task.result.errors.map((error) => error.stack).join('\n') testItem.error = error } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1d17595..1c0423f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -551,12 +551,6 @@ importers: specifier: catalog:latest version: 4.1.0(@types/node@24.10.1)(@vitest/browser-playwright@4.1.0)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) - samples/shared-base-config: - devDependencies: - vitest: - specifier: catalog:latest - version: 4.1.0(@types/node@24.10.1)(@vitest/browser-playwright@4.1.0)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) - samples/vue: dependencies: vue: diff --git a/samples/shared-base-config/README.md b/samples/shared-base-config/README.md deleted file mode 100644 index f34da55c..00000000 --- a/samples/shared-base-config/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# Shared base config - -Reproduces [#799](https://github.com/vitest-dev/vscode/issues/799): a shared -base config (`vitest.config.base.ts`) matches the extension's `configGlob` and -claims the same test files as the package-level config that merges it. - -``` -vitest.config.base.ts shared config with no tags -packages/foo/vitest.config.ts mergeConfig(base) + leaf tag definition -packages/foo/test/tagged.test.ts test using the leaf tag -``` - -The extension resolves the shallow root config first, so opening the file -collects the test with that config and reproduces the missing-tags error. The -end-to-end test verifies that the error identifies the selected profile and -explains how to choose another one. diff --git a/samples/shared-base-config/package.json b/samples/shared-base-config/package.json deleted file mode 100644 index b59dcd82..00000000 --- a/samples/shared-base-config/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "@vitest/vscode-sample-shared-base-config", - "version": "1.0.0", - "private": true, - "type": "module", - "scripts": { - "test": "vitest" - }, - "devDependencies": { - "vitest": "catalog:latest" - } -} diff --git a/samples/shared-base-config/packages/foo/test/tagged.test.ts b/samples/shared-base-config/packages/foo/test/tagged.test.ts deleted file mode 100644 index 385b2de4..00000000 --- a/samples/shared-base-config/packages/foo/test/tagged.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { expect, it } from 'vitest' - -it('runs with the leaf tag', { tags: ['leaf'] }, () => { - expect(true).toBe(true) -}) diff --git a/samples/shared-base-config/packages/foo/vitest.config.ts b/samples/shared-base-config/packages/foo/vitest.config.ts deleted file mode 100644 index 2e15e016..00000000 --- a/samples/shared-base-config/packages/foo/vitest.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineConfig, mergeConfig } from 'vitest/config' -import baseConfig from '../../vitest.config.base' - -export default mergeConfig( - baseConfig, - defineConfig({ - test: { - tags: [ - { - description: 'Defined only in the package config.', - name: 'leaf', - }, - ], - }, - }), -) diff --git a/samples/shared-base-config/vitest.config.base.ts b/samples/shared-base-config/vitest.config.base.ts deleted file mode 100644 index 5484df18..00000000 --- a/samples/shared-base-config/vitest.config.base.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from 'vitest/config' - -// Shared base config that is only meant to be merged into other configs, -// but still matches the extension's configGlob and defines no tags (#799). -export default defineConfig({ - test: { - environment: 'node', - }, -}) diff --git a/test/e2e/shared-base-config.test.ts b/test/e2e/shared-base-config.test.ts deleted file mode 100644 index e6ccba62..00000000 --- a/test/e2e/shared-base-config.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { readFileSync } from 'node:fs' -import { beforeAll, vi } from 'vitest' -import { test } from './utils/helper' - -// Vitest extension doesn't work with CI flag -beforeAll(() => { - delete process.env.CI - delete process.env.GITHUB_ACTIONS -}) - -// https://github.com/vitest-dev/vscode/issues/799 -test('collection identifies the selected profile when configs overlap', async ({ - launch, - logPath, -}) => { - const { tester } = await launch({ - workspacePath: './samples/shared-base-config', - }) - - // wait until both discovered configs are resolved - await vi.waitUntil( - () => { - try { - const log = readFileSync(logPath, 'utf-8') - return ( - log.includes('Watching vitest.config.base.ts') && - log.includes('Watching packages/foo/vitest.config.ts') - ) - } catch { - return false - } - }, - { timeout: 60_000 }, - ) - - await tester.tree.expand('packages/foo/test/tagged.test.ts') - - // Opening the file collects it through the first, root config. The tag exists - // only in the package config, so collection fails before a gutter action exists. - await vi.waitUntil( - () => - readFileSync(logPath, 'utf-8').includes( - 'Vitest used the "shared-base-config:vitest.config.base.ts" profile for this test.', - ), - { timeout: 30_000 }, - ) -}) diff --git a/test/unit/profileError.test.ts b/test/unit/profileError.test.ts deleted file mode 100644 index a84214eb..00000000 --- a/test/unit/profileError.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { expect } from 'chai' -import { addProfileHint } from '../../packages/extension/src/profileError' - -describe('profile error hint', () => { - it('identifies the profile used for a missing tags configuration error', () => { - const error = - 'Error: The Vitest config does\'t define any "tags", cannot apply "integration" tag for this test.' - - expect(addProfileHint(error, 'workspace:vitest.config.base.ts')).to.equal( - [ - error, - '', - 'Vitest used the "workspace:vitest.config.base.ts" profile for this test.', - 'If another config defines this tag, select its profile with "Execute Using Profile..." or disable this one with "Vitest: Toggle Configs".', - ].join('\n'), - ) - }) - - it('does not change unrelated errors', () => { - const error = 'AssertionError: expected 1 to equal 2' - expect(addProfileHint(error, 'workspace:vitest.config.ts')).to.equal(error) - }) -}) diff --git a/test/unit/testTree.test.ts b/test/unit/testTree.test.ts new file mode 100644 index 00000000..e2a44ce1 --- /dev/null +++ b/test/unit/testTree.test.ts @@ -0,0 +1,44 @@ +import { resolve } from 'node:path' +import * as vscode from 'vscode' +import { expect } from 'chai' +import type { VitestProcessAPI } from '../../packages/extension/src/apiProcess' +import { TransformSchemaProvider } from '../../packages/extension/src/schemaProvider' +import { TagsManager } from '../../packages/extension/src/tagsManager' +import { TestTree } from '../../packages/extension/src/testTree' + +describe('TestTree', () => { + it('adds every config tag to a shared test file', () => { + const controller = vscode.tests.createTestController('test-tree', 'Vitest') + const loader = controller.createTestItem('loader', 'Loading') + const schemaProvider = new TransformSchemaProvider(async () => null) + const tree = new TestTree(controller, loader, new TagsManager(), schemaProvider) + const root = resolve(__dirname, '../..') + const workspaceFolder = { + uri: vscode.Uri.file(root), + name: 'vscode', + index: 0, + } + const file = resolve(__dirname, 'testTree.test.ts') + const metadata = { project: '', pool: 'threads' } + const baseApi = { + tag: new vscode.TestTag('root:vitest.config.base.ts'), + } as VitestProcessAPI + const packageApi = { + tag: new vscode.TestTag('foo:vitest.config.ts'), + } as VitestProcessAPI + + tree.reset([workspaceFolder]) + const item = tree.getOrCreateFileTestItem(baseApi, metadata, file) + const cachedItem = tree.getOrCreateFileTestItem(packageApi, metadata, file) + + expect(cachedItem).to.equal(item) + expect(cachedItem.tags.map((tag) => tag.id)).to.deep.equal([ + 'root:vitest.config.base.ts', + 'foo:vitest.config.ts', + ]) + + tree.dispose() + schemaProvider.dispose() + controller.dispose() + }) +})