test(auto-reply): add helper coverage and docs
This commit is contained in:
11
docs/refactor/2025-11-26-auto-reply-split.md
Normal file
11
docs/refactor/2025-11-26-auto-reply-split.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Auto-reply refactor notes (2025-11-26)
|
||||||
|
|
||||||
|
- Split `src/auto-reply/reply.ts` into smaller helpers:
|
||||||
|
- Command handling lives in `src/auto-reply/command-reply.ts`.
|
||||||
|
- Audio transcription helpers live in `src/auto-reply/transcription.ts`.
|
||||||
|
- Shared reply types live in `src/auto-reply/types.ts` (re-exported from `reply.ts`).
|
||||||
|
- `runCommandReply` now returns `{ payload, meta }`, supports injected enqueue runners for tests, logs structured metadata, and respects `mediaMaxMb` for local media paths.
|
||||||
|
- Added focused tests:
|
||||||
|
- `src/auto-reply/command-reply.test.ts` exercises Claude flag injection, session args, timeout messaging, media token handling, and Claude metadata reporting.
|
||||||
|
- `src/auto-reply/transcription.test.ts` covers media download + transcription command invocation.
|
||||||
|
- Existing public surface (`getReplyFromConfig`, `autoReplyIfConfigured`, `ReplyPayload`) remains unchanged; integration tests still pass (`pnpm test`).
|
||||||
186
src/auto-reply/command-reply.test.ts
Normal file
186
src/auto-reply/command-reply.test.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { runCommandReply, summarizeClaudeMetadata } from "./command-reply.js";
|
||||||
|
import type { ReplyPayload } from "./types.js";
|
||||||
|
|
||||||
|
const noopTemplateCtx = {
|
||||||
|
Body: "hello",
|
||||||
|
BodyStripped: "hello",
|
||||||
|
SessionId: "sess",
|
||||||
|
IsNewSession: "true",
|
||||||
|
};
|
||||||
|
|
||||||
|
type RunnerResult = {
|
||||||
|
stdout?: string;
|
||||||
|
stderr?: string;
|
||||||
|
code?: number;
|
||||||
|
signal?: string | null;
|
||||||
|
killed?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeRunner(result: RunnerResult, capture: ReplyPayload[] = []) {
|
||||||
|
return vi.fn(async (argv: string[]) => {
|
||||||
|
capture.push({ text: argv.join(" "), argv });
|
||||||
|
return {
|
||||||
|
stdout: result.stdout ?? "",
|
||||||
|
stderr: result.stderr ?? "",
|
||||||
|
code: result.code ?? 0,
|
||||||
|
signal: result.signal ?? null,
|
||||||
|
killed: result.killed ?? false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const enqueueImmediate = vi.fn(
|
||||||
|
async <T>(task: () => Promise<T>, opts?: { onWait?: (ms: number, ahead: number) => void }) => {
|
||||||
|
opts?.onWait?.(25, 2);
|
||||||
|
return task();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("summarizeClaudeMetadata", () => {
|
||||||
|
it("builds concise meta string", () => {
|
||||||
|
const meta = summarizeClaudeMetadata({
|
||||||
|
duration_ms: 1200,
|
||||||
|
num_turns: 3,
|
||||||
|
total_cost_usd: 0.012345,
|
||||||
|
usage: { server_tool_use: { a: 1, b: 2 } },
|
||||||
|
modelUsage: { "claude-3": 2, "haiku": 1 },
|
||||||
|
});
|
||||||
|
expect(meta).toContain("duration=1200ms");
|
||||||
|
expect(meta).toContain("turns=3");
|
||||||
|
expect(meta).toContain("cost=$0.0123");
|
||||||
|
expect(meta).toContain("tool_calls=3");
|
||||||
|
expect(meta).toContain("models=claude-3,haiku");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runCommandReply", () => {
|
||||||
|
it("injects claude flags and identity prefix", async () => {
|
||||||
|
const captures: ReplyPayload[] = [];
|
||||||
|
const runner = makeRunner({ stdout: "ok" }, captures);
|
||||||
|
const { payload } = await runCommandReply({
|
||||||
|
reply: {
|
||||||
|
mode: "command",
|
||||||
|
command: ["claude", "{{Body}}"],
|
||||||
|
claudeOutputFormat: "json",
|
||||||
|
},
|
||||||
|
templatingCtx: noopTemplateCtx,
|
||||||
|
sendSystemOnce: false,
|
||||||
|
isNewSession: true,
|
||||||
|
isFirstTurnInSession: true,
|
||||||
|
systemSent: false,
|
||||||
|
timeoutMs: 1000,
|
||||||
|
timeoutSeconds: 1,
|
||||||
|
commandRunner: runner,
|
||||||
|
enqueue: enqueueImmediate,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload?.text).toBe("ok");
|
||||||
|
const finalArgv = captures[0].argv as string[];
|
||||||
|
expect(finalArgv).toContain("--output-format");
|
||||||
|
expect(finalArgv).toContain("json");
|
||||||
|
expect(finalArgv).toContain("-p");
|
||||||
|
expect(finalArgv.at(-1)).toContain("You are Clawd (Claude)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picks session resume args when not new", async () => {
|
||||||
|
const captures: ReplyPayload[] = [];
|
||||||
|
const runner = makeRunner({ stdout: "hi" }, captures);
|
||||||
|
await runCommandReply({
|
||||||
|
reply: {
|
||||||
|
mode: "command",
|
||||||
|
command: ["cli", "{{Body}}"],
|
||||||
|
session: {
|
||||||
|
sessionArgNew: ["--new", "{{SessionId}}"],
|
||||||
|
sessionArgResume: ["--resume", "{{SessionId}}"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
templatingCtx: { ...noopTemplateCtx, SessionId: "abc" },
|
||||||
|
sendSystemOnce: true,
|
||||||
|
isNewSession: false,
|
||||||
|
isFirstTurnInSession: false,
|
||||||
|
systemSent: true,
|
||||||
|
timeoutMs: 1000,
|
||||||
|
timeoutSeconds: 1,
|
||||||
|
commandRunner: runner,
|
||||||
|
enqueue: enqueueImmediate,
|
||||||
|
});
|
||||||
|
const argv = captures[0].argv as string[];
|
||||||
|
expect(argv).toContain("--resume");
|
||||||
|
expect(argv).toContain("abc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns timeout text with partial snippet", async () => {
|
||||||
|
const runner = vi.fn(async () => {
|
||||||
|
throw { stdout: "partial output here", killed: true, signal: "SIGKILL" };
|
||||||
|
});
|
||||||
|
const { payload, meta } = await runCommandReply({
|
||||||
|
reply: { mode: "command", command: ["echo", "hi"] },
|
||||||
|
templatingCtx: noopTemplateCtx,
|
||||||
|
sendSystemOnce: false,
|
||||||
|
isNewSession: true,
|
||||||
|
isFirstTurnInSession: true,
|
||||||
|
systemSent: false,
|
||||||
|
timeoutMs: 10,
|
||||||
|
timeoutSeconds: 1,
|
||||||
|
commandRunner: runner,
|
||||||
|
enqueue: enqueueImmediate,
|
||||||
|
});
|
||||||
|
expect(payload?.text).toContain("Command timed out after 1s");
|
||||||
|
expect(payload?.text).toContain("partial output");
|
||||||
|
expect(meta.killed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses MEDIA tokens and respects mediaMaxMb for local files", async () => {
|
||||||
|
const tmp = path.join(os.tmpdir(), `warelay-test-${Date.now()}.bin`);
|
||||||
|
const bigBuffer = Buffer.alloc(2 * 1024 * 1024, 1);
|
||||||
|
await fs.writeFile(tmp, bigBuffer);
|
||||||
|
const runner = makeRunner({
|
||||||
|
stdout: `hi\nMEDIA:${tmp}\nMEDIA:https://example.com/img.jpg`,
|
||||||
|
});
|
||||||
|
const { payload } = await runCommandReply({
|
||||||
|
reply: { mode: "command", command: ["echo", "hi"], mediaMaxMb: 1 },
|
||||||
|
templatingCtx: noopTemplateCtx,
|
||||||
|
sendSystemOnce: false,
|
||||||
|
isNewSession: true,
|
||||||
|
isFirstTurnInSession: true,
|
||||||
|
systemSent: false,
|
||||||
|
timeoutMs: 1000,
|
||||||
|
timeoutSeconds: 1,
|
||||||
|
commandRunner: runner,
|
||||||
|
enqueue: enqueueImmediate,
|
||||||
|
});
|
||||||
|
expect(payload?.mediaUrls).toEqual(["https://example.com/img.jpg"]);
|
||||||
|
await fs.unlink(tmp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits Claude metadata", async () => {
|
||||||
|
const runner = makeRunner({
|
||||||
|
stdout:
|
||||||
|
'{"text":"hi","duration_ms":50,"total_cost_usd":0.0001,"usage":{"server_tool_use":{"a":1}}}',
|
||||||
|
});
|
||||||
|
const { meta } = await runCommandReply({
|
||||||
|
reply: {
|
||||||
|
mode: "command",
|
||||||
|
command: ["claude", "{{Body}}"],
|
||||||
|
claudeOutputFormat: "json",
|
||||||
|
},
|
||||||
|
templatingCtx: noopTemplateCtx,
|
||||||
|
sendSystemOnce: false,
|
||||||
|
isNewSession: true,
|
||||||
|
isFirstTurnInSession: true,
|
||||||
|
systemSent: false,
|
||||||
|
timeoutMs: 1000,
|
||||||
|
timeoutSeconds: 1,
|
||||||
|
commandRunner: runner,
|
||||||
|
enqueue: enqueueImmediate,
|
||||||
|
});
|
||||||
|
expect(meta.claudeMeta).toContain("duration=50ms");
|
||||||
|
expect(meta.claudeMeta).toContain("tool_calls=1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import type { WarelayConfig } from "../config/config.js";
|
import type { WarelayConfig } from "../config/config.js";
|
||||||
@@ -19,6 +20,8 @@ type CommandReplyConfig = NonNullable<WarelayConfig["inbound"]>["reply"] & {
|
|||||||
mode: "command";
|
mode: "command";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type EnqueueRunner = typeof enqueueCommand;
|
||||||
|
|
||||||
type CommandReplyParams = {
|
type CommandReplyParams = {
|
||||||
reply: CommandReplyConfig;
|
reply: CommandReplyConfig;
|
||||||
templatingCtx: TemplateContext;
|
templatingCtx: TemplateContext;
|
||||||
@@ -29,9 +32,25 @@ type CommandReplyParams = {
|
|||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
timeoutSeconds: number;
|
timeoutSeconds: number;
|
||||||
commandRunner: typeof runCommandWithTimeout;
|
commandRunner: typeof runCommandWithTimeout;
|
||||||
|
enqueue?: EnqueueRunner;
|
||||||
};
|
};
|
||||||
|
|
||||||
function summarizeClaudeMetadata(payload: unknown): string | undefined {
|
export type CommandReplyMeta = {
|
||||||
|
durationMs: number;
|
||||||
|
queuedMs?: number;
|
||||||
|
queuedAhead?: number;
|
||||||
|
exitCode?: number | null;
|
||||||
|
signal?: string | null;
|
||||||
|
killed?: boolean;
|
||||||
|
claudeMeta?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CommandReplyResult = {
|
||||||
|
payload?: ReplyPayload;
|
||||||
|
meta: CommandReplyMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function summarizeClaudeMetadata(payload: unknown): string | undefined {
|
||||||
if (!payload || typeof payload !== "object") return undefined;
|
if (!payload || typeof payload !== "object") return undefined;
|
||||||
const obj = payload as Record<string, unknown>;
|
const obj = payload as Record<string, unknown>;
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
@@ -83,7 +102,7 @@ function summarizeClaudeMetadata(payload: unknown): string | undefined {
|
|||||||
|
|
||||||
export async function runCommandReply(
|
export async function runCommandReply(
|
||||||
params: CommandReplyParams,
|
params: CommandReplyParams,
|
||||||
): Promise<ReplyPayload | undefined> {
|
): Promise<CommandReplyResult> {
|
||||||
const {
|
const {
|
||||||
reply,
|
reply,
|
||||||
templatingCtx,
|
templatingCtx,
|
||||||
@@ -94,6 +113,7 @@ export async function runCommandReply(
|
|||||||
timeoutMs,
|
timeoutMs,
|
||||||
timeoutSeconds,
|
timeoutSeconds,
|
||||||
commandRunner,
|
commandRunner,
|
||||||
|
enqueue = enqueueCommand,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
let argv = reply.command.map((part) => applyTemplate(part, templatingCtx));
|
let argv = reply.command.map((part) => applyTemplate(part, templatingCtx));
|
||||||
@@ -167,11 +187,15 @@ export async function runCommandReply(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
|
let queuedMs: number | undefined;
|
||||||
|
let queuedAhead: number | undefined;
|
||||||
try {
|
try {
|
||||||
const { stdout, stderr, code, signal, killed } = await enqueueCommand(
|
const { stdout, stderr, code, signal, killed } = await enqueue(
|
||||||
() => commandRunner(finalArgv, { timeoutMs, cwd: reply.cwd }),
|
() => commandRunner(finalArgv, { timeoutMs, cwd: reply.cwd }),
|
||||||
{
|
{
|
||||||
onWait: (waitMs, queuedAhead) => {
|
onWait: (waitMs, ahead) => {
|
||||||
|
queuedMs = waitMs;
|
||||||
|
queuedAhead = ahead;
|
||||||
if (isVerbose()) {
|
if (isVerbose()) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`Command auto-reply queued for ${waitMs}ms (${queuedAhead} ahead)`,
|
`Command auto-reply queued for ${waitMs}ms (${queuedAhead} ahead)`,
|
||||||
@@ -223,23 +247,86 @@ export async function runCommandReply(
|
|||||||
console.error(
|
console.error(
|
||||||
`Command auto-reply exited with code ${code ?? "unknown"} (signal: ${signal ?? "none"})`,
|
`Command auto-reply exited with code ${code ?? "unknown"} (signal: ${signal ?? "none"})`,
|
||||||
);
|
);
|
||||||
return undefined;
|
return {
|
||||||
|
payload: undefined,
|
||||||
|
meta: {
|
||||||
|
durationMs: Date.now() - started,
|
||||||
|
queuedMs,
|
||||||
|
queuedAhead,
|
||||||
|
exitCode: code,
|
||||||
|
signal,
|
||||||
|
killed,
|
||||||
|
claudeMeta: parsed ? summarizeClaudeMetadata(parsed.parsed) : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (killed && !signal) {
|
if (killed && !signal) {
|
||||||
console.error(
|
console.error(
|
||||||
`Command auto-reply process killed before completion (exit code ${code ?? "unknown"})`,
|
`Command auto-reply process killed before completion (exit code ${code ?? "unknown"})`,
|
||||||
);
|
);
|
||||||
return undefined;
|
return {
|
||||||
|
payload: undefined,
|
||||||
|
meta: {
|
||||||
|
durationMs: Date.now() - started,
|
||||||
|
queuedMs,
|
||||||
|
queuedAhead,
|
||||||
|
exitCode: code,
|
||||||
|
signal,
|
||||||
|
killed,
|
||||||
|
claudeMeta: parsed ? summarizeClaudeMetadata(parsed.parsed) : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const mediaUrls =
|
let mediaUrls =
|
||||||
mediaFromCommand ?? (reply.mediaUrl ? [reply.mediaUrl] : undefined);
|
mediaFromCommand ?? (reply.mediaUrl ? [reply.mediaUrl] : undefined);
|
||||||
return trimmed || mediaUrls?.length
|
|
||||||
? {
|
// If mediaMaxMb is set, skip local media paths larger than the cap.
|
||||||
text: trimmed || undefined,
|
if (mediaUrls?.length && reply.mediaMaxMb) {
|
||||||
mediaUrl: mediaUrls?.[0],
|
const maxBytes = reply.mediaMaxMb * 1024 * 1024;
|
||||||
mediaUrls,
|
const filtered: string[] = [];
|
||||||
|
for (const url of mediaUrls) {
|
||||||
|
if (/^https?:\/\//i.test(url)) {
|
||||||
|
filtered.push(url);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
: undefined;
|
const abs = path.isAbsolute(url) ? url : path.resolve(url);
|
||||||
|
try {
|
||||||
|
const stats = await fs.stat(abs);
|
||||||
|
if (stats.size <= maxBytes) {
|
||||||
|
filtered.push(url);
|
||||||
|
} else if (isVerbose()) {
|
||||||
|
logVerbose(
|
||||||
|
`Skipping media ${url} (${(stats.size / (1024 * 1024)).toFixed(2)}MB) over cap ${reply.mediaMaxMb}MB`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
filtered.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mediaUrls = filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload =
|
||||||
|
trimmed || mediaUrls?.length
|
||||||
|
? {
|
||||||
|
text: trimmed || undefined,
|
||||||
|
mediaUrl: mediaUrls?.[0],
|
||||||
|
mediaUrls,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
const meta: CommandReplyMeta = {
|
||||||
|
durationMs: Date.now() - started,
|
||||||
|
queuedMs,
|
||||||
|
queuedAhead,
|
||||||
|
exitCode: code,
|
||||||
|
signal,
|
||||||
|
killed,
|
||||||
|
claudeMeta: parsed ? summarizeClaudeMetadata(parsed.parsed) : undefined,
|
||||||
|
};
|
||||||
|
if (isVerbose()) {
|
||||||
|
logVerbose(`Command auto-reply meta: ${JSON.stringify(meta)}`);
|
||||||
|
}
|
||||||
|
return { payload, meta };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const elapsed = Date.now() - started;
|
const elapsed = Date.now() - started;
|
||||||
const anyErr = err as { killed?: boolean; signal?: string };
|
const anyErr = err as { killed?: boolean; signal?: string };
|
||||||
@@ -261,9 +348,29 @@ export async function runCommandReply(
|
|||||||
const text = partialSnippet
|
const text = partialSnippet
|
||||||
? `${baseMsg}\n\nPartial output before timeout:\n${partialSnippet}`
|
? `${baseMsg}\n\nPartial output before timeout:\n${partialSnippet}`
|
||||||
: baseMsg;
|
: baseMsg;
|
||||||
return { text };
|
return {
|
||||||
|
payload: { text },
|
||||||
|
meta: {
|
||||||
|
durationMs: elapsed,
|
||||||
|
queuedMs,
|
||||||
|
queuedAhead,
|
||||||
|
exitCode: undefined,
|
||||||
|
signal: anyErr.signal,
|
||||||
|
killed: anyErr.killed,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
logError(`Command auto-reply failed after ${elapsed}ms: ${String(err)}`);
|
logError(`Command auto-reply failed after ${elapsed}ms: ${String(err)}`);
|
||||||
return undefined;
|
return {
|
||||||
|
payload: undefined,
|
||||||
|
meta: {
|
||||||
|
durationMs: elapsed,
|
||||||
|
queuedMs,
|
||||||
|
queuedAhead,
|
||||||
|
exitCode: undefined,
|
||||||
|
signal: anyErr.signal,
|
||||||
|
killed: anyErr.killed,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ export async function getReplyFromConfig(
|
|||||||
if (reply.mode === "command" && reply.command?.length) {
|
if (reply.mode === "command" && reply.command?.length) {
|
||||||
await onReplyStart();
|
await onReplyStart();
|
||||||
try {
|
try {
|
||||||
const result = await runCommandReply({
|
const { payload, meta } = await runCommandReply({
|
||||||
reply,
|
reply,
|
||||||
templatingCtx,
|
templatingCtx,
|
||||||
sendSystemOnce,
|
sendSystemOnce,
|
||||||
@@ -252,7 +252,10 @@ export async function getReplyFromConfig(
|
|||||||
timeoutSeconds,
|
timeoutSeconds,
|
||||||
commandRunner,
|
commandRunner,
|
||||||
});
|
});
|
||||||
return result;
|
if (meta.claudeMeta && isVerbose()) {
|
||||||
|
logVerbose(`Claude JSON meta: ${meta.claudeMeta}`);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
} finally {
|
} finally {
|
||||||
cleanupTyping();
|
cleanupTyping();
|
||||||
}
|
}
|
||||||
|
|||||||
63
src/auto-reply/transcription.test.ts
Normal file
63
src/auto-reply/transcription.test.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { transcribeInboundAudio } from "./transcription.js";
|
||||||
|
|
||||||
|
vi.mock("../globals.js", () => ({
|
||||||
|
isVerbose: () => false,
|
||||||
|
logVerbose: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../process/exec.js", () => ({
|
||||||
|
runExec: vi.fn(async () => ({ stdout: "transcribed text\n" })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const runtime = {
|
||||||
|
error: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("transcribeInboundAudio", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("downloads mediaUrl to temp file and returns transcript", async () => {
|
||||||
|
const tmpBuf = Buffer.from("audio-bytes");
|
||||||
|
const tmpFile = path.join(os.tmpdir(), `warelay-audio-${Date.now()}.ogg`);
|
||||||
|
await fs.writeFile(tmpFile, tmpBuf);
|
||||||
|
|
||||||
|
const fetchMock = vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
arrayBuffer: async () => tmpBuf,
|
||||||
|
})) as unknown as typeof fetch;
|
||||||
|
// @ts-expect-error override global fetch for test
|
||||||
|
global.fetch = fetchMock;
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
inbound: {
|
||||||
|
transcribeAudio: {
|
||||||
|
command: ["echo", "{{MediaPath}}"],
|
||||||
|
timeoutSeconds: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ctx = { MediaUrl: "https://example.com/audio.ogg" };
|
||||||
|
|
||||||
|
const result = await transcribeInboundAudio(
|
||||||
|
cfg as never,
|
||||||
|
ctx as never,
|
||||||
|
runtime as never,
|
||||||
|
);
|
||||||
|
expect(result?.text).toBe("transcribed text");
|
||||||
|
expect(fetchMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when no transcription command", async () => {
|
||||||
|
const res = await transcribeInboundAudio({ inbound: {} } as never, {} as never, runtime as never);
|
||||||
|
expect(res).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user