From 1de0aa22dd6311a93546a75a3c58a6be519c1baf Mon Sep 17 00:00:00 2001 From: Vladimir Date: Wed, 11 Mar 2026 15:25:37 +0100 Subject: [PATCH] fix: correctly identify concurrent test during static analysis (#9846) --- packages/vitest/src/node/ast-collect.ts | 101 ++++++--- test/cli/test/static-collect.test.ts | 284 ++++++++++++++++++++++++ 2 files changed, 360 insertions(+), 25 deletions(-) diff --git a/packages/vitest/src/node/ast-collect.ts b/packages/vitest/src/node/ast-collect.ts index 7ea1b2c94479..3a662f1b2258 100644 --- a/packages/vitest/src/node/ast-collect.ts +++ b/packages/vitest/src/node/ast-collect.ts @@ -1,5 +1,4 @@ import type { File, Suite, Task, Test } from '@vitest/runner' -import type { Property } from 'estree' import type { SerializedConfig } from '../runtime/config' import type { TestError } from '../types/general' import type { TestProject } from './project' @@ -46,6 +45,8 @@ interface LocalCallDefinition { mode: 'run' | 'skip' | 'only' | 'todo' | 'queued' task: ParsedSuite | ParsedFile | ParsedTest dynamic: boolean + concurrent: boolean + sequential: boolean tags: string[] } @@ -103,8 +104,8 @@ function astParseFile(filepath: string, code: string) { ) { return getName(callee.property) } - // call as `__vite_ssr__.test.skip()` - return getName(callee.object?.property) + // call as `__vite_ssr__.test.skip()` or `describe.concurrent.each()` + return getName(callee.object) } // unwrap (0, ...) if (callee.type === 'SequenceExpression' && callee.expressions.length === 2) { @@ -116,6 +117,29 @@ function astParseFile(filepath: string, code: string) { return null } + const getProperties = (callee: any): string[] => { + if (!callee) { + return [] + } + if (callee.type === 'Identifier') { + return [] + } + if (callee.type === 'CallExpression') { + return getProperties(callee.callee) + } + if (callee.type === 'TaggedTemplateExpression') { + return getProperties(callee.tag) + } + if (callee.type === 'MemberExpression') { + const props = getProperties(callee.object) + if (callee.property?.name) { + props.push(callee.property.name) + } + return props + } + return [] + } + walkAst(ast as any, { CallExpression(node) { const { callee } = node as any @@ -127,12 +151,24 @@ function astParseFile(filepath: string, code: string) { verbose?.(`Skipping ${name} (unknown call)`) return } + const properties = getProperties(callee) const property = callee?.property?.name - let mode = !property || property === name ? 'run' : property - // they will be picked up in the next iteration - if (['each', 'for', 'skipIf', 'runIf', 'extend', 'scoped', 'override'].includes(mode)) { + // intermediate calls like .each(), .for() will be picked up in the next iteration + if (property && ['each', 'for', 'skipIf', 'runIf', 'extend', 'scoped', 'override'].includes(property)) { return } + // derive mode from the full chain (handles any order like .skip.concurrent or .concurrent.skip) + let mode: 'run' | 'skip' | 'only' | 'todo' = 'run' + for (const prop of properties) { + if (prop === 'skip' || prop === 'only' || prop === 'todo') { + mode = prop + } + else if (prop === 'skipIf' || prop === 'runIf') { + mode = 'skip' + } + } + let isConcurrent = properties.includes('concurrent') + let isSequential = properties.includes('sequential') let start: number const end = node.end @@ -179,11 +215,6 @@ function astParseFile(filepath: string, code: string) { // Vitest module mocker injects these .replace(/__vi_import_\d+__\./g, '') - // cannot statically analyze, so we always skip it - if (mode === 'skipIf' || mode === 'runIf') { - mode = 'skip' - } - const parentCalleeName = typeof callee?.callee === 'object' && callee?.callee.type === 'MemberExpression' && callee?.callee.property?.name let isDynamicEach = parentCalleeName === 'each' || parentCalleeName === 'for' if (!isDynamicEach && callee.type === 'TaggedTemplateExpression') { @@ -191,27 +222,39 @@ function astParseFile(filepath: string, code: string) { isDynamicEach = property === 'each' || property === 'for' } - // Extract tags from the second argument if it's an options object + // Extract options from the second argument if it's an options object const tags: string[] = [] const secondArg = node.arguments?.[1] if (secondArg?.type === 'ObjectExpression') { - const tagsProperty = secondArg.properties?.find( - (p: any) => p.type === 'Property' && p.key?.type === 'Identifier' && p.key.name === 'tags', - ) as Property | undefined - if (tagsProperty) { - const tagsValue = tagsProperty.value - if (tagsValue?.type === 'Literal' && typeof tagsValue.value === 'string') { - // tags: 'single-tag' - tags.push(tagsValue.value) + for (const prop of (secondArg.properties || []) as any[]) { + if (prop.type !== 'Property' || prop.key?.type !== 'Identifier') { + continue } - else if (tagsValue?.type === 'ArrayExpression') { - // tags: ['tag1', 'tag2'] - for (const element of tagsValue.elements || []) { - if (element?.type === 'Literal' && typeof element.value === 'string') { - tags.push(element.value) + const keyName = prop.key.name + if (keyName === 'tags') { + const tagsValue = prop.value + if (tagsValue?.type === 'Literal' && typeof tagsValue.value === 'string') { + tags.push(tagsValue.value) + } + else if (tagsValue?.type === 'ArrayExpression') { + for (const element of tagsValue.elements || []) { + if (element?.type === 'Literal' && typeof element.value === 'string') { + tags.push(element.value) + } } } } + else if (prop.value?.type === 'Literal' && prop.value.value === true) { + if (keyName === 'skip' || keyName === 'only' || keyName === 'todo') { + mode = keyName + } + else if (keyName === 'concurrent') { + isConcurrent = true + } + else if (keyName === 'sequential') { + isSequential = true + } + } } } @@ -224,6 +267,8 @@ function astParseFile(filepath: string, code: string) { mode, task: null as any, dynamic: isDynamicEach, + concurrent: isConcurrent, + sequential: isSequential, tags, } satisfies LocalCallDefinition) }, @@ -366,6 +411,10 @@ function createFileTask( // Inherit tags from parent suite and merge with own tags const parentTags = latestSuite.tags || [] const taskTags = unique([...parentTags, ...definition.tags]) + // resolve concurrent/sequential: sequential cancels inherited concurrent + const concurrent = definition.sequential + ? undefined + : (definition.concurrent || latestSuite.concurrent || undefined) if (definition.type === 'suite') { const task: ParsedSuite = { @@ -376,6 +425,7 @@ function createFileTask( tasks: [], mode, each: definition.dynamic, + concurrent, name: definition.name, fullName: createTaskName([latestSuite.fullName, definition.name]), fullTestName: createTaskName([latestSuite.fullTestName, definition.name]), @@ -398,6 +448,7 @@ function createFileTask( suite: latestSuite, file, each: definition.dynamic, + concurrent, mode, context: {} as any, // not used on the server name: definition.name, diff --git a/test/cli/test/static-collect.test.ts b/test/cli/test/static-collect.test.ts index 12eb886b3253..89e0250c59d8 100644 --- a/test/cli/test/static-collect.test.ts +++ b/test/cli/test/static-collect.test.ts @@ -1046,6 +1046,289 @@ test('collects tags with other options', async () => { `) }) +test('sequential cancels inherited concurrent', async () => { + const testModule = await collectTests(` + import { test, describe } from 'vitest' + + describe.concurrent('concurrent suite', () => { + test('inherits concurrent', () => {}) + + describe.sequential('sequential nested', () => { + test('not concurrent', () => {}) + }) + + describe('regular nested', () => { + test('still concurrent', () => {}) + }) + }) +`) + expect(testModule).toMatchInlineSnapshot(` + { + "concurrent suite": { + "inherits concurrent": { + "concurrent": true, + "errors": [], + "fullName": "concurrent suite > inherits concurrent", + "id": "-1732721377_0_0", + "location": "5:6", + "mode": "run", + "state": "pending", + }, + "regular nested": { + "still concurrent": { + "concurrent": true, + "errors": [], + "fullName": "concurrent suite > regular nested > still concurrent", + "id": "-1732721377_0_2_0", + "location": "12:8", + "mode": "run", + "state": "pending", + }, + }, + "sequential nested": { + "not concurrent": { + "errors": [], + "fullName": "concurrent suite > sequential nested > not concurrent", + "id": "-1732721377_0_1_0", + "location": "8:8", + "mode": "run", + "state": "pending", + }, + }, + }, + } + `) +}) + +test('collects tests with sequential modifier', async () => { + const testModule = await collectTests(` + import { test, describe } from 'vitest' + + describe.sequential('sequential suite', () => { + test('test in sequential suite', () => {}) + }) + + test.sequential('sequential test', () => {}) +`) + expect(testModule).toMatchInlineSnapshot(` + { + "sequential suite": { + "test in sequential suite": { + "errors": [], + "fullName": "sequential suite > test in sequential suite", + "id": "-1732721377_0_0", + "location": "5:6", + "mode": "run", + "state": "pending", + }, + }, + "sequential test": { + "errors": [], + "fullName": "sequential test", + "id": "-1732721377_1", + "location": "8:4", + "mode": "run", + "state": "pending", + }, + } + `) +}) + +test('collects tests with concurrent modifier in different order', async () => { + const testModule = await collectTests(` + import { test, describe } from 'vitest' + + describe.skip.concurrent('concurrent suite', () => { + test('test in concurrent suite', () => {}) + }) + + test('test outside concurrent suite', () => {}) +`) + expect(testModule).toMatchInlineSnapshot(` + { + "concurrent suite": { + "test in concurrent suite": { + "concurrent": true, + "errors": [], + "fullName": "concurrent suite > test in concurrent suite", + "id": "-1732721377_0_0", + "location": "5:6", + "mode": "skip", + "state": "skipped", + }, + }, + "test outside concurrent suite": { + "errors": [], + "fullName": "test outside concurrent suite", + "id": "-1732721377_1", + "location": "8:4", + "mode": "run", + "state": "pending", + }, + } + `) +}) + +test('collects tests with options object modifiers', async () => { + const testModule = await collectTests(` + import { test, describe } from 'vitest' + + describe('options tests', () => { + test('skipped via options', { skip: true }, () => {}) + test('only via options', { only: true }, () => {}) + test('todo via options', { todo: true }, () => {}) + test('concurrent via options', { concurrent: true }, () => {}) + test('skip and concurrent via options', { skip: true, concurrent: true }, () => {}) + }) +`) + expect(testModule).toMatchInlineSnapshot(` + { + "options tests": { + "concurrent via options": { + "concurrent": true, + "errors": [], + "fullName": "options tests > concurrent via options", + "id": "-1732721377_0_3", + "location": "8:6", + "mode": "skip", + "state": "skipped", + }, + "only via options": { + "errors": [], + "fullName": "options tests > only via options", + "id": "-1732721377_0_1", + "location": "6:6", + "mode": "run", + "state": "pending", + }, + "skip and concurrent via options": { + "concurrent": true, + "errors": [], + "fullName": "options tests > skip and concurrent via options", + "id": "-1732721377_0_4", + "location": "9:6", + "mode": "skip", + "state": "skipped", + }, + "skipped via options": { + "errors": [], + "fullName": "options tests > skipped via options", + "id": "-1732721377_0_0", + "location": "5:6", + "mode": "skip", + "state": "skipped", + }, + "todo via options": { + "errors": [], + "fullName": "options tests > todo via options", + "id": "-1732721377_0_2", + "location": "7:6", + "mode": "todo", + "state": "skipped", + }, + }, + } + `) +}) + +test('collects tests with concurrent modifier', async () => { + const testModule = await collectTests(` + import { test, describe } from 'vitest' + + describe.concurrent('concurrent suite', () => { + test('test in concurrent suite', () => {}) + test.skip('skipped in concurrent suite', () => {}) + }) + + test.concurrent('concurrent test', () => {}) +`) + expect(testModule).toMatchInlineSnapshot(` + { + "concurrent suite": { + "skipped in concurrent suite": { + "concurrent": true, + "errors": [], + "fullName": "concurrent suite > skipped in concurrent suite", + "id": "-1732721377_0_1", + "location": "6:6", + "mode": "skip", + "state": "skipped", + }, + "test in concurrent suite": { + "concurrent": true, + "errors": [], + "fullName": "concurrent suite > test in concurrent suite", + "id": "-1732721377_0_0", + "location": "5:6", + "mode": "run", + "state": "pending", + }, + }, + "concurrent test": { + "concurrent": true, + "errors": [], + "fullName": "concurrent test", + "id": "-1732721377_1", + "location": "9:4", + "mode": "run", + "state": "pending", + }, + } + `) +}) + +test('collects tests with describe.concurrent.each', async () => { + const testModule = await collectTests(` + import { test, describe } from 'vitest' + + describe.concurrent.each([1, 2, 3])('concurrent each %i', (num) => { + test('test inside concurrent each', () => {}) + }) +`) + expect(testModule).toMatchInlineSnapshot(` + { + "concurrent each %i": { + "test inside concurrent each": { + "concurrent": true, + "errors": [], + "fullName": "concurrent each %i > test inside concurrent each", + "id": "-1732721377_0_0", + "location": "5:6", + "mode": "run", + "state": "pending", + }, + }, + } + `) +}) + +test('collects tests with test.concurrent.each', async () => { + const testModule = await collectTests(` + import { test } from 'vitest' + + describe('suite', () => { + test.concurrent.each([1, 2, 3])('concurrent each test %i', (num) => {}) + }) +`) + expect(testModule).toMatchInlineSnapshot(` + { + "suite": { + "concurrent each test %i": { + "concurrent": true, + "dynamic": true, + "each": true, + "errors": [], + "fullName": "suite > concurrent each test %i", + "id": "-1732721377_0_0-dynamic", + "location": "5:36", + "mode": "run", + "state": "pending", + }, + }, + } + `) +}) + test('reports error when using undefined tag', async () => { const testModule = await collectTestModule(` import { test } from 'vitest' @@ -1192,6 +1475,7 @@ function testItem(testCase: TestCase) { errors: testCase.result().errors || [], ...(testCase.task.dynamic ? { dynamic: true } : {}), ...(testCase.options.each ? { each: true } : {}), + ...(testCase.options.concurrent ? { concurrent: true } : {}), ...(testCase.task.tags?.length ? { tags: testCase.task.tags } : {}), } }