Use turbopack for dev builds. Remove graphql-tag/loader, replaced by gql`` tag literals instead. Approved-by: Linus Flood
333 lines
8.7 KiB
TypeScript
333 lines
8.7 KiB
TypeScript
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<string>()
|
|
const used = new Set<string>()
|
|
const fragMap = new Map<string, any>()
|
|
|
|
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<string>()) {
|
|
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 }
|
|
}
|