diff --git a/samples/basic/test/add.test.ts b/samples/basic/test/add.test.ts index 2d011b7..480d43b 100644 --- a/samples/basic/test/add.test.ts +++ b/samples/basic/test/add.test.ts @@ -45,4 +45,8 @@ describe('testing', () => { it('mul', () => { expect(5 * 5).toBe(25) }) + + it("mul fail", () => { + expect(5 * 5).toBe(26) + }) }) diff --git a/samples/basic/test/each.test.ts b/samples/basic/test/each.test.ts new file mode 100644 index 0000000..f7cff91 --- /dev/null +++ b/samples/basic/test/each.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, test, } from 'vitest' + +describe('testing', (a) => { + it.each([ + [1, 1], [2, 2], [3, 3] + ])(`all pass: %i => %i`, (a, b) => { + expect(a).toBe(b) + }) + it.each([ + [1, 1], [2, 1], [3, 1] + ])(`first pass: %i => %i`, (a, b) => { + expect(a).toBe(b) + }) + it.each([ + [1, 1], [2, 2], [3, 1] + ])(`last pass: %i => %i`, (a, b) => { + expect(a).toBe(b) + }) + it.each([ + [1, 1], [2, 2], [3, 1] + ])(`first fail: %i => %i`, (a, b) => { + expect(a).toBe(b) + }) + it.each([ + [1, 1], [2, 2], [3, 1] + ])(`last fail: %i => %i`, (a, b) => { + expect(a).toBe(b) + }) + it.each([ + [1, 0], [2, 0], [3, 0] + ])(`all fail: %i => %i`, (a, b) => { + expect(a).toBe(b) + }) + it.each([ + 1, 2, 3 + ])('run %i', (a) => { + expect(a).toBe(a) + }) + it.each([ + [1, 1], [2, 4], [3, 9] + ])('run mul %i', (a,b) => { + expect(a * a).toBe(b) + }) + test.each([ + ["test1", 1], + ["test2", 2], + ["test3", 3], + ])(`%s => %i`, (a, b) => { + expect(a.at(-1)).toBe(`${b}`) + }) + test.each` + a | b | expected + ${1} | ${1} | ${2} + ${'a'} | ${'b'} | ${'ab'} + ${[]} | ${'b'} | ${'b'} + ${{}} | ${'b'} | ${'[object Object]b'} + ${{ asd: 1 }} | ${'b'} | ${'[object Object]b'} + `('table1: returns $expected when $a is added $b', ({ a, b, expected }) => { + expect(a + b).toBe(expected) + }) + test.each` + a | b | expected + ${{v: 1}} | ${{v: 1}} | ${2} + `('table2: returns $expected when $a.v is added $b.v', ({ a, b, expected }) => { + expect(a.v + b.v).toBe(expected) + }) + test.each([ + { input: 1, add: 1, sum: 2 }, + { input: 2, add: 2, sum: 4 }, + ])('$input + $add = $sum', ({ input, add, sum }) => { + expect(input + add).toBe(sum) + }) +}) + +// 'Test result not fourd' error occurs as both .each patterns are matched +// TODO: Fix this +describe("over matched test patterns", () => { + test.each(['1', '2'])('run %s', (a) => { + expect(a).toBe(String(a)) + }) + test.each(['1', '2'])('run for %s', (a) => { + expect(a).toBe(String(a)) + }) +}) diff --git a/src/discover.ts b/src/discover.ts index 8340524..4462a7a 100644 --- a/src/discover.ts +++ b/src/discover.ts @@ -14,6 +14,7 @@ import type { NamedBlock } from './pure/parsers/parser_nodes' import { vitestEnvironmentFolders } from './config' import { log } from './log' +import { transformTestPattern } from './pure/testName' import { openTestTag } from './tags' import { globFiles, shouldIncludeFile } from './vscodeUtils' @@ -263,7 +264,10 @@ export function discoverTestFromFileContent( parent.children.push(caseItem) if (block.type === 'describe') { const data = new TestDescribe( - block.name!, + transformTestPattern({ + testName: block.name!, + isEach: block.lastProperty === 'each', + }), fileItem, caseItem, parent.data as TestFile, @@ -280,7 +284,10 @@ export function discoverTestFromFileContent( } else if (block.type === 'it') { const testCase = new TestCase( - block.name!, + transformTestPattern({ + testName: block.name!, + isEach: block.lastProperty === 'each', + }), fileItem, caseItem, parent.data as TestFile | TestDescribe, diff --git a/src/pure/runner.ts b/src/pure/runner.ts index 9e62810..e5b0d81 100644 --- a/src/pure/runner.ts +++ b/src/pure/runner.ts @@ -61,7 +61,7 @@ export class TestRunner { async scheduleRun( testFile: string[] | undefined, - testNamePattern: string | undefined, + testPattern: string | undefined, log: { info: (msg: string) => void; error: (line: string) => void } = { info: () => {}, error: console.error }, workspaceEnv: Record = {}, vitestCommand: { cmd: string; args: string[] } = this.defaultVitestCommand @@ -80,14 +80,14 @@ export class TestRunner { if (updateSnapshot) args.push('--update') - if (testNamePattern) { - // Vitest's test name pattern is a regex, so we need to escape any special regex characters. - // Additionally, when a custom start process is not used on Windows, child_process.spawn is used with shell: true. - // That disables automatic quoting/escaping of arguments, requiring us to manually perform that here as well. - if (isWindows && !customStartProcess) - args.push('-t', `"${testNamePattern.replace(/[$^+?()[\]"]/g, '\\$&')}"`) - else - args.push('-t', testNamePattern.replace(/[$^+?()[\]"]/g, '\\$&')) + if (testPattern) { + let argsValue = testPattern + if (isWindows && !customStartProcess) { + // Wrap the test pattern in quotes to ensure it is treated as a single argument + argsValue = `"${argsValue.replace(/"/g, '\\"')}"` + } + + args.push('-t', argsValue) } const workspacePath = sanitizeFilePath(this.workspacePath) diff --git a/src/pure/testName.ts b/src/pure/testName.ts new file mode 100644 index 0000000..7b116c7 --- /dev/null +++ b/src/pure/testName.ts @@ -0,0 +1,34 @@ +function escapeRegExp(str: string) { + return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\\\^\|\.\$]/g, '\\$&') +} + +const kReplacers = new Map([ + ['%i', '\\d+?'], + ['%#', '\\d+?'], + ['%d', '[\\d.eE+-]+?'], + ['%f', '[\\d.eE+-]+?'], + ['%s', '.+?'], + ['%j', '.+?'], + ['%o', '.+?'], + ['%%', '%'], +]) + +export function transformTestPattern( + { testName, isEach }: + { testName: string; isEach: boolean }, +): string { + // https://vitest.dev/api/#test-each + // replace vitest's table test placeholder and treat it as regex + let result = testName + if (isEach) { + // Replace object access patterns ($value, $obj.a) with %s first + result = result.replace(/\$[a-zA-Z_.]+/g, '%s') + result = escapeRegExp(result) + // Replace percent placeholders with their respective regex + result = result.replace(/%[i#dfsjo%]/g, m => kReplacers.get(m) || m) + } + else { + result = escapeRegExp(result) + } + return result +} diff --git a/src/runHandler.ts b/src/runHandler.ts index 12d1e6d..7e0c9d5 100644 --- a/src/runHandler.ts +++ b/src/runHandler.ts @@ -300,11 +300,10 @@ async function runTest( }) } const finishedTests: Set = new Set() + const headItem = items.length === 1 ? WEAKMAP_TEST_DATA.get(items[0]) : undefined const { output, testResultFiles } = await runner!.scheduleRun( fileItems.map(x => x.uri!.fsPath), - items.length === 1 - ? WEAKMAP_TEST_DATA.get(items[0])!.getFullPattern() - : '', + headItem?.getFullPattern(), { info: log.info, error: log.error, diff --git a/src/watch.ts b/src/watch.ts index 4ce76b8..b0c811e 100644 --- a/src/watch.ts +++ b/src/watch.ts @@ -3,7 +3,6 @@ import path from 'node:path' import getPort from 'get-port' import { getTasks } from '@vitest/ws-client' import { effect, ref } from '@vue/reactivity' -import Fuse from 'fuse.js' import type { ErrorWithDiff, File, ParsedStack, Task } from 'vitest' import type { TestController, TestItem, TestRun, WorkspaceFolder } from 'vscode' import { Disposable, Location, Position, TestMessage, TestRunRequest, Uri } from 'vscode' @@ -345,104 +344,136 @@ export function syncTestStatusToVsCode( isFirstUpdate: boolean, finishedTest?: Set, ) { - sync(run, vscodeFile.children, vitestFile.tasks) - - function sync( - run: TestRun, - vscode: (TestDescribe | TestCase)[], - vitest: Task[], - ) { - const set = new Set(vscode) - for (const task of vitest) { - const data = matchTask(task, set) - if (task.type === 'test' || task.type === 'custom') { + const groups = groupTasksByPattern(new Map(), vscodeFile.children, vitestFile.tasks) + for (const [data, tasks] of groups.entries()) { + if (finished) { + for (const task of tasks) { + if (!task.logs) + continue // for now, display logs after all tests are finished. // TODO: append logs during test execution using `onUserConsoleLog` rpc. - if (finished) { - for (const log of task.logs ?? []) { - // LF to CRLF https://code.visualstudio.com/api/extension-guides/testing#test-output - const output = log.content.replace(/(? testMessageForTestError(data.item, i)) ?? [], - task.result.duration, - ) - finishedTest && finishedTest.add(data.item) - break - case 'skip': - case 'todo': - run.skipped(data.item) - finishedTest && finishedTest.add(data.item) - break - case 'run': - run.started(data.item) - break - case 'only': - break - default: - console.error('unexpected result state', task.result) - } + for (const log of task.logs) { + // LF to CRLF https://code.visualstudio.com/api/extension-guides/testing#test-output + const output = log.content.replace(/(?, - ): TestDescribe | TestCase { - let ans: (TestDescribe | TestCase) | undefined - for (const candidate of candidates) { - if (task.type === 'suite' && !(candidate instanceof TestDescribe)) - continue - - if ((task.type === 'test' || task.type === 'custom') && !(candidate instanceof TestCase)) + else { + if (finishedTest?.has(data.item)) continue - if (candidate.pattern === task.name) { - ans = candidate - break + const duration = tasks.reduce((acc, i) => acc + (i.result?.duration ?? 0), 0) + const errors = tasks.flatMap(i => i.result?.errors ?? []) + switch (primaryTask?.result?.state) { + case 'pass': + run.passed(data.item, duration) + finishedTest && finishedTest.add(data.item) + break + case 'fail': + run.failed( + data.item, + errors.map(i => testMessageForTestError(data.item, i)), + duration, + ) + finishedTest && finishedTest.add(data.item) + break + case 'skip': + case 'todo': + run.skipped(data.item) + finishedTest && finishedTest.add(data.item) + break + case 'run': + run.started(data.item) + break + case 'only': + break + default: + console.error('unexpected result state', tasks) } } + } +} - if (ans) { - candidates.delete(ans) - } - else { - ans = new Fuse(Array.from(candidates), { keys: ['pattern'] }).search( - task.name, - )[0]?.item - // should not delete ans from candidates here, because there are usages like `test.each` - // TODO: should we create new TestCase here? +function groupTasksByPattern( + map: Map, + vscode: (TestDescribe | TestCase)[], + vitest: Task[], +) { + const set = new Set(vitest) + for (const descOrTest of vscode) { + const tasks = matchTask(descOrTest, set) + if (tasks.length === 0) + continue + + if (!map.has(descOrTest)) + map.set(descOrTest, []) + + map.get(descOrTest)!.push(...tasks) + + for (const task of tasks) { + if (task.type === 'suite') + groupTasksByPattern(map, (descOrTest as TestDescribe).children, task.tasks) } + } + return map +} + +function getPrimaryResultTask(tasks: Task[]): Task | undefined { + const failedOne = tasks.find(i => i.result?.state === 'fail') + if (failedOne) + return failedOne + const runningOne = tasks.find(i => i.result?.state === 'run') + if (runningOne) + return runningOne + const allPassed = tasks.every(i => i.result?.state === 'pass') + if (allPassed) + return tasks[0] + const allSkipped = tasks.every(i => i.result?.state === 'skip') + if (allSkipped) + return tasks[0] + return tasks[0] +} - return ans +function getFullTaskName(task: Task): string { + if (task.suite) { + const suiteName = getFullTaskName(task.suite) + // root parent is a suite, but it's name is empty + if (suiteName) + return `${suiteName} ${task.name}` } + return task.name +} + +function matchTask( + vscode: TestDescribe | TestCase, + candidates: Set, +): Task[] { + const result: Task[] = [] + for (const task of candidates) { + if (task.type === 'suite' && !(vscode instanceof TestDescribe)) + continue + + if ((task.type === 'test' || task.type === 'custom') && !(vscode instanceof TestCase)) + continue + + const fullTaskName = getFullTaskName(task) + const fullCandidatesPattern = new RegExp(`^${vscode.getFullPattern()}$`) + if (fullTaskName.match(fullCandidatesPattern)) + result.push(task) + } + for (const task of result) + candidates.delete(task) + + return result } diff --git a/test/parse.test.ts b/test/parse.test.ts index adb914e..88d5e00 100644 --- a/test/parse.test.ts +++ b/test/parse.test.ts @@ -29,6 +29,17 @@ describe('parse', () => { expect(out.describeBlocks.length).toBe(1) }) + it('parse each', () => { + const out = parse( + 'x.js', + '' + + 'describe.each([1,2,3])(`test %i`, (i) => {\n' + + '}); \n', + ) + expect(out.describeBlocks.length).toBe(1) + expect(out.describeBlocks[0].lastProperty).toBe('each') + }) + it('parse decorator', () => { const out = parse( 'x.ts', diff --git a/test/pure/testName.test.ts b/test/pure/testName.test.ts new file mode 100644 index 0000000..b5ec799 --- /dev/null +++ b/test/pure/testName.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { transformTestPattern } from '../../src/pure/testName' + +describe('testName', () => { + describe('transformTestPattern', () => { + it.each([ + ['test', 'test'], + ['$^+?()[]', '\\$\\^\\+\\?\\(\\)\\[\\]'], + ['$value', '.+?'], + ['$obj_name.a', '.+?'], + ['%%', '%'], + ['%d = %f', '[\\d.eE+-]+? = [\\d.eE+-]+?'], + ['%j = %o', '.+? = .+?'], + ['test %i', 'test \\d+?'], + ])('isEach=true, value=%s', (input, expected) => { + expect(transformTestPattern({ + testName: input, + isEach: true, + })).toBe(expected) + }) + it.each([ + ['test', 'test'], + ['$^+?()[]', '\\$\\^\\+\\?\\(\\)\\[\\]'], + ['$value', '\\$value'], + ['$obj_name.a', '\\$obj_name\\.a'], + ['%%', '%%'], + ['%d = %f', '%d = %f'], + ['%j = %o', '%j = %o'], + ['test %i', 'test %i'], + ])('isEach=false, value=%s', (input, expected) => { + expect(transformTestPattern({ + testName: input, + isEach: false, + })).toBe(expected) + }) + }) +}) diff --git a/test/runner.test.ts b/test/runner.test.ts index c347a5f..77cb242 100644 --- a/test/runner.test.ts +++ b/test/runner.test.ts @@ -53,13 +53,13 @@ describe('TestRunner', () => { }) test.each([ - [false, false, 'vitest,abc.spec.ts,-t,a \\(b\\) \\\"c\\\" d'], - [false, true, 'vitest,abc.spec.ts,-t,a \\(b\\) \\\"c\\\" d'], - [true, false, 'vitest,abc.spec.ts,-t,\"a \\(b\\) \\\"c\\\" d\"'], - [true, true, 'vitest,abc.spec.ts,-t,a \\(b\\) \\\"c\\\" d'], - ])('scheduleRun properly escapes arguments (isWindows: %s, customStartProcess: %s)', async (isWindows, useCustomStartProcess, expectedArgs) => { + [false, false, `vitest,abc.spec.ts,-t,a (b) "c" d`], + [false, true, `vitest,abc.spec.ts,-t,a (b) "c" d`], + [true, false, `vitest,abc.spec.ts,-t,"a (b) \\"c\\" d"`], + [true, true, `vitest,abc.spec.ts,-t,a (b) "c" d`], + ])('scheduleRun wrap test patterns if needed, (isWindows: %s, customStartProcess: %s)', + async (isWindows, useCustomStartProcess, expectedArgs) => { Object.defineProperty(platformConstants, 'isWindows', { value: isWindows, writable: true }) - const workspacePath = '/test' const testFiles = ['abc.spec.ts'] const testNamePattern = 'a (b) "c" d'