test: update health/status and legacy migration coverage

This commit is contained in:
Peter Steinberger
2026-01-17 01:14:06 +00:00
parent f14d622c0f
commit c592f395df
7 changed files with 305 additions and 25 deletions

View File

@@ -30,7 +30,7 @@ const probeGateway = vi.fn(async ({ url }: { url: string }) => {
close: null,
health: { ok: true },
status: {
linkProvider: {
linkChannel: {
id: "whatsapp",
label: "WhatsApp",
linked: false,
@@ -60,7 +60,7 @@ const probeGateway = vi.fn(async ({ url }: { url: string }) => {
close: null,
health: { ok: true },
status: {
linkProvider: {
linkChannel: {
id: "whatsapp",
label: "WhatsApp",
linked: true,

View File

@@ -35,10 +35,12 @@ describe("healthCommand (coverage)", () => {
durationMs: 5,
channels: {
whatsapp: {
accountId: "default",
linked: true,
authAgeMs: 5 * 60_000,
},
telegram: {
accountId: "default",
configured: true,
probe: {
ok: true,
@@ -48,6 +50,7 @@ describe("healthCommand (coverage)", () => {
},
},
discord: {
accountId: "default",
configured: false,
},
},
@@ -58,6 +61,29 @@ describe("healthCommand (coverage)", () => {
discord: "Discord",
},
heartbeatSeconds: 60,
defaultAgentId: "main",
agents: [
{
agentId: "main",
isDefault: true,
heartbeat: {
enabled: true,
every: "1m",
everyMs: 60_000,
prompt: "hi",
target: "last",
ackMaxChars: 160,
},
sessions: {
path: "/tmp/sessions.json",
count: 2,
recent: [
{ key: "main", updatedAt: Date.now() - 60_000, age: 60_000 },
{ key: "foo", updatedAt: null, age: null },
],
},
},
],
sessions: {
path: "/tmp/sessions.json",
count: 2,

View File

@@ -30,10 +30,6 @@ vi.mock("../web/auth-store.js", () => ({
logWebSelfId: vi.fn(),
}));
vi.mock("../web/reconnect.js", () => ({
resolveHeartbeatSeconds: vi.fn(() => 60),
}));
describe("getHealthSnapshot", () => {
afterEach(() => {
vi.unstubAllGlobals();
@@ -229,4 +225,32 @@ describe("getHealthSnapshot", () => {
expect(telegram.probe?.ok).toBe(false);
expect(telegram.probe?.error).toMatch(/network down/i);
});
it("disables heartbeat for agents without heartbeat blocks", async () => {
testConfig = {
agents: {
defaults: {
heartbeat: {
every: "30m",
target: "last",
},
},
list: [
{ id: "main", default: true },
{ id: "ops", heartbeat: { every: "1h", target: "whatsapp" } },
],
},
};
testStore = {};
const snap = await getHealthSnapshot({ timeoutMs: 10, probe: false });
const byAgent = new Map(snap.agents.map((agent) => [agent.agentId, agent] as const));
const main = byAgent.get("main");
const ops = byAgent.get("ops");
expect(main?.heartbeat.everyMs).toBeNull();
expect(main?.heartbeat.every).toBe("disabled");
expect(ops?.heartbeat.everyMs).toBeTruthy();
expect(ops?.heartbeat.every).toBe("1h");
});
});

View File

@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { HealthSummary } from "./health.js";
import { healthCommand } from "./health.js";
import { formatHealthChannelLines, healthCommand } from "./health.js";
const runtime = {
log: vi.fn(),
@@ -20,14 +20,23 @@ describe("healthCommand", () => {
});
it("outputs JSON from gateway", async () => {
const agentSessions = {
path: "/tmp/sessions.json",
count: 1,
recent: [{ key: "+1555", updatedAt: Date.now(), age: 0 }],
};
const snapshot: HealthSummary = {
ok: true,
ts: Date.now(),
durationMs: 5,
channels: {
whatsapp: { linked: true, authAgeMs: 5000 },
telegram: { configured: true, probe: { ok: true, elapsedMs: 1 } },
discord: { configured: false },
whatsapp: { accountId: "default", linked: true, authAgeMs: 5000 },
telegram: {
accountId: "default",
configured: true,
probe: { ok: true, elapsedMs: 1 },
},
discord: { accountId: "default", configured: false },
},
channelOrder: ["whatsapp", "telegram", "discord"],
channelLabels: {
@@ -36,11 +45,23 @@ describe("healthCommand", () => {
discord: "Discord",
},
heartbeatSeconds: 60,
sessions: {
path: "/tmp/sessions.json",
count: 1,
recent: [{ key: "+1555", updatedAt: Date.now(), age: 0 }],
},
defaultAgentId: "main",
agents: [
{
agentId: "main",
isDefault: true,
heartbeat: {
enabled: true,
every: "1m",
everyMs: 60_000,
prompt: "hi",
target: "last",
ackMaxChars: 160,
},
sessions: agentSessions,
},
],
sessions: agentSessions,
};
callGatewayMock.mockResolvedValueOnce(snapshot);
@@ -60,9 +81,9 @@ describe("healthCommand", () => {
ts: Date.now(),
durationMs: 5,
channels: {
whatsapp: { linked: false, authAgeMs: null },
telegram: { configured: false },
discord: { configured: false },
whatsapp: { accountId: "default", linked: false, authAgeMs: null },
telegram: { accountId: "default", configured: false },
discord: { accountId: "default", configured: false },
},
channelOrder: ["whatsapp", "telegram", "discord"],
channelLabels: {
@@ -71,6 +92,22 @@ describe("healthCommand", () => {
discord: "Discord",
},
heartbeatSeconds: 60,
defaultAgentId: "main",
agents: [
{
agentId: "main",
isDefault: true,
heartbeat: {
enabled: true,
every: "1m",
everyMs: 60_000,
prompt: "hi",
target: "last",
ackMaxChars: 160,
},
sessions: { path: "/tmp/sessions.json", count: 0, recent: [] },
},
],
sessions: { path: "/tmp/sessions.json", count: 0, recent: [] },
} satisfies HealthSummary);
@@ -79,4 +116,61 @@ describe("healthCommand", () => {
expect(runtime.exit).not.toHaveBeenCalled();
expect(runtime.log).toHaveBeenCalled();
});
it("formats per-account probe timings", () => {
const summary: HealthSummary = {
ok: true,
ts: Date.now(),
durationMs: 5,
channels: {
telegram: {
accountId: "main",
configured: true,
probe: { ok: true, elapsedMs: 196, bot: { username: "pinguini_ugi_bot" } },
accounts: {
main: {
accountId: "main",
configured: true,
probe: { ok: true, elapsedMs: 196, bot: { username: "pinguini_ugi_bot" } },
},
flurry: {
accountId: "flurry",
configured: true,
probe: { ok: true, elapsedMs: 190, bot: { username: "flurry_ugi_bot" } },
},
poe: {
accountId: "poe",
configured: true,
probe: { ok: true, elapsedMs: 188, bot: { username: "poe_ugi_bot" } },
},
},
},
},
channelOrder: ["telegram"],
channelLabels: { telegram: "Telegram" },
heartbeatSeconds: 60,
defaultAgentId: "main",
agents: [
{
agentId: "main",
isDefault: true,
heartbeat: {
enabled: true,
every: "1m",
everyMs: 60_000,
prompt: "hi",
target: "last",
ackMaxChars: 160,
},
sessions: { path: "/tmp/sessions.json", count: 0, recent: [] },
},
],
sessions: { path: "/tmp/sessions.json", count: 0, recent: [] },
};
const lines = formatHealthChannelLines(summary, { accountMode: "all" });
expect(lines).toContain(
"Telegram: ok (@pinguini_ugi_bot:main:196ms, @flurry_ugi_bot:flurry:190ms, @poe_ugi_bot:poe:188ms)",
);
});
});

View File

@@ -32,6 +32,12 @@ const mocks = vi.hoisted(() => ({
configSnapshot: null,
}),
callGateway: vi.fn().mockResolvedValue({}),
listAgentsForGateway: vi.fn().mockReturnValue({
defaultId: "main",
mainKey: "agent:main:main",
scope: "per-sender",
agents: [{ id: "main", name: "Main" }],
}),
runSecurityAudit: vi.fn().mockResolvedValue({
ts: 0,
summary: { critical: 1, warn: 1, info: 2 },
@@ -154,12 +160,7 @@ vi.mock("../gateway/call.js", async (importOriginal) => {
return { ...actual, callGateway: mocks.callGateway };
});
vi.mock("../gateway/session-utils.js", () => ({
listAgentsForGateway: () => ({
defaultId: "main",
mainKey: "agent:main:main",
scope: "per-sender",
agents: [{ id: "main", name: "Main" }],
}),
listAgentsForGateway: mocks.listAgentsForGateway,
}));
vi.mock("../infra/clawdbot-root.js", () => ({
resolveClawdbotPackageRoot: vi.fn().mockResolvedValue("/tmp/clawdbot"),
@@ -234,7 +235,7 @@ describe("statusCommand", () => {
const payload = JSON.parse((runtime.log as vi.Mock).mock.calls[0][0]);
expect(payload.linkChannel.linked).toBe(true);
expect(payload.sessions.count).toBe(1);
expect(payload.sessions.path).toBe("/tmp/sessions.json");
expect(payload.sessions.paths).toContain("/tmp/sessions.json");
expect(payload.sessions.defaults.model).toBeTruthy();
expect(payload.sessions.defaults.contextTokens).toBeGreaterThan(0);
expect(payload.sessions.recent[0].percentUsed).toBe(50);
@@ -335,4 +336,62 @@ describe("statusCommand", () => {
expect(logs.join("\n")).toMatch(/gateway:/i);
expect(logs.join("\n")).toMatch(/WARN/);
});
it("includes sessions across agents in JSON output", async () => {
const originalAgents = mocks.listAgentsForGateway.getMockImplementation();
const originalResolveStorePath = mocks.resolveStorePath.getMockImplementation();
const originalLoadSessionStore = mocks.loadSessionStore.getMockImplementation();
mocks.listAgentsForGateway.mockReturnValue({
defaultId: "main",
mainKey: "agent:main:main",
scope: "per-sender",
agents: [
{ id: "main", name: "Main" },
{ id: "ops", name: "Ops" },
],
});
mocks.resolveStorePath.mockImplementation((_store, opts) =>
opts?.agentId === "ops" ? "/tmp/ops.json" : "/tmp/main.json",
);
mocks.loadSessionStore.mockImplementation((storePath) => {
if (storePath === "/tmp/ops.json") {
return {
"agent:ops:main": {
updatedAt: Date.now() - 120_000,
inputTokens: 1_000,
outputTokens: 1_000,
contextTokens: 10_000,
model: "pi:opus",
},
};
}
return {
"+1000": {
updatedAt: Date.now() - 60_000,
verboseLevel: "on",
thinkingLevel: "low",
inputTokens: 2_000,
outputTokens: 3_000,
contextTokens: 10_000,
model: "pi:opus",
sessionId: "abc123",
systemSent: true,
},
};
});
await statusCommand({ json: true }, runtime as never);
const payload = JSON.parse((runtime.log as vi.Mock).mock.calls.at(-1)?.[0]);
expect(payload.sessions.count).toBe(2);
expect(payload.sessions.paths.length).toBe(2);
expect(payload.sessions.recent.some((sess: { key?: string }) => sess.key === "agent:ops:main"))
.toBe(true);
if (originalAgents) mocks.listAgentsForGateway.mockImplementation(originalAgents);
if (originalResolveStorePath)
mocks.resolveStorePath.mockImplementation(originalResolveStorePath);
if (originalLoadSessionStore)
mocks.loadSessionStore.mockImplementation(originalLoadSessionStore);
});
});

View File

@@ -338,6 +338,43 @@ describe("legacy config detection", () => {
}
});
});
it("auto-migrates bindings[].match.accountID on load and writes back", async () => {
await withTempHome(async (home) => {
const configPath = path.join(home, ".clawdbot", "clawdbot.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify(
{
bindings: [{ agentId: "main", match: { channel: "telegram", accountID: "work" } }],
},
null,
2,
),
"utf-8",
);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.resetModules();
try {
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.bindings?.[0]?.match?.accountId).toBe("work");
const raw = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(raw) as {
bindings?: Array<{ match?: { accountId?: string; accountID?: string } }>;
};
expect(parsed.bindings?.[0]?.match?.accountId).toBe("work");
expect(parsed.bindings?.[0]?.match?.accountID).toBeUndefined();
expect(
warnSpy.mock.calls.some(([msg]) => String(msg).includes("Auto-migrated config")),
).toBe(true);
} finally {
warnSpy.mockRestore();
}
});
});
it("auto-migrates session.sendPolicy.rules[].match.provider on load and writes back", async () => {
await withTempHome(async (home) => {
const configPath = path.join(home, ".clawdbot", "clawdbot.json");

View File

@@ -12,6 +12,7 @@ import {
resolveStorePath,
} from "../config/sessions.js";
import {
isHeartbeatEnabledForAgent,
resolveHeartbeatIntervalMs,
resolveHeartbeatPrompt,
runHeartbeatOnce,
@@ -81,6 +82,30 @@ describe("resolveHeartbeatPrompt", () => {
});
});
describe("isHeartbeatEnabledForAgent", () => {
it("enables only explicit heartbeat agents when configured", () => {
const cfg: ClawdbotConfig = {
agents: {
defaults: { heartbeat: { every: "30m" } },
list: [{ id: "main" }, { id: "ops", heartbeat: { every: "1h" } }],
},
};
expect(isHeartbeatEnabledForAgent(cfg, "main")).toBe(false);
expect(isHeartbeatEnabledForAgent(cfg, "ops")).toBe(true);
});
it("falls back to default agent when no explicit heartbeat entries", () => {
const cfg: ClawdbotConfig = {
agents: {
defaults: { heartbeat: { every: "30m" } },
list: [{ id: "main" }, { id: "ops" }],
},
};
expect(isHeartbeatEnabledForAgent(cfg, "main")).toBe(true);
expect(isHeartbeatEnabledForAgent(cfg, "ops")).toBe(false);
});
});
describe("resolveHeartbeatDeliveryTarget", () => {
const baseEntry = {
sessionId: "sid",
@@ -214,6 +239,21 @@ describe("resolveHeartbeatDeliveryTarget", () => {
});
describe("runHeartbeatOnce", () => {
it("skips when agent heartbeat is not enabled", async () => {
const cfg: ClawdbotConfig = {
agents: {
defaults: { heartbeat: { every: "30m" } },
list: [{ id: "main" }, { id: "ops", heartbeat: { every: "1h" } }],
},
};
const res = await runHeartbeatOnce({ cfg, agentId: "main" });
expect(res.status).toBe("skipped");
if (res.status === "skipped") {
expect(res.reason).toBe("disabled");
}
});
it("uses the last non-empty payload for delivery", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
const storePath = path.join(tmpDir, "sessions.json");