Add CLI and infra test coverage
This commit is contained in:
91
src/cli/program.test.ts
Normal file
91
src/cli/program.test.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const sendCommand = vi.fn();
|
||||||
|
const statusCommand = vi.fn();
|
||||||
|
const upCommand = vi.fn().mockResolvedValue({ server: undefined });
|
||||||
|
const webhookCommand = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const ensureTwilioEnv = vi.fn();
|
||||||
|
const loginWeb = vi.fn();
|
||||||
|
const monitorWebProvider = vi.fn();
|
||||||
|
const pickProvider = vi.fn();
|
||||||
|
const monitorTwilio = vi.fn();
|
||||||
|
const logTwilioFrom = vi.fn();
|
||||||
|
const logWebSelfId = vi.fn();
|
||||||
|
const waitForever = vi.fn();
|
||||||
|
const spawnRelayTmux = vi.fn().mockResolvedValue("warelay-relay");
|
||||||
|
|
||||||
|
const runtime = {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
exit: vi.fn(() => {
|
||||||
|
throw new Error("exit");
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("../commands/send.js", () => ({ sendCommand }));
|
||||||
|
vi.mock("../commands/status.js", () => ({ statusCommand }));
|
||||||
|
vi.mock("../commands/up.js", () => ({ upCommand }));
|
||||||
|
vi.mock("../commands/webhook.js", () => ({ webhookCommand }));
|
||||||
|
vi.mock("../env.js", () => ({ ensureTwilioEnv }));
|
||||||
|
vi.mock("../runtime.js", () => ({ defaultRuntime: runtime }));
|
||||||
|
vi.mock("../provider-web.js", () => ({
|
||||||
|
loginWeb,
|
||||||
|
monitorWebProvider,
|
||||||
|
pickProvider,
|
||||||
|
}));
|
||||||
|
vi.mock("./deps.js", () => ({
|
||||||
|
createDefaultDeps: () => ({ waitForever }),
|
||||||
|
logTwilioFrom,
|
||||||
|
logWebSelfId,
|
||||||
|
monitorTwilio,
|
||||||
|
}));
|
||||||
|
vi.mock("./relay_tmux.js", () => ({ spawnRelayTmux }));
|
||||||
|
|
||||||
|
const { buildProgram } = await import("./program.js");
|
||||||
|
|
||||||
|
describe("cli program", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs send with required options", async () => {
|
||||||
|
const program = buildProgram();
|
||||||
|
await program.parseAsync(["send", "--to", "+1", "--message", "hi"], {
|
||||||
|
from: "user",
|
||||||
|
});
|
||||||
|
expect(sendCommand).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid relay provider", async () => {
|
||||||
|
const program = buildProgram();
|
||||||
|
await expect(
|
||||||
|
program.parseAsync(["relay", "--provider", "bogus"], { from: "user" }),
|
||||||
|
).rejects.toThrow("exit");
|
||||||
|
expect(runtime.error).toHaveBeenCalledWith(
|
||||||
|
"--provider must be auto, web, or twilio",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to twilio when web relay fails", async () => {
|
||||||
|
pickProvider.mockResolvedValue("web");
|
||||||
|
monitorWebProvider.mockRejectedValue(new Error("no web"));
|
||||||
|
const program = buildProgram();
|
||||||
|
await program.parseAsync(
|
||||||
|
["relay", "--provider", "auto", "--interval", "2", "--lookback", "1"],
|
||||||
|
{ from: "user" },
|
||||||
|
);
|
||||||
|
expect(logWebSelfId).toHaveBeenCalled();
|
||||||
|
expect(ensureTwilioEnv).toHaveBeenCalled();
|
||||||
|
expect(monitorTwilio).toHaveBeenCalledWith(2, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs relay tmux attach command", async () => {
|
||||||
|
const program = buildProgram();
|
||||||
|
await program.parseAsync(["relay:tmux:attach"], { from: "user" });
|
||||||
|
expect(spawnRelayTmux).toHaveBeenCalledWith(
|
||||||
|
"pnpm warelay relay --verbose",
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
49
src/cli/prompt.test.ts
Normal file
49
src/cli/prompt.test.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { isYes, setVerbose, setYes } from "../globals.js";
|
||||||
|
|
||||||
|
vi.mock("node:readline/promises", () => {
|
||||||
|
const question = vi.fn<[], Promise<string>>();
|
||||||
|
const close = vi.fn();
|
||||||
|
const createInterface = vi.fn(() => ({ question, close }));
|
||||||
|
return { default: { createInterface } };
|
||||||
|
});
|
||||||
|
|
||||||
|
type ReadlineMock = {
|
||||||
|
default: {
|
||||||
|
createInterface: () => {
|
||||||
|
question: ReturnType<typeof vi.fn<[], Promise<string>>>;
|
||||||
|
close: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const { promptYesNo } = await import("./prompt.js");
|
||||||
|
const readline = (await import("node:readline/promises")) as ReadlineMock;
|
||||||
|
|
||||||
|
describe("promptYesNo", () => {
|
||||||
|
it("returns true when global --yes is set", async () => {
|
||||||
|
setYes(true);
|
||||||
|
setVerbose(false);
|
||||||
|
const result = await promptYesNo("Continue?");
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(isYes()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks the question and respects default", async () => {
|
||||||
|
setYes(false);
|
||||||
|
setVerbose(false);
|
||||||
|
const { question: questionMock } = readline.default.createInterface();
|
||||||
|
questionMock.mockResolvedValueOnce("");
|
||||||
|
const resultDefaultYes = await promptYesNo("Continue?", true);
|
||||||
|
expect(resultDefaultYes).toBe(true);
|
||||||
|
|
||||||
|
questionMock.mockResolvedValueOnce("n");
|
||||||
|
const resultNo = await promptYesNo("Continue?", true);
|
||||||
|
expect(resultNo).toBe(false);
|
||||||
|
|
||||||
|
questionMock.mockResolvedValueOnce("y");
|
||||||
|
const resultYes = await promptYesNo("Continue?", false);
|
||||||
|
expect(resultYes).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
47
src/cli/relay_tmux.test.ts
Normal file
47
src/cli/relay_tmux.test.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("node:child_process", () => {
|
||||||
|
const spawn = vi.fn((_cmd: string, _args: string[]) => {
|
||||||
|
const proc = new EventEmitter() as EventEmitter & {
|
||||||
|
kill: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
queueMicrotask(() => {
|
||||||
|
proc.emit("exit", 0);
|
||||||
|
});
|
||||||
|
proc.kill = vi.fn();
|
||||||
|
return proc;
|
||||||
|
});
|
||||||
|
return { spawn };
|
||||||
|
});
|
||||||
|
|
||||||
|
const { spawnRelayTmux } = await import("./relay_tmux.js");
|
||||||
|
const { spawn } = await import("node:child_process");
|
||||||
|
|
||||||
|
describe("spawnRelayTmux", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kills old session, starts new one, and attaches", async () => {
|
||||||
|
const session = await spawnRelayTmux("echo hi", true, true);
|
||||||
|
expect(session).toBe("warelay-relay");
|
||||||
|
const spawnMock = spawn as unknown as vi.Mock;
|
||||||
|
expect(spawnMock.mock.calls.length).toBe(3);
|
||||||
|
const calls = spawnMock.mock.calls as Array<[string, string[], unknown]>;
|
||||||
|
expect(calls[0][0]).toBe("tmux"); // kill-session
|
||||||
|
expect(calls[1][2]?.cmd ?? "").not.toBeUndefined(); // new session
|
||||||
|
expect(calls[2][1][0]).toBe("attach-session");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can skip attach", async () => {
|
||||||
|
await spawnRelayTmux("echo hi", false, true);
|
||||||
|
const spawnMock = spawn as unknown as vi.Mock;
|
||||||
|
const hasAttach = spawnMock.mock.calls.some(
|
||||||
|
(c) =>
|
||||||
|
Array.isArray(c[1]) && (c[1] as string[]).includes("attach-session"),
|
||||||
|
);
|
||||||
|
expect(hasAttach).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
16
src/cli/wait.test.ts
Normal file
16
src/cli/wait.test.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { waitForever } from "./wait.js";
|
||||||
|
|
||||||
|
describe("waitForever", () => {
|
||||||
|
it("creates an unref'ed interval and returns a pending promise", () => {
|
||||||
|
const setIntervalSpy = vi.spyOn(global, "setInterval");
|
||||||
|
const promise = waitForever();
|
||||||
|
expect(setIntervalSpy).toHaveBeenCalledWith(
|
||||||
|
expect.any(Function),
|
||||||
|
1_000_000,
|
||||||
|
);
|
||||||
|
expect(promise).toBeInstanceOf(Promise);
|
||||||
|
setIntervalSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
76
src/commands/up.test.ts
Normal file
76
src/commands/up.test.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { CliDeps } from "../cli/deps.js";
|
||||||
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
import { upCommand } from "./up.js";
|
||||||
|
|
||||||
|
const runtime: RuntimeEnv = {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
exit: vi.fn(() => {
|
||||||
|
throw new Error("exit");
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeDeps = (): CliDeps => ({
|
||||||
|
ensurePortAvailable: vi.fn().mockResolvedValue(undefined),
|
||||||
|
readEnv: vi.fn().mockReturnValue({
|
||||||
|
whatsappFrom: "whatsapp:+1555",
|
||||||
|
whatsappSenderSid: "WW",
|
||||||
|
}),
|
||||||
|
ensureBinary: vi.fn().mockResolvedValue(undefined),
|
||||||
|
ensureFunnel: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getTailnetHostname: vi.fn().mockResolvedValue("tailnet-host"),
|
||||||
|
startWebhook: vi.fn().mockResolvedValue({ server: true }),
|
||||||
|
createClient: vi.fn().mockReturnValue({ client: true }),
|
||||||
|
findWhatsappSenderSid: vi.fn().mockResolvedValue("SID123"),
|
||||||
|
updateWebhook: vi.fn().mockResolvedValue(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("upCommand", () => {
|
||||||
|
it("throws on invalid port", async () => {
|
||||||
|
await expect(() =>
|
||||||
|
upCommand({ port: "0", path: "/cb" }, makeDeps(), runtime),
|
||||||
|
).rejects.toThrow("Port must be between 1 and 65535");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("performs dry run and returns mock data", async () => {
|
||||||
|
runtime.log.mockClear();
|
||||||
|
const result = await upCommand(
|
||||||
|
{ port: "42873", path: "/cb", dryRun: true },
|
||||||
|
makeDeps(),
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
expect(runtime.log).toHaveBeenCalledWith(
|
||||||
|
"[dry-run] would enable funnel on port 42873",
|
||||||
|
);
|
||||||
|
expect(result?.publicUrl).toBe("https://dry-run/cb");
|
||||||
|
expect(result?.senderSid).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enables funnel, starts webhook, and updates Twilio", async () => {
|
||||||
|
const deps = makeDeps();
|
||||||
|
const res = await upCommand(
|
||||||
|
{ port: "42873", path: "/hook", verbose: true },
|
||||||
|
deps,
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
expect(deps.ensureBinary).toHaveBeenCalledWith(
|
||||||
|
"tailscale",
|
||||||
|
undefined,
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
expect(deps.ensureFunnel).toHaveBeenCalled();
|
||||||
|
expect(deps.startWebhook).toHaveBeenCalled();
|
||||||
|
expect(deps.updateWebhook).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
"SID123",
|
||||||
|
"https://tailnet-host/hook",
|
||||||
|
"POST",
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
expect(res?.publicUrl).toBe("https://tailnet-host/hook");
|
||||||
|
// waiter is returned to keep the process alive in real use.
|
||||||
|
expect(typeof res?.waiter).toBe("function");
|
||||||
|
});
|
||||||
|
});
|
||||||
56
src/commands/webhook.test.ts
Normal file
56
src/commands/webhook.test.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { CliDeps } from "../cli/deps.js";
|
||||||
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
|
||||||
|
import { webhookCommand } from "./webhook.js";
|
||||||
|
|
||||||
|
const runtime: RuntimeEnv = {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
exit: vi.fn(() => {
|
||||||
|
throw new Error("exit");
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const deps: CliDeps = {
|
||||||
|
ensurePortAvailable: vi.fn().mockResolvedValue(undefined),
|
||||||
|
startWebhook: vi.fn().mockResolvedValue({ server: true }),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("webhookCommand", () => {
|
||||||
|
it("throws on invalid port", async () => {
|
||||||
|
await expect(() =>
|
||||||
|
webhookCommand({ port: "70000", path: "/hook" }, deps, runtime),
|
||||||
|
).rejects.toThrow("Port must be between 1 and 65535");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs dry run instead of starting server", async () => {
|
||||||
|
runtime.log.mockClear();
|
||||||
|
const res = await webhookCommand(
|
||||||
|
{ port: "42873", path: "/hook", reply: "dry-run" },
|
||||||
|
deps,
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
expect(res).toBeUndefined();
|
||||||
|
expect(runtime.log).toHaveBeenCalledWith(
|
||||||
|
"[dry-run] would start webhook on port 42873 path /hook",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts webhook when valid", async () => {
|
||||||
|
const res = await webhookCommand(
|
||||||
|
{ port: "42873", path: "/hook", reply: "ok", verbose: true },
|
||||||
|
deps,
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
expect(deps.startWebhook).toHaveBeenCalledWith(
|
||||||
|
42873,
|
||||||
|
"/hook",
|
||||||
|
"ok",
|
||||||
|
true,
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
expect(res).toEqual({ server: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
38
src/infra/binaries.test.ts
Normal file
38
src/infra/binaries.test.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { runExec } from "../process/exec.js";
|
||||||
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
import { ensureBinary } from "./binaries.js";
|
||||||
|
|
||||||
|
describe("ensureBinary", () => {
|
||||||
|
it("passes through when binary exists", async () => {
|
||||||
|
const exec: typeof runExec = vi.fn().mockResolvedValue({
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
});
|
||||||
|
const runtime: RuntimeEnv = {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
exit: vi.fn(),
|
||||||
|
};
|
||||||
|
await ensureBinary("node", exec, runtime);
|
||||||
|
expect(exec).toHaveBeenCalledWith("which", ["node"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs and exits when missing", async () => {
|
||||||
|
const exec: typeof runExec = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue(new Error("missing"));
|
||||||
|
const error = vi.fn();
|
||||||
|
const exit = vi.fn(() => {
|
||||||
|
throw new Error("exit");
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
ensureBinary("ghost", exec, { log: vi.fn(), error, exit }),
|
||||||
|
).rejects.toThrow("exit");
|
||||||
|
expect(error).toHaveBeenCalledWith(
|
||||||
|
"Missing required binary: ghost. Please install it.",
|
||||||
|
);
|
||||||
|
expect(exit).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
34
src/logger.test.ts
Normal file
34
src/logger.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { setVerbose } from "./globals.js";
|
||||||
|
import { logDebug, logError, logInfo, logSuccess, logWarn } from "./logger.js";
|
||||||
|
import type { RuntimeEnv } from "./runtime.js";
|
||||||
|
|
||||||
|
describe("logger helpers", () => {
|
||||||
|
it("formats messages through runtime log/error", () => {
|
||||||
|
const log = vi.fn();
|
||||||
|
const error = vi.fn();
|
||||||
|
const runtime: RuntimeEnv = { log, error, exit: vi.fn() };
|
||||||
|
|
||||||
|
logInfo("info", runtime);
|
||||||
|
logWarn("warn", runtime);
|
||||||
|
logSuccess("ok", runtime);
|
||||||
|
logError("bad", runtime);
|
||||||
|
|
||||||
|
expect(log).toHaveBeenCalledTimes(3);
|
||||||
|
expect(error).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only logs debug when verbose is enabled", () => {
|
||||||
|
const logVerbose = vi.spyOn(console, "log");
|
||||||
|
setVerbose(false);
|
||||||
|
logDebug("quiet");
|
||||||
|
expect(logVerbose).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
setVerbose(true);
|
||||||
|
logVerbose.mockClear();
|
||||||
|
logDebug("loud");
|
||||||
|
expect(logVerbose).toHaveBeenCalled();
|
||||||
|
logVerbose.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
19
src/providers/web/index.test.ts
Normal file
19
src/providers/web/index.test.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import * as impl from "../../provider-web.js";
|
||||||
|
import * as entry from "./index.js";
|
||||||
|
|
||||||
|
describe("providers/web entrypoint", () => {
|
||||||
|
it("re-exports web provider helpers", () => {
|
||||||
|
expect(entry.createWaSocket).toBe(impl.createWaSocket);
|
||||||
|
expect(entry.loginWeb).toBe(impl.loginWeb);
|
||||||
|
expect(entry.logWebSelfId).toBe(impl.logWebSelfId);
|
||||||
|
expect(entry.monitorWebInbox).toBe(impl.monitorWebInbox);
|
||||||
|
expect(entry.monitorWebProvider).toBe(impl.monitorWebProvider);
|
||||||
|
expect(entry.pickProvider).toBe(impl.pickProvider);
|
||||||
|
expect(entry.sendMessageWeb).toBe(impl.sendMessageWeb);
|
||||||
|
expect(entry.WA_WEB_AUTH_DIR).toBe(impl.WA_WEB_AUTH_DIR);
|
||||||
|
expect(entry.waitForWaConnection).toBe(impl.waitForWaConnection);
|
||||||
|
expect(entry.webAuthExists).toBe(impl.webAuthExists);
|
||||||
|
});
|
||||||
|
});
|
||||||
10
src/webhook/server.test.ts
Normal file
10
src/webhook/server.test.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import * as impl from "../twilio/webhook.js";
|
||||||
|
import * as entry from "./server.js";
|
||||||
|
|
||||||
|
describe("webhook server wrapper", () => {
|
||||||
|
it("re-exports startWebhook", () => {
|
||||||
|
expect(entry.startWebhook).toBe(impl.startWebhook);
|
||||||
|
});
|
||||||
|
});
|
||||||
15
src/webhook/update.test.ts
Normal file
15
src/webhook/update.test.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import * as impl from "../twilio/update-webhook.js";
|
||||||
|
import * as entry from "./update.js";
|
||||||
|
|
||||||
|
describe("webhook update wrappers", () => {
|
||||||
|
it("mirror the Twilio implementations", () => {
|
||||||
|
expect(entry.updateWebhook).toBe(impl.updateWebhook);
|
||||||
|
expect(entry.findIncomingNumberSid).toBe(impl.findIncomingNumberSid);
|
||||||
|
expect(entry.findMessagingServiceSid).toBe(impl.findMessagingServiceSid);
|
||||||
|
expect(entry.setMessagingServiceWebhook).toBe(
|
||||||
|
impl.setMessagingServiceWebhook,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user