import { Router } from "@suaveplan/api-router"; import { describe, expect, it } from "@suaveplan/testing/runner"; import { createGraphiqlUi, GRAPHQL_UI_GRAPHIQL_CODES, type GraphiqlRouteHandler, type RouterResponseValue, } from "../../src/index.js"; /** * End-to-end integration test. * * Wires the GraphiQL UI plugin into an in-memory `Router` alongside a * minimal POST `/graphql` handler that echoes a canned response, then * exercises the full request flow: * 1. GET `/graphql/ui` -> HTML shell (status 200, CSP header, hashed * asset references embedded). * 2. GET `/graphql/ui/assets/` -> bundled JS / CSS * payload (status 200, immutable cache, no CDN refs). * 3. POST `/graphql` -> JSON response (the explorer would issue this * via `fetch` once the user clicks "Run"; here we drive it * directly so the integration scope stays in-process). * * The umbrella Stage-7 plan calls for a Playwright-driven browser test * once `api-graphql-yoga` is wired. This test stands in until that * package lands — it pins every contract this plugin is responsible * for and stays meaningful without a real GraphQL engine. */ type ProtocolHandler = (request: { readonly method: string; readonly path: string; readonly body?: string; }) => RouterResponseValue; function dispatch( router: Router, method: "GET" | "POST", path: string, body?: string ): RouterResponseValue { const match = router.match(method, path); if (match === null) throw new Error(`no match for ${method} ${path}`); const handler = match.handler as unknown as | GraphiqlRouteHandler | ProtocolHandler; return handler({ method: match.method, path, body }); } describe("api-graphql-ui-graphiql — integration", () => { it("boots end-to-end: shell loads, asset serves, query round-trips", { timeout: 30000, }, () => { const router = new Router({ plugin: "host" }); // Minimal /graphql endpoint — JSON in, JSON out. const protocolHandler: ProtocolHandler = (req) => { const parsed: { query?: string } = req.body ? (JSON.parse(req.body) as { query?: string }) : {}; return { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ data: { echo: parsed.query ?? null }, }), }; }; router.post( "/graphql", protocolHandler as unknown as Parameters[1] ); createGraphiqlUi({}).register(router); // 1. Shell loads at /graphql/ui. const shell = dispatch(router, "GET", "/graphql/ui"); expect(shell.status).toBe(200); expect(shell.headers["Content-Security-Policy"]).toContain( "default-src 'self'" ); expect(shell.body).toContain(""); // No external host references. expect(shell.body).not.toMatch(/https?:\/\//); // Extract the JS asset filename from the shell. const jsMatch = shell.body.match( /\/graphql\/ui\/assets\/(graphiql\.[0-9a-f]{8}\.js)/ ); expect(jsMatch).not.toBeNull(); const jsFilename = jsMatch?.[1] ?? ""; expect(jsFilename.length).toBeGreaterThan(0); // 2. Asset round-trips. const asset = dispatch( router, "GET", `/graphql/ui/assets/${jsFilename}` ); expect(asset.status).toBe(200); expect(asset.headers["Content-Type"]).toContain( "application/javascript" ); expect(asset.headers["Cache-Control"]).toBe( "public, max-age=31536000, immutable" ); expect(asset.body.length).toBeGreaterThan(0); // 3. Query round-trips through the /graphql protocol endpoint // — exactly what the in-browser explorer does on "Run". const protocol = dispatch( router, "POST", "/graphql", JSON.stringify({ query: "{ ping }" }) ); expect(protocol.status).toBe(200); const decoded = JSON.parse(protocol.body) as { data?: { echo?: string }; }; expect(decoded.data?.echo).toBe("{ ping }"); }); it("stale-hash asset names resolve during a rolling deploy", { timeout: 5000, }, () => { const router = new Router({ plugin: "host" }); createGraphiqlUi({}).register(router); const stale = dispatch( router, "GET", "/graphql/ui/assets/graphiql.deadbeef.js" ); expect(stale.status).toBe(200); expect(stale.body.length).toBeGreaterThan(0); }); it("asset endpoint rejects unknown filenames with ASSET_MISSING", { timeout: 5000, }, () => { const router = new Router({ plugin: "host" }); createGraphiqlUi({}).register(router); try { dispatch(router, "GET", "/graphql/ui/assets/unknown.deadbeef.png"); throw new Error("expected throw"); } catch (err) { const code = (err as { code?: string }).code; expect(code).toBe(GRAPHQL_UI_GRAPHIQL_CODES.ASSET_MISSING); } }); });