/** * GraphQL detection + introspection. * * During crawl, if a GraphQL endpoint is discovered (via network observer or * known-URL heuristics), fire a minimal introspection query to get the schema. * The schema is stored in the crawl artifact for use during test generation. */ export interface GqlType { name: string; kind: string; fields?: Array<{ name: string; type: string }>; inputFields?: Array<{ name: string; type: string }>; enumValues?: string[]; } export interface GqlSchema { queryType?: string; mutationType?: string; subscriptionType?: string; types: GqlType[]; } export interface GqlIntrospectionResult { endpoint: string; schema: GqlSchema | null; error?: string; durationMs: number; } const INTROSPECTION_QUERY = ` query IntrospectionQuery { __schema { queryType { name } mutationType { name } subscriptionType { name } types { name kind fields(includeDeprecated: false) { name type { name kind ofType { name kind } } } inputFields { name type { name kind ofType { name kind } } } enumValues(includeDeprecated: false) { name } } } } `; function flattenType(t: any): string { if (!t) return 'unknown'; if (t.kind === 'NON_NULL' || t.kind === 'LIST') return flattenType(t.ofType); return t.name ?? 'unknown'; } function parseSchema(raw: any): GqlSchema { const s = raw?.__schema ?? raw?.data?.__schema ?? raw; const types: GqlType[] = (s?.types ?? []) .filter((t: any) => !t.name?.startsWith('__')) .map((t: any) => ({ name: t.name, kind: t.kind, fields: (t.fields ?? []).map((f: any) => ({ name: f.name, type: flattenType(f.type) })), inputFields: (t.inputFields ?? []).map((f: any) => ({ name: f.name, type: flattenType(f.type) })), enumValues: (t.enumValues ?? []).map((e: any) => e.name), })); return { queryType: s?.queryType?.name, mutationType: s?.mutationType?.name, subscriptionType: s?.subscriptionType?.name, types, }; } export async function introspectGraphQL( endpoint: string, opts: { headers?: Record; timeoutMs?: number; cookies?: string; } = {}, ): Promise { const { headers = {}, timeoutMs = 10_000, cookies } = opts; const t0 = Date.now(); try { const reqHeaders: Record = { 'Content-Type': 'application/json', 'Accept': 'application/json', ...headers, }; if (cookies) reqHeaders['Cookie'] = cookies; const res = await fetch(endpoint, { method: 'POST', headers: reqHeaders, body: JSON.stringify({ query: INTROSPECTION_QUERY }), signal: AbortSignal.timeout(timeoutMs), }); if (!res.ok) { return { endpoint, schema: null, error: `HTTP ${res.status}`, durationMs: Date.now() - t0 }; } const json = await res.json(); if (json?.errors?.length) { const msg = json.errors[0]?.message ?? 'introspection disabled'; return { endpoint, schema: null, error: msg, durationMs: Date.now() - t0 }; } return { endpoint, schema: parseSchema(json?.data ?? json), durationMs: Date.now() - t0 }; } catch (e: any) { return { endpoint, schema: null, error: e.message, durationMs: Date.now() - t0 }; } } /** Guess GraphQL endpoint URLs from a known app URL. */ export function guessGraphQLEndpoints(appUrl: string): string[] { try { const u = new URL(appUrl); const base = `${u.protocol}//${u.host}`; return [ `${base}/graphql`, `${base}/api/graphql`, `${base}/v1/graphql`, `${base}/gql`, `${base}/query`, ]; } catch { return []; } }