import fg from "fast-glob" import { Kind } from "graphql" import gql from "graphql-tag" import path from "path" import { describe, expect, it } from "vitest" import type { DocumentNode } from "graphql" const repoRoot = path.resolve(__dirname, "..", "..", "..") const fragmentFiles = await fg("**/Fragments/**/*.graphql.ts", { cwd: repoRoot, absolute: true, }) const queryFiles = await fg("**/Query/**/*.graphql.ts", { cwd: repoRoot, absolute: true, }) describe("GraphQL Validate", () => { it("should validate a simple query", async () => { const document = gql` fragment Dance on DanceType { style } ` const validationResult = validateFragments(document) expect(validationResult.valid).toBe(true) }) it("should validate a simple query", async () => { const doc = gql` fragment Dance on DanceType { style } ` expect(validateFragments(doc)).toEqual({ valid: true, hasOperation: false, missing: [], unused: ["Dance"], }) }) it("should throw when we pass a broken fragment", async () => { expect( () => gql` fraagment Dance on DanceType { style } ` ).toThrow() }) it("should throw when we pass a query with invalid syntax", async () => { expect( () => gql` queery Dance { style } ` ).toThrow() }) it("should not be valid when we have a unused fragment", async () => { const doc = gql` query Dance { style } fragment UnusedFragment on DanceType { id } ` const validationResult = validateFragments(doc) expect(validationResult.valid).toBe(false) }) it("should not be valid when using a non-existent fragment", async () => { const doc = gql` query Dance { style ...NonExistingFragment } ` const validationResult = validateFragments(doc) expect(validationResult.valid).toBe(false) }) it("should be valid when file contains only fragments without internal fragment references", async () => { const doc = gql` fragment UserInfo on User { id name email } fragment PostInfo on Post { id title content } ` const validationResult = validateFragments(doc) expect(validationResult.valid).toBe(true) expect(validationResult.missing).toEqual([]) expect(validationResult.unused).toEqual(["UserInfo", "PostInfo"]) // Fragments are unused but that's OK when there are no operations }) it("should not be valid when fragments reference undefined fragments", async () => { const doc = gql` fragment UserInfo on User { id name ...UndefinedFragment } fragment PostInfo on Post { id title content } ` const validationResult = validateFragments(doc) expect(validationResult.valid).toBe(false) expect(validationResult.missing).toEqual(["UndefinedFragment"]) }) it("should be valid when fragments reference other fragments defined in the same file", async () => { const doc = gql` fragment UserDetails on User { id name ...ContactInfo } fragment ContactInfo on User { email phone } ` const validationResult = validateFragments(doc) expect(validationResult.valid).toBe(true) expect(validationResult.missing).toEqual([]) // ContactInfo is used by UserDetails, but UserDetails itself is unused expect(validationResult.unused).toEqual(["UserDetails"]) }) it.each(fragmentFiles)("validates fragment %s", async (file) => { const imported = await import(file) if (imported.default) { const graphqlDocument = imported.default as unknown as DocumentNode const validationResult = validateFragments(graphqlDocument) expect(validationResult.valid).toBe(true) } for (const namedExports in imported) { const graphqlDocument = imported[namedExports] as unknown as DocumentNode if (!graphqlDocument) continue expect(graphqlDocument.kind).toBe(Kind.DOCUMENT) const validationResult = validateFragments(graphqlDocument) expect(validationResult.missing).toEqual([]) expect(validationResult.valid).toBe(true) } }) it("should handle transitive fragment usage in operations", () => { const doc = gql` query TestQuery { user { ...UserInfo } } fragment UserInfo on User { id name ...ContactInfo } fragment ContactInfo on User { email phone } ` const validationResult = validateFragments(doc) expect(validationResult.valid).toBe(true) expect(validationResult.hasOperation).toBe(true) expect(validationResult.missing).toEqual([]) expect(validationResult.unused).toEqual([]) }) it("should handle complex union type fragments", () => { const doc = gql` query TestQuery { blocks { __typename ...BlockA ...BlockB } } fragment BlockA on TypeA { id title } fragment BlockB on TypeB { id content } ` const validationResult = validateFragments(doc) expect(validationResult).toEqual({ valid: true, hasOperation: true, missing: [], unused: [], }) }) it.each(queryFiles)("validates query %s", async (file) => { const imported = await import(file) if (imported.default) { const graphqlDocument = imported.default as unknown as DocumentNode const validationResult = validateFragments(graphqlDocument) expect(validationResult.valid).toBe(true) } for (const namedExport in imported) { const graphqlDocument = imported[namedExport] as unknown as DocumentNode if (!graphqlDocument) continue expect(graphqlDocument.kind).toBe(Kind.DOCUMENT) const validationResult = validateFragments(graphqlDocument) expect(validationResult, `Validation failed for ${namedExport}`).toEqual({ valid: true, hasOperation: true, unused: [], missing: [], }) } }) }) /** * Validates GraphQL fragments in a document. * * For documents with operations: * - All fragments must be used (no unused fragments allowed) * - All referenced fragments must be defined (no missing fragments allowed) * * For documents with only fragments (no operations): * - Fragments can be unused (they are definitions for external use) * - All referenced fragments must still be defined (no missing fragments allowed) * * @param doc - The GraphQL document to validate * @returns Validation result with valid flag, missing fragments, and unused fragments */ export function validateFragments(doc: DocumentNode) { const defined = new Set() const used = new Set() const fragMap = new Map() for (const def of doc.definitions) { if (def.kind === Kind.FRAGMENT_DEFINITION) { defined.add(def.name.value) fragMap.set(def.name.value, def) } } function walk(node: any, visitedFragments = new Set()) { if (!node) return if (node.kind === Kind.FRAGMENT_SPREAD) { const name = node.name.value if (!used.has(name)) { used.add(name) // If we have the fragment definition, walk its selectionSet to mark transitive usages. const fragDef = fragMap.get(name) if (fragDef && !visitedFragments.has(name)) { visitedFragments.add(name) walk(fragDef.selectionSet, new Set(visitedFragments)) } } return } if (node.selectionSet) { for (const sel of node.selectionSet.selections) { walk(sel, visitedFragments) } } // Also check in inline fragments and other selection types if (node.selections) { for (const sel of node.selections) { walk(sel, visitedFragments) } } } const hasOperation = doc.definitions.some( (d) => d.kind === Kind.OPERATION_DEFINITION ) if (hasOperation) { // Start from operations to determine which fragments are actually used. for (const def of doc.definitions) { if (def.kind === Kind.OPERATION_DEFINITION) { walk(def) } } } else { // No operations: allow standalone fragments. // Still validate fragment-to-fragment references (detect missing fragments). for (const def of doc.definitions) { if (def.kind === Kind.FRAGMENT_DEFINITION) { walk(def) } } } const missing = [...used].filter((name) => !defined.has(name)) const unused = [...defined].filter((name) => !used.has(name)) const valid = hasOperation ? missing.length === 0 && unused.length === 0 : missing.length === 0 return { valid, missing, unused, hasOperation } }