388 lines
14 KiB
TypeScript
388 lines
14 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import {
|
|
buildCIFailureNotificationData,
|
|
buildSessionTransitionNotificationData,
|
|
type NotificationEventContext,
|
|
type NotifyAction,
|
|
type OrchestratorEvent,
|
|
} from "@aoagents/ao-core";
|
|
import { create, manifest } from "./index.js";
|
|
|
|
function makeEvent(overrides: Partial<OrchestratorEvent> = {}): OrchestratorEvent {
|
|
return {
|
|
id: "evt-1",
|
|
type: "reaction.escalated",
|
|
priority: "urgent",
|
|
sessionId: "ao-5",
|
|
projectId: "ao",
|
|
timestamp: new Date("2026-03-08T12:00:00Z"),
|
|
message: "Reaction escalated after retries",
|
|
data: { attempts: 5, reason: "ci_failed" },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const prContext: NotificationEventContext = {
|
|
pr: {
|
|
number: 1579,
|
|
url: "https://github.com/ComposioHQ/agent-orchestrator/pull/1579",
|
|
title: "Normalize AO notifier payloads",
|
|
branch: "ao/demo-notifier-harness",
|
|
baseBranch: "main",
|
|
owner: "ComposioHQ",
|
|
repo: "agent-orchestrator",
|
|
isDraft: false,
|
|
},
|
|
issueId: "AO-1579",
|
|
issueTitle: "Make AO notification payloads API-grade",
|
|
summary: "Normalize AO notifier payloads",
|
|
branch: "ao/demo-notifier-harness",
|
|
};
|
|
|
|
describe("notifier-openclaw", () => {
|
|
let tempConfigDir: string;
|
|
let tempConfigPath: string;
|
|
let tempHealthPath: string;
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
delete process.env.OPENCLAW_HOOKS_TOKEN;
|
|
tempConfigDir = mkdtempSync(join(tmpdir(), "openclaw-notifier-test-"));
|
|
tempConfigPath = join(tempConfigDir, "agent-orchestrator.yaml");
|
|
tempHealthPath = join(tempConfigDir, "openclaw-health.json");
|
|
writeFileSync(tempConfigPath, "projects: {}\n");
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.useRealTimers();
|
|
rmSync(tempConfigDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("has correct manifest", () => {
|
|
expect(manifest.name).toBe("openclaw");
|
|
expect(manifest.slot).toBe("notifier");
|
|
});
|
|
|
|
it("uses default OpenClaw hooks endpoint", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const notifier = create({ token: "tok" });
|
|
await notifier.notify(makeEvent());
|
|
|
|
expect(fetchMock).toHaveBeenCalledOnce();
|
|
expect(fetchMock.mock.calls[0][0]).toBe("http://127.0.0.1:18789/hooks/agent");
|
|
});
|
|
|
|
it("uses token from OPENCLAW_HOOKS_TOKEN env", async () => {
|
|
process.env.OPENCLAW_HOOKS_TOKEN = "env-token";
|
|
const missingOpenClawConfigPath = join(tempConfigDir, "missing-openclaw.json");
|
|
|
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const notifier = create({ openclawConfigPath: missingOpenClawConfigPath });
|
|
await notifier.notify(makeEvent());
|
|
|
|
const headers = fetchMock.mock.calls[0][1].headers;
|
|
expect(headers["Authorization"]).toBe("Bearer env-token");
|
|
});
|
|
|
|
it("uses hooks token from configured OpenClaw config path", async () => {
|
|
const openclawConfigPath = join(tempConfigDir, "openclaw.json");
|
|
writeFileSync(openclawConfigPath, JSON.stringify({ hooks: { token: "config-token" } }));
|
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const notifier = create({ openclawConfigPath });
|
|
await notifier.notify(makeEvent());
|
|
|
|
const headers = fetchMock.mock.calls[0][1].headers;
|
|
expect(headers["Authorization"]).toBe("Bearer config-token");
|
|
});
|
|
|
|
it("warns and sends without Authorization when token missing", async () => {
|
|
const missingOpenClawConfigPath = join(tempConfigDir, "missing-openclaw.json");
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const notifier = create({ openclawConfigPath: missingOpenClawConfigPath });
|
|
await notifier.notify(makeEvent());
|
|
|
|
const headers = fetchMock.mock.calls[0][1].headers as Record<string, string>;
|
|
expect(headers["Authorization"]).toBeUndefined();
|
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("No token configured"));
|
|
});
|
|
|
|
it("builds per-session OpenClaw session key", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const notifier = create({ token: "tok", sessionKeyPrefix: "hook:ao:" });
|
|
await notifier.notify(makeEvent({ sessionId: "ao-12" }));
|
|
|
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
expect(body.sessionKey).toBe("hook:ao:ao-12");
|
|
});
|
|
|
|
it("sanitizes invalid characters in session id", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const notifier = create({ token: "tok" });
|
|
await notifier.notify(makeEvent({ sessionId: "ao/12?x" }));
|
|
|
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
expect(body.sessionKey).toBe("hook:ao:ao-12-x");
|
|
});
|
|
|
|
it("notifyWithActions appends action links", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const notifier = create({ token: "tok" });
|
|
const actions: NotifyAction[] = [
|
|
{ label: "Open dashboard", url: "http://localhost:3000" },
|
|
{ label: "Acknowledge", callbackEndpoint: "http://localhost:3000/api/ack" },
|
|
];
|
|
await notifier.notifyWithActions!(makeEvent(), actions);
|
|
|
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
expect(body.message).toContain("**Actions**");
|
|
expect(body.message).toContain("- [Open dashboard](http://localhost:3000)");
|
|
expect(body.message).toContain("- [Acknowledge](http://localhost:3000/api/ack)");
|
|
});
|
|
|
|
it("escapes markdown-sensitive labels and URLs in links", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const notifier = create({ token: "tok" });
|
|
const actions: NotifyAction[] = [
|
|
{
|
|
label: "Open [prod] (now) *please*",
|
|
url: "https://github.com/org/repo/pull/1?a=(test)",
|
|
},
|
|
];
|
|
await notifier.notifyWithActions!(makeEvent(), actions);
|
|
|
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
expect(body.message).toContain(
|
|
"- [Open \\[prod\\] \\(now\\) \\*please\\*](https://github.com/org/repo/pull/1?a=%28test%29)",
|
|
);
|
|
});
|
|
|
|
it("formats v3 CI notifications as a compact OpenClaw brief", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const notifier = create({ token: "tok" });
|
|
await notifier.notify(
|
|
makeEvent({
|
|
type: "ci.failing",
|
|
priority: "action",
|
|
sessionId: "demo-agent-19",
|
|
projectId: "demo",
|
|
message: "CI is failing on PR #1579",
|
|
data: buildCIFailureNotificationData({
|
|
sessionId: "demo-agent-19",
|
|
projectId: "demo",
|
|
context: prContext,
|
|
failedChecks: [
|
|
{
|
|
name: "typecheck",
|
|
status: "failed",
|
|
conclusion: "FAILURE",
|
|
url: "https://github.com/ComposioHQ/agent-orchestrator/pull/1579/checks",
|
|
},
|
|
],
|
|
}),
|
|
}),
|
|
);
|
|
|
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
expect(body.message).toContain("**AO ACTION** `ci.failing`");
|
|
expect(body.message).toContain("**Pull Request**");
|
|
expect(body.message).toContain(
|
|
"[#1579 - Normalize AO notifier payloads](https://github.com/ComposioHQ/agent-orchestrator/pull/1579)",
|
|
);
|
|
expect(body.message).toContain("**Checks**");
|
|
expect(body.message).toContain(
|
|
"- [typecheck](https://github.com/ComposioHQ/agent-orchestrator/pull/1579/checks): `failed/FAILURE`",
|
|
);
|
|
expect(body.message).not.toContain("Context: {");
|
|
});
|
|
|
|
it("formats v3 merge notifications with status and links", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const notifier = create({ token: "tok" });
|
|
await notifier.notify(
|
|
makeEvent({
|
|
type: "merge.ready",
|
|
priority: "action",
|
|
sessionId: "demo-agent-29",
|
|
projectId: "demo",
|
|
message: "PR #1579 is ready to merge",
|
|
data: buildSessionTransitionNotificationData({
|
|
eventType: "merge.ready",
|
|
sessionId: "demo-agent-29",
|
|
projectId: "demo",
|
|
context: prContext,
|
|
oldStatus: "approved",
|
|
newStatus: "mergeable",
|
|
enrichment: {
|
|
state: "open",
|
|
ciStatus: "passing",
|
|
reviewDecision: "approved",
|
|
mergeable: true,
|
|
title: "Normalize AO notifier payloads",
|
|
hasConflicts: false,
|
|
isBehind: false,
|
|
},
|
|
}),
|
|
}),
|
|
);
|
|
|
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
expect(body.message).toContain("- Transition: `approved` -> `mergeable`");
|
|
expect(body.message).toContain("- CI: `passing`");
|
|
expect(body.message).toContain("- Review: `approved`");
|
|
expect(body.message).toContain("- Merge ready: yes");
|
|
expect(body.message).toContain(
|
|
"- [Pull request](https://github.com/ComposioHQ/agent-orchestrator/pull/1579)",
|
|
);
|
|
});
|
|
|
|
it("post uses context sessionId when provided", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const notifier = create({ token: "tok" });
|
|
await notifier.post!("ready", { sessionId: "ao-77" });
|
|
|
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
expect(body.sessionKey).toBe("hook:ao:ao-77");
|
|
expect(body.message).toBe("ready");
|
|
});
|
|
|
|
it("defaults wakeMode=now and deliver=true", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const notifier = create({ token: "tok" });
|
|
await notifier.notify(makeEvent());
|
|
|
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
expect(body.wakeMode).toBe("now");
|
|
expect(body.deliver).toBe(true);
|
|
});
|
|
|
|
it("supports wakeMode=next-heartbeat when configured", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const notifier = create({ token: "tok", wakeMode: "next-heartbeat" });
|
|
await notifier.notify(makeEvent());
|
|
|
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
expect(body.wakeMode).toBe("next-heartbeat");
|
|
});
|
|
|
|
it("retries on 5xx response", async () => {
|
|
vi.useFakeTimers();
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ ok: false, status: 503, text: () => Promise.resolve("down") })
|
|
.mockResolvedValueOnce({ ok: true });
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const notifier = create({ token: "tok", retries: 1, retryDelayMs: 50 });
|
|
const promise = notifier.notify(makeEvent());
|
|
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
|
|
await promise;
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("Retry 1/1 for session=ao-5 after HTTP 503"),
|
|
);
|
|
});
|
|
|
|
it("does not retry on 4xx response", async () => {
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValue({ ok: false, status: 401, text: () => Promise.resolve("unauthorized") });
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const notifier = create({ token: "tok", retries: 2, retryDelayMs: 1 });
|
|
await expect(notifier.notify(makeEvent())).rejects.toThrow("OpenClaw rejected the auth token");
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("throws actionable error on ECONNREFUSED", async () => {
|
|
const fetchMock = vi.fn().mockRejectedValue(new Error("fetch failed: ECONNREFUSED"));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const notifier = create({ token: "tok", retries: 0 });
|
|
await expect(notifier.notify(makeEvent())).rejects.toThrow("Can't reach OpenClaw gateway");
|
|
});
|
|
|
|
it("records success telemetry when a notification is sent", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const notifier = create({
|
|
token: "tok",
|
|
configPath: tempConfigPath,
|
|
healthSummaryPath: tempHealthPath,
|
|
});
|
|
await notifier.notify(makeEvent());
|
|
|
|
expect(existsSync(tempHealthPath)).toBe(true);
|
|
|
|
const summary = JSON.parse(readFileSync(tempHealthPath, "utf-8")) as {
|
|
lastSuccessAt: string | null;
|
|
totalSent: number;
|
|
totalFailed: number;
|
|
};
|
|
expect(summary.lastSuccessAt).toBeTruthy();
|
|
expect(summary.totalSent).toBe(1);
|
|
expect(summary.totalFailed).toBe(0);
|
|
});
|
|
|
|
it("records failure telemetry when notification delivery fails", async () => {
|
|
const fetchMock = vi.fn().mockRejectedValue(new Error("fetch failed: ECONNREFUSED"));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const notifier = create({
|
|
token: "tok",
|
|
retries: 0,
|
|
configPath: tempConfigPath,
|
|
healthSummaryPath: tempHealthPath,
|
|
});
|
|
await expect(notifier.notify(makeEvent())).rejects.toThrow();
|
|
|
|
const summary = JSON.parse(readFileSync(tempHealthPath, "utf-8")) as {
|
|
lastFailureAt: string | null;
|
|
lastFailureError: string | null;
|
|
totalSent: number;
|
|
totalFailed: number;
|
|
};
|
|
expect(summary.lastFailureAt).toBeTruthy();
|
|
expect(summary.lastFailureError).toContain("Can't reach OpenClaw gateway");
|
|
expect(summary.totalSent).toBe(0);
|
|
expect(summary.totalFailed).toBe(1);
|
|
});
|
|
});
|