From dced2613edaae5f53e1435aad352443e116b21e8 Mon Sep 17 00:00:00 2001 From: saimanikantavarma Date: Wed, 22 Jul 2026 19:52:44 +0530 Subject: [PATCH] feat(cli): add --timeout flag to demo command --- src/commands/demo.ts | 13 ++++++- tests/ci-issue.test.ts | 14 +++---- tests/demo-command.test.ts | 79 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 8 deletions(-) create mode 100644 tests/demo-command.test.ts diff --git a/src/commands/demo.ts b/src/commands/demo.ts index 823e6e7..704fa6f 100644 --- a/src/commands/demo.ts +++ b/src/commands/demo.ts @@ -76,14 +76,23 @@ export function registerDemoCommands(program: Command): void { .command("demo") .description("Quick interactive demo — scan your MCP servers and see your safety grade") .option("--server ", `Demo with a built-in server: ${DEMO_SERVERS.map(s => s.name).join(", ")}`) + .option("--timeout ", "Timeout in milliseconds", "15000") .option("--deep", "Run full tool invocation and security checks (takes longer)", false) .option("--no-color", "Disable colored output") - .action(async (options: { server?: string; deep?: boolean }) => { + .action(async (options: { server?: string; deep?: boolean; timeout?: string }) => { const sessionId = generateSessionId(); recordSessionStart(sessionId); const t0 = Date.now(); const bin = getBinName(); + const timeoutMs = parseInt(options.timeout ?? "15000", 10); + if (isNaN(timeoutMs) || timeoutMs <= 0 || String(timeoutMs) !== (options.timeout ?? "15000")) { + process.stderr.write(`\n ${c(ANSI.red, "✗")} Invalid timeout value: must be a positive integer number.\n\n`); + process.exitCode = 1; + recordSessionEnd(sessionId); + return; + } + if (!isQuiet()) { process.stdout.write(LOGO + "\n"); } @@ -158,6 +167,8 @@ export function registerDemoCommands(program: Command): void { ); } + targetConfig.timeoutMs = timeoutMs; + process.stdout.write("\n"); // ── Step 2: Run scan ─────────────────────────────────────────────── diff --git a/tests/ci-issue.test.ts b/tests/ci-issue.test.ts index 09a01be..a6c652c 100644 --- a/tests/ci-issue.test.ts +++ b/tests/ci-issue.test.ts @@ -196,10 +196,10 @@ describe("createOrUpdateIssue", () => { describe("execCommand", () => { it("resolves with stdout and stderr on success", async () => { - mockedExecFile.mockImplementation( + (mockedExecFile as unknown as { mockImplementation: (fn: (cmd: string, args: string[], callback: (err: Error | null, stdout: string, stderr: string) => void) => unknown) => void }).mockImplementation( (_cmd, _args, callback) => { callback(null, "output", ""); - return {} as never; + return {}; }, ); const { execCommand } = await import("../src/ci-issue.js"); @@ -208,10 +208,10 @@ describe("execCommand", () => { }); it("rejects with error on failure", async () => { - mockedExecFile.mockImplementation( + (mockedExecFile as unknown as { mockImplementation: (fn: (cmd: string, args: string[], callback: (err: Error | null, stdout: string, stderr: string) => void) => unknown) => void }).mockImplementation( (_cmd, _args, callback) => { callback(new Error("command not found"), "", ""); - return {} as never; + return {}; }, ); const { execCommand } = await import("../src/ci-issue.js"); @@ -219,10 +219,10 @@ describe("execCommand", () => { }); it("handles null stdout/stderr from execFile", async () => { - mockedExecFile.mockImplementation( + (mockedExecFile as unknown as { mockImplementation: (fn: (cmd: string, args: string[], callback: (err: Error | null, stdout: unknown, stderr: unknown) => void) => unknown) => void }).mockImplementation( (_cmd, _args, callback) => { - callback(null, null as never, null as never); - return {} as never; + callback(null, null, null); + return {}; }, ); const { execCommand } = await import("../src/ci-issue.js"); diff --git a/tests/demo-command.test.ts b/tests/demo-command.test.ts new file mode 100644 index 0000000..3c3db51 --- /dev/null +++ b/tests/demo-command.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import { Command } from "commander"; +import { registerDemoCommands } from "../src/commands/demo.js"; +import { runTarget } from "../src/index.js"; + +vi.mock("../src/index.js", () => ({ + runTarget: vi.fn().mockResolvedValue({ + artifactType: "run", + gate: "pass", + checks: [], + summary: { pass: 0, fail: 0, partial: 0, flaky: 0, unsupported: 0, skipped: 0 }, + }), +})); + +vi.mock("../src/discovery.js", () => ({ + scanForTargets: vi.fn().mockResolvedValue([]), +})); + +vi.mock("../src/telemetry.js", () => ({ + generateSessionId: vi.fn().mockReturnValue("test-session"), + recordSessionStart: vi.fn(), + recordSessionEnd: vi.fn(), + buildEvent: vi.fn().mockReturnValue({}), + recordEvent: vi.fn(), +})); + +vi.mock("../src/commercial.js", () => ({ + maybePrintCloudCta: vi.fn(), +})); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("demo command", () => { + it("uses default timeout of 15000ms", async () => { + const program = new Command(); + registerDemoCommands(program); + + await program.parseAsync(["node", "cli.js", "demo"]); + + expect(runTarget).toHaveBeenCalledWith( + expect.objectContaining({ + timeoutMs: 15000, + }), + expect.anything() + ); + }); + + it("respects custom timeout value", async () => { + const program = new Command(); + registerDemoCommands(program); + + await program.parseAsync(["node", "cli.js", "demo", "--timeout", "30000"]); + + expect(runTarget).toHaveBeenCalledWith( + expect.objectContaining({ + timeoutMs: 30000, + }), + expect.anything() + ); + }); + + it("shows clear error for non-number timeout value", async () => { + const program = new Command(); + registerDemoCommands(program); + + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + process.exitCode = undefined; + + await program.parseAsync(["node", "cli.js", "demo", "--timeout", "abc"]); + + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining("Invalid timeout value") + ); + expect(process.exitCode).toBe(1); + process.exitCode = undefined; + }); +});