diff --git a/src/testTree.ts b/src/testTree.ts index ef81fb9c..de865ed0 100644 --- a/src/testTree.ts +++ b/src/testTree.ts @@ -316,10 +316,13 @@ export class TestTree extends vscode.Disposable { log.error(`Cannot find location for "${testItem.label}". Using "id" to sort instead.`) testItem.sortText = task.id } + // dynamic exists only during browser collection + // see src/worker/collect.ts:172 + const isDynamic = (task as any).dynamic if (task.type === 'suite') - TestSuite.register(testItem, parent, fileData) + TestSuite.register(testItem, parent, fileData, isDynamic) else if (task.type === 'test' || task.type === 'custom') - TestCase.register(testItem, parent, fileData) + TestCase.register(testItem, parent, fileData, isDynamic) this.flatTestItems.set(task.id, testItem) parent.children.add(testItem) diff --git a/src/testTreeData.ts b/src/testTreeData.ts index 6c81a554..4ba5662e 100644 --- a/src/testTreeData.ts +++ b/src/testTreeData.ts @@ -74,16 +74,21 @@ export class TestFile extends BaseTestData { class TaskName { constructor( private readonly data: TestData, + public readonly dynamic: boolean, ) {} + public get label() { + return this.data.label + } + getTestNamePattern() { - const patterns = [escapeRegex(this.data.label)] + const patterns = [escapeTestName(this.data.label, this.dynamic)] let iter = this.data.parent while (iter) { // if we reached test file, then stop if (iter instanceof TestFile || iter instanceof TestFolder) break - patterns.push(escapeRegex(iter.label)) + patterns.push(escapeTestName(iter.label, iter.name.dynamic)) iter = iter.parent } // vitest's test task name starts with ' ' of root suite @@ -93,49 +98,75 @@ class TaskName { } export class TestCase extends BaseTestData { - private nameResolver: TaskName + public name: TaskName public readonly type = 'test' private constructor( item: vscode.TestItem, parent: vscode.TestItem, public readonly file: TestFile, + dynamic: boolean, ) { super(item, parent) - this.nameResolver = new TaskName(this) + this.name = new TaskName(this, dynamic) } - public static register(item: vscode.TestItem, parent: vscode.TestItem, file: TestFile) { - return addTestData(item, new TestCase(item, parent, file)) + public static register(item: vscode.TestItem, parent: vscode.TestItem, file: TestFile, dynamic: boolean) { + return addTestData(item, new TestCase(item, parent, file, dynamic)) } getTestNamePattern() { - return `^${this.nameResolver.getTestNamePattern()}$` + return `^${this.name.getTestNamePattern()}$` } } export class TestSuite extends BaseTestData { - private nameResolver: TaskName + public name: TaskName public readonly type = 'suite' private constructor( item: vscode.TestItem, parent: vscode.TestItem, public readonly file: TestFile, + dynamic: boolean, ) { super(item, parent) - this.nameResolver = new TaskName(this) + this.name = new TaskName(this, dynamic) } - public static register(item: vscode.TestItem, parent: vscode.TestItem, file: TestFile) { - return addTestData(item, new TestSuite(item, parent, file)) + public static register(item: vscode.TestItem, parent: vscode.TestItem, file: TestFile, dynamic: boolean) { + return addTestData(item, new TestSuite(item, parent, file, dynamic)) } getTestNamePattern() { - return `^${this.nameResolver.getTestNamePattern()}` + return `^${this.name.getTestNamePattern()}` } } function escapeRegex(str: string) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } + +const kReplacers = new Map([ + ['%i', '\\d+?'], + ['%#', '\\d+?'], + ['%d', '[\\d.eE+-]+?'], + ['%f', '[\\d.eE+-]+?'], + ['%s', '.+?'], + ['%j', '.+?'], + ['%o', '.+?'], + ['%%', '%'], +]) + +function escapeTestName(label: string, dynamic: boolean) { + if (!dynamic) { + return escapeRegex(label) + } + + // Replace object access patterns ($value, $obj.a) with %s first + let pattern = label.replace(/\$[a-z_.]+/gi, '%s') + pattern = escapeRegex(pattern) + // Replace percent placeholders with their respective regex + pattern = pattern.replace(/%[i#dfsjo%]/g, m => kReplacers.get(m) || m) + return pattern +} diff --git a/src/worker/collect.ts b/src/worker/collect.ts index 1902ff3e..2bd0c56a 100644 --- a/src/worker/collect.ts +++ b/src/worker/collect.ts @@ -21,11 +21,13 @@ interface ParsedFile extends RunnerTestFile { interface ParsedTest extends RunnerTestCase { start: number end: number + dynamic: boolean } interface ParsedSuite extends RunnerTestSuite { start: number end: number + dynamic: boolean } interface LocalCallDefinition { @@ -36,6 +38,7 @@ interface LocalCallDefinition { type: 'suite' | 'test' mode: 'run' | 'skip' | 'only' | 'todo' task: ParsedSuite | ParsedFile | ParsedTest + dynamic: boolean } export interface FileInformation { @@ -105,7 +108,6 @@ export function astParseFile(filepath: string, code: string) { const name = getName(callee) let unknown = false if (!name) { - verbose?.('Unknown call', callee) return } if (!['it', 'test', 'describe', 'suite'].includes(name)) { @@ -114,12 +116,8 @@ export function astParseFile(filepath: string, code: string) { } const property = callee?.property?.name let mode = !property || property === name ? 'run' : property - if (property === 'skipIf' || property === 'runIf') { - // skip, it will pick up the correct one by name later - return - } - if (mode === 'each') { - debug?.('Skipping `.each` (support not implemented yet)', name) + // they will be picked up in the next iteration + if (['each', 'for', 'skipIf', 'runIf'].includes(mode)) { return } @@ -160,6 +158,8 @@ export function astParseFile(filepath: string, code: string) { if (mode === 'skipIf' || mode === 'runIf') { mode = 'skip' } + const parentCalleeName = typeof callee?.callee === 'object' && callee?.callee.type === 'MemberExpression' && callee?.callee.property?.name + const isDynamicEach = parentCalleeName === 'each' || parentCalleeName === 'for' debug?.('Found', name, message, `(${mode})`) definitions.push({ start, @@ -169,6 +169,7 @@ export function astParseFile(filepath: string, code: string) { type: name === 'it' || name === 'test' ? 'test' : 'suite', mode, task: null as any, + dynamic: isDynamicEach, } satisfies LocalCallDefinition) }, }) @@ -205,15 +206,9 @@ export async function astCollectTests( file: null!, } file.file = file - if (verbose) { - verbose('Collecing', testFilepath, request.code) - } - else { - debug?.('Collecting', testFilepath) - } const indexMap = createIndexMap(request.code) const map = request.map && new TraceMap(request.map as any) - let lastSuite: ParsedSuite = file + let lastSuite: ParsedSuite = file as any const updateLatestSuite = (index: number) => { while (lastSuite.suite && lastSuite.end < index) { lastSuite = lastSuite.suite as ParsedSuite @@ -276,9 +271,8 @@ export async function astCollectTests( end: definition.end, start: definition.start, location, - meta: { - typecheck: true, - }, + dynamic: definition.dynamic, + meta: {}, } definition.task = task latestSuite.tasks.push(task) @@ -296,9 +290,8 @@ export async function astCollectTests( end: definition.end, start: definition.start, location, - meta: { - typecheck: true, - }, + dynamic: definition.dynamic, + meta: {}, } definition.task = task latestSuite.tasks.push(task) @@ -312,6 +305,7 @@ export async function astCollectTests( false, ctx.config.allowOnly, ) + markDynamicTests(file.tasks) if (!file.tasks.length) { file.result = { state: 'fail', @@ -418,6 +412,17 @@ function interpretTaskModes( } } +function markDynamicTests(tasks: TaskBase[]) { + for (const task of tasks) { + if ((task as any).dynamic) { + task.id += '-dynamic' + } + if ('children' in task) { + markDynamicTests(task.children as TaskBase[]) + } + } +} + function checkAllowOnly(task: TaskBase, allowOnly?: boolean) { if (allowOnly) { return diff --git a/test/TestData.test.ts b/test/TestData.test.ts index 4b83fe8c..67ced3ad 100644 --- a/test/TestData.test.ts +++ b/test/TestData.test.ts @@ -57,13 +57,13 @@ describe('TestData', () => { suiteItem.children.add(testItem2) suiteItem.children.add(testItem3) - const suite = TestSuite.register(suiteItem, testItem, file) + const suite = TestSuite.register(suiteItem, testItem, file, false) expect(suite.getTestNamePattern()).to.equal('^\\s?describe') - const test1 = TestCase.register(testItem1, suiteItem, file) - const test2 = TestCase.register(testItem2, suiteItem, file) - const test3 = TestCase.register(testItem3, suiteItem, file) + const test1 = TestCase.register(testItem1, suiteItem, file, false) + const test2 = TestCase.register(testItem2, suiteItem, file, false) + const test3 = TestCase.register(testItem3, suiteItem, file, false) expect(testItem1.parent).to.exist