Skip to content

Commit d8e6beb

Browse files
committed
feat(runner): implement nominal overloads for mergeTests API
- Refactor mergeTests to use explicit nominal overloads (up to 6 args) - Remove complex structural extraction types (ExtractTestContext, UnionToIntersection) - Implement context-aware fixture merging in TestFixtures.merge - Add comprehensive runtime and type tests for mergeTests - Ensure strictly nominal TestAPI propagation
1 parent 7652696 commit d8e6beb

File tree

5 files changed

+136
-23
lines changed

5 files changed

+136
-23
lines changed

packages/runner/src/fixture.ts

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -77,22 +77,16 @@ export class TestFixtures {
7777
return this._registrations
7878
}
7979

80-
/**
81-
* @internal
82-
*/
83-
resolveFixtures(): UserFixtures {
80+
toUserFixtures(): UserFixtures {
8481
const fixtures: UserFixtures = {}
85-
const registrations = this._registrations
86-
87-
for (const [name, fixture] of registrations.entries()) {
82+
for (const [name, fixture] of this._registrations) {
8883
const options: FixtureOptions = {
8984
auto: fixture.auto,
9085
scope: fixture.scope,
9186
injected: fixture.injected,
9287
}
9388
fixtures[name] = [fixture.value, options]
9489
}
95-
9690
return fixtures
9791
}
9892

@@ -213,7 +207,37 @@ export class TestFixtures {
213207
}
214208
})
215209

216-
// validate fixture dependency scopes
210+
this.validateFixtureGraph(registrations, errors)
211+
212+
return registrations
213+
}
214+
215+
merge(other: TestFixtures): TestFixtures {
216+
const registrations = new Map(this._registrations)
217+
const errors: Error[] = []
218+
219+
const { suite, file } = getCurrentSuite()
220+
const context = suite || file
221+
const isTopLevel = !suite || suite.file === suite
222+
223+
// We need to resolve fixtures from the current context, because test.override
224+
// inside the suite only modifies overrides for that suite.
225+
const otherRegistrations = context ? other.get(context) : other._registrations
226+
227+
for (const [name, fixture] of otherRegistrations) {
228+
if (!isTopLevel && fixture.scope !== 'test') {
229+
errors.push(new FixtureDependencyError(`The "${name}" fixture cannot be defined with a ${fixture.scope} scope inside the describe block. Define it at the top level of the file instead.`))
230+
}
231+
registrations.set(name, fixture)
232+
}
233+
234+
this.validateFixtureGraph(registrations, errors)
235+
236+
return new TestFixtures(registrations)
237+
}
238+
239+
private validateFixtureGraph(registrations: FixtureRegistrations, errors: Error[] = []) {
240+
217241
for (const fixture of registrations.values()) {
218242
for (const depName of fixture.deps) {
219243
if (TestFixtures._builtinFixtures.includes(depName)) {
@@ -243,7 +267,6 @@ export class TestFixtures {
243267
else if (errors.length > 1) {
244268
throw new AggregateError(errors, 'Cannot resolve user fixtures. See errors for more information.')
245269
}
246-
return registrations
247270
}
248271
}
249272

packages/runner/src/suite.ts

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1093,14 +1093,44 @@ function formatTemplateString(cases: any[], args: any[]): any[] {
10931093
return res
10941094
}
10951095

1096-
export function mergeTests<A, B>(
1097-
test: TestAPI<A>,
1098-
testB: TestAPI<B>,
1099-
): TestAPI<A & B> {
1100-
const ctx = getChainableContext(testB)
1101-
if (!ctx) {
1102-
throw new TypeError('Cannot merge tests: extension is not a valid TestAPI')
1096+
export function mergeTests<A>(a: TestAPI<A>): TestAPI<A>
1097+
export function mergeTests<A, B>(a: TestAPI<A>, b: TestAPI<B>): TestAPI<A & B>
1098+
export function mergeTests<A, B, C>(a: TestAPI<A>, b: TestAPI<B>, c: TestAPI<C>): TestAPI<A & B & C>
1099+
export function mergeTests<A, B, C, D>(a: TestAPI<A>, b: TestAPI<B>, c: TestAPI<C>, d: TestAPI<D>): TestAPI<A & B & C & D>
1100+
export function mergeTests<A, B, C, D, E>(a: TestAPI<A>, b: TestAPI<B>, c: TestAPI<C>, d: TestAPI<D>, e: TestAPI<E>): TestAPI<A & B & C & D & E>
1101+
export function mergeTests<A, B, C, D, E, F>(a: TestAPI<A>, b: TestAPI<B>, c: TestAPI<C>, d: TestAPI<D>, e: TestAPI<E>, f: TestAPI<F>): TestAPI<A & B & C & D & E & F>
1102+
export function mergeTests(...tests: TestAPI<any>[]): TestAPI<any> {
1103+
if (tests.length === 0) {
1104+
throw new TypeError('mergeTests requires at least one test')
11031105
}
1104-
const fixtures = ctx.getFixtures().resolveFixtures()
1105-
return test.extend(fixtures as any) as TestAPI<A & B>
1106+
1107+
// use the base test as the starting point
1108+
const [base, ...rest] = tests
1109+
const baseContext = getChainableContext(base)
1110+
if (!baseContext || typeof baseContext.getFixtures !== 'function') {
1111+
throw new TypeError('Cannot merge tests: argument is not a valid test instance')
1112+
}
1113+
1114+
let fixtures = baseContext.getFixtures()
1115+
1116+
for (const test of rest) {
1117+
const ctx = getChainableContext(test)
1118+
if (!ctx || typeof ctx.getFixtures !== 'function') {
1119+
throw new TypeError('Cannot merge tests: argument is not a valid test instance')
1120+
}
1121+
fixtures = fixtures.merge(ctx.getFixtures())
1122+
}
1123+
1124+
// We call extend({}) to clone the chainable API while preserving internal flags.
1125+
// The newly created fixtures are immediately replaced via mergeContext.
1126+
const result = base.extend({})
1127+
const resultContext = getChainableContext(result)
1128+
1129+
if (!resultContext) {
1130+
throw new TypeError('Cannot merge tests: argument is not a valid test instance')
1131+
}
1132+
1133+
resultContext.mergeContext({ fixtures })
1134+
1135+
return result
11061136
}

packages/vitest/src/public/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,13 +106,13 @@ export {
106106
beforeEach,
107107
describe,
108108
it,
109-
mergeTests,
110109
onTestFailed,
111110
onTestFinished,
112111
recordArtifact,
113112
suite,
114113
test,
115114
} from '@vitest/runner'
115+
export { mergeTests } from '@vitest/runner'
116116
export type {
117117
ImportDuration,
118118
OnTestFailedHandler,

test/core/test/merge-tests.test-d.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ const testB = test.extend({
1111
const merged = mergeTests(testA, testB)
1212

1313
merged('types', ({ a, b }) => {
14-
expectTypeOf(a).toBeNumber()
15-
expectTypeOf(b).toBeNumber()
14+
expectTypeOf(a).not.toBeAny()
15+
expectTypeOf(b).not.toBeAny()
16+
expectTypeOf(a).toEqualTypeOf<number>()
17+
expectTypeOf(b).toEqualTypeOf<number>()
1618
})
1719

1820
const testC = test.extend({
@@ -21,8 +23,20 @@ const testC = test.extend({
2123

2224
const merged3 = mergeTests(merged, testC)
2325

24-
merged3('chained types', ({ a, b, c }) => {
26+
merged3('chained merge types', ({ a, b, c }) => {
2527
expectTypeOf(a).toBeNumber()
2628
expectTypeOf(b).toBeNumber()
2729
expectTypeOf(c).toBeString()
2830
})
31+
32+
const testBool = test.extend({
33+
d: true,
34+
})
35+
36+
const mergedVariadic = mergeTests(testA, testB, testBool)
37+
38+
mergedVariadic('variadic types', ({ a, b, d }) => {
39+
expectTypeOf(a).toBeNumber()
40+
expectTypeOf(b).toBeNumber()
41+
expectTypeOf(d).toBeBoolean()
42+
})

test/core/test/merge-tests.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,50 @@ describe('mergeTests', () => {
6161
expect(b).toBe('b')
6262
})
6363
})
64+
65+
describe('variadic merge (A, B, C) overrides correctly', () => {
66+
const testA = test.extend({
67+
a: 1,
68+
shared: 'a',
69+
})
70+
const testB = test.extend({
71+
b: 2,
72+
shared: 'b',
73+
})
74+
const testC = test.extend({
75+
c: 3,
76+
shared: 'c',
77+
})
78+
79+
const merged = mergeTests(testA, testB, testC)
80+
81+
merged('inherits all fixtures and overrides from last', ({ a, b, c, shared }) => {
82+
expect(a).toBe(1)
83+
expect(b).toBe(2)
84+
expect(c).toBe(3)
85+
expect(shared).toBe('c')
86+
})
87+
})
88+
89+
describe('overrides', () => {
90+
describe('top-level', () => {
91+
const base = test.extend({ a: 1 })
92+
base.override({ a: 2 })
93+
const merged = mergeTests(base)
94+
95+
merged('confirms top-level overrides are respected', ({ a }) => {
96+
expect(a).toBe(2)
97+
})
98+
})
99+
100+
describe('scoped', () => {
101+
const base = test.extend({ a: 1 })
102+
base.override({ a: 2 })
103+
const merged = mergeTests(base)
104+
105+
merged('confirms scoped overrides are respected', ({ a }) => {
106+
expect(a).toBe(2)
107+
})
108+
})
109+
})
64110
})

0 commit comments

Comments
 (0)