@graphql-hive/federation-gateway-audit
Version:
Audit tool for Apollo Federation Gateway
548 lines (546 loc) • 15.9 kB
JavaScript
#!/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);
}
}