mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:20:42 +00:00
test(voice-call): cover tunnel startup
This commit is contained in:
166
extensions/voice-call/src/tunnel.test.ts
Normal file
166
extensions/voice-call/src/tunnel.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
class FakeChildProcess extends EventEmitter {
|
||||
readonly stdout = new EventEmitter();
|
||||
readonly stderr = new EventEmitter();
|
||||
killedWith: NodeJS.Signals | null = null;
|
||||
|
||||
kill(signal: NodeJS.Signals = "SIGTERM"): boolean {
|
||||
this.killedWith = signal;
|
||||
queueMicrotask(() => this.emit("close", null));
|
||||
return true;
|
||||
}
|
||||
|
||||
close(code: number | null = 0): void {
|
||||
this.emit("close", code);
|
||||
}
|
||||
|
||||
fail(error: Error): void {
|
||||
this.emit("error", error);
|
||||
}
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
spawn: vi.fn(),
|
||||
getTailscaleDnsName: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
spawn: mocks.spawn,
|
||||
}));
|
||||
|
||||
vi.mock("./webhook/tailscale.js", () => ({
|
||||
getTailscaleDnsName: mocks.getTailscaleDnsName,
|
||||
}));
|
||||
|
||||
import { isNgrokAvailable, startNgrokTunnel, startTailscaleTunnel, startTunnel } from "./tunnel.js";
|
||||
|
||||
function nextProcess(): FakeChildProcess {
|
||||
const proc = new FakeChildProcess();
|
||||
mocks.spawn.mockReturnValueOnce(proc as never);
|
||||
return proc;
|
||||
}
|
||||
|
||||
function emitNgrokUrl(proc: FakeChildProcess, url: string): void {
|
||||
proc.stdout.emit("data", Buffer.from(`${JSON.stringify({ msg: "started tunnel", url })}\n`));
|
||||
}
|
||||
|
||||
describe("voice-call tunnels", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getTailscaleDnsName.mockReset();
|
||||
});
|
||||
|
||||
it("checks ngrok availability from the version command exit code", async () => {
|
||||
const proc = nextProcess();
|
||||
const result = isNgrokAvailable();
|
||||
proc.close(0);
|
||||
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(mocks.spawn).toHaveBeenCalledWith("ngrok", ["version"], expect.any(Object));
|
||||
});
|
||||
|
||||
it("treats ngrok spawn failures as unavailable", async () => {
|
||||
const proc = nextProcess();
|
||||
const result = isNgrokAvailable();
|
||||
proc.fail(new Error("spawn ngrok ENOENT"));
|
||||
|
||||
await expect(result).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("starts ngrok and appends the webhook path to the public URL", async () => {
|
||||
const proc = nextProcess();
|
||||
const result = startNgrokTunnel({ port: 3334, path: "/voice/webhook" });
|
||||
|
||||
emitNgrokUrl(proc, "https://abc.ngrok.io");
|
||||
|
||||
await expect(result).resolves.toMatchObject({
|
||||
publicUrl: "https://abc.ngrok.io/voice/webhook",
|
||||
provider: "ngrok",
|
||||
});
|
||||
expect(mocks.spawn).toHaveBeenCalledWith(
|
||||
"ngrok",
|
||||
expect.arrayContaining(["http", "3334"]),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("sets ngrok auth token before starting the tunnel", async () => {
|
||||
const authProc = nextProcess();
|
||||
const tunnelProc = nextProcess();
|
||||
const result = startNgrokTunnel({
|
||||
port: 3334,
|
||||
path: "/hook",
|
||||
authToken: "token",
|
||||
});
|
||||
|
||||
authProc.close(0);
|
||||
await vi.waitFor(() => expect(mocks.spawn).toHaveBeenCalledTimes(2));
|
||||
emitNgrokUrl(tunnelProc, "https://auth.ngrok.io");
|
||||
|
||||
await expect(result).resolves.toMatchObject({
|
||||
publicUrl: "https://auth.ngrok.io/hook",
|
||||
});
|
||||
expect(mocks.spawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"ngrok",
|
||||
["config", "add-authtoken", "token"],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects ngrok startup errors from stderr", async () => {
|
||||
const proc = nextProcess();
|
||||
const result = startNgrokTunnel({ port: 3334, path: "/hook" });
|
||||
|
||||
proc.stderr.emit("data", Buffer.from("ERR_NGROK_3200: invalid auth token"));
|
||||
|
||||
await expect(result).rejects.toThrow("ngrok error:");
|
||||
});
|
||||
|
||||
it("starts Tailscale serve using the resolved tailnet DNS name", async () => {
|
||||
mocks.getTailscaleDnsName.mockResolvedValue("host.tailnet.ts.net");
|
||||
const proc = nextProcess();
|
||||
const result = startTailscaleTunnel({
|
||||
mode: "serve",
|
||||
port: 3334,
|
||||
path: "voice/webhook",
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(mocks.spawn).toHaveBeenCalled());
|
||||
proc.close(0);
|
||||
|
||||
await expect(result).resolves.toMatchObject({
|
||||
publicUrl: "https://host.tailnet.ts.net/voice/webhook",
|
||||
provider: "tailscale-serve",
|
||||
});
|
||||
expect(mocks.spawn).toHaveBeenCalledWith(
|
||||
"tailscale",
|
||||
expect.arrayContaining(["serve", "--set-path", "/voice/webhook"]),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects Tailscale tunnel startup when the DNS name is unavailable", async () => {
|
||||
mocks.getTailscaleDnsName.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
startTailscaleTunnel({ mode: "funnel", port: 3334, path: "/hook" }),
|
||||
).rejects.toThrow("Could not get Tailscale DNS name");
|
||||
expect(mocks.spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dispatches tunnel providers from config", async () => {
|
||||
await expect(startTunnel({ provider: "none", port: 3334, path: "/hook" })).resolves.toBeNull();
|
||||
|
||||
const proc = nextProcess();
|
||||
const result = startTunnel({ provider: "ngrok", port: 3334, path: "/hook" });
|
||||
emitNgrokUrl(proc, "https://dispatch.ngrok.io");
|
||||
|
||||
await expect(result).resolves.toMatchObject({
|
||||
publicUrl: "https://dispatch.ngrok.io/hook",
|
||||
provider: "ngrok",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user