From a4a3ef6e6fb10c85991fbcd5a08214e88520115a Mon Sep 17 00:00:00 2001 From: Felix Kling Date: Wed, 13 Mar 2024 10:09:01 +0100 Subject: [PATCH] svelte: Improve TS types for GraphQL mocks (#61047) Before this commit the "mock types" had been generated by simply applying `DeepPartial` (which is like `Partial` but applies recursively) to the existing GraphQL types. This commit generates the TS types "from scratch". This allows us to customize how TS types for mocks are defined. Concrete differences (so far): - Mock types for interfaces have a `__typename` field that is a union of the type names of all implementations of that interface. This should make it easier to define mocks of concreate types for fields that accept an interface. - Fields that take an enum are now defined as string union, which allows speciying the enum value as string literal (with completion support), instead of having to import the actual enum. Example of generated code: ``` export interface AggregationModeAvailabilityMock { __typename?: 'AggregationModeAvailability', /** * Boolean indicating if the mode is available */ available?: Scalars['Boolean']['output'], /** * The SearchAggregationMode */ mode?: 'AUTHOR' | 'CAPTURE_GROUP' | 'PATH' | 'REPO' | 'REPO_METADATA', /** * If the mode is unavailable the reason why */ reasonUnavailable?: Scalars['String']['output'] | null, } ``` Note that this only affect type mocks, not operation mocks. Those require more effort to process. I might do that in a future PR. --- .../web-sveltekit/dev/graphql-type-mocks.cjs | 130 ++++++++++++++++-- .../web-sveltekit/src/testing/integration.ts | 2 + 2 files changed, 123 insertions(+), 9 deletions(-) diff --git a/client/web-sveltekit/dev/graphql-type-mocks.cjs b/client/web-sveltekit/dev/graphql-type-mocks.cjs index 90d35965cdf..ee381c104d4 100644 --- a/client/web-sveltekit/dev/graphql-type-mocks.cjs +++ b/client/web-sveltekit/dev/graphql-type-mocks.cjs @@ -1,6 +1,15 @@ // @ts-check -const { isObjectType, visit, concatAST } = require('graphql') -const { isScalarType } = require('graphql') +const { + visit, + concatAST, + isObjectType, + isNonNullType, + isListType, + isInterfaceType, + isUnionType, + isEnumType, + isScalarType, +} = require('graphql') const logger = require('signale') /** @@ -17,26 +26,123 @@ function documentsToAST(documents) { return concatAST(documentNodes) } +/** + * @param {import('graphql').GraphQLNamedType} type + * @returns {string} + */ +function getMockTypeName(type) { + return type.name + 'Mock' +} + +/** + * @param {string[]} lines + * @param {string} indent + * @returns {string} + */ +function formatLines(lines, indent = '') { + return lines.map(line => indent + line).join('\n') +} + +/** + * @param {import('graphql').GraphQLSchema} schema + * @param {import('graphql').GraphQLNamedType} type + * @returns {string} + */ +function generateObjectTypeFields(schema, type, indent = '') { + if (!isObjectType(type) && !isInterfaceType(type)) { + throw new Error('Unsupported type ' + type) + } + let lines = [] + if (isObjectType(type)) { + lines.push(`__typename?: '${type.name}',`) + } + if (isInterfaceType(type)) { + lines.push( + `__typename?: ${schema + .getImplementations(type) + .objects.map(type => `'${type.name}'`) + .join(' | ')},` + ) + } + + Object.entries(type.getFields()).forEach(([fieldName, field]) => { + if (field.description || field.deprecationReason) { + lines.push('/**') + if (field.description) { + field.description.split('\n').forEach(line => lines.push(` * ${line}`)) + } + if (field.deprecationReason) { + lines.push(` * @deprecated ${field.deprecationReason}`) + } + lines.push(' */') + } + lines.push(`${fieldName}?: ${generateTSTypeForNullableGraphQLType(field.type, indent)},`) + }) + return formatLines(lines, indent) +} + +/** + * @param {import('graphql').GraphQLType} type + * @param {string} indent + * @returns {string} + */ +function generateTSTypeForNullableGraphQLType(type, indent = '') { + if (isNonNullType(type)) { + return generateTSTypeForGraphQLType(type.ofType, indent) + } + return generateTSTypeForGraphQLType(type, indent) + ' | null' +} + +/** + * @param {import('graphql').GraphQLType} type + * @returns {string} + */ +function generateTSTypeForGraphQLType(type, indent = '') { + if (isListType(type)) { + // Using Array<...> instead of ...[] to avoid having to wrap some inner types in parentheses + return `Array<${generateTSTypeForNullableGraphQLType(type.ofType, indent)}>` + } + if (isUnionType(type)) { + return type + .getTypes() + .map(type => generateTSTypeForGraphQLType(type, indent)) + .join(' | ') + } + if (isObjectType(type) || isInterfaceType(type)) { + return getMockTypeName(type) + } + if (isEnumType(type)) { + return type + .getValues() + .map(value => `'${value.name}'`) + .join(' | ') + } + if (isScalarType(type)) { + return `Scalars['${type.name}']['output']` + } + throw new Error('Unsupported type ' + type) +} + /** * * @param {import('graphql').GraphQLSchema} schema * @param {import('@graphql-codegen/plugin-helpers').Types.DocumentFile[]} documents - * @param {{typesImport: string, mockInterfaceName?: string, operationResultSuffix?: string}} config + * @param {{mockInterfaceName?: string, operationResultSuffix?: string}} config * @returns {import('@graphql-codegen/plugin-helpers').Types.PluginOutput} */ const plugin = (schema, documents, config) => { - const { mockInterfaceName = 'TypeMocks', typesImport, operationResultSuffix = '' } = config + const { mockInterfaceName = 'TypeMocks', operationResultSuffix = '' } = config const interfaceFields = Object.values(schema.getTypeMap()) .filter(value => !value.name.startsWith('__') && (isScalarType(value) || isObjectType(value))) .map( value => `${value.name}?: MockFunction<${ - isScalarType(value) ? `Scalars['${value.name}']['output']` : `DeepPartial<${value.name}Mock>` + isScalarType(value) ? `Scalars['${value.name}']['output']` : getMockTypeName(value) }>` ) const objectTypes = Object.values(schema.getTypeMap()).filter( - value => !value.name.startsWith('__') && isObjectType(value) + value => !value.name.startsWith('__') && (isObjectType(value) || isInterfaceType(value)) ) const operations = [] @@ -53,14 +159,16 @@ const plugin = (schema, documents, config) => { return { prepend: [ `import type { GraphQLResolveInfo } from 'graphql'`, - `import type * as Types from '${typesImport}'`, 'type DeepPartial = T extends object ? {', ' [P in keyof T]?: DeepPartial', '} : T', 'type MockFunction = (info: GraphQLResolveInfo) => Return', ], content: [ - ...objectTypes.map(type => `export type ${type.name}Mock = DeepPartial`), + ...objectTypes.map( + type => + `export interface ${getMockTypeName(type)} {\n${generateObjectTypeFields(schema, type, ' ')}\n}\n` + ), `export interface ${mockInterfaceName} {`, ` [key: string]: MockFunction|undefined`, ` ${interfaceFields.join('\n ')}`, @@ -74,7 +182,11 @@ const plugin = (schema, documents, config) => { ) .join('\n ')}`, `}`, - 'export type ObjectMock = ' + objectTypes.map(type => `${type.name}Mock`).join(' | '), + 'export type ObjectMock = ' + + objectTypes + .filter(type => !isInterfaceType(type)) + .map(type => getMockTypeName(type)) + .join(' | '), ].join('\n'), } } diff --git a/client/web-sveltekit/src/testing/integration.ts b/client/web-sveltekit/src/testing/integration.ts index 5dca3521e56..1ef252eaba2 100644 --- a/client/web-sveltekit/src/testing/integration.ts +++ b/client/web-sveltekit/src/testing/integration.ts @@ -194,6 +194,8 @@ class Sourcegraph { } public fixture(fixtures: (ObjectMock & { __typename: NonNullable })[]): void { + // @ts-expect-error - Unclear how to type this correctly. ObjectMock is missing string index signature + // which is required by addFixtures this.graphqlMock.addFixtures(fixtures) }