Add ez-assistant and kerberos service folders
This commit is contained in:
89
docker-compose/ez-assistant/test/auto-reply.retry.test.ts
Normal file
89
docker-compose/ez-assistant/test/auto-reply.retry.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../src/web/media.js", () => ({
|
||||
loadWebMedia: vi.fn(async () => ({
|
||||
buffer: Buffer.from("img"),
|
||||
contentType: "image/jpeg",
|
||||
kind: "image",
|
||||
fileName: "img.jpg",
|
||||
})),
|
||||
}));
|
||||
|
||||
import { defaultRuntime } from "../src/runtime.js";
|
||||
import { deliverWebReply } from "../src/web/auto-reply.js";
|
||||
import type { WebInboundMessage } from "../src/web/inbound.js";
|
||||
|
||||
const noopLogger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
};
|
||||
|
||||
function makeMsg(): WebInboundMessage {
|
||||
const reply = vi.fn<
|
||||
Parameters<WebInboundMessage["reply"]>,
|
||||
ReturnType<WebInboundMessage["reply"]>
|
||||
>();
|
||||
const sendMedia = vi.fn<
|
||||
Parameters<WebInboundMessage["sendMedia"]>,
|
||||
ReturnType<WebInboundMessage["sendMedia"]>
|
||||
>();
|
||||
const sendComposing = vi.fn<
|
||||
Parameters<WebInboundMessage["sendComposing"]>,
|
||||
ReturnType<WebInboundMessage["sendComposing"]>
|
||||
>();
|
||||
return {
|
||||
from: "+10000000000",
|
||||
conversationId: "+10000000000",
|
||||
to: "+20000000000",
|
||||
id: "abc",
|
||||
body: "hello",
|
||||
chatType: "direct",
|
||||
chatId: "chat-1",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
};
|
||||
}
|
||||
|
||||
describe("deliverWebReply retry", () => {
|
||||
it("retries text send on transient failure", async () => {
|
||||
const msg = makeMsg();
|
||||
msg.reply.mockRejectedValueOnce(new Error("connection closed"));
|
||||
msg.reply.mockResolvedValueOnce(undefined);
|
||||
|
||||
await expect(
|
||||
deliverWebReply({
|
||||
replyResult: { text: "hi" },
|
||||
msg,
|
||||
maxMediaBytes: 5_000_000,
|
||||
replyLogger: noopLogger,
|
||||
runtime: defaultRuntime,
|
||||
skipLog: true,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(msg.reply).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("retries media send on transient failure", async () => {
|
||||
const msg = makeMsg();
|
||||
msg.sendMedia.mockRejectedValueOnce(new Error("socket reset"));
|
||||
msg.sendMedia.mockResolvedValueOnce(undefined);
|
||||
|
||||
await expect(
|
||||
deliverWebReply({
|
||||
replyResult: {
|
||||
text: "caption",
|
||||
mediaUrl: "http://example.com/img.jpg",
|
||||
},
|
||||
msg,
|
||||
maxMediaBytes: 5_000_000,
|
||||
replyLogger: noopLogger,
|
||||
runtime: defaultRuntime,
|
||||
skipLog: true,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(msg.sendMedia).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
19
docker-compose/ez-assistant/test/fixtures/child-process-bridge/child.js
vendored
Normal file
19
docker-compose/ez-assistant/test/fixtures/child-process-bridge/child.js
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
import http from "node:http";
|
||||
|
||||
const server = http.createServer((_, res) => {
|
||||
res.writeHead(200, { "content-type": "text/plain" });
|
||||
res.end("ok");
|
||||
});
|
||||
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === "string") throw new Error("unexpected address");
|
||||
process.stdout.write(`${addr.port}\n`);
|
||||
});
|
||||
|
||||
const shutdown = () => {
|
||||
server.close(() => process.exit(0));
|
||||
};
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
411
docker-compose/ez-assistant/test/gateway.multi.e2e.test.ts
Normal file
411
docker-compose/ez-assistant/test/gateway.multi.e2e.test.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { loadOrCreateDeviceIdentity } from "../src/infra/device-identity.js";
|
||||
import { GatewayClient } from "../src/gateway/client.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../src/utils/message-channel.js";
|
||||
|
||||
type GatewayInstance = {
|
||||
name: string;
|
||||
port: number;
|
||||
hookToken: string;
|
||||
gatewayToken: string;
|
||||
homeDir: string;
|
||||
stateDir: string;
|
||||
configPath: string;
|
||||
child: ChildProcessWithoutNullStreams;
|
||||
stdout: string[];
|
||||
stderr: string[];
|
||||
};
|
||||
|
||||
type NodeListPayload = {
|
||||
nodes?: Array<{ nodeId?: string; connected?: boolean; paired?: boolean }>;
|
||||
};
|
||||
|
||||
type HealthPayload = { ok?: boolean };
|
||||
|
||||
const GATEWAY_START_TIMEOUT_MS = 45_000;
|
||||
const E2E_TIMEOUT_MS = 120_000;
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const getFreePort = async () => {
|
||||
const srv = net.createServer();
|
||||
await new Promise<void>((resolve) => srv.listen(0, "127.0.0.1", resolve));
|
||||
const addr = srv.address();
|
||||
if (!addr || typeof addr === "string") {
|
||||
srv.close();
|
||||
throw new Error("failed to bind ephemeral port");
|
||||
}
|
||||
await new Promise<void>((resolve) => srv.close(() => resolve()));
|
||||
return addr.port;
|
||||
};
|
||||
|
||||
const waitForPortOpen = async (
|
||||
proc: ChildProcessWithoutNullStreams,
|
||||
chunksOut: string[],
|
||||
chunksErr: string[],
|
||||
port: number,
|
||||
timeoutMs: number,
|
||||
) => {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (proc.exitCode !== null) {
|
||||
const stdout = chunksOut.join("");
|
||||
const stderr = chunksErr.join("");
|
||||
throw new Error(
|
||||
`gateway exited before listening (code=${String(proc.exitCode)} signal=${String(proc.signalCode)})\n` +
|
||||
`--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.connect({ host: "127.0.0.1", port });
|
||||
socket.once("connect", () => {
|
||||
socket.destroy();
|
||||
resolve();
|
||||
});
|
||||
socket.once("error", (err) => {
|
||||
socket.destroy();
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// keep polling
|
||||
}
|
||||
|
||||
await sleep(25);
|
||||
}
|
||||
const stdout = chunksOut.join("");
|
||||
const stderr = chunksErr.join("");
|
||||
throw new Error(
|
||||
`timeout waiting for gateway to listen on port ${port}\n` +
|
||||
`--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
|
||||
);
|
||||
};
|
||||
|
||||
const spawnGatewayInstance = async (name: string): Promise<GatewayInstance> => {
|
||||
const port = await getFreePort();
|
||||
const hookToken = `token-${name}-${randomUUID()}`;
|
||||
const gatewayToken = `gateway-${name}-${randomUUID()}`;
|
||||
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), `moltbot-e2e-${name}-`));
|
||||
const configDir = path.join(homeDir, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
const configPath = path.join(configDir, "moltbot.json");
|
||||
const stateDir = path.join(configDir, "state");
|
||||
const config = {
|
||||
gateway: { port, auth: { mode: "token", token: gatewayToken } },
|
||||
hooks: { enabled: true, token: hookToken, path: "/hooks" },
|
||||
};
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
|
||||
|
||||
const stdout: string[] = [];
|
||||
const stderr: string[] = [];
|
||||
let child: ChildProcessWithoutNullStreams | null = null;
|
||||
|
||||
try {
|
||||
child = spawn(
|
||||
"node",
|
||||
[
|
||||
"dist/index.js",
|
||||
"gateway",
|
||||
"--port",
|
||||
String(port),
|
||||
"--bind",
|
||||
"loopback",
|
||||
"--allow-unconfigured",
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
CLAWDBOT_CONFIG_PATH: configPath,
|
||||
CLAWDBOT_STATE_DIR: stateDir,
|
||||
CLAWDBOT_GATEWAY_TOKEN: "",
|
||||
CLAWDBOT_GATEWAY_PASSWORD: "",
|
||||
CLAWDBOT_SKIP_CHANNELS: "1",
|
||||
CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER: "1",
|
||||
CLAWDBOT_SKIP_CANVAS_HOST: "1",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
|
||||
child.stdout?.setEncoding("utf8");
|
||||
child.stderr?.setEncoding("utf8");
|
||||
child.stdout?.on("data", (d) => stdout.push(String(d)));
|
||||
child.stderr?.on("data", (d) => stderr.push(String(d)));
|
||||
|
||||
await waitForPortOpen(child, stdout, stderr, port, GATEWAY_START_TIMEOUT_MS);
|
||||
|
||||
return {
|
||||
name,
|
||||
port,
|
||||
hookToken,
|
||||
gatewayToken,
|
||||
homeDir,
|
||||
stateDir,
|
||||
configPath,
|
||||
child,
|
||||
stdout,
|
||||
stderr,
|
||||
};
|
||||
} catch (err) {
|
||||
if (child && child.exitCode === null && !child.killed) {
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
await fs.rm(homeDir, { recursive: true, force: true });
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const stopGatewayInstance = async (inst: GatewayInstance) => {
|
||||
if (inst.child.exitCode === null && !inst.child.killed) {
|
||||
try {
|
||||
inst.child.kill("SIGTERM");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
const exited = await Promise.race([
|
||||
new Promise<boolean>((resolve) => {
|
||||
if (inst.child.exitCode !== null) return resolve(true);
|
||||
inst.child.once("exit", () => resolve(true));
|
||||
}),
|
||||
sleep(5_000).then(() => false),
|
||||
]);
|
||||
if (!exited && inst.child.exitCode === null && !inst.child.killed) {
|
||||
try {
|
||||
inst.child.kill("SIGKILL");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
await fs.rm(inst.homeDir, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
const runCliJson = async (args: string[], env: NodeJS.ProcessEnv): Promise<unknown> => {
|
||||
const stdout: string[] = [];
|
||||
const stderr: string[] = [];
|
||||
const child = spawn("node", ["dist/index.js", ...args], {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
child.stdout?.setEncoding("utf8");
|
||||
child.stderr?.setEncoding("utf8");
|
||||
child.stdout?.on("data", (d) => stdout.push(String(d)));
|
||||
child.stderr?.on("data", (d) => stderr.push(String(d)));
|
||||
const result = await new Promise<{
|
||||
code: number | null;
|
||||
signal: string | null;
|
||||
}>((resolve) => child.once("exit", (code, signal) => resolve({ code, signal })));
|
||||
const out = stdout.join("").trim();
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
`cli failed (code=${String(result.code)} signal=${String(result.signal)})\n` +
|
||||
`--- stdout ---\n${out}\n--- stderr ---\n${stderr.join("")}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
return out ? (JSON.parse(out) as unknown) : null;
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`cli returned non-json output: ${String(err)}\n` +
|
||||
`--- stdout ---\n${out}\n--- stderr ---\n${stderr.join("")}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const postJson = async (url: string, body: unknown) => {
|
||||
const payload = JSON.stringify(body);
|
||||
const parsed = new URL(url);
|
||||
return await new Promise<{ status: number; json: unknown }>((resolve, reject) => {
|
||||
const req = httpRequest(
|
||||
{
|
||||
method: "POST",
|
||||
hostname: parsed.hostname,
|
||||
port: Number(parsed.port),
|
||||
path: `${parsed.pathname}${parsed.search}`,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": Buffer.byteLength(payload),
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
let data = "";
|
||||
res.setEncoding("utf8");
|
||||
res.on("data", (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
res.on("end", () => {
|
||||
let json: unknown = null;
|
||||
if (data.trim()) {
|
||||
try {
|
||||
json = JSON.parse(data);
|
||||
} catch {
|
||||
json = data;
|
||||
}
|
||||
}
|
||||
resolve({ status: res.statusCode ?? 0, json });
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
};
|
||||
|
||||
const connectNode = async (
|
||||
inst: GatewayInstance,
|
||||
label: string,
|
||||
): Promise<{ client: GatewayClient; nodeId: string }> => {
|
||||
const identityPath = path.join(inst.homeDir, `${label}-device.json`);
|
||||
const deviceIdentity = loadOrCreateDeviceIdentity(identityPath);
|
||||
const nodeId = deviceIdentity.deviceId;
|
||||
let settled = false;
|
||||
let resolveReady: (() => void) | null = null;
|
||||
let rejectReady: ((err: Error) => void) | null = null;
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
resolveReady = resolve;
|
||||
rejectReady = reject;
|
||||
});
|
||||
|
||||
const client = new GatewayClient({
|
||||
url: `ws://127.0.0.1:${inst.port}`,
|
||||
token: inst.gatewayToken,
|
||||
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
|
||||
clientDisplayName: label,
|
||||
clientVersion: "1.0.0",
|
||||
platform: "ios",
|
||||
mode: GATEWAY_CLIENT_MODES.NODE,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: ["system"],
|
||||
commands: ["system.run"],
|
||||
deviceIdentity,
|
||||
onHelloOk: () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolveReady?.();
|
||||
},
|
||||
onConnectError: (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
rejectReady?.(err);
|
||||
},
|
||||
onClose: (code, reason) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
rejectReady?.(new Error(`gateway closed (${code}): ${reason}`));
|
||||
},
|
||||
});
|
||||
|
||||
client.start();
|
||||
try {
|
||||
await Promise.race([
|
||||
ready,
|
||||
sleep(10_000).then(() => {
|
||||
throw new Error(`timeout waiting for ${label} to connect`);
|
||||
}),
|
||||
]);
|
||||
} catch (err) {
|
||||
client.stop();
|
||||
throw err;
|
||||
}
|
||||
return { client, nodeId };
|
||||
};
|
||||
|
||||
const waitForNodeStatus = async (inst: GatewayInstance, nodeId: string, timeoutMs = 10_000) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const list = (await runCliJson(
|
||||
["nodes", "status", "--json", "--url", `ws://127.0.0.1:${inst.port}`],
|
||||
{
|
||||
CLAWDBOT_GATEWAY_TOKEN: inst.gatewayToken,
|
||||
CLAWDBOT_GATEWAY_PASSWORD: "",
|
||||
},
|
||||
)) as NodeListPayload;
|
||||
const match = list.nodes?.find((n) => n.nodeId === nodeId);
|
||||
if (match?.connected && match?.paired) return;
|
||||
await sleep(50);
|
||||
}
|
||||
throw new Error(`timeout waiting for node status for ${nodeId}`);
|
||||
};
|
||||
|
||||
describe("gateway multi-instance e2e", () => {
|
||||
const instances: GatewayInstance[] = [];
|
||||
const nodeClients: GatewayClient[] = [];
|
||||
|
||||
afterAll(async () => {
|
||||
for (const client of nodeClients) {
|
||||
client.stop();
|
||||
}
|
||||
for (const inst of instances) {
|
||||
await stopGatewayInstance(inst);
|
||||
}
|
||||
});
|
||||
|
||||
it(
|
||||
"spins up two gateways and exercises WS + HTTP + node pairing",
|
||||
{ timeout: E2E_TIMEOUT_MS },
|
||||
async () => {
|
||||
const gwA = await spawnGatewayInstance("a");
|
||||
instances.push(gwA);
|
||||
const gwB = await spawnGatewayInstance("b");
|
||||
instances.push(gwB);
|
||||
|
||||
const [healthA, healthB] = (await Promise.all([
|
||||
runCliJson(["health", "--json", "--timeout", "10000"], {
|
||||
CLAWDBOT_GATEWAY_PORT: String(gwA.port),
|
||||
CLAWDBOT_GATEWAY_TOKEN: gwA.gatewayToken,
|
||||
CLAWDBOT_GATEWAY_PASSWORD: "",
|
||||
}),
|
||||
runCliJson(["health", "--json", "--timeout", "10000"], {
|
||||
CLAWDBOT_GATEWAY_PORT: String(gwB.port),
|
||||
CLAWDBOT_GATEWAY_TOKEN: gwB.gatewayToken,
|
||||
CLAWDBOT_GATEWAY_PASSWORD: "",
|
||||
}),
|
||||
])) as [HealthPayload, HealthPayload];
|
||||
expect(healthA.ok).toBe(true);
|
||||
expect(healthB.ok).toBe(true);
|
||||
|
||||
const [hookResA, hookResB] = await Promise.all([
|
||||
postJson(`http://127.0.0.1:${gwA.port}/hooks/wake?token=${gwA.hookToken}`, {
|
||||
text: "wake a",
|
||||
mode: "now",
|
||||
}),
|
||||
postJson(`http://127.0.0.1:${gwB.port}/hooks/wake?token=${gwB.hookToken}`, {
|
||||
text: "wake b",
|
||||
mode: "now",
|
||||
}),
|
||||
]);
|
||||
expect(hookResA.status).toBe(200);
|
||||
expect((hookResA.json as { ok?: boolean } | undefined)?.ok).toBe(true);
|
||||
expect(hookResB.status).toBe(200);
|
||||
expect((hookResB.json as { ok?: boolean } | undefined)?.ok).toBe(true);
|
||||
|
||||
const nodeA = await connectNode(gwA, "node-a");
|
||||
const nodeB = await connectNode(gwB, "node-b");
|
||||
nodeClients.push(nodeA.client, nodeB.client);
|
||||
|
||||
await Promise.all([
|
||||
waitForNodeStatus(gwA, nodeA.nodeId),
|
||||
waitForNodeStatus(gwB, nodeB.nodeId),
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
6
docker-compose/ez-assistant/test/global-setup.ts
Normal file
6
docker-compose/ez-assistant/test/global-setup.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { installTestEnv } from "./test-env";
|
||||
|
||||
export default async () => {
|
||||
const { cleanup } = installTestEnv();
|
||||
return () => cleanup();
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
type EnvelopeTimestampZone = string;
|
||||
|
||||
function formatUtcTimestamp(date: Date): string {
|
||||
const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
|
||||
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(date.getUTCDate()).padStart(2, "0");
|
||||
const hh = String(date.getUTCHours()).padStart(2, "0");
|
||||
const min = String(date.getUTCMinutes()).padStart(2, "0");
|
||||
return `${yyyy}-${mm}-${dd}T${hh}:${min}Z`;
|
||||
}
|
||||
|
||||
function formatZonedTimestamp(date: Date, timeZone?: string): string {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
timeZoneName: "short",
|
||||
}).formatToParts(date);
|
||||
|
||||
const pick = (type: string) => parts.find((part) => part.type === type)?.value;
|
||||
const yyyy = pick("year");
|
||||
const mm = pick("month");
|
||||
const dd = pick("day");
|
||||
const hh = pick("hour");
|
||||
const min = pick("minute");
|
||||
const tz = [...parts]
|
||||
.reverse()
|
||||
.find((part) => part.type === "timeZoneName")
|
||||
?.value?.trim();
|
||||
|
||||
if (!yyyy || !mm || !dd || !hh || !min) {
|
||||
throw new Error("Missing date parts for envelope timestamp formatting.");
|
||||
}
|
||||
|
||||
return `${yyyy}-${mm}-${dd} ${hh}:${min}${tz ? ` ${tz}` : ""}`;
|
||||
}
|
||||
|
||||
export function formatEnvelopeTimestamp(date: Date, zone: EnvelopeTimestampZone = "utc"): string {
|
||||
const normalized = zone.trim().toLowerCase();
|
||||
if (normalized === "utc" || normalized === "gmt") return formatUtcTimestamp(date);
|
||||
if (normalized === "local" || normalized === "host") return formatZonedTimestamp(date);
|
||||
return formatZonedTimestamp(date, zone);
|
||||
}
|
||||
|
||||
export function formatLocalEnvelopeTimestamp(date: Date): string {
|
||||
return formatEnvelopeTimestamp(date, "local");
|
||||
}
|
||||
|
||||
export function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
20
docker-compose/ez-assistant/test/helpers/inbound-contract.ts
Normal file
20
docker-compose/ez-assistant/test/helpers/inbound-contract.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { normalizeChatType } from "../../src/channels/chat-type.js";
|
||||
import { resolveConversationLabel } from "../../src/channels/conversation-label.js";
|
||||
import { validateSenderIdentity } from "../../src/channels/sender-identity.js";
|
||||
import type { MsgContext } from "../../src/auto-reply/templating.js";
|
||||
|
||||
export function expectInboundContextContract(ctx: MsgContext) {
|
||||
expect(validateSenderIdentity(ctx)).toEqual([]);
|
||||
|
||||
expect(ctx.Body).toBeTypeOf("string");
|
||||
expect(ctx.BodyForAgent).toBeTypeOf("string");
|
||||
expect(ctx.BodyForCommands).toBeTypeOf("string");
|
||||
|
||||
const chatType = normalizeChatType(ctx.ChatType);
|
||||
if (chatType && chatType !== "direct") {
|
||||
const label = ctx.ConversationLabel?.trim() || resolveConversationLabel(ctx);
|
||||
expect(label).toBeTruthy();
|
||||
}
|
||||
}
|
||||
31
docker-compose/ez-assistant/test/helpers/normalize-text.ts
Normal file
31
docker-compose/ez-assistant/test/helpers/normalize-text.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
function stripAnsi(input: string): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const code = input.charCodeAt(i);
|
||||
if (code !== 27) {
|
||||
out += input[i];
|
||||
continue;
|
||||
}
|
||||
|
||||
const next = input[i + 1];
|
||||
if (next !== "[") continue;
|
||||
i += 1;
|
||||
|
||||
while (i + 1 < input.length) {
|
||||
i += 1;
|
||||
const c = input[i];
|
||||
if (!c) break;
|
||||
const isLetter = (c >= "A" && c <= "Z") || (c >= "a" && c <= "z") || c === "~";
|
||||
if (isLetter) break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeTestText(input: string): string {
|
||||
return stripAnsi(input)
|
||||
.replaceAll("\r\n", "\n")
|
||||
.replaceAll("…", "...")
|
||||
.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, "?")
|
||||
.replace(/[\uD800-\uDFFF]/g, "?");
|
||||
}
|
||||
16
docker-compose/ez-assistant/test/helpers/paths.ts
Normal file
16
docker-compose/ez-assistant/test/helpers/paths.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import path from "node:path";
|
||||
|
||||
export function isPathWithinBase(base: string, target: string): boolean {
|
||||
if (process.platform === "win32") {
|
||||
const normalizedBase = path.win32.normalize(path.win32.resolve(base));
|
||||
const normalizedTarget = path.win32.normalize(path.win32.resolve(target));
|
||||
|
||||
const rel = path.win32.relative(normalizedBase.toLowerCase(), normalizedTarget.toLowerCase());
|
||||
return rel === "" || (!rel.startsWith("..") && !path.win32.isAbsolute(rel));
|
||||
}
|
||||
|
||||
const normalizedBase = path.resolve(base);
|
||||
const normalizedTarget = path.resolve(target);
|
||||
const rel = path.relative(normalizedBase, normalizedTarget);
|
||||
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
||||
}
|
||||
25
docker-compose/ez-assistant/test/helpers/poll.ts
Normal file
25
docker-compose/ez-assistant/test/helpers/poll.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export type PollOptions = {
|
||||
timeoutMs?: number;
|
||||
intervalMs?: number;
|
||||
};
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export async function pollUntil<T>(
|
||||
fn: () => Promise<T | null | undefined>,
|
||||
opts: PollOptions = {},
|
||||
): Promise<T | undefined> {
|
||||
const timeoutMs = opts.timeoutMs ?? 2000;
|
||||
const intervalMs = opts.intervalMs ?? 25;
|
||||
const start = Date.now();
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const value = await fn();
|
||||
if (value !== null && value !== undefined) return value;
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
102
docker-compose/ez-assistant/test/helpers/temp-home.ts
Normal file
102
docker-compose/ez-assistant/test/helpers/temp-home.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
type EnvValue = string | undefined | ((home: string) => string | undefined);
|
||||
|
||||
type EnvSnapshot = {
|
||||
home: string | undefined;
|
||||
userProfile: string | undefined;
|
||||
homeDrive: string | undefined;
|
||||
homePath: string | undefined;
|
||||
stateDir: string | undefined;
|
||||
};
|
||||
|
||||
function snapshotEnv(): EnvSnapshot {
|
||||
return {
|
||||
home: process.env.HOME,
|
||||
userProfile: process.env.USERPROFILE,
|
||||
homeDrive: process.env.HOMEDRIVE,
|
||||
homePath: process.env.HOMEPATH,
|
||||
stateDir: process.env.CLAWDBOT_STATE_DIR,
|
||||
};
|
||||
}
|
||||
|
||||
function restoreEnv(snapshot: EnvSnapshot) {
|
||||
const restoreKey = (key: string, value: string | undefined) => {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
};
|
||||
restoreKey("HOME", snapshot.home);
|
||||
restoreKey("USERPROFILE", snapshot.userProfile);
|
||||
restoreKey("HOMEDRIVE", snapshot.homeDrive);
|
||||
restoreKey("HOMEPATH", snapshot.homePath);
|
||||
restoreKey("CLAWDBOT_STATE_DIR", snapshot.stateDir);
|
||||
}
|
||||
|
||||
function snapshotExtraEnv(keys: string[]): Record<string, string | undefined> {
|
||||
const snapshot: Record<string, string | undefined> = {};
|
||||
for (const key of keys) snapshot[key] = process.env[key];
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function restoreExtraEnv(snapshot: Record<string, string | undefined>) {
|
||||
for (const [key, value] of Object.entries(snapshot)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function setTempHome(base: string) {
|
||||
process.env.HOME = base;
|
||||
process.env.USERPROFILE = base;
|
||||
process.env.CLAWDBOT_STATE_DIR = path.join(base, ".clawdbot");
|
||||
|
||||
if (process.platform !== "win32") return;
|
||||
const match = base.match(/^([A-Za-z]:)(.*)$/);
|
||||
if (!match) return;
|
||||
process.env.HOMEDRIVE = match[1];
|
||||
process.env.HOMEPATH = match[2] || "\\";
|
||||
}
|
||||
|
||||
export async function withTempHome<T>(
|
||||
fn: (home: string) => Promise<T>,
|
||||
opts: { env?: Record<string, EnvValue>; prefix?: string } = {},
|
||||
): Promise<T> {
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), opts.prefix ?? "moltbot-test-home-"));
|
||||
const snapshot = snapshotEnv();
|
||||
const envKeys = Object.keys(opts.env ?? {});
|
||||
for (const key of envKeys) {
|
||||
if (key === "HOME" || key === "USERPROFILE" || key === "HOMEDRIVE" || key === "HOMEPATH") {
|
||||
throw new Error(`withTempHome: use built-in home env (got ${key})`);
|
||||
}
|
||||
}
|
||||
const envSnapshot = snapshotExtraEnv(envKeys);
|
||||
|
||||
setTempHome(base);
|
||||
await fs.mkdir(path.join(base, ".clawdbot", "agents", "main", "sessions"), { recursive: true });
|
||||
if (opts.env) {
|
||||
for (const [key, raw] of Object.entries(opts.env)) {
|
||||
const value = typeof raw === "function" ? raw(base) : raw;
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await fn(base);
|
||||
} finally {
|
||||
restoreExtraEnv(envSnapshot);
|
||||
restoreEnv(snapshot);
|
||||
try {
|
||||
await fs.rm(base, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 10,
|
||||
retryDelay: 50,
|
||||
});
|
||||
} catch {
|
||||
// ignore cleanup failures in tests
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import type { MsgContext } from "../src/auto-reply/templating.js";
|
||||
import { finalizeInboundContext } from "../src/auto-reply/reply/inbound-context.js";
|
||||
import { expectInboundContextContract } from "./helpers/inbound-contract.js";
|
||||
|
||||
describe("inbound context contract (providers + extensions)", () => {
|
||||
const cases: Array<{ name: string; ctx: MsgContext }> = [
|
||||
{
|
||||
name: "whatsapp group",
|
||||
ctx: {
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
ChatType: "group",
|
||||
From: "123@g.us",
|
||||
To: "+15550001111",
|
||||
Body: "[WhatsApp 123@g.us] hi",
|
||||
RawBody: "hi",
|
||||
CommandBody: "hi",
|
||||
SenderName: "Alice",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "telegram group",
|
||||
ctx: {
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
ChatType: "group",
|
||||
From: "group:123",
|
||||
To: "telegram:123",
|
||||
Body: "[Telegram group:123] hi",
|
||||
RawBody: "hi",
|
||||
CommandBody: "hi",
|
||||
GroupSubject: "Telegram Group",
|
||||
SenderName: "Alice",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "slack channel",
|
||||
ctx: {
|
||||
Provider: "slack",
|
||||
Surface: "slack",
|
||||
ChatType: "channel",
|
||||
From: "slack:channel:C123",
|
||||
To: "channel:C123",
|
||||
Body: "[Slack #general] hi",
|
||||
RawBody: "hi",
|
||||
CommandBody: "hi",
|
||||
GroupSubject: "#general",
|
||||
SenderName: "Alice",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "discord channel",
|
||||
ctx: {
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
ChatType: "channel",
|
||||
From: "group:123",
|
||||
To: "channel:123",
|
||||
Body: "[Discord #general] hi",
|
||||
RawBody: "hi",
|
||||
CommandBody: "hi",
|
||||
GroupSubject: "#general",
|
||||
SenderName: "Alice",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "signal dm",
|
||||
ctx: {
|
||||
Provider: "signal",
|
||||
Surface: "signal",
|
||||
ChatType: "direct",
|
||||
From: "signal:+15550001111",
|
||||
To: "signal:+15550002222",
|
||||
Body: "[Signal] hi",
|
||||
RawBody: "hi",
|
||||
CommandBody: "hi",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "imessage group",
|
||||
ctx: {
|
||||
Provider: "imessage",
|
||||
Surface: "imessage",
|
||||
ChatType: "group",
|
||||
From: "group:chat_id:123",
|
||||
To: "chat_id:123",
|
||||
Body: "[iMessage Group] hi",
|
||||
RawBody: "hi",
|
||||
CommandBody: "hi",
|
||||
GroupSubject: "iMessage Group",
|
||||
SenderName: "Alice",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "matrix channel",
|
||||
ctx: {
|
||||
Provider: "matrix",
|
||||
Surface: "matrix",
|
||||
ChatType: "channel",
|
||||
From: "matrix:channel:!room:example.org",
|
||||
To: "room:!room:example.org",
|
||||
Body: "[Matrix] hi",
|
||||
RawBody: "hi",
|
||||
CommandBody: "hi",
|
||||
GroupSubject: "#general",
|
||||
SenderName: "Alice",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "msteams channel",
|
||||
ctx: {
|
||||
Provider: "msteams",
|
||||
Surface: "msteams",
|
||||
ChatType: "channel",
|
||||
From: "msteams:channel:19:abc@thread.tacv2",
|
||||
To: "msteams:channel:19:abc@thread.tacv2",
|
||||
Body: "[Teams] hi",
|
||||
RawBody: "hi",
|
||||
CommandBody: "hi",
|
||||
GroupSubject: "Teams Channel",
|
||||
SenderName: "Alice",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "zalo dm",
|
||||
ctx: {
|
||||
Provider: "zalo",
|
||||
Surface: "zalo",
|
||||
ChatType: "direct",
|
||||
From: "zalo:123",
|
||||
To: "zalo:123",
|
||||
Body: "[Zalo] hi",
|
||||
RawBody: "hi",
|
||||
CommandBody: "hi",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "zalouser group",
|
||||
ctx: {
|
||||
Provider: "zalouser",
|
||||
Surface: "zalouser",
|
||||
ChatType: "group",
|
||||
From: "group:123",
|
||||
To: "zalouser:123",
|
||||
Body: "[Zalo Personal] hi",
|
||||
RawBody: "hi",
|
||||
CommandBody: "hi",
|
||||
GroupSubject: "Zalouser Group",
|
||||
SenderName: "Alice",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const entry of cases) {
|
||||
it(entry.name, () => {
|
||||
const ctx = finalizeInboundContext({ ...entry.ctx });
|
||||
expectInboundContextContract(ctx);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
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 type { MoltbotConfig } from "../src/config/config.js";
|
||||
import type { MsgContext } from "../src/auto-reply/templating.js";
|
||||
|
||||
const makeTempDir = async (prefix: string) => await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
|
||||
const writeExecutable = async (dir: string, name: string, content: string) => {
|
||||
const filePath = path.join(dir, name);
|
||||
await fs.writeFile(filePath, content, { mode: 0o755 });
|
||||
return filePath;
|
||||
};
|
||||
|
||||
const makeTempMedia = async (ext: string) => {
|
||||
const dir = await makeTempDir("moltbot-media-e2e-");
|
||||
const filePath = path.join(dir, `sample${ext}`);
|
||||
await fs.writeFile(filePath, "audio");
|
||||
return { dir, filePath };
|
||||
};
|
||||
|
||||
const loadApply = async () => {
|
||||
vi.resetModules();
|
||||
return await import("../src/media-understanding/apply.js");
|
||||
};
|
||||
|
||||
const envSnapshot = () => ({
|
||||
PATH: process.env.PATH,
|
||||
SHERPA_ONNX_MODEL_DIR: process.env.SHERPA_ONNX_MODEL_DIR,
|
||||
WHISPER_CPP_MODEL: process.env.WHISPER_CPP_MODEL,
|
||||
});
|
||||
|
||||
const restoreEnv = (snapshot: ReturnType<typeof envSnapshot>) => {
|
||||
process.env.PATH = snapshot.PATH;
|
||||
process.env.SHERPA_ONNX_MODEL_DIR = snapshot.SHERPA_ONNX_MODEL_DIR;
|
||||
process.env.WHISPER_CPP_MODEL = snapshot.WHISPER_CPP_MODEL;
|
||||
};
|
||||
|
||||
describe("media understanding auto-detect (e2e)", () => {
|
||||
let tempPaths: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const p of tempPaths) {
|
||||
await fs.rm(p, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
tempPaths = [];
|
||||
});
|
||||
|
||||
it("uses sherpa-onnx-offline when available", async () => {
|
||||
const snapshot = envSnapshot();
|
||||
try {
|
||||
const binDir = await makeTempDir("moltbot-bin-sherpa-");
|
||||
const modelDir = await makeTempDir("moltbot-sherpa-model-");
|
||||
tempPaths.push(binDir, modelDir);
|
||||
|
||||
await fs.writeFile(path.join(modelDir, "tokens.txt"), "a");
|
||||
await fs.writeFile(path.join(modelDir, "encoder.onnx"), "a");
|
||||
await fs.writeFile(path.join(modelDir, "decoder.onnx"), "a");
|
||||
await fs.writeFile(path.join(modelDir, "joiner.onnx"), "a");
|
||||
|
||||
await writeExecutable(
|
||||
binDir,
|
||||
"sherpa-onnx-offline",
|
||||
"#!/usr/bin/env bash\n" + 'echo "{\\"text\\":\\"sherpa ok\\"}"\n',
|
||||
);
|
||||
|
||||
process.env.PATH = `${binDir}:/usr/bin:/bin`;
|
||||
process.env.SHERPA_ONNX_MODEL_DIR = modelDir;
|
||||
|
||||
const { filePath } = await makeTempMedia(".wav");
|
||||
tempPaths.push(path.dirname(filePath));
|
||||
|
||||
const { applyMediaUnderstanding } = await loadApply();
|
||||
const ctx: MsgContext = {
|
||||
Body: "<media:audio>",
|
||||
MediaPath: filePath,
|
||||
MediaType: "audio/wav",
|
||||
};
|
||||
const cfg: MoltbotConfig = { tools: { media: { audio: {} } } };
|
||||
|
||||
await applyMediaUnderstanding({ ctx, cfg });
|
||||
|
||||
expect(ctx.Transcript).toBe("sherpa ok");
|
||||
} finally {
|
||||
restoreEnv(snapshot);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses whisper-cli when sherpa is missing", async () => {
|
||||
const snapshot = envSnapshot();
|
||||
try {
|
||||
const binDir = await makeTempDir("moltbot-bin-whispercpp-");
|
||||
const modelDir = await makeTempDir("moltbot-whispercpp-model-");
|
||||
tempPaths.push(binDir, modelDir);
|
||||
|
||||
const modelPath = path.join(modelDir, "tiny.bin");
|
||||
await fs.writeFile(modelPath, "model");
|
||||
|
||||
await writeExecutable(
|
||||
binDir,
|
||||
"whisper-cli",
|
||||
"#!/usr/bin/env bash\n" +
|
||||
'out=""\n' +
|
||||
'prev=""\n' +
|
||||
'for arg in "$@"; do\n' +
|
||||
' if [ "$prev" = "-of" ]; then out="$arg"; break; fi\n' +
|
||||
' prev="$arg"\n' +
|
||||
"done\n" +
|
||||
'if [ -n "$out" ]; then echo \'whisper cpp ok\' > "${out}.txt"; fi\n',
|
||||
);
|
||||
|
||||
process.env.PATH = `${binDir}:/usr/bin:/bin`;
|
||||
process.env.WHISPER_CPP_MODEL = modelPath;
|
||||
|
||||
const { filePath } = await makeTempMedia(".wav");
|
||||
tempPaths.push(path.dirname(filePath));
|
||||
|
||||
const { applyMediaUnderstanding } = await loadApply();
|
||||
const ctx: MsgContext = {
|
||||
Body: "<media:audio>",
|
||||
MediaPath: filePath,
|
||||
MediaType: "audio/wav",
|
||||
};
|
||||
const cfg: MoltbotConfig = { tools: { media: { audio: {} } } };
|
||||
|
||||
await applyMediaUnderstanding({ ctx, cfg });
|
||||
|
||||
expect(ctx.Transcript).toBe("whisper cpp ok");
|
||||
} finally {
|
||||
restoreEnv(snapshot);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses gemini CLI for images when available", async () => {
|
||||
const snapshot = envSnapshot();
|
||||
try {
|
||||
const binDir = await makeTempDir("moltbot-bin-gemini-");
|
||||
tempPaths.push(binDir);
|
||||
|
||||
await writeExecutable(
|
||||
binDir,
|
||||
"gemini",
|
||||
"#!/usr/bin/env bash\necho '{" + '\\"response\\":\\"gemini ok\\"' + "}'\n",
|
||||
);
|
||||
|
||||
process.env.PATH = `${binDir}:/usr/bin:/bin`;
|
||||
|
||||
const { filePath } = await makeTempMedia(".png");
|
||||
tempPaths.push(path.dirname(filePath));
|
||||
|
||||
const { applyMediaUnderstanding } = await loadApply();
|
||||
const ctx: MsgContext = {
|
||||
Body: "<media:image>",
|
||||
MediaPath: filePath,
|
||||
MediaType: "image/png",
|
||||
};
|
||||
const cfg: MoltbotConfig = { tools: { media: { image: {} } } };
|
||||
|
||||
await applyMediaUnderstanding({ ctx, cfg });
|
||||
|
||||
expect(ctx.Body).toContain("gemini ok");
|
||||
} finally {
|
||||
restoreEnv(snapshot);
|
||||
}
|
||||
});
|
||||
});
|
||||
68
docker-compose/ez-assistant/test/mocks/baileys.ts
Normal file
68
docker-compose/ez-assistant/test/mocks/baileys.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
import { vi } from "vitest";
|
||||
|
||||
export type MockBaileysSocket = {
|
||||
ev: EventEmitter;
|
||||
ws: { close: ReturnType<typeof vi.fn> };
|
||||
sendPresenceUpdate: ReturnType<typeof vi.fn>;
|
||||
sendMessage: ReturnType<typeof vi.fn>;
|
||||
readMessages: ReturnType<typeof vi.fn>;
|
||||
user?: { id?: string };
|
||||
};
|
||||
|
||||
export type MockBaileysModule = {
|
||||
DisconnectReason: { loggedOut: number };
|
||||
fetchLatestBaileysVersion: ReturnType<typeof vi.fn>;
|
||||
makeCacheableSignalKeyStore: ReturnType<typeof vi.fn>;
|
||||
makeWASocket: ReturnType<typeof vi.fn>;
|
||||
useMultiFileAuthState: ReturnType<typeof vi.fn>;
|
||||
jidToE164?: (jid: string) => string | null;
|
||||
proto?: unknown;
|
||||
downloadMediaMessage?: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
export function createMockBaileys(): {
|
||||
mod: MockBaileysModule;
|
||||
lastSocket: () => MockBaileysSocket;
|
||||
} {
|
||||
const sockets: MockBaileysSocket[] = [];
|
||||
const makeWASocket = vi.fn((_opts: unknown) => {
|
||||
const ev = new EventEmitter();
|
||||
const sock: MockBaileysSocket = {
|
||||
ev,
|
||||
ws: { close: vi.fn() },
|
||||
sendPresenceUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
sendMessage: vi.fn().mockResolvedValue({ key: { id: "msg123" } }),
|
||||
readMessages: vi.fn().mockResolvedValue(undefined),
|
||||
user: { id: "123@s.whatsapp.net" },
|
||||
};
|
||||
setImmediate(() => ev.emit("connection.update", { connection: "open" }));
|
||||
sockets.push(sock);
|
||||
return sock;
|
||||
});
|
||||
|
||||
const mod: MockBaileysModule = {
|
||||
DisconnectReason: { loggedOut: 401 },
|
||||
fetchLatestBaileysVersion: vi.fn().mockResolvedValue({ version: [1, 2, 3] }),
|
||||
makeCacheableSignalKeyStore: vi.fn((keys: unknown) => keys),
|
||||
makeWASocket,
|
||||
useMultiFileAuthState: vi.fn(async () => ({
|
||||
state: { creds: {}, keys: {} },
|
||||
saveCreds: vi.fn(),
|
||||
})),
|
||||
jidToE164: (jid: string) => jid.replace(/@.*$/, "").replace(/^/, "+"),
|
||||
downloadMediaMessage: vi.fn().mockResolvedValue(Buffer.from("img")),
|
||||
};
|
||||
|
||||
return {
|
||||
mod,
|
||||
lastSocket: () => {
|
||||
const last = sockets.at(-1);
|
||||
if (!last) {
|
||||
throw new Error("No Baileys sockets created");
|
||||
}
|
||||
return last;
|
||||
},
|
||||
};
|
||||
}
|
||||
283
docker-compose/ez-assistant/test/provider-timeout.e2e.test.ts
Normal file
283
docker-compose/ez-assistant/test/provider-timeout.e2e.test.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { GatewayClient } from "../src/gateway/client.js";
|
||||
import { startGatewayServer } from "../src/gateway/server.js";
|
||||
import { getDeterministicFreePortBlock } from "../src/test-utils/ports.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../src/utils/message-channel.js";
|
||||
|
||||
type OpenAIResponseStreamEvent =
|
||||
| { type: "response.output_item.added"; item: Record<string, unknown> }
|
||||
| { type: "response.output_item.done"; item: Record<string, unknown> }
|
||||
| {
|
||||
type: "response.completed";
|
||||
response: {
|
||||
status: "completed";
|
||||
usage: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function buildOpenAIResponsesSse(text: string): Response {
|
||||
const events: OpenAIResponseStreamEvent[] = [
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "msg_test_1",
|
||||
role: "assistant",
|
||||
content: [],
|
||||
status: "in_progress",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "msg_test_1",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text, annotations: [] }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
status: "completed",
|
||||
usage: { input_tokens: 10, output_tokens: 10, total_tokens: 20 },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const sse = `${events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("")}data: [DONE]\n\n`;
|
||||
const encoder = new TextEncoder();
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(sse));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
|
||||
function extractPayloadText(result: unknown): string {
|
||||
const record = result as Record<string, unknown>;
|
||||
const payloads = Array.isArray(record.payloads) ? record.payloads : [];
|
||||
const texts = payloads
|
||||
.map((p) => (p && typeof p === "object" ? (p as Record<string, unknown>).text : undefined))
|
||||
.filter((t): t is string => typeof t === "string" && t.trim().length > 0);
|
||||
return texts.join("\n").trim();
|
||||
}
|
||||
|
||||
async function connectClient(params: { url: string; token: string }) {
|
||||
return await new Promise<InstanceType<typeof GatewayClient>>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const stop = (err?: Error, client?: InstanceType<typeof GatewayClient>) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (err) reject(err);
|
||||
else resolve(client as InstanceType<typeof GatewayClient>);
|
||||
};
|
||||
const client = new GatewayClient({
|
||||
url: params.url,
|
||||
token: params.token,
|
||||
clientName: GATEWAY_CLIENT_NAMES.TEST,
|
||||
clientDisplayName: "vitest-timeout-fallback",
|
||||
clientVersion: "dev",
|
||||
mode: GATEWAY_CLIENT_MODES.TEST,
|
||||
onHelloOk: () => stop(undefined, client),
|
||||
onConnectError: (err) => stop(err),
|
||||
onClose: (code, reason) =>
|
||||
stop(new Error(`gateway closed during connect (${code}): ${reason}`)),
|
||||
});
|
||||
const timer = setTimeout(() => stop(new Error("gateway connect timeout")), 10_000);
|
||||
timer.unref();
|
||||
client.start();
|
||||
});
|
||||
}
|
||||
|
||||
async function getFreeGatewayPort(): Promise<number> {
|
||||
return await getDeterministicFreePortBlock({ offsets: [0, 1, 2, 3, 4] });
|
||||
}
|
||||
|
||||
describe("provider timeouts (e2e)", () => {
|
||||
it(
|
||||
"falls back when the primary provider aborts with a timeout-like AbortError",
|
||||
{ timeout: 60_000 },
|
||||
async () => {
|
||||
const prev = {
|
||||
home: process.env.HOME,
|
||||
configPath: process.env.CLAWDBOT_CONFIG_PATH,
|
||||
token: process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||
skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS,
|
||||
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
|
||||
skipCron: process.env.CLAWDBOT_SKIP_CRON,
|
||||
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
|
||||
};
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const primaryBaseUrl = "https://primary.example/v1";
|
||||
const fallbackBaseUrl = "https://fallback.example/v1";
|
||||
const counts = { primary: 0, fallback: 0 };
|
||||
const fetchImpl = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
if (url.startsWith(`${primaryBaseUrl}/responses`)) {
|
||||
counts.primary += 1;
|
||||
const err = new Error("request was aborted");
|
||||
err.name = "AbortError";
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (url.startsWith(`${fallbackBaseUrl}/responses`)) {
|
||||
counts.fallback += 1;
|
||||
return buildOpenAIResponsesSse("fallback-ok");
|
||||
}
|
||||
|
||||
if (!originalFetch) throw new Error(`fetch is not available (url=${url})`);
|
||||
return await originalFetch(input, init);
|
||||
};
|
||||
(globalThis as unknown as { fetch: unknown }).fetch = fetchImpl;
|
||||
|
||||
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-timeout-e2e-"));
|
||||
process.env.HOME = tempHome;
|
||||
process.env.CLAWDBOT_SKIP_CHANNELS = "1";
|
||||
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
|
||||
process.env.CLAWDBOT_SKIP_CRON = "1";
|
||||
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
|
||||
|
||||
const token = `test-${randomUUID()}`;
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN = token;
|
||||
|
||||
const configDir = path.join(tempHome, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
const configPath = path.join(configDir, "moltbot.json");
|
||||
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "primary/gpt-5.2",
|
||||
fallbacks: ["fallback/gpt-5.2"],
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
mode: "replace",
|
||||
providers: {
|
||||
primary: {
|
||||
baseUrl: primaryBaseUrl,
|
||||
apiKey: "test",
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.2",
|
||||
name: "gpt-5.2",
|
||||
api: "openai-responses",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
],
|
||||
},
|
||||
fallback: {
|
||||
baseUrl: fallbackBaseUrl,
|
||||
apiKey: "test",
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.2",
|
||||
name: "gpt-5.2",
|
||||
api: "openai-responses",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
gateway: { auth: { token } },
|
||||
};
|
||||
|
||||
await fs.writeFile(configPath, `${JSON.stringify(cfg, null, 2)}\n`);
|
||||
process.env.CLAWDBOT_CONFIG_PATH = configPath;
|
||||
|
||||
const port = await getFreeGatewayPort();
|
||||
const server = await startGatewayServer(port, {
|
||||
bind: "loopback",
|
||||
auth: { mode: "token", token },
|
||||
controlUiEnabled: false,
|
||||
});
|
||||
|
||||
const client = await connectClient({
|
||||
url: `ws://127.0.0.1:${port}`,
|
||||
token,
|
||||
});
|
||||
|
||||
try {
|
||||
const sessionKey = "agent:dev:timeout-fallback";
|
||||
await client.request<Record<string, unknown>>("sessions.patch", {
|
||||
key: sessionKey,
|
||||
model: "primary/gpt-5.2",
|
||||
});
|
||||
|
||||
const runId = randomUUID();
|
||||
const payload = await client.request<{
|
||||
status?: unknown;
|
||||
result?: unknown;
|
||||
}>(
|
||||
"agent",
|
||||
{
|
||||
sessionKey,
|
||||
idempotencyKey: `idem-${runId}`,
|
||||
message: "say fallback-ok",
|
||||
deliver: false,
|
||||
},
|
||||
{ expectFinal: true },
|
||||
);
|
||||
|
||||
expect(payload?.status).toBe("ok");
|
||||
const text = extractPayloadText(payload?.result);
|
||||
expect(text).toContain("fallback-ok");
|
||||
expect(counts.primary).toBeGreaterThan(0);
|
||||
expect(counts.fallback).toBeGreaterThan(0);
|
||||
} finally {
|
||||
client.stop();
|
||||
await server.close({ reason: "timeout fallback test complete" });
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
(globalThis as unknown as { fetch: unknown }).fetch = originalFetch;
|
||||
if (prev.home === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = prev.home;
|
||||
if (prev.configPath === undefined) delete process.env.CLAWDBOT_CONFIG_PATH;
|
||||
else process.env.CLAWDBOT_CONFIG_PATH = prev.configPath;
|
||||
if (prev.token === undefined) delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
else process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token;
|
||||
if (prev.skipChannels === undefined) delete process.env.CLAWDBOT_SKIP_CHANNELS;
|
||||
else process.env.CLAWDBOT_SKIP_CHANNELS = prev.skipChannels;
|
||||
if (prev.skipGmail === undefined) delete process.env.CLAWDBOT_SKIP_GMAIL_WATCHER;
|
||||
else process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail;
|
||||
if (prev.skipCron === undefined) delete process.env.CLAWDBOT_SKIP_CRON;
|
||||
else process.env.CLAWDBOT_SKIP_CRON = prev.skipCron;
|
||||
if (prev.skipCanvas === undefined) delete process.env.CLAWDBOT_SKIP_CANVAS_HOST;
|
||||
else process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas;
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
162
docker-compose/ez-assistant/test/setup.ts
Normal file
162
docker-compose/ez-assistant/test/setup.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { afterAll, afterEach, beforeEach, vi } from "vitest";
|
||||
|
||||
// Ensure Vitest environment is properly set
|
||||
process.env.VITEST = "true";
|
||||
|
||||
import type {
|
||||
ChannelId,
|
||||
ChannelOutboundAdapter,
|
||||
ChannelPlugin,
|
||||
} from "../src/channels/plugins/types.js";
|
||||
import type { MoltbotConfig } from "../src/config/config.js";
|
||||
import type { OutboundSendDeps } from "../src/infra/outbound/deliver.js";
|
||||
import { installProcessWarningFilter } from "../src/infra/warnings.js";
|
||||
import { setActivePluginRegistry } from "../src/plugins/runtime.js";
|
||||
import { createTestRegistry } from "../src/test-utils/channel-plugins.js";
|
||||
import { withIsolatedTestHome } from "./test-env";
|
||||
|
||||
installProcessWarningFilter();
|
||||
|
||||
const testEnv = withIsolatedTestHome();
|
||||
afterAll(() => testEnv.cleanup());
|
||||
const pickSendFn = (id: ChannelId, deps?: OutboundSendDeps) => {
|
||||
switch (id) {
|
||||
case "discord":
|
||||
return deps?.sendDiscord;
|
||||
case "slack":
|
||||
return deps?.sendSlack;
|
||||
case "telegram":
|
||||
return deps?.sendTelegram;
|
||||
case "whatsapp":
|
||||
return deps?.sendWhatsApp;
|
||||
case "signal":
|
||||
return deps?.sendSignal;
|
||||
case "imessage":
|
||||
return deps?.sendIMessage;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const createStubOutbound = (
|
||||
id: ChannelId,
|
||||
deliveryMode: ChannelOutboundAdapter["deliveryMode"] = "direct",
|
||||
): ChannelOutboundAdapter => ({
|
||||
deliveryMode,
|
||||
sendText: async ({ deps, to, text }) => {
|
||||
const send = pickSendFn(id, deps);
|
||||
if (send) {
|
||||
const result = await send(to, text, {});
|
||||
return { channel: id, ...result };
|
||||
}
|
||||
return { channel: id, messageId: "test" };
|
||||
},
|
||||
sendMedia: async ({ deps, to, text, mediaUrl }) => {
|
||||
const send = pickSendFn(id, deps);
|
||||
if (send) {
|
||||
const result = await send(to, text, { mediaUrl });
|
||||
return { channel: id, ...result };
|
||||
}
|
||||
return { channel: id, messageId: "test" };
|
||||
},
|
||||
});
|
||||
|
||||
const createStubPlugin = (params: {
|
||||
id: ChannelId;
|
||||
label?: string;
|
||||
aliases?: string[];
|
||||
deliveryMode?: ChannelOutboundAdapter["deliveryMode"];
|
||||
preferSessionLookupForAnnounceTarget?: boolean;
|
||||
}): ChannelPlugin => ({
|
||||
id: params.id,
|
||||
meta: {
|
||||
id: params.id,
|
||||
label: params.label ?? String(params.id),
|
||||
selectionLabel: params.label ?? String(params.id),
|
||||
docsPath: `/channels/${params.id}`,
|
||||
blurb: "test stub.",
|
||||
aliases: params.aliases,
|
||||
preferSessionLookupForAnnounceTarget: params.preferSessionLookupForAnnounceTarget,
|
||||
},
|
||||
capabilities: { chatTypes: ["direct", "group"] },
|
||||
config: {
|
||||
listAccountIds: (cfg: MoltbotConfig) => {
|
||||
const channels = cfg.channels as Record<string, unknown> | undefined;
|
||||
const entry = channels?.[params.id];
|
||||
if (!entry || typeof entry !== "object") return [];
|
||||
const accounts = (entry as { accounts?: Record<string, unknown> }).accounts;
|
||||
const ids = accounts ? Object.keys(accounts).filter(Boolean) : [];
|
||||
return ids.length > 0 ? ids : ["default"];
|
||||
},
|
||||
resolveAccount: (cfg: MoltbotConfig, accountId: string) => {
|
||||
const channels = cfg.channels as Record<string, unknown> | undefined;
|
||||
const entry = channels?.[params.id];
|
||||
if (!entry || typeof entry !== "object") return {};
|
||||
const accounts = (entry as { accounts?: Record<string, unknown> }).accounts;
|
||||
const match = accounts?.[accountId];
|
||||
return (match && typeof match === "object") || typeof match === "string" ? match : entry;
|
||||
},
|
||||
isConfigured: async (_account, cfg: MoltbotConfig) => {
|
||||
const channels = cfg.channels as Record<string, unknown> | undefined;
|
||||
return Boolean(channels?.[params.id]);
|
||||
},
|
||||
},
|
||||
outbound: createStubOutbound(params.id, params.deliveryMode),
|
||||
});
|
||||
|
||||
const createDefaultRegistry = () =>
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "discord",
|
||||
plugin: createStubPlugin({ id: "discord", label: "Discord" }),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "slack",
|
||||
plugin: createStubPlugin({ id: "slack", label: "Slack" }),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "telegram",
|
||||
plugin: {
|
||||
...createStubPlugin({ id: "telegram", label: "Telegram" }),
|
||||
status: {
|
||||
buildChannelSummary: async () => ({
|
||||
configured: false,
|
||||
tokenSource: process.env.TELEGRAM_BOT_TOKEN ? "env" : "none",
|
||||
}),
|
||||
},
|
||||
},
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "whatsapp",
|
||||
plugin: createStubPlugin({
|
||||
id: "whatsapp",
|
||||
label: "WhatsApp",
|
||||
deliveryMode: "gateway",
|
||||
preferSessionLookupForAnnounceTarget: true,
|
||||
}),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "signal",
|
||||
plugin: createStubPlugin({ id: "signal", label: "Signal" }),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "imessage",
|
||||
plugin: createStubPlugin({ id: "imessage", label: "iMessage", aliases: ["imsg"] }),
|
||||
source: "test",
|
||||
},
|
||||
]);
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(createDefaultRegistry());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createDefaultRegistry());
|
||||
// Guard against leaked fake timers across test files/workers.
|
||||
vi.useRealTimers();
|
||||
});
|
||||
136
docker-compose/ez-assistant/test/test-env.ts
Normal file
136
docker-compose/ez-assistant/test/test-env.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
type RestoreEntry = { key: string; value: string | undefined };
|
||||
|
||||
function restoreEnv(entries: RestoreEntry[]): void {
|
||||
for (const { key, value } of entries) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function loadProfileEnv(): void {
|
||||
const profilePath = path.join(os.homedir(), ".profile");
|
||||
if (!fs.existsSync(profilePath)) return;
|
||||
try {
|
||||
const output = execFileSync(
|
||||
"/bin/bash",
|
||||
["-lc", `set -a; source "${profilePath}" >/dev/null 2>&1; env -0`],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
const entries = output.split("\0");
|
||||
let applied = 0;
|
||||
for (const entry of entries) {
|
||||
if (!entry) continue;
|
||||
const idx = entry.indexOf("=");
|
||||
if (idx <= 0) continue;
|
||||
const key = entry.slice(0, idx);
|
||||
if (!key || (process.env[key] ?? "") !== "") continue;
|
||||
process.env[key] = entry.slice(idx + 1);
|
||||
applied += 1;
|
||||
}
|
||||
if (applied > 0) {
|
||||
console.log(`[live] loaded ${applied} env vars from ~/.profile`);
|
||||
}
|
||||
} catch {
|
||||
// ignore profile load failures
|
||||
}
|
||||
}
|
||||
|
||||
export function installTestEnv(): { cleanup: () => void; tempHome: string } {
|
||||
const live =
|
||||
process.env.LIVE === "1" ||
|
||||
process.env.CLAWDBOT_LIVE_TEST === "1" ||
|
||||
process.env.CLAWDBOT_LIVE_GATEWAY === "1";
|
||||
|
||||
// Live tests must use the real user environment (keys, profiles, config).
|
||||
// The default test env isolates HOME to avoid touching real state.
|
||||
if (live) {
|
||||
loadProfileEnv();
|
||||
return { cleanup: () => {}, tempHome: process.env.HOME ?? "" };
|
||||
}
|
||||
|
||||
const restore: RestoreEntry[] = [
|
||||
{ key: "CLAWDBOT_TEST_FAST", value: process.env.CLAWDBOT_TEST_FAST },
|
||||
{ key: "HOME", value: process.env.HOME },
|
||||
{ key: "USERPROFILE", value: process.env.USERPROFILE },
|
||||
{ key: "XDG_CONFIG_HOME", value: process.env.XDG_CONFIG_HOME },
|
||||
{ key: "XDG_DATA_HOME", value: process.env.XDG_DATA_HOME },
|
||||
{ key: "XDG_STATE_HOME", value: process.env.XDG_STATE_HOME },
|
||||
{ key: "XDG_CACHE_HOME", value: process.env.XDG_CACHE_HOME },
|
||||
{ key: "CLAWDBOT_STATE_DIR", value: process.env.CLAWDBOT_STATE_DIR },
|
||||
{ key: "CLAWDBOT_CONFIG_PATH", value: process.env.CLAWDBOT_CONFIG_PATH },
|
||||
{ key: "CLAWDBOT_GATEWAY_PORT", value: process.env.CLAWDBOT_GATEWAY_PORT },
|
||||
{ key: "CLAWDBOT_BRIDGE_ENABLED", value: process.env.CLAWDBOT_BRIDGE_ENABLED },
|
||||
{ key: "CLAWDBOT_BRIDGE_HOST", value: process.env.CLAWDBOT_BRIDGE_HOST },
|
||||
{ key: "CLAWDBOT_BRIDGE_PORT", value: process.env.CLAWDBOT_BRIDGE_PORT },
|
||||
{ key: "CLAWDBOT_CANVAS_HOST_PORT", value: process.env.CLAWDBOT_CANVAS_HOST_PORT },
|
||||
{ key: "CLAWDBOT_TEST_HOME", value: process.env.CLAWDBOT_TEST_HOME },
|
||||
{ key: "TELEGRAM_BOT_TOKEN", value: process.env.TELEGRAM_BOT_TOKEN },
|
||||
{ key: "DISCORD_BOT_TOKEN", value: process.env.DISCORD_BOT_TOKEN },
|
||||
{ key: "SLACK_BOT_TOKEN", value: process.env.SLACK_BOT_TOKEN },
|
||||
{ key: "SLACK_APP_TOKEN", value: process.env.SLACK_APP_TOKEN },
|
||||
{ key: "SLACK_USER_TOKEN", value: process.env.SLACK_USER_TOKEN },
|
||||
{ key: "COPILOT_GITHUB_TOKEN", value: process.env.COPILOT_GITHUB_TOKEN },
|
||||
{ key: "GH_TOKEN", value: process.env.GH_TOKEN },
|
||||
{ key: "GITHUB_TOKEN", value: process.env.GITHUB_TOKEN },
|
||||
{ key: "NODE_OPTIONS", value: process.env.NODE_OPTIONS },
|
||||
];
|
||||
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "moltbot-test-home-"));
|
||||
|
||||
process.env.HOME = tempHome;
|
||||
process.env.USERPROFILE = tempHome;
|
||||
process.env.CLAWDBOT_TEST_HOME = tempHome;
|
||||
process.env.CLAWDBOT_TEST_FAST = "1";
|
||||
|
||||
// Ensure test runs never touch the developer's real config/state, even if they have overrides set.
|
||||
delete process.env.CLAWDBOT_CONFIG_PATH;
|
||||
// Prefer deriving state dir from HOME so nested tests that change HOME also isolate correctly.
|
||||
delete process.env.CLAWDBOT_STATE_DIR;
|
||||
// Prefer test-controlled ports over developer overrides (avoid port collisions across tests/workers).
|
||||
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
delete process.env.CLAWDBOT_BRIDGE_ENABLED;
|
||||
delete process.env.CLAWDBOT_BRIDGE_HOST;
|
||||
delete process.env.CLAWDBOT_BRIDGE_PORT;
|
||||
delete process.env.CLAWDBOT_CANVAS_HOST_PORT;
|
||||
// Avoid leaking real GitHub/Copilot tokens into non-live test runs.
|
||||
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||
delete process.env.DISCORD_BOT_TOKEN;
|
||||
delete process.env.SLACK_BOT_TOKEN;
|
||||
delete process.env.SLACK_APP_TOKEN;
|
||||
delete process.env.SLACK_USER_TOKEN;
|
||||
delete process.env.COPILOT_GITHUB_TOKEN;
|
||||
delete process.env.GH_TOKEN;
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
// Avoid leaking local dev tooling flags into tests (e.g. --inspect).
|
||||
delete process.env.NODE_OPTIONS;
|
||||
|
||||
// Windows: prefer the legacy default state dir so auth/profile tests match real paths.
|
||||
if (process.platform === "win32") {
|
||||
process.env.CLAWDBOT_STATE_DIR = path.join(tempHome, ".clawdbot");
|
||||
}
|
||||
|
||||
process.env.XDG_CONFIG_HOME = path.join(tempHome, ".config");
|
||||
process.env.XDG_DATA_HOME = path.join(tempHome, ".local", "share");
|
||||
process.env.XDG_STATE_HOME = path.join(tempHome, ".local", "state");
|
||||
process.env.XDG_CACHE_HOME = path.join(tempHome, ".cache");
|
||||
|
||||
const cleanup = () => {
|
||||
restoreEnv(restore);
|
||||
try {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
};
|
||||
|
||||
return { cleanup, tempHome };
|
||||
}
|
||||
|
||||
export function withIsolatedTestHome(): { cleanup: () => void; tempHome: string } {
|
||||
return installTestEnv();
|
||||
}
|
||||
Reference in New Issue
Block a user