Skip to content

Commit 9971a2d

Browse files
authored
Merge pull request #66 from sonukapoor/codex/issue-65-cli-integration-tests
test: add CLI integration tests
2 parents 6a6fd4b + 1a102ae commit 9971a2d

1 file changed

Lines changed: 270 additions & 0 deletions

File tree

tests/cli-integration.test.ts

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import { jest } from "@jest/globals";
2+
import type { Finding, PackageRef, ScanInput } from "../src/types.js";
3+
4+
const printBannerMock = jest.fn();
5+
const printHelpMock = jest.fn();
6+
const parseArgsMock = jest.fn();
7+
const loadPackagesMock = jest.fn();
8+
const buildNoPackagesMessageMock = jest.fn();
9+
const scanPackagesMock = jest.fn();
10+
const printCacheSummaryMock = jest.fn();
11+
const logInfoMock = jest.fn();
12+
const logWarnMock = jest.fn();
13+
const serializeFindingMock = jest.fn();
14+
const printSummaryMock = jest.fn();
15+
const printActionSummaryMock = jest.fn();
16+
const printPriorityFixesMock = jest.fn();
17+
const printFixPlanMock = jest.fn();
18+
const printCoverageMock = jest.fn();
19+
const printSkippedDependenciesMock = jest.fn();
20+
const printGroupSummaryMock = jest.fn();
21+
const printTableMock = jest.fn();
22+
const printPathHintsMock = jest.fn();
23+
const printFinalStatusMock = jest.fn();
24+
const printCompactOutputMock = jest.fn();
25+
26+
jest.unstable_mockModule("../src/cli/help.js", () => ({
27+
printBanner: printBannerMock,
28+
printHelp: printHelpMock,
29+
}));
30+
31+
jest.unstable_mockModule("../src/cli/args.js", () => ({
32+
parseArgs: parseArgsMock,
33+
}));
34+
35+
jest.unstable_mockModule("../src/parsers/index.js", () => ({
36+
loadPackages: loadPackagesMock,
37+
buildNoPackagesMessage: buildNoPackagesMessageMock,
38+
}));
39+
40+
jest.unstable_mockModule("../src/scanner.js", () => ({
41+
scanPackages: scanPackagesMock,
42+
buildCoverageNotes: jest.fn(() => ["Coverage note"]),
43+
}));
44+
45+
jest.unstable_mockModule("../src/output/formatters.js", () => ({
46+
logInfo: logInfoMock,
47+
logWarn: logWarnMock,
48+
printCacheSummary: printCacheSummaryMock,
49+
serializeFinding: serializeFindingMock,
50+
}));
51+
52+
jest.unstable_mockModule("../src/output/printers.js", () => ({
53+
printSummary: printSummaryMock,
54+
printActionSummary: printActionSummaryMock,
55+
printPriorityFixes: printPriorityFixesMock,
56+
printFixPlan: printFixPlanMock,
57+
printCoverage: printCoverageMock,
58+
printSkippedDependencies: printSkippedDependenciesMock,
59+
printGroupSummary: printGroupSummaryMock,
60+
printTable: printTableMock,
61+
printPathHints: printPathHintsMock,
62+
printFinalStatus: printFinalStatusMock,
63+
printCompactOutput: printCompactOutputMock,
64+
}));
65+
66+
function createScanInput(overrides?: Partial<ScanInput>): ScanInput {
67+
return {
68+
mode: "manifest-fallback",
69+
source: "package-json",
70+
filePath: "/tmp/project/package.json",
71+
packages: [],
72+
notes: ["Parser note"],
73+
warnings: [],
74+
skippedDependencies: [],
75+
...overrides,
76+
};
77+
}
78+
79+
function createFinding(overrides?: Partial<Finding>): Finding {
80+
return {
81+
pkg: {
82+
name: "lodash",
83+
version: "4.17.20",
84+
ecosystem: "npm",
85+
paths: [["project", "lodash"]],
86+
},
87+
vulnerabilities: [{ id: "OSV-123" }],
88+
severity: "critical",
89+
cveAliases: ["CVE-2026-0001"],
90+
dependencyPaths: [["project", "lodash"]],
91+
relationship: "direct",
92+
firstFixedVersion: "4.17.21",
93+
recommendedParentUpgrade: undefined,
94+
...overrides,
95+
};
96+
}
97+
98+
async function runIndexModule() {
99+
const exitSpy = jest
100+
.spyOn(process, "exit")
101+
.mockImplementation(((code?: number) => code as never) as never);
102+
const logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
103+
const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
104+
105+
try {
106+
await import(`../src/index.ts?test=${Date.now()}-${Math.random()}`);
107+
await new Promise(resolve => setTimeout(resolve, 0));
108+
} finally {
109+
}
110+
111+
const exitCalls = exitSpy.mock.calls.map(call => call[0]);
112+
const stdout = logSpy.mock.calls.map(call => call.map(value => String(value)).join(" "));
113+
const stderr = errorSpy.mock.calls.map(call => call.map(value => String(value)).join(" "));
114+
115+
exitSpy.mockRestore();
116+
logSpy.mockRestore();
117+
errorSpy.mockRestore();
118+
119+
if (exitCalls.length === 0) {
120+
throw new Error("index.ts did not call process.exit");
121+
}
122+
123+
return {
124+
exitCode: Number(exitCalls[exitCalls.length - 1] ?? 0),
125+
exitCalls,
126+
stdout,
127+
stderr,
128+
};
129+
}
130+
131+
describe("CLI integration", () => {
132+
beforeEach(() => {
133+
jest.clearAllMocks();
134+
parseArgsMock.mockReturnValue({
135+
options: {
136+
failOn: "critical",
137+
batchSize: "100",
138+
searchDepth: "4",
139+
minSeverity: "medium",
140+
},
141+
projectArg: ".",
142+
});
143+
buildNoPackagesMessageMock.mockReturnValue("No scannable packages were found.");
144+
loadPackagesMock.mockReturnValue(createScanInput());
145+
scanPackagesMock.mockResolvedValue([]);
146+
serializeFindingMock.mockImplementation((finding: Finding) => ({
147+
package: finding.pkg.name,
148+
severity: finding.severity,
149+
}));
150+
});
151+
152+
it("returns a json payload and exits successfully when no findings are present", async () => {
153+
const packages: PackageRef[] = [
154+
{ name: "lodash", version: "4.17.21", ecosystem: "npm", paths: [["project", "lodash"]] },
155+
];
156+
parseArgsMock.mockReturnValue({
157+
options: {
158+
json: true,
159+
failOn: "critical",
160+
batchSize: "100",
161+
searchDepth: "4",
162+
minSeverity: "medium",
163+
},
164+
projectArg: ".",
165+
});
166+
loadPackagesMock.mockReturnValue(createScanInput({ packages }));
167+
168+
const result = await runIndexModule();
169+
170+
expect(result.exitCode).toBe(0);
171+
expect(result.stdout[0]).toContain("Advisory source: OSV");
172+
expect(JSON.parse(result.stdout[result.stdout.length - 1] ?? "")).toMatchObject({
173+
mode: "manifest-fallback",
174+
source: "package-json",
175+
packageCount: 1,
176+
findingCount: 0,
177+
findings: [],
178+
});
179+
});
180+
181+
it("exits with a failure code when findings meet the fail-on threshold", async () => {
182+
const finding = createFinding();
183+
parseArgsMock.mockReturnValue({
184+
options: {
185+
json: true,
186+
failOn: "high",
187+
batchSize: "100",
188+
searchDepth: "4",
189+
minSeverity: "medium",
190+
},
191+
projectArg: ".",
192+
});
193+
loadPackagesMock.mockReturnValue(createScanInput({ packages: [finding.pkg] }));
194+
scanPackagesMock.mockResolvedValue([finding]);
195+
196+
const result = await runIndexModule();
197+
198+
expect(result.exitCode).toBe(1);
199+
expect(result.stdout[0]).toContain("Advisory source: OSV");
200+
expect(JSON.parse(result.stdout[result.stdout.length - 1] ?? "")).toMatchObject({
201+
findingCount: 1,
202+
findings: [{ package: "lodash", severity: "critical" }],
203+
});
204+
});
205+
206+
it("warns and exits cleanly when no scannable packages are found", async () => {
207+
loadPackagesMock.mockReturnValue(createScanInput({ packages: [] }));
208+
209+
const result = await runIndexModule();
210+
211+
expect(result.exitCode).toBe(0);
212+
expect(logWarnMock).toHaveBeenCalledWith("No scannable packages were found.", expect.anything());
213+
});
214+
215+
it("fails fast for an invalid osv url", async () => {
216+
parseArgsMock.mockReturnValue({
217+
options: {
218+
failOn: "critical",
219+
batchSize: "100",
220+
searchDepth: "4",
221+
minSeverity: "medium",
222+
osvUrl: "not-a-url",
223+
},
224+
projectArg: ".",
225+
});
226+
227+
const result = await runIndexModule();
228+
229+
expect(result.exitCode).toBe(1);
230+
expect(result.stderr.join("\n")).toContain("Invalid value for --osv-url: not-a-url");
231+
expect(loadPackagesMock).not.toHaveBeenCalled();
232+
});
233+
234+
it("routes verbose mode through the detailed printer pipeline", async () => {
235+
const finding = createFinding({ severity: "medium" });
236+
parseArgsMock.mockReturnValue({
237+
options: {
238+
verbose: true,
239+
failOn: "critical",
240+
batchSize: "100",
241+
searchDepth: "4",
242+
minSeverity: "medium",
243+
all: false,
244+
},
245+
projectArg: ".",
246+
});
247+
loadPackagesMock.mockReturnValue(
248+
createScanInput({
249+
packages: [finding.pkg],
250+
warnings: ["Manifest fallback warning"],
251+
skippedDependencies: ["dependencies:debug@^4.3.0"],
252+
}),
253+
);
254+
scanPackagesMock.mockResolvedValue([finding]);
255+
256+
const result = await runIndexModule();
257+
258+
expect(result.exitCode).toBe(0);
259+
expect(logWarnMock).toHaveBeenCalledWith("Manifest fallback warning", expect.anything());
260+
expect(printSummaryMock).toHaveBeenCalled();
261+
expect(printActionSummaryMock).toHaveBeenCalled();
262+
expect(printPriorityFixesMock).toHaveBeenCalled();
263+
expect(printFixPlanMock).toHaveBeenCalled();
264+
expect(printCoverageMock).toHaveBeenCalledWith(["Parser note", "Coverage note"]);
265+
expect(printSkippedDependenciesMock).toHaveBeenCalledWith(["dependencies:debug@^4.3.0"]);
266+
expect(printTableMock).toHaveBeenCalled();
267+
expect(printFinalStatusMock).toHaveBeenCalled();
268+
expect(printCompactOutputMock).not.toHaveBeenCalled();
269+
});
270+
});

0 commit comments

Comments
 (0)