From e7a38ca96c59f0098f2cf47c47d0194bda68ce61 Mon Sep 17 00:00:00 2001 From: sebastian-tello Date: Wed, 17 Dec 2025 12:20:04 +0100 Subject: [PATCH 1/4] test: add integration tests for cli prior to refactoring --- src/__tests__/cli.test.ts | 58 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/__tests__/cli.test.ts diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts new file mode 100644 index 0000000..ffda44d --- /dev/null +++ b/src/__tests__/cli.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { spawn } from "node:child_process"; +import path from "node:path"; + +const fixturesDir = path.join(__dirname, "fixtures"); +const cliPath = path.join(__dirname, "..", "cli.ts"); +const tsxCli = require.resolve("tsx/cli"); + +const runCli = ( + oasPath: string, + pactPath: string, +): Promise<{ exitCode: number; stdout: string; stderr: string }> => { + return new Promise((resolve) => { + const child = spawn( + process.execPath, + [tsxCli, cliPath, oasPath, pactPath], + { + cwd: fixturesDir, + }, + ); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (data) => { + stdout += data.toString(); + }); + child.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + child.on("close", (exitCode) => { + resolve({ exitCode: exitCode ?? 0, stdout, stderr }); + }); + }); +}; + +describe("CLI integration", () => { + it("should exit with code 0 when no errors are found", async () => { + const { exitCode, stdout } = await runCli( + path.join("example-petstore-valid", "oas.yaml"), + path.join("example-petstore-valid", "pact.json"), + ); + + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe("[]"); + }); + + it("should exit with non-zero code when errors are found", async () => { + const { exitCode, stdout } = await runCli( + path.join("example-petstore-invalid", "oas.yaml"), + path.join("example-petstore-invalid", "pact.json"), + ); + + expect(exitCode).toBe(1); + expect(stdout).toContain('"type":"error"'); + expect(stdout).toContain('"code":"response.status.unknown"'); + }); +}); From ff291bd0251bc16d0de39e404f04e7565122bef2 Mon Sep 17 00:00:00 2001 From: sebastian-tello Date: Wed, 17 Dec 2025 12:25:58 +0100 Subject: [PATCH 2/4] refactor(cli): separate command parsing logic from executin details The existing implementation was not unit-tested (and hard to test). Following the single responsibility principle, and separating the command parsing logic from its execution allows the implementation to be fully tested with unit-tests. --- src/cli.ts | 39 +------ src/cli/runner.test.ts | 250 +++++++++++++++++++++++++++++++++++++++++ src/cli/runner.ts | 64 +++++++++++ 3 files changed, 319 insertions(+), 34 deletions(-) create mode 100644 src/cli/runner.test.ts create mode 100644 src/cli/runner.ts diff --git a/src/cli.ts b/src/cli.ts index 01eb3ec..b3073d8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,23 +1,8 @@ #!/usr/bin/env node import { program } from "commander"; -import yaml from "js-yaml"; -import fs from "node:fs"; -import { Comparator } from "./index"; import packageJson from "../package.json"; - -const readAndParse = async (filename: string) => { - const file = await fs.promises.readFile(filename, { encoding: "utf-8" }); - try { - return JSON.parse(file); - } catch (error) { - try { - return yaml.load(file); - } catch (_err) { - throw error; - } - } -}; +import { Runner } from "./cli/runner"; program .version(packageJson.version) @@ -31,23 +16,9 @@ The exit code equals the number of Pact files with errors (not the number of err ) .argument("", "path to OAS file") .argument("", "path(s) to Pact file(s)") - .action(async (oasPath, pactPaths) => { - const oas = await readAndParse(oasPath); - const comparator = new Comparator(oas); - - let errors = 0; - for (const pactPath of pactPaths) { - const pact = await readAndParse(pactPath); - - const results = []; - for await (const result of comparator.compare(pact)) { - results.push(result); - } - - errors += results.some((r) => r.type === "error") ? 1 : 0; - console.log(JSON.stringify(results)); - } - - process.exit(errors); + .action(async (oasPath: string, pactPaths: string[]) => { + const runner = new Runner(); + const exitCode = await runner.run(oasPath, pactPaths); + process.exit(exitCode); }) .parse(process.argv); diff --git a/src/cli/runner.test.ts b/src/cli/runner.test.ts new file mode 100644 index 0000000..05a0f93 --- /dev/null +++ b/src/cli/runner.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it, vi, beforeEach, type Mock } from "vitest"; +import yaml from "js-yaml"; +import { Runner } from "./runner"; +import type { Result } from "#results/index"; + +describe("Runner", () => { + const defaultOasContent = JSON.stringify({ openapi: "3.0.0" }); + const defaultPactContent = JSON.stringify({ interactions: [] }); + + let readFile: Mock; + let output: Mock; + let compare: Mock; + let createComparator: Mock; + + const givenReadFileReturns = (...contents: string[]) => { + readFile.mockReset(); + for (const content of contents) { + readFile.mockResolvedValueOnce(content); + } + }; + + const givenCompareReturns = (...resultsPerCall: Result[][]) => { + let callCount = 0; + compare.mockImplementation(async function* () { + const results = resultsPerCall[callCount] ?? []; + callCount++; + for (const result of results) { + yield result; + } + }); + }; + + beforeEach(() => { + readFile = vi.fn(); + output = vi.fn(); + compare = vi.fn(); + createComparator = vi.fn().mockReturnValue({ compare }); + givenReadFileReturns(defaultOasContent, defaultPactContent); + givenCompareReturns([]); + }); + + const whenRunIsCalled = (oasPath: string, pactPaths: string[]) => { + const runner = new Runner({ readFile, output, createComparator }); + return runner.run(oasPath, pactPaths); + }; + + describe("reading and parsing input files", () => { + it("should parse JSON content", async () => { + const oasDocument = { + openapi: "3.0.0", + info: { title: "Test", version: "1.0" }, + }; + const pactDocument = { interactions: [] }; + const oasJsonContent = JSON.stringify(oasDocument); + const pactJsonContent = JSON.stringify(pactDocument); + givenReadFileReturns(oasJsonContent, pactJsonContent); + + await whenRunIsCalled("oas.json", ["pact.json"]); + + expect(createComparator).toHaveBeenCalledWith(oasDocument); + expect(compare).toHaveBeenCalledWith(pactDocument); + }); + + it("should parse YAML content when JSON parsing fails", async () => { + const oasDocument = { + openapi: "3.0.0", + info: { title: "Test API", version: "1.0" }, + }; + const yamlContent = yaml.dump(oasDocument); + givenReadFileReturns(yamlContent, defaultPactContent); + + await whenRunIsCalled("oas.yaml", ["pact.json"]); + + expect(createComparator).toHaveBeenCalledWith(oasDocument); + }); + + it("should throw JSON error when both JSON and YAML parsing fail", async () => { + givenReadFileReturns("{ invalid json and not valid yaml either: ["); + + await expect( + whenRunIsCalled("invalid.txt", ["pact.json"]), + ).rejects.toThrow(SyntaxError); + }); + }); + + describe("Comparator initialization", () => { + it("should initialize Comparator with parsed OAS document", async () => { + const oasDocument = { openapi: "3.0.0", paths: {} }; + givenReadFileReturns(JSON.stringify(oasDocument), defaultPactContent); + + await whenRunIsCalled("oas.json", ["pact.json"]); + + expect(createComparator).toHaveBeenCalledTimes(1); + expect(createComparator).toHaveBeenCalledWith(oasDocument); + }); + + it("should reuse same Comparator instance for multiple pact files", async () => { + const oasDocument = { openapi: "3.0.0" }; + const pactDocument1 = { interactions: [], name: "pact1" }; + const pactDocument2 = { interactions: [], name: "pact2" }; + givenReadFileReturns( + JSON.stringify(oasDocument), + JSON.stringify(pactDocument1), + JSON.stringify(pactDocument2), + ); + + await whenRunIsCalled("oas.json", ["pact1.json", "pact2.json"]); + + expect(createComparator).toHaveBeenCalledTimes(1); + expect(compare).toHaveBeenCalledTimes(2); + expect(compare).toHaveBeenNthCalledWith(1, pactDocument1); + expect(compare).toHaveBeenNthCalledWith(2, pactDocument2); + }); + }); + + describe("compare results handling", () => { + it("should output results as JSON to stdout", async () => { + const mockResults: Result[] = [ + { + type: "warning", + code: "request.header.unknown", + message: "Test warning", + }, + ]; + givenCompareReturns(mockResults); + + await whenRunIsCalled("oas.json", ["pact.json"]); + + expect(output).toHaveBeenCalledWith(JSON.stringify(mockResults)); + }); + + it("should output separate JSON lines for each pact file", async () => { + const results1: Result[] = [ + { type: "warning", code: "request.header.unknown", message: "warn 1" }, + ]; + const results2: Result[] = [ + { type: "warning", code: "request.query.unknown", message: "warn 2" }, + ]; + givenCompareReturns(results1, results2); + givenReadFileReturns( + defaultOasContent, + defaultPactContent, + defaultPactContent, + ); + + await whenRunIsCalled("oas.json", ["pact1.json", "pact2.json"]); + + expect(output).toHaveBeenCalledTimes(2); + expect(output).toHaveBeenNthCalledWith(1, JSON.stringify(results1)); + expect(output).toHaveBeenNthCalledWith(2, JSON.stringify(results2)); + }); + }); + + describe("exit codes", () => { + it("should return 0 when no errors are found", async () => { + givenCompareReturns([ + { type: "warning", code: "request.header.unknown", message: "warning" }, + ]); + + const exitCode = await whenRunIsCalled("oas.json", ["pact.json"]); + + expect(exitCode).toBe(0); + }); + + it("should return 1 when one pact file has errors", async () => { + givenCompareReturns([ + { + type: "error", + code: "request.body.incompatible", + message: "An error", + }, + ]); + + const exitCode = await whenRunIsCalled("oas.json", ["pact.json"]); + + expect(exitCode).toBe(1); + }); + + it("should return count of pact files that have errors", async () => { + givenCompareReturns( + [ + { + type: "error", + code: "request.body.incompatible", + message: "error", + }, + ], + [ + { + type: "warning", + code: "request.header.unknown", + message: "warning", + }, + ], + [ + { + type: "error", + code: "request.body.incompatible", + message: "error", + }, + ], + ); + givenReadFileReturns( + defaultOasContent, + defaultPactContent, + defaultPactContent, + defaultPactContent, + ); + + const exitCode = await whenRunIsCalled("oas.json", [ + "pact1.json", + "pact2.json", + "pact3.json", + ]); + + expect(exitCode).toBe(2); + }); + + it("should count only one error per pact file even with multiple errors", async () => { + givenCompareReturns([ + { + type: "error", + code: "request.body.incompatible", + message: "First error", + }, + { + type: "error", + code: "request.header.incompatible", + message: "Second error", + }, + { + type: "error", + code: "request.query.incompatible", + message: "Third error", + }, + ]); + + const exitCode = await whenRunIsCalled("oas.json", ["pact.json"]); + + expect(exitCode).toBe(1); + }); + }); + + describe("default dependencies", () => { + it("should use default dependencies when none provided", () => { + const runner = new Runner(); + expect(runner).toBeInstanceOf(Runner); + }); + }); +}); diff --git a/src/cli/runner.ts b/src/cli/runner.ts new file mode 100644 index 0000000..3980f48 --- /dev/null +++ b/src/cli/runner.ts @@ -0,0 +1,64 @@ +import type { OpenAPIV2, OpenAPIV3 } from "openapi-types"; +import yaml from "js-yaml"; +import fs from "node:fs"; +import { Comparator } from "../index"; +import type { Result } from "#results/index"; + +type OASDocument = OpenAPIV2.Document | OpenAPIV3.Document; + +export interface ComparatorLike { + compare(pact: unknown): AsyncGenerator; +} + +export interface RunnerDependencies { + readFile: (path: string) => Promise; + output: (message: string) => void; + createComparator: (oas: OASDocument) => ComparatorLike; +} + +const defaultDependencies: RunnerDependencies = { + readFile: (path: string) => fs.promises.readFile(path, { encoding: "utf-8" }), + output: (message: string) => console.log(message), + createComparator: (oas: OASDocument) => new Comparator(oas), +}; + +export class Runner { + private deps: RunnerDependencies; + + constructor(deps: Partial = {}) { + this.deps = { ...defaultDependencies, ...deps }; + } + + private async readAndParse(filename: string): Promise { + const file = await this.deps.readFile(filename); + try { + return JSON.parse(file); + } catch (error) { + try { + return yaml.load(file); + } catch (_err) { + throw error; + } + } + } + + async run(oasPath: string, pactPaths: string[]): Promise { + const oas = (await this.readAndParse(oasPath)) as OASDocument; + const comparator = this.deps.createComparator(oas); + + let errors = 0; + for (const pactPath of pactPaths) { + const pact = await this.readAndParse(pactPath); + + const results: Result[] = []; + for await (const result of comparator.compare(pact)) { + results.push(result); + } + + errors += results.some((r) => r.type === "error") ? 1 : 0; + this.deps.output(JSON.stringify(results)); + } + + return errors; + } +} From 4a22c198a19cf8db75b2a71ff0649e23a66857a6 Mon Sep 17 00:00:00 2001 From: sebastian-tello Date: Wed, 17 Dec 2025 12:45:08 +0100 Subject: [PATCH 3/4] fix: cap exit code at 255 to avoid overflowing back to 0 --- src/cli/runner.test.ts | 19 +++++++++++++++++++ src/cli/runner.ts | 4 +++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/cli/runner.test.ts b/src/cli/runner.test.ts index 05a0f93..1f3153f 100644 --- a/src/cli/runner.test.ts +++ b/src/cli/runner.test.ts @@ -239,6 +239,25 @@ describe("Runner", () => { expect(exitCode).toBe(1); }); + + it("should cap exit code at 255 to avoid shell overflow", async () => { + const pactCount = 300; + const errorResults: Result[] = [ + { type: "error", code: "request.body.incompatible", message: "error" }, + ]; + givenCompareReturns(...Array(pactCount).fill(errorResults)); + givenReadFileReturns( + defaultOasContent, + ...Array(pactCount).fill(defaultPactContent), + ); + + const exitCode = await whenRunIsCalled( + "oas.json", + Array(pactCount).fill("pact.json"), + ); + + expect(exitCode).toBe(255); + }); }); describe("default dependencies", () => { diff --git a/src/cli/runner.ts b/src/cli/runner.ts index 3980f48..4ff12ce 100644 --- a/src/cli/runner.ts +++ b/src/cli/runner.ts @@ -6,6 +6,8 @@ import type { Result } from "#results/index"; type OASDocument = OpenAPIV2.Document | OpenAPIV3.Document; +const MAX_EXIT_CODE = 255; + export interface ComparatorLike { compare(pact: unknown): AsyncGenerator; } @@ -59,6 +61,6 @@ export class Runner { this.deps.output(JSON.stringify(results)); } - return errors; + return Math.min(errors, MAX_EXIT_CODE); } } From fc8825a214682bc182c843bcee3d27aec17a6659 Mon Sep 17 00:00:00 2001 From: sebastian-tello Date: Wed, 17 Dec 2025 14:11:59 +0100 Subject: [PATCH 4/4] feat(cli): support URLs for cli input arguments --- .changeset/thirty-clowns-call.md | 8 +++ CONTRIBUTING.md | 2 +- src/__tests__/cli.test.ts | 37 +++++++++- src/cli.ts | 4 +- src/cli/runner.test.ts | 113 ++++++++++++++++++++++++------- src/cli/runner.ts | 34 ++++++++-- 6 files changed, 164 insertions(+), 34 deletions(-) create mode 100644 .changeset/thirty-clowns-call.md diff --git a/.changeset/thirty-clowns-call.md b/.changeset/thirty-clowns-call.md new file mode 100644 index 0000000..ba8d856 --- /dev/null +++ b/.changeset/thirty-clowns-call.md @@ -0,0 +1,8 @@ +--- +"@pactflow/openapi-pact-comparator": minor +--- + +Support URLs for OAS and Pact file arguments + +- CLI now accepts http:// and https:// URLs for both OAS and Pact files +- Fixed exit code overflow by capping at 255 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f9d07dc..9f46876 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,4 +12,4 @@ npm run test Then, when you're ready for a PR, use [changesets](https://github.com/changesets/changesets) to describe your PR. -Simply `npm run changesets:add` and follow the prompts. +Simply `npm run changeset:add` and follow the prompts. diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index ffda44d..30b3bcb 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -1,11 +1,36 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, beforeAll, afterAll } from "vitest"; import { spawn } from "node:child_process"; +import { createServer, type Server } from "node:http"; +import { readFileSync } from "node:fs"; import path from "node:path"; const fixturesDir = path.join(__dirname, "fixtures"); const cliPath = path.join(__dirname, "..", "cli.ts"); const tsxCli = require.resolve("tsx/cli"); +let server: Server; +let baseUrl: string; + +beforeAll(async () => { + server = createServer((req, res) => { + const filePath = path.join(fixturesDir, req.url!); + try { + const content = readFileSync(filePath); + res.writeHead(200); + res.end(content); + } catch { + res.writeHead(404); + res.end("Not Found"); + } + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const addr = server.address() as { port: number }; + baseUrl = `http://127.0.0.1:${addr.port}`; +}); + +afterAll(() => server.close()); + const runCli = ( oasPath: string, pactPath: string, @@ -55,4 +80,14 @@ describe("CLI integration", () => { expect(stdout).toContain('"type":"error"'); expect(stdout).toContain('"code":"response.status.unknown"'); }); + + it("should fetch OAS and Pact from URLs", async () => { + const oasUrl = `${baseUrl}/example-petstore-valid/oas.yaml`; + const pactUrl = `${baseUrl}/example-petstore-valid/pact.json`; + + const { exitCode, stdout } = await runCli(oasUrl, pactUrl); + + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe("[]"); + }); }); diff --git a/src/cli.ts b/src/cli.ts index b3073d8..fa89739 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,8 +14,8 @@ Comparison output is presented as ND-JSON, with one line per Pact file. The exit code equals the number of Pact files with errors (not the number of errors in one comparison).`, ) - .argument("", "path to OAS file") - .argument("", "path(s) to Pact file(s)") + .argument("", "path or URL to OAS file") + .argument("", "path(s) or URL(s) to Pact file(s)") .action(async (oasPath: string, pactPaths: string[]) => { const runner = new Runner(); const exitCode = await runner.run(oasPath, pactPaths); diff --git a/src/cli/runner.test.ts b/src/cli/runner.test.ts index 1f3153f..bb73952 100644 --- a/src/cli/runner.test.ts +++ b/src/cli/runner.test.ts @@ -7,21 +7,32 @@ describe("Runner", () => { const defaultOasContent = JSON.stringify({ openapi: "3.0.0" }); const defaultPactContent = JSON.stringify({ interactions: [] }); - let readFile: Mock; - let output: Mock; - let compare: Mock; - let createComparator: Mock; + let readFileMock: Mock; + let fetchMock: Mock; + let outputMock: Mock; + let compareMock: Mock; + let createComparatorMock: Mock; const givenReadFileReturns = (...contents: string[]) => { - readFile.mockReset(); + readFileMock.mockReset(); for (const content of contents) { - readFile.mockResolvedValueOnce(content); + readFileMock.mockResolvedValueOnce(content); + } + }; + + const givenFetchReturns = (...contents: string[]) => { + fetchMock.mockReset(); + for (const content of contents) { + fetchMock.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(content), + }); } }; const givenCompareReturns = (...resultsPerCall: Result[][]) => { let callCount = 0; - compare.mockImplementation(async function* () { + compareMock.mockImplementation(async function* () { const results = resultsPerCall[callCount] ?? []; callCount++; for (const result of results) { @@ -31,20 +42,26 @@ describe("Runner", () => { }; beforeEach(() => { - readFile = vi.fn(); - output = vi.fn(); - compare = vi.fn(); - createComparator = vi.fn().mockReturnValue({ compare }); + readFileMock = vi.fn(); + fetchMock = vi.fn(); + outputMock = vi.fn(); + compareMock = vi.fn(); + createComparatorMock = vi.fn().mockReturnValue({ compare: compareMock }); givenReadFileReturns(defaultOasContent, defaultPactContent); givenCompareReturns([]); }); const whenRunIsCalled = (oasPath: string, pactPaths: string[]) => { - const runner = new Runner({ readFile, output, createComparator }); + const runner = new Runner({ + readFile: readFileMock, + fetch: fetchMock, + output: outputMock, + createComparator: createComparatorMock, + }); return runner.run(oasPath, pactPaths); }; - describe("reading and parsing input files", () => { + describe("reading and parsing inputs", () => { it("should parse JSON content", async () => { const oasDocument = { openapi: "3.0.0", @@ -57,8 +74,8 @@ describe("Runner", () => { await whenRunIsCalled("oas.json", ["pact.json"]); - expect(createComparator).toHaveBeenCalledWith(oasDocument); - expect(compare).toHaveBeenCalledWith(pactDocument); + expect(createComparatorMock).toHaveBeenCalledWith(oasDocument); + expect(compareMock).toHaveBeenCalledWith(pactDocument); }); it("should parse YAML content when JSON parsing fails", async () => { @@ -71,7 +88,7 @@ describe("Runner", () => { await whenRunIsCalled("oas.yaml", ["pact.json"]); - expect(createComparator).toHaveBeenCalledWith(oasDocument); + expect(createComparatorMock).toHaveBeenCalledWith(oasDocument); }); it("should throw JSON error when both JSON and YAML parsing fail", async () => { @@ -81,6 +98,50 @@ describe("Runner", () => { whenRunIsCalled("invalid.txt", ["pact.json"]), ).rejects.toThrow(SyntaxError); }); + + it.each([ + ["https URL", "https://example.com/oas.yaml"], + ["http URL", "http://example.com/oas.yaml"], + ["HTTPS URL (uppercase)", "HTTPS://example.com/oas.yaml"], + ])( + "should fetch content when OAS path is a %s", + async (_description, oasUrl) => { + const oasDocument = { openapi: "3.0.0" }; + givenFetchReturns(JSON.stringify(oasDocument)); + + await whenRunIsCalled(oasUrl, ["pact.json"]); + + expect(fetchMock).toHaveBeenCalledWith(oasUrl); + expect(readFileMock).not.toHaveBeenCalledWith(oasUrl); + expect(createComparatorMock).toHaveBeenCalledWith(oasDocument); + }, + ); + + it("should fetch content when Pact path is a URL", async () => { + const pactUrl = "https://example.com/pact.json"; + const pactDocument = { interactions: [] }; + givenFetchReturns(JSON.stringify(pactDocument)); + + await whenRunIsCalled("oas.json", [pactUrl]); + + expect(fetchMock).toHaveBeenCalledWith(pactUrl); + expect(readFileMock).not.toHaveBeenCalledWith(pactUrl); + expect(compareMock).toHaveBeenCalledWith(pactDocument); + }); + + it("should throw error when URL response status is not ok", async () => { + const oasUrl = "https://example.com/oas.yaml"; + fetchMock.mockResolvedValue({ + ok: false, + status: 404, + statusText: "Not Found", + text: () => Promise.resolve("Not Found"), + }); + + await expect(whenRunIsCalled(oasUrl, ["pact.json"])).rejects.toThrow( + "HTTP 404: Not Found", + ); + }); }); describe("Comparator initialization", () => { @@ -90,8 +151,8 @@ describe("Runner", () => { await whenRunIsCalled("oas.json", ["pact.json"]); - expect(createComparator).toHaveBeenCalledTimes(1); - expect(createComparator).toHaveBeenCalledWith(oasDocument); + expect(createComparatorMock).toHaveBeenCalledTimes(1); + expect(createComparatorMock).toHaveBeenCalledWith(oasDocument); }); it("should reuse same Comparator instance for multiple pact files", async () => { @@ -106,10 +167,10 @@ describe("Runner", () => { await whenRunIsCalled("oas.json", ["pact1.json", "pact2.json"]); - expect(createComparator).toHaveBeenCalledTimes(1); - expect(compare).toHaveBeenCalledTimes(2); - expect(compare).toHaveBeenNthCalledWith(1, pactDocument1); - expect(compare).toHaveBeenNthCalledWith(2, pactDocument2); + expect(createComparatorMock).toHaveBeenCalledTimes(1); + expect(compareMock).toHaveBeenCalledTimes(2); + expect(compareMock).toHaveBeenNthCalledWith(1, pactDocument1); + expect(compareMock).toHaveBeenNthCalledWith(2, pactDocument2); }); }); @@ -126,7 +187,7 @@ describe("Runner", () => { await whenRunIsCalled("oas.json", ["pact.json"]); - expect(output).toHaveBeenCalledWith(JSON.stringify(mockResults)); + expect(outputMock).toHaveBeenCalledWith(JSON.stringify(mockResults)); }); it("should output separate JSON lines for each pact file", async () => { @@ -145,9 +206,9 @@ describe("Runner", () => { await whenRunIsCalled("oas.json", ["pact1.json", "pact2.json"]); - expect(output).toHaveBeenCalledTimes(2); - expect(output).toHaveBeenNthCalledWith(1, JSON.stringify(results1)); - expect(output).toHaveBeenNthCalledWith(2, JSON.stringify(results2)); + expect(outputMock).toHaveBeenCalledTimes(2); + expect(outputMock).toHaveBeenNthCalledWith(1, JSON.stringify(results1)); + expect(outputMock).toHaveBeenNthCalledWith(2, JSON.stringify(results2)); }); }); diff --git a/src/cli/runner.ts b/src/cli/runner.ts index 4ff12ce..09c5fd2 100644 --- a/src/cli/runner.ts +++ b/src/cli/runner.ts @@ -14,12 +14,14 @@ export interface ComparatorLike { export interface RunnerDependencies { readFile: (path: string) => Promise; + fetch: (url: string) => Promise; output: (message: string) => void; createComparator: (oas: OASDocument) => ComparatorLike; } const defaultDependencies: RunnerDependencies = { readFile: (path: string) => fs.promises.readFile(path, { encoding: "utf-8" }), + fetch: (url: string) => fetch(url), output: (message: string) => console.log(message), createComparator: (oas: OASDocument) => new Comparator(oas), }; @@ -31,19 +33,43 @@ export class Runner { this.deps = { ...defaultDependencies, ...deps }; } - private async readAndParse(filename: string): Promise { - const file = await this.deps.readFile(filename); + private isUrl(path: string): boolean { try { - return JSON.parse(file); + const url = new URL(path); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } + } + + private async readContent(path: string): Promise { + if (this.isUrl(path)) { + const response = await this.deps.fetch(path); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return response.text(); + } + return this.deps.readFile(path); + } + + private parseContent(content: string): unknown { + try { + return JSON.parse(content); } catch (error) { try { - return yaml.load(file); + return yaml.load(content); } catch (_err) { throw error; } } } + private async readAndParse(path: string): Promise { + const content = await this.readContent(path); + return this.parseContent(content); + } + async run(oasPath: string, pactPaths: string[]): Promise { const oas = (await this.readAndParse(oasPath)) as OASDocument; const comparator = this.deps.createComparator(oas);