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
Merged
2 changes: 1 addition & 1 deletion docs/api/index.md
Expand Up @@ -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
Expand Down
16 changes: 9 additions & 7 deletions docs/guide/test-context.md
Expand Up @@ -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)

Expand Down Expand Up @@ -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])
}

Expand All @@ -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()` with fixtures, 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.
Expand Down
153 changes: 114 additions & 39 deletions packages/runner/src/fixture.ts
@@ -1,66 +1,141 @@
import type { Fixtures, Test } from './types'
import type { TestContext } from './types'

export interface FixtureItem {
prop: string
value: any
index: number
/**
* Indicates whether the fixture is a function
*/
isFn: boolean
/**
* The dependencies(fixtures) of current fixture function.
*/
deps?: FixtureItem[]
}

export function mergeContextFixtures(fixtures: Record<string, any>, context: { fixtures?: FixtureItem[] } = {}) {
const fixtureArray: FixtureItem[] = Object.entries(fixtures)
.map(([prop, value], index) => {
const isFn = typeof value === 'function'
return {
prop,
value,
index,
isFn,
}
})

if (Array.isArray(context.fixtures))
context.fixtures = context.fixtures.concat(fixtureArray)
else
context.fixtures = fixtureArray

// Update dependencies of fixture functions
fixtureArray.forEach((fixture) => {
if (fixture.isFn) {
const usedProps = getUsedProps(fixture.value)
if (usedProps.length)
fixture.deps = context.fixtures!.filter(({ index, prop }) => index !== fixture.index && usedProps.includes(prop))
}
})

export function withFixtures(fn: Function, fixtures: Fixtures<Record<string, any>>, context: Test<Record<string, any>>['context']) {
const props = getUsedFixtureProps(fn, Object.keys(fixtures))
return context
}

if (props.length === 0)
export function withFixtures(fn: Function, fixtures: FixtureItem[], context: TestContext & Record<string, any>) {
if (!fixtures.length)
return () => fn(context)

const usedProps = getUsedProps(fn)
if (!usedProps.length)
return () => fn(context)

const usedFixtures = fixtures.filter(({ prop }) => usedProps.includes(prop))
const pendingFixtures = resolveDeps(usedFixtures)
let cursor = 0

async function use(fixtureValue: any) {
context[props[cursor++]] = fixtureValue

if (cursor < props.length)
const { prop } = pendingFixtures[cursor++]
context[prop] = fixtureValue
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(context, use) : await use(value)
}

return () => next()
}

function getUsedFixtureProps(fn: Function, fixtureProps: string[]) {
if (!fixtureProps.length || !fn.length)
return []
function resolveDeps(fixtures: FixtureItem[], depSet = new Set<FixtureItem>(), 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 paramsStr = fn.toString().match(/[^(]*\(([^)]*)/)![1]
depSet.add(fixture)
resolveDeps(fixture.deps, depSet, pendingFixtures)
pendingFixtures.push(fixture)
depSet.clear()
})

if (paramsStr[0] === '{' && paramsStr.at(-1) === '}') {
// ({...}) => {}
const props = paramsStr.slice(1, -1).split(',')
const filteredProps = []
return pendingFixtures
}

for (const prop of props) {
if (!prop)
continue
function getUsedProps(fn: Function) {
const match = fn.toString().match(/[^(]*\(([^)]*)/)
if (!match)
return []

let _prop = prop.trim()
const args = splitByComma(match[1])
if (!args.length)
return []

if (_prop.startsWith('...')) {
// ({ a, b, ...rest }) => {}
return fixtureProps
}
const first = args[0]
if (!(first.startsWith('{') && first.endsWith('}')))
throw new Error('the first argument must use object destructuring pattern')

const colonIndex = _prop.indexOf(':')
if (colonIndex > 0)
_prop = _prop.slice(0, colonIndex).trim()
const _first = first.slice(1, -1).replace(/\s/g, '')
const props = splitByComma(_first).map((prop) => {
return prop.replace(/\:.*|\=.*/g, '')
})

if (fixtureProps.includes(_prop))
filteredProps.push(_prop)
}
const last = props.at(-1)
if (last && last.startsWith('...'))
throw new Error('Rest parameters are not supported')

// ({}) => {}
// ({ a, b, c}) => {}
return filteredProps
}
return props
}

// (ctx) => {}
return fixtureProps
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] === '{' ? '}' : ']')
}
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
}
}
const lastToken = s.substring(start).trim()
if (lastToken)
result.push(lastToken)
return result
}
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] | ((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
} & TestContext, use: (fixture: T[K]) => Promise<void>) => 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
}