Skip to content

Commit ddf97bf

Browse files
committed
refactor(runner): simplify mergeTests implementation and restore baseline architecture
1 parent d71197a commit ddf97bf

File tree

2 files changed

+58
-133
lines changed

2 files changed

+58
-133
lines changed

packages/runner/src/fixture.ts

Lines changed: 46 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,10 @@ export interface TestFixtureItem extends FixtureOptions {
1717
export type UserFixtures = Record<string, unknown>
1818
export type FixtureRegistrations = Map<string, TestFixtureItem>
1919

20-
const kPropsSymbol = Symbol('vitest:fixture-props')
21-
22-
interface FixturePropsOptions {
23-
original: any
24-
index?: number
25-
}
26-
2720
export class TestFixtures {
28-
private _suiteContexts: WeakMap<Suite | symbol, Record<string, unknown>>
21+
private _suiteContexts: WeakMap<Suite | symbol, /* context object */ Record<string, unknown>>
2922
private _overrides = new WeakMap<Suite, FixtureRegistrations>()
3023
private _registrations: FixtureRegistrations
31-
private _parent?: TestFixtures
3224

3325
private static _definitions: TestFixtures[] = []
3426
private static _builtinFixtures: string[] = [
@@ -56,129 +48,64 @@ export class TestFixtures {
5648
return TestFixtures._definitions.map(f => f.getFileContext(file))
5749
}
5850

59-
constructor(
60-
registrations?: FixtureRegistrations,
61-
parent?: TestFixtures,
62-
) {
51+
constructor(registrations?: FixtureRegistrations) {
6352
this._registrations = registrations ?? new Map()
64-
this._parent = parent
65-
66-
// isolation fix: do NOT share contexts with parent
6753
this._suiteContexts = new WeakMap()
68-
6954
TestFixtures._definitions.push(this)
7055
}
7156

72-
extend(runner: VitestRunner, userFixtures: UserFixtures): TestFixtures {
57+
extend(runner: VitestRunner, userFixtures: UserFixtures | TestFixtures): TestFixtures {
7358
const { suite } = getCurrentSuite()
7459
const isTopLevel = !suite || suite.file === suite
75-
// Pass empty map to avoid copying parent registrations
76-
const registrations = this.parseUserFixtures(runner, userFixtures, isTopLevel, new Map())
77-
return new TestFixtures(registrations, this)
78-
}
79-
80-
get(suite: Suite): FixtureRegistrations {
81-
let currentSuite: Suite | undefined = suite
82-
while (currentSuite) {
83-
if (this._overrides.has(currentSuite)) {
84-
return this._overrides.get(currentSuite)!
85-
}
8660

87-
if (currentSuite === currentSuite.file) {
88-
break
61+
if (userFixtures instanceof TestFixtures) {
62+
const registrations = new Map(this._registrations)
63+
for (const [name, item] of userFixtures._registrations) {
64+
registrations.set(name, item)
8965
}
90-
currentSuite = currentSuite.suite || currentSuite.file
91-
}
92-
93-
if (this._parent) {
94-
return new Map([...this._parent.getPureRegistrations(), ...this._registrations])
66+
return new TestFixtures(registrations)
9567
}
9668

97-
return this._registrations
69+
const registrations = this.parseUserFixtures(runner, userFixtures, isTopLevel)
70+
return new TestFixtures(registrations)
9871
}
9972

100-
private getPureRegistrations(): FixtureRegistrations {
101-
if (this._parent) {
102-
return new Map([...this._parent.getPureRegistrations(), ...this._registrations])
103-
}
104-
return this._registrations
73+
getFixtures(): TestFixtures {
74+
return this
10575
}
10676

107-
isAncestor(fixture: TestFixtures): boolean {
108-
let current: TestFixtures | undefined = this as TestFixtures
109-
while (current) {
110-
if (current === fixture) {
111-
return true
77+
get(suite: Suite): FixtureRegistrations {
78+
let currentSuite: Suite | undefined = suite
79+
while (currentSuite) {
80+
const overrides = this._overrides.get(currentSuite)
81+
// return the closest override
82+
if (overrides) {
83+
return overrides
11284
}
113-
current = current._parent
114-
}
115-
return false
116-
}
117-
118-
resolveFixture(name: string): TestFixtureItem | undefined {
119-
if (this._registrations.has(name)) {
120-
return this._registrations.get(name)
121-
}
122-
if (this._parent) {
123-
return this._parent.resolveFixture(name)
124-
}
125-
return undefined
126-
}
127-
128-
toUserFixtures(ignore?: TestFixtures): UserFixtures {
129-
const fixtures: UserFixtures = {}
130-
const registrations = new Map<string, TestFixtureItem>()
131-
let current: TestFixtures | undefined = this as TestFixtures
132-
133-
while (current) {
134-
if (ignore && ignore.isAncestor(current)) {
85+
if (currentSuite === currentSuite.file) {
13586
break
13687
}
137-
138-
for (const [name, fixture] of current._registrations) {
139-
if (registrations.has(name)) {
140-
continue
141-
}
142-
// If the fixture is just a copy from the parent, we skip it
143-
// We will encounter it again when traversing up (unless it's a common ancestor)
144-
if (current._parent) {
145-
const parentFixture = current._parent.resolveFixture(name)
146-
if (parentFixture === fixture) {
147-
continue
148-
}
149-
}
150-
registrations.set(name, fixture)
151-
}
152-
current = current._parent
153-
}
154-
155-
for (const [name, fixture] of registrations) {
156-
const options: FixtureOptions = {
157-
auto: fixture.auto,
158-
scope: fixture.scope,
159-
injected: fixture.injected,
160-
}
161-
let value = fixture.value
162-
// Unwrap the fixture if it was wrapped by another mergeTests call
163-
if (typeof value === 'function' && kPropsSymbol in value) {
164-
const props = value[kPropsSymbol] as FixturePropsOptions
165-
if (props.original) {
166-
value = props.original
167-
}
168-
}
169-
fixtures[name] = [value, options]
88+
currentSuite = currentSuite.suite || currentSuite.file
17089
}
171-
return fixtures
90+
return this._registrations
17291
}
17392

17493
override(runner: VitestRunner, userFixtures: UserFixtures): void {
17594
const { suite: currentSuite, file } = getCurrentSuite()
17695
const suite = currentSuite || file
17796
const isTopLevel = !currentSuite || currentSuite.file === currentSuite
97+
// Create a copy of the closest parent's registrations to avoid modifying them
98+
// For chained calls, this.get(suite) returns this suite's overrides; for first call, returns parent's
17899
const suiteRegistrations = new Map(this.get(suite))
179100
const registrations = this.parseUserFixtures(runner, userFixtures, isTopLevel, suiteRegistrations)
180-
181-
this._overrides.set(suite, registrations)
101+
// If defined in top-level, just override all registrations
102+
// We don't support overriding suite-level fixtures anyway (it will throw an error)
103+
if (isTopLevel) {
104+
this._registrations = registrations
105+
}
106+
else {
107+
this._overrides.set(suite, registrations)
108+
}
182109
}
183110

184111
getFileContext(file: File): Record<string, any> {
@@ -190,10 +117,7 @@ export class TestFixtures {
190117

191118
getWorkerContext(): Record<string, any> {
192119
if (!this._suiteContexts.has(TestFixtures._workerContextSymbol)) {
193-
this._suiteContexts.set(
194-
TestFixtures._workerContextSymbol,
195-
Object.create(null),
196-
)
120+
this._suiteContexts.set(TestFixtures._workerContextSymbol, Object.create(null))
197121
}
198122
return this._suiteContexts.get(TestFixtures._workerContextSymbol)!
199123
}
@@ -231,8 +155,7 @@ export class TestFixtures {
231155
value = fn
232156
}
233157

234-
// Check against current registrations mask or parent
235-
const parent = registrations.get(name) || this._registrations.get(name) || (this._parent ? this._parent.resolveFixture(name) : undefined)
158+
const parent = registrations.get(name)
236159
if (parent && options) {
237160
if (parent.scope !== options.scope) {
238161
errors.push(new FixtureDependencyError(`The "${name}" fixture was already registered with a "${options.scope}" scope.`))
@@ -256,10 +179,6 @@ export class TestFixtures {
256179
}
257180
}
258181

259-
if (!options) {
260-
throw new Error('Fixture options are not defined. This should not happen.')
261-
}
262-
263182
if (options.scope && !TestFixtures._fixtureScopes.includes(options.scope)) {
264183
errors.push(new FixtureDependencyError(`The "${name}" fixture has unknown scope "${options.scope}".`))
265184
}
@@ -288,19 +207,14 @@ export class TestFixtures {
288207
}
289208
})
290209

291-
this.validateFixtureGraph(registrations, errors)
292-
293-
return registrations
294-
}
295-
296-
private validateFixtureGraph(registrations: FixtureRegistrations, errors: Error[] = []) {
210+
// validate fixture dependency scopes
297211
for (const fixture of registrations.values()) {
298212
for (const depName of fixture.deps) {
299213
if (TestFixtures._builtinFixtures.includes(depName)) {
300214
continue
301215
}
302216

303-
const dep = registrations.get(depName) || this.resolveFixture(depName)
217+
const dep = registrations.get(depName)
304218
if (!dep) {
305219
errors.push(new FixtureDependencyError(`The "${fixture.name}" fixture depends on unknown fixture "${depName}".`))
306220
continue
@@ -323,6 +237,7 @@ export class TestFixtures {
323237
else if (errors.length > 1) {
324238
throw new AggregateError(errors, 'Cannot resolve user fixtures. See errors for more information.')
325239
}
240+
return registrations
326241
}
327242
}
328243

@@ -507,7 +422,7 @@ function isFixtureFunction(value: unknown): value is FixtureFn<any, any, any> {
507422
return typeof value === 'function'
508423
}
509424

510-
async function resolveTestFixtureValue(
425+
function resolveTestFixtureValue(
511426
fixture: TestFixtureItem,
512427
context: TestContext & { [key: string]: any },
513428
cleanupFnArray: (() => void | Promise<void>)[],
@@ -516,12 +431,11 @@ async function resolveTestFixtureValue(
516431
return fixture.value
517432
}
518433

519-
const value = await resolveFixtureFunction(
434+
return resolveFixtureFunction(
520435
fixture.value,
521436
context,
522437
cleanupFnArray,
523438
)
524-
return value
525439
}
526440

527441
const scopedFixturePromiseCache = new WeakMap<TestFixtureItem, Promise<unknown>>()
@@ -659,6 +573,13 @@ function validateSuiteHook(fn: Function, hook: string, error: Error | undefined)
659573
}
660574
}
661575

576+
const kPropsSymbol = Symbol('$vitest:fixture-props')
577+
578+
interface FixturePropsOptions {
579+
original: any
580+
index?: number
581+
}
582+
662583
export function configureProps(fn: Function, options: FixturePropsOptions): void {
663584
Object.defineProperty(fn, kPropsSymbol, {
664585
value: options,

packages/runner/src/suite.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ function createDefaultSuite(runner: VitestRunner) {
220220
if (config.concurrent != null) {
221221
options.concurrent = config.concurrent
222222
}
223-
const collector = suite('', options, () => { })
223+
const collector = suite('', options, () => {})
224224
// no parent suite for top-level tests
225225
delete collector.suite
226226
return collector
@@ -302,7 +302,7 @@ function parseArguments<T extends (...args: any[]) => any>(
302302
// implementations
303303
function createSuiteCollector(
304304
name: string,
305-
factory: SuiteFactory = () => { },
305+
factory: SuiteFactory = () => {},
306306
mode: RunMode,
307307
each?: boolean,
308308
suiteOptions?: SuiteOptions,
@@ -615,11 +615,11 @@ function createSuite() {
615615
options.shuffle = shuffle
616616
}
617617

618-
let mode: RunMode = (this.only || options.only)
618+
let mode: RunMode = (this.only ?? options.only)
619619
? 'only'
620-
: (this.skip || options.skip)
620+
: (this.skip ?? options.skip)
621621
? 'skip'
622-
: (this.todo || options.todo)
622+
: (this.todo ?? options.todo)
623623
? 'todo'
624624
: 'run'
625625

@@ -1093,6 +1093,11 @@ function formatTemplateString(cases: any[], args: any[]): any[] {
10931093
return res
10941094
}
10951095

1096+
/**
1097+
* Merges multiple test instances into a single test instance.
1098+
* All fixtures from the provided tests will be available in the returned test.
1099+
* If multiple tests define the same fixture name, the last one wins.
1100+
*/
10961101
export function mergeTests<A>(a: TestAPI<A>): TestAPI<A>
10971102
export function mergeTests<A, B>(a: TestAPI<A>, b: TestAPI<B>): TestAPI<A & B>
10981103
export function mergeTests<A, B, C>(a: TestAPI<A>, b: TestAPI<B>, c: TestAPI<C>): TestAPI<A & B & C>
@@ -1110,17 +1115,16 @@ export function mergeTests(...tests: TestAPI<any>[]): TestAPI<any> {
11101115
for (const nextTest of rest) {
11111116
const nextContext = getChainableContext(nextTest)
11121117
if (!nextContext || typeof nextContext.getFixtures !== 'function') {
1113-
throw new TypeError('Cannot merge tests: argument is not a valid test instance')
1118+
throw new TypeError('mergeTests requires extended test instances created via test.extend()')
11141119
}
11151120

11161121
// Extract fixtures from the next test and extend the current test
11171122
// This behaves exactly like currentTest.extend(nextFixtures)
1118-
// Existing overrides on currentTest are dropped/reset by .extend(), which is the intended simplified behavior.
11191123
const currentContext = getChainableContext(currentTest)
11201124
if (!currentContext) {
11211125
throw new TypeError('Cannot merge tests: base test is not a valid test instance')
11221126
}
1123-
const fixtures = nextContext.getFixtures().toUserFixtures()
1127+
const fixtures = nextContext.getFixtures()
11241128
currentTest = currentTest.extend(fixtures as any)
11251129
}
11261130

0 commit comments

Comments
 (0)