Skip to content

Commit e3fbd40

Browse files
committed
fix: run gutter tests with the most specific config when several configs claim the same file
When a shared base config (e.g. vitest.config.base.ts) matches the extension's configGlob, it gets its own Vitest instance that claims the same test files as the package-level configs merging it. The editor gutter "Run Test" button then executed tests with the base config, which lacks the merged test options (tags, env, ...), failing with errors like 'The Vitest config does't define any "tags"'. Two changes: 1. testTree: file items now accumulate the tags of every config that claims them (folder items already did this). Previously a file item only carried the tag of the first config that resolved it, so that config's profile was the only one VS Code could ever pick. 2. extension: run/debug/coverage profiles are registered for all discovered configs upfront - deepest config first - before any Vitest process spawns. When several profiles can run the same test item, VS Code invokes the first-registered one, so the most specific config becomes the effective default. Config resolution order is unchanged; profiles for configs that never resolve (claimed by a workspace config, disabled, or failed) are disposed after resolution. Verified with a new e2e regression test: a gutter run on a test file claimed by both a root base config and a package-level config must execute under the package-level config. Fixes #799
1 parent 4a4561c commit e3fbd40

9 files changed

Lines changed: 217 additions & 1 deletion

File tree

packages/extension/src/extension.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { VitestAPI } from './api'
22
import type { VitestProcessAPI } from './apiProcess'
3+
import type { VitestPackage } from './spawn/pkg'
34
import { basename, normalize, relative } from 'pathe'
45
import * as vscode from 'vscode'
56
import { version } from '../../../package.json'
@@ -128,6 +129,7 @@ class VitestExtension {
128129

129130
const previousRunProfiles = this.runProfiles
130131
this.runProfiles = new Map()
132+
const resolvedConfigs = new Set<string>()
131133

132134
try {
133135
await this.api?.dispose()
@@ -140,6 +142,16 @@ class VitestExtension {
140142
profile.dispose()
141143
}
142144

145+
// Register run profiles for all discovered configs upfront, most specific
146+
// config first — before any Vitest process is spawned (#799). When several
147+
// profiles can run the same test item, VS Code invokes the profile that was
148+
// registered first, so this makes e.g. `packages/foo/vitest.config.ts` win
149+
// over a root `vitest.config.base.ts` that also claims the same test files.
150+
// `setupProcessAPI` binds the real run handlers to these profiles as each
151+
// config resolves; profiles for configs that never resolve (e.g. claimed by
152+
// a workspace config) are disposed below.
153+
this.registerRunProfiles([...workspaces, ...configs])
154+
143155
this.api = await resolveVitestAPI(
144156
workspaces,
145157
configs,
@@ -149,6 +161,7 @@ class VitestExtension {
149161
return
150162
}
151163

164+
resolvedConfigs.add(vitest.id)
152165
this.testTree.watchTestFilesInWorkspace(vitest, files)
153166
this.setupProcessAPI(vitest)
154167

@@ -165,6 +178,16 @@ class VitestExtension {
165178
return
166179
} finally {
167180
this.testController.items.delete(this.loadingTestItem.id)
181+
182+
// dispose profiles whose configs never resolved — configs claimed by a
183+
// workspace config, disabled configs, or failed/cancelled resolution
184+
for (const [key, profile] of this.runProfiles) {
185+
const configId = key.slice(0, key.lastIndexOf(':'))
186+
if (!resolvedConfigs.has(configId)) {
187+
profile.dispose()
188+
this.runProfiles.delete(key)
189+
}
190+
}
168191
}
169192

170193
this.api.processes.forEach((process) => {
@@ -193,6 +216,43 @@ class VitestExtension {
193216
})
194217
}
195218

219+
private registerRunProfiles(packages: VitestPackage[]) {
220+
// deepest config first, so the most specific config becomes the profile
221+
// VS Code picks when several configs claim the same test file (#799)
222+
const sortedPackages = [...packages].sort((a, b) => {
223+
return normalize(b.id).split('/').length - normalize(a.id).split('/').length
224+
})
225+
226+
const kinds: [string, vscode.TestRunProfileKind, boolean][] = [
227+
['run', vscode.TestRunProfileKind.Run, true],
228+
// continuous debugging and coverage are not supported
229+
['debug', vscode.TestRunProfileKind.Debug, false],
230+
['coverage', vscode.TestRunProfileKind.Coverage, false],
231+
]
232+
233+
for (const pkg of sortedPackages) {
234+
const id = normalize(pkg.id)
235+
const tag = new vscode.TestTag(pkg.prefix)
236+
for (const [name, kind, supportsContinuousRun] of kinds) {
237+
const key = `${id}:${name}`
238+
if (this.runProfiles.has(key)) {
239+
continue
240+
}
241+
const profile = this.testController.createRunProfile(
242+
pkg.prefix,
243+
kind,
244+
() => {
245+
log.error('Run handler is not defined')
246+
},
247+
true,
248+
tag,
249+
supportsContinuousRun,
250+
)
251+
this.runProfiles.set(key, profile)
252+
}
253+
}
254+
}
255+
196256
private setupProcessAPI(vitest: VitestProcessAPI) {
197257
// Register collection listener so test tree gets notified when tests are collected
198258
vitest.onCollected((file) => {

packages/extension/src/testTree.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,12 @@ export class TestTree extends vscode.Disposable {
146146
const normalizedFile = normalize(file)
147147
const fileId = `${normalizedFile}${project}`
148148
const cached = this.fileItems.get(fileId)
149-
if (cached) return cached
149+
if (cached) {
150+
// another config also claims this file — add its tag so the config's
151+
// run profiles can run this item too (#799)
152+
if (!cached.tags.includes(api.tag)) cached.tags = [...cached.tags, api.tag]
153+
return cached
154+
}
150155

151156
const fileUri = vscode.Uri.file(resolve(file))
152157
const parentItem = this.getOrCreateFolderTestItem(api, dirname(file))

pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Shared base config (#799)
2+
3+
Regression workspace for [vitest-dev/vscode#799](https://github.com/vitest-dev/vscode/issues/799):
4+
a shared base config (`vitest.config.base.ts`) whose name matches the extension's
5+
`configGlob`, so the extension discovers it as a runnable config. Rooted at the
6+
workspace root with the default `include`, it claims the same test files as the
7+
package-level config that merges it.
8+
9+
```
10+
vitest.config.base.ts "base" — CONFIG_NAME=base
11+
packages/foo/vitest.config.ts "leaf" — mergeConfig(base) + CONFIG_NAME=leaf
12+
packages/foo/test/which-config.test.ts passes ONLY under the leaf config
13+
```
14+
15+
The test asserts `process.env.CONFIG_NAME === 'leaf'`, so the run result alone
16+
tells you which config executed it.
17+
18+
When several run profiles can run the same test item, VS Code invokes the
19+
profile that was **registered first**. The extension therefore registers
20+
profiles for the most specific (deepest) configs first, so the editor gutter
21+
"Run Test" button executes this workspace's tests with
22+
`packages/foo/vitest.config.ts` — not the shared base config, which does not
23+
carry the merged test options (`tags`, `env`, …).
24+
25+
Covered by `test/e2e/shared-base-config.test.ts`.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "@vitest/vscode-sample-shared-base-config",
3+
"version": "1.0.0",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"test": "vitest"
8+
},
9+
"devDependencies": {
10+
"vitest": "catalog:latest"
11+
}
12+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { expect, it } from 'vitest'
2+
3+
// Discriminator test: passes ONLY when run through packages/foo/vitest.config.ts
4+
// (the "leaf" config, CONFIG_NAME=leaf). Fails when run through the root
5+
// vitest.config.base.ts, which the extension also discovers and which claims
6+
// this file too (vitest-dev/vscode#799).
7+
it('runs with the leaf config', () => {
8+
expect(process.env.CONFIG_NAME).toBe('leaf')
9+
})
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { defineConfig, mergeConfig } from 'vitest/config'
2+
import baseConfig from '../../vitest.config.base'
3+
4+
export default mergeConfig(
5+
baseConfig,
6+
defineConfig({
7+
test: {
8+
env: {
9+
CONFIG_NAME: 'leaf',
10+
},
11+
},
12+
}),
13+
)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { defineConfig } from 'vitest/config'
2+
3+
// Shared base config — NOT meant to be run directly, only merged into
4+
// package-level configs. Its name intentionally matches the extension's
5+
// configGlob so the extension discovers it as a runnable config, which
6+
// reproduces vitest-dev/vscode#799.
7+
export default defineConfig({
8+
test: {
9+
env: {
10+
CONFIG_NAME: 'base',
11+
},
12+
},
13+
})
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { readFileSync } from 'node:fs'
2+
import type { Page } from '@playwright/test'
3+
import { expect } from '@playwright/test'
4+
import { beforeAll, vi } from 'vitest'
5+
import { test } from './utils/helper'
6+
7+
// Regression test for vitest-dev/vscode#799.
8+
//
9+
// The workspace `samples/shared-base-config` contains a shared base config
10+
// (`vitest.config.base.ts`) that matches the extension's configGlob and claims
11+
// the same test file as the package-level config that merges it. When several
12+
// run profiles can run the same test item, VS Code invokes the profile that
13+
// was registered FIRST — so the extension must register the most specific
14+
// (deepest) config's profiles first. The test file passes ONLY under the
15+
// package-level "leaf" config (it asserts CONFIG_NAME === 'leaf'), so a
16+
// passing run proves the gutter button used the right config.
17+
18+
// Vitest extension doesn't work with CI flag
19+
beforeAll(() => {
20+
delete process.env.CI
21+
delete process.env.GITHUB_ACTIONS
22+
})
23+
24+
async function openFileViaQuickOpen(page: Page, fileName: string) {
25+
// click the result row instead of pressing Enter — Enter races the async
26+
// population of the quick-open result list
27+
await page.keyboard.press('ControlOrMeta+P')
28+
await page.keyboard.type(fileName)
29+
const row = page.locator('.quick-input-widget .monaco-list-row', { hasText: fileName }).first()
30+
await row.waitFor({ state: 'visible', timeout: 30_000 })
31+
await row.click()
32+
await page
33+
.locator('.tabs-container .tab', { hasText: fileName })
34+
.first()
35+
.waitFor({ state: 'visible', timeout: 30_000 })
36+
}
37+
38+
test('gutter run uses the most specific config, not the shared base config', async ({
39+
launch,
40+
logPath,
41+
}) => {
42+
const { page, tester } = await launch({
43+
workspacePath: './samples/shared-base-config',
44+
})
45+
46+
// wait until BOTH discovered configs are resolved — clicking earlier could
47+
// pass spuriously with only the leaf config registered
48+
await vi.waitUntil(
49+
() => {
50+
try {
51+
const log = readFileSync(logPath, 'utf-8')
52+
return (
53+
log.includes('Watching vitest.config.base.ts') &&
54+
log.includes('Watching packages/foo/vitest.config.ts')
55+
)
56+
} catch {
57+
return false
58+
}
59+
},
60+
{ timeout: 60_000 },
61+
)
62+
63+
await openFileViaQuickOpen(page, 'which-config.test.ts')
64+
65+
// the run glyph appears in the editor margin once tests are discovered
66+
const glyph = page.locator('.glyph-margin-widgets .testing-run-glyph').first()
67+
await glyph.waitFor({ state: 'visible', timeout: 60_000 })
68+
await glyph.click()
69+
70+
// the test passes only under packages/foo/vitest.config.ts; if the shared
71+
// base config ran it instead, the summary shows 0/1
72+
await expect(tester.tree.getResultsLocator()).toHaveText('1/1', { timeout: 30_000 })
73+
})

0 commit comments

Comments
 (0)