Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1349,11 +1349,14 @@ export type GeneratorOperations = Record<string, GeneratorOperation>;
// A single generator's accumulated mock output, keyed by the generator's
// `OutputMockType`. Writers iterate over `GeneratorTarget.mockOutputs` to
// emit one file per entry (e.g. `<file>.msw.ts` and `<file>.faker.ts`).
export type StrictMockSchemaKind = 'object' | 'alias' | 'binary';

export interface GeneratorMockOutput {
type: OutputMockType;
implementation: string;
imports: GeneratorImport[];
strictMockSchemaTypeNames?: string[];
strictMockSchemaKinds?: Record<string, StrictMockSchemaKind>;
}

export interface GeneratorMockOutputFull {
Expand All @@ -1365,6 +1368,7 @@ export interface GeneratorMockOutputFull {
};
imports: GeneratorImport[];
strictMockSchemaTypeNames?: string[];
strictMockSchemaKinds?: Record<string, StrictMockSchemaKind>;
}

export interface GeneratorTarget {
Expand Down Expand Up @@ -1533,6 +1537,7 @@ export interface ClientMockGeneratorBuilder {
imports: GeneratorImport[];
implementation: ClientMockGeneratorImplementation;
strictMockSchemaTypeNames?: string[];
strictMockSchemaKinds?: Record<string, StrictMockSchemaKind>;
}

export type ClientMockBuilder = (
Expand Down Expand Up @@ -1698,6 +1703,7 @@ export type ResReqTypesValue = ScalarValue & {
export interface FinalizeMockImplementationOptions {
mockOptions?: Pick<MockOptions, 'required' | 'nonNullable'>;
strictSchemaTypeNames?: readonly string[];
strictMockSchemaKinds?: Readonly<Record<string, StrictMockSchemaKind>>;
}

export interface WriteSpecBuilder {
Expand Down
40 changes: 39 additions & 1 deletion packages/core/src/writers/finalize-mock-implementation.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type {
FinalizeMockImplementationOptions,
GeneratorImport,
GeneratorMockOutput,
NormalizedOutputOptions,
StrictMockSchemaKind,
} from '../types';

type MockOutputWithStrictNames = Pick<
GeneratorMockOutput,
'strictMockSchemaTypeNames'
'strictMockSchemaTypeNames' | 'strictMockSchemaKinds'
>;

export function getFinalizeMockImplementationOptions(
Expand All @@ -25,10 +27,46 @@ export function getFinalizeMockImplementationOptions(
),
),
];
const strictMockSchemaKinds = outputs.reduce<
Record<string, StrictMockSchemaKind>
>((acc, mockOutput) => {
if (!mockOutput.strictMockSchemaKinds) {
return acc;
}
for (const [name, kind] of Object.entries(
mockOutput.strictMockSchemaKinds,
)) {
acc[name] ??= kind;
}
return acc;
}, {});

return {
mockOptions: output.override.mock,
strictSchemaTypeNames:
strictSchemaTypeNames.length > 0 ? strictSchemaTypeNames : undefined,
strictMockSchemaKinds:
Object.keys(strictMockSchemaKinds).length > 0
? strictMockSchemaKinds
: undefined,
};
}

/** Drop schema-factory `{Schema}Mock` type imports that are declared locally. */
export function filterLocalStrictMockTypeImports(
imports: readonly GeneratorImport[],
strictSchemaTypeNames?: readonly string[],
): GeneratorImport[] {
if (!strictSchemaTypeNames?.length) {
return [...imports];
}

const localMockTypeNames = new Set(
strictSchemaTypeNames.map((name) => `${name}Mock`),
);

return imports.filter(
(imp) =>
!(imp.schemaFactory && !imp.values && localMockTypeNames.has(imp.name)),
);
}
83 changes: 83 additions & 0 deletions packages/core/src/writers/mock-imports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest';

import {
buildKnownSchemaFactoryImportSets,
collectRecoveredSchemaFactoryImports,
collectSchemaFactoryImportsFromImplementation,
mergeGeneratorImports,
} from './mock-imports';

describe('buildKnownSchemaFactoryImportSets', () => {
it('maps schema names to consolidated faker symbols', () => {
const known = buildKnownSchemaFactoryImportSets([
'Pet',
'PetDetailResponse',
]);

expect(known.factoryNames.has('getPetMock')).toBe(true);
expect(known.typeNames.has('PetMock')).toBe(true);
expect(known.factoryNames.has('getPetDetailResponseMock')).toBe(true);
});
});

describe('collectSchemaFactoryImportsFromImplementation', () => {
it('collects factory and mock type imports from strict delegation casts', () => {
const implementation = `export const getFooResponseMock = (): FooMock => ({ bar: { ...getBarMock() as BarMock } });`;

expect(
collectSchemaFactoryImportsFromImplementation(implementation),
).toEqual([
{ name: 'getBarMock', values: true, schemaFactory: true },
{ name: 'BarMock', values: false, schemaFactory: true },
]);
});

it('ignores split response helpers when filtered by known component schemas (#3590)', () => {
const implementation = [
'export const getStorePetResponseMock = (): PetDetailResponseMock => ({',
' pet: { ...getPetMock() as PetMock },',
' variant: getStorePetResponsePetDetailResponseItemMock(),',
'});',
].join('\n');

const known = buildKnownSchemaFactoryImportSets([
'Pet',
'PetDetailResponse',
]);

expect(
collectSchemaFactoryImportsFromImplementation(implementation, known),
).toEqual([
{ name: 'getPetMock', values: true, schemaFactory: true },
{ name: 'PetMock', values: false, schemaFactory: true },
]);
});
});

describe('collectRecoveredSchemaFactoryImports', () => {
it('uses component schema names to recover consolidated faker imports', () => {
const implementation = [
'export const getStorePetResponseMock = (): PetDetailResponseMock => ({',
' pet: { ...getPetMock() as PetMock },',
'});',
].join('\n');

expect(
collectRecoveredSchemaFactoryImports(implementation, ['Pet']),
).toEqual([
{ name: 'getPetMock', values: true, schemaFactory: true },
{ name: 'PetMock', values: false, schemaFactory: true },
]);
});
});

describe('mergeGeneratorImports', () => {
it('prefers value imports over type-only duplicates', () => {
expect(
mergeGeneratorImports(
[{ name: 'PetMock', values: false, schemaFactory: true }],
[{ name: 'PetMock', values: true, schemaFactory: true }],
),
).toEqual([{ name: 'PetMock', values: true, schemaFactory: true }]);
});
});
106 changes: 106 additions & 0 deletions packages/core/src/writers/mock-imports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { GeneratorImport } from '../types';
import { pascal } from '../utils';

export interface KnownSchemaFactoryImportSets {
factoryNames: ReadonlySet<string>;
typeNames: ReadonlySet<string>;
}

/** Maps `components/schemas` names to consolidated index.faker import symbols. */
export function buildKnownSchemaFactoryImportSets(
schemaNames: readonly string[],
): KnownSchemaFactoryImportSets {
const factoryNames = new Set<string>();
const typeNames = new Set<string>();

for (const name of schemaNames) {
const typeName = pascal(name);
factoryNames.add(`get${typeName}Mock`);
typeNames.add(`${typeName}Mock`);
}

return { factoryNames, typeNames };
}

/**
* Recover schema-factory imports referenced in generated mock bodies but
* missing from the collected import list (e.g. after shared-array import
* aggregation on large specs). Scans for `get<Schema>Mock()` calls and
* `as <Schema>Mock` casts emitted by strict schema delegation (#3590).
*
* When `knownSets` is provided, only symbols that exist in the consolidated
* schemas faker file are recovered — this avoids importing one-off split
* response helper factories that live in the tag file itself.
*/
export function collectSchemaFactoryImportsFromImplementation(
implementation: string,
knownSets?: KnownSchemaFactoryImportSets,
): GeneratorImport[] {
const imports: GeneratorImport[] = [];
const seen = new Set<string>();

for (const match of implementation.matchAll(/\b(get[A-Za-z0-9]+Mock)\(\)/g)) {
const factoryName = match[1];
if (knownSets && !knownSets.factoryNames.has(factoryName)) {
continue;
}
const key = `value::${factoryName}`;
if (seen.has(key)) continue;
seen.add(key);
imports.push({
name: factoryName,
values: true,
schemaFactory: true,
});
}

for (const match of implementation.matchAll(/\bas ([A-Za-z0-9]+Mock)\b/g)) {
const typeName = match[1];
if (knownSets && !knownSets.typeNames.has(typeName)) {
continue;
}
const key = `type::${typeName}`;
if (seen.has(key)) continue;
seen.add(key);
imports.push({
name: typeName,
values: false,
schemaFactory: true,
});
}

return imports;
}

export function mergeGeneratorImports(
...groups: readonly (readonly GeneratorImport[])[]
): GeneratorImport[] {
const merged = new Map<string, GeneratorImport>();

for (const group of groups) {
for (const imp of group) {
const key = `${imp.name}::${imp.alias ?? ''}`;
const existing = merged.get(key);
if (!existing) {
merged.set(key, imp);
continue;
}
if (!existing.values && imp.values) {
merged.set(key, imp);
}
}
}

return [...merged.values()];
}

/** Recover missing index.faker imports when `schemas: true` is enabled. */
export function collectRecoveredSchemaFactoryImports(
implementation: string,
componentSchemaNames: readonly string[],
): GeneratorImport[] {
return collectSchemaFactoryImportsFromImplementation(
implementation,
buildKnownSchemaFactoryImportSets(componentSchemaNames),
);
}
26 changes: 18 additions & 8 deletions packages/core/src/writers/single-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ import {
import { getMockFileExtensionByTypeName } from '../utils/file-extensions';
import { escapeRegExp } from '../utils/string';
import { writeGeneratedFile } from './file';
import { getFinalizeMockImplementationOptions } from './finalize-mock-implementation';
import {
getFinalizeMockImplementationOptions,
filterLocalStrictMockTypeImports,
} from './finalize-mock-implementation';
import { generateImportsForBuilder } from './generate-imports-for-builder';
import { collapseInlineMockOutputs } from './mock-outputs';
import {
Expand Down Expand Up @@ -168,13 +171,20 @@ export async function writeSingleMode({
const entry = output.mock.generators.find(
(g) => !isFunction(g) && g.type === mockOutput.type,
);
const filteredMockImports = mockOutput.imports.filter(
(impMock) =>
!normalizedImports.some(
(imp) =>
imp.name === impMock.name &&
(imp.alias ?? '') === (impMock.alias ?? ''),
),
const finalizeMockOptions = getFinalizeMockImplementationOptions(
output,
mockOutput,
);
const filteredMockImports = filterLocalStrictMockTypeImports(
mockOutput.imports.filter(
(impMock) =>
!normalizedImports.some(
(imp) =>
imp.name === impMock.name &&
(imp.alias ?? '') === (impMock.alias ?? ''),
),
),
finalizeMockOptions.strictSchemaTypeNames,
);
const importsMockForBuilder = schemasPath
? generateImportsForBuilder(
Expand Down
43 changes: 37 additions & 6 deletions packages/core/src/writers/split-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,15 @@ import {
} from '../utils';
import { getMockFileExtensionByTypeName } from '../utils/file-extensions';
import { writeGeneratedFile } from './file';
import { getFinalizeMockImplementationOptions } from './finalize-mock-implementation';
import {
filterLocalStrictMockTypeImports,
getFinalizeMockImplementationOptions,
} from './finalize-mock-implementation';
import { generateImportsForBuilder } from './generate-imports-for-builder';
import {
collectRecoveredSchemaFactoryImports,
mergeGeneratorImports,
} from './mock-imports';
import { getMockDir, resolveMockSchemasPath } from './mock-utils';
import { generateTarget } from './target';
import { getOrvalGeneratedTypes, getTypedResponse } from './types';
Expand Down Expand Up @@ -211,18 +218,42 @@ export async function writeSplitMode({
schemaCustomImportPath ??
resolveMockSchemasPath(mockFilePath, schemasTarget);

const importsMockForBuilder = generateImportsForBuilder(
const finalizeMockOptions = getFinalizeMockImplementationOptions(
output,
mockOutput.imports,
mockRelativeSchemasPath,
mockOutput,
);
let mockData = header;

const finalizedMockImplementation = builder.finalizeMockImplementation
? builder.finalizeMockImplementation(
mockOutput.implementation,
getFinalizeMockImplementationOptions(output, mockOutput),
finalizeMockOptions,
)
: mockOutput.implementation;

const usesSchemaFactories =
!isFunction(rawEntry) &&
rawEntry.type === OutputMockType.FAKER &&
rawEntry.schemas === true;
const recoveredSchemaFactoryImports =
usesSchemaFactories && output.schemas
? collectRecoveredSchemaFactoryImports(
finalizedMockImplementation,
builder.schemas.filter((s) => s.schema).map((s) => s.name),
)
: [];

const importsMockForBuilder = generateImportsForBuilder(
output,
filterLocalStrictMockTypeImports(
mergeGeneratorImports(
mockOutput.imports,
recoveredSchemaFactoryImports,
),
finalizeMockOptions.strictSchemaTypeNames,
),
mockRelativeSchemasPath,
);
let mockData = header;
mockData += builder.importsMock({
implementation: finalizedMockImplementation,
imports: importsMockForBuilder,
Expand Down
Loading