UNPKG

@graphql-hive/federation-gateway-audit

Version:
548 lines (546 loc) 15.9 kB
#!/usr/bin/env node import { writeFileSync, existsSync, mkdirSync, createWriteStream, 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 { tap, junit } 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'; import 'fets'; import 'node:http'; process.removeAllListeners("warning"); const __dirname$1 = dirname(fileURLToPath(import.meta.url)); const ext = extname(fileURLToPath(import.meta.url)); const defaultPort = 4200; function readPort(address) { return parseInt(new URL(address).port, 10); } function resolvePath(argv, path) { 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", (yargs2) => { return yargs2.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", (yargs2) => { return yargs2.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", (yargs2) => { return yargs2.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", (yargs2) => { return yargs2.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(); 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", () => { gatewayExit.resolve(); }); await gatewayExit.promise; } ).command( "test-suite", "run a test group", (yargs2) => { return yargs2.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(); 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", (yargs2) => { return yargs2.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()); const results = []; 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(); 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} `); process.stdout.write( `Passed: ${styleText("greenBright", passed + "")} ` ); if (failed > 0) { process.stdout.write( `Failed: ${styleText("redBright", failed + "")} ` ); } 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) { process.stdout.write(`${args.test} `); 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(); const dotan = createDotReporter(resolve); const testStream = run({ concurrency: 1, files: [join(__dirname$1, `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) { const report = []; return async function* dot2(source) { 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) { let count = 0; let columns = getLineLength(); 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"); } if ((type === "test:fail" || type === "test:pass") && ++count === columns) { yield "\n"; columns = getLineLength(); count = 0; } } yield "\n"; } function getLineLength() { return Math.max(process.stdout.columns ?? 20, 20); } function readVersion() { try { return "v" + JSON.parse(readFileSync(join(__dirname$1, "../package.json"), "utf-8")).version; } catch { } try { return "v" + JSON.parse(readFileSync(join(__dirname$1, "./package.json"), "utf-8")).version; } catch { } } async function waitOnGraphQL(endpoints) { const timeout = process.env["GATEWAY_TIMEOUT"] ? parseInt(process.env["GATEWAY_TIMEOUT"]) : 5e3; 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: 2e3, 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) { if (await detectPort(port)) { await killPortProcess(port); } }