UNPKG

@graphql-hive/federation-gateway-audit

Version:
701 lines (633 loc) 18.1 kB
#!/usr/bin/env node process.removeAllListeners("warning"); import { writeFileSync, createWriteStream, mkdirSync, existsSync, readFileSync, } from "node:fs"; import retry from "async-retry"; import yargs from "yargs"; import waitOn from "wait-on"; import getPort from "get-port"; import detectPort from "detect-port"; import { spawn } from "node:child_process"; import { hideBin } from "yargs/helpers"; import { run } from "node:test"; import { junit, tap } from "node:test/reporters"; import { styleText } from "node:util"; import { killPortProcess } from "kill-port-process"; import { dirname, extname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { serve } from "./index.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const ext = extname(fileURLToPath(import.meta.url)); const defaultPort = 4200; function readPort(address: string) { return parseInt(new URL(address).port, 10); } function resolvePath( argv: { cwd: string; }, path: string, ) { if (path.startsWith("/")) { return path; } return join(argv.cwd, path); } yargs(hideBin(process.argv)) .scriptName("graphql-federation-audit") .epilogue( "for more information, find our manual at https://github.com/the-guild-org/federation-compatibility", ) .version(readVersion() ?? "local") .recommendCommands() .option("cwd", { describe: "Change the current working directory", type: "string", default: process.cwd(), }) .command( "serve", "start the server", (yargs) => { return yargs.option("port", { describe: "port to bind on", default: defaultPort, }); }, async (argv) => { await serve(argv.port); console.log("Server started on port", argv.port); }, ) .command( "supergraph", "fetch a supergraph.graphql for a test group", (yargs) => { return yargs .option("test", { describe: "test group id", type: "string", }) .option("port", { describe: "port to bind on", default: defaultPort, }) .demandOption("test"); }, async (argv) => { const res = await fetch( `http://localhost:${argv.port}/${argv.test}/supergraph`, ); if (!res.ok) { process.stderr.write("Failed to fetch supergraph"); process.stderr.write(`HTTP ${res.status} ${res.statusText}`); process.stderr.write('Body: "' + (await res.text()) + '"'); process.exit(1); } writeFileSync(resolvePath(argv, "supergraph.graphql"), await res.text()); process.exit(0); }, ) .command( "subgraphs", "fetch a subgraphs.json for a test group", (yargs) => { return yargs .option("test", { describe: "test group id", type: "string", }) .option("port", { describe: "port to bind on", default: defaultPort, }) .demandOption("test"); }, async (argv) => { const endpoint = `http://localhost:${argv.port}/${argv.test}/subgraphs`; const res = await fetch(endpoint, { headers: { accept: "application/json", }, }); if (!res.ok) { process.stderr.write("Failed to fetch subgraphs: " + endpoint); process.stderr.write(`HTTP ${res.status} ${res.statusText}`); process.stderr.write('Body: "' + (await res.text()) + '"'); process.exit(1); } writeFileSync(resolvePath(argv, "subgraphs.json"), await res.text()); process.exit(0); }, ) .command( "start", "start a gateway for a requested test group", (yargs) => { return yargs .option("test", { describe: "Test group id", type: "string", }) .option("run-script", { describe: "Path to a bash script to run before each test", type: "string", }) .option("graphql", { describe: "GraphQL endpoint serving the supergraph", type: "string", }) .option("healthcheck", { describe: "Health check endpoint", type: "string", }) .option("port", { describe: "Port to bind on", default: defaultPort, }) .demandOption("test") .demandOption("graphql") .demandOption("healthcheck") .demandOption("run-script"); }, async (argv) => { const abortSignal = new AbortController(); process.once("SIGINT", () => { if (!abortSignal.signal.aborted) { abortSignal.abort(); } }); process.once("SIGTERM", () => { if (!abortSignal.signal.aborted) { abortSignal.abort(); } }); const port = argv.port ?? (await getPort()); await serve(port); process.stdout.write("\n"); await killPortIfRunning(readPort(argv.graphql)).catch(() => {}); const gatewayExit = Promise.withResolvers<void>(); let gatewayExited = false; const gateway = spawn("sh", [argv.runScript, argv.test], { signal: abortSignal.signal, stdio: "inherit", cwd: dirname(resolvePath(argv, argv.runScript)), }); gateway.on("error", (err) => { if (err.message.includes("aborted")) { return; } process.stderr.write(err.message); }); gateway.once("exit", () => { gatewayExited = true; gatewayExit.resolve(); }); await gatewayExit.promise; }, ) .command( "test-suite", "run a test group", (yargs) => { return yargs .option("test", { describe: "Test group id", type: "string", }) .option("run-script", { describe: "Path to a bash script to run before each test", type: "string", }) .option("graphql", { describe: "GraphQL endpoint serving the supergraph", type: "string", }) .option("healthcheck", { describe: "Health check endpoint", type: "string", }) .option("port", { describe: "Port to bind on", default: defaultPort, }) .option("no-server", { describe: "Skip starting the server", type: "boolean", default: false, }) .option("reporter", { describe: "Choose a reporter", choices: ["dot", "tap"], default: "tap", }) .option("junit", { describe: "Write test results to a JUnit XML file", type: "boolean", }) .demandOption("test") .demandOption("graphql") .demandOption("healthcheck") .demandOption("run-script"); }, async (argv) => { const abortSignal = new AbortController(); process.once("SIGINT", () => { if (!abortSignal.signal.aborted) { abortSignal.abort(); } }); process.once("SIGTERM", () => { if (!abortSignal.signal.aborted) { abortSignal.abort(); } }); const port = argv.port ?? (await getPort()); if (!argv["no-server"]) { await serve(port); } process.stdout.write("\n"); if (!existsSync(resolvePath(argv, "./logs"))) { mkdirSync(resolvePath(argv, "./logs")); } await killPortIfRunning(readPort(argv.graphql)).catch(() => {}); const logStream = createWriteStream( resolvePath(argv, `./logs/${argv.test}-gateway.log`), { flags: "w+", }, ); const gatewayExit = Promise.withResolvers<void>(); let gatewayExited = false; const gateway = spawn("sh", [argv.runScript, argv.test], { signal: abortSignal.signal, stdio: "pipe", cwd: dirname(resolvePath(argv, argv.runScript)), }); gateway.once("exit", () => { gatewayExited = true; gatewayExit.resolve(); }); gateway.stdout.pipe(logStream); gateway.stderr.pipe(logStream); const result = await runTest({ ...argv, reporter: argv.reporter === "tap" ? "tap" : "dot", junit: argv.junit, port, }); if (!gatewayExited) { gateway.kill(); } await gatewayExit.promise; process.stdout.write("\n"); if (result.includes("X")) { process.exit(1); } else { process.exit(0); } }, ) .command( "test", "run all test groups in a sequence", (yargs) => { return yargs .option("run-script", { describe: "Path to a bash script to run before each test", type: "string", }) .option("no-server", { describe: "Skip starting the server", type: "boolean", default: false, }) .option("graphql", { describe: "GraphQL endpoint serving the supergraph", type: "string", }) .option("healthcheck", { describe: "Health check endpoint", type: "string", }) .option("port", { describe: "Port to bind on", default: defaultPort, }) .option("reporter", { describe: "Choose a reporter", choices: ["dot", "tap"], default: "dot", }) .option("junit", { describe: "Write test results to a JUnit XML file", type: "boolean", }) .option("write", { describe: "Write test results to a file", type: "string", default: "results.txt", }) .option("exit-on-fail", { describe: "Exit with status 1 if any test fails", type: "boolean", default: false, }) .demandOption("graphql") .demandOption("healthcheck") .demandOption("run-script"); }, async (argv) => { const abortSignal = new AbortController(); process.once("SIGINT", () => { if (!abortSignal.signal.aborted) { abortSignal.abort(); } }); process.once("SIGTERM", () => { if (!abortSignal.signal.aborted) { abortSignal.abort(); } }); const port = argv.port ?? (await getPort()); if (!argv["no-server"]) { await serve(port); } const ids = await fetch(`http://localhost:${port}/ids`, { signal: abortSignal.signal, }).then((res) => res.json() as Promise<string[]>); const results: Array<{ id: string; result: Array<"." | "X">; }> = []; process.stdout.write("Running " + ids.length + " test suites\n"); if (!existsSync(resolvePath(argv, "./logs"))) { mkdirSync(resolvePath(argv, "./logs")); } process.stdout.write("\n"); for await (const id of ids) { await killPortIfRunning(readPort(argv.graphql)).catch(() => {}); const logStream = createWriteStream( resolvePath(argv, `./logs/${id}-gateway.log`), { flags: "w+", }, ); const gatewayExit = Promise.withResolvers<void>(); let gatewayExited = false; const gateway = spawn("sh", [argv.runScript, id], { signal: abortSignal.signal, stdio: "pipe", cwd: dirname(resolvePath(argv, argv.runScript)), }); gateway.once("exit", () => { gatewayExited = true; gatewayExit.resolve(); }); gateway.stdout.pipe(logStream); gateway.stderr.pipe(logStream); const result = await runTest({ test: id, graphql: argv.graphql, healthcheck: argv.healthcheck, port: argv.port, reporter: argv.reporter === "tap" ? "tap" : "dot", junit: argv.junit, cwd: argv.cwd, }); results.push({ id, result }); if (!gatewayExited) { gateway.kill(); } await gatewayExit.promise; } let total = 0; let passed = 0; let failed = 0; for (const { result } of results) { for (const test of result) { if (test === ".") { total++; passed++; } else if (test === "X") { total++; failed++; } } } process.stdout.write("\n\n\n"); process.stdout.write(styleText("bold", "Results") + "\n"); process.stdout.write("-----------\n"); process.stdout.write(`Total: ${total}\n`); process.stdout.write( `Passed: ${styleText("greenBright", passed + "")}\n`, ); if (failed > 0) { process.stdout.write( `Failed: ${styleText("redBright", failed + "")}\n`, ); } process.stdout.write("\n"); if (failed > 0) { process.stdout.write( styleText("redBright", "Your gateway is not fully compatible\n"), ); } else { process.stdout.write( styleText("greenBright", "Your gateway is fully compatible\n"), ); } writeFileSync( resolvePath(argv, argv.write), results .flatMap((r) => [r.id, r.result.join("")]) .concat([ "", "---", `Total: ${total}`, `Passed: ${passed}`, `Failed: ${failed}`, ]) .join("\n"), ); if (argv["exit-on-fail"] && failed > 0) { process.exit(1); } process.exit(0); }, ) .demandCommand(1) .parse(); async function runTest(args: { test: string; graphql: string; healthcheck?: string; port: number; reporter?: "dot" | "tap"; junit?: boolean; cwd: string; }): Promise<Array<"." | "X">> { process.stdout.write(`${args.test}\n`); process.env.TESTS_ENDPOINT = `http://localhost:${args.port}/${args.test}/tests`; process.env.GRAPHQL_ENDPOINT = args.graphql; process.env.TEST_SUITE = args.test; const logStream = createWriteStream( resolvePath({ cwd: args.cwd }, `./logs/${args.test}-tests.log`), { flags: "w+", }, ); if (args.healthcheck) { try { await waitOnGraphQL({ healthcheck: args.healthcheck, graphql: args.graphql, }); } catch (err) { logStream.write("\nHealth check failed\n"); } } const { resolve, promise } = Promise.withResolvers<Array<"." | "X">>(); const dotan = createDotReporter(resolve); const testStream = run({ concurrency: 1, files: [join(__dirname, `test${ext}`)], }); testStream.compose(args.reporter === "tap" ? tap : dot).pipe(process.stdout); testStream.compose(tap).pipe(logStream); testStream.compose(dotan); if (args.junit) { const reportsDir = resolvePath({ cwd: args.cwd }, "reports"); if (!existsSync(reportsDir)) { mkdirSync(reportsDir, { recursive: true }); } const junitPath = join(reportsDir, `${args.test}.xml`); const junitStream = createWriteStream(junitPath, { flags: "w+", }); testStream.compose(junit).pipe(junitStream); } return promise; } function createDotReporter(resolve: (value: Array<"." | "X">) => void) { const report: Array<"." | "X"> = []; return async function* dot(source: Parameters<typeof tap>[0]) { for await (const { type, data } of source) { if (data != null && "details" in data && data.details?.type === "suite") { continue; } if (type === "test:pass") { report.push("."); } if (type === "test:fail") { report.push("X"); } } resolve(report); }; } async function* dot(source: Parameters<typeof tap>[0]) { let count = 0; let columns = getLineLength(); const failedTests = []; for await (const { type, data } of source) { if (data != null && "details" in data && data.details?.type === "suite") { continue; } if (type === "test:pass") { yield styleText("green", "."); } if (type === "test:fail") { yield styleText("red", "X"); failedTests.push(data); } if ((type === "test:fail" || type === "test:pass") && ++count === columns) { yield "\n"; // Getting again in case the terminal was resized. columns = getLineLength(); count = 0; } } yield "\n"; } function getLineLength() { return Math.max(process.stdout.columns ?? 20, 20); } function readVersion(): string | undefined { try { // src/cli.ts return ( "v" + JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8")) .version ); } catch { // } try { // dist/cli.js return ( "v" + JSON.parse(readFileSync(join(__dirname, "./package.json"), "utf-8")) .version ); } catch { // } } async function waitOnGraphQL(endpoints: { healthcheck: string; graphql: string; }) { const timeout = process.env["GATEWAY_TIMEOUT"] ? parseInt(process.env["GATEWAY_TIMEOUT"]) : 5000; await waitOn({ // Make sure the health check is a GET request resources: [endpoints.healthcheck.replace("http://", "http-get://")], timeout, httpTimeout: 200, log: false, verbose: false, }); await retry(fetchTypename, { maxRetryTime: 2_000, retries: 20, maxTimeout: 500, minTimeout: 100, }); async function fetchTypename() { const response = await fetch(endpoints.graphql, { method: "POST", headers: { "Content-Type": "application/json", accept: "application/json", }, body: JSON.stringify({ operationName: "ping", query: /* GraphQL */ ` query ping { __typename } `, }), }); if (response.ok) { const json = await response.json(); if (json.data.__typename === "Query") { return; } } throw new Error("Failed to fetch __typename"); } } async function killPortIfRunning(port: number) { if (await detectPort(port)) { await killPortProcess(port); } }