Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/cli/__tests__/commands/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ vi.mock("../../src/lib/script-runner.js", () => ({

vi.mock("@aoagents/ao-core", () => ({
buildCIFailureNotificationData: () => ({ schemaVersion: 3 }),
buildNotificationPresentation: (event: { priority: string; message: string }) => ({
version: 1,
category: "generic",
priority: event.priority,
title: "Test notification",
body: event.message,
}),
buildPRStateNotificationData: () => ({ schemaVersion: 3 }),
buildReactionNotificationData: () => ({ schemaVersion: 3 }),
buildSessionTransitionNotificationData: () => ({ schemaVersion: 3 }),
Expand All @@ -50,6 +57,10 @@ vi.mock("@aoagents/ao-core", () => ({
getObservabilityBaseDir: () => "/tmp/.agent-orchestrator/observability",
loadConfig: (...args: unknown[]) => mockLoadConfig(...args),
recordNotificationDelivery: (...args: unknown[]) => mockRecordNotificationDelivery(...args),
resolveNotificationRoute: (
config: { defaults?: { notifiers?: string[] }; notificationRouting?: Record<string, string[]> },
priority: string,
) => config.notificationRouting?.[priority] ?? config.defaults?.notifiers ?? [],
resolveNotifierTarget: (
config: { notifiers?: Record<string, { plugin?: string }> },
reference: string,
Expand Down
24 changes: 24 additions & 0 deletions packages/cli/__tests__/commands/notify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,30 @@ vi.mock("@aoagents/ao-core", () => {
reference,
pluginName: config.notifiers?.[reference]?.plugin ?? reference,
}),
resolveNotificationRoute: (
config: {
defaults?: { notifiers?: string[] };
notificationRouting?: Record<string, string[]>;
},
priority: string,
) => {
if (Object.prototype.hasOwnProperty.call(config.notificationRouting ?? {}, priority)) {
return config.notificationRouting?.[priority] ?? [];
}
const defaults = config.defaults?.notifiers ?? [];
if (priority === "urgent" || priority === "warning") {
const withoutDesktop = defaults.filter((notifier) => notifier !== "desktop");
return defaults.includes("desktop") ? ["desktop", ...withoutDesktop] : withoutDesktop;
}
return defaults.filter((notifier) => notifier !== "desktop");
Comment thread
whoisasx marked this conversation as resolved.
},
buildNotificationPresentation: (event: { priority: string; message: string }) => ({
version: 1,
category: "generic",
priority: event.priority,
title: "Test notification",
body: event.message,
}),
buildCIFailureNotificationData: (input: {
sessionId: string;
projectId: string;
Expand Down
56 changes: 37 additions & 19 deletions packages/cli/__tests__/commands/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@
vi.unstubAllGlobals();
});

it("writes dashboard notifier config with the urgent-action routing default", async () => {
it("adds dashboard to defaults and writes limit overrides without default routing", async () => {
const program = createProgram();

await program.parseAsync([
Expand All @@ -283,15 +283,31 @@

const written = String(mockWriteFileSync.mock.calls[0][1]);
const parsed = parseYaml(written) as {
defaults?: { notifiers?: string[] };
notifiers?: Record<string, { plugin?: string; limit?: number }>;
notificationRouting?: Record<string, string[]>;
};

expect(parsed.defaults?.notifiers).toEqual(["dashboard"]);
expect(parsed.notifiers?.["dashboard"]).toEqual({ plugin: "dashboard", limit: 75 });
expect(parsed.notificationRouting?.urgent).toContain("dashboard");
expect(parsed.notificationRouting?.action).toContain("dashboard");
expect(parsed.notificationRouting?.warning ?? []).not.toContain("dashboard");
expect(parsed.notificationRouting?.info ?? []).not.toContain("dashboard");
expect(parsed.notificationRouting).toBeUndefined();
});

it("adds dashboard to defaults without writing a config block for default options", async () => {
const program = createProgram();

await program.parseAsync(["node", "test", "setup", "dashboard", "--non-interactive"]);

const written = String(mockWriteFileSync.mock.calls[0][1]);
const parsed = parseYaml(written) as {
defaults?: { notifiers?: string[] };
notifiers?: Record<string, unknown>;
notificationRouting?: Record<string, string[]>;
};

expect(parsed.defaults?.notifiers).toEqual(["dashboard"]);
expect(parsed.notifiers?.["dashboard"]).toBeUndefined();
expect(parsed.notificationRouting).toBeUndefined();
});

it("prints status without mutating config", async () => {
Expand Down Expand Up @@ -2100,7 +2116,7 @@
expect(mockValidateToken).not.toHaveBeenCalled();
expect(mockWriteFileSync).toHaveBeenCalled();
const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string;
expect(writtenYaml).toContain("${OPENCLAW_HOOKS_TOKEN}");

Check warning on line 2119 in packages/cli/__tests__/commands/setup.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected template string expression
});

it("reads token from OpenClaw config without copying it into AO config", async () => {
Expand Down Expand Up @@ -2717,27 +2733,22 @@
expect(setup?.commands.some((command) => command.name() === "desktop")).toBe(true);
});

it("installs the bundled app and wires desktop routing to all priorities", async () => {
it("installs the bundled app and enables desktop without default config or routing blocks", async () => {
const program = createProgram();

await program.parseAsync(["node", "test", "setup", "desktop", "--non-interactive"]);

expect(mockCpSync).toHaveBeenCalledWith(sourceApp, targetApp, { recursive: true });
const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string;
const parsed = parseYaml(writtenYaml) as {
defaults?: { notifiers?: string[] };
notifiers?: Record<string, { plugin?: string; backend?: string; dashboardUrl?: string }>;
notificationRouting?: Record<string, string[]>;
};

expect(parsed.notifiers?.["desktop"]).toMatchObject({
plugin: "desktop",
backend: "ao-app",
dashboardUrl: "http://localhost:3000",
});
expect(parsed.notificationRouting?.["urgent"]).toContain("desktop");
expect(parsed.notificationRouting?.["action"]).toContain("desktop");
expect(parsed.notificationRouting?.["warning"]).toContain("desktop");
expect(parsed.notificationRouting?.["info"]).toContain("desktop");
expect(parsed.defaults?.notifiers).toEqual(["desktop"]);
expect(parsed.notifiers?.["desktop"]).toBeUndefined();
expect(parsed.notificationRouting).toBeUndefined();
});

it("configures terminal-notifier backend without installing AO Notifier.app", async () => {
Expand Down Expand Up @@ -2773,8 +2784,10 @@

const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string;
const parsed = parseYaml(writtenYaml) as {
defaults?: { notifiers?: string[] };
notifiers?: Record<string, { plugin?: string; backend?: string; dashboardUrl?: string }>;
};
expect(parsed.defaults?.notifiers).toEqual(["desktop"]);
expect(parsed.notifiers?.["desktop"]).toMatchObject({
plugin: "desktop",
backend: "terminal-notifier",
Expand Down Expand Up @@ -2808,8 +2821,10 @@

const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string;
const parsed = parseYaml(writtenYaml) as {
defaults?: { notifiers?: string[] };
notifiers?: Record<string, { plugin?: string; backend?: string }>;
};
expect(parsed.defaults?.notifiers).toEqual(["desktop"]);
expect(parsed.notifiers?.["desktop"]).toMatchObject({
plugin: "desktop",
backend: "osascript",
Expand Down Expand Up @@ -2952,7 +2967,7 @@
expect(mockWriteFileSync).not.toHaveBeenCalled();
});

it("preserves existing routing entries while adding desktop", async () => {
it("preserves existing routing entries while adding desktop to defaults", async () => {
mockReadFileSync.mockReturnValue(`
port: 3001
defaults:
Expand All @@ -2977,9 +2992,10 @@
notificationRouting?: Record<string, string[]>;
defaults?: { notifiers?: string[] };
};
expect(parsed.notificationRouting?.["urgent"]).toEqual(["slack", "desktop"]);
expect(parsed.notificationRouting?.["action"]).toEqual(["slack", "desktop"]);
expect(parsed.defaults?.notifiers).toEqual(["slack"]);
expect(parsed.notificationRouting).toEqual({
urgent: ["slack"],
});
expect(parsed.defaults?.notifiers).toEqual(["slack", "desktop"]);
});

it("fails on conflicting desktop notifier config in non-interactive mode", async () => {
Expand Down Expand Up @@ -3043,8 +3059,10 @@

const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string;
const parsed = parseYaml(writtenYaml) as {
defaults?: { notifiers?: string[] };
notifiers?: Record<string, { plugin?: string; backend?: string }>;
};
expect(parsed.defaults?.notifiers).toEqual(["desktop"]);
expect(parsed.notifiers?.["desktop"]).toMatchObject({ plugin: "desktop", backend: "ao-app" });
});

Expand Down
126 changes: 113 additions & 13 deletions packages/cli/__tests__/commands/start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,7 @@ import { basename, join } from "node:path";
import { tmpdir } from "node:os";
import { parse as parseYaml } from "yaml";
import { EventEmitter } from "node:events";
import {
getDefaultRuntime,
recordActivityEvent,
type SessionManager,
} from "@aoagents/ao-core";
import { getDefaultRuntime, recordActivityEvent, type SessionManager } from "@aoagents/ao-core";

// ---------------------------------------------------------------------------
// Hoisted mocks
Expand Down Expand Up @@ -77,6 +73,11 @@ const { mockProcessCwd } = vi.hoisted(() => ({
mockProcessCwd: vi.fn<() => string | undefined>(),
}));

const { mockIsMac, mockInstallAoNotifierAppForStartup } = vi.hoisted(() => ({
mockIsMac: vi.fn().mockReturnValue(false),
mockInstallAoNotifierAppForStartup: vi.fn().mockResolvedValue(undefined),
}));

const { mockPromptSelect, mockPromptConfirm } = vi.hoisted(() => ({
mockPromptSelect: vi.fn(),
mockPromptConfirm: vi.fn().mockResolvedValue(true),
Expand Down Expand Up @@ -154,6 +155,7 @@ vi.mock("@aoagents/ao-core", async (importOriginal) => {
},
findPidByPort: mockFindPidByPort,
killProcessTree: mockKillProcessTree,
isMac: () => mockIsMac(),
sweepDaemonChildren: mockSweepDaemonChildren,
scanAoOrphans: mockScanAoOrphans,
reapAoOrphans: mockReapAoOrphans,
Expand Down Expand Up @@ -246,6 +248,11 @@ vi.mock("../../src/lib/openclaw-probe.js", () => ({
detectOpenClawInstallation: (...args: unknown[]) => mockDetectOpenClawInstallation(...args),
}));

vi.mock("../../src/lib/desktop-setup.js", () => ({
installAoNotifierAppForStartup: (...args: unknown[]) =>
mockInstallAoNotifierAppForStartup(...args),
}));

vi.mock("../../src/lib/prompts.js", () => ({
promptSelect: (...args: unknown[]) => mockPromptSelect(...args),
promptConfirm: (...args: unknown[]) => mockPromptConfirm(...args),
Expand Down Expand Up @@ -412,6 +419,10 @@ beforeEach(async () => {
});
mockWaitForPortAndOpen.mockReset();
mockWaitForPortAndOpen.mockResolvedValue(undefined);
mockIsMac.mockReset();
mockIsMac.mockReturnValue(false);
mockInstallAoNotifierAppForStartup.mockReset();
mockInstallAoNotifierAppForStartup.mockResolvedValue(undefined);
mockFindPidByPort.mockReset();
mockFindPidByPort.mockResolvedValue(null);
mockKillProcessTree.mockReset();
Expand Down Expand Up @@ -550,6 +561,87 @@ describe("start command — project resolution", () => {
expect(output).toContain("Startup complete");
});

it("does not expand default notifier config or routing during startup", async () => {
const configPath = join(tmpDir, "agent-orchestrator.yaml");
const yaml = [
"port: 3000",
"defaults:",
" notifiers:",
" - dashboard",
" - desktop",
"projects:",
" my-app:",
" name: My App",
" path: ./main-repo",
"",
].join("\n");
writeFileSync(configPath, yaml);
mockConfigRef.current = {
...makeConfig({ "my-app": makeProject() }),
configPath,
defaults: {
runtime: "process",
agent: "claude-code",
workspace: "worktree",
notifiers: ["dashboard", "desktop"],
},
notifiers: {},
notificationRouting: {},
};

await program.parseAsync(["node", "test", "start", "--no-dashboard", "--no-orchestrator"]);

expect(readFileSync(configPath, "utf-8")).toBe(yaml);
});

it("installs AO Notifier.app on macOS without expanding notifier YAML", async () => {
mockIsMac.mockReturnValue(true);
const configPath = join(tmpDir, "agent-orchestrator.yaml");
const yaml = [
"port: 3000",
"defaults:",
" notifiers:",
" - dashboard",
" - desktop",
"projects:",
" my-app:",
" name: My App",
" path: ./main-repo",
"",
].join("\n");
writeFileSync(configPath, yaml);
mockConfigRef.current = {
...makeConfig({ "my-app": makeProject() }),
configPath,
defaults: {
runtime: "process",
agent: "claude-code",
workspace: "worktree",
notifiers: ["dashboard", "desktop"],
},
notifiers: {},
notificationRouting: {},
};

await program.parseAsync(["node", "test", "start", "--no-dashboard", "--no-orchestrator"]);

expect(mockInstallAoNotifierAppForStartup).toHaveBeenCalledOnce();
expect(readFileSync(configPath, "utf-8")).toBe(yaml);
});

it("continues startup with a warning when macOS AO Notifier.app setup fails", async () => {
mockIsMac.mockReturnValue(true);
mockInstallAoNotifierAppForStartup.mockRejectedValueOnce(new Error("copy failed"));
mockConfigRef.current = makeConfig({ "my-app": makeProject() });

await program.parseAsync(["node", "test", "start", "--no-dashboard", "--no-orchestrator"]);

const output = vi.mocked(console.log).mock.calls.map((call) => call.join(" ")).join("\n");
expect(output).toContain("Could not set up AO Notifier.app");
expect(output).toContain("copy failed");
expect(output).toContain("Startup complete");
});

it("uses explicit project arg when given", async () => {
mockConfigRef.current = makeConfig({
frontend: makeProject({ name: "Frontend", sessionPrefix: "fe" }),
Expand Down Expand Up @@ -2366,7 +2458,7 @@ describe("start command — platform-aware runtime fallback", () => {
// ---------------------------------------------------------------------------

describe("start command — autoCreateConfig", () => {
it("writes a flat local config and returns the global project identity", async () => {
it("writes a flat local config, returns global project identity, and creates config-light notifier defaults", async () => {
const { detectEnvironment } = await import("../../src/lib/detect-env.js");
vi.mocked(detectEnvironment).mockResolvedValue({
isGitRepo: true,
Expand Down Expand Up @@ -2418,15 +2510,26 @@ describe("start command — autoCreateConfig", () => {
const globalContent = readFileSync(globalConfigPath, "utf-8");
const globalParsed = parseYaml(globalContent) as {
defaults?: { notifiers?: unknown[] };
notifiers?: Record<
string,
{ plugin?: string; backend?: string; dashboardUrl?: string; limit?: number }
>;
notificationRouting?: Record<string, string[]>;
projects?: Record<string, { path?: string; sessionPrefix?: string }>;
};
expect(globalParsed.defaults?.notifiers).toEqual(["composio", "desktop"]);
expect(globalParsed.defaults?.notifiers).toEqual(["dashboard", "desktop"]);
expect(globalParsed.notifiers).toEqual({});
expect(globalParsed.notificationRouting).toEqual({});
expect(globalContent).not.toContain("composio");

const projectIds = Object.keys(globalParsed.projects ?? {});
expect(projectIds).toHaveLength(1);
expect(config.configPath).toBe(configPath);
expect(Object.keys(config.projects)).toEqual(projectIds);
expect(config.projects[projectIds[0]!]!.path).toBe(realpathSync(tmpDir));
expect(config.defaults.notifiers).toEqual(["dashboard", "desktop"]);
expect(config.notifiers).toEqual({});
expect(config.notificationRouting).toEqual({});
});

it("removes the flat local config when global registration fails", async () => {
Expand Down Expand Up @@ -2459,12 +2562,9 @@ describe("start command — autoCreateConfig", () => {

writeFileSync(
process.env["AO_GLOBAL_CONFIG"]!,
[
"projects:",
` ${basename(tmpDir)}:`,
` path: ${join(tmpDir, "other-repo")}`,
"",
].join("\n"),
["projects:", ` ${basename(tmpDir)}:`, ` path: ${join(tmpDir, "other-repo")}`, ""].join(
"\n",
),
);

await expect(autoCreateConfig(tmpDir)).rejects.toThrow("already registered");
Expand Down
Loading
Loading