From 659cd3ab413652a3fefd7c0f50339f21908d5863 Mon Sep 17 00:00:00 2001 From: Michael van Tellingen Date: Mon, 26 Oct 2026 07:25:30 +0100 Subject: [PATCH 01/14] fix(k6): import DocumentNode in generated requester The Requester type refers to DocumentNode, but the base visitor only imports it for the documentNode modes. Import it for graphQLTag and external as well, respecting documentNodeImport and useTypeImports. --- packages/k6/src/visitor.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/k6/src/visitor.ts b/packages/k6/src/visitor.ts index fab4f9e..e0211c1 100644 --- a/packages/k6/src/visitor.ts +++ b/packages/k6/src/visitor.ts @@ -55,7 +55,7 @@ export class GenericSdkVisitor extends ClientSideBaseVisitor< ): string { if (node.name == null) { throw new Error( - `PlugJn '@labdigital/graphql-codegen-k6' cannot generate SDK for unnamed operation.\n\n${print(node)}`, + `Plugin '@labdigital/graphql-codegen-k6' cannot generate SDK for unnamed operation.\n\n${print(node)}`, ); } else { this._operationsToInclude.push({ @@ -71,6 +71,29 @@ export class GenericSdkVisitor extends ClientSideBaseVisitor< return ""; } + /** + * The `Requester` type refers to `DocumentNode` in every mode except + * `string`, but the base visitor only imports it for the `documentNode*` + * modes. Add the import for the other modes as well. + */ + public getImports(options: { excludeFragments?: boolean } = {}): string[] { + const imports = super.getImports(options); + if (this.config.documentMode === DocumentMode.string) { + return imports; + } + const documentNodeImport = this._generateImport( + this._parseImport( + this.config.documentNodeImport || "graphql#DocumentNode", + ), + "DocumentNode", + true, + ); + if (documentNodeImport && !imports.includes(documentNodeImport)) { + imports.push(documentNodeImport); + } + return imports; + } + private getDocumentNodeVariable(documentVariableName: string): string { return this.config.documentMode === DocumentMode.external ? `Operations.${documentVariableName}` From 8f227c66ccbfddf3e2771cb87a650493c37b8109 Mon Sep 17 00:00:00 2001 From: Michael van Tellingen Date: Mon, 26 Oct 2026 07:25:30 +0100 Subject: [PATCH 02/14] fix(k6): emit TypedDocumentString for documentMode string documentMode string referenced a TypedDocumentString class that was never defined. Emit the class (as typed-document-node and the client preset do, without the @graphql-typed-document-node/core dependency), type the operation documents with their result and variables, and pass the TypedDocumentString to the requester. --- packages/k6/src/index.ts | 3 +++ packages/k6/src/visitor.ts | 51 +++++++++++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/packages/k6/src/index.ts b/packages/k6/src/index.ts index 9080902..2000815 100644 --- a/packages/k6/src/index.ts +++ b/packages/k6/src/index.ts @@ -48,6 +48,9 @@ export const plugin: PluginFunction = ( return { prepend: visitor.getImports(), content: [ + ...(visitor.typedDocumentStringClass + ? [visitor.typedDocumentStringClass] + : []), visitor.fragments, ...visitorResult.definitions.filter((t) => typeof t === "string"), visitor.sdkContent, diff --git a/packages/k6/src/visitor.ts b/packages/k6/src/visitor.ts index e0211c1..81f8986 100644 --- a/packages/k6/src/visitor.ts +++ b/packages/k6/src/visitor.ts @@ -7,6 +7,7 @@ import { } from "@graphql-codegen/visitor-plugin-common"; import autoBind from "auto-bind"; import { + type FragmentDefinitionNode, type GraphQLSchema, Kind, type OperationDefinitionNode, @@ -94,6 +95,54 @@ export class GenericSdkVisitor extends ClientSideBaseVisitor< return imports; } + /** + * With `documentMode: "string"` the documents are emitted as + * `new TypedDocumentString(...)`; type them with the result and variables + * of the operation, the same way `typed-document-node` does. + */ + protected getDocumentNodeSignature( + resultType: string, + variablesTypes: string, + node: FragmentDefinitionNode | OperationDefinitionNode, + ): string { + if ( + this.config.documentMode === DocumentMode.string && + node.kind === Kind.OPERATION_DEFINITION + ) { + return ` as unknown as TypedDocumentString<${this._externalImportPrefix}${resultType}, ${this._externalImportPrefix}${variablesTypes}>`; + } + return super.getDocumentNodeSignature(resultType, variablesTypes, node); + } + + /** + * The `TypedDocumentString` class used by `documentMode: "string"`. This is + * the same class as generated by `typed-document-node` and the client + * preset, without the dependency on `@graphql-typed-document-node/core` + * (the `__apiType` property is structurally compatible with its + * `DocumentTypeDecoration`). + */ + public get typedDocumentStringClass(): string { + if (this.config.documentMode !== DocumentMode.string) { + return ""; + } + return `export class TypedDocumentString extends String { + __apiType?: (variables: TVariables) => TResult; + private value: string; + public __meta__?: Record | undefined; + + constructor(value: string, __meta__?: Record | undefined) { + super(value); + this.value = value; + this.__meta__ = __meta__; + } + + override toString(): string { + return this.value; + } +} +`; + } + private getDocumentNodeVariable(documentVariableName: string): string { return this.config.documentMode === DocumentMode.external ? `Operations.${documentVariableName}` @@ -125,7 +174,7 @@ export class GenericSdkVisitor extends ClientSideBaseVisitor< const documentNodeType = this.config.documentMode === DocumentMode.string - ? "string" + ? "TypedDocumentString" : "DocumentNode"; return ` From a779c99d3796c0dbbd8b6ce2d6b2817bee077aae Mon Sep 17 00:00:00 2001 From: Michael van Tellingen Date: Mon, 26 Oct 2026 07:25:41 +0100 Subject: [PATCH 03/14] test(k6): test generated output and type check it Replace the dummy test with snapshots of the generated output for every documentMode, and compile the output of typescript-operations plus this plugin, together with a k6 script using the SDK, with tsc. --- .gitignore | 1 + packages/k6/package.json | 4 + packages/k6/src/__mocks__/operations.graphql | 34 ++ packages/k6/src/__mocks__/schema.graphqls | 33 ++ .../k6/src/__snapshots__/index.test.ts.snap | 329 ++++++++++++++++++ packages/k6/src/index.test.ts | 264 +++++++++++++- packages/k6/tsconfig.json | 3 +- packages/k6/vitest.config.ts | 25 ++ pnpm-lock.yaml | 52 ++- 9 files changed, 737 insertions(+), 8 deletions(-) create mode 100644 packages/k6/src/__mocks__/operations.graphql create mode 100644 packages/k6/src/__mocks__/schema.graphqls create mode 100644 packages/k6/src/__snapshots__/index.test.ts.snap create mode 100644 packages/k6/vitest.config.ts diff --git a/.gitignore b/.gitignore index 49a976f..6bde798 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ dist/* test-reports/* .turbo/ +.typecheck/ diff --git a/packages/k6/package.json b/packages/k6/package.json index 722a239..8cab06c 100644 --- a/packages/k6/package.json +++ b/packages/k6/package.json @@ -37,7 +37,11 @@ "auto-bind": "5.0.1" }, "devDependencies": { + "@graphql-codegen/typescript-operations": "^6.1.10", + "@types/k6": "^2.3.0", + "@types/node": "^26.6.5", "graphql": "^17.0.2", + "graphql-tag": "^2.0.0", "publint": "^0.3.25", "tsdown": "^0.23.1", "tslib": "2.8.1", diff --git a/packages/k6/src/__mocks__/operations.graphql b/packages/k6/src/__mocks__/operations.graphql new file mode 100644 index 0000000..d2d75fc --- /dev/null +++ b/packages/k6/src/__mocks__/operations.graphql @@ -0,0 +1,34 @@ +query GetProduct($id: ID!, $locale: Locale) { + product(id: $id, locale: $locale) { + ...ProductFields + } +} + +query ListProducts($filter: ProductFilter) { + products(filter: $filter) { + ...ProductFields + } +} + +mutation AddToCart($productId: ID!, $quantity: Int!) { + addToCart(productId: $productId, quantity: $quantity) { + id + totalItems + } +} + +subscription CartUpdated($cartId: ID!) { + cartUpdated(cartId: $cartId) { + id + } +} + +fragment ProductFields on Product { + id + name + ...ProductPrice +} + +fragment ProductPrice on Product { + price +} diff --git a/packages/k6/src/__mocks__/schema.graphqls b/packages/k6/src/__mocks__/schema.graphqls new file mode 100644 index 0000000..f9ce7bd --- /dev/null +++ b/packages/k6/src/__mocks__/schema.graphqls @@ -0,0 +1,33 @@ +type Query { + product(id: ID!, locale: Locale): Product + products(filter: ProductFilter): [Product!]! +} + +type Mutation { + addToCart(productId: ID!, quantity: Int!): Cart! +} + +type Subscription { + cartUpdated(cartId: ID!): Cart! +} + +enum Locale { + NL + EN +} + +input ProductFilter { + query: String + locale: Locale +} + +type Product { + id: ID! + name: String! + price: Float! +} + +type Cart { + id: ID! + totalItems: Int! +} diff --git a/packages/k6/src/__snapshots__/index.test.ts.snap b/packages/k6/src/__snapshots__/index.test.ts.snap new file mode 100644 index 0000000..766085f --- /dev/null +++ b/packages/k6/src/__snapshots__/index.test.ts.snap @@ -0,0 +1,329 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`k6 plugin > documentMode documentNode (useTypeImports: false) > generates the sdk 1`] = ` +"import { type RefinedResponse, type ResponseType } from "k6/http"; +import { DocumentNode } from 'graphql'; +export const ProductPriceFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProductPrice"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Product"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"}}]}}]} as unknown as DocumentNode; +export const ProductFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProductFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Product"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProductPrice"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProductPrice"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Product"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"}}]}}]} as unknown as DocumentNode; +export const GetProductDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProduct"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"locale"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Locale"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"product"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"locale"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProductFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProductFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Product"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProductPrice"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProductPrice"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Product"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"}}]}}]} as unknown as DocumentNode; +export const ListProductsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ListProducts"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ProductFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"products"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProductFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProductFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Product"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProductPrice"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProductPrice"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Product"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"}}]}}]} as unknown as DocumentNode; +export const AddToCartDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AddToCart"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"productId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"quantity"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addToCart"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"productId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"productId"}}},{"kind":"Argument","name":{"kind":"Name","value":"quantity"},"value":{"kind":"Variable","name":{"kind":"Name","value":"quantity"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"totalItems"}}]}}]}}]} as unknown as DocumentNode; +export const CartUpdatedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"CartUpdated"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"cartId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cartUpdated"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"cartId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"cartId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; + + export type Requester = (doc: DocumentNode, vars?: V, options?: C) => RefinedResponse + + export function getSdk(requester: Requester) { + return { + GetProduct(variables: GetProductQueryVariables, options?: C): RefinedResponse { + return requester(GetProductDocument, variables, options); + }, + ListProducts(variables?: ListProductsQueryVariables, options?: C): RefinedResponse { + return requester(ListProductsDocument, variables, options); + }, + AddToCart(variables: AddToCartMutationVariables, options?: C): RefinedResponse { + return requester(AddToCartDocument, variables, options); + }, + CartUpdated(variables: CartUpdatedSubscriptionVariables, options?: C): RefinedResponse { + return requester(CartUpdatedDocument, variables, options); + } + }; + } + export type Sdk = ReturnType;" +`; + +exports[`k6 plugin > documentMode documentNodeImportFragments (useTypeImports: false) > generates the sdk 1`] = ` +"import { type RefinedResponse, type ResponseType } from "k6/http"; +import { DocumentNode } from 'graphql'; +export const ProductPriceFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProductPrice"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Product"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"}}]}}]} as unknown as DocumentNode; +export const ProductFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProductFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Product"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProductPrice"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProductPrice"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Product"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"}}]}}]} as unknown as DocumentNode; +export const GetProductDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProduct"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"locale"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Locale"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"product"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"locale"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProductFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProductPrice"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Product"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProductFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Product"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProductPrice"}}]}}]} as unknown as DocumentNode; +export const ListProductsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ListProducts"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ProductFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"products"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProductFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProductPrice"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Product"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProductFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Product"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProductPrice"}}]}}]} as unknown as DocumentNode; +export const AddToCartDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AddToCart"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"productId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"quantity"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addToCart"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"productId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"productId"}}},{"kind":"Argument","name":{"kind":"Name","value":"quantity"},"value":{"kind":"Variable","name":{"kind":"Name","value":"quantity"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"totalItems"}}]}}]}}]} as unknown as DocumentNode; +export const CartUpdatedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"CartUpdated"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"cartId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cartUpdated"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"cartId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"cartId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; + + export type Requester = (doc: DocumentNode, vars?: V, options?: C) => RefinedResponse + + export function getSdk(requester: Requester) { + return { + GetProduct(variables: GetProductQueryVariables, options?: C): RefinedResponse { + return requester(GetProductDocument, variables, options); + }, + ListProducts(variables?: ListProductsQueryVariables, options?: C): RefinedResponse { + return requester(ListProductsDocument, variables, options); + }, + AddToCart(variables: AddToCartMutationVariables, options?: C): RefinedResponse { + return requester(AddToCartDocument, variables, options); + }, + CartUpdated(variables: CartUpdatedSubscriptionVariables, options?: C): RefinedResponse { + return requester(CartUpdatedDocument, variables, options); + } + }; + } + export type Sdk = ReturnType;" +`; + +exports[`k6 plugin > documentMode external > generates the sdk 1`] = ` +"import { type RefinedResponse, type ResponseType } from "k6/http"; +import * as Operations from './operations'; +import { DocumentNode } from 'graphql'; + + + + + + + export type Requester = (doc: DocumentNode, vars?: V, options?: C) => RefinedResponse + + export function getSdk(requester: Requester) { + return { + GetProduct(variables: GetProductQueryVariables, options?: C): RefinedResponse { + return requester(Operations.GetProductDocument, variables, options); + }, + ListProducts(variables?: ListProductsQueryVariables, options?: C): RefinedResponse { + return requester(Operations.ListProductsDocument, variables, options); + }, + AddToCart(variables: AddToCartMutationVariables, options?: C): RefinedResponse { + return requester(Operations.AddToCartDocument, variables, options); + }, + CartUpdated(variables: CartUpdatedSubscriptionVariables, options?: C): RefinedResponse { + return requester(Operations.CartUpdatedDocument, variables, options); + } + }; + } + export type Sdk = ReturnType;" +`; + +exports[`k6 plugin > documentMode graphQLTag (useTypeImports: false) > generates the sdk 1`] = ` +"import { type RefinedResponse, type ResponseType } from "k6/http"; +import gql from 'graphql-tag'; +import { DocumentNode } from 'graphql'; +export const ProductPriceFragmentDoc = gql\` + fragment ProductPrice on Product { + price +} + \`; +export const ProductFieldsFragmentDoc = gql\` + fragment ProductFields on Product { + id + name + ...ProductPrice +} + \`; +export const GetProductDocument = gql\` + query GetProduct($id: ID!, $locale: Locale) { + product(id: $id, locale: $locale) { + ...ProductFields + } +} + \${ProductFieldsFragmentDoc} +\${ProductPriceFragmentDoc}\`; +export const ListProductsDocument = gql\` + query ListProducts($filter: ProductFilter) { + products(filter: $filter) { + ...ProductFields + } +} + \${ProductFieldsFragmentDoc} +\${ProductPriceFragmentDoc}\`; +export const AddToCartDocument = gql\` + mutation AddToCart($productId: ID!, $quantity: Int!) { + addToCart(productId: $productId, quantity: $quantity) { + id + totalItems + } +} + \`; +export const CartUpdatedDocument = gql\` + subscription CartUpdated($cartId: ID!) { + cartUpdated(cartId: $cartId) { + id + } +} + \`; + + export type Requester = (doc: DocumentNode, vars?: V, options?: C) => RefinedResponse + + export function getSdk(requester: Requester) { + return { + GetProduct(variables: GetProductQueryVariables, options?: C): RefinedResponse { + return requester(GetProductDocument, variables, options); + }, + ListProducts(variables?: ListProductsQueryVariables, options?: C): RefinedResponse { + return requester(ListProductsDocument, variables, options); + }, + AddToCart(variables: AddToCartMutationVariables, options?: C): RefinedResponse { + return requester(AddToCartDocument, variables, options); + }, + CartUpdated(variables: CartUpdatedSubscriptionVariables, options?: C): RefinedResponse { + return requester(CartUpdatedDocument, variables, options); + } + }; + } + export type Sdk = ReturnType;" +`; + +exports[`k6 plugin > documentMode graphQLTag (useTypeImports: true) > generates the sdk 1`] = ` +"import { type RefinedResponse, type ResponseType } from "k6/http"; +import gql from 'graphql-tag'; +import type { DocumentNode } from 'graphql'; +export const ProductPriceFragmentDoc = gql\` + fragment ProductPrice on Product { + price +} + \`; +export const ProductFieldsFragmentDoc = gql\` + fragment ProductFields on Product { + id + name + ...ProductPrice +} + \`; +export const GetProductDocument = gql\` + query GetProduct($id: ID!, $locale: Locale) { + product(id: $id, locale: $locale) { + ...ProductFields + } +} + \${ProductFieldsFragmentDoc} +\${ProductPriceFragmentDoc}\`; +export const ListProductsDocument = gql\` + query ListProducts($filter: ProductFilter) { + products(filter: $filter) { + ...ProductFields + } +} + \${ProductFieldsFragmentDoc} +\${ProductPriceFragmentDoc}\`; +export const AddToCartDocument = gql\` + mutation AddToCart($productId: ID!, $quantity: Int!) { + addToCart(productId: $productId, quantity: $quantity) { + id + totalItems + } +} + \`; +export const CartUpdatedDocument = gql\` + subscription CartUpdated($cartId: ID!) { + cartUpdated(cartId: $cartId) { + id + } +} + \`; + + export type Requester = (doc: DocumentNode, vars?: V, options?: C) => RefinedResponse + + export function getSdk(requester: Requester) { + return { + GetProduct(variables: GetProductQueryVariables, options?: C): RefinedResponse { + return requester(GetProductDocument, variables, options); + }, + ListProducts(variables?: ListProductsQueryVariables, options?: C): RefinedResponse { + return requester(ListProductsDocument, variables, options); + }, + AddToCart(variables: AddToCartMutationVariables, options?: C): RefinedResponse { + return requester(AddToCartDocument, variables, options); + }, + CartUpdated(variables: CartUpdatedSubscriptionVariables, options?: C): RefinedResponse { + return requester(CartUpdatedDocument, variables, options); + } + }; + } + export type Sdk = ReturnType;" +`; + +exports[`k6 plugin > documentMode string > generates the sdk 1`] = ` +"import { type RefinedResponse, type ResponseType } from "k6/http"; +export class TypedDocumentString extends String { + __apiType?: (variables: TVariables) => TResult; + private value: string; + public __meta__?: Record | undefined; + + constructor(value: string, __meta__?: Record | undefined) { + super(value); + this.value = value; + this.__meta__ = __meta__; + } + + override toString(): string { + return this.value; + } +} + +export const ProductPriceFragmentDoc = new TypedDocumentString(\` + fragment ProductPrice on Product { + price +} + \`, {"fragmentName":"ProductPrice"}); +export const ProductFieldsFragmentDoc = new TypedDocumentString(\` + fragment ProductFields on Product { + id + name + ...ProductPrice +} + fragment ProductPrice on Product { + price +}\`, {"fragmentName":"ProductFields"}); +export const GetProductDocument = new TypedDocumentString(\` + query GetProduct($id: ID!, $locale: Locale) { + product(id: $id, locale: $locale) { + ...ProductFields + } +} + fragment ProductFields on Product { + id + name + ...ProductPrice +} +fragment ProductPrice on Product { + price +}\`) as unknown as TypedDocumentString; +export const ListProductsDocument = new TypedDocumentString(\` + query ListProducts($filter: ProductFilter) { + products(filter: $filter) { + ...ProductFields + } +} + fragment ProductFields on Product { + id + name + ...ProductPrice +} +fragment ProductPrice on Product { + price +}\`) as unknown as TypedDocumentString; +export const AddToCartDocument = new TypedDocumentString(\` + mutation AddToCart($productId: ID!, $quantity: Int!) { + addToCart(productId: $productId, quantity: $quantity) { + id + totalItems + } +} + \`) as unknown as TypedDocumentString; +export const CartUpdatedDocument = new TypedDocumentString(\` + subscription CartUpdated($cartId: ID!) { + cartUpdated(cartId: $cartId) { + id + } +} + \`) as unknown as TypedDocumentString; + + export type Requester = (doc: TypedDocumentString, vars?: V, options?: C) => RefinedResponse + + export function getSdk(requester: Requester) { + return { + GetProduct(variables: GetProductQueryVariables, options?: C): RefinedResponse { + return requester(GetProductDocument, variables, options); + }, + ListProducts(variables?: ListProductsQueryVariables, options?: C): RefinedResponse { + return requester(ListProductsDocument, variables, options); + }, + AddToCart(variables: AddToCartMutationVariables, options?: C): RefinedResponse { + return requester(AddToCartDocument, variables, options); + }, + CartUpdated(variables: CartUpdatedSubscriptionVariables, options?: C): RefinedResponse { + return requester(CartUpdatedDocument, variables, options); + } + }; + } + export type Sdk = ReturnType;" +`; diff --git a/packages/k6/src/index.test.ts b/packages/k6/src/index.test.ts index c33c5f3..6489b49 100644 --- a/packages/k6/src/index.test.ts +++ b/packages/k6/src/index.test.ts @@ -1,7 +1,263 @@ -import { describe, expect, it } from "vitest"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import type { Types } from "@graphql-codegen/plugin-helpers"; +import { plugin as typescriptOperationsPlugin } from "@graphql-codegen/typescript-operations"; +import { DocumentMode } from "@graphql-codegen/visitor-plugin-common"; +import { buildSchema, parse } from "graphql"; +import { afterAll, describe, expect, it } from "vitest"; +import type { RawK6PluginConfig } from "./config"; +import { plugin } from "./index"; -describe("dummy", () => { - it("test", () => { - expect(true).toBe(true); +const packageDir = path.join(__dirname, ".."); +const mocksDir = path.join(__dirname, "__mocks__"); +// Inside the package so that the fixture resolves `graphql`, `graphql-tag` +// and `@types/k6` from its node_modules (ignored by git). +const typecheckDir = path.join(packageDir, ".typecheck"); + +const schema = buildSchema( + fs.readFileSync(path.join(mocksDir, "schema.graphqls"), "utf-8"), +); +const documents: Types.DocumentFile[] = [ + { + location: path.join(mocksDir, "operations.graphql"), + document: parse( + fs.readFileSync(path.join(mocksDir, "operations.graphql"), "utf-8"), + ), + }, +]; + +const outputToString = (output: Types.PluginOutput): string => { + if (typeof output === "string") { + return output; + } + return [...(output.prepend ?? []), output.content, ...(output.append ?? [])] + .filter(Boolean) + .join("\n"); +}; + +const generateK6 = async (config: RawK6PluginConfig) => + outputToString( + await plugin(schema, documents, config, { outputFile: "sdk.ts" }), + ); + +/** The output of `typescript-operations` followed by the k6 plugin. */ +const generateSdk = async (config: RawK6PluginConfig) => { + const operations = outputToString( + await typescriptOperationsPlugin(schema, documents, config, { + outputFile: "sdk.ts", + }), + ); + return `${operations}\n${await generateK6(config)}`; +}; + +const tsconfig = { + compilerOptions: { + target: "ES2022", + lib: ["ES2022"], + module: "ESNext", + moduleResolution: "bundler", + strict: true, + noEmit: true, + skipLibCheck: true, + types: ["k6"], + }, + include: ["*.ts"], +}; + +const documentNodeUsage = ` +import { print } from "graphql"; +import http, { type Params } from "k6/http"; +import { getSdk } from "./sdk"; + +const sdk = getSdk((doc, variables, options) => + http.post("http://localhost/graphql", JSON.stringify({ query: print(doc), variables }), options), +); +`; + +const stringUsage = ` +import http, { type Params } from "k6/http"; +import { getSdk } from "./sdk"; + +const sdk = getSdk((doc, variables, options) => + http.post("http://localhost/graphql", JSON.stringify({ query: doc, variables }), options), +); +`; + +const sdkCalls = ` +export default function () { + sdk.GetProduct({ id: "1", locale: "NL" }, { tags: { name: "GetProduct" } }); + sdk.ListProducts(); + sdk.ListProducts({ filter: { query: "shoe" } }); + sdk.AddToCart({ productId: "1", quantity: 2 }); + sdk.CartUpdated({ cartId: "1" }); + // @ts-expect-error required variables are missing + sdk.GetProduct(); + // @ts-expect-error unknown enum value + sdk.GetProduct({ id: "1", locale: "DE" }); +} +`; + +const typecheck = (name: string, files: Record) => { + const dir = path.join(typecheckDir, name); + fs.rmSync(dir, { recursive: true, force: true }); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "tsconfig.json"), JSON.stringify(tsconfig)); + for (const [file, content] of Object.entries(files)) { + fs.writeFileSync(path.join(dir, file), content); + } + try { + execFileSync( + path.join(packageDir, "node_modules", ".bin", "tsc"), + ["-p", dir], + { encoding: "utf-8", stdio: "pipe" }, + ); + } catch (error) { + const { stdout, stderr } = error as { stdout: string; stderr: string }; + throw new Error(`Type check of ${name} failed:\n${stdout}${stderr}`); + } +}; + +afterAll(() => { + fs.rmSync(typecheckDir, { recursive: true, force: true }); +}); + +describe("k6 plugin", () => { + describe.each([ + { documentMode: DocumentMode.graphQLTag, useTypeImports: false }, + { documentMode: DocumentMode.graphQLTag, useTypeImports: true }, + { documentMode: DocumentMode.documentNode, useTypeImports: false }, + { + documentMode: DocumentMode.documentNodeImportFragments, + useTypeImports: false, + }, + ])( + "documentMode $documentMode (useTypeImports: $useTypeImports)", + (config) => { + it("generates the sdk", async () => { + const output = await generateK6(config); + expect(output).toMatchSnapshot(); + + const documentNodeImport = config.useTypeImports + ? "import type { DocumentNode } from 'graphql';" + : "import { DocumentNode } from 'graphql';"; + expect( + output.split("\n").filter((line) => line === documentNodeImport), + ).toHaveLength(1); + expect(output).toContain("(doc: DocumentNode, vars?: V"); + }); + + it("type checks", { timeout: 30_000 }, async () => { + const name = `${config.documentMode}${config.useTypeImports ? "-type-imports" : ""}`; + typecheck(name, { + "sdk.ts": await generateSdk(config), + "script.ts": documentNodeUsage + sdkCalls, + }); + }); + }, + ); + + describe("documentMode string", () => { + const config = { documentMode: DocumentMode.string }; + + it("generates the sdk", async () => { + const output = await generateK6(config); + expect(output).toMatchSnapshot(); + + expect(output).toContain( + "export class TypedDocumentString extends String", + ); + expect(output).toContain( + "as unknown as TypedDocumentString;", + ); + expect(output).toContain("(doc: TypedDocumentString, vars?: V"); + expect(output).not.toContain("DocumentNode"); + }); + + it("type checks", { timeout: 30_000 }, async () => { + typecheck("string", { + "sdk.ts": await generateSdk(config), + "script.ts": stringUsage + sdkCalls, + }); + }); + + it("serializes the document as the query string", async () => { + const output = await generateK6(config); + const source = output.slice( + output.indexOf("export class TypedDocumentString"), + output.indexOf("export const"), + ); + // Let vite transpile the generated class and evaluate it + const dir = path.join(typecheckDir, "runtime"); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, "typed-document-string.ts"); + fs.writeFileSync(file, source); + const { TypedDocumentString } = await import(file); + const doc = new TypedDocumentString("query A { a }"); + expect(JSON.stringify({ query: doc })).toBe('{"query":"query A { a }"}'); + expect(String(doc)).toBe("query A { a }"); + }); + }); + + describe("documentMode external", () => { + const config = { + documentMode: DocumentMode.external, + importDocumentNodeExternallyFrom: "./operations", + }; + + it("generates the sdk", async () => { + const output = await generateK6(config); + expect(output).toMatchSnapshot(); + expect(output).toContain("import * as Operations from './operations';"); + expect(output).toContain("import { DocumentNode } from 'graphql';"); + }); + + it("type checks", { timeout: 30_000 }, async () => { + typecheck("external", { + "operations.ts": await generateSdk({ + documentMode: DocumentMode.documentNode, + }), + "sdk.ts": await generateSdk(config), + "script.ts": documentNodeUsage + sdkCalls, + }); + }); + }); + + it("only generates documents and the sdk, no schema types", async () => { + // typescript-operations v6 generates the enum and input types it needs; + // the k6 plugin must not declare any of them (again). + const output = await generateK6({}); + const declarations = [ + ...output.matchAll( + /^\s*export (?:type|enum|const|class|function) (\w+)/gm, + ), + ].map((match) => match[1]); + expect(declarations).toEqual([ + "ProductPriceFragmentDoc", + "ProductFieldsFragmentDoc", + "GetProductDocument", + "ListProductsDocument", + "AddToCartDocument", + "CartUpdatedDocument", + "Requester", + "getSdk", + "Sdk", + ]); + }); + + it("rejects unnamed operations", async () => { + expect(() => + plugin( + schema, + [ + { + location: "anonymous.graphql", + document: parse("{ products { id } }"), + }, + ], + {}, + { outputFile: "sdk.ts" }, + ), + ).toThrow(/cannot generate SDK for unnamed operation/); }); }); diff --git a/packages/k6/tsconfig.json b/packages/k6/tsconfig.json index 876f1c0..5e8378f 100644 --- a/packages/k6/tsconfig.json +++ b/packages/k6/tsconfig.json @@ -6,7 +6,8 @@ "outDir": "./dist", "esModuleInterop": true, "skipLibCheck": true, - "strict": true + "strict": true, + "types": ["node"] }, "include": ["src/**/*.ts"] } diff --git a/packages/k6/vitest.config.ts b/packages/k6/vitest.config.ts new file mode 100644 index 0000000..5050228 --- /dev/null +++ b/packages/k6/vitest.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + // graphql 17 exposes a separate "development" build; make sure the test + // files and the (externalized) graphql-tools loaders share one instance. + ssr: { + resolve: { + conditions: ["module-sync", "node", "import", "default"], + externalConditions: ["module-sync", "node"], + }, + }, + test: { + testTimeout: 30000, + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + }, + passWithNoTests: true, + server: { + deps: { + fallbackCJS: true, + }, + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d1f270..ccf993c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -236,13 +236,22 @@ importers: auto-bind: specifier: 5.0.1 version: 5.0.1 - graphql-tag: - specifier: ^2.0.0 - version: 2.12.7(graphql@17.0.2) devDependencies: + '@graphql-codegen/typescript-operations': + specifier: ^6.1.10 + version: 6.1.10(graphql@17.0.2) + '@types/k6': + specifier: ^2.3.0 + version: 2.3.0 + '@types/node': + specifier: ^26.6.5 + version: 26.6.5 graphql: specifier: ^17.0.2 version: 17.0.2 + graphql-tag: + specifier: ^2.0.0 + version: 2.12.7(graphql@17.0.2) publint: specifier: ^0.3.25 version: 0.3.25 @@ -444,6 +453,22 @@ packages: peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + '@graphql-codegen/schema-ast@6.1.1': + resolution: {integrity: sha512-9THb+iRVIDdRBVBL/FIAXIVXx++XkGxynqVCco7F5lKY4NYy07fB4oRcLGIpKuLGNeGNcRkTaQ3x7DNyojbC+A==} + engines: {node: '>=16'} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-codegen/typescript-operations@6.1.10': + resolution: {integrity: sha512-hTnwAwQI+/lV0mAlWV0Jg7jBo2XCC++8+njr0meDHQbeAJSH6Pew1ZIcmLVvQUsQNSQwNoR4SKftHbDvq6KoNA==} + engines: {node: '>=16'} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + graphql-sock: ^1.0.0 + peerDependenciesMeta: + graphql-sock: + optional: true + '@graphql-codegen/visitor-plugin-common@7.2.10': resolution: {integrity: sha512-o1a71JUeMNfEwxr7Vzokd3ufvrESL4+xhwJFzcBkc8Nurr5eAELi5gNUFQOUsEsU4169PEBIOzSL4iqO8OpBag==} engines: {node: '>=16'} @@ -689,6 +714,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/k6@2.3.0': + resolution: {integrity: sha512-kEiRQx9MRQZ0vAwWgZUyYvuEc1JC++OZbH9uHVLsSOIKFLwHz8UeIV1EuIlB4Fqn03A4ZM+E624zVsvQPAAp6Q==} + '@types/node@26.6.5': resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==} @@ -1806,6 +1834,22 @@ snapshots: import-from: 4.0.0 tslib: 2.8.1 + '@graphql-codegen/schema-ast@6.1.1(graphql@17.0.2)': + dependencies: + '@graphql-codegen/plugin-helpers': 7.4.2(graphql@17.0.2) + '@graphql-tools/utils': 12.0.3(graphql@17.0.2) + graphql: 17.0.2 + tslib: 2.8.1 + + '@graphql-codegen/typescript-operations@6.1.10(graphql@17.0.2)': + dependencies: + '@graphql-codegen/plugin-helpers': 7.4.2(graphql@17.0.2) + '@graphql-codegen/schema-ast': 6.1.1(graphql@17.0.2) + '@graphql-codegen/visitor-plugin-common': 7.2.10(graphql@17.0.2) + auto-bind: 5.0.1 + graphql: 17.0.2 + tslib: 2.8.1 + '@graphql-codegen/visitor-plugin-common@7.2.10(graphql@17.0.2)': dependencies: '@graphql-codegen/plugin-helpers': 7.4.2(graphql@17.0.2) @@ -2003,6 +2047,8 @@ snapshots: '@types/estree@1.0.9': {} + '@types/k6@2.3.0': {} + '@types/node@26.6.5': dependencies: undici-types: 8.9.0 From 935d3447e6b4d974f16bd4aabd97ba0b5c816fae Mon Sep 17 00:00:00 2001 From: Michael van Tellingen Date: Mon, 26 Oct 2026 07:25:47 +0100 Subject: [PATCH 04/14] docs(k6): document the fixed document modes and typescript-operations setup typescript-operations v6 generates the enum and input types itself, so combining it with the typescript plugin duplicates those declarations; this plugin does not emit schema types. Document to use typescript-operations on its own, and document documentMode string. --- packages/k6/README.md | 41 +++++++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/packages/k6/README.md b/packages/k6/README.md index ca367dc..e555262 100644 --- a/packages/k6/README.md +++ b/packages/k6/README.md @@ -22,8 +22,14 @@ pnpm add -D @types/k6 ``` `graphql` and `graphql-tag` are peer dependencies. The plugin only generates -the SDK methods; the operation types it refers to come from -`@graphql-codegen/typescript-operations`. +the operation documents and the SDK methods; the operation types it refers to +come from `@graphql-codegen/typescript-operations`. + +Use `typescript-operations` (v6) on its own, **without** the `typescript` +plugin in the same output: `typescript-operations` v6 generates the enum and +input types the operations need itself, so adding `typescript` results in +duplicate declarations of those types. This plugin does not generate any schema +types. ## Usage @@ -37,7 +43,8 @@ const config: CodegenConfig = { "k6/sdk.ts": { plugins: ["typescript-operations", "@labdigital/graphql-codegen-k6"], config: { - // Embed the operations as DocumentNode objects (see "Notes") + // Embed the operations as DocumentNode objects (the default, + // "graphQLTag", uses gql from graphql-tag) documentMode: "documentNode", useTypeImports: true, }, @@ -105,7 +112,7 @@ The options that affect the generated SDK: | Option | Type | Default | Description | | ------ | ---- | ------- | ----------- | -| `documentMode` | `"graphQLTag" \| "documentNode" \| "documentNodeImportFragments" \| "external" \| "string"` | `"graphQLTag"` | How the operation documents are emitted. `graphQLTag` uses `gql` from `graphql-tag`, `documentNode` embeds them as plain `DocumentNode` objects, `external` imports them from `importDocumentNodeExternallyFrom`. See the notes below. | +| `documentMode` | `"graphQLTag" \| "documentNode" \| "documentNodeImportFragments" \| "external" \| "string"` | `"graphQLTag"` | How the operation documents are emitted. `graphQLTag` uses `gql` from `graphql-tag`, `documentNode` embeds them as plain `DocumentNode` objects, `external` imports them from `importDocumentNodeExternallyFrom`, `string` emits them as `TypedDocumentString` (see [String documents](#string-documents)). | | `importDocumentNodeExternallyFrom` | `string` | `""` | Module to import the documents from when `documentMode` is `external`. The SDK then refers to them as `Operations.Document`. | | `importOperationTypesFrom` | `string` | `""` | Namespace prefix for the operation result and variables types used in the SDK (for example `Types` results in `Types.GetProductQuery`). Used by presets such as `near-operation-file`. | | `gqlImport` | `string` | `"graphql-tag#gql"` | Module (and export) to import `gql` from, when `documentMode` is `graphQLTag`. | @@ -131,6 +138,21 @@ When the plugin runs in the same output as `typescript-operations` (as in the example above), shared options such as `namingConvention` can be set once in the `config` of the output. +### String documents + +With `documentMode: "string"` the operations are emitted as instances of a +`TypedDocumentString` class (a `String` subclass, the same class as generated +by `typed-document-node` and the client preset), which the plugin generates in +the output file. The requester receives the document as a +`TypedDocumentString`; it serializes to the query string, so you do not need +`graphql` at runtime: + +```ts +const sdk = getSdk((doc, variables, options) => + http.post(endpoint, JSON.stringify({ query: doc, variables }), options), +); +``` + ## Example output For this operation: @@ -185,11 +207,6 @@ variables (such as `ListProducts`). - Every operation must be named; codegen fails on unnamed operations. - The SDK methods return the raw k6 `RefinedResponse`; the response body is not typed or parsed. Use `res.json()` to read the result. -- Use `documentMode: "documentNode"` (as above). With the default - `graphQLTag` mode the generated `Requester` type refers to `DocumentNode` - without importing it, so the file does not type check. `documentMode: - "string"` is not supported: the generated code uses a `TypedDocumentString` - class that is not emitted. -- Use `typescript-operations` without the `typescript` plugin in the same - output: `typescript-operations` v6 already generates the input and enum - types it needs, and combining both results in duplicate type declarations. +- The generated `Requester` type refers to `DocumentNode` (or + `TypedDocumentString` with `documentMode: "string"`); the plugin imports it + from `documentNodeImport` (default `graphql`). From e54a976b337c3d799df093113bf208aa919ff85f Mon Sep 17 00:00:00 2001 From: Michael van Tellingen Date: Mon, 26 Oct 2026 07:26:00 +0100 Subject: [PATCH 05/14] fix(bruno): only include used fragments per operation Every request embedded the complete source document (all operations and fragments in it), followed by the used fragments, so fragments defined in the same document were included twice. Emit only the operation itself followed by the fragments it transitively uses, each once. --- .../src/__snapshots__/queries/GetCustomer.bru | 11 -- packages/bruno/src/operations.test.ts | 64 +++++++- packages/bruno/src/operations.ts | 142 +++++++++--------- 3 files changed, 129 insertions(+), 88 deletions(-) diff --git a/packages/bruno/src/__snapshots__/queries/GetCustomer.bru b/packages/bruno/src/__snapshots__/queries/GetCustomer.bru index ebbb145..a999e0f 100644 --- a/packages/bruno/src/__snapshots__/queries/GetCustomer.bru +++ b/packages/bruno/src/__snapshots__/queries/GetCustomer.bru @@ -28,17 +28,6 @@ body:graphql { id city } - fragment CustomerDetails on Customer { - firstName - email - addresses { - ...AddressDetails - } - } - fragment AddressDetails on Address { - id - city - } } diff --git a/packages/bruno/src/operations.test.ts b/packages/bruno/src/operations.test.ts index 1dfac6a..78e1e27 100644 --- a/packages/bruno/src/operations.test.ts +++ b/packages/bruno/src/operations.test.ts @@ -1,7 +1,65 @@ +import { type FragmentDefinitionNode, Kind, parse } from "graphql"; import { describe, expect, it } from "vitest"; +import { collectUsedFragments } from "./operations"; -describe("operations", () => { - it("should add two numbers", () => { - expect(1 + 1).toBe(2); +const document = parse(` + query GetCustomer { + customer { + ...A + addresses { + ...B + } + ... on Customer { + ...C + } + } + } + + fragment A on Customer { id ...B } + fragment B on Address { id ...D } + fragment C on Customer { id ...A } + fragment D on Address { city } + fragment Unused on Customer { id } + fragment Cyclic1 on Customer { ...Cyclic2 } + fragment Cyclic2 on Customer { ...Cyclic1 } +`); + +const fragments = new Map( + document.definitions + .filter((d) => d.kind === Kind.FRAGMENT_DEFINITION) + .map((d) => [d.name.value, d as FragmentDefinitionNode]), +); + +describe("collectUsedFragments", () => { + it("returns the transitively used fragments once, in order of use", () => { + const operation = document.definitions[0]; + if (operation.kind !== Kind.OPERATION_DEFINITION) { + throw new Error("Expected an operation"); + } + expect(collectUsedFragments(operation, fragments)).toEqual([ + "A", + "B", + "D", + "C", + ]); + }); + + it("handles cyclic fragments", () => { + const fragment = fragments.get("Cyclic1"); + if (!fragment) { + throw new Error("Expected a fragment"); + } + expect(collectUsedFragments(fragment, fragments)).toEqual([ + "Cyclic2", + "Cyclic1", + ]); + }); + + it("includes unknown fragments by name", () => { + const operation = parse("{ ...External }").definitions[0]; + if (operation.kind !== Kind.OPERATION_DEFINITION) { + throw new Error("Expected an operation"); + } + expect(collectUsedFragments(operation, fragments)).toEqual(["External"]); }); }); diff --git a/packages/bruno/src/operations.ts b/packages/bruno/src/operations.ts index e2db23d..8ee4c51 100644 --- a/packages/bruno/src/operations.ts +++ b/packages/bruno/src/operations.ts @@ -1,106 +1,100 @@ -import assert from "node:assert"; import type { Types } from "@graphql-codegen/plugin-helpers"; import { type FragmentDefinitionNode, type GraphQLSchema, Kind, type OperationDefinitionNode, + type OperationTypeNode, print, + visit, } from "graphql"; import { generateExampleVariables } from "./vars"; -export interface BruPluginConfig { - outputDir?: string; -} - export type FileContent = { - kind: string | undefined; + kind: OperationTypeNode; name: string; content: string; vars: Record; location: string | undefined; }; +/** + * Returns the names of the fragments used by the given node, including the + * fragments used by those fragments, in order of first use. Every fragment is + * listed once. + */ +export const collectUsedFragments = ( + node: OperationDefinitionNode | FragmentDefinitionNode, + fragments: ReadonlyMap, +): string[] => { + const used = new Set(); + + const collect = ( + current: OperationDefinitionNode | FragmentDefinitionNode, + ) => { + visit(current, { + FragmentSpread(spread) { + const name = spread.name.value; + if (used.has(name)) { + return; + } + used.add(name); + const fragment = fragments.get(name); + if (fragment) { + collect(fragment); + } + }, + }); + }; + + collect(node); + return [...used]; +}; + export const extractOperations = ( schema: GraphQLSchema, documents: Types.DocumentFile[], ): FileContent[] => { - const results: FileContent[] = []; - - // Step 1: Collect all fragments - const allFragments = new Map(); - + // Collect the fragments of all documents, so operations can use fragments + // defined in other documents. + const fragments = new Map(); for (const doc of documents) { - if (doc.document) { - for (const definition of doc.document.definitions) { - if (definition.kind === Kind.FRAGMENT_DEFINITION) { - const fragmentName = definition.name.value; - // allFragments[fragmentName] = definition as FragmentDefinitionNode; - allFragments.set(fragmentName, definition as FragmentDefinitionNode); - } + for (const definition of doc.document?.definitions ?? []) { + if (definition.kind === Kind.FRAGMENT_DEFINITION) { + fragments.set(definition.name.value, definition); } } } - // Step 2: Process operations and append only used fragments + const results: FileContent[] = []; for (const doc of documents) { - if (doc.document) { - const operations = doc.document.definitions - .filter((def) => def.kind === Kind.OPERATION_DEFINITION) - .filter((operation) => operation.name?.value !== undefined) // Filter out unnamed operations - .map((operation): FileContent => { - assert(operation.name?.value); - - const operationString = doc.rawSDL || ""; - - // Collect the names of all fragments used in this operation - const usedFragments = new Set(); - const collectFragments = ( - node: OperationDefinitionNode | FragmentDefinitionNode, - ) => { - if (node.selectionSet) { - for (const selection of node.selectionSet.selections) { - if (selection.kind === Kind.FRAGMENT_SPREAD) { - usedFragments.add(selection.name.value); - // Recursively collect fragments within fragments - const fragment = allFragments.get(selection.name.value); - if (fragment) { - collectFragments(fragment); - } - } else if (selection.selectionSet) { - // @ts-expect-error - collectFragments(selection as OperationDefinitionNode); - } - } - } - }; - - collectFragments(operation); - - // Append only the used fragments - let fileContent = operationString; - - for (const fragmentName of usedFragments) { - const fragment = allFragments.get(fragmentName); - if (fragment) { - const fragmentString = print(fragment); - fileContent += `\n${fragmentString}`; - } - } + for (const definition of doc.document?.definitions ?? []) { + // Unnamed operations are skipped + if ( + definition.kind !== Kind.OPERATION_DEFINITION || + !definition.name?.value + ) { + continue; + } - return { - name: operation.name?.value, - location: operation.loc?.source.name, - kind: operation.operation, - content: fileContent, - vars: generateExampleVariables( - schema, - operation as OperationDefinitionNode, - ), - }; - }); + // The request contains the operation itself, followed by the + // fragments it (transitively) uses. + const content = [ + definition, + ...collectUsedFragments(definition, fragments) + .map((name) => fragments.get(name)) + .filter((fragment) => fragment !== undefined), + ] + .map((node) => print(node)) + .join("\n\n"); - results.push(...operations); + results.push({ + name: definition.name.value, + location: definition.loc?.source.name ?? doc.location, + kind: definition.operation, + content, + vars: generateExampleVariables(schema, definition), + }); } } From 224447c23c3553df4353c8d69eef09f7d65aeeb5 Mon Sep 17 00:00:00 2001 From: Michael van Tellingen Date: Mon, 26 Oct 2026 07:26:11 +0100 Subject: [PATCH 06/14] fix(bruno): stop bundling prettier prettier was used at runtime but only a devDependency, so it was bundled into dist (5.0 MB index.js plus a 7.5 MB source map). The request body is now printed with graphql's print, which already formats the operation and fragments, so prettier is no longer needed. dist/index.js is now about 7 kB. --- packages/bruno/package.json | 1 - .../bruno/src/__snapshots__/queries/GetCustomer.bru | 1 - packages/bruno/src/bruno.ts | 11 +++-------- packages/bruno/src/index.ts | 2 +- packages/bruno/tsdown.config.ts | 2 -- pnpm-lock.yaml | 10 ---------- 6 files changed, 4 insertions(+), 23 deletions(-) diff --git a/packages/bruno/package.json b/packages/bruno/package.json index 5c28c24..e536ffc 100644 --- a/packages/bruno/package.json +++ b/packages/bruno/package.json @@ -43,7 +43,6 @@ "@types/node": "^26.6.5", "@vitest/coverage-v8": "5.0.3", "graphql": "^17.0.2", - "prettier": "^3.9.10", "publint": "^0.3.25", "tsdown": "^0.23.1", "typescript": "^7.0.2", diff --git a/packages/bruno/src/__snapshots__/queries/GetCustomer.bru b/packages/bruno/src/__snapshots__/queries/GetCustomer.bru index a999e0f..a812609 100644 --- a/packages/bruno/src/__snapshots__/queries/GetCustomer.bru +++ b/packages/bruno/src/__snapshots__/queries/GetCustomer.bru @@ -28,7 +28,6 @@ body:graphql { id city } - } body:graphql:vars { diff --git a/packages/bruno/src/bruno.ts b/packages/bruno/src/bruno.ts index 5b8944c..785e367 100644 --- a/packages/bruno/src/bruno.ts +++ b/packages/bruno/src/bruno.ts @@ -1,4 +1,3 @@ -import prettier from "prettier"; import type { FileContent } from "./operations"; export interface BrunoPluginConfig { @@ -7,14 +6,10 @@ export interface BrunoPluginConfig { clean: boolean; } -export const asBruno = async ( +export const asBruno = ( operation: FileContent, config: BrunoPluginConfig, -) => { - const formattedContent = await prettier.format(operation.content, { - parser: "graphql", - }); - +): string => { const vars = mergeDefaults(operation.vars, config.defaults); const file = new FileCreator(); @@ -31,7 +26,7 @@ export const asBruno = async ( ), ); - file.addElement("body:graphql", formattedContent.split("\n")); + file.addElement("body:graphql", operation.content.split("\n")); file.addElement( "body:graphql:vars", diff --git a/packages/bruno/src/index.ts b/packages/bruno/src/index.ts index c31daf9..e72f0d4 100644 --- a/packages/bruno/src/index.ts +++ b/packages/bruno/src/index.ts @@ -28,7 +28,7 @@ export const plugin: PluginFunction = async ( const fileName = `${operation.name}.bru`; const outputPath = path.join(outputDir, subpath, fileName); - const formattedContent = await asBruno(operation, config); + const formattedContent = asBruno(operation, config); outputFileSync(outputPath, formattedContent); result[operation.name] = { diff --git a/packages/bruno/tsdown.config.ts b/packages/bruno/tsdown.config.ts index c10773e..17c39bf 100644 --- a/packages/bruno/tsdown.config.ts +++ b/packages/bruno/tsdown.config.ts @@ -13,6 +13,4 @@ export default defineConfig({ outDir: "dist", fixedExtension: false, outputOptions: { codeSplitting: false }, - // prettier is a devDependency and intentionally bundled (same as with tsup) - deps: { onlyBundle: ["prettier"] }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ccf993c..133b216 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -209,9 +209,6 @@ importers: graphql: specifier: ^17.0.2 version: 17.0.2 - prettier: - specifier: ^3.9.10 - version: 3.9.10 publint: specifier: ^0.3.25 version: 0.3.25 @@ -1305,11 +1302,6 @@ packages: resolution: {integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==} engines: {node: ^10 || ^12 || >=14} - prettier@3.9.10: - resolution: {integrity: sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==} - engines: {node: '>=14'} - hasBin: true - publint@0.3.25: resolution: {integrity: sha512-WMMbn/d7JqRlfXQL/JUI4n5skC8cJ5WVr8/wT5mmiYWkUdENkZr1I5rgs2+dQPVRgoJd+JRYtB46MTsuDylw9g==} engines: {node: '>=18'} @@ -2468,8 +2460,6 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.2 - prettier@3.9.10: {} - publint@0.3.25: dependencies: '@publint/pack': 0.1.7 From c1894c651144731743f8cc0911fc615cfdd1bcaa Mon Sep 17 00:00:00 2001 From: Michael van Tellingen Date: Mon, 26 Oct 2026 07:26:20 +0100 Subject: [PATCH 07/14] fix(bruno): write subscriptions to subscriptions/ Subscriptions were written to the mutations/ folder. --- packages/bruno/src/index.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/bruno/src/index.ts b/packages/bruno/src/index.ts index e72f0d4..4d90782 100644 --- a/packages/bruno/src/index.ts +++ b/packages/bruno/src/index.ts @@ -1,7 +1,7 @@ import nodeFs from "node:fs"; import path from "node:path"; import type { PluginFunction, Types } from "@graphql-codegen/plugin-helpers"; -import type { GraphQLSchema } from "graphql"; +import type { GraphQLSchema, OperationTypeNode } from "graphql"; import { asBruno, type BrunoPluginConfig } from "./bruno"; import { extractOperations } from "./operations"; @@ -24,9 +24,12 @@ export const plugin: PluginFunction = async ( ); const result: Record> = {}; for (const operation of operations) { - const subpath = operation.kind === "query" ? "queries" : "mutations"; const fileName = `${operation.name}.bru`; - const outputPath = path.join(outputDir, subpath, fileName); + const outputPath = path.join( + outputDir, + OPERATION_FOLDERS[operation.kind], + fileName, + ); const formattedContent = asBruno(operation, config); @@ -41,6 +44,13 @@ export const plugin: PluginFunction = async ( return JSON.stringify(result, null, 2); }; +/** The folder (in the output directory) per operation type. */ +const OPERATION_FOLDERS: Record = { + query: "queries", + mutation: "mutations", + subscription: "subscriptions", +}; + function emptyDirSync(dirPath: string): void { if (!nodeFs.existsSync(dirPath)) { nodeFs.mkdirSync(dirPath, { recursive: true }); From ad490f3eb17ca82fb70bac119ce07f5124dd40f4 Mon Sep 17 00:00:00 2001 From: Michael van Tellingen Date: Mon, 26 Oct 2026 07:26:39 +0100 Subject: [PATCH 08/14] fix(bruno): only remove generated requests when cleaning clean: true emptied the whole output directory, which removed bruno.json and environments when the output pointed to the collection root. Only remove the .bru files (except folder.bru) in the queries/, mutations/ and subscriptions/ folders the plugin writes to, and those folders when they are empty afterwards. --- packages/bruno/src/index.ts | 42 ++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/packages/bruno/src/index.ts b/packages/bruno/src/index.ts index 4d90782..14e11af 100644 --- a/packages/bruno/src/index.ts +++ b/packages/bruno/src/index.ts @@ -14,9 +14,9 @@ export const plugin: PluginFunction = async ( if (!info?.outputFile) { throw new Error("Output directory not provided"); } - const outputDir = path.dirname(info?.outputFile); + const outputDir = path.dirname(info.outputFile); if (config.clean) { - emptyDirSync(outputDir); + cleanGeneratedFiles(outputDir); } const operations = extractOperations(schema, documents).sort((a, b) => @@ -31,9 +31,7 @@ export const plugin: PluginFunction = async ( fileName, ); - const formattedContent = asBruno(operation, config); - - outputFileSync(outputPath, formattedContent); + outputFileSync(outputPath, asBruno(operation, config)); result[operation.name] = { filename: fileName, source: operation.location @@ -51,16 +49,30 @@ const OPERATION_FOLDERS: Record = { subscription: "subscriptions", }; -function emptyDirSync(dirPath: string): void { - if (!nodeFs.existsSync(dirPath)) { - nodeFs.mkdirSync(dirPath, { recursive: true }); - return; - } - - const files = nodeFs.readdirSync(dirPath); - for (const file of files) { - const filePath = path.join(dirPath, file); - nodeFs.rmSync(filePath, { recursive: true, force: true }); +/** + * Removes the requests generated by a previous run: the `.bru` files in the + * folders this plugin writes to, and those folders when they are empty + * afterwards. Other files, such as `bruno.json`, environments or a + * `folder.bru` with folder settings, are kept. + */ +function cleanGeneratedFiles(outputDir: string): void { + for (const folder of Object.values(OPERATION_FOLDERS)) { + const dirPath = path.join(outputDir, folder); + if (!nodeFs.existsSync(dirPath)) { + continue; + } + for (const entry of nodeFs.readdirSync(dirPath, { withFileTypes: true })) { + if ( + entry.isFile() && + entry.name.endsWith(".bru") && + entry.name !== "folder.bru" + ) { + nodeFs.rmSync(path.join(dirPath, entry.name)); + } + } + if (nodeFs.readdirSync(dirPath).length === 0) { + nodeFs.rmdirSync(dirPath); + } } } From a13f49091547033abb9cd634b077418b657a7a61 Mon Sep 17 00:00:00 2001 From: Michael van Tellingen Date: Mon, 26 Oct 2026 07:26:40 +0100 Subject: [PATCH 09/14] feat(bruno): make the request url and auth mode configurable Add the url and auth options, defaulting to the previously hardcoded {{graphql-gateway}}/graphql and none. Make all options optional and export the BrunoPluginConfig type. --- packages/bruno/src/bruno.ts | 32 ++++++++++++++++++++++++++------ packages/bruno/src/index.ts | 2 ++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/bruno/src/bruno.ts b/packages/bruno/src/bruno.ts index 785e367..72092f1 100644 --- a/packages/bruno/src/bruno.ts +++ b/packages/bruno/src/bruno.ts @@ -1,23 +1,43 @@ import type { FileContent } from "./operations"; +export const DEFAULT_URL = "{{graphql-gateway}}/graphql"; +export const DEFAULT_AUTH = "none"; + export interface BrunoPluginConfig { - defaults: Record; - headers: Record; - clean: boolean; + /** + * Values for operation variables, by variable name. Replaces the generated + * example value of a top-level variable with the same name. + */ + defaults?: Record; + /** Headers added to every request. */ + headers?: Record; + /** + * Remove the previously generated requests (the `.bru` files in the + * `queries/`, `mutations/` and `subscriptions/` folders of the output + * directory) before generating. + */ + clean?: boolean; + /** URL of the requests. Defaults to `{{graphql-gateway}}/graphql`. */ + url?: string; + /** + * Auth mode of the requests, for example `inherit` to use the auth of the + * collection. Defaults to `none`. + */ + auth?: string; } export const asBruno = ( operation: FileContent, config: BrunoPluginConfig, ): string => { - const vars = mergeDefaults(operation.vars, config.defaults); + const vars = mergeDefaults(operation.vars, config.defaults ?? {}); const file = new FileCreator(); file.addElement("meta", [`name: ${operation.name}`, "type: graphql"]); file.addElement("post", [ - "url: {{graphql-gateway}}/graphql", + `url: ${config.url ?? DEFAULT_URL}`, "body: graphql", - "auth: none", + `auth: ${config.auth ?? DEFAULT_AUTH}`, ]); file.addElement( "headers", diff --git a/packages/bruno/src/index.ts b/packages/bruno/src/index.ts index 14e11af..61ab092 100644 --- a/packages/bruno/src/index.ts +++ b/packages/bruno/src/index.ts @@ -83,3 +83,5 @@ function outputFileSync(filePath: string, content: string): void { } nodeFs.writeFileSync(filePath, content); } + +export type { BrunoPluginConfig }; From b7f46547c620d54fd2e24035ac3bf0923fc8bfbc Mon Sep 17 00:00:00 2001 From: Michael van Tellingen Date: Mon, 26 Oct 2026 07:26:42 +0100 Subject: [PATCH 10/14] test(bruno): test fragments, output folders, options and clean Test the generated requests of several operations per document (fragments from other documents, subscriptions), the url, auth, headers and defaults options, and that clean only removes generated requests. Write the output to a temporary directory instead of the tracked src/__output__. --- .../bruno/src/__mocks__/operations.graphql | 42 ++++ packages/bruno/src/__mocks__/schema.graphqls | 8 + .../src/__output__/queries/GetCustomer.bru | 55 ----- .../mutations/UpdateCustomer.bru | 39 +++ .../src/__snapshots__/queries/GetProduct.bru | 30 +++ .../subscriptions/CustomerUpdated.bru | 40 +++ packages/bruno/src/index.test.ts | 233 +++++++++++++++--- 7 files changed, 364 insertions(+), 83 deletions(-) create mode 100644 packages/bruno/src/__mocks__/operations.graphql delete mode 100644 packages/bruno/src/__output__/queries/GetCustomer.bru create mode 100644 packages/bruno/src/__snapshots__/mutations/UpdateCustomer.bru create mode 100644 packages/bruno/src/__snapshots__/queries/GetProduct.bru create mode 100644 packages/bruno/src/__snapshots__/subscriptions/CustomerUpdated.bru diff --git a/packages/bruno/src/__mocks__/operations.graphql b/packages/bruno/src/__mocks__/operations.graphql new file mode 100644 index 0000000..ad74189 --- /dev/null +++ b/packages/bruno/src/__mocks__/operations.graphql @@ -0,0 +1,42 @@ +# Several operations in one document + +query GetProduct($id: ID!) { + product(id: $id) { + ...ProductFields + } +} + +mutation UpdateCustomer($id: ID!, $firstName: String) { + updateCustomer(id: $id, firstName: $firstName) { + id + ...CustomerDetails + } +} + +subscription CustomerUpdated($id: ID!) { + customerUpdated(id: $id) { + id + ... on Customer { + ...CustomerDetails + } + } +} + +# Unnamed operations are skipped +# biome-ignore lint/correctness/useLoneAnonymousOperation: tests unnamed operations +# biome-ignore lint/correctness/useGraphqlNamedOperations: tests unnamed operations +query { + product(id: "1") { + id + } +} + +fragment ProductFields on Product { + id + name + price +} + +fragment UnusedFields on Product { + id +} diff --git a/packages/bruno/src/__mocks__/schema.graphqls b/packages/bruno/src/__mocks__/schema.graphqls index 82b9ba5..f5e1006 100644 --- a/packages/bruno/src/__mocks__/schema.graphqls +++ b/packages/bruno/src/__mocks__/schema.graphqls @@ -3,6 +3,14 @@ type Query { product(id: ID!): Product } +type Mutation { + updateCustomer(id: ID!, firstName: String): Customer +} + +type Subscription { + customerUpdated(id: ID!): Customer +} + input CustomerInfo { credentials: Credentials! authMethod: AuthMethod! diff --git a/packages/bruno/src/__output__/queries/GetCustomer.bru b/packages/bruno/src/__output__/queries/GetCustomer.bru deleted file mode 100644 index ebbb145..0000000 --- a/packages/bruno/src/__output__/queries/GetCustomer.bru +++ /dev/null @@ -1,55 +0,0 @@ -meta { - name: GetCustomer - type: graphql -} - -post { - url: {{graphql-gateway}}/graphql - body: graphql - auth: none -} - -body:graphql { - query GetCustomer($info: CustomerInfo!) { - customer(info: $info) { - ...CustomerDetails - } - } - - fragment CustomerDetails on Customer { - firstName - email - addresses { - ...AddressDetails - } - } - - fragment AddressDetails on Address { - id - city - } - fragment CustomerDetails on Customer { - firstName - email - addresses { - ...AddressDetails - } - } - fragment AddressDetails on Address { - id - city - } - -} - -body:graphql:vars { - { - "info": { - "credentials": { - "email": "example", - "password": "example" - }, - "authMethod": "EMAIL" - } - } -} \ No newline at end of file diff --git a/packages/bruno/src/__snapshots__/mutations/UpdateCustomer.bru b/packages/bruno/src/__snapshots__/mutations/UpdateCustomer.bru new file mode 100644 index 0000000..1c9358e --- /dev/null +++ b/packages/bruno/src/__snapshots__/mutations/UpdateCustomer.bru @@ -0,0 +1,39 @@ +meta { + name: UpdateCustomer + type: graphql +} + +post { + url: {{graphql-gateway}}/graphql + body: graphql + auth: none +} + +body:graphql { + mutation UpdateCustomer($id: ID!, $firstName: String) { + updateCustomer(id: $id, firstName: $firstName) { + id + ...CustomerDetails + } + } + + fragment CustomerDetails on Customer { + firstName + email + addresses { + ...AddressDetails + } + } + + fragment AddressDetails on Address { + id + city + } +} + +body:graphql:vars { + { + "id": "example-id", + "firstName": "example" + } +} \ No newline at end of file diff --git a/packages/bruno/src/__snapshots__/queries/GetProduct.bru b/packages/bruno/src/__snapshots__/queries/GetProduct.bru new file mode 100644 index 0000000..21f9abc --- /dev/null +++ b/packages/bruno/src/__snapshots__/queries/GetProduct.bru @@ -0,0 +1,30 @@ +meta { + name: GetProduct + type: graphql +} + +post { + url: {{graphql-gateway}}/graphql + body: graphql + auth: none +} + +body:graphql { + query GetProduct($id: ID!) { + product(id: $id) { + ...ProductFields + } + } + + fragment ProductFields on Product { + id + name + price + } +} + +body:graphql:vars { + { + "id": "example-id" + } +} \ No newline at end of file diff --git a/packages/bruno/src/__snapshots__/subscriptions/CustomerUpdated.bru b/packages/bruno/src/__snapshots__/subscriptions/CustomerUpdated.bru new file mode 100644 index 0000000..c9c30f0 --- /dev/null +++ b/packages/bruno/src/__snapshots__/subscriptions/CustomerUpdated.bru @@ -0,0 +1,40 @@ +meta { + name: CustomerUpdated + type: graphql +} + +post { + url: {{graphql-gateway}}/graphql + body: graphql + auth: none +} + +body:graphql { + subscription CustomerUpdated($id: ID!) { + customerUpdated(id: $id) { + id + ... on Customer { + ...CustomerDetails + } + } + } + + fragment CustomerDetails on Customer { + firstName + email + addresses { + ...AddressDetails + } + } + + fragment AddressDetails on Address { + id + city + } +} + +body:graphql:vars { + { + "id": "example-id" + } +} \ No newline at end of file diff --git a/packages/bruno/src/index.test.ts b/packages/bruno/src/index.test.ts index 6b2e125..b73749c 100644 --- a/packages/bruno/src/index.test.ts +++ b/packages/bruno/src/index.test.ts @@ -1,49 +1,226 @@ -import * as fs from "node:fs"; +import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import type { Types } from "@graphql-codegen/plugin-helpers"; import { GraphQLFileLoader } from "@graphql-tools/graphql-file-loader"; import { loadDocumentsSync, loadSchemaSync } from "@graphql-tools/load"; -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { BrunoPluginConfig } from "./bruno"; import { plugin } from "./index"; -const mockOutputDir = path.join(__dirname, "__output__"); const snapshotDir = path.join(__dirname, "__snapshots__"); -const mockSchemaPath = path.join(__dirname, "__mocks__", "schema.graphqls"); -const mockDocumentsPath = path.join(__dirname, "__mocks__", "query.graphql"); +const mocksDir = path.join(__dirname, "__mocks__"); + +const schema = loadSchemaSync(path.join(mocksDir, "schema.graphqls"), { + loaders: [new GraphQLFileLoader()], +}); +const documents = loadDocumentsSync( + [ + path.join(mocksDir, "query.graphql"), + path.join(mocksDir, "operations.graphql"), + ], + { loaders: [new GraphQLFileLoader()] }, +) as Types.DocumentFile[]; + +const defaultConfig: BrunoPluginConfig = { + defaults: {}, + headers: {}, + clean: false, +}; + +/** Lists the files in a directory, recursively, as sorted relative paths. */ +const listFiles = (dir: string): string[] => + (fs.readdirSync(dir, { recursive: true, withFileTypes: true }) as fs.Dirent[]) + .filter((entry) => entry.isFile()) + .map((entry) => path.relative(dir, path.join(entry.parentPath, entry.name))) + .sort(); describe("Bruno Plugin", () => { + let collectionDir: string; + let outputDir: string; + let outputFile: string; + + beforeEach(() => { + collectionDir = fs.mkdtempSync(path.join(os.tmpdir(), "codegen-bruno-")); + outputDir = path.join(collectionDir, "generated"); + outputFile = path.join(outputDir, "index.json"); + }); + + afterEach(() => { + fs.rmSync(collectionDir, { recursive: true, force: true }); + }); + + const read = (file: string) => + fs.readFileSync(path.join(outputDir, file), "utf-8"); + it("should generate the expected output", async () => { - // Load the mock schema - const schema = loadSchemaSync(mockSchemaPath, { - loaders: [new GraphQLFileLoader()], + const data = await plugin(schema, documents, defaultConfig, { + outputFile, + }); + + const index: Record = + JSON.parse(data as string); + const resolved = Object.fromEntries( + Object.entries(index).map(([name, { filename, source }]) => [ + name, + { filename, source: path.resolve(outputDir, source) }, + ]), + ); + expect(resolved).toEqual({ + CustomerUpdated: { + filename: "CustomerUpdated.bru", + source: path.join(mocksDir, "operations.graphql"), + }, + GetCustomer: { + filename: "GetCustomer.bru", + source: path.join(mocksDir, "query.graphql"), + }, + GetProduct: { + filename: "GetProduct.bru", + source: path.join(mocksDir, "operations.graphql"), + }, + UpdateCustomer: { + filename: "UpdateCustomer.bru", + source: path.join(mocksDir, "operations.graphql"), + }, }); + expect(listFiles(outputDir)).toEqual([ + path.join("mutations", "UpdateCustomer.bru"), + path.join("queries", "GetCustomer.bru"), + path.join("queries", "GetProduct.bru"), + path.join("subscriptions", "CustomerUpdated.bru"), + ]); - // Load the mock documents - const documents = loadDocumentsSync(mockDocumentsPath, { - loaders: [new GraphQLFileLoader()], - }) as Types.DocumentFile[]; + for (const file of listFiles(outputDir)) { + await expect(read(file)).toMatchFileSnapshot( + path.join(snapshotDir, file), + ); + } + }); - // Clear the output directory before running the test - fs.rmSync(mockOutputDir, { recursive: true, force: true }); + it("only includes the operation and the fragments it uses, once", async () => { + await plugin(schema, documents, defaultConfig, { outputFile }); - // Run the plugin - const outputFile = path.join(mockOutputDir, "output.json"); - const pluginConfig: BrunoPluginConfig = { - defaults: {}, - headers: {}, - clean: false, - }; - const data = await plugin(schema, documents, pluginConfig, { outputFile }); + const getCustomer = read(path.join("queries", "GetCustomer.bru")); + expect(getCustomer.match(/fragment CustomerDetails/g)).toHaveLength(1); + expect(getCustomer.match(/fragment AddressDetails/g)).toHaveLength(1); - expect(data).toBeDefined(); + const getProduct = read(path.join("queries", "GetProduct.bru")); + expect(getProduct).toContain("query GetProduct"); + expect(getProduct).toContain("fragment ProductFields"); + expect(getProduct).not.toContain("UpdateCustomer"); + expect(getProduct).not.toContain("CustomerUpdated"); + expect(getProduct).not.toContain("UnusedFields"); + expect(getProduct).not.toContain("# Several operations"); + + // Fragments from another document, used via an inline fragment + const customerUpdated = read( + path.join("subscriptions", "CustomerUpdated.bru"), + ); + expect(customerUpdated).toContain("fragment CustomerDetails"); + expect(customerUpdated).toContain("fragment AddressDetails"); + }); - const result = fs.readFileSync( - path.join(mockOutputDir, "queries", "GetCustomer.bru"), - "utf-8", + it("uses the configured url, auth and headers", async () => { + await plugin( + schema, + documents, + { + ...defaultConfig, + url: "{{baseUrl}}/api/graphql", + auth: "inherit", + headers: { Authorization: "Bearer {{token}}" }, + defaults: { id: "product-1" }, + }, + { outputFile }, ); - await expect(result).toMatchFileSnapshot( - path.join(snapshotDir, "queries", "GetCustomer.bru"), + + const getProduct = read(path.join("queries", "GetProduct.bru")); + expect(getProduct).toContain( + [ + "post {", + " url: {{baseUrl}}/api/graphql", + " body: graphql", + " auth: inherit", + "}", + "", + "headers {", + " Authorization: Bearer {{token}}", + "}", + ].join("\n"), ); + expect(getProduct).toContain('"id": "product-1"'); + }); + + describe("clean", () => { + const writeFile = (file: string, content = "") => { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); + }; + + it("only removes previously generated requests", async () => { + // The collection, with the generated requests in its root + outputFile = path.join(collectionDir, "index.json"); + outputDir = collectionDir; + writeFile(path.join(collectionDir, "bruno.json"), "{}"); + writeFile(path.join(collectionDir, "collection.bru")); + writeFile(path.join(collectionDir, "environments", "local.bru")); + writeFile(path.join(collectionDir, "custom", "Custom.bru")); + writeFile(path.join(collectionDir, "queries", "folder.bru")); + writeFile(path.join(collectionDir, "queries", "notes.md")); + // Generated by a previous run, for operations that no longer exist + writeFile(path.join(collectionDir, "queries", "Removed.bru")); + writeFile(path.join(collectionDir, "mutations", "Removed.bru")); + writeFile(path.join(collectionDir, "subscriptions", "Removed.bru")); + + await plugin( + schema, + documents, + { ...defaultConfig, clean: true }, + { outputFile }, + ); + + expect(listFiles(collectionDir)).toEqual([ + "bruno.json", + "collection.bru", + path.join("custom", "Custom.bru"), + path.join("environments", "local.bru"), + path.join("mutations", "UpdateCustomer.bru"), + path.join("queries", "GetCustomer.bru"), + path.join("queries", "GetProduct.bru"), + path.join("queries", "folder.bru"), + path.join("queries", "notes.md"), + path.join("subscriptions", "CustomerUpdated.bru"), + ]); + }); + + it("removes folders that are empty afterwards", async () => { + writeFile(path.join(outputDir, "queries", "Removed.bru")); + + await plugin( + schema, + [documents[1]], + { ...defaultConfig, clean: true }, + { outputFile }, + ); + await plugin( + schema, + [], + { ...defaultConfig, clean: true }, + { outputFile }, + ); + + expect(fs.readdirSync(outputDir)).toEqual([]); + }); + + it("keeps everything when clean is not enabled", async () => { + writeFile(path.join(outputDir, "queries", "Removed.bru")); + + await plugin(schema, documents, defaultConfig, { outputFile }); + + expect(listFiles(outputDir)).toContain( + path.join("queries", "Removed.bru"), + ); + }); }); }); From b136e78e40e236b7e275efe3195d419d3ed9af48 Mon Sep 17 00:00:00 2001 From: Michael van Tellingen Date: Mon, 26 Oct 2026 07:26:54 +0100 Subject: [PATCH 11/14] docs(bruno): document url, auth, clean and the fixed limitations --- packages/bruno/README.md | 49 ++++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/packages/bruno/README.md b/packages/bruno/README.md index 51cc699..8191500 100644 --- a/packages/bruno/README.md +++ b/packages/bruno/README.md @@ -35,8 +35,12 @@ const config: CodegenConfig = { "api-collection/generated/index.json": { plugins: ["@labdigital/graphql-codegen-bruno"], config: { - // Empty the output directory before generating + // Remove previously generated requests before generating clean: true, + // URL of the requests (default: "{{graphql-gateway}}/graphql") + url: "{{baseUrl}}/graphql", + // Use the auth of the collection (default: "none") + auth: "inherit", // Headers added to every request headers: { Authorization: "Bearer {{token}}", @@ -68,20 +72,24 @@ api-collection/ ├── index.json ├── mutations/ │ └── AddToCart.bru - └── queries/ - ├── GetProduct.bru - └── ListProducts.bru + ├── queries/ + │ ├── GetProduct.bru + │ └── ListProducts.bru + └── subscriptions/ + └── CartUpdated.bru ``` -The requests are sent to `{{graphql-gateway}}/graphql`, so define a +By default the requests are sent to `{{graphql-gateway}}/graphql`, so define a `graphql-gateway` variable (for example `http://localhost:4000`) in your Bruno -environment. +environment, or set `url` to match your environment variables. ## Configuration | Option | Type | Default | Description | | ------ | ---- | ------- | ----------- | -| `clean` | `boolean` | `false` | Remove everything in the output directory (the directory of the output file) before generating. | +| `clean` | `boolean` | `false` | Remove the previously generated requests before generating: the `.bru` files in the `queries/`, `mutations/` and `subscriptions/` folders of the output directory (except `folder.bru`), and those folders when they are empty afterwards. Other files, such as `bruno.json`, `collection.bru` or environments, are never removed. | +| `url` | `string` | `"{{graphql-gateway}}/graphql"` | URL of every request. May use Bruno variables. | +| `auth` | `string` | `"none"` | Auth mode of every request. Use `inherit` to use the auth configured on the collection or folder. | | `headers` | `Record` | `{}` | Headers added to the `headers` block of every request. Values may use Bruno variables, such as `{{token}}`. | | `defaults` | `Record` | `{}` | Values for operation variables, by variable name. A default replaces the generated example value of a top-level variable with the same name; variables that the operation does not declare are ignored. | @@ -119,9 +127,9 @@ meta { } post { - url: {{graphql-gateway}}/graphql + url: {{baseUrl}}/graphql body: graphql - auth: none + auth: inherit } headers { @@ -134,12 +142,12 @@ body:graphql { ...ProductFields } } + fragment ProductFields on Product { id name price } - } body:graphql:vars { @@ -189,18 +197,15 @@ Variables get an example value based on their type: - Codegen requires a file as output, so the plugin needs an output *file* even though it generates a directory. The file is used for the JSON index. -- `clean: true` removes **everything** in the directory of the output file. - Point the output to a dedicated directory inside your collection, not to the - collection root, or it removes your `bruno.json` and environments as well. -- Queries are written to `queries/`; mutations (and subscriptions) to - `mutations/`. Files are named after the operation, so operation names must be - unique. +- Queries are written to `queries/`, mutations to `mutations/` and + subscriptions to `subscriptions/`. Files are named after the operation, so + operation names must be unique. - Unnamed operations are skipped. -- The request body contains the complete source document of the operation - (one `.graphql` file, or one `gql` template literal in TypeScript), followed - by the fragments the operation uses. Put one operation per document; - otherwise every request contains all operations of that document, and - fragments defined in the same document are included twice. -- The URL (`{{graphql-gateway}}/graphql`) and `auth: none` are fixed. +- The request body contains the operation, followed by the fragments it uses + (also fragments used by those fragments, and fragments defined in other + documents), each fragment once. The GraphQL is printed with `graphql`'s + `print`, so comments in the source documents are not included. +- Without `clean`, requests of operations that were removed or renamed stay in + the output directory. - `defaults` only applies to top-level variables; it replaces the whole value, it does not merge into input objects. From 2cf272b2b91875e75c585cb639d0d21b9770c267 Mon Sep 17 00:00:00 2001 From: Michael van Tellingen Date: Mon, 26 Oct 2026 07:27:14 +0100 Subject: [PATCH 12/14] fix(typedefs): only extend the schema definition The output prefixed the whole printed schema with 'extend', which only worked when it started with a schema definition without description. A schema without root types resulted in 'extend type ...' or 'extend directive ...', and a schema description in invalid syntax. Convert just the schema definition to a schema extension (dropping its description, which extensions cannot have). Plain schemas keep working: graphql's buildSchema and makeExecutableSchema accept 'extend schema' without a schema definition. --- .../src/__snapshots__/index.test.ts.snap | 32 ++++++++ packages/typedefs/src/index.test.ts | 77 +++++++++++++++++++ packages/typedefs/src/index.ts | 38 ++++++++- packages/typedefs/vitest.config.ts | 8 ++ 4 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 packages/typedefs/src/__snapshots__/index.test.ts.snap create mode 100644 packages/typedefs/src/index.test.ts diff --git a/packages/typedefs/src/__snapshots__/index.test.ts.snap b/packages/typedefs/src/__snapshots__/index.test.ts.snap new file mode 100644 index 0000000..325f4ff --- /dev/null +++ b/packages/typedefs/src/__snapshots__/index.test.ts.snap @@ -0,0 +1,32 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`typedefs plugin > generates the typeDefs module 1`] = ` +"// This file is automatically generated by @labdigital/graphql-codegen-typedefs +import { gql } from "graphql-tag"; + +export const typeDefs = gql\` + extend schema { + query: Query + mutation: Mutation + } + + type Query { + """Uses \\\`backticks\\\`""" + product(id: ID!): Product + } + + type Mutation { + addToCart(productId: ID!, quantity: Int!): Cart! + } + + type Product { + id: ID! + name: String! + } + + type Cart { + id: ID! + totalItems: Int! + } +\`;" +`; diff --git a/packages/typedefs/src/index.test.ts b/packages/typedefs/src/index.test.ts new file mode 100644 index 0000000..c3ae9f5 --- /dev/null +++ b/packages/typedefs/src/index.test.ts @@ -0,0 +1,77 @@ +import { buildSchema, printSchema } from "graphql"; +import { describe, expect, it } from "vitest"; +import { plugin } from "./index"; + +/** Returns the SDL inside the generated gql`...` template literal. */ +const extractSDL = (output: string): string => { + const match = output.match(/gql`\n([\s\S]*)\n`;$/); + if (!match) { + throw new Error(`Unexpected output:\n${output}`); + } + return match[1].replace(/\\`/g, "`"); +}; + +const schemaSDL = /* GraphQL */ ` + type Query { + "Uses \`backticks\`" + product(id: ID!): Product + } + + type Mutation { + addToCart(productId: ID!, quantity: Int!): Cart! + } + + type Product { + id: ID! + name: String! + } + + type Cart { + id: ID! + totalItems: Int! + } +`; + +describe("typedefs plugin", () => { + it("generates the typeDefs module", () => { + const output = plugin(buildSchema(schemaSDL)); + expect(output).toMatchSnapshot(); + expect(extractSDL(output)).toMatch(/^\textend schema \{\n/); + }); + + it("generates valid SDL that builds the same schema", () => { + const schema = buildSchema(schemaSDL); + const sdl = extractSDL(plugin(schema)); + expect(printSchema(buildSchema(sdl))).toBe(printSchema(schema)); + }); + + it("only extends the schema definition", () => { + // A schema without root types has no schema definition; previously the + // output started with `extend type A` or `extend directive @x`. + for (const input of [ + "type A { a: Int }", + "directive @x on FIELD_DEFINITION type A { a: Int @x }", + ]) { + const schema = buildSchema(input); + const sdl = extractSDL(plugin(schema)); + expect(sdl).not.toContain("extend"); + expect(printSchema(buildSchema(sdl))).toBe(printSchema(schema)); + } + }); + + it("handles a schema definition with a description and directives", () => { + const schema = buildSchema(` + "The schema" + schema @meta { query: Root } + directive @meta on SCHEMA + type Root { a: Int } + `); + + // Extensions cannot have a description + const extended = extractSDL(plugin(schema)); + expect(extended).toMatch( + /^\textend schema @meta \{\n\t\tquery: Root\n\t\}/, + ); + expect(buildSchema(extended).getQueryType()?.name).toBe("Root"); + }); +}); diff --git a/packages/typedefs/src/index.ts b/packages/typedefs/src/index.ts index 3325975..03e5642 100644 --- a/packages/typedefs/src/index.ts +++ b/packages/typedefs/src/index.ts @@ -1,5 +1,11 @@ -import { printSchemaWithDirectives } from "@graphql-tools/utils"; -import type { GraphQLSchema } from "graphql"; +import { getDocumentNodeFromSchema } from "@graphql-tools/utils"; +import { + type DefinitionNode, + type GraphQLSchema, + Kind, + print as printAST, + type SchemaExtensionNode, +} from "graphql"; const print = (schema: string): string => { let escaped = schema.replace(/\\`/g, "\\\\`").replace(/`/g, "\\`"); @@ -24,11 +30,35 @@ const print = (schema: string): string => { import { gql } from "graphql-tag"; export const typeDefs = gql\` - extend ${escaped} +${escaped} \`; `.trim(); }; +/** + * Converts the schema definition to a schema extension. Extensions cannot + * have a description, so that is dropped. + */ +const toSchemaExtension = (definition: DefinitionNode): DefinitionNode => { + if (definition.kind !== Kind.SCHEMA_DEFINITION) { + return definition; + } + const extension: SchemaExtensionNode = { + kind: Kind.SCHEMA_EXTENSION, + directives: definition.directives, + operationTypes: definition.operationTypes, + }; + return extension; +}; + export const plugin = (schema: GraphQLSchema): string => { - return print(printSchemaWithDirectives(schema)); + // Same as `printSchemaWithDirectives` from @graphql-tools/utils, with the + // schema definition printed as an extension + const document = getDocumentNodeFromSchema(schema); + return print( + printAST({ + ...document, + definitions: document.definitions.map(toSchemaExtension), + }), + ); }; diff --git a/packages/typedefs/vitest.config.ts b/packages/typedefs/vitest.config.ts index a5ebfc5..e588c09 100644 --- a/packages/typedefs/vitest.config.ts +++ b/packages/typedefs/vitest.config.ts @@ -2,6 +2,14 @@ import path from "node:path"; import { defineConfig } from "vitest/config"; export default defineConfig({ + // graphql 17 exposes a separate "development" build; make sure the test + // files and the (externalized) graphql-tools utils share one instance. + ssr: { + resolve: { + conditions: ["module-sync", "node", "import", "default"], + externalConditions: ["module-sync", "node"], + }, + }, test: { coverage: { provider: "v8", From c2b870ec373a84b17aa753d102d4ec6f0e943293 Mon Sep 17 00:00:00 2001 From: Michael van Tellingen Date: Mon, 26 Oct 2026 07:27:15 +0100 Subject: [PATCH 13/14] feat(typedefs): add extendSchema option Set extendSchema: false to print a plain schema definition (including its description) instead of 'extend schema'. Defaults to true, the previous behaviour. --- packages/typedefs/README.md | 17 ++++++++++++----- packages/typedefs/src/index.test.ts | 18 ++++++++++++++++-- packages/typedefs/src/index.ts | 29 ++++++++++++++++++++--------- 3 files changed, 48 insertions(+), 16 deletions(-) diff --git a/packages/typedefs/README.md b/packages/typedefs/README.md index 814554a..de871ad 100644 --- a/packages/typedefs/README.md +++ b/packages/typedefs/README.md @@ -55,7 +55,9 @@ const schema = makeExecutableSchema({ typeDefs, resolvers }); ## Configuration -This plugin has no configuration options. +| Option | Type | Default | Description | +| ------ | ---- | ------- | ----------- | +| `extendSchema` | `boolean` | `true` | Print the schema definition as an extension, `extend schema { ... }`, instead of `schema { ... }`. | ## Example output @@ -88,7 +90,7 @@ the plugin generates `src/generated/typedefs.ts`: import { gql } from "graphql-tag"; export const typeDefs = gql` - extend schema { + extend schema { query: Query mutation: Mutation } @@ -118,9 +120,14 @@ export const typeDefs = gql` - The schema is printed with `printSchemaWithDirectives` from `@graphql-tools/utils`, so directives on the schema and its types are kept. Types are sorted alphabetically. -- The output starts with the schema definition, printed as an extension: - `extend schema { ... }`. `makeExecutableSchema` from `@graphql-tools/schema` - accepts this as is. +- The schema definition (the root operation types) is printed as an extension, + `extend schema { ... }`, which `graphql`'s `buildSchema` and + `makeExecutableSchema` from `@graphql-tools/schema` accept as is, and which + can be merged with other type definitions that define the schema. Set + `extendSchema: false` to print a plain `schema { ... }` definition instead. + Schema extensions cannot have a description, so the description of the + schema (if any) is only kept with `extendSchema: false`. A schema without + root types has no schema definition; nothing is extended then. - Backticks in the schema (for example in descriptions) are escaped. - The schema is written as a whole; there is no option to include or exclude types. diff --git a/packages/typedefs/src/index.test.ts b/packages/typedefs/src/index.test.ts index c3ae9f5..9a42133 100644 --- a/packages/typedefs/src/index.test.ts +++ b/packages/typedefs/src/index.test.ts @@ -41,8 +41,18 @@ describe("typedefs plugin", () => { it("generates valid SDL that builds the same schema", () => { const schema = buildSchema(schemaSDL); - const sdl = extractSDL(plugin(schema)); - expect(printSchema(buildSchema(sdl))).toBe(printSchema(schema)); + for (const extendSchema of [true, false]) { + const sdl = extractSDL(plugin(schema, [], { extendSchema })); + expect(printSchema(buildSchema(sdl))).toBe(printSchema(schema)); + } + }); + + it("prints a plain schema definition when extendSchema is false", () => { + const sdl = extractSDL( + plugin(buildSchema(schemaSDL), [], { extendSchema: false }), + ); + expect(sdl).toMatch(/^\tschema \{\n/); + expect(sdl).not.toContain("extend"); }); it("only extends the schema definition", () => { @@ -73,5 +83,9 @@ describe("typedefs plugin", () => { /^\textend schema @meta \{\n\t\tquery: Root\n\t\}/, ); expect(buildSchema(extended).getQueryType()?.name).toBe("Root"); + + const plain = extractSDL(plugin(schema, [], { extendSchema: false })); + expect(plain).toMatch(/^\t"""The schema"""\n\tschema @meta \{/); + expect(buildSchema(plain).description).toBe("The schema"); }); }); diff --git a/packages/typedefs/src/index.ts b/packages/typedefs/src/index.ts index 03e5642..9c88ee1 100644 --- a/packages/typedefs/src/index.ts +++ b/packages/typedefs/src/index.ts @@ -7,6 +7,14 @@ import { type SchemaExtensionNode, } from "graphql"; +export interface TypedefsPluginConfig { + /** + * Print the schema definition (`schema { query: Query ... }`) as an + * extension: `extend schema { ... }`. Defaults to `true`. + */ + extendSchema?: boolean; +} + const print = (schema: string): string => { let escaped = schema.replace(/\\`/g, "\\\\`").replace(/`/g, "\\`"); const tabSize = 2; @@ -51,14 +59,17 @@ const toSchemaExtension = (definition: DefinitionNode): DefinitionNode => { return extension; }; -export const plugin = (schema: GraphQLSchema): string => { - // Same as `printSchemaWithDirectives` from @graphql-tools/utils, with the - // schema definition printed as an extension +export const plugin = ( + schema: GraphQLSchema, + _documents?: unknown, + config: TypedefsPluginConfig = {}, +): string => { + // Same as `printSchemaWithDirectives` from @graphql-tools/utils const document = getDocumentNodeFromSchema(schema); - return print( - printAST({ - ...document, - definitions: document.definitions.map(toSchemaExtension), - }), - ); + const definitions = + config.extendSchema === false + ? document.definitions + : document.definitions.map(toSchemaExtension); + + return print(printAST({ ...document, definitions })); }; From 52dbb0b7764c0307bbac8135bd83275a03b201f9 Mon Sep 17 00:00:00 2001 From: Michael van Tellingen Date: Mon, 26 Oct 2026 07:27:35 +0100 Subject: [PATCH 14/14] chore: add changesets for the plugin fixes --- .changeset/bruno-fixes.md | 12 ++++++++++++ .changeset/k6-fix-document-modes.md | 7 +++++++ .changeset/typedefs-extend-schema.md | 7 +++++++ 3 files changed, 26 insertions(+) create mode 100644 .changeset/bruno-fixes.md create mode 100644 .changeset/k6-fix-document-modes.md create mode 100644 .changeset/typedefs-extend-schema.md diff --git a/.changeset/bruno-fixes.md b/.changeset/bruno-fixes.md new file mode 100644 index 0000000..128e6b9 --- /dev/null +++ b/.changeset/bruno-fixes.md @@ -0,0 +1,12 @@ +--- +"@labdigital/graphql-codegen-bruno": minor +--- + +Add the `url` and `auth` options for the request URL and auth mode; they default to the previously hardcoded `{{graphql-gateway}}/graphql` and `none`. + +This release changes the generated output: + +- A request now contains only its operation followed by the fragments it (transitively) uses, each once. Previously it contained the complete source document (including other operations in the same document) and fragments could be included twice. The GraphQL is printed with `graphql`'s `print`, so comments of the source document are no longer included. +- Subscriptions are written to `subscriptions/` instead of `mutations/`. Run once with `clean: true` (or remove them manually) to remove the subscriptions from `mutations/`. +- `clean: true` now only removes the previously generated requests (the `.bru` files, except `folder.bru`, in the `queries/`, `mutations/` and `subscriptions/` folders of the output directory) instead of everything in the output directory, so it no longer deletes `bruno.json`, environments or other files. +- prettier is no longer bundled (the package was 5 MB); formatting is done by `graphql`'s `print`. diff --git a/.changeset/k6-fix-document-modes.md b/.changeset/k6-fix-document-modes.md new file mode 100644 index 0000000..87b85d6 --- /dev/null +++ b/.changeset/k6-fix-document-modes.md @@ -0,0 +1,7 @@ +--- +"@labdigital/graphql-codegen-k6": patch +--- + +Fix the generated code for `documentMode` `graphQLTag` (the default) and `external`: the `Requester` type referred to `DocumentNode` without importing it, so the output did not type check. `DocumentNode` is now imported from `documentNodeImport` (default `graphql`), respecting `useTypeImports`. + +Fix `documentMode: "string"`: the output referred to a `TypedDocumentString` class that was never defined. The class is now generated (the same class as `typed-document-node` and the client preset use), the operation documents are typed with their result and variables, and the requester receives the `TypedDocumentString` (which serializes to the query string). diff --git a/.changeset/typedefs-extend-schema.md b/.changeset/typedefs-extend-schema.md new file mode 100644 index 0000000..b6deabe --- /dev/null +++ b/.changeset/typedefs-extend-schema.md @@ -0,0 +1,7 @@ +--- +"@labdigital/graphql-codegen-typedefs": minor +--- + +Only print the schema definition as `extend schema { ... }`. Previously the whole printed schema was prefixed with `extend`, which produced invalid output for schemas without root types (`extend type ...`, `extend directive ...`) and for schemas with a description. The description of the schema is dropped from the extension, as extensions cannot have one. + +Add the `extendSchema` option (default `true`, the previous behaviour); set it to `false` to print a plain `schema { ... }` definition instead.