From 9d62a6dd5bc95c4ded8c6c59b72f8693b08e838f Mon Sep 17 00:00:00 2001 From: Han Feng Date: Fri, 23 Jun 2023 22:25:13 +0800 Subject: [PATCH 01/12] feat: support accessing other fixtures in fixture function --- packages/runner/src/fixture.ts | 162 +++++++++++++++++++++++------ packages/runner/src/suite.ts | 17 +-- packages/runner/src/types/tasks.ts | 15 ++- packages/runner/src/utils/chain.ts | 6 +- 4 files changed, 155 insertions(+), 45 deletions(-) diff --git a/packages/runner/src/fixture.ts b/packages/runner/src/fixture.ts index 3f88e61009d0..50e978012f52 100644 --- a/packages/runner/src/fixture.ts +++ b/packages/runner/src/fixture.ts @@ -1,66 +1,162 @@ import type { Fixtures, Test } from './types' -export function withFixtures(fn: Function, fixtures: Fixtures>, context: Test>['context']) { - const props = getUsedFixtureProps(fn, Object.keys(fixtures)) +export interface FixtureItem { + prop: string + value: any + hasDeps: boolean + index: number + end: number +} + +export function mergeContextFixtures(fixtures: Fixtures>, context: Record = {}) { + const fixtureArray: FixtureItem[] = Object.entries(fixtures) + .map(([prop, value], index, { length }) => { + return { + prop, + value, + index, + end: length, + hasDeps: typeof value === 'function' && value.length >= 2, + } + }) + + if (Array.isArray(context.fixtures)) { + fixtureArray.forEach((fixture) => { + fixture.index += context.fixtures.length + fixture.end += context.fixtures.length + }) + + context.fixtures = context.fixtures.concat(fixtureArray) + } + else { + context.fixtures = fixtureArray + } + + return context +} + +export function withFixtures(fn: Function, fixtures: FixtureItem[], context: Test>['context']) { + const props = getTestFnDepProps(fn, fixtures.map(({ prop }) => prop)) if (props.length === 0) return () => fn(context) + const filteredFixtures = fixtures.filter(({ prop }) => props.includes(prop)) + const pendingFixtures = resolveFixtureDeps(filteredFixtures, fixtures) + let cursor = 0 async function use(fixtureValue: any) { - context[props[cursor++]] = fixtureValue + const { prop } = pendingFixtures[cursor++] + context[prop] = fixtureValue - if (cursor < props.length) + if (cursor < pendingFixtures.length) await next() else await fn(context) } async function next() { - const fixtureValue = fixtures[props[cursor]] - typeof fixtureValue === 'function' - ? await fixtureValue(use) - : await use(fixtureValue) + const { value } = pendingFixtures[cursor] + typeof value === 'function' ? await value(use, context) : await use(value) } return () => next() } -function getUsedFixtureProps(fn: Function, fixtureProps: string[]) { - if (!fixtureProps.length || !fn.length) - return [] +function resolveFixtureDeps(initialFixtures: FixtureItem[], fixtures: FixtureItem[]) { + const pendingFixtures: FixtureItem[] = [] + + function resolveDeps(fixture: FixtureItem, temp: Set) { + if (!fixture.hasDeps) { + pendingFixtures.push(fixture) + return + } - const paramsStr = fn.toString().match(/[^(]*\(([^)]*)/)![1] + // fixture function may depend on other fixtures + const { index, value: fn, end } = fixture - if (paramsStr[0] === '{' && paramsStr.at(-1) === '}') { - // ({...}) => {} - const props = paramsStr.slice(1, -1).split(',') - const filteredProps = [] + const potentialDeps = fixtures + .slice(0, end) + .filter(dep => dep.index !== index) - for (const prop of props) { - if (!prop) - continue + const props = getFixtureFnDepProps(fn, potentialDeps.map(({ prop }) => prop)) - let _prop = prop.trim() + const deps = potentialDeps.filter(({ prop }) => props.includes(prop)) + deps.forEach((dep) => { + if (!pendingFixtures.includes(dep)) { + if (dep.hasDeps) { + if (temp.has(dep)) + throw new Error('circular fixture dependency') + temp.add(dep) + } - if (_prop.startsWith('...')) { - // ({ a, b, ...rest }) => {} - return fixtureProps + resolveDeps(dep, temp) } + }) + + pendingFixtures.push(fixture) + } + + initialFixtures.forEach(fixture => resolveDeps(fixture, new Set([fixture]))) - const colonIndex = _prop.indexOf(':') - if (colonIndex > 0) - _prop = _prop.slice(0, colonIndex).trim() + return pendingFixtures +} + +function getFixtureFnDepProps(fn: Function, allProps: string[]) { + if (fn.length !== 2) + throw new Error('fixture function should have two arguments, the fist one is the use function that should be called with fixture value, and the second is other fixtures that should be used with destructured expression. For example, `async ({ a, b }, use) => { await use(a + b) }`') + + const args = fn.toString().match(/[^(]*\(([^)]*)/)![1] + const target = args.slice(args.indexOf(',') + 1).trim() + + return filterDestructuredProps(target, allProps, { enableRestParams: false, errorPrefix: `invalid fixture function\n\n${fn}\n\n` }) +} - if (fixtureProps.includes(_prop)) - filteredProps.push(_prop) +function getTestFnDepProps(fn: Function, allProps: string[]) { + if (!fn.length) + return [] + if (fn.length > 1) + throw new Error('extended test function should have only one argument') + + const arg = fn.toString().match(/[^(]*\(([^)]*)/)![1] + if (arg[0] !== '{' && arg.at(-1) !== '}') + return allProps + + return filterDestructuredProps(arg, allProps, { enableRestParams: true, errorPrefix: `invalid extended test function\n\n${fn}\n\n` }) +} + +function filterDestructuredProps(arg: string, props: string[], options: { enableRestParams: boolean; errorPrefix?: string }) { + if (!props.length) + return [] + if (arg.length < 2 || arg[0] !== '{' || arg.at(-1) !== '}') + throw new Error(`${options.errorPrefix}invalid destructured expression`) + + if (arg.indexOf('{') !== arg.lastIndexOf('{')) + throw new Error(`${options.errorPrefix}nested destructured expression is not supported`) + + const usedProps = arg.slice(1, -1).split(',') + const filteredProps = [] + + for (const prop of usedProps) { + if (!prop) + continue + + let _prop = prop.trim() + + if (_prop.startsWith('...')) { + // { a, b, ...rest } + if (!options.enableRestParams) + throw new Error(`${options.errorPrefix}rest param is not supported`) + return props } - // ({}) => {} - // ({ a, b, c}) => {} - return filteredProps + const colonIndex = _prop.indexOf(':') + if (colonIndex > 0) + _prop = _prop.slice(0, colonIndex).trim() + + if (props.includes(_prop)) + filteredProps.push(_prop) } - // (ctx) => {} - return fixtureProps + return filteredProps } diff --git a/packages/runner/src/suite.ts b/packages/runner/src/suite.ts index 787db602930b..655c2e8f6283 100644 --- a/packages/runner/src/suite.ts +++ b/packages/runner/src/suite.ts @@ -4,7 +4,8 @@ import type { VitestRunner } from './types/runner' import { createChainable } from './utils/chain' import { collectTask, collectorContext, createTestContext, runWithSuite, withTimeout } from './context' import { getHooks, setFn, setHooks } from './map' -import { withFixtures } from './fixture' +import type { FixtureItem } from './fixture' +import { mergeContextFixtures, withFixtures } from './fixture' // apis export const suite = createSuite() @@ -232,7 +233,7 @@ function createSuite() { function createTest(fn: ( ( - this: Record<'concurrent' | 'skip' | 'only' | 'todo' | 'fails' | 'each', boolean | undefined> & { fixtures?: Fixtures> }, + this: Record<'concurrent' | 'skip' | 'only' | 'todo' | 'fails' | 'each', boolean | undefined> & { fixtures?: FixtureItem[] }, title: string, fn?: TestFunction, options?: number | TestOptions @@ -266,20 +267,22 @@ function createTest(fn: ( testFn.runIf = (condition: any) => (condition ? test : test.skip) as TestAPI testFn.extend = function (fixtures: Fixtures>) { - const _context = context - ? { ...context, fixtures: { ...context.fixtures, ...fixtures } } - : { fixtures } + const _context = mergeContextFixtures(fixtures, context) return createTest(function fn(name: string | Function, fn?: TestFunction, options?: number | TestOptions) { getCurrentSuite().test.fn.call(this, formatName(name), fn, options) }, _context) } - return createChainable( + const _test = createChainable( ['concurrent', 'skip', 'only', 'todo', 'fails'], testFn, - context, ) as TestAPI + + if (context) + (_test as any).mergeContext(context) + + return _test } function formatName(name: string | Function) { diff --git a/packages/runner/src/types/tasks.ts b/packages/runner/src/types/tasks.ts index b1e41e46d08b..9503be4057b4 100644 --- a/packages/runner/src/types/tasks.ts +++ b/packages/runner/src/types/tasks.ts @@ -182,11 +182,20 @@ export type TestAPI = ChainableTestAPI & { each: TestEachFunction skipIf(condition: any): ChainableTestAPI runIf(condition: any): ChainableTestAPI - extend>(fixtures: Fixtures): TestAPI + extend>(fixtures: Fixtures): TestAPI<{ + [K in keyof T | keyof ExtraContext]: + K extends keyof T ? T[K] : + K extends keyof ExtraContext ? ExtraContext[K] : never }> } -export type Fixtures> = { - [K in keyof T]: T[K] | ((use: (fixture: T[K]) => Promise) => Promise) +export type Fixtures, ExtraContext = {}> = { + [K in keyof T]: T[K] | ((use: (fixture: T[K]) => Promise, context: { + [P in keyof T | keyof ExtraContext as P extends K ? + P extends keyof ExtraContext ? P : never : P + ]: + K extends P ? K extends keyof ExtraContext ? ExtraContext[K] : never : + P extends keyof T ? T[P] : never + }) => Promise) } type ChainableSuiteAPI = ChainableFunction< diff --git a/packages/runner/src/utils/chain.ts b/packages/runner/src/utils/chain.ts index 9b55c0ee58e7..ef1c3c6cc029 100644 --- a/packages/runner/src/utils/chain.ts +++ b/packages/runner/src/utils/chain.ts @@ -9,7 +9,6 @@ export type ChainableFunction( keys: T[], fn: (this: Record, ...args: Args) => R, - initialContext?: Record, ): ChainableFunction { function create(context: Record) { const chain = function (this: any, ...args: Args) { @@ -20,6 +19,9 @@ export function createChainable { context[key] = value } + chain.mergeContext = (ctx: Record) => { + Object.assign(context, ctx) + } for (const key of keys) { Object.defineProperty(chain, key, { get() { @@ -30,7 +32,7 @@ export function createChainable Date: Fri, 23 Jun 2023 22:25:27 +0800 Subject: [PATCH 02/12] test: update --- test/core/test/fixture-fn-deps.test.ts | 130 +++++++++++++++++++++++++ test/core/test/test-extend.test.ts | 18 +++- 2 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 test/core/test/fixture-fn-deps.test.ts diff --git a/test/core/test/fixture-fn-deps.test.ts b/test/core/test/fixture-fn-deps.test.ts new file mode 100644 index 000000000000..3bc02cf23507 --- /dev/null +++ b/test/core/test/fixture-fn-deps.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, expectTypeOf, test, vi } from 'vitest' + +interface Fixtures { + a: number + b: number + c: number + d: number +} + +const fnB = vi.fn() +const myTest = test.extend>({ + a: 1, + b: async (use, { a }) => { + fnB() + await use (a * 2) // 2 + fnB.mockClear() + }, +}) + +const fnA = vi.fn() +const fnB2 = vi.fn() +const fnC = vi.fn() +const fnD = vi.fn() +const myTest2 = myTest.extend & { a: string; b: string }>({ + // override origin a + a: async (use, { a: originA }) => { + expectTypeOf(originA).toEqualTypeOf() + fnA() + await use(String(originA)) // '1' + fnA.mockClear() + }, + b: async (use, { a }) => { + expectTypeOf(a).toEqualTypeOf() + fnB2() + await use(String(Number(a) * 2)) // '2' + fnB2.mockClear() + }, + c: async (use, { a, b }) => { + expectTypeOf(b).toEqualTypeOf() + fnC() + await use(Number(a) + Number(b)) // 3 + fnC.mockClear() + }, + d: async (use, { a, b, c }) => { + fnD() + await use(Number(a) + Number(b) + c) // 6 + fnD.mockClear() + }, +}) + +describe('test.extend()', () => { + describe('fixture override', () => { + myTest('origin a and b', ({ a, b }) => { + expect(a).toBe(1) + expect(b).toBe(2) + + expectTypeOf(a).toEqualTypeOf() + expectTypeOf(b).toEqualTypeOf() + + expect(fnB).toBeCalledTimes(1) + + expect(fnB2).not.toBeCalled() + expect(fnA).not.toBeCalled() + expect(fnC).not.toBeCalled() + expect(fnD).not.toBeCalled() + }) + + myTest2('overriding a and b', ({ a, b }) => { + expect(a).toBe('1') + expect(b).toBe('2') + + expectTypeOf(a).toEqualTypeOf() + expectTypeOf(b).toEqualTypeOf() + + expect(fnA).toBeCalledTimes(1) + expect(fnB).toBeCalledTimes(1) + expect(fnB2).toBeCalledTimes(1) + + expect(fnC).not.toBeCalled() + expect(fnD).not.toBeCalled() + }) + }) + + describe('fixture dependency', () => { + myTest2('b => a', ({ b }) => { + expect(b).toBe('2') + + expect(fnA).toBeCalledTimes(1) + expect(fnB).toBeCalledTimes(1) + expect(fnB2).toBeCalledTimes(1) + + expect(fnC).not.toBeCalled() + expect(fnD).not.toBeCalled() + }) + + myTest2('c => [a, b]', ({ c }) => { + expect(c).toBe(3) + + expect(fnA).toBeCalledTimes(1) + expect(fnB).toBeCalledTimes(1) + expect(fnB2).toBeCalledTimes(1) + expect(fnC).toBeCalledTimes(1) + + expect(fnD).not.toBeCalled() + }) + + myTest2('d => c', ({ d }) => { + expect(d).toBe(6) + + expect(fnA).toBeCalledTimes(1) + expect(fnB).toBeCalledTimes(1) + expect(fnB2).toBeCalledTimes(1) + expect(fnC).toBeCalledTimes(1) + expect(fnD).toBeCalledTimes(1) + }) + + myTest2('should only call once for each fixture fn', ({ a, b, c, d }) => { + expect(a).toBe('1') + expect(b).toBe('2') + expect(c).toBe(3) + expect(d).toBe(6) + + expect(fnA).toBeCalledTimes(1) + expect(fnB).toBeCalledTimes(1) + expect(fnB2).toBeCalledTimes(1) + expect(fnC).toBeCalledTimes(1) + expect(fnD).toBeCalledTimes(1) + }) + }) +}) diff --git a/test/core/test/test-extend.test.ts b/test/core/test/test-extend.test.ts index 1d75bd3ae15f..95abd5246fcf 100644 --- a/test/core/test/test-extend.test.ts +++ b/test/core/test/test-extend.test.ts @@ -21,11 +21,17 @@ const doneFn = vi.fn().mockImplementation(async (use) => { doneList.length = 0 }) +interface Fixtures { + todoList: number[] + doneList: number[] + archiveList: number[] +} + const myTest = test - .extend<{ todoList: number[] }>({ + .extend>({ todoList: todoFn, }) - .extend<{ doneList: number[]; archiveList: number[] }>({ + .extend>({ doneList: doneFn, archiveList, }) @@ -54,12 +60,14 @@ describe('test.extend()', () => { archiveList.push(todoList.shift()!) expect(todoList).toEqual([]) expect(archiveList).toEqual([3]) + + archiveList.pop() }) myTest('should called cleanup functions', ({ todoList, doneList, archiveList }) => { expect(todoList).toEqual([1, 2, 3]) expect(doneList).toEqual([]) - expect(archiveList).toEqual([3]) + expect(archiveList).toEqual([]) }) describe('smartly init fixtures', () => { @@ -124,7 +132,7 @@ describe('test.extend()', () => { expect(todoList).toEqual([1, 2, 3]) expect(rest.doneList).toEqual([]) - expect(rest.archiveList).toEqual([3]) + expect(rest.archiveList).toEqual([]) }) myTest('should init all fixtures', (context) => { @@ -137,7 +145,7 @@ describe('test.extend()', () => { expect(context.todoList).toEqual([1, 2, 3]) expect(context.doneList).toEqual([]) - expect(context.archiveList).toEqual([3]) + expect(context.archiveList).toEqual([]) }) }) }) From 50b1c079f5299b6538c2e97591e05f882d8a8bf9 Mon Sep 17 00:00:00 2001 From: Han Feng Date: Sat, 24 Jun 2023 14:58:00 +0800 Subject: [PATCH 03/12] chore: update --- packages/runner/src/fixture.ts | 148 +++++++++++++++++------------ test/core/test/test-extend.test.ts | 43 +++++---- 2 files changed, 107 insertions(+), 84 deletions(-) diff --git a/packages/runner/src/fixture.ts b/packages/runner/src/fixture.ts index 50e978012f52..944d6737b149 100644 --- a/packages/runner/src/fixture.ts +++ b/packages/runner/src/fixture.ts @@ -1,48 +1,57 @@ -import type { Fixtures, Test } from './types' +import type { TestContext } from './types' export interface FixtureItem { prop: string value: any - hasDeps: boolean - index: number - end: number + /** + * Indicates whether the fixture is a function + */ + isFn: boolean + /** + * Fixture function may depend on other fixtures, + * e.g. `async (use, { a, b }) => await use(a + b)`. + * This array contains the available dependencies of the fixture function. + */ + availableDeps: FixtureItem[] } -export function mergeContextFixtures(fixtures: Fixtures>, context: Record = {}) { +export function mergeContextFixtures(fixtures: Record, context: { fixtures?: FixtureItem[] } = {}) { const fixtureArray: FixtureItem[] = Object.entries(fixtures) - .map(([prop, value], index, { length }) => { + .map(([prop, value]) => { + const isFn = typeof value === 'function' + if (isFn) + validateFixtureFn(value) + return { prop, value, - index, - end: length, - hasDeps: typeof value === 'function' && value.length >= 2, + isFn, + availableDeps: [], } }) - if (Array.isArray(context.fixtures)) { - fixtureArray.forEach((fixture) => { - fixture.index += context.fixtures.length - fixture.end += context.fixtures.length - }) - + if (Array.isArray(context.fixtures)) context.fixtures = context.fixtures.concat(fixtureArray) - } - else { + else context.fixtures = fixtureArray - } + + fixtureArray.forEach((fixture) => { + if (fixture.isFn) + fixture.availableDeps = context.fixtures!.filter(item => item !== fixture) + }) return context } -export function withFixtures(fn: Function, fixtures: FixtureItem[], context: Test>['context']) { +export function withFixtures(fn: Function, fixtures: FixtureItem[], context: TestContext & Record) { + validateTestFn(fn) const props = getTestFnDepProps(fn, fixtures.map(({ prop }) => prop)) if (props.length === 0) return () => fn(context) const filteredFixtures = fixtures.filter(({ prop }) => props.includes(prop)) - const pendingFixtures = resolveFixtureDeps(filteredFixtures, fixtures) + const pendingFixtures = resolveFixtureDeps(filteredFixtures) let cursor = 0 @@ -63,78 +72,63 @@ export function withFixtures(fn: Function, fixtures: FixtureItem[], context: Tes return () => next() } -function resolveFixtureDeps(initialFixtures: FixtureItem[], fixtures: FixtureItem[]) { +function resolveFixtureDeps(fixtures: FixtureItem[]) { const pendingFixtures: FixtureItem[] = [] - function resolveDeps(fixture: FixtureItem, temp: Set) { - if (!fixture.hasDeps) { + function resolveDeps(fixture: FixtureItem, depSet: Set) { + if (!fixture.isFn) { pendingFixtures.push(fixture) return } - // fixture function may depend on other fixtures - const { index, value: fn, end } = fixture - - const potentialDeps = fixtures - .slice(0, end) - .filter(dep => dep.index !== index) - - const props = getFixtureFnDepProps(fn, potentialDeps.map(({ prop }) => prop)) + const { value: fn, availableDeps } = fixture + const props = getFixtureFnDepProps(fn, availableDeps.map(({ prop }) => prop)) - const deps = potentialDeps.filter(({ prop }) => props.includes(prop)) + const deps = availableDeps.filter(({ prop }) => props.includes(prop)) deps.forEach((dep) => { if (!pendingFixtures.includes(dep)) { - if (dep.hasDeps) { - if (temp.has(dep)) + if (dep.isFn) { + if (depSet.has(dep)) throw new Error('circular fixture dependency') - temp.add(dep) + depSet.add(dep) } - resolveDeps(dep, temp) + resolveDeps(dep, depSet) } }) pendingFixtures.push(fixture) } - initialFixtures.forEach(fixture => resolveDeps(fixture, new Set([fixture]))) + fixtures.forEach(fixture => resolveDeps(fixture, new Set([fixture]))) return pendingFixtures } -function getFixtureFnDepProps(fn: Function, allProps: string[]) { - if (fn.length !== 2) - throw new Error('fixture function should have two arguments, the fist one is the use function that should be called with fixture value, and the second is other fixtures that should be used with destructured expression. For example, `async ({ a, b }, use) => { await use(a + b) }`') - - const args = fn.toString().match(/[^(]*\(([^)]*)/)![1] - const target = args.slice(args.indexOf(',') + 1).trim() - - return filterDestructuredProps(target, allProps, { enableRestParams: false, errorPrefix: `invalid fixture function\n\n${fn}\n\n` }) +function getFixtureFnDepProps(fn: Function, props: string[]) { + if (fn.length === 1) + return [] + const args = getFnArgumentsStr(fn) + const secondArg = args.slice(args.indexOf(',') + 1).trim() + return filterPropsByObjectDestructuring(secondArg, props) } -function getTestFnDepProps(fn: Function, allProps: string[]) { +function getTestFnDepProps(fn: Function, props: string[]) { if (!fn.length) return [] - if (fn.length > 1) - throw new Error('extended test function should have only one argument') - const arg = fn.toString().match(/[^(]*\(([^)]*)/)![1] - if (arg[0] !== '{' && arg.at(-1) !== '}') - return allProps + const arg = getFnArgumentsStr(fn).trim() + if (!arg.startsWith('{')) + return props - return filterDestructuredProps(arg, allProps, { enableRestParams: true, errorPrefix: `invalid extended test function\n\n${fn}\n\n` }) + return filterPropsByObjectDestructuring(arg, props) } -function filterDestructuredProps(arg: string, props: string[], options: { enableRestParams: boolean; errorPrefix?: string }) { - if (!props.length) - return [] - if (arg.length < 2 || arg[0] !== '{' || arg.at(-1) !== '}') - throw new Error(`${options.errorPrefix}invalid destructured expression`) - - if (arg.indexOf('{') !== arg.lastIndexOf('{')) - throw new Error(`${options.errorPrefix}nested destructured expression is not supported`) +function filterPropsByObjectDestructuring(argStr: string, props: string[]) { + if (!argStr.startsWith('{') || !argStr.endsWith('}')) + throw new Error('Invalid object destructuring pattern') - const usedProps = arg.slice(1, -1).split(',') + const usedProps = argStr.slice(1, -1).split(',') const filteredProps = [] for (const prop of usedProps) { @@ -145,8 +139,6 @@ function filterDestructuredProps(arg: string, props: string[], options: { enable if (_prop.startsWith('...')) { // { a, b, ...rest } - if (!options.enableRestParams) - throw new Error(`${options.errorPrefix}rest param is not supported`) return props } @@ -160,3 +152,33 @@ function filterDestructuredProps(arg: string, props: string[], options: { enable return filteredProps } + +function getFnArgumentsStr(fn: Function) { + if (!fn.length) + return '' + return fn.toString().match(/[^(]*\(([^)]*)/)![1] +} + +function validateFixtureFn(fn: Function) { + if (fn.length < 1 || fn.length > 2) + throw new Error('invalid fixture function, should follow below rules:\n- have at least one argument, at most two arguments\n- the first argument is the "use" function, which must be invoked with fixture value\n- the second argument should use the object destructuring pattern to access other fixtures\n\nFor instance,\nasync (use) => { await use(0) }\nasync (use, { a, b }) => { await use(a + b) }') + + if (fn.length === 2) { + const args = getFnArgumentsStr(fn) + if (args.includes('...')) + throw new Error('rest param is not supported') + + const second = args.slice(args.indexOf(',') + 1).trim() + + if (second.length < 2 || !second.startsWith('{') || !second.endsWith('}')) + throw new Error('the second argument should use the object destructuring pattern') + + if (second.indexOf('{') !== second.lastIndexOf('{')) + throw new Error('nested object destructuring pattern is not supported') + } +} + +function validateTestFn(fn: Function) { + if (fn.length > 1) + throw new Error('extended test function should have only one argument') +} diff --git a/test/core/test/test-extend.test.ts b/test/core/test/test-extend.test.ts index 95abd5246fcf..fbdcc500eb5c 100644 --- a/test/core/test/test-extend.test.ts +++ b/test/core/test/test-extend.test.ts @@ -2,37 +2,38 @@ /* eslint-disable prefer-rest-params */ import { describe, expect, expectTypeOf, test, vi } from 'vitest' -const todoList: number[] = [1, 2, 3] -const doneList: number[] = [] -const archiveList: number[] = [] - -const todoFn = vi.fn().mockImplementation(async (use) => { - await use(todoList) - // cleanup - todoFn.mockClear() - todoList.length = 0 - todoList.push(1, 2, 3) -}) - -const doneFn = vi.fn().mockImplementation(async (use) => { - await use(doneList) - // cleanup - doneFn.mockClear() - doneList.length = 0 -}) - interface Fixtures { todoList: number[] doneList: number[] archiveList: number[] } +const todoList: number[] = [1, 2, 3] +const doneList: number[] = [] +const archiveList: number[] = [] + +const todoFn = vi.fn() +const doneFn = vi.fn() + const myTest = test .extend>({ - todoList: todoFn, + todoList: async (use) => { + todoFn() + await use(todoList) + // cleanup + todoFn.mockClear() + todoList.length = 0 + todoList.push(1, 2, 3) + }, }) .extend>({ - doneList: doneFn, + doneList: async (use) => { + doneFn() + await use(doneList) + // cleanup + doneFn.mockClear() + doneList.length = 0 + }, archiveList, }) From c8a04982d4f59fc3e71c5f1e3aa5b06142be4bb9 Mon Sep 17 00:00:00 2001 From: Han Feng Date: Sun, 25 Jun 2023 21:28:16 +0800 Subject: [PATCH 04/12] refactor: get used fixtures --- packages/runner/src/fixture.ts | 267 ++++++++++++++++++++------------- 1 file changed, 161 insertions(+), 106 deletions(-) diff --git a/packages/runner/src/fixture.ts b/packages/runner/src/fixture.ts index 944d6737b149..deffbcba10b4 100644 --- a/packages/runner/src/fixture.ts +++ b/packages/runner/src/fixture.ts @@ -3,30 +3,26 @@ import type { TestContext } from './types' export interface FixtureItem { prop: string value: any + index: number /** * Indicates whether the fixture is a function */ isFn: boolean /** - * Fixture function may depend on other fixtures, - * e.g. `async (use, { a, b }) => await use(a + b)`. - * This array contains the available dependencies of the fixture function. + * The dependencies(fixtures) of current fixture function. */ - availableDeps: FixtureItem[] + deps?: FixtureItem[] } export function mergeContextFixtures(fixtures: Record, context: { fixtures?: FixtureItem[] } = {}) { const fixtureArray: FixtureItem[] = Object.entries(fixtures) - .map(([prop, value]) => { + .map(([prop, value], index) => { const isFn = typeof value === 'function' - if (isFn) - validateFixtureFn(value) - return { prop, value, + index, isFn, - availableDeps: [], } }) @@ -35,30 +31,67 @@ export function mergeContextFixtures(fixtures: Record, context: { f else context.fixtures = fixtureArray + // Update dependencies of fixture functions fixtureArray.forEach((fixture) => { - if (fixture.isFn) - fixture.availableDeps = context.fixtures!.filter(item => item !== fixture) + if (fixture.isFn) { + const { args, restParams } = parseFnArgs(fixture.value) + + if (args.length <= 1 && !restParams) { + // async () => {} + // async (use) => {} + return + } + + // exclude self + let deps = context.fixtures!.filter(item => item.index !== fixture.index) + if (args.length >= 2 && isObjectDestructuring(args[1])) { + const { props, restParams } = getDestructuredProps(args[1]) + if (!restParams) { + // async (use, { a, b }) => {} + deps = deps.filter(item => props.includes(item.prop)) + } + } + + // async (...rest) => {} + // async (use, ...rest) => {} + // async (use, context) => {} + // async (use, { a, b, ...rest }) => {} + fixture.deps = deps + } }) return context } export function withFixtures(fn: Function, fixtures: FixtureItem[], context: TestContext & Record) { - validateTestFn(fn) - const props = getTestFnDepProps(fn, fixtures.map(({ prop }) => prop)) - - if (props.length === 0) + const { args, restParams } = parseFnArgs(fn) + if ((!args.length && !restParams)) { + // test('', () => {}) return () => fn(context) + } - const filteredFixtures = fixtures.filter(({ prop }) => props.includes(prop)) - const pendingFixtures = resolveFixtureDeps(filteredFixtures) + // test('', (context) => {}) + // test('', ({ a, b, ...rest }) => {}) + let filteredFixtures = fixtures + if (isObjectDestructuring(args[0])) { + const { props, restParams } = getDestructuredProps(args[0]) + if (!props.length && !restParams) { + // test('', ({ }) => {}) + return () => fn(context) + } + if (!restParams && props.length) { + // test('', ({ a, b }) => {}) + filteredFixtures = fixtures.filter(item => props.includes(item.prop)) + } + } + + const pendingFixtures = resolveDeps(filteredFixtures) let cursor = 0 async function use(fixtureValue: any) { const { prop } = pendingFixtures[cursor++] context[prop] = fixtureValue - if (cursor < pendingFixtures.length) await next() else await fn(context) @@ -72,113 +105,135 @@ export function withFixtures(fn: Function, fixtures: FixtureItem[], context: Tes return () => next() } -function resolveFixtureDeps(fixtures: FixtureItem[]) { - const pendingFixtures: FixtureItem[] = [] - - function resolveDeps(fixture: FixtureItem, depSet: Set) { - if (!fixture.isFn) { +function resolveDeps(fixtures: FixtureItem[], depSet = new Set(), pendingFixtures: FixtureItem[] = []) { + fixtures.forEach((fixture) => { + if (pendingFixtures.includes(fixture)) + return + if (!fixture.isFn || !fixture.deps) { pendingFixtures.push(fixture) return } + if (depSet.has(fixture)) + throw new Error('circular fixture dependency') - const { value: fn, availableDeps } = fixture - const props = getFixtureFnDepProps(fn, availableDeps.map(({ prop }) => prop)) - - const deps = availableDeps.filter(({ prop }) => props.includes(prop)) - deps.forEach((dep) => { - if (!pendingFixtures.includes(dep)) { - if (dep.isFn) { - if (depSet.has(dep)) - throw new Error('circular fixture dependency') - depSet.add(dep) - } + depSet.add(fixture) - resolveDeps(dep, depSet) - } - }) + resolveDeps(fixture.deps, depSet, pendingFixtures) pendingFixtures.push(fixture) - } - - fixtures.forEach(fixture => resolveDeps(fixture, new Set([fixture]))) + depSet.clear() + }) return pendingFixtures } -function getFixtureFnDepProps(fn: Function, props: string[]) { - if (fn.length === 1) - return [] - const args = getFnArgumentsStr(fn) - const secondArg = args.slice(args.indexOf(',') + 1).trim() - return filterPropsByObjectDestructuring(secondArg, props) -} - -function getTestFnDepProps(fn: Function, props: string[]) { - if (!fn.length) - return [] - - const arg = getFnArgumentsStr(fn).trim() - if (!arg.startsWith('{')) - return props - - return filterPropsByObjectDestructuring(arg, props) -} - -function filterPropsByObjectDestructuring(argStr: string, props: string[]) { - if (!argStr.startsWith('{') || !argStr.endsWith('}')) - throw new Error('Invalid object destructuring pattern') - - const usedProps = argStr.slice(1, -1).split(',') - const filteredProps = [] - - for (const prop of usedProps) { - if (!prop) - continue - - let _prop = prop.trim() +/** + * To smartly initialize fixtures based on usage, we need to know whether a fixture will be consumed in test function(or in another fixture function) or not, so this function was implemented to get the arguments of both the test function and the fixture function. + * + * e.g. `async (use, { a, b }, ...rest) => {}` => `{ args: ['use', '{a,b}'], restParams: true }` + * + */ +function parseFnArgs(fn: Function) { + let str = fn.toString() + str = str.slice(str.indexOf('(') + 1) + str = str.replace(/\s|\'.*\'|\".*\"|\`.*\`|\(.*\)/g, '') + const parentheses = ['('] + const curlyBrackets = [] + const brackets = [] + const args: string[] = [] + let arg = '' + let i = 0 + + function addArg(a: string) { + args.push(a) + arg = '' + } - if (_prop.startsWith('...')) { - // { a, b, ...rest } - return props + while (i < str.length) { + const s = str[i++] + switch (s) { + case '(': + parentheses.push(s) + break + case ')': + parentheses.pop() + if (!parentheses.length) { + addArg(arg) + break + } + break + case '{': + curlyBrackets.push(s) + break + case '}': + curlyBrackets.pop() + break + case '[': + brackets.push(s) + break + case ']': + brackets.pop() + break + case ',': + if (!curlyBrackets.length && !brackets.length) { + addArg(arg) + continue + } + break } - - const colonIndex = _prop.indexOf(':') - if (colonIndex > 0) - _prop = _prop.slice(0, colonIndex).trim() - - if (props.includes(_prop)) - filteredProps.push(_prop) + arg += s } - - return filteredProps + const restParams = args.length > 0 && args.at(-1)?.startsWith('...') + const _args = restParams ? args.slice(0, -1) : args + return { args: _args.filter(Boolean), restParams } } -function getFnArgumentsStr(fn: Function) { - if (!fn.length) - return '' - return fn.toString().match(/[^(]*\(([^)]*)/)![1] +function isObjectDestructuring(str: string) { + return str.startsWith('{') && str.endsWith('}') } -function validateFixtureFn(fn: Function) { - if (fn.length < 1 || fn.length > 2) - throw new Error('invalid fixture function, should follow below rules:\n- have at least one argument, at most two arguments\n- the first argument is the "use" function, which must be invoked with fixture value\n- the second argument should use the object destructuring pattern to access other fixtures\n\nFor instance,\nasync (use) => { await use(0) }\nasync (use, { a, b }) => { await use(a + b) }') - - if (fn.length === 2) { - const args = getFnArgumentsStr(fn) - if (args.includes('...')) - throw new Error('rest param is not supported') - - const second = args.slice(args.indexOf(',') + 1).trim() - - if (second.length < 2 || !second.startsWith('{') || !second.endsWith('}')) - throw new Error('the second argument should use the object destructuring pattern') +/** + * `'{ a, b: { c }, ...rest }'` => `{ props: ['a', 'b'], restParams: true }` + */ +function getDestructuredProps(str: string) { + str = str.replace(/\s/g, '') + const curlyBrackets = ['{'] + const props: string[] = [] + let prop = '' + let i = 1 + + function pushProp(p: string) { + p = p.trim() + p.length > 0 && props.push(p) + prop = '' + } - if (second.indexOf('{') !== second.lastIndexOf('{')) - throw new Error('nested object destructuring pattern is not supported') + while (i < str.length) { + const s = str[i++] + if (s === '{') + curlyBrackets.push(s) + if (s === '}') { + if (curlyBrackets.length === 1) { + pushProp(prop) + break + } + else { curlyBrackets.pop() } + } + if (s === ',' && curlyBrackets.length === 1) { + pushProp(prop) + continue + } + prop += s } -} -function validateTestFn(fn: Function) { - if (fn.length > 1) - throw new Error('extended test function should have only one argument') + const restParams = props.length > 0 && props.at(-1)?.startsWith('...') + const _props = restParams ? props.slice(0, -1) : props + return { + props: _props.map((p) => { + if (/\:|\=/.test(p)) + return p.replace(/\:.*|\=.*/g, '') + return p + }), + restParams, + } } From 8c188964d2859825428ece103b08a4ee034e7876 Mon Sep 17 00:00:00 2001 From: Han Feng Date: Sat, 1 Jul 2023 11:29:14 +0800 Subject: [PATCH 05/12] refactor: resolve fn args --- packages/runner/src/fixture.ts | 197 ++++++++------------------------- 1 file changed, 48 insertions(+), 149 deletions(-) diff --git a/packages/runner/src/fixture.ts b/packages/runner/src/fixture.ts index deffbcba10b4..dc35bc35f2a7 100644 --- a/packages/runner/src/fixture.ts +++ b/packages/runner/src/fixture.ts @@ -34,29 +34,9 @@ export function mergeContextFixtures(fixtures: Record, context: { f // Update dependencies of fixture functions fixtureArray.forEach((fixture) => { if (fixture.isFn) { - const { args, restParams } = parseFnArgs(fixture.value) - - if (args.length <= 1 && !restParams) { - // async () => {} - // async (use) => {} - return - } - - // exclude self - let deps = context.fixtures!.filter(item => item.index !== fixture.index) - if (args.length >= 2 && isObjectDestructuring(args[1])) { - const { props, restParams } = getDestructuredProps(args[1]) - if (!restParams) { - // async (use, { a, b }) => {} - deps = deps.filter(item => props.includes(item.prop)) - } - } - - // async (...rest) => {} - // async (use, ...rest) => {} - // async (use, context) => {} - // async (use, { a, b, ...rest }) => {} - fixture.deps = deps + const usedProps = getUsedProps(fixture.value) + if (usedProps.length) + fixture.deps = context.fixtures!.filter(({ index, prop }) => index !== fixture.index && usedProps.includes(prop)) } }) @@ -64,29 +44,12 @@ export function mergeContextFixtures(fixtures: Record, context: { f } export function withFixtures(fn: Function, fixtures: FixtureItem[], context: TestContext & Record) { - const { args, restParams } = parseFnArgs(fn) - if ((!args.length && !restParams)) { - // test('', () => {}) + const usedProps = getUsedProps(fn) + if (!fixtures.length || !usedProps.length) return () => fn(context) - } - - // test('', (context) => {}) - // test('', ({ a, b, ...rest }) => {}) - let filteredFixtures = fixtures - if (isObjectDestructuring(args[0])) { - const { props, restParams } = getDestructuredProps(args[0]) - if (!props.length && !restParams) { - // test('', ({ }) => {}) - return () => fn(context) - } - if (!restParams && props.length) { - // test('', ({ a, b }) => {}) - filteredFixtures = fixtures.filter(item => props.includes(item.prop)) - } - } - - const pendingFixtures = resolveDeps(filteredFixtures) + const usedFixtures = fixtures.filter(({ prop }) => usedProps.includes(prop)) + const pendingFixtures = resolveDeps(usedFixtures) let cursor = 0 async function use(fixtureValue: any) { @@ -99,7 +62,7 @@ export function withFixtures(fn: Function, fixtures: FixtureItem[], context: Tes async function next() { const { value } = pendingFixtures[cursor] - typeof value === 'function' ? await value(use, context) : await use(value) + typeof value === 'function' ? await value(context, use) : await use(value) } return () => next() @@ -117,9 +80,7 @@ function resolveDeps(fixtures: FixtureItem[], depSet = new Set(), p throw new Error('circular fixture dependency') depSet.add(fixture) - resolveDeps(fixture.deps, depSet, pendingFixtures) - pendingFixtures.push(fixture) depSet.clear() }) @@ -127,113 +88,51 @@ function resolveDeps(fixtures: FixtureItem[], depSet = new Set(), p return pendingFixtures } -/** - * To smartly initialize fixtures based on usage, we need to know whether a fixture will be consumed in test function(or in another fixture function) or not, so this function was implemented to get the arguments of both the test function and the fixture function. - * - * e.g. `async (use, { a, b }, ...rest) => {}` => `{ args: ['use', '{a,b}'], restParams: true }` - * - */ -function parseFnArgs(fn: Function) { - let str = fn.toString() - str = str.slice(str.indexOf('(') + 1) - str = str.replace(/\s|\'.*\'|\".*\"|\`.*\`|\(.*\)/g, '') - const parentheses = ['('] - const curlyBrackets = [] - const brackets = [] - const args: string[] = [] - let arg = '' - let i = 0 - - function addArg(a: string) { - args.push(a) - arg = '' - } +function getUsedProps(fn: Function) { + const match = fn.toString().match(/[^(]*\(([^)]*)/) + if (!match) + return [] - while (i < str.length) { - const s = str[i++] - switch (s) { - case '(': - parentheses.push(s) - break - case ')': - parentheses.pop() - if (!parentheses.length) { - addArg(arg) - break - } - break - case '{': - curlyBrackets.push(s) - break - case '}': - curlyBrackets.pop() - break - case '[': - brackets.push(s) - break - case ']': - brackets.pop() - break - case ',': - if (!curlyBrackets.length && !brackets.length) { - addArg(arg) - continue - } - break - } - arg += s - } - const restParams = args.length > 0 && args.at(-1)?.startsWith('...') - const _args = restParams ? args.slice(0, -1) : args - return { args: _args.filter(Boolean), restParams } -} + const args = splitByComma(match[1]) + if (!args.length) + return [] -function isObjectDestructuring(str: string) { - return str.startsWith('{') && str.endsWith('}') -} + const first = args[0] + if (!(first.startsWith('{') && first.endsWith('}'))) + throw new Error('the first argument must use object destructuring pattern') -/** - * `'{ a, b: { c }, ...rest }'` => `{ props: ['a', 'b'], restParams: true }` - */ -function getDestructuredProps(str: string) { - str = str.replace(/\s/g, '') - const curlyBrackets = ['{'] - const props: string[] = [] - let prop = '' - let i = 1 - - function pushProp(p: string) { - p = p.trim() - p.length > 0 && props.push(p) - prop = '' - } + const _first = first.slice(1, -1).replace(/\s/g, '') + const props = splitByComma(_first).map((prop) => { + return prop.replace(/\:.*|\=.*/g, '') + }) - while (i < str.length) { - const s = str[i++] - if (s === '{') - curlyBrackets.push(s) - if (s === '}') { - if (curlyBrackets.length === 1) { - pushProp(prop) - break - } - else { curlyBrackets.pop() } + const last = props.at(-1) + if (last && last.startsWith('...')) + throw new Error('Rest parameters are not supported') + + return props +} + +function splitByComma(s: string) { + const result = [] + const stack = [] + let start = 0 + for (let i = 0; i < s.length; i++) { + if (s[i] === '{' || s[i] === '[') { + stack.push(s[i] === '{' ? '}' : ']') } - if (s === ',' && curlyBrackets.length === 1) { - pushProp(prop) - continue + else if (s[i] === stack[stack.length - 1]) { + stack.pop() + } + else if (!stack.length && s[i] === ',') { + const token = s.substring(start, i).trim() + if (token) + result.push(token) + start = i + 1 } - prop += s - } - - const restParams = props.length > 0 && props.at(-1)?.startsWith('...') - const _props = restParams ? props.slice(0, -1) : props - return { - props: _props.map((p) => { - if (/\:|\=/.test(p)) - return p.replace(/\:.*|\=.*/g, '') - return p - }), - restParams, } + const lastToken = s.substring(start).trim() + if (lastToken) + result.push(lastToken) + return result } From 1f056c48c286e1e81dfe316e4444d18515b39c28 Mon Sep 17 00:00:00 2001 From: Han Feng Date: Sat, 1 Jul 2023 11:29:30 +0800 Subject: [PATCH 06/12] types: extend fixture context --- packages/runner/src/types/tasks.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/runner/src/types/tasks.ts b/packages/runner/src/types/tasks.ts index 9503be4057b4..07c3c4e0da33 100644 --- a/packages/runner/src/types/tasks.ts +++ b/packages/runner/src/types/tasks.ts @@ -182,20 +182,20 @@ export type TestAPI = ChainableTestAPI & { each: TestEachFunction skipIf(condition: any): ChainableTestAPI runIf(condition: any): ChainableTestAPI - extend>(fixtures: Fixtures): TestAPI<{ + extend = {}>(fixtures: Fixtures): TestAPI<{ [K in keyof T | keyof ExtraContext]: K extends keyof T ? T[K] : K extends keyof ExtraContext ? ExtraContext[K] : never }> } export type Fixtures, ExtraContext = {}> = { - [K in keyof T]: T[K] | ((use: (fixture: T[K]) => Promise, context: { + [K in keyof T]: T[K] | ((context: { [P in keyof T | keyof ExtraContext as P extends K ? P extends keyof ExtraContext ? P : never : P ]: K extends P ? K extends keyof ExtraContext ? ExtraContext[K] : never : P extends keyof T ? T[P] : never - }) => Promise) + } & TestContext, use: (fixture: T[K]) => Promise) => Promise) } type ChainableSuiteAPI = ChainableFunction< From a75c9de19eb4e84db57d54c47d5fa7e61ff242f4 Mon Sep 17 00:00:00 2001 From: Han Feng Date: Sat, 1 Jul 2023 11:29:54 +0800 Subject: [PATCH 07/12] test: update --- ...test.ts => fixture-initialization.test.ts} | 12 +++--- test/core/test/test-extend.test.ts | 37 ++++++++++--------- 2 files changed, 25 insertions(+), 24 deletions(-) rename test/core/test/{fixture-fn-deps.test.ts => fixture-initialization.test.ts} (93%) diff --git a/test/core/test/fixture-fn-deps.test.ts b/test/core/test/fixture-initialization.test.ts similarity index 93% rename from test/core/test/fixture-fn-deps.test.ts rename to test/core/test/fixture-initialization.test.ts index 3bc02cf23507..4b7e1c0385c1 100644 --- a/test/core/test/fixture-fn-deps.test.ts +++ b/test/core/test/fixture-initialization.test.ts @@ -10,7 +10,7 @@ interface Fixtures { const fnB = vi.fn() const myTest = test.extend>({ a: 1, - b: async (use, { a }) => { + b: async ({ a }, use) => { fnB() await use (a * 2) // 2 fnB.mockClear() @@ -23,32 +23,32 @@ const fnC = vi.fn() const fnD = vi.fn() const myTest2 = myTest.extend & { a: string; b: string }>({ // override origin a - a: async (use, { a: originA }) => { + a: async ({ a: originA }, use) => { expectTypeOf(originA).toEqualTypeOf() fnA() await use(String(originA)) // '1' fnA.mockClear() }, - b: async (use, { a }) => { + b: async ({ a }, use) => { expectTypeOf(a).toEqualTypeOf() fnB2() await use(String(Number(a) * 2)) // '2' fnB2.mockClear() }, - c: async (use, { a, b }) => { + c: async ({ a, b }, use) => { expectTypeOf(b).toEqualTypeOf() fnC() await use(Number(a) + Number(b)) // 3 fnC.mockClear() }, - d: async (use, { a, b, c }) => { + d: async ({ a, b, c }, use) => { fnD() await use(Number(a) + Number(b) + c) // 6 fnD.mockClear() }, }) -describe('test.extend()', () => { +describe('fixture initialization', () => { describe('fixture override', () => { myTest('origin a and b', ({ a, b }) => { expect(a).toBe(1) diff --git a/test/core/test/test-extend.test.ts b/test/core/test/test-extend.test.ts index fbdcc500eb5c..c03fbe0bee78 100644 --- a/test/core/test/test-extend.test.ts +++ b/test/core/test/test-extend.test.ts @@ -1,5 +1,5 @@ -/* eslint-disable no-empty-pattern */ /* eslint-disable prefer-rest-params */ +/* eslint-disable no-empty-pattern */ import { describe, expect, expectTypeOf, test, vi } from 'vitest' interface Fixtures { @@ -17,7 +17,7 @@ const doneFn = vi.fn() const myTest = test .extend>({ - todoList: async (use) => { + todoList: async ({}, use) => { todoFn() await use(todoList) // cleanup @@ -27,7 +27,7 @@ const myTest = test }, }) .extend>({ - doneList: async (use) => { + doneList: async ({}, use) => { doneFn() await use(doneList) // cleanup @@ -123,30 +123,31 @@ describe('test.extend()', () => { expect(arguments[0].archiveList).toBeUndefined() }) - myTest('should init all fixtures', ({ todoList, ...rest }) => { - expect(todoFn).toBeCalledTimes(1) + myTest('should only init doneList and archiveList', function ({ doneList, archiveList }) { expect(doneFn).toBeCalledTimes(1) - expectTypeOf(todoList).toEqualTypeOf() - expectTypeOf(rest.doneList).toEqualTypeOf() - expectTypeOf(rest.archiveList).toEqualTypeOf() + expectTypeOf(doneList).toEqualTypeOf() + expectTypeOf(archiveList).toEqualTypeOf() + expectTypeOf(arguments[0].todoList).not.toEqualTypeOf() - expect(todoList).toEqual([1, 2, 3]) - expect(rest.doneList).toEqual([]) - expect(rest.archiveList).toEqual([]) + expect(doneList).toEqual([]) + expect(archiveList).toEqual([]) + expect(arguments[0].todoList).toBeUndefined() }) + }) - myTest('should init all fixtures', (context) => { + describe('test function', () => { + myTest('prop alias', ({ todoList: todos, doneList: done, archiveList: archive }) => { expect(todoFn).toBeCalledTimes(1) expect(doneFn).toBeCalledTimes(1) - expectTypeOf(context.todoList).toEqualTypeOf() - expectTypeOf(context.doneList).toEqualTypeOf() - expectTypeOf(context.archiveList).toEqualTypeOf() + expectTypeOf(todos).toEqualTypeOf() + expectTypeOf(done).toEqualTypeOf() + expectTypeOf(archive).toEqualTypeOf() - expect(context.todoList).toEqual([1, 2, 3]) - expect(context.doneList).toEqual([]) - expect(context.archiveList).toEqual([]) + expect(todos).toEqual([1, 2, 3]) + expect(done).toEqual([]) + expect(archive).toEqual([]) }) }) }) From 8bfa263e0f5c752e107c500ac2f7f0ca95d5c8d9 Mon Sep 17 00:00:00 2001 From: Han Feng Date: Sat, 1 Jul 2023 11:30:31 +0800 Subject: [PATCH 08/12] test: add fails --- .../fixtures/test-extend/fixture-rest-params.test.ts | 6 ++++++ .../fixtures/test-extend/fixture-rest-props.test.ts | 6 ++++++ .../fixture-without-destructuring.test.ts | 6 ++++++ .../fixtures/test-extend/test-rest-params.test.ts | 8 ++++++++ .../fixtures/test-extend/test-rest-props.test.ts | 8 ++++++++ .../test-extend/test-without-destructuring.test.ts | 8 ++++++++ test/fails/test/__snapshots__/runner.test.ts.snap | 12 ++++++++++++ 7 files changed, 54 insertions(+) create mode 100644 test/fails/fixtures/test-extend/fixture-rest-params.test.ts create mode 100644 test/fails/fixtures/test-extend/fixture-rest-props.test.ts create mode 100644 test/fails/fixtures/test-extend/fixture-without-destructuring.test.ts create mode 100644 test/fails/fixtures/test-extend/test-rest-params.test.ts create mode 100644 test/fails/fixtures/test-extend/test-rest-props.test.ts create mode 100644 test/fails/fixtures/test-extend/test-without-destructuring.test.ts diff --git a/test/fails/fixtures/test-extend/fixture-rest-params.test.ts b/test/fails/fixtures/test-extend/fixture-rest-params.test.ts new file mode 100644 index 000000000000..262727e828d5 --- /dev/null +++ b/test/fails/fixtures/test-extend/fixture-rest-params.test.ts @@ -0,0 +1,6 @@ +import { test } from 'vitest' + +test.extend({ + // eslint-disable-next-line unused-imports/no-unused-vars + a: async (...rest) => {}, +}) diff --git a/test/fails/fixtures/test-extend/fixture-rest-props.test.ts b/test/fails/fixtures/test-extend/fixture-rest-props.test.ts new file mode 100644 index 000000000000..ce8127338dda --- /dev/null +++ b/test/fails/fixtures/test-extend/fixture-rest-props.test.ts @@ -0,0 +1,6 @@ +import { test } from 'vitest' + +test.extend({ + // eslint-disable-next-line unused-imports/no-unused-vars + a: async ({ ...rest }) => {}, +}) diff --git a/test/fails/fixtures/test-extend/fixture-without-destructuring.test.ts b/test/fails/fixtures/test-extend/fixture-without-destructuring.test.ts new file mode 100644 index 000000000000..4cc6881645fe --- /dev/null +++ b/test/fails/fixtures/test-extend/fixture-without-destructuring.test.ts @@ -0,0 +1,6 @@ +import { test } from 'vitest' + +test.extend({ + // eslint-disable-next-line unused-imports/no-unused-vars + a: async (context) => {}, +}) diff --git a/test/fails/fixtures/test-extend/test-rest-params.test.ts b/test/fails/fixtures/test-extend/test-rest-params.test.ts new file mode 100644 index 000000000000..32cfcbdddf70 --- /dev/null +++ b/test/fails/fixtures/test-extend/test-rest-params.test.ts @@ -0,0 +1,8 @@ +import { test } from 'vitest' + +const myTest = test.extend({}) + +// eslint-disable-next-line unused-imports/no-unused-vars +myTest('', (...rest) => { + +}) diff --git a/test/fails/fixtures/test-extend/test-rest-props.test.ts b/test/fails/fixtures/test-extend/test-rest-props.test.ts new file mode 100644 index 000000000000..0a75d130d6cf --- /dev/null +++ b/test/fails/fixtures/test-extend/test-rest-props.test.ts @@ -0,0 +1,8 @@ +import { test } from 'vitest' + +const myTest = test.extend({}) + +// eslint-disable-next-line unused-imports/no-unused-vars +myTest('', ({ ...rest }) => { + +}) diff --git a/test/fails/fixtures/test-extend/test-without-destructuring.test.ts b/test/fails/fixtures/test-extend/test-without-destructuring.test.ts new file mode 100644 index 000000000000..2b1537a841d3 --- /dev/null +++ b/test/fails/fixtures/test-extend/test-without-destructuring.test.ts @@ -0,0 +1,8 @@ +import { test } from 'vitest' + +const myTest = test.extend({}) + +// eslint-disable-next-line unused-imports/no-unused-vars +myTest('', (context) => { + +}) diff --git a/test/fails/test/__snapshots__/runner.test.ts.snap b/test/fails/test/__snapshots__/runner.test.ts.snap index 68475f764dcb..7082c12039c9 100644 --- a/test/fails/test/__snapshots__/runner.test.ts.snap +++ b/test/fails/test/__snapshots__/runner.test.ts.snap @@ -40,6 +40,18 @@ TypeError: failure TypeError: failure" `; +exports[`should fail test-extend/fixture-rest-params.test.ts > test-extend/fixture-rest-params.test.ts 1`] = `"Error: the first argument must use object destructuring pattern"`; + +exports[`should fail test-extend/fixture-rest-props.test.ts > test-extend/fixture-rest-props.test.ts 1`] = `"Error: Rest parameters are not supported"`; + +exports[`should fail test-extend/fixture-without-destructuring.test.ts > test-extend/fixture-without-destructuring.test.ts 1`] = `"Error: the first argument must use object destructuring pattern"`; + +exports[`should fail test-extend/test-rest-params.test.ts > test-extend/test-rest-params.test.ts 1`] = `"Error: the first argument must use object destructuring pattern"`; + +exports[`should fail test-extend/test-rest-props.test.ts > test-extend/test-rest-props.test.ts 1`] = `"Error: Rest parameters are not supported"`; + +exports[`should fail test-extend/test-without-destructuring.test.ts > test-extend/test-without-destructuring.test.ts 1`] = `"Error: the first argument must use object destructuring pattern"`; + exports[`should fail test-timeout.test.ts > test-timeout.test.ts 1`] = ` "Error: Test timed out in 200ms. Error: Test timed out in 100ms. From 3d9a81261e35ea96534b1839e972f9f91b397615 Mon Sep 17 00:00:00 2001 From: Han Feng Date: Sat, 1 Jul 2023 11:56:52 +0800 Subject: [PATCH 09/12] docs: update --- docs/api/index.md | 2 +- docs/guide/test-context.md | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/api/index.md b/docs/api/index.md index 2ea9aa18f96a..a9d43126a023 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -70,7 +70,7 @@ In Jest, `TestFunction` can also be of type `(done: DoneCallback) => void`. If t const archive = [] const myTest = test.extend({ - todos: async (use) => { + todos: async ({ task }, use) => { todos.push(1, 2, 3) await use(todos) todos.length = 0 diff --git a/docs/guide/test-context.md b/docs/guide/test-context.md index 0aa057497a5a..93a2e5636ebb 100644 --- a/docs/guide/test-context.md +++ b/docs/guide/test-context.md @@ -51,7 +51,7 @@ const todos = [] const archive = [] export const myTest = test.extend({ - todos: async (use) => { + todos: async ({ task }, use) => { // setup the fixture before each test function todos.push(1, 2, 3) @@ -105,7 +105,7 @@ Vitest runner will smartly initialize your fixtures and inject them into the tes ```ts import { test } from 'vitest' -async function todosFn(use) { +async function todosFn({ task }, use) { await use([1, 2, 3]) } @@ -115,15 +115,17 @@ const myTest = test.extend({ }) // todosFn will not run -myTest('', () => {}) // no fixture is available -myTets('', ({ archive }) => {}) // only archive is available +myTest('', () => {}) +myTets('', ({ archive }) => {}) // todosFn will run -myTest('', ({ todos }) => {}) // only todos is available -myTest('', (context) => {}) // both are available -myTest('', ({ archive, ...rest }) => {}) // both are available +myTest('', ({ todos }) => {}) ``` +::: warning +When using `test.extend()`, you should always use the object destructuring pattern `{ todos }` to access context both in fixture function and test function. +::: + #### TypeScript To provide fixture types for all your custom contexts, you can pass the fixtures type as a generic. From 29ace52621720cf131e67daca48de23cf7759687 Mon Sep 17 00:00:00 2001 From: Han Feng Date: Sat, 1 Jul 2023 15:32:26 +0800 Subject: [PATCH 10/12] chore: allow access if no fixtures --- packages/runner/src/fixture.ts | 5 ++++- test/fails/fixtures/test-extend/test-rest-params.test.ts | 2 +- test/fails/fixtures/test-extend/test-rest-props.test.ts | 2 +- .../fixtures/test-extend/test-without-destructuring.test.ts | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/runner/src/fixture.ts b/packages/runner/src/fixture.ts index dc35bc35f2a7..53db170f1c83 100644 --- a/packages/runner/src/fixture.ts +++ b/packages/runner/src/fixture.ts @@ -44,8 +44,11 @@ export function mergeContextFixtures(fixtures: Record, context: { f } export function withFixtures(fn: Function, fixtures: FixtureItem[], context: TestContext & Record) { + if (!fixtures.length) + return () => fn(context) + const usedProps = getUsedProps(fn) - if (!fixtures.length || !usedProps.length) + if (!usedProps.length) return () => fn(context) const usedFixtures = fixtures.filter(({ prop }) => usedProps.includes(prop)) diff --git a/test/fails/fixtures/test-extend/test-rest-params.test.ts b/test/fails/fixtures/test-extend/test-rest-params.test.ts index 32cfcbdddf70..b058a3a05b59 100644 --- a/test/fails/fixtures/test-extend/test-rest-params.test.ts +++ b/test/fails/fixtures/test-extend/test-rest-params.test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest' -const myTest = test.extend({}) +const myTest = test.extend({ a: 1 }) // eslint-disable-next-line unused-imports/no-unused-vars myTest('', (...rest) => { diff --git a/test/fails/fixtures/test-extend/test-rest-props.test.ts b/test/fails/fixtures/test-extend/test-rest-props.test.ts index 0a75d130d6cf..31492881ed74 100644 --- a/test/fails/fixtures/test-extend/test-rest-props.test.ts +++ b/test/fails/fixtures/test-extend/test-rest-props.test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest' -const myTest = test.extend({}) +const myTest = test.extend({ a: 1 }) // eslint-disable-next-line unused-imports/no-unused-vars myTest('', ({ ...rest }) => { diff --git a/test/fails/fixtures/test-extend/test-without-destructuring.test.ts b/test/fails/fixtures/test-extend/test-without-destructuring.test.ts index 2b1537a841d3..24d9aa774d9c 100644 --- a/test/fails/fixtures/test-extend/test-without-destructuring.test.ts +++ b/test/fails/fixtures/test-extend/test-without-destructuring.test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest' -const myTest = test.extend({}) +const myTest = test.extend({ a: 1 }) // eslint-disable-next-line unused-imports/no-unused-vars myTest('', (context) => { From 377a8ad58d4f971a761f3b6e45a9861d05eb71ff Mon Sep 17 00:00:00 2001 From: Han Feng Date: Sat, 1 Jul 2023 15:34:01 +0800 Subject: [PATCH 11/12] docs: update --- docs/guide/test-context.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/test-context.md b/docs/guide/test-context.md index 93a2e5636ebb..ac1c7a5ee53c 100644 --- a/docs/guide/test-context.md +++ b/docs/guide/test-context.md @@ -123,7 +123,7 @@ myTest('', ({ todos }) => {}) ``` ::: warning -When using `test.extend()`, you should always use the object destructuring pattern `{ todos }` to access context both in fixture function and test function. +When using `test.extend()` with fixtures, you should always use the object destructuring pattern `{ todos }` to access context both in fixture function and test function. ::: #### TypeScript From 9945b063e7b7e206b57aa3c739e7ae6d0243fb33 Mon Sep 17 00:00:00 2001 From: Han Feng Date: Sat, 1 Jul 2023 15:57:01 +0800 Subject: [PATCH 12/12] test: circular dependency --- .../test-extend/circular-dependency.test.ts | 14 ++++++++++++++ test/fails/test/__snapshots__/runner.test.ts.snap | 2 ++ 2 files changed, 16 insertions(+) create mode 100644 test/fails/fixtures/test-extend/circular-dependency.test.ts diff --git a/test/fails/fixtures/test-extend/circular-dependency.test.ts b/test/fails/fixtures/test-extend/circular-dependency.test.ts new file mode 100644 index 000000000000..3e69e296d640 --- /dev/null +++ b/test/fails/fixtures/test-extend/circular-dependency.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from 'vitest' + +const myTest = test.extend<{ a: number; b: number }>({ + a: async ({ b }, use) => { + await use(b) + }, + b: async ({ a }, use) => { + await use(a) + }, +}) + +myTest('', ({ a }) => { + expect(a).toBe(0) +}) diff --git a/test/fails/test/__snapshots__/runner.test.ts.snap b/test/fails/test/__snapshots__/runner.test.ts.snap index 7082c12039c9..3e31825c19a2 100644 --- a/test/fails/test/__snapshots__/runner.test.ts.snap +++ b/test/fails/test/__snapshots__/runner.test.ts.snap @@ -40,6 +40,8 @@ TypeError: failure TypeError: failure" `; +exports[`should fail test-extend/circular-dependency.test.ts > test-extend/circular-dependency.test.ts 1`] = `"Error: circular fixture dependency"`; + exports[`should fail test-extend/fixture-rest-params.test.ts > test-extend/fixture-rest-params.test.ts 1`] = `"Error: the first argument must use object destructuring pattern"`; exports[`should fail test-extend/fixture-rest-props.test.ts > test-extend/fixture-rest-props.test.ts 1`] = `"Error: Rest parameters are not supported"`;