From af9f905f341f96f2041fbad53da78e6ada202451 Mon Sep 17 00:00:00 2001 From: Stephen Crosby Date: Sun, 17 May 2026 20:51:29 -0700 Subject: [PATCH 1/3] Operation response factories When `documents` is set in codegen config, emit one factory per named operation, shaped to that operation's selection-set type. Useful with msw and other tooling that needs operation-shaped data. ```ts import { aGetUserQueryResponse } from './mocks.generated'; const data = aGetUserQueryResponse({ user: { name: 'Alice' }, }); ``` Leaf values flow through the existing mockValueGenerator, so scalar config, locale, and seed match the schema-typed factories. Aliases, __typename, nested objects, lists, and named fragment spreads are walked from the operation document. Union and interface fields with __typename selected and two or more inline-fragment branches generate per-branch closures plus a dispatcher. The override slot accepts an object override (applied to the alphabetically-first branch) or a branch-keyed callback: ```ts aGetNodeQueryResponse({ node: ({ User }) => User({ name: 'Alice' }), }); ``` Lists of a union/interface follow the same shape: ```ts aSearchQueryResponse({ search: ({ User, Document }) => [ User({ name: 'Alice' }), Document({ title: 'Q3 Report' }), ], }); ``` Activated by `generateOperationFactories: true` (default) when documents are present and `typesFile` is set. Set the option to false to disable. --- README.md | 139 +++++ src/index.ts | 36 +- src/operationFactories.ts | 497 ++++++++++++++++++ src/operationRuntime.ts | 109 ++++ .../__snapshots__/snapshot.spec.ts.snap | 249 +++++++++ tests/operationFactories/activation.spec.ts | 28 + .../aliasesAndTypename.spec.ts | 32 ++ .../callbackBranches.spec.ts | 81 +++ tests/operationFactories/collection.spec.ts | 22 + tests/operationFactories/fixtures.ts | 62 +++ tests/operationFactories/fragments.spec.ts | 47 ++ tests/operationFactories/leafValues.spec.ts | 25 + tests/operationFactories/lists.spec.ts | 32 ++ tests/operationFactories/nested.spec.ts | 19 + tests/operationFactories/runtime.spec.ts | 19 + tests/operationFactories/simple.spec.ts | 48 ++ tests/operationFactories/snapshot.spec.ts | 21 + tests/operationFactories/unions.spec.ts | 79 +++ 18 files changed, 1544 insertions(+), 1 deletion(-) create mode 100644 src/operationFactories.ts create mode 100644 src/operationRuntime.ts create mode 100644 tests/operationFactories/__snapshots__/snapshot.spec.ts.snap create mode 100644 tests/operationFactories/activation.spec.ts create mode 100644 tests/operationFactories/aliasesAndTypename.spec.ts create mode 100644 tests/operationFactories/callbackBranches.spec.ts create mode 100644 tests/operationFactories/collection.spec.ts create mode 100644 tests/operationFactories/fixtures.ts create mode 100644 tests/operationFactories/fragments.spec.ts create mode 100644 tests/operationFactories/leafValues.spec.ts create mode 100644 tests/operationFactories/lists.spec.ts create mode 100644 tests/operationFactories/nested.spec.ts create mode 100644 tests/operationFactories/runtime.spec.ts create mode 100644 tests/operationFactories/simple.spec.ts create mode 100644 tests/operationFactories/snapshot.spec.ts create mode 100644 tests/operationFactories/unions.spec.ts diff --git a/README.md b/README.md index 7bb3134..b49304e 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,145 @@ Adds `__typename` property to mock data Changes enums to TypeScript string union types +### generateOperationFactories (`boolean`, default: `true`) + +When the codegen `documents` config is non-empty, the plugin emits one factory per named operation, shaped to that operation's selection-set type. This is useful with [msw](https://mswjs.io/) or any tooling that needs operation-shaped data without hand-rolling the response literal. + +Requires `typesFile` to be set. Set `generateOperationFactories: false` to disable the feature. + +Assume the schema and operations below for the rest of this section: + +```graphql +interface Node { + id: ID! +} + +type User implements Node { + id: ID! + name: String! + friends: [User!]! +} + +type Document implements Node { + id: ID! + title: String! +} + +union SearchHit = User | Document + +type Query { + user(id: ID!): User + node(id: ID!): Node + search(q: String!): [SearchHit!]! +} +``` + +```graphql +query GetUser($id: ID!) { + user(id: $id) { + id + name + friends { + id + name + } + } +} + +query GetNode($id: ID!) { + node(id: $id) { + __typename + ... on User { + id + name + } + ... on Document { + id + title + } + } +} + +query Search($q: String!) { + search(q: $q) { + __typename + ... on User { + id + name + } + ... on Document { + id + title + } + } +} +``` + +#### Basic usage + +The factory returns a fully-populated response shaped to the operation's selection set. Pass a partial override to customize specific fields: + +```ts +import { aGetUserQueryResponse } from './mocks.generated'; + +const response = aGetUserQueryResponse({ + user: { name: 'Alice' }, +}); +// response.user.id is auto-generated; response.user.name is 'Alice' +// response.user.friends is populated with default-shaped User entries +``` + +#### Lists + +Arrays of object/scalar elements take either a literal array or a `(make) => Element[]` callback. The callback's `make` argument produces one default-populated element per call, and accepts a partial override: + +```ts +aGetUserQueryResponse({ + user: { + friends: (make) => [ + make(), // default User + make({ name: 'Bob' }), // overridden name + make({ id: 'pinned-friend' }), + ], + }, +}); +``` + +A literal array replaces the defaults entirely. Use this form when you already have fully-built objects. + +#### Union and interface fields + +When an operation's union/interface field selects `__typename` and two or more concrete branches via inline fragments, the override slot accepts a branch-keyed callback. Each key is a concrete type name; each value is a factory for that branch's operation-shaped subset: + +```ts +import { aGetNodeQueryResponse } from './mocks.generated'; + +// Default branch: alphabetically-first concrete type (Document here). +aGetNodeQueryResponse(); + +// Object override targets the default branch. +aGetNodeQueryResponse({ + node: { title: 'Quarterly Report' }, +}); + +// Branch callback picks a non-default branch with full type inference. +aGetNodeQueryResponse({ + node: ({ User }) => User({ name: 'Alice' }), +}); +``` + +For lists of a union/interface, the array slot likewise accepts a branch-keyed callback. Build the array directly by calling the per-branch factories: + +```ts +import { aSearchQueryResponse } from './mocks.generated'; + +aSearchQueryResponse({ + search: ({ User, Document }) => [User({ name: 'Alice' }), Document({ title: 'Q3 Report' }), User()], +}); +``` + +If the operation does **not** select `__typename`, branch dispatch isn't generated for that field. The override slot falls back to a partial of the alphabetically-first branch. Add `__typename` to the operation if you want callback-style branch overrides. + ### includedTypes (`string[]`, defaultValue: `undefined`) Specifies an array of types to **include** in the mock generation. When provided, only the types listed in this array will have mock data generated. diff --git a/src/index.ts b/src/index.ts index 7503e64..4bd97ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { sentenceCase } from 'sentence-case'; import a from 'indefinite'; import { printSchemaWithDirectives } from '@graphql-tools/utils'; import { setupFunctionTokens, setupMockValueGenerator } from './mockValueGenerator'; +import { buildOperationFactories } from './operationFactories'; type NamingConvention = 'change-case-all#pascalCase' | 'keep' | string; @@ -446,7 +447,7 @@ const getNamedType = (opts: Options): } }; -const generateMockValue = (opts: Options): string | number | boolean => { +export const generateMockValue = (opts: Options): string | number | boolean => { switch (opts.currentType.kind) { case 'NamedType': return getNamedType({ @@ -542,6 +543,7 @@ const getImportTypes = ({ enumsAsTypes, useTypeImports, typeNamesMapping, + extraTypeImports, }: { typeNamesConvention: NamingConvention; definitions: any; @@ -553,6 +555,7 @@ const getImportTypes = ({ enumsAsTypes: boolean; useTypeImports: boolean; typeNamesMapping?: Record; + extraTypeImports?: string[]; }) => { const typenameConverter = createNameConverter(typeNamesConvention, transformUnderscore); const typeImports = typesPrefix?.endsWith('.') @@ -571,6 +574,10 @@ const getImportTypes = ({ renamedTypeImports.push(...enumTypes); } + if (extraTypeImports && extraTypeImports.length > 0) { + renamedTypeImports.push(...extraTypeImports); + } + function onlyUnique(value, index, self) { return self.indexOf(value) === index; } @@ -635,6 +642,7 @@ export interface TypescriptMocksPluginConfig { typeNamesMapping?: Record; includedTypes?: string[]; excludedTypes?: string[]; + generateOperationFactories?: boolean; } interface TypeItem { @@ -912,6 +920,29 @@ export const plugin: PluginFunction = (schema, docu ); const typesFile = config.typesFile ? config.typesFile.replace(/\.[\w]+$/, '') : null; + // Generate operation factories first to collect operation type imports + let operationOutput = ''; + let operationTypeImports: string[] = []; + const generateOps = config.generateOperationFactories !== false && documents && documents.length > 0; + if (generateOps) { + if (!config.typesFile) { + throw new Error( + 'Plugin "typescript-mock-data" requires `typesFile` to be set when generating operation factories. ' + + 'Either set `typesFile: ` or `generateOperationFactories: false`.', + ); + } + const { output, operationTypeImports: opTypeImports } = buildOperationFactories({ + schema, + documents, + typesFile: config.typesFile, + listElementCount, + prefix: config.prefix, + sharedGenerateMockOpts, + }); + operationOutput = output; + operationTypeImports = opTypeImports; + } + const typesFileImport = getImportTypes({ typeNamesConvention, definitions, @@ -923,6 +954,7 @@ export const plugin: PluginFunction = (schema, docu useTypeImports: config.useTypeImports, enumsAsTypes, typeNamesMapping, + extraTypeImports: operationTypeImports, }); // Function that will generate the mocks. // We generate it after having visited because we need to distinct types from enums @@ -940,5 +972,7 @@ export const plugin: PluginFunction = (schema, docu mockFile += mockFns; if (dynamicValues) mockFile += `\n\n${functionTokens.seedFunction}`; mockFile += '\n'; + mockFile += operationOutput; + return mockFile; }; diff --git a/src/operationFactories.ts b/src/operationFactories.ts new file mode 100644 index 0000000..d765ac1 --- /dev/null +++ b/src/operationFactories.ts @@ -0,0 +1,497 @@ +// src/operationFactories.ts +import { + GraphQLSchema, + OperationDefinitionNode, + Kind, + SelectionSetNode, + GraphQLObjectType, + GraphQLOutputType, + GraphQLNamedType, + isScalarType, + isEnumType, + getNamedType, + FieldNode, + isListType, + isNonNullType, + isObjectType, + isInterfaceType, + isUnionType, + TypeNode, + NamedTypeNode, + ListTypeNode, + FragmentDefinitionNode, +} from 'graphql'; +import { Types } from '@graphql-codegen/plugin-helpers'; +import { pascalCase } from 'change-case-all'; +import { sentenceCase } from 'sentence-case'; +import a from 'indefinite'; +import { RUNTIME_HELPERS } from './operationRuntime'; +import { generateMockValue } from './index'; + +export interface BuildOperationFactoriesArgs { + schema: GraphQLSchema; + documents: Types.DocumentFile[]; + typesFile: string; + listElementCount: number; + prefix: string | undefined; + sharedGenerateMockOpts: any; +} + +export interface BuildOperationFactoriesResult { + output: string; + operationTypeImports: string[]; +} + +const operationLocation = (op: OperationDefinitionNode): string => { + const src = op.loc?.source.name ?? ''; + const line = op.loc?.startToken.line ?? '?'; + return `${src}:${line}`; +}; + +const operationTypeSuffix = (op: OperationDefinitionNode): string => { + switch (op.operation) { + case 'query': + return 'Query'; + case 'mutation': + return 'Mutation'; + case 'subscription': + return 'Subscription'; + default: + throw new Error(`Unknown operation type: ${op.operation}`); + } +}; + +const operationTypeName = (op: OperationDefinitionNode): string => + `${pascalCase(op.name!.value)}${operationTypeSuffix(op)}`; + +const factoryName = (op: OperationDefinitionNode, prefix: string | undefined): string => { + const tn = operationTypeName(op); + const article = prefix !== undefined ? prefix : a(sentenceCase(tn).split(' ')[0], { articleOnly: true }); + return `${article}${tn}Response`; +}; + +const collectOperations = (documents: Types.DocumentFile[]): OperationDefinitionNode[] => { + const seen = new Map(); + const ops: OperationDefinitionNode[] = []; + for (const file of documents) { + for (const def of file.document.definitions) { + if (def.kind !== Kind.OPERATION_DEFINITION) continue; + if (!def.name) continue; + const name = def.name.value; + const prior = seen.get(name); + if (prior) { + throw new Error( + `Plugin "typescript-mock-data" found two operations named "${name}":\n` + + ` - ${operationLocation(prior)}\n` + + ` - ${operationLocation(def)}\n` + + `Each named operation must have a unique name.`, + ); + } + seen.set(name, def); + ops.push(def); + } + } + return ops; +}; + +const collectFragments = (documents: Types.DocumentFile[]): Map => { + const map = new Map(); + for (const file of documents) { + for (const def of file.document.definitions) { + if (def.kind === Kind.FRAGMENT_DEFINITION) { + map.set(def.name.value, def); + } + } + } + return map; +}; + +type LeafGenerator = (typeName: string, fieldName: string, gqlType: GraphQLOutputType) => string; + +interface WalkContext { + schema: GraphQLSchema; + listElementCount: number; + closures: string[]; + closureIdSeq: { n: number }; + generateLeaf: LeafGenerator; + fragments: Map; +} + +const unwrap = (t: GraphQLOutputType): GraphQLNamedType => getNamedType(t) as GraphQLNamedType; + +const pickBranch = ( + schema: GraphQLSchema, + parentType: GraphQLNamedType, + selectionSet: SelectionSetNode, +): GraphQLObjectType | null => { + if (isObjectType(parentType)) return parentType; + + const candidateNames = new Set(); + for (const sel of selectionSet.selections) { + if (sel.kind === Kind.INLINE_FRAGMENT && sel.typeCondition) { + candidateNames.add(sel.typeCondition.name.value); + } + } + + if (candidateNames.size === 0) { + // No inline fragments. Pick from possible types. + if (isUnionType(parentType)) { + const types = parentType + .getTypes() + .slice() + .sort((a, b) => a.name.localeCompare(b.name)); + return types[0] ?? null; + } + if (isInterfaceType(parentType)) { + const types = schema + .getImplementations(parentType) + .objects.slice() + .sort((a, b) => a.name.localeCompare(b.name)); + return types[0] ?? null; + } + return null; + } + + const sorted = Array.from(candidateNames).sort((a, b) => a.localeCompare(b)); + const chosen = schema.getType(sorted[0]); + if (isObjectType(chosen)) return chosen; + if (isInterfaceType(chosen)) { + const types = schema + .getImplementations(chosen) + .objects.slice() + .sort((a, b) => a.name.localeCompare(b.name)); + return types[0] ?? null; + } + if (isUnionType(chosen)) { + const types = chosen + .getTypes() + .slice() + .sort((a, b) => a.name.localeCompare(b.name)); + return types[0] ?? null; + } + return null; +}; + +const toTypeNode = (t: GraphQLOutputType): TypeNode => { + if (isNonNullType(t)) { + return { kind: Kind.NON_NULL_TYPE, type: toTypeNode(t.ofType) as NamedTypeNode | ListTypeNode }; + } + if (isListType(t)) { + return { kind: Kind.LIST_TYPE, type: toTypeNode(t.ofType) }; + } + return { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: (t as any).name }, + }; +}; + +const nextClosureName = (ctx: WalkContext, hint: string): string => { + const id = ++ctx.closureIdSeq.n; + return `make${pascalCase(hint)}_${id}`; +}; + +const selectsTypename = ( + selectionSet: SelectionSetNode, + fragments: Map, + seen: Set = new Set(), +): boolean => { + for (const sel of selectionSet.selections) { + if (sel.kind === Kind.FIELD && sel.name.value === '__typename') return true; + if (sel.kind === Kind.INLINE_FRAGMENT && selectsTypename(sel.selectionSet, fragments, seen)) return true; + if (sel.kind === Kind.FRAGMENT_SPREAD) { + if (seen.has(sel.name.value)) continue; + const frag = fragments.get(sel.name.value); + if (!frag) continue; + seen.add(sel.name.value); + if (selectsTypename(frag.selectionSet, fragments, seen)) return true; + } + } + return false; +}; + +const collectInlineFragmentTypeNames = ( + schema: GraphQLSchema, + selectionSet: SelectionSetNode, + fragments: Map, + names: Set, + seen: Set, +): void => { + for (const sel of selectionSet.selections) { + if (sel.kind === Kind.INLINE_FRAGMENT && sel.typeCondition) { + const t = schema.getType(sel.typeCondition.name.value); + if (isObjectType(t)) { + names.add(t.name); + } else if (isInterfaceType(t)) { + for (const impl of schema.getImplementations(t).objects) names.add(impl.name); + } else if (isUnionType(t)) { + for (const member of t.getTypes()) names.add(member.name); + } + } else if (sel.kind === Kind.FRAGMENT_SPREAD) { + if (seen.has(sel.name.value)) continue; + const frag = fragments.get(sel.name.value); + if (!frag) continue; + seen.add(sel.name.value); + collectInlineFragmentTypeNames(schema, frag.selectionSet, fragments, names, seen); + } + } +}; + +const selectedBranches = ( + schema: GraphQLSchema, + parentType: GraphQLNamedType, + selectionSet: SelectionSetNode, + fragments: Map, +): GraphQLObjectType[] => { + const names = new Set(); + collectInlineFragmentTypeNames(schema, selectionSet, fragments, names, new Set()); + return Array.from(names) + .sort((a, b) => a.localeCompare(b)) + .map((n) => schema.getType(n)) + .filter(isObjectType) as GraphQLObjectType[]; +}; + +const emitCallbackDispatch = ( + selectionSet: SelectionSetNode, + parentType: GraphQLNamedType, + ctx: WalkContext, + overrideAccess: string, +): string => { + const branches = selectedBranches(ctx.schema, parentType, selectionSet, ctx.fragments); + const branchEntries: string[] = []; + for (const branch of branches) { + const literal = walkSelectionSet(selectionSet, branch, ctx, '_o'); + const closureName = nextClosureName(ctx, branch.name); + ctx.closures.push(` + const ${closureName} = (_o?: any): any => { + const _defaults = ${literal}; + return mergeOverrides(_defaults, _o); + };`); + branchEntries.push(`'${branch.name}': ${closureName}`); + } + return `pickByCallback({ ${branchEntries.join(', ')} }, '${branches[0].name}', ${overrideAccess})`; +}; + +const walkField = ( + field: FieldNode, + parentType: GraphQLObjectType, + ctx: WalkContext, + overrideAccess: string, +): string => { + const fieldName = field.name.value; + const aliasOrName = field.alias?.value ?? fieldName; + if (fieldName === '__typename') { + return `${aliasOrName}: '${parentType.name}' as const`; + } + const fieldDef = parentType.getFields()[fieldName]; + if (!fieldDef) return `${aliasOrName}: null`; + + const childOverride = `${overrideAccess}?.${aliasOrName}`; + + let t = fieldDef.type; + if (isNonNullType(t)) t = t.ofType; + const fieldIsList = isListType(t); + + if (fieldIsList) { + let inner = isListType(t) ? t.ofType : t; + if (isNonNullType(inner)) inner = inner.ofType; + const innerNamed = getNamedType(inner); + const makeName = nextClosureName(ctx, aliasOrName); + + const isAbstract = isInterfaceType(innerNamed) || isUnionType(innerNamed); + if (isAbstract && field.selectionSet && selectsTypename(field.selectionSet, ctx.fragments)) { + const branches = selectedBranches(ctx.schema, innerNamed, field.selectionSet, ctx.fragments); + if (branches.length >= 2) { + const branchEntries: string[] = []; + for (const branch of branches) { + const literal = walkSelectionSet(field.selectionSet, branch, ctx, '_o'); + const closureName = nextClosureName(ctx, branch.name); + ctx.closures.push(` + const ${closureName} = (_o?: any): any => { + const _defaults = ${literal}; + return mergeOverrides(_defaults, _o); + };`); + branchEntries.push(`'${branch.name}': ${closureName}`); + } + return `${aliasOrName}: applyBranchedArrayOverride({ ${branchEntries.join(', ')} }, '${ + branches[0].name + }', ${childOverride}, ${ctx.listElementCount})`; + } + } + + let elemBody: string; + if (isScalarType(innerNamed) || isEnumType(innerNamed)) { + elemBody = ctx.generateLeaf(parentType.name, field.name.value, inner); + } else if (isObjectType(innerNamed) && field.selectionSet) { + elemBody = walkSelectionSet(field.selectionSet, innerNamed, ctx, '_o'); + } else if (isAbstract && field.selectionSet) { + elemBody = walkSelectionSet(field.selectionSet, innerNamed, ctx, '_o'); + } else { + elemBody = 'null'; + } + + ctx.closures.push(` + const ${makeName} = (_o?: any): any => { + const _defaults = ${elemBody}; + return mergeOverrides(_defaults, _o); + };`); + + return `${aliasOrName}: applyArrayOverride(${makeName}, ${childOverride}, ${ctx.listElementCount})`; + } + + const named = unwrap(fieldDef.type); + if (isScalarType(named) || isEnumType(named)) { + return `${aliasOrName}: ${ctx.generateLeaf(parentType.name, fieldName, fieldDef.type)}`; + } + if (isObjectType(named) && field.selectionSet) { + return `${aliasOrName}: ${walkSelectionSet(field.selectionSet, named, ctx, childOverride)}`; + } + if ((isInterfaceType(named) || isUnionType(named)) && field.selectionSet) { + if (selectsTypename(field.selectionSet, ctx.fragments)) { + const branches = selectedBranches(ctx.schema, named, field.selectionSet, ctx.fragments); + if (branches.length >= 2) { + return `${aliasOrName}: ${emitCallbackDispatch(field.selectionSet, named, ctx, childOverride)}`; + } + } + // Cast the override to any when descending into a single concrete branch of an + // interface/union. typescript-operations emits the full implementer list as the + // static type, but we only walk one branch, so descendants need to reach + // branch-specific fields without TS narrowing. + return `${aliasOrName}: ${walkSelectionSet(field.selectionSet, named, ctx, `(${childOverride} as any)`)}`; + } + return `${aliasOrName}: null`; +}; + +const walkSelectionSet = ( + selectionSet: SelectionSetNode, + parentType: GraphQLNamedType, + ctx: WalkContext, + overrideAccess: string, +): string => { + let concrete: GraphQLObjectType; + if (isObjectType(parentType)) { + concrete = parentType; + } else { + const picked = pickBranch(ctx.schema, parentType, selectionSet); + if (!picked) return '{}'; + concrete = picked; + } + + const entries: string[] = []; + const seenFragments = new Set(); + const seenFields = new Set(); + collectFieldSelections(selectionSet, concrete, ctx, overrideAccess, entries, seenFragments, seenFields); + return `{\n ${entries.join(',\n ')},\n }`; +}; + +const collectFieldSelections = ( + selectionSet: SelectionSetNode, + concrete: GraphQLObjectType, + ctx: WalkContext, + overrideAccess: string, + entries: string[], + seenFragments: Set, + seenFields: Set, +): void => { + for (const sel of selectionSet.selections) { + if (sel.kind === Kind.FIELD) { + const key = sel.alias?.value ?? sel.name.value; + if (seenFields.has(key)) continue; + seenFields.add(key); + entries.push(walkField(sel, concrete, ctx, overrideAccess)); + } else if (sel.kind === Kind.INLINE_FRAGMENT) { + const cond = sel.typeCondition?.name.value; + if (cond && !typeConditionMatches(ctx.schema, concrete, cond)) continue; + collectFieldSelections(sel.selectionSet, concrete, ctx, overrideAccess, entries, seenFragments, seenFields); + } else if (sel.kind === Kind.FRAGMENT_SPREAD) { + if (seenFragments.has(sel.name.value)) continue; + const frag = ctx.fragments.get(sel.name.value); + if (!frag) continue; + const fragCond = frag.typeCondition.name.value; + if (!typeConditionMatches(ctx.schema, concrete, fragCond)) continue; + seenFragments.add(sel.name.value); + collectFieldSelections( + frag.selectionSet, + concrete, + ctx, + overrideAccess, + entries, + seenFragments, + seenFields, + ); + } + } +}; + +const typeConditionMatches = (schema: GraphQLSchema, concrete: GraphQLObjectType, conditionName: string): boolean => { + if (concrete.name === conditionName) return true; + const conditionType = schema.getType(conditionName); + if (!conditionType) return false; + if (isInterfaceType(conditionType)) { + return concrete.getInterfaces().some((i) => i.name === conditionType.name); + } + if (isUnionType(conditionType)) { + return conditionType.getTypes().some((t) => t.name === concrete.name); + } + return false; +}; + +const buildFactory = ( + op: OperationDefinitionNode, + schema: GraphQLSchema, + listElementCount: number, + prefix: string | undefined, + generateLeaf: LeafGenerator, + fragments: Map, +): string => { + const opType = schema.getRootType(op.operation); + if (!opType) return ''; + const typeName = operationTypeName(op); + const fnName = factoryName(op, prefix); + const ctx: WalkContext = { + schema, + listElementCount, + closures: [], + closureIdSeq: { n: 0 }, + generateLeaf, + fragments, + }; + const literal = walkSelectionSet(op.selectionSet, opType, ctx, 'overrides'); + const closures = ctx.closures.join('\n'); + return ` +export const ${fnName} = ( + overrides?: DeepPartial<${typeName}>, +): ${typeName} => {${closures} + const defaults: ${typeName} = ${literal}; + return mergeOverrides(defaults, overrides); +}; +`; +}; + +export const buildOperationFactories = (args: BuildOperationFactoriesArgs): BuildOperationFactoriesResult => { + const ops = collectOperations(args.documents); + if (ops.length === 0) return { output: '', operationTypeImports: [] }; + + const fragments = collectFragments(args.documents); + + const generateLeaf: LeafGenerator = (typeName, fieldName, gqlType) => { + const typeNode = toTypeNode(gqlType); + return String( + generateMockValue({ + typeName, + fieldName, + generatorMode: 'output', + currentType: typeNode, + ...args.sharedGenerateMockOpts, + }), + ); + }; + + const factories = ops + .map((op) => buildFactory(op, args.schema, args.listElementCount, args.prefix, generateLeaf, fragments)) + .join('\n'); + const imports = ops.map((op) => operationTypeName(op)); + return { + output: `\n${RUNTIME_HELPERS}\n${factories}`, + operationTypeImports: imports, + }; +}; diff --git a/src/operationRuntime.ts b/src/operationRuntime.ts new file mode 100644 index 0000000..663eea5 --- /dev/null +++ b/src/operationRuntime.ts @@ -0,0 +1,109 @@ +// src/operationRuntime.ts +export const RUNTIME_HELPERS = ` +type _BranchKeys = T extends { __typename: infer K extends string } ? K : never; +// True when the union has two or more distinct __typename values. Distributive +// instead of recursive so it scales to large interface unions. +type _HasMultipleBranches> = + [K] extends [never] ? false : + K extends K + ? [Exclude<_BranchKeys, K>] extends [never] ? false : true + : false; +type Branches = { + [P in _BranchKeys]: (o?: DeepPartial>) => Extract; +}; + +// Distribute over union members so each branch's keys are preserved. Taking keyof +// on the union directly would only yield the keys common to every branch. +type _PerBranchPartial = T extends object ? { [K in keyof T]?: _Field } : T; + +type _Field = [T] extends [never] + ? T + : _HasMultipleBranches> extends true + ? _PerBranchPartial> | ((b: Branches>) => NonNullable) + : DeepPartial; + +type DeepPartial = [T] extends [never] + ? T + : [T] extends [Array] + ? _HasMultipleBranches> extends true + ? T | ((b: Branches>) => U[]) + : T | ((make: (o?: DeepPartial) => U) => U[]) + : T extends object ? { [K in keyof T]?: _Field } : T; + +// NoInfer-like helper for TS <5.4. Prevents the second parameter of mergeOverrides +// from being used to infer T, so T is anchored to the defaults parameter only. +type _NoInfer = [T][T extends any ? 0 : never]; + +function mergeOverrides(defaults: T, overrides: _NoInfer> | undefined): T { + if (overrides === undefined) return defaults; + if (overrides === null) return overrides as unknown as T; + // Callback overrides are consumed at the callsite that knows how to dispatch them; + // by the time we get here, the dispatched value is already in defaults. Skip. + if (typeof overrides === 'function') return defaults; + // Arrays were already constructed by applyArrayOverride, which consumes function-typed + // and per-element overrides. The outer mergeOverrides shouldn't replace them. + if (Array.isArray(defaults) && Array.isArray(overrides)) { + return defaults; + } + if (Array.isArray(defaults) || Array.isArray(overrides)) { + return overrides as unknown as T; + } + if (typeof defaults !== 'object' || defaults === null) { + return overrides as unknown as T; + } + if (typeof overrides !== 'object') { + return overrides as unknown as T; + } + const out: any = { ...defaults }; + for (const key of Object.keys(overrides)) { + const ov = (overrides as any)[key]; + if (ov === undefined) continue; + out[key] = mergeOverrides((defaults as any)[key], ov); + } + return out; +} + +function applyArrayOverride( + makeDefault: (o?: DeepPartial) => T, + override: T[] | ((m: (o?: DeepPartial) => T) => T[]) | null | undefined, + count: number, +): T[] { + if (override === null || override === undefined) { + const arr: T[] = []; + for (let i = 0; i < count; i++) arr.push(makeDefault()); + return arr; + } + if (typeof override === 'function') { + return (override as (m: (o?: DeepPartial) => T) => T[])(makeDefault); + } + return (override as any[]).map((el) => makeDefault(el)); +} + +function pickByCallback( + branches: Record T>, + defaultBranch: string, + override: T | ((b: Record T>) => T) | undefined | null, +): T { + if (typeof override === 'function') { + return (override as (b: Record T>) => T)(branches); + } + return branches[defaultBranch](override ?? undefined); +} + +function applyBranchedArrayOverride( + branches: Record T>, + defaultBranch: string, + override: T[] | ((b: Record T>) => T[]) | null | undefined, + count: number, +): T[] { + if (override === null || override === undefined) { + const arr: T[] = []; + for (let i = 0; i < count; i++) arr.push(branches[defaultBranch]()); + return arr; + } + if (typeof override === 'function') { + return (override as (b: Record T>) => T[])(branches); + } + return override; +} +`; diff --git a/tests/operationFactories/__snapshots__/snapshot.spec.ts.snap b/tests/operationFactories/__snapshots__/snapshot.spec.ts.snap new file mode 100644 index 0000000..a6b1fe7 --- /dev/null +++ b/tests/operationFactories/__snapshots__/snapshot.spec.ts.snap @@ -0,0 +1,249 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`snapshot matches snapshot for a representative operation set 1`] = ` +"import { Node, User, Avatar, Document, Query, Mutation, Subscription, Status, GetUserQuery, AllUsersQuery, RenameMutation, SearchQuery } from './types'; + +export const aNode = (overrides?: Partial): Node => { + return { + id: overrides && overrides.hasOwnProperty('id') ? overrides.id! : '8b23789f-fc2b-42cb-a56c-665574fdcbfe', + }; +}; + +export const aUser = (overrides?: Partial): User => { + return { + id: overrides && overrides.hasOwnProperty('id') ? overrides.id! : 'b7605a2a-ad1e-4667-801e-5e47e5de933b', + name: overrides && overrides.hasOwnProperty('name') ? overrides.name! : 'supellex', + email: overrides && overrides.hasOwnProperty('email') ? overrides.email! : 'natus', + status: overrides && overrides.hasOwnProperty('status') ? overrides.status! : Status.Active, + avatar: overrides && overrides.hasOwnProperty('avatar') ? overrides.avatar! : anAvatar(), + friends: overrides && overrides.hasOwnProperty('friends') ? overrides.friends! : [aUser(), aUser()], + }; +}; + +export const anAvatar = (overrides?: Partial): Avatar => { + return { + id: overrides && overrides.hasOwnProperty('id') ? overrides.id! : '15f9c394-c8fc-46bc-a882-b3b853f28c03', + url: overrides && overrides.hasOwnProperty('url') ? overrides.url! : 'consectetur', + }; +}; + +export const aDocument = (overrides?: Partial): Document => { + return { + id: overrides && overrides.hasOwnProperty('id') ? overrides.id! : 'ab777987-09a3-448f-a2b6-4c0ecc3b45da', + title: overrides && overrides.hasOwnProperty('title') ? overrides.title! : 'suasoria', + url: overrides && overrides.hasOwnProperty('url') ? overrides.url! : 'tutamen', + }; +}; + +export const aQuery = (overrides?: Partial): Query => { + return { + user: overrides && overrides.hasOwnProperty('user') ? overrides.user! : aUser(), + node: overrides && overrides.hasOwnProperty('node') ? overrides.node! : aNode(), + search: overrides && overrides.hasOwnProperty('search') ? overrides.search! : [aUser(), aUser()], + users: overrides && overrides.hasOwnProperty('users') ? overrides.users! : [aUser(), aUser()], + }; +}; + +export const aMutation = (overrides?: Partial): Mutation => { + return { + renameUser: overrides && overrides.hasOwnProperty('renameUser') ? overrides.renameUser! : aUser(), + }; +}; + +export const aSubscription = (overrides?: Partial): Subscription => { + return { + userUpdated: overrides && overrides.hasOwnProperty('userUpdated') ? overrides.userUpdated! : aUser(), + }; +}; + + +type _BranchKeys = T extends { __typename: infer K extends string } ? K : never; +// True when the union has two or more distinct __typename values. Distributive +// instead of recursive so it scales to large interface unions. +type _HasMultipleBranches> = + [K] extends [never] ? false : + K extends K + ? [Exclude<_BranchKeys, K>] extends [never] ? false : true + : false; +type Branches = { + [P in _BranchKeys]: (o?: DeepPartial>) => Extract; +}; + +// Distribute over union members so each branch's keys are preserved. Taking keyof +// on the union directly would only yield the keys common to every branch. +type _PerBranchPartial = T extends object ? { [K in keyof T]?: _Field } : T; + +type _Field = [T] extends [never] + ? T + : _HasMultipleBranches> extends true + ? _PerBranchPartial> | ((b: Branches>) => NonNullable) + : DeepPartial; + +type DeepPartial = [T] extends [never] + ? T + : [T] extends [Array] + ? _HasMultipleBranches> extends true + ? T | ((b: Branches>) => U[]) + : T | ((make: (o?: DeepPartial) => U) => U[]) + : T extends object ? { [K in keyof T]?: _Field } : T; + +// NoInfer-like helper for TS <5.4. Prevents the second parameter of mergeOverrides +// from being used to infer T, so T is anchored to the defaults parameter only. +type _NoInfer = [T][T extends any ? 0 : never]; + +function mergeOverrides(defaults: T, overrides: _NoInfer> | undefined): T { + if (overrides === undefined) return defaults; + if (overrides === null) return overrides as unknown as T; + // Callback overrides are consumed at the callsite that knows how to dispatch them; + // by the time we get here, the dispatched value is already in defaults. Skip. + if (typeof overrides === 'function') return defaults; + // Arrays were already constructed by applyArrayOverride, which consumes function-typed + // and per-element overrides. The outer mergeOverrides shouldn't replace them. + if (Array.isArray(defaults) && Array.isArray(overrides)) { + return defaults; + } + if (Array.isArray(defaults) || Array.isArray(overrides)) { + return overrides as unknown as T; + } + if (typeof defaults !== 'object' || defaults === null) { + return overrides as unknown as T; + } + if (typeof overrides !== 'object') { + return overrides as unknown as T; + } + const out: any = { ...defaults }; + for (const key of Object.keys(overrides)) { + const ov = (overrides as any)[key]; + if (ov === undefined) continue; + out[key] = mergeOverrides((defaults as any)[key], ov); + } + return out; +} + +function applyArrayOverride( + makeDefault: (o?: DeepPartial) => T, + override: T[] | ((m: (o?: DeepPartial) => T) => T[]) | null | undefined, + count: number, +): T[] { + if (override === null || override === undefined) { + const arr: T[] = []; + for (let i = 0; i < count; i++) arr.push(makeDefault()); + return arr; + } + if (typeof override === 'function') { + return (override as (m: (o?: DeepPartial) => T) => T[])(makeDefault); + } + return (override as any[]).map((el) => makeDefault(el)); +} + +function pickByCallback( + branches: Record T>, + defaultBranch: string, + override: T | ((b: Record T>) => T) | undefined | null, +): T { + if (typeof override === 'function') { + return (override as (b: Record T>) => T)(branches); + } + return branches[defaultBranch](override ?? undefined); +} + +function applyBranchedArrayOverride( + branches: Record T>, + defaultBranch: string, + override: T[] | ((b: Record T>) => T[]) | null | undefined, + count: number, +): T[] { + if (override === null || override === undefined) { + const arr: T[] = []; + for (let i = 0; i < count; i++) arr.push(branches[defaultBranch]()); + return arr; + } + if (typeof override === 'function') { + return (override as (b: Record T>) => T[])(branches); + } + return override; +} + + +export const aGetUserQueryResponse = ( + overrides?: DeepPartial, +): GetUserQuery => { + const defaults: GetUserQuery = { + user: { + id: 'b7605a2a-ad1e-4667-801e-5e47e5de933b', + name: 'supellex', + email: 'natus', + avatar: { + id: '15f9c394-c8fc-46bc-a882-b3b853f28c03', + url: 'consectetur', + }, + }, + }; + return mergeOverrides(defaults, overrides); +}; + + +export const anAllUsersQueryResponse = ( + overrides?: DeepPartial, +): AllUsersQuery => { + const makeFriends_2 = (_o?: any): any => { + const _defaults = { + id: 'b7605a2a-ad1e-4667-801e-5e47e5de933b', + name: 'supellex', + }; + return mergeOverrides(_defaults, _o); + }; + + const makeUsers_1 = (_o?: any): any => { + const _defaults = { + id: 'b7605a2a-ad1e-4667-801e-5e47e5de933b', + friends: applyArrayOverride(makeFriends_2, _o?.friends, 2), + }; + return mergeOverrides(_defaults, _o); + }; + const defaults: AllUsersQuery = { + users: applyArrayOverride(makeUsers_1, overrides?.users, 2), + }; + return mergeOverrides(defaults, overrides); +}; + + +export const aRenameMutationResponse = ( + overrides?: DeepPartial, +): RenameMutation => { + const defaults: RenameMutation = { + renameUser: { + id: 'b7605a2a-ad1e-4667-801e-5e47e5de933b', + name: 'supellex', + }, + }; + return mergeOverrides(defaults, overrides); +}; + + +export const aSearchQueryResponse = ( + overrides?: DeepPartial, +): SearchQuery => { + const makeDocument_2 = (_o?: any): any => { + const _defaults = { + __typename: 'Document' as const, + id: 'ab777987-09a3-448f-a2b6-4c0ecc3b45da', + title: 'suasoria', + }; + return mergeOverrides(_defaults, _o); + }; + + const makeUser_3 = (_o?: any): any => { + const _defaults = { + __typename: 'User' as const, + id: 'b7605a2a-ad1e-4667-801e-5e47e5de933b', + }; + return mergeOverrides(_defaults, _o); + }; + const defaults: SearchQuery = { + search: applyBranchedArrayOverride({ 'Document': makeDocument_2, 'User': makeUser_3 }, 'Document', overrides?.search, 2), + }; + return mergeOverrides(defaults, overrides); +}; +" +`; diff --git a/tests/operationFactories/activation.spec.ts b/tests/operationFactories/activation.spec.ts new file mode 100644 index 0000000..5c2808d --- /dev/null +++ b/tests/operationFactories/activation.spec.ts @@ -0,0 +1,28 @@ +// tests/operationFactories/activation.spec.ts +import '@graphql-codegen/testing'; +import { plugin } from '../../src'; +import { opSchema, makeDocs } from './fixtures'; + +describe('operation factory activation', () => { + it('emits nothing extra when no documents are provided', async () => { + const result = await plugin(opSchema, [], { typesFile: './types.ts' }); + expect(result).not.toContain('QueryResponse'); + expect(result).not.toContain('mergeOverrides'); + }); + + it('throws when documents are present but typesFile is missing', async () => { + const docs = makeDocs([`query GetUser($id: ID!) { user(id: $id) { id } }`]); + await expect(async () => { + await plugin(opSchema, docs, {}); + }).rejects.toThrow(/requires `typesFile` to be set/); + }); + + it('does nothing when generateOperationFactories is false', async () => { + const docs = makeDocs([`query GetUser($id: ID!) { user(id: $id) { id } }`]); + const result = await plugin(opSchema, docs, { + typesFile: './types.ts', + generateOperationFactories: false, + }); + expect(result).not.toContain('GetUserQueryResponse'); + }); +}); diff --git a/tests/operationFactories/aliasesAndTypename.spec.ts b/tests/operationFactories/aliasesAndTypename.spec.ts new file mode 100644 index 0000000..11b9d9e --- /dev/null +++ b/tests/operationFactories/aliasesAndTypename.spec.ts @@ -0,0 +1,32 @@ +// tests/operationFactories/aliasesAndTypename.spec.ts +import '@graphql-codegen/testing'; +import { plugin } from '../../src'; +import { opSchema, makeDocs } from './fixtures'; + +describe('aliases and __typename', () => { + it('uses the alias as the literal key', async () => { + const docs = makeDocs([`query GetUser($id: ID!) { user(id: $id) { uid: id } }`]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + const factory = String(result).match(/export const aGetUserQueryResponse[\s\S]+?^};$/m)?.[0] ?? ''; + expect(factory).toContain('uid:'); + // The factory should not have an unaliased "id:" key when alias is used. + // We test by ensuring we don't see two distinct id keys in the user object. + expect(factory.match(/id:/g)?.length ?? 0).toBeLessThanOrEqual(1); + }); + + it('emits __typename only when selected', async () => { + const withTypename = await plugin( + opSchema, + makeDocs([`query GetUser($id: ID!) { user(id: $id) { __typename id } }`]), + { typesFile: './types.ts' }, + ); + const withFactory = String(withTypename).match(/export const aGetUserQueryResponse[\s\S]+?^};$/m)?.[0] ?? ''; + expect(withFactory).toMatch(/__typename:\s*'User'/); + + const without = await plugin(opSchema, makeDocs([`query GetUser($id: ID!) { user(id: $id) { id } }`]), { + typesFile: './types.ts', + }); + const withoutFactory = String(without).match(/export const aGetUserQueryResponse[\s\S]+?^};$/m)?.[0] ?? ''; + expect(withoutFactory).not.toContain('__typename'); + }); +}); diff --git a/tests/operationFactories/callbackBranches.spec.ts b/tests/operationFactories/callbackBranches.spec.ts new file mode 100644 index 0000000..7b500d2 --- /dev/null +++ b/tests/operationFactories/callbackBranches.spec.ts @@ -0,0 +1,81 @@ +// tests/operationFactories/callbackBranches.spec.ts +import '@graphql-codegen/testing'; +import { plugin } from '../../src'; +import { opSchema, makeDocs } from './fixtures'; + +const factoryFor = async (source: string, name: string): Promise => { + const result = await plugin(opSchema, makeDocs([source]), { typesFile: './types.ts' }); + const matched = String(result).match(new RegExp(`export const ${name}[\\s\\S]+?^};$`, 'm')); + if (!matched) throw new Error(`factory ${name} not found`); + return matched[0]; +}; + +describe('callback branch overrides for union/interface fields', () => { + it('emits a per-branch defaults closure for each concrete type selected on an interface field', async () => { + const factory = await factoryFor( + `query GetNode($id: ID!) { node(id: $id) { __typename ... on User { id name } ... on Document { id title } } }`, + 'aGetNodeQueryResponse', + ); + // One closure per branch, named after the concrete type. + expect(factory).toMatch( + /const makeDocument_\d+ = \(_o\?: any\): any => \{[\s\S]+__typename: 'Document'[\s\S]+title:/, + ); + expect(factory).toMatch(/const makeUser_\d+ = \(_o\?: any\): any => \{[\s\S]+__typename: 'User'[\s\S]+name:/); + // The field's value is built by pickByCallback wiring those closures by typename. + expect(factory).toMatch( + /node: pickByCallback\(\s*\{[^}]*'Document':\s*makeDocument_\d+[^}]*'User':\s*makeUser_\d+[^}]*\}/, + ); + }); + + it('emits per-branch closures and applyBranchedArrayOverride for a list of a union with __typename', async () => { + const factory = await factoryFor( + `query Search { search(q: "hi") { __typename ... on User { id name } ... on Document { id title } } }`, + 'aSearchQueryResponse', + ); + expect(factory).toMatch(/const makeDocument_\d+ = \(_o\?: any\): any => \{[\s\S]+__typename: 'Document'/); + expect(factory).toMatch(/const makeUser_\d+ = \(_o\?: any\): any => \{[\s\S]+__typename: 'User'/); + // List of a union dispatches via applyBranchedArrayOverride, which receives the branch + // factories and the override directly, with no intermediate make closure. + expect(factory).toMatch( + /search: applyBranchedArrayOverride\(\s*\{[^}]*'Document':\s*makeDocument_\d+[^}]*'User':\s*makeUser_\d+[^}]*\}\s*,\s*'Document'\s*,\s*overrides\?\.search\s*,/, + ); + // The flat make+pickByCallback combo from the previous design is gone. + expect(factory).not.toMatch(/const makeSearch_\d+ = \(_o\?: any\): any => pickByCallback\(/); + }); + + it('points the pickByCallback default at the alphabetically-first branch', async () => { + const factory = await factoryFor( + `query GetNode($id: ID!) { node(id: $id) { __typename ... on User { id name } ... on Document { id title } } }`, + 'aGetNodeQueryResponse', + ); + expect(factory).toMatch(/pickByCallback\(\s*\{[^}]+\}\s*,\s*'Document'/); + }); + + it('does not emit branch dispatch when only one inline fragment is present', async () => { + const factory = await factoryFor( + `query GetNode($id: ID!) { node(id: $id) { __typename ... on User { id name } } }`, + 'aGetNodeQueryResponse', + ); + expect(factory).not.toContain('pickByCallback'); + }); + + it('does not emit branch dispatch when __typename is not selected', async () => { + const factory = await factoryFor( + `query GetNode($id: ID!) { node(id: $id) { ... on User { id name } ... on Document { id title } } }`, + 'aGetNodeQueryResponse', + ); + expect(factory).not.toContain('pickByCallback'); + }); + + it('finds inline-fragment branches reached through a named fragment spread', async () => { + const factory = await factoryFor( + `fragment Hit on SearchHit { __typename ... on User { id name } ... on Document { id title } } + query Search { search(q: "hi") { ...Hit } }`, + 'aSearchQueryResponse', + ); + // Both branch closures should be emitted, not just the alphabetically-first one. + expect(factory).toMatch(/const makeDocument_\d+ = \(_o\?: any\): any => \{[\s\S]+__typename: 'Document'/); + expect(factory).toMatch(/const makeUser_\d+ = \(_o\?: any\): any => \{[\s\S]+__typename: 'User'/); + expect(factory).toMatch(/applyBranchedArrayOverride\(\s*\{[^}]*'Document':[^}]*'User':/); + }); +}); diff --git a/tests/operationFactories/collection.spec.ts b/tests/operationFactories/collection.spec.ts new file mode 100644 index 0000000..1dfb818 --- /dev/null +++ b/tests/operationFactories/collection.spec.ts @@ -0,0 +1,22 @@ +// tests/operationFactories/collection.spec.ts +import '@graphql-codegen/testing'; +import { plugin } from '../../src'; +import { opSchema, makeDocs } from './fixtures'; + +describe('operation collection', () => { + it('throws on duplicate operation names with both source locations', async () => { + const docs = makeDocs([ + `query GetUser($id: ID!) { user(id: $id) { id } }`, + `query GetUser($id: ID!) { user(id: $id) { name } }`, + ]); + await expect(async () => { + await plugin(opSchema, docs, { typesFile: './types.ts' }); + }).rejects.toThrow(/two operations named "GetUser"/); + }); + + it('silently skips anonymous operations', async () => { + const docs = makeDocs([`{ user(id: "1") { id } }`, `query Named($id: ID!) { user(id: $id) { id } }`]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + expect(result).toContain('aNamedQueryResponse'); + }); +}); diff --git a/tests/operationFactories/fixtures.ts b/tests/operationFactories/fixtures.ts new file mode 100644 index 0000000..5dccadf --- /dev/null +++ b/tests/operationFactories/fixtures.ts @@ -0,0 +1,62 @@ +// tests/operationFactories/fixtures.ts +import { buildSchema, parse, Source } from 'graphql'; +import { Types } from '@graphql-codegen/plugin-helpers'; + +export const opSchema = buildSchema(/* GraphQL */ ` + scalar Date + + enum Status { + ACTIVE + INACTIVE + } + + interface Node { + id: ID! + } + + type User implements Node { + id: ID! + name: String! + email: String! + status: Status! + avatar: Avatar + friends: [User!]! + } + + type Avatar { + id: ID! + url: String! + } + + type Document implements Node { + id: ID! + title: String! + url: String! + } + + union SearchHit = User | Document + + type Query { + user(id: ID!): User + node(id: ID!): Node + search(q: String!): [SearchHit!]! + users: [User!]! + } + + type Mutation { + renameUser(id: ID!, name: String!): User! + } + + type Subscription { + userUpdated(id: ID!): User! + } +`); + +export const makeDocs = (sources: string[]): Types.DocumentFile[] => + sources.map((source, i) => { + const location = `tests/operationFactories/fixtures.graphql#${i}`; + return { + document: parse(new Source(source, location)), + location, + }; + }); diff --git a/tests/operationFactories/fragments.spec.ts b/tests/operationFactories/fragments.spec.ts new file mode 100644 index 0000000..20cbc20 --- /dev/null +++ b/tests/operationFactories/fragments.spec.ts @@ -0,0 +1,47 @@ +// tests/operationFactories/fragments.spec.ts +import '@graphql-codegen/testing'; +import { plugin } from '../../src'; +import { opSchema, makeDocs } from './fixtures'; + +describe('named fragment spreads', () => { + it('inlines fragment selections into the operation literal', async () => { + const docs = makeDocs([ + `fragment UserCore on User { id name } + query GetUser($id: ID!) { user(id: $id) { ...UserCore email } }`, + ]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + // Scope assertions to the operation factory body (avoid false positives from + // schema-level type factories that also contain these field names). + const factoryMatch = String(result).match(/export const aGetUserQueryResponse[\s\S]+?^};$/m); + expect(factoryMatch).toBeTruthy(); + const factory = factoryMatch![0]; + expect(factory).toContain('id:'); + expect(factory).toContain('name:'); + expect(factory).toContain('email:'); + expect(result).not.toContain('aUserCoreFragment'); // no fragment factory + }); + + it('resolves fragments defined in a separate document', async () => { + const docs = makeDocs([ + `fragment UserCore on User { id name }`, + `query GetUser($id: ID!) { user(id: $id) { ...UserCore } }`, + ]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + const factoryMatch = String(result).match(/export const aGetUserQueryResponse[\s\S]+?^};$/m); + expect(factoryMatch).toBeTruthy(); + expect(factoryMatch![0]).toContain('name:'); + }); + + it('deduplicates fields selected directly and via a spread', async () => { + const docs = makeDocs([ + `fragment UserBits on User { __typename name } + query GetUser($id: ID!) { user(id: $id) { __typename name ...UserBits } }`, + ]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + const factoryMatch = String(result).match(/export const aGetUserQueryResponse[\s\S]+?^};$/m); + expect(factoryMatch).toBeTruthy(); + const factory = factoryMatch![0]; + expect(factory.match(/__typename:\s*'User'/g)?.length).toBe(1); + expect(factory.match(/\bname:/g)?.length).toBe(1); + }); +}); diff --git a/tests/operationFactories/leafValues.spec.ts b/tests/operationFactories/leafValues.spec.ts new file mode 100644 index 0000000..d4e90ba --- /dev/null +++ b/tests/operationFactories/leafValues.spec.ts @@ -0,0 +1,25 @@ +// tests/operationFactories/leafValues.spec.ts +import '@graphql-codegen/testing'; +import { plugin } from '../../src'; +import { opSchema, makeDocs } from './fixtures'; + +describe('leaf value generation', () => { + it('honors custom scalars config', async () => { + const docs = makeDocs([`query GetUser($id: ID!) { user(id: $id) { id } }`]); + const result = await plugin(opSchema, docs, { + typesFile: './types.ts', + scalars: { ID: 'string.uuid' }, + }); + // The factory's id default should look like a UUID call from faker. + expect(result).toMatch(/id:\s*'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/); + }); + + it('honors fieldGeneration config', async () => { + const docs = makeDocs([`query GetUser($id: ID!) { user(id: $id) { email } }`]); + const result = await plugin(opSchema, docs, { + typesFile: './types.ts', + fieldGeneration: { User: { email: 'internet.email' } }, + }); + expect(result).toMatch(/email:\s*'\S+@\S+'/); + }); +}); diff --git a/tests/operationFactories/lists.spec.ts b/tests/operationFactories/lists.spec.ts new file mode 100644 index 0000000..17c8223 --- /dev/null +++ b/tests/operationFactories/lists.spec.ts @@ -0,0 +1,32 @@ +// tests/operationFactories/lists.spec.ts +import '@graphql-codegen/testing'; +import { plugin } from '../../src'; +import { opSchema, makeDocs } from './fixtures'; + +describe('list selections', () => { + it('emits applyArrayOverride and a per-element factory', async () => { + const docs = makeDocs([`query AllUsers { users { id name } }`]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + expect(result).toContain('applyArrayOverride('); + expect(result).toMatch(/const make[A-Z]\w* = \(/); + }); + + it('honors listElementCount config', async () => { + const docs = makeDocs([`query AllUsers { users { id } }`]); + const result = await plugin(opSchema, docs, { + typesFile: './types.ts', + listElementCount: 3, + }); + expect(result).toContain('applyArrayOverride('); + expect(result).toContain(', 3)'); + }); + + it('handles nested lists (friends inside user)', async () => { + const docs = makeDocs([`query AllUsers { users { id friends { id name } } }`]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + // Should have at least two make* factories (top-level users and nested friends) + const output = typeof result === 'string' ? result : result.content; + const matches = output.match(/const make[A-Z]\w* = \(/g) ?? []; + expect(matches.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/tests/operationFactories/nested.spec.ts b/tests/operationFactories/nested.spec.ts new file mode 100644 index 0000000..ed74034 --- /dev/null +++ b/tests/operationFactories/nested.spec.ts @@ -0,0 +1,19 @@ +// tests/operationFactories/nested.spec.ts +import '@graphql-codegen/testing'; +import { plugin } from '../../src'; +import { opSchema, makeDocs } from './fixtures'; + +describe('nested object selections', () => { + it('emits nested literals for object subfields', async () => { + const docs = makeDocs([`query Profile($id: ID!) { user(id: $id) { id avatar { id url } } }`]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + expect(result).toMatch(/avatar:\s*\{[^}]*id:[^}]*url:[^}]*\}/); + }); + + it('handles a null branch on nullable object fields', async () => { + const docs = makeDocs([`query Profile($id: ID!) { user(id: $id) { id avatar { url } } }`]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + // avatar is nullable; default should be the object, not null + expect(result).not.toMatch(/avatar:\s*null/); + }); +}); diff --git a/tests/operationFactories/runtime.spec.ts b/tests/operationFactories/runtime.spec.ts new file mode 100644 index 0000000..7eb51e0 --- /dev/null +++ b/tests/operationFactories/runtime.spec.ts @@ -0,0 +1,19 @@ +// tests/operationFactories/runtime.spec.ts +import '@graphql-codegen/testing'; +import { plugin } from '../../src'; +import { opSchema, makeDocs } from './fixtures'; + +describe('runtime helpers', () => { + it('emits DeepPartial, mergeOverrides, applyArrayOverride when operations are generated', async () => { + const docs = makeDocs([`query GetUser($id: ID!) { user(id: $id) { id name } }`]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + expect(result).toContain('type DeepPartial<'); + expect(result).toContain('function mergeOverrides<'); + expect(result).toContain('function applyArrayOverride<'); + }); + + it('does not emit runtime helpers when no operations exist', async () => { + const result = await plugin(opSchema, [], { typesFile: './types.ts' }); + expect(result).not.toContain('mergeOverrides'); + }); +}); diff --git a/tests/operationFactories/simple.spec.ts b/tests/operationFactories/simple.spec.ts new file mode 100644 index 0000000..29bb3f0 --- /dev/null +++ b/tests/operationFactories/simple.spec.ts @@ -0,0 +1,48 @@ +import '@graphql-codegen/testing'; +import { plugin } from '../../src'; +import { opSchema, makeDocs } from './fixtures'; + +describe('simple scalar operation factory', () => { + it('emits a factory whose name is articleNameOperation+Response', async () => { + const docs = makeDocs([`query GetUser($id: ID!) { user(id: $id) { id name email } }`]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + expect(result).toContain('export const aGetUserQueryResponse = ('); + }); + + it('annotates return type with the operation TS type', async () => { + const docs = makeDocs([`query GetUser($id: ID!) { user(id: $id) { id name email } }`]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + expect(result).toContain('): GetUserQuery =>'); + }); + + it('imports the operation type from typesFile', async () => { + const docs = makeDocs([`query GetUser($id: ID!) { user(id: $id) { id name email } }`]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + expect(result).toMatch(/import\s*\{[^}]*GetUserQuery[^}]*\}\s*from\s*['"]\.\/types['"]/); + }); + + it('emits an inline literal matching the selection set', async () => { + const docs = makeDocs([`query GetUser($id: ID!) { user(id: $id) { id name email } }`]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + expect(result).toContain('user:'); + expect(result).toContain('id:'); + expect(result).toContain('name:'); + expect(result).toContain('email:'); + }); + + it('emits an `an`-prefixed factory when operation type starts with a vowel', async () => { + const docs = makeDocs([`query Audit { user(id: "1") { id } }`]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + expect(result).toContain('export const anAuditQueryResponse = ('); + }); + + it('emits factories for mutations and subscriptions too', async () => { + const docs = makeDocs([ + `mutation Rename($id: ID!, $name: String!) { renameUser(id: $id, name: $name) { id name } }`, + `subscription Watch($id: ID!) { userUpdated(id: $id) { id name } }`, + ]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + expect(result).toContain('aRenameMutationResponse'); + expect(result).toContain('aWatchSubscriptionResponse'); + }); +}); diff --git a/tests/operationFactories/snapshot.spec.ts b/tests/operationFactories/snapshot.spec.ts new file mode 100644 index 0000000..338440b --- /dev/null +++ b/tests/operationFactories/snapshot.spec.ts @@ -0,0 +1,21 @@ +// tests/operationFactories/snapshot.spec.ts +import '@graphql-codegen/testing'; +import { plugin } from '../../src'; +import { opSchema, makeDocs } from './fixtures'; + +describe('snapshot', () => { + it('matches snapshot for a representative operation set', async () => { + const docs = makeDocs([ + `fragment UserCore on User { id name email }`, + `query GetUser($id: ID!) { user(id: $id) { ...UserCore avatar { id url } } }`, + `query AllUsers { users { id friends { id name } } }`, + `mutation Rename($id: ID!, $name: String!) { renameUser(id: $id, name: $name) { id name } }`, + `query Search { search(q: "x") { __typename ... on User { id } ... on Document { id title } } }`, + ]); + const result = await plugin(opSchema, docs, { + typesFile: './types.ts', + listElementCount: 2, + }); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/tests/operationFactories/unions.spec.ts b/tests/operationFactories/unions.spec.ts new file mode 100644 index 0000000..0217374 --- /dev/null +++ b/tests/operationFactories/unions.spec.ts @@ -0,0 +1,79 @@ +// tests/operationFactories/unions.spec.ts +import '@graphql-codegen/testing'; +import { plugin } from '../../src'; +import { opSchema, makeDocs } from './fixtures'; + +describe('unions and interfaces', () => { + it('picks the alphabetically first concrete branch as the default for a list of a union (with __typename)', async () => { + const docs = makeDocs([ + `query Search { search(q: "hi") { __typename ... on User { id name } ... on Document { id title } } }`, + ]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + const factoryMatch = String(result).match(/export const aSearchQueryResponse[\s\S]+?^};$/m); + expect(factoryMatch).toBeTruthy(); + // Document < User alphabetically, so the list dispatch default is 'Document'. + expect(factoryMatch![0]).toMatch(/applyBranchedArrayOverride\(\s*\{[^}]+\}\s*,\s*'Document'/); + }); + + it('picks the alphabetically first concrete branch as the default for an interface narrowing (with __typename)', async () => { + const docs = makeDocs([ + `query GetNode($id: ID!) { node(id: $id) { __typename ... on User { id name } ... on Document { id title } } }`, + ]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + const factoryMatch = String(result).match(/export const aGetNodeQueryResponse[\s\S]+?^};$/m); + expect(factoryMatch).toBeTruthy(); + expect(factoryMatch![0]).toMatch(/pickByCallback\(\s*\{[^}]+\}\s*,\s*'Document'/); + }); + + it('does not emit callback dispatch when __typename is not selected', async () => { + const docs = makeDocs([ + `query Search { search(q: "hi") { ... on User { id name } ... on Document { id title } } }`, + ]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + const factory = String(result).match(/export const aSearchQueryResponse[\s\S]+?^};$/m)?.[0] ?? ''; + expect(factory).not.toContain('pickByCallback'); + }); + + it('emits __typename when the operation selects it on a union branch', async () => { + const docs = makeDocs([ + `query Search { search(q: "hi") { __typename ... on User { id } ... on Document { id } } }`, + ]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + expect(result).toMatch(/__typename:\s*'Document'/); + }); + + it('inlines fields from an inline fragment whose type condition is an interface implemented by the chosen concrete type', async () => { + const docs = makeDocs([`query GetNode($id: ID!) { node(id: $id) { ... on Node { id } } }`]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + const factoryMatch = String(result).match(/export const aGetNodeQueryResponse[\s\S]+?^};$/m); + expect(factoryMatch).toBeTruthy(); + const factory = factoryMatch![0]; + expect(factory).toContain('id:'); + // Must not produce empty object literal (which surfaces as `{ , }` after our join). + expect(factory).not.toMatch(/\{\s*,\s*\}/); + }); + + it('inlines fragment spreads nested inside an inline fragment matching the chosen branch', async () => { + const docs = makeDocs([ + // Only the Document branch's inline fragment is present, and it spreads a fragment. + `fragment DocBits on Document { id title } + query Search { search(q: "hi") { ... on Document { ...DocBits } } }`, + ]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + const factoryMatch = String(result).match(/export const aSearchQueryResponse[\s\S]+?^};$/m); + expect(factoryMatch).toBeTruthy(); + const factory = factoryMatch![0]; + expect(factory).toContain('title:'); + expect(factory).not.toMatch(/\{\s*,\s*\}/); + }); + + it('inlines interface-typed inline fragments selected on a concrete object field (e.g. payload errors lists)', async () => { + const docs = makeDocs([`query Friends { users { friends { ... on Node { id } } } }`]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + const factoryMatch = String(result).match(/export const aFriendsQueryResponse[\s\S]+?^};$/m); + expect(factoryMatch).toBeTruthy(); + const factory = factoryMatch![0]; + expect(factory).toContain('id:'); + expect(factory).not.toMatch(/_defaults\s*=\s*\{\s*,?\s*\};?/); + }); +}); From 55ec598ac1b2d62f2128e3b0566ec70850909641 Mon Sep 17 00:00:00 2001 From: Stephen Crosby Date: Tue, 16 Jun 2026 09:30:32 -0700 Subject: [PATCH 2/3] Loosen list element override type to accept partial elements Array overrides flow through applyArrayOverride, which calls makeDefault(el) on each element, so partial elements work at runtime. The DeepPartial type was stricter than the runtime, requiring complete U for every element. Switch to Array> so partial element overrides type-check. --- src/operationRuntime.ts | 2 +- tests/operationFactories/__snapshots__/snapshot.spec.ts.snap | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/operationRuntime.ts b/src/operationRuntime.ts index 663eea5..8acac0f 100644 --- a/src/operationRuntime.ts +++ b/src/operationRuntime.ts @@ -27,7 +27,7 @@ type DeepPartial = [T] extends [never] : [T] extends [Array] ? _HasMultipleBranches> extends true ? T | ((b: Branches>) => U[]) - : T | ((make: (o?: DeepPartial) => U) => U[]) + : Array> | ((make: (o?: DeepPartial) => U) => U[]) : T extends object ? { [K in keyof T]?: _Field } : T; // NoInfer-like helper for TS <5.4. Prevents the second parameter of mergeOverrides diff --git a/tests/operationFactories/__snapshots__/snapshot.spec.ts.snap b/tests/operationFactories/__snapshots__/snapshot.spec.ts.snap index a6b1fe7..2b55db4 100644 --- a/tests/operationFactories/__snapshots__/snapshot.spec.ts.snap +++ b/tests/operationFactories/__snapshots__/snapshot.spec.ts.snap @@ -84,7 +84,7 @@ type DeepPartial = [T] extends [never] : [T] extends [Array] ? _HasMultipleBranches> extends true ? T | ((b: Branches>) => U[]) - : T | ((make: (o?: DeepPartial) => U) => U[]) + : Array> | ((make: (o?: DeepPartial) => U) => U[]) : T extends object ? { [K in keyof T]?: _Field } : T; // NoInfer-like helper for TS <5.4. Prevents the second parameter of mergeOverrides From 0fb7d16d02ce2493dcacdf82f93d9546e6e68014 Mon Sep 17 00:00:00 2001 From: Stephen Crosby Date: Tue, 16 Jun 2026 09:39:30 -0700 Subject: [PATCH 3/3] Test partial element overrides type-check end-to-end Adds a typeCheck spec that runs the TypeScript compiler over the generated factory plus a hand-written types file, asserting that partial element overrides for object lists compile cleanly and that unknown element fields are still rejected. While wiring up the test, applyArrayOverride's `override` parameter needed the same Array> loosening as the user-facing DeepPartial; otherwise the generated factory body fails to type-check when forwarding the now-permissive override into the helper. --- src/operationRuntime.ts | 2 +- .../__snapshots__/snapshot.spec.ts.snap | 2 +- tests/operationFactories/typeCheck.spec.ts | 144 ++++++++++++++++++ 3 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 tests/operationFactories/typeCheck.spec.ts diff --git a/src/operationRuntime.ts b/src/operationRuntime.ts index 8acac0f..ff22bfe 100644 --- a/src/operationRuntime.ts +++ b/src/operationRuntime.ts @@ -65,7 +65,7 @@ function mergeOverrides(defaults: T, overrides: _NoInfer> | un function applyArrayOverride( makeDefault: (o?: DeepPartial) => T, - override: T[] | ((m: (o?: DeepPartial) => T) => T[]) | null | undefined, + override: Array> | ((m: (o?: DeepPartial) => T) => T[]) | null | undefined, count: number, ): T[] { if (override === null || override === undefined) { diff --git a/tests/operationFactories/__snapshots__/snapshot.spec.ts.snap b/tests/operationFactories/__snapshots__/snapshot.spec.ts.snap index 2b55db4..5297c13 100644 --- a/tests/operationFactories/__snapshots__/snapshot.spec.ts.snap +++ b/tests/operationFactories/__snapshots__/snapshot.spec.ts.snap @@ -122,7 +122,7 @@ function mergeOverrides(defaults: T, overrides: _NoInfer> | un function applyArrayOverride( makeDefault: (o?: DeepPartial) => T, - override: T[] | ((m: (o?: DeepPartial) => T) => T[]) | null | undefined, + override: Array> | ((m: (o?: DeepPartial) => T) => T[]) | null | undefined, count: number, ): T[] { if (override === null || override === undefined) { diff --git a/tests/operationFactories/typeCheck.spec.ts b/tests/operationFactories/typeCheck.spec.ts new file mode 100644 index 0000000..bf85edb --- /dev/null +++ b/tests/operationFactories/typeCheck.spec.ts @@ -0,0 +1,144 @@ +// tests/operationFactories/typeCheck.spec.ts +import '@graphql-codegen/testing'; +import * as ts from 'typescript'; +import { buildSchema } from 'graphql'; +import { plugin } from '../../src'; +import { makeDocs } from './fixtures'; + +const listUsersSchema = buildSchema(/* GraphQL */ ` + type Query { + users: UserConnection! + } + + type UserConnection { + nodes: [User!]! + totalCount: Int! + } + + type User { + id: ID! + name: String! + email: String! + } +`); + +const listUsersTypes = ` +export type User = { id: string; name: string; email: string }; +export type UserConnection = { nodes: User[]; totalCount: number }; +export type Query = { users: UserConnection }; +export type ListUsersQuery = { + users: { + nodes: User[]; + totalCount: number; + }; +}; +`; + +const compile = (files: Record): readonly ts.Diagnostic[] => { + const options: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2018, + module: ts.ModuleKind.CommonJS, + moduleResolution: ts.ModuleResolutionKind.NodeJs, + strict: true, + skipLibCheck: true, + noEmit: true, + esModuleInterop: true, + }; + const host = ts.createCompilerHost(options); + const realGetSourceFile = host.getSourceFile.bind(host); + host.getSourceFile = (fileName, languageVersion, onError, shouldCreateNewSourceFile) => { + if (Object.prototype.hasOwnProperty.call(files, fileName)) { + return ts.createSourceFile(fileName, files[fileName], languageVersion, true); + } + return realGetSourceFile(fileName, languageVersion, onError, shouldCreateNewSourceFile); + }; + host.fileExists = (fileName) => + Object.prototype.hasOwnProperty.call(files, fileName) || ts.sys.fileExists(fileName); + host.readFile = (fileName) => + Object.prototype.hasOwnProperty.call(files, fileName) ? files[fileName] : ts.sys.readFile(fileName); + host.writeFile = () => undefined; + + const program = ts.createProgram(Object.keys(files), options, host); + return ts.getPreEmitDiagnostics(program); +}; + +const formatDiagnostics = (diagnostics: readonly ts.Diagnostic[]): string => + diagnostics + .map((d) => { + const message = ts.flattenDiagnosticMessageText(d.messageText, '\n'); + if (d.file && d.start !== undefined) { + const { line, character } = d.file.getLineAndCharacterOfPosition(d.start); + return `${d.file.fileName}:${line + 1}:${character + 1} ${message}`; + } + return message; + }) + .join('\n'); + +describe('generated factories type-check', () => { + it('accepts partial element overrides for object lists', async () => { + const docs = makeDocs([`query ListUsers { users { nodes { id name email } totalCount } }`]); + const generated = await plugin(listUsersSchema, docs, { typesFile: './types.ts' }); + const mockFile = typeof generated === 'string' ? generated : generated.content; + + const usage = ` +import { aListUsersQueryResponse } from './mocks.generated'; + +const partialElement = aListUsersQueryResponse({ + users: { nodes: [{ id: 'u1' }] }, +}); + +const partialElementWithCount = aListUsersQueryResponse({ + users: { + nodes: [{ id: 'u1' }, { name: 'Alice' }], + totalCount: 2, + }, +}); + +const callbackForm = aListUsersQueryResponse({ + users: { + nodes: (make) => [make({ id: 'u1' }), make({ name: 'Alice' })], + }, +}); + +const noOverride = aListUsersQueryResponse(); + +void partialElement; +void partialElementWithCount; +void callbackForm; +void noOverride; +`; + + const diagnostics = compile({ + '/types.ts': listUsersTypes, + '/mocks.generated.ts': mockFile, + '/usage.ts': usage, + }); + + expect(formatDiagnostics(diagnostics)).toBe(''); + }); + + it('still rejects element overrides with extra unknown properties', async () => { + const docs = makeDocs([`query ListUsers { users { nodes { id name email } totalCount } }`]); + const generated = await plugin(listUsersSchema, docs, { typesFile: './types.ts' }); + const mockFile = typeof generated === 'string' ? generated : generated.content; + + const usage = ` +import { aListUsersQueryResponse } from './mocks.generated'; + +const bogus = aListUsersQueryResponse({ + users: { nodes: [{ notAField: 'oops' }] }, +}); + +void bogus; +`; + + const diagnostics = compile({ + '/types.ts': listUsersTypes, + '/mocks.generated.ts': mockFile, + '/usage.ts': usage, + }); + + expect(diagnostics.length).toBeGreaterThan(0); + expect(formatDiagnostics(diagnostics)).toMatch(/notAField/); + }); +});