Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support accessing other fixtures in fixture function #3651

Merged
merged 16 commits into from Jul 3, 2023
162 changes: 129 additions & 33 deletions packages/runner/src/fixture.ts
@@ -1,66 +1,162 @@
import type { Fixtures, Test } from './types'

export function withFixtures(fn: Function, fixtures: Fixtures<Record<string, any>>, context: Test<Record<string, any>>['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<Record<string, any>>, context: Record<string, any> = {}) {
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<Record<string, any>>['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<FixtureItem>) {
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) }`')
fenghan34 marked this conversation as resolved.
Show resolved Hide resolved

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
}
17 changes: 10 additions & 7 deletions packages/runner/src/suite.ts
Expand Up @@ -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()
Expand Down Expand Up @@ -232,7 +233,7 @@ function createSuite() {

function createTest(fn: (
(
this: Record<'concurrent' | 'skip' | 'only' | 'todo' | 'fails' | 'each', boolean | undefined> & { fixtures?: Fixtures<Record<string, any>> },
this: Record<'concurrent' | 'skip' | 'only' | 'todo' | 'fails' | 'each', boolean | undefined> & { fixtures?: FixtureItem[] },
title: string,
fn?: TestFunction,
options?: number | TestOptions
Expand Down Expand Up @@ -266,20 +267,22 @@ function createTest(fn: (
testFn.runIf = (condition: any) => (condition ? test : test.skip) as TestAPI

testFn.extend = function (fixtures: Fixtures<Record<string, any>>) {
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) {
Expand Down
15 changes: 12 additions & 3 deletions packages/runner/src/types/tasks.ts
Expand Up @@ -182,11 +182,20 @@ export type TestAPI<ExtraContext = {}> = ChainableTestAPI<ExtraContext> & {
each: TestEachFunction
skipIf(condition: any): ChainableTestAPI<ExtraContext>
runIf(condition: any): ChainableTestAPI<ExtraContext>
extend<T extends Record<string, any>>(fixtures: Fixtures<T>): TestAPI<ExtraContext & T>
extend<T extends Record<string, any>>(fixtures: Fixtures<T, ExtraContext>): TestAPI<{
[K in keyof T | keyof ExtraContext]:
K extends keyof T ? T[K] :
K extends keyof ExtraContext ? ExtraContext[K] : never }>
}

export type Fixtures<T extends Record<string, any>> = {
[K in keyof T]: T[K] | ((use: (fixture: T[K]) => Promise<void>) => Promise<void>)
export type Fixtures<T extends Record<string, any>, ExtraContext = {}> = {
[K in keyof T]: T[K] | ((use: (fixture: T[K]) => Promise<void>, 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<void>)
}

type ChainableSuiteAPI<ExtraContext = {}> = ChainableFunction<
Expand Down
6 changes: 4 additions & 2 deletions packages/runner/src/utils/chain.ts
Expand Up @@ -9,7 +9,6 @@ export type ChainableFunction<T extends string, Args extends any[], R = any, E =
export function createChainable<T extends string, Args extends any[], R = any, E = {}>(
keys: T[],
fn: (this: Record<T, any>, ...args: Args) => R,
initialContext?: Record<T, any>,
): ChainableFunction<T, Args, R, E> {
function create(context: Record<T, any>) {
const chain = function (this: any, ...args: Args) {
Expand All @@ -20,6 +19,9 @@ export function createChainable<T extends string, Args extends any[], R = any, E
chain.setContext = (key: T, value: any) => {
context[key] = value
}
chain.mergeContext = (ctx: Record<T, any>) => {
Object.assign(context, ctx)
}
for (const key of keys) {
Object.defineProperty(chain, key, {
get() {
Expand All @@ -30,7 +32,7 @@ export function createChainable<T extends string, Args extends any[], R = any, E
return chain
}

const chain = create(initialContext || {} as any) as any
const chain = create({} as any) as any
chain.fn = fn
return chain
}