555e282cc4
pi-agent-plugin checks / lint (push) Has been cancelled
pi-agent-plugin checks / test (20) (push) Has been cancelled
pi-agent-plugin checks / test (22) (push) Has been cancelled
pi-agent-plugin checks / build (push) Has been cancelled
TypeScript SDK CI / check_changes (push) Has been cancelled
TypeScript SDK CI / changelog_check (push) Has been cancelled
ci / changelog_check (push) Has been cancelled
ci / check_changes (push) Has been cancelled
ci / build_mem0 (3.10) (push) Has been cancelled
ci / build_mem0 (3.11) (push) Has been cancelled
ci / build_mem0 (3.12) (push) Has been cancelled
CLI Node CI / lint (push) Has been cancelled
CLI Node CI / test (20) (push) Has been cancelled
CLI Node CI / test (22) (push) Has been cancelled
CLI Node CI / build (push) Has been cancelled
CLI Python CI / lint (push) Has been cancelled
CLI Python CI / test (3.10) (push) Has been cancelled
CLI Python CI / test (3.11) (push) Has been cancelled
CLI Python CI / test (3.12) (push) Has been cancelled
CLI Python CI / build (push) Has been cancelled
openclaw checks / lint (push) Has been cancelled
openclaw checks / test (20) (push) Has been cancelled
openclaw checks / test (22) (push) Has been cancelled
openclaw checks / build (push) Has been cancelled
opencode-plugin checks / build (push) Has been cancelled
TypeScript SDK CI / build_ts_sdk (20) (push) Has been cancelled
TypeScript SDK CI / build_ts_sdk (22) (push) Has been cancelled
TypeScript SDK CI / integration_ts_sdk (20) (push) Has been cancelled
TypeScript SDK CI / integration_ts_sdk (22) (push) Has been cancelled
1755 lines
57 KiB
TypeScript
1755 lines
57 KiB
TypeScript
/**
|
|
* Tests for CLI subcommand registration and action handlers in cli/commands.ts.
|
|
*
|
|
* Since the helper functions (getSystemUsername, resolveUserId, apiPost, etc.)
|
|
* are module-private, we test them indirectly through registerCliCommands by
|
|
* building a mock Commander-like program that captures each subcommand's action
|
|
* callback, then invoking those callbacks with controlled arguments.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Module mocks — must be declared before importing the module under test
|
|
// ---------------------------------------------------------------------------
|
|
|
|
vi.mock("../cli/config-file.ts", () => ({
|
|
readPluginAuth: vi.fn().mockReturnValue({}),
|
|
writePluginAuth: vi.fn(),
|
|
writePluginConfigField: vi.fn(),
|
|
enableSkillsConfig: vi.fn(),
|
|
getBaseUrl: vi.fn().mockReturnValue("https://api.mem0.ai"),
|
|
OPENCLAW_CONFIG_FILE: "/mock/.openclaw/openclaw.json",
|
|
}));
|
|
|
|
vi.mock("../fs-safe.ts", () => ({
|
|
readText: vi.fn().mockReturnValue("{}"),
|
|
exists: vi.fn().mockReturnValue(true),
|
|
writeText: vi.fn(),
|
|
mkdirp: vi.fn(),
|
|
unlink: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../skill-loader.ts", () => ({
|
|
loadDreamPrompt: vi.fn().mockReturnValue("dream prompt"),
|
|
}));
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Imports (after mocks)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
import { registerCliCommands } from "../cli/commands.ts";
|
|
import {
|
|
readPluginAuth,
|
|
writePluginAuth,
|
|
writePluginConfigField,
|
|
enableSkillsConfig,
|
|
getBaseUrl,
|
|
} from "../cli/config-file.ts";
|
|
import { loadDreamPrompt } from "../skill-loader.ts";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock Commander program builder
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface MockCommand {
|
|
_name: string;
|
|
_description: string;
|
|
_subcommands: MockCommand[];
|
|
_options: Array<{ flags: string; desc: string; defaultVal?: string }>;
|
|
_action: ((...args: any[]) => any) | null;
|
|
_args: Array<{ name: string; desc: string }>;
|
|
command(name: string): MockCommand;
|
|
description(desc: string): MockCommand;
|
|
configureHelp(opts: any): MockCommand;
|
|
hook(event: string, fn: (...args: any[]) => any): MockCommand;
|
|
option(flags: string, desc: string, defaultVal?: string): MockCommand;
|
|
argument(name: string, desc: string): MockCommand;
|
|
action(fn: (...args: any[]) => any): MockCommand;
|
|
}
|
|
|
|
function createMockCommand(name: string): MockCommand {
|
|
const cmd: MockCommand = {
|
|
_name: name,
|
|
_description: "",
|
|
_subcommands: [],
|
|
_options: [],
|
|
_action: null,
|
|
_args: [],
|
|
command(n: string) {
|
|
const sub = createMockCommand(n);
|
|
cmd._subcommands.push(sub);
|
|
return sub;
|
|
},
|
|
description(desc: string) {
|
|
cmd._description = desc;
|
|
return cmd;
|
|
},
|
|
configureHelp(_opts: any) {
|
|
return cmd;
|
|
},
|
|
hook(_event: string, _fn: (...args: any[]) => any) {
|
|
return cmd;
|
|
},
|
|
option(flags: string, desc: string, defaultVal?: string) {
|
|
cmd._options.push({ flags, desc, defaultVal });
|
|
return cmd;
|
|
},
|
|
argument(n: string, desc: string) {
|
|
cmd._args.push({ name: n, desc });
|
|
return cmd;
|
|
},
|
|
action(fn: (...args: any[]) => any) {
|
|
cmd._action = fn;
|
|
return cmd;
|
|
},
|
|
};
|
|
return cmd;
|
|
}
|
|
|
|
/** Recursively find a subcommand by name. */
|
|
function findCommand(root: MockCommand, name: string): MockCommand | undefined {
|
|
for (const sub of root._subcommands) {
|
|
if (sub._name === name) return sub;
|
|
const deep = findCommand(sub, name);
|
|
if (deep) return deep;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function createMockProvider() {
|
|
return {
|
|
add: vi.fn().mockResolvedValue({
|
|
results: [{ id: "new-1", event: "ADD", memory: "stored fact" }],
|
|
}),
|
|
search: vi.fn().mockResolvedValue([
|
|
{ id: "m1", memory: "test memory", score: 0.9, categories: ["preference"], created_at: "2026-01-01" },
|
|
]),
|
|
get: vi.fn().mockResolvedValue({
|
|
id: "m1",
|
|
memory: "test memory",
|
|
user_id: "testuser",
|
|
categories: ["preference"],
|
|
metadata: {},
|
|
created_at: "2026-01-01",
|
|
updated_at: "2026-01-02",
|
|
}),
|
|
getAll: vi.fn().mockResolvedValue([
|
|
{ id: "m1", memory: "test memory", categories: ["preference"], created_at: "2026-01-01", updated_at: "2026-01-02" },
|
|
]),
|
|
update: vi.fn().mockResolvedValue(undefined),
|
|
delete: vi.fn().mockResolvedValue(undefined),
|
|
deleteAll: vi.fn().mockResolvedValue(undefined),
|
|
history: vi.fn().mockResolvedValue([
|
|
{ id: "h1", old_memory: "old", new_memory: "new", event: "UPDATE", created_at: "2026-01-01" },
|
|
]),
|
|
};
|
|
}
|
|
|
|
function createMockBackend() {
|
|
return {
|
|
status: vi.fn().mockResolvedValue({
|
|
connected: true,
|
|
url: "https://api.mem0.ai",
|
|
}),
|
|
add: vi.fn().mockResolvedValue({ id: "new-1" }),
|
|
listEvents: vi.fn().mockResolvedValue([
|
|
{ id: "evt-1111-2222-3333-4444", event_type: "ADD", status: "SUCCEEDED", latency: 1500, created_at: "2026-04-01T12:00:00Z" },
|
|
]),
|
|
getEvent: vi.fn().mockResolvedValue({
|
|
id: "evt-1111-2222-3333-4444",
|
|
event_type: "ADD",
|
|
status: "SUCCEEDED",
|
|
latency: 1500,
|
|
created_at: "2026-04-01T12:00:00Z",
|
|
updated_at: "2026-04-01T12:00:01Z",
|
|
}),
|
|
};
|
|
}
|
|
|
|
function createMockCfg() {
|
|
return {
|
|
mode: "platform" as const,
|
|
userId: "testuser",
|
|
apiKey: "m0-test-key-1234",
|
|
baseUrl: "https://api.mem0.ai",
|
|
topK: 5,
|
|
autoCapture: true,
|
|
autoRecall: true,
|
|
searchThreshold: 0.1,
|
|
customInstructions: "",
|
|
customCategories: {},
|
|
skills: {},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Register CLI commands using mocked deps, and return the captured
|
|
* mem0 command tree plus all mock objects for assertions.
|
|
*/
|
|
function setup() {
|
|
const provider = createMockProvider();
|
|
const backend = createMockBackend();
|
|
const cfg = createMockCfg();
|
|
const effectiveUserId = vi.fn().mockReturnValue("testuser");
|
|
const agentUserId = vi.fn((id: string) => `testuser:agent:${id}`);
|
|
const buildSearchOptions = vi.fn().mockReturnValue({
|
|
user_id: "testuser",
|
|
top_k: 5,
|
|
source: "OPENCLAW",
|
|
});
|
|
const getCurrentSessionId = vi.fn().mockReturnValue(undefined);
|
|
|
|
let registeredCallback: any;
|
|
const mockApi = {
|
|
registerCli: vi.fn((cb: any) => {
|
|
registeredCallback = cb;
|
|
}),
|
|
logger: { info: vi.fn(), warn: vi.fn() },
|
|
} as any;
|
|
|
|
registerCliCommands(
|
|
mockApi,
|
|
backend as any,
|
|
provider as any,
|
|
cfg as any,
|
|
effectiveUserId,
|
|
agentUserId,
|
|
buildSearchOptions,
|
|
getCurrentSessionId,
|
|
);
|
|
|
|
// Build a mock program and invoke the captured callback
|
|
const rootProgram = createMockCommand("root");
|
|
registeredCallback({ program: rootProgram });
|
|
|
|
// The callback creates a "mem0" subcommand on the root program
|
|
const mem0 = findCommand(rootProgram, "mem0")!;
|
|
|
|
return {
|
|
mem0,
|
|
mockApi,
|
|
provider,
|
|
backend,
|
|
cfg,
|
|
effectiveUserId,
|
|
agentUserId,
|
|
buildSearchOptions,
|
|
getCurrentSessionId,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test suites
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("registerCliCommands", () => {
|
|
let consoleSpy: {
|
|
log: ReturnType<typeof vi.spyOn>;
|
|
error: ReturnType<typeof vi.spyOn>;
|
|
warn: ReturnType<typeof vi.spyOn>;
|
|
};
|
|
let stderrSpy: ReturnType<typeof vi.spyOn>;
|
|
let stdoutSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
|
|
// Re-set default mock return values after resetAllMocks
|
|
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValue({});
|
|
(writePluginAuth as ReturnType<typeof vi.fn>).mockImplementation(() => {});
|
|
(getBaseUrl as ReturnType<typeof vi.fn>).mockReturnValue("https://api.mem0.ai");
|
|
(loadDreamPrompt as ReturnType<typeof vi.fn>).mockReturnValue("dream prompt");
|
|
|
|
consoleSpy = {
|
|
log: vi.spyOn(console, "log").mockImplementation(() => {}),
|
|
error: vi.spyOn(console, "error").mockImplementation(() => {}),
|
|
warn: vi.spyOn(console, "warn").mockImplementation(() => {}),
|
|
};
|
|
stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
});
|
|
|
|
afterEach(() => {
|
|
consoleSpy.log.mockRestore();
|
|
consoleSpy.error.mockRestore();
|
|
consoleSpy.warn.mockRestore();
|
|
stderrSpy.mockRestore();
|
|
stdoutSpy.mockRestore();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
// ========================================================================
|
|
// Registration
|
|
// ========================================================================
|
|
|
|
describe("command registration", () => {
|
|
it("calls api.registerCli exactly once", () => {
|
|
const { mockApi } = setup();
|
|
expect(mockApi.registerCli).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("registers a mem0 parent command", () => {
|
|
const { mem0 } = setup();
|
|
expect(mem0).toBeDefined();
|
|
expect(mem0._name).toBe("mem0");
|
|
});
|
|
|
|
it("registers all expected subcommands under mem0", () => {
|
|
const { mem0 } = setup();
|
|
const names = mem0._subcommands.map((c) => c._name);
|
|
expect(names).toContain("init");
|
|
expect(names).toContain("add");
|
|
expect(names).toContain("search");
|
|
expect(names).toContain("get");
|
|
expect(names).toContain("list");
|
|
expect(names).toContain("update");
|
|
expect(names).toContain("delete");
|
|
expect(names).toContain("status");
|
|
expect(names).toContain("config");
|
|
expect(names).toContain("dream");
|
|
});
|
|
|
|
it("registers config subcommands: show, get, set", () => {
|
|
const { mem0 } = setup();
|
|
const configCmd = findCommand(mem0, "config")!;
|
|
const configSubs = configCmd._subcommands.map((c) => c._name);
|
|
expect(configSubs).toContain("show");
|
|
expect(configSubs).toContain("get");
|
|
expect(configSubs).toContain("set");
|
|
});
|
|
});
|
|
|
|
// ========================================================================
|
|
// init subcommand
|
|
// ========================================================================
|
|
|
|
describe("init subcommand", () => {
|
|
it("saves config and validates API key with --api-key flag", async () => {
|
|
const { mem0 } = setup();
|
|
const initCmd = findCommand(mem0, "init")!;
|
|
|
|
// Stub fetch for validateApiKey
|
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: vi.fn().mockResolvedValue({}),
|
|
}));
|
|
|
|
await initCmd._action!({ apiKey: "m0-my-key-1234" });
|
|
|
|
// saveLoginConfig calls writePluginAuth with the API key
|
|
expect(writePluginAuth).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
apiKey: "m0-my-key-1234",
|
|
mode: "platform",
|
|
}),
|
|
);
|
|
|
|
// validateApiKey calls fetch with /v1/ping/
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
"https://api.mem0.ai/v1/ping/",
|
|
expect.objectContaining({
|
|
headers: {
|
|
Authorization: "Token m0-my-key-1234",
|
|
"X-Mem0-Source": "OPENCLAW",
|
|
"X-Mem0-Client-Language": "node",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(consoleSpy.log).toHaveBeenCalledWith(
|
|
expect.stringContaining("API key validated"),
|
|
);
|
|
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("warns when API key validation returns non-ok status", async () => {
|
|
const { mem0 } = setup();
|
|
const initCmd = findCommand(mem0, "init")!;
|
|
|
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 401,
|
|
json: vi.fn().mockResolvedValue({}),
|
|
}));
|
|
|
|
await initCmd._action!({ apiKey: "bad-key" });
|
|
|
|
expect(consoleSpy.warn).toHaveBeenCalledWith(
|
|
expect.stringContaining("HTTP 401"),
|
|
);
|
|
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("warns when network error during API key validation", async () => {
|
|
const { mem0 } = setup();
|
|
const initCmd = findCommand(mem0, "init")!;
|
|
|
|
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED")));
|
|
|
|
await initCmd._action!({ apiKey: "some-key" });
|
|
|
|
expect(consoleSpy.warn).toHaveBeenCalledWith(
|
|
expect.stringContaining("could not reach"),
|
|
);
|
|
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("rejects using both --api-key and --email together", async () => {
|
|
const { mem0 } = setup();
|
|
const initCmd = findCommand(mem0, "init")!;
|
|
|
|
await initCmd._action!({ apiKey: "key", email: "test@example.com" });
|
|
|
|
expect(consoleSpy.error).toHaveBeenCalledWith(
|
|
"Cannot use both --api-key and --email.",
|
|
);
|
|
expect(writePluginAuth).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("sends verification code with --email only", async () => {
|
|
const { mem0 } = setup();
|
|
const initCmd = findCommand(mem0, "init")!;
|
|
|
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: vi.fn().mockResolvedValue({}),
|
|
}));
|
|
|
|
await initCmd._action!({ email: "TEST@Example.Com" });
|
|
|
|
// sendVerificationCode calls apiPost which calls fetch
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
"https://api.mem0.ai/api/v1/auth/email_code/",
|
|
expect.objectContaining({
|
|
method: "POST",
|
|
body: JSON.stringify({ email: "test@example.com" }),
|
|
}),
|
|
);
|
|
|
|
expect(consoleSpy.log).toHaveBeenCalledWith(
|
|
expect.stringContaining("Verification code sent"),
|
|
);
|
|
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("verifies email code with --email and --code, saves config", async () => {
|
|
const { mem0 } = setup();
|
|
const initCmd = findCommand(mem0, "init")!;
|
|
|
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: vi.fn().mockResolvedValue({ api_key: "m0-verified-key" }),
|
|
}));
|
|
|
|
await initCmd._action!({
|
|
email: "user@example.com",
|
|
code: "123456",
|
|
});
|
|
|
|
// verifyEmailCode POST
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
"https://api.mem0.ai/api/v1/auth/email_code/verify/",
|
|
expect.objectContaining({
|
|
method: "POST",
|
|
body: JSON.stringify({ email: "user@example.com", code: "123456" }),
|
|
}),
|
|
);
|
|
|
|
// saveLoginConfig writes the returned API key
|
|
expect(writePluginAuth).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
apiKey: "m0-verified-key",
|
|
}),
|
|
);
|
|
|
|
expect(consoleSpy.log).toHaveBeenCalledWith(
|
|
expect.stringContaining("Authenticated"),
|
|
);
|
|
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("does not save config when email verification returns no api_key", async () => {
|
|
const { mem0 } = setup();
|
|
const initCmd = findCommand(mem0, "init")!;
|
|
|
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: vi.fn().mockResolvedValue({}), // no api_key field
|
|
}));
|
|
|
|
await initCmd._action!({ email: "user@example.com", code: "000000" });
|
|
|
|
expect(consoleSpy.error).toHaveBeenCalledWith(
|
|
expect.stringContaining("no API key was returned"),
|
|
);
|
|
expect(writePluginAuth).not.toHaveBeenCalled();
|
|
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("shows usage in non-interactive mode with no flags", async () => {
|
|
const { mem0 } = setup();
|
|
const initCmd = findCommand(mem0, "init")!;
|
|
|
|
// Simulate non-TTY
|
|
const origIsTTY = process.stdin.isTTY;
|
|
Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true });
|
|
|
|
await initCmd._action!({});
|
|
|
|
expect(consoleSpy.log).toHaveBeenCalledWith(
|
|
expect.stringContaining("Usage (non-interactive)"),
|
|
);
|
|
|
|
Object.defineProperty(process.stdin, "isTTY", { value: origIsTTY, configurable: true });
|
|
});
|
|
|
|
it("preserves userId from --user-id flag during init", async () => {
|
|
const { mem0 } = setup();
|
|
const initCmd = findCommand(mem0, "init")!;
|
|
|
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: vi.fn().mockResolvedValue({}),
|
|
}));
|
|
|
|
await initCmd._action!({ apiKey: "m0-key", userId: "custom-user" });
|
|
|
|
expect(writePluginAuth).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
userId: "custom-user",
|
|
}),
|
|
);
|
|
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("outputs JSON for --api-key flow when --json is set", async () => {
|
|
const { mem0 } = setup();
|
|
const initCmd = findCommand(mem0, "init")!;
|
|
|
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: vi.fn().mockResolvedValue({}),
|
|
}));
|
|
|
|
await initCmd._action!({ apiKey: "m0-key", json: true });
|
|
|
|
const jsonCall = stdoutSpy.mock.calls.find((c) => {
|
|
try {
|
|
const p = JSON.parse(c[0] as string);
|
|
return typeof p.ok === "boolean";
|
|
} catch { return false; }
|
|
});
|
|
expect(jsonCall).toBeDefined();
|
|
const parsed = JSON.parse(jsonCall![0] as string);
|
|
expect(parsed.ok).toBe(true);
|
|
expect(parsed.mode).toBe("platform");
|
|
expect(parsed.validated).toBe(true);
|
|
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("outputs JSON for --api-key flow with failed validation when --json is set", async () => {
|
|
const { mem0 } = setup();
|
|
const initCmd = findCommand(mem0, "init")!;
|
|
|
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 401,
|
|
json: vi.fn().mockResolvedValue({}),
|
|
}));
|
|
|
|
await initCmd._action!({ apiKey: "bad-key", json: true });
|
|
|
|
const jsonCall = stdoutSpy.mock.calls.find((c) => {
|
|
try {
|
|
const p = JSON.parse(c[0] as string);
|
|
return typeof p.ok === "boolean";
|
|
} catch { return false; }
|
|
});
|
|
expect(jsonCall).toBeDefined();
|
|
const parsed = JSON.parse(jsonCall![0] as string);
|
|
expect(parsed.ok).toBe(false);
|
|
expect(parsed.mode).toBe("platform");
|
|
expect(parsed.validated).toBe(false);
|
|
expect(parsed.httpStatus).toBe(401);
|
|
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("outputs JSON for --api-key + --email conflict when --json is set", async () => {
|
|
const { mem0 } = setup();
|
|
const initCmd = findCommand(mem0, "init")!;
|
|
|
|
await initCmd._action!({ apiKey: "key", email: "a@b.com", json: true });
|
|
|
|
const jsonCall = stdoutSpy.mock.calls.find((c) => {
|
|
try {
|
|
const p = JSON.parse(c[0] as string);
|
|
return p.ok === false;
|
|
} catch { return false; }
|
|
});
|
|
expect(jsonCall).toBeDefined();
|
|
const parsed = JSON.parse(jsonCall![0] as string);
|
|
expect(parsed.error).toContain("Cannot use both");
|
|
expect(writePluginAuth).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("outputs JSON for email send-code flow when --json is set", async () => {
|
|
const { mem0 } = setup();
|
|
const initCmd = findCommand(mem0, "init")!;
|
|
|
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: vi.fn().mockResolvedValue({}),
|
|
}));
|
|
|
|
await initCmd._action!({ email: "user@example.com", json: true });
|
|
|
|
const jsonCall = stdoutSpy.mock.calls.find((c) => {
|
|
try {
|
|
const p = JSON.parse(c[0] as string);
|
|
return p.codeSent === true;
|
|
} catch { return false; }
|
|
});
|
|
expect(jsonCall).toBeDefined();
|
|
const parsed = JSON.parse(jsonCall![0] as string);
|
|
expect(parsed.ok).toBe(true);
|
|
expect(parsed.email).toBe("user@example.com");
|
|
expect(parsed.nextCommand).toContain("--code");
|
|
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("outputs JSON for email verify flow when --json is set", async () => {
|
|
const { mem0 } = setup();
|
|
const initCmd = findCommand(mem0, "init")!;
|
|
|
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: vi.fn().mockResolvedValue({ api_key: "m0-verified" }),
|
|
}));
|
|
|
|
await initCmd._action!({ email: "u@b.com", code: "123456", json: true });
|
|
|
|
const jsonCall = stdoutSpy.mock.calls.find((c) => {
|
|
try {
|
|
const p = JSON.parse(c[0] as string);
|
|
return p.ok === true && p.mode === "platform";
|
|
} catch { return false; }
|
|
});
|
|
expect(jsonCall).toBeDefined();
|
|
const parsed = JSON.parse(jsonCall![0] as string);
|
|
expect(parsed.email).toBe("u@b.com");
|
|
expect(parsed.message).toContain("Authenticated");
|
|
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("clears stale apiKey when switching to OSS mode", async () => {
|
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }));
|
|
|
|
const { mem0 } = setup();
|
|
const initCmd = findCommand(mem0, "init")!;
|
|
|
|
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
apiKey: "m0-old-platform-key",
|
|
mode: "platform",
|
|
userId: "testuser",
|
|
});
|
|
|
|
await initCmd._action!({
|
|
mode: "open-source",
|
|
ossLlm: "ollama",
|
|
ossEmbedder: "ollama",
|
|
ossVector: "qdrant",
|
|
});
|
|
|
|
expect(writePluginAuth).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
apiKey: "",
|
|
mode: "open-source",
|
|
}),
|
|
);
|
|
|
|
vi.unstubAllGlobals();
|
|
});
|
|
});
|
|
|
|
// ========================================================================
|
|
// add subcommand
|
|
// ========================================================================
|
|
|
|
describe("add subcommand", () => {
|
|
it("calls provider.add with text and prints result", async () => {
|
|
const { mem0, provider } = setup();
|
|
const addCmd = findCommand(mem0, "add")!;
|
|
|
|
await addCmd._action!("User likes TypeScript", {});
|
|
|
|
expect(provider.add).toHaveBeenCalledWith(
|
|
[{ role: "user", content: "User likes TypeScript" }],
|
|
expect.objectContaining({ user_id: "testuser" }),
|
|
);
|
|
expect(consoleSpy.log).toHaveBeenCalledWith(
|
|
expect.stringContaining("Added 1 memory"),
|
|
);
|
|
});
|
|
|
|
it("uses agentUserId when --agent-id is provided", async () => {
|
|
const { mem0, provider, agentUserId } = setup();
|
|
const addCmd = findCommand(mem0, "add")!;
|
|
|
|
await addCmd._action!("agent fact", { agentId: "researcher" });
|
|
|
|
expect(agentUserId).toHaveBeenCalledWith("researcher");
|
|
expect(provider.add).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({ user_id: "testuser:agent:researcher" }),
|
|
);
|
|
});
|
|
|
|
it("uses --user-id override when provided", async () => {
|
|
const { mem0, provider } = setup();
|
|
const addCmd = findCommand(mem0, "add")!;
|
|
|
|
await addCmd._action!("some fact", { userId: "alice" });
|
|
|
|
expect(provider.add).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({ user_id: "alice" }),
|
|
);
|
|
});
|
|
|
|
it("prints message when no new memories are extracted", async () => {
|
|
const { mem0, provider } = setup();
|
|
provider.add.mockResolvedValueOnce({ results: [] });
|
|
const addCmd = findCommand(mem0, "add")!;
|
|
|
|
await addCmd._action!("the", {});
|
|
|
|
expect(consoleSpy.log).toHaveBeenCalledWith(
|
|
expect.stringContaining("No new memories extracted"),
|
|
);
|
|
});
|
|
|
|
it("handles add errors gracefully", async () => {
|
|
const { mem0, provider } = setup();
|
|
provider.add.mockRejectedValueOnce(new Error("API timeout"));
|
|
const addCmd = findCommand(mem0, "add")!;
|
|
|
|
await addCmd._action!("failing fact", {});
|
|
|
|
expect(consoleSpy.error).toHaveBeenCalledWith(
|
|
expect.stringContaining("Add failed"),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ========================================================================
|
|
// search subcommand
|
|
// ========================================================================
|
|
|
|
describe("search subcommand", () => {
|
|
it("calls provider.search and outputs results as JSON", async () => {
|
|
const { mem0, provider } = setup();
|
|
const searchCmd = findCommand(mem0, "search")!;
|
|
|
|
await searchCmd._action!("user preferences", {
|
|
topK: "5",
|
|
scope: "all",
|
|
});
|
|
|
|
expect(provider.search).toHaveBeenCalled();
|
|
// The output is JSON.stringify of results
|
|
expect(consoleSpy.log).toHaveBeenCalledWith(
|
|
expect.stringContaining("test memory"),
|
|
);
|
|
});
|
|
|
|
it("prints 'No memories found' when search returns empty", async () => {
|
|
const { mem0, provider } = setup();
|
|
provider.search.mockResolvedValue([]);
|
|
const searchCmd = findCommand(mem0, "search")!;
|
|
|
|
await searchCmd._action!("nothing", { topK: "5", scope: "all" });
|
|
|
|
expect(consoleSpy.log).toHaveBeenCalledWith("No memories found.");
|
|
});
|
|
|
|
it("uses --user-id override for search", async () => {
|
|
const { mem0, provider } = setup();
|
|
const searchCmd = findCommand(mem0, "search")!;
|
|
|
|
await searchCmd._action!("query", {
|
|
topK: "5",
|
|
scope: "all",
|
|
userId: "alice",
|
|
});
|
|
|
|
// The userId should flow through to the search options
|
|
expect(provider.search).toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses agentUserId when --agent-id is provided", async () => {
|
|
const { mem0, agentUserId } = setup();
|
|
const searchCmd = findCommand(mem0, "search")!;
|
|
|
|
await searchCmd._action!("query", {
|
|
topK: "5",
|
|
scope: "all",
|
|
agentId: "researcher",
|
|
});
|
|
|
|
expect(agentUserId).toHaveBeenCalledWith("researcher");
|
|
});
|
|
|
|
it("handles search errors gracefully", async () => {
|
|
const { mem0, provider } = setup();
|
|
provider.search.mockRejectedValue(new Error("search boom"));
|
|
const searchCmd = findCommand(mem0, "search")!;
|
|
|
|
await searchCmd._action!("test", { topK: "5", scope: "all" });
|
|
|
|
expect(consoleSpy.error).toHaveBeenCalledWith(
|
|
expect.stringContaining("Search failed"),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ========================================================================
|
|
// get subcommand
|
|
// ========================================================================
|
|
|
|
describe("get subcommand", () => {
|
|
it("calls provider.get and outputs the memory as JSON", async () => {
|
|
const { mem0, provider } = setup();
|
|
const getCmd = findCommand(mem0, "get")!;
|
|
|
|
await getCmd._action!("m1");
|
|
|
|
expect(provider.get).toHaveBeenCalledWith("m1");
|
|
expect(consoleSpy.log).toHaveBeenCalledWith(
|
|
expect.stringContaining("test memory"),
|
|
);
|
|
});
|
|
|
|
it("handles get errors gracefully", async () => {
|
|
const { mem0, provider } = setup();
|
|
provider.get.mockRejectedValueOnce(new Error("not found"));
|
|
const getCmd = findCommand(mem0, "get")!;
|
|
|
|
await getCmd._action!("bad-id");
|
|
|
|
expect(consoleSpy.error).toHaveBeenCalledWith(
|
|
expect.stringContaining("Get failed"),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ========================================================================
|
|
// list subcommand
|
|
// ========================================================================
|
|
|
|
describe("list subcommand", () => {
|
|
it("calls provider.getAll and prints memories as JSON", async () => {
|
|
const { mem0, provider } = setup();
|
|
const listCmd = findCommand(mem0, "list")!;
|
|
|
|
await listCmd._action!({ topK: "50" });
|
|
|
|
expect(provider.getAll).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
user_id: "testuser",
|
|
page_size: 50,
|
|
source: "OPENCLAW",
|
|
}),
|
|
);
|
|
expect(consoleSpy.log).toHaveBeenCalledWith(
|
|
expect.stringContaining("test memory"),
|
|
);
|
|
expect(consoleSpy.log).toHaveBeenCalledWith(
|
|
expect.stringContaining("Total: 1 memories"),
|
|
);
|
|
});
|
|
|
|
it("prints 'No memories found' when list returns empty", async () => {
|
|
const { mem0, provider } = setup();
|
|
provider.getAll.mockResolvedValueOnce([]);
|
|
const listCmd = findCommand(mem0, "list")!;
|
|
|
|
await listCmd._action!({ topK: "50" });
|
|
|
|
expect(consoleSpy.log).toHaveBeenCalledWith("No memories found.");
|
|
});
|
|
|
|
it("uses agentUserId when --agent-id is provided", async () => {
|
|
const { mem0, provider, agentUserId } = setup();
|
|
const listCmd = findCommand(mem0, "list")!;
|
|
|
|
await listCmd._action!({ topK: "50", agentId: "builder" });
|
|
|
|
expect(agentUserId).toHaveBeenCalledWith("builder");
|
|
expect(provider.getAll).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
user_id: "testuser:agent:builder",
|
|
source: "OPENCLAW",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("handles list errors gracefully", async () => {
|
|
const { mem0, provider } = setup();
|
|
provider.getAll.mockRejectedValueOnce(new Error("list boom"));
|
|
const listCmd = findCommand(mem0, "list")!;
|
|
|
|
await listCmd._action!({ topK: "50" });
|
|
|
|
expect(consoleSpy.error).toHaveBeenCalledWith(
|
|
expect.stringContaining("List failed"),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ========================================================================
|
|
// update subcommand
|
|
// ========================================================================
|
|
|
|
describe("update subcommand", () => {
|
|
it("calls provider.update and prints confirmation", async () => {
|
|
const { mem0, provider } = setup();
|
|
const updateCmd = findCommand(mem0, "update")!;
|
|
|
|
await updateCmd._action!("m1", "updated text");
|
|
|
|
expect(provider.update).toHaveBeenCalledWith("m1", "updated text");
|
|
expect(consoleSpy.log).toHaveBeenCalledWith("Memory m1 updated.");
|
|
});
|
|
|
|
it("handles update errors gracefully", async () => {
|
|
const { mem0, provider } = setup();
|
|
provider.update.mockRejectedValueOnce(new Error("update boom"));
|
|
const updateCmd = findCommand(mem0, "update")!;
|
|
|
|
await updateCmd._action!("m1", "text");
|
|
|
|
expect(consoleSpy.error).toHaveBeenCalledWith(
|
|
expect.stringContaining("Update failed"),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ========================================================================
|
|
// delete subcommand
|
|
// ========================================================================
|
|
|
|
describe("delete subcommand", () => {
|
|
it("deletes a single memory by ID", async () => {
|
|
const { mem0, provider } = setup();
|
|
const deleteCmd = findCommand(mem0, "delete")!;
|
|
|
|
await deleteCmd._action!("m1", {});
|
|
|
|
expect(provider.delete).toHaveBeenCalledWith("m1");
|
|
expect(consoleSpy.log).toHaveBeenCalledWith("Memory m1 deleted.");
|
|
});
|
|
|
|
it("bulk deletes with --all and --confirm", async () => {
|
|
const { mem0, provider } = setup();
|
|
const deleteCmd = findCommand(mem0, "delete")!;
|
|
|
|
await deleteCmd._action!(undefined, { all: true, confirm: true });
|
|
|
|
expect(provider.deleteAll).toHaveBeenCalledWith("testuser");
|
|
expect(consoleSpy.log).toHaveBeenCalledWith(
|
|
expect.stringContaining("All memories deleted"),
|
|
);
|
|
});
|
|
|
|
it("requires --confirm for bulk delete in non-interactive mode", async () => {
|
|
const { mem0, provider } = setup();
|
|
const deleteCmd = findCommand(mem0, "delete")!;
|
|
|
|
const origIsTTY = process.stdin.isTTY;
|
|
Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true });
|
|
|
|
await deleteCmd._action!(undefined, { all: true });
|
|
|
|
expect(provider.deleteAll).not.toHaveBeenCalled();
|
|
expect(consoleSpy.error).toHaveBeenCalledWith(
|
|
expect.stringContaining("--confirm flag"),
|
|
);
|
|
|
|
Object.defineProperty(process.stdin, "isTTY", { value: origIsTTY, configurable: true });
|
|
});
|
|
|
|
it("requires memory_id or --all flag", async () => {
|
|
const { mem0 } = setup();
|
|
const deleteCmd = findCommand(mem0, "delete")!;
|
|
|
|
await deleteCmd._action!(undefined, {});
|
|
|
|
expect(consoleSpy.error).toHaveBeenCalledWith(
|
|
expect.stringContaining("Provide a memory_id or use --all"),
|
|
);
|
|
});
|
|
|
|
it("uses agentUserId for --all --agent-id", async () => {
|
|
const { mem0, provider, agentUserId } = setup();
|
|
const deleteCmd = findCommand(mem0, "delete")!;
|
|
|
|
await deleteCmd._action!(undefined, {
|
|
all: true,
|
|
confirm: true,
|
|
agentId: "researcher",
|
|
});
|
|
|
|
expect(agentUserId).toHaveBeenCalledWith("researcher");
|
|
expect(provider.deleteAll).toHaveBeenCalledWith("testuser:agent:researcher");
|
|
});
|
|
|
|
it("handles delete errors gracefully", async () => {
|
|
const { mem0, provider } = setup();
|
|
provider.delete.mockRejectedValueOnce(new Error("delete failed"));
|
|
const deleteCmd = findCommand(mem0, "delete")!;
|
|
|
|
await deleteCmd._action!("m1", {});
|
|
|
|
expect(consoleSpy.error).toHaveBeenCalledWith(
|
|
expect.stringContaining("Delete failed"),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ========================================================================
|
|
// status subcommand
|
|
// ========================================================================
|
|
|
|
describe("status subcommand", () => {
|
|
it("calls backend.status and prints connection info", async () => {
|
|
const { mem0, backend } = setup();
|
|
const statusCmd = findCommand(mem0, "status")!;
|
|
|
|
await statusCmd._action!();
|
|
|
|
expect(backend.status).toHaveBeenCalled();
|
|
expect(consoleSpy.log).toHaveBeenCalledWith("Mode: platform");
|
|
expect(consoleSpy.log).toHaveBeenCalledWith("User ID: testuser");
|
|
expect(consoleSpy.log).toHaveBeenCalledWith("Connected to Mem0");
|
|
expect(consoleSpy.log).toHaveBeenCalledWith(
|
|
expect.stringContaining("https://api.mem0.ai"),
|
|
);
|
|
});
|
|
|
|
it("shows 'Not connected' when backend returns disconnected", async () => {
|
|
const { mem0, backend } = setup();
|
|
backend.status.mockResolvedValueOnce({
|
|
connected: false,
|
|
error: "ECONNREFUSED",
|
|
});
|
|
const statusCmd = findCommand(mem0, "status")!;
|
|
|
|
await statusCmd._action!();
|
|
|
|
expect(consoleSpy.log).toHaveBeenCalledWith("Not connected to Mem0");
|
|
expect(consoleSpy.log).toHaveBeenCalledWith(
|
|
expect.stringContaining("ECONNREFUSED"),
|
|
);
|
|
});
|
|
|
|
it("handles status errors gracefully", async () => {
|
|
const { mem0, backend } = setup();
|
|
backend.status.mockRejectedValueOnce(new Error("status boom"));
|
|
const statusCmd = findCommand(mem0, "status")!;
|
|
|
|
await statusCmd._action!();
|
|
|
|
expect(consoleSpy.error).toHaveBeenCalledWith(
|
|
expect.stringContaining("Status check failed"),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ========================================================================
|
|
// config show subcommand
|
|
// ========================================================================
|
|
|
|
describe("config show subcommand", () => {
|
|
it("displays all config keys with values", () => {
|
|
const { mem0 } = setup();
|
|
const configCmd = findCommand(mem0, "config")!;
|
|
const showCmd = findCommand(configCmd, "show")!;
|
|
|
|
showCmd._action!();
|
|
|
|
// Should print header, separator, and each key
|
|
const allOutput = consoleSpy.log.mock.calls.map((c) => c[0]).join("\n");
|
|
expect(allOutput).toContain("Key");
|
|
expect(allOutput).toContain("Value");
|
|
expect(allOutput).toContain("api_key");
|
|
expect(allOutput).toContain("email");
|
|
expect(allOutput).toContain("user_id");
|
|
expect(allOutput).toContain("Config file:");
|
|
});
|
|
});
|
|
|
|
// ========================================================================
|
|
// config get subcommand
|
|
// ========================================================================
|
|
|
|
describe("config get subcommand", () => {
|
|
it("prints value for a known config key", () => {
|
|
const { mem0 } = setup();
|
|
const configCmd = findCommand(mem0, "config")!;
|
|
const getCmd = findCommand(configCmd, "get")!;
|
|
|
|
getCmd._action!("mode");
|
|
|
|
expect(consoleSpy.log).toHaveBeenCalledWith("platform");
|
|
});
|
|
|
|
it("prints '(not set)' for an unset config key", () => {
|
|
const { mem0 } = setup();
|
|
const configCmd = findCommand(mem0, "config")!;
|
|
const getCmd = findCommand(configCmd, "get")!;
|
|
|
|
getCmd._action!("email");
|
|
|
|
expect(consoleSpy.log).toHaveBeenCalledWith("(not set)");
|
|
});
|
|
|
|
it("errors on unknown config key", () => {
|
|
const { mem0 } = setup();
|
|
const configCmd = findCommand(mem0, "config")!;
|
|
const getCmd = findCommand(configCmd, "get")!;
|
|
|
|
getCmd._action!("nonexistent_key");
|
|
|
|
expect(consoleSpy.error).toHaveBeenCalledWith(
|
|
expect.stringContaining("Unknown config key: nonexistent_key"),
|
|
);
|
|
});
|
|
|
|
it("redacts API key in display", () => {
|
|
const { mem0 } = setup();
|
|
const configCmd = findCommand(mem0, "config")!;
|
|
const getCmd = findCommand(configCmd, "get")!;
|
|
|
|
// readPluginAuth returns config with apiKey
|
|
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
apiKey: "m0-supersecretkey1234",
|
|
});
|
|
|
|
getCmd._action!("api_key");
|
|
|
|
// Should show redacted value (first 4 + ... + last 4)
|
|
const logged = consoleSpy.log.mock.calls[0][0] as string;
|
|
expect(logged).toContain("...");
|
|
expect(logged).not.toContain("supersecret");
|
|
});
|
|
|
|
it("supports short alias keys like email", () => {
|
|
const { mem0 } = setup();
|
|
const configCmd = findCommand(mem0, "config")!;
|
|
const getCmd = findCommand(configCmd, "get")!;
|
|
|
|
getCmd._action!("email");
|
|
|
|
expect(consoleSpy.log).toHaveBeenCalledWith("(not set)");
|
|
});
|
|
});
|
|
|
|
// ========================================================================
|
|
// config set subcommand
|
|
// ========================================================================
|
|
|
|
describe("config set subcommand", () => {
|
|
it("sets a string config value via writePluginAuth", () => {
|
|
const { mem0 } = setup();
|
|
const configCmd = findCommand(mem0, "config")!;
|
|
const setCmd = findCommand(configCmd, "set")!;
|
|
|
|
setCmd._action!("user_id", "alice");
|
|
|
|
expect(writePluginAuth).toHaveBeenCalledWith(
|
|
expect.objectContaining({ userId: "alice" }),
|
|
);
|
|
expect(consoleSpy.log).toHaveBeenCalledWith(
|
|
expect.stringContaining("user_id = alice"),
|
|
);
|
|
});
|
|
|
|
it("coerces 'false' to boolean false for boolean keys", () => {
|
|
const { mem0 } = setup();
|
|
const configCmd = findCommand(mem0, "config")!;
|
|
const setCmd = findCommand(configCmd, "set")!;
|
|
|
|
setCmd._action!("auto_recall", "false");
|
|
|
|
expect(writePluginAuth).toHaveBeenCalledWith(
|
|
expect.objectContaining({ autoRecall: false }),
|
|
);
|
|
});
|
|
|
|
it("coerces '1' to boolean true for boolean keys", () => {
|
|
const { mem0 } = setup();
|
|
const configCmd = findCommand(mem0, "config")!;
|
|
const setCmd = findCommand(configCmd, "set")!;
|
|
|
|
setCmd._action!("auto_capture", "1");
|
|
|
|
expect(writePluginAuth).toHaveBeenCalledWith(
|
|
expect.objectContaining({ autoCapture: true }),
|
|
);
|
|
});
|
|
|
|
it("coerces integer string for integer keys", () => {
|
|
const { mem0 } = setup();
|
|
const configCmd = findCommand(mem0, "config")!;
|
|
const setCmd = findCommand(configCmd, "set")!;
|
|
|
|
setCmd._action!("top_k", "10");
|
|
|
|
expect(writePluginAuth).toHaveBeenCalledWith(
|
|
expect.objectContaining({ topK: 10 }),
|
|
);
|
|
});
|
|
|
|
it("errors on invalid integer value for integer keys", () => {
|
|
const { mem0 } = setup();
|
|
const configCmd = findCommand(mem0, "config")!;
|
|
const setCmd = findCommand(configCmd, "set")!;
|
|
|
|
setCmd._action!("top_k", "abc");
|
|
|
|
expect(consoleSpy.error).toHaveBeenCalledWith(
|
|
expect.stringContaining("Invalid integer value: abc"),
|
|
);
|
|
expect(writePluginAuth).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("errors on unknown config key", () => {
|
|
const { mem0 } = setup();
|
|
const configCmd = findCommand(mem0, "config")!;
|
|
const setCmd = findCommand(configCmd, "set")!;
|
|
|
|
setCmd._action!("unknown_key", "value");
|
|
|
|
expect(consoleSpy.error).toHaveBeenCalledWith(
|
|
expect.stringContaining("Unknown config key: unknown_key"),
|
|
);
|
|
expect(writePluginAuth).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("supports short alias keys for set", () => {
|
|
const { mem0 } = setup();
|
|
const configCmd = findCommand(mem0, "config")!;
|
|
const setCmd = findCommand(configCmd, "set")!;
|
|
|
|
setCmd._action!("user_id", "bob");
|
|
|
|
expect(writePluginAuth).toHaveBeenCalledWith(
|
|
expect.objectContaining({ userId: "bob" }),
|
|
);
|
|
});
|
|
|
|
it("redacts API key value in set confirmation output", () => {
|
|
const { mem0 } = setup();
|
|
const configCmd = findCommand(mem0, "config")!;
|
|
const setCmd = findCommand(configCmd, "set")!;
|
|
|
|
setCmd._action!("api_key", "m0-new-secret-key-abcd1234");
|
|
|
|
expect(writePluginAuth).toHaveBeenCalledWith(
|
|
expect.objectContaining({ apiKey: "m0-new-secret-key-abcd1234" }),
|
|
);
|
|
const logged = consoleSpy.log.mock.calls[0][0] as string;
|
|
expect(logged).toContain("...");
|
|
expect(logged).not.toContain("new-secret-key");
|
|
});
|
|
});
|
|
|
|
// ========================================================================
|
|
// dream subcommand
|
|
// ========================================================================
|
|
|
|
describe("dream subcommand", () => {
|
|
it("fetches memories and outputs dream prompt to stdout", async () => {
|
|
const { mem0, provider } = setup();
|
|
provider.getAll.mockResolvedValueOnce([
|
|
{
|
|
id: "m1",
|
|
memory: "User is an engineer",
|
|
categories: ["identity"],
|
|
metadata: { category: "identity", importance: 0.9 },
|
|
created_at: "2026-01-01",
|
|
},
|
|
]);
|
|
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
const dreamCmd = findCommand(mem0, "dream")!;
|
|
|
|
await dreamCmd._action!({});
|
|
|
|
expect(provider.getAll).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
user_id: "testuser",
|
|
source: "OPENCLAW",
|
|
}),
|
|
);
|
|
expect(loadDreamPrompt).toHaveBeenCalled();
|
|
|
|
// stdout should contain the dream prompt
|
|
const stdoutOutput = stdoutSpy.mock.calls.map((c) => c[0]).join("");
|
|
expect(stdoutOutput).toContain("<dream-protocol>");
|
|
expect(stdoutOutput).toContain("dream prompt");
|
|
expect(stdoutOutput).toContain("<all-memories");
|
|
expect(stdoutOutput).toContain("User is an engineer");
|
|
|
|
stdoutSpy.mockRestore();
|
|
});
|
|
|
|
it("prints dry-run message and does not output dream prompt", async () => {
|
|
const { mem0, provider } = setup();
|
|
provider.getAll.mockResolvedValueOnce([
|
|
{ id: "m1", memory: "test", categories: [], metadata: {}, created_at: "2026-01-01" },
|
|
]);
|
|
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
const dreamCmd = findCommand(mem0, "dream")!;
|
|
|
|
await dreamCmd._action!({ dryRun: true });
|
|
|
|
// Dry run should write inventory to stderr, NOT dream prompt to stdout
|
|
expect(stderrSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("Dry run"),
|
|
);
|
|
expect(stdoutSpy).not.toHaveBeenCalled();
|
|
|
|
stdoutSpy.mockRestore();
|
|
});
|
|
|
|
it("prints message when no memories to consolidate", async () => {
|
|
const { mem0, provider } = setup();
|
|
provider.getAll.mockResolvedValueOnce([]);
|
|
const dreamCmd = findCommand(mem0, "dream")!;
|
|
|
|
await dreamCmd._action!({});
|
|
|
|
expect(consoleSpy.log).toHaveBeenCalledWith(
|
|
"No memories to consolidate.",
|
|
);
|
|
});
|
|
|
|
it("prints error when dream skill file is not found", async () => {
|
|
const { mem0, provider } = setup();
|
|
provider.getAll.mockResolvedValueOnce([
|
|
{ id: "m1", memory: "test", categories: [], metadata: {}, created_at: "2026-01-01" },
|
|
]);
|
|
(loadDreamPrompt as ReturnType<typeof vi.fn>).mockReturnValueOnce("");
|
|
const dreamCmd = findCommand(mem0, "dream")!;
|
|
|
|
await dreamCmd._action!({});
|
|
|
|
expect(stderrSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("Dream skill file not found"),
|
|
);
|
|
});
|
|
|
|
it("handles dream errors gracefully", async () => {
|
|
const { mem0, provider } = setup();
|
|
provider.getAll.mockRejectedValueOnce(new Error("dream boom"));
|
|
const dreamCmd = findCommand(mem0, "dream")!;
|
|
|
|
await dreamCmd._action!({});
|
|
|
|
expect(consoleSpy.error).toHaveBeenCalledWith(
|
|
expect.stringContaining("Dream failed"),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ========================================================================
|
|
// import subcommand
|
|
// ========================================================================
|
|
|
|
describe("import subcommand", () => {
|
|
it("imports memories from a JSON array file", async () => {
|
|
const { mem0, backend } = setup();
|
|
const { readText } = await import("../fs-safe.ts");
|
|
(readText as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
|
JSON.stringify([
|
|
{ memory: "fact one" },
|
|
{ memory: "fact two" },
|
|
]),
|
|
);
|
|
const importCmd = findCommand(mem0, "import")!;
|
|
|
|
await importCmd._action!("memories.json", {});
|
|
|
|
expect(backend.add).toHaveBeenCalledTimes(2);
|
|
expect(consoleSpy.log).toHaveBeenCalledWith("Imported 2 memories.");
|
|
});
|
|
|
|
it("imports a single JSON object", async () => {
|
|
const { mem0, backend } = setup();
|
|
const { readText } = await import("../fs-safe.ts");
|
|
(readText as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
|
JSON.stringify({ memory: "single fact" }),
|
|
);
|
|
const importCmd = findCommand(mem0, "import")!;
|
|
|
|
await importCmd._action!("single.json", {});
|
|
|
|
expect(backend.add).toHaveBeenCalledTimes(1);
|
|
expect(consoleSpy.log).toHaveBeenCalledWith("Imported 1 memories.");
|
|
});
|
|
|
|
it("skips items with no extractable content", async () => {
|
|
const { mem0, backend } = setup();
|
|
const { readText } = await import("../fs-safe.ts");
|
|
(readText as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
|
JSON.stringify([{ memory: "valid" }, { nofield: true }]),
|
|
);
|
|
const importCmd = findCommand(mem0, "import")!;
|
|
|
|
await importCmd._action!("mixed.json", {});
|
|
|
|
expect(backend.add).toHaveBeenCalledTimes(1);
|
|
expect(consoleSpy.error).toHaveBeenCalledWith("1 memories failed to import.");
|
|
});
|
|
|
|
it("uses --user-id and --agent-id overrides", async () => {
|
|
const { mem0, backend } = setup();
|
|
const { readText } = await import("../fs-safe.ts");
|
|
(readText as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
|
JSON.stringify([{ memory: "fact" }]),
|
|
);
|
|
const importCmd = findCommand(mem0, "import")!;
|
|
|
|
await importCmd._action!("f.json", { userId: "override-user", agentId: "agent-1" });
|
|
|
|
expect(backend.add).toHaveBeenCalledWith("fact", undefined, expect.objectContaining({
|
|
userId: "override-user",
|
|
agentId: "agent-1",
|
|
}));
|
|
});
|
|
|
|
it("handles file read errors gracefully", async () => {
|
|
const { mem0 } = setup();
|
|
const { readText } = await import("../fs-safe.ts");
|
|
(readText as ReturnType<typeof vi.fn>).mockImplementationOnce(() => {
|
|
throw new Error("ENOENT");
|
|
});
|
|
const importCmd = findCommand(mem0, "import")!;
|
|
|
|
await importCmd._action!("missing.json", {});
|
|
|
|
expect(consoleSpy.error).toHaveBeenCalledWith(
|
|
expect.stringContaining("Failed to read file"),
|
|
);
|
|
});
|
|
|
|
it("handles backend.add failures gracefully", async () => {
|
|
const { mem0, backend } = setup();
|
|
const { readText } = await import("../fs-safe.ts");
|
|
(readText as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
|
JSON.stringify([{ memory: "will fail" }]),
|
|
);
|
|
backend.add.mockRejectedValueOnce(new Error("API error"));
|
|
const importCmd = findCommand(mem0, "import")!;
|
|
|
|
await importCmd._action!("fail.json", {});
|
|
|
|
expect(consoleSpy.log).toHaveBeenCalledWith("Imported 0 memories.");
|
|
expect(consoleSpy.error).toHaveBeenCalledWith("1 memories failed to import.");
|
|
});
|
|
});
|
|
|
|
// ========================================================================
|
|
// event subcommand
|
|
// ========================================================================
|
|
|
|
describe("event subcommand", () => {
|
|
it("lists events in table format", async () => {
|
|
const { mem0, backend } = setup();
|
|
const eventCmd = findCommand(mem0, "event")!;
|
|
const listCmd = findCommand(eventCmd, "list")!;
|
|
|
|
await listCmd._action!();
|
|
|
|
expect(backend.listEvents).toHaveBeenCalled();
|
|
expect(consoleSpy.log).toHaveBeenCalledWith(
|
|
expect.stringContaining("evt-1111-2222-3333-4444"),
|
|
);
|
|
expect(consoleSpy.log).toHaveBeenCalledWith(
|
|
expect.stringContaining("1 event"),
|
|
);
|
|
});
|
|
|
|
it("prints message when no events found", async () => {
|
|
const { mem0, backend } = setup();
|
|
backend.listEvents.mockResolvedValueOnce([]);
|
|
const eventCmd = findCommand(mem0, "event")!;
|
|
const listCmd = findCommand(eventCmd, "list")!;
|
|
|
|
await listCmd._action!();
|
|
|
|
expect(consoleSpy.log).toHaveBeenCalledWith("No events found.");
|
|
});
|
|
|
|
it("handles event list errors gracefully", async () => {
|
|
const { mem0, backend } = setup();
|
|
backend.listEvents.mockRejectedValueOnce(new Error("event boom"));
|
|
const eventCmd = findCommand(mem0, "event")!;
|
|
const listCmd = findCommand(eventCmd, "list")!;
|
|
|
|
await listCmd._action!();
|
|
|
|
expect(consoleSpy.error).toHaveBeenCalledWith(
|
|
expect.stringContaining("Failed to list events"),
|
|
);
|
|
});
|
|
|
|
it("shows event status details", async () => {
|
|
const { mem0, backend } = setup();
|
|
const eventCmd = findCommand(mem0, "event")!;
|
|
const statusCmd = findCommand(eventCmd, "status")!;
|
|
|
|
await statusCmd._action!("evt-1111-2222-3333-4444");
|
|
|
|
expect(backend.getEvent).toHaveBeenCalledWith("evt-1111-2222-3333-4444");
|
|
expect(consoleSpy.log).toHaveBeenCalledWith("Event ID: evt-1111-2222-3333-4444");
|
|
expect(consoleSpy.log).toHaveBeenCalledWith("Type: ADD");
|
|
expect(consoleSpy.log).toHaveBeenCalledWith("Status: SUCCEEDED");
|
|
});
|
|
|
|
it("handles event status errors gracefully", async () => {
|
|
const { mem0, backend } = setup();
|
|
backend.getEvent.mockRejectedValueOnce(new Error("not found"));
|
|
const eventCmd = findCommand(mem0, "event")!;
|
|
const statusCmd = findCommand(eventCmd, "status")!;
|
|
|
|
await statusCmd._action!("bad-id");
|
|
|
|
expect(consoleSpy.error).toHaveBeenCalledWith(
|
|
expect.stringContaining("Failed to get event"),
|
|
);
|
|
});
|
|
|
|
it("event list returns early in open-source mode", async () => {
|
|
const provider = createMockProvider();
|
|
const cfg = { ...createMockCfg(), mode: "open-source" as const };
|
|
const mockApi = {
|
|
registerCli: vi.fn((cb: any) => {
|
|
const root = createMockCommand("root");
|
|
cb({ program: root });
|
|
const mem0 = findCommand(root, "mem0")!;
|
|
const eventCmd = findCommand(mem0, "event")!;
|
|
const listCmd = findCommand(eventCmd, "list")!;
|
|
listCmd._action!();
|
|
}),
|
|
logger: { info: vi.fn(), warn: vi.fn() },
|
|
} as any;
|
|
|
|
registerCliCommands(
|
|
mockApi,
|
|
null as any,
|
|
provider as any,
|
|
cfg as any,
|
|
vi.fn().mockReturnValue("testuser"),
|
|
vi.fn((id: string) => `testuser:agent:${id}`),
|
|
vi.fn().mockReturnValue({ user_id: "testuser", top_k: 5 }),
|
|
vi.fn().mockReturnValue(undefined),
|
|
);
|
|
|
|
// Wait for async action
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
|
|
expect(consoleSpy.log).toHaveBeenCalledWith(
|
|
"Event tracking is only available in platform mode.",
|
|
);
|
|
});
|
|
|
|
it("event status returns early in open-source mode", async () => {
|
|
const provider = createMockProvider();
|
|
const cfg = { ...createMockCfg(), mode: "open-source" as const };
|
|
const mockApi = {
|
|
registerCli: vi.fn((cb: any) => {
|
|
const root = createMockCommand("root");
|
|
cb({ program: root });
|
|
const mem0 = findCommand(root, "mem0")!;
|
|
const eventCmd = findCommand(mem0, "event")!;
|
|
const statusCmd = findCommand(eventCmd, "status")!;
|
|
statusCmd._action!("evt-123");
|
|
}),
|
|
logger: { info: vi.fn(), warn: vi.fn() },
|
|
} as any;
|
|
|
|
registerCliCommands(
|
|
mockApi,
|
|
null as any,
|
|
provider as any,
|
|
cfg as any,
|
|
vi.fn().mockReturnValue("testuser"),
|
|
vi.fn((id: string) => `testuser:agent:${id}`),
|
|
vi.fn().mockReturnValue({ user_id: "testuser", top_k: 5 }),
|
|
vi.fn().mockReturnValue(undefined),
|
|
);
|
|
|
|
// Wait for async action
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
|
|
expect(consoleSpy.log).toHaveBeenCalledWith(
|
|
"Event tracking is only available in platform mode.",
|
|
);
|
|
});
|
|
});
|
|
|
|
// ========================================================================
|
|
// Restructured init menu flags
|
|
// ========================================================================
|
|
|
|
describe("init — restructured menu", () => {
|
|
it("registers --mode flag", () => {
|
|
const { mem0 } = setup();
|
|
const initCmd = findCommand(mem0, "init")!;
|
|
const modeOpt = initCmd._options.find((o) => o.flags.includes("--mode"));
|
|
expect(modeOpt).toBeDefined();
|
|
});
|
|
|
|
it("registers --oss-llm flag", () => {
|
|
const { mem0 } = setup();
|
|
const initCmd = findCommand(mem0, "init")!;
|
|
const opt = initCmd._options.find((o) => o.flags.includes("--oss-llm "));
|
|
expect(opt).toBeDefined();
|
|
});
|
|
|
|
it("registers --json flag on init", () => {
|
|
const { mem0 } = setup();
|
|
const initCmd = findCommand(mem0, "init")!;
|
|
const opt = initCmd._options.find((o) => o.flags.includes("--json"));
|
|
expect(opt).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ========================================================================
|
|
// --json flag registration on all commands
|
|
// ========================================================================
|
|
|
|
describe("--json flag registration", () => {
|
|
for (const name of ["search", "add", "get", "list", "update", "delete", "status", "import", "dream"]) {
|
|
it(`registers --json on ${name}`, () => {
|
|
const { mem0 } = setup();
|
|
const cmd = findCommand(mem0, name)!;
|
|
const opt = cmd._options.find((o) => o.flags.includes("--json"));
|
|
expect(opt).toBeDefined();
|
|
});
|
|
}
|
|
|
|
it("registers --json on config show", () => {
|
|
const { mem0 } = setup();
|
|
const configCmd = findCommand(mem0, "config")!;
|
|
const showCmd = findCommand(configCmd, "show")!;
|
|
expect(showCmd).toBeDefined();
|
|
const opt = showCmd._options.find((o) => o.flags.includes("--json"));
|
|
expect(opt).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ========================================================================
|
|
// Non-interactive OSS init
|
|
// ========================================================================
|
|
|
|
describe("init --mode open-source (non-interactive)", () => {
|
|
it("writes LLM, embedder, and vector config for ollama + qdrant", async () => {
|
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }));
|
|
|
|
const { mem0 } = setup();
|
|
const initCmd = findCommand(mem0, "init")!;
|
|
|
|
await initCmd._action!({
|
|
mode: "open-source",
|
|
ossLlm: "ollama",
|
|
ossEmbedder: "ollama",
|
|
ossVector: "qdrant",
|
|
userId: "test-user",
|
|
});
|
|
|
|
expect(writePluginConfigField).toHaveBeenCalledWith(
|
|
["oss", "llm"],
|
|
expect.objectContaining({ provider: "ollama" }),
|
|
);
|
|
expect(writePluginConfigField).toHaveBeenCalledWith(
|
|
["oss", "embedder"],
|
|
expect.objectContaining({ provider: "ollama" }),
|
|
);
|
|
expect(writePluginConfigField).toHaveBeenCalledWith(
|
|
["oss", "vectorStore"],
|
|
expect.objectContaining({ provider: "qdrant" }),
|
|
);
|
|
expect(writePluginAuth).toHaveBeenCalledWith(
|
|
expect.objectContaining({ mode: "open-source", userId: "test-user" }),
|
|
);
|
|
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("outputs JSON when --json is passed", async () => {
|
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }));
|
|
|
|
const { mem0 } = setup();
|
|
const initCmd = findCommand(mem0, "init")!;
|
|
|
|
await initCmd._action!({
|
|
mode: "open-source",
|
|
ossLlm: "ollama",
|
|
ossEmbedder: "ollama",
|
|
ossVector: "qdrant",
|
|
json: true,
|
|
});
|
|
|
|
const jsonCall = stdoutSpy.mock.calls.find((c) => {
|
|
try {
|
|
const p = JSON.parse(c[0] as string);
|
|
return p.ok === true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
expect(jsonCall).toBeDefined();
|
|
if (jsonCall) {
|
|
const parsed = JSON.parse(jsonCall[0] as string);
|
|
expect(parsed.mode).toBe("open-source");
|
|
expect(parsed.config.llm.provider).toBe("ollama");
|
|
}
|
|
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("errors when openai LLM has no key", async () => {
|
|
const { mem0 } = setup();
|
|
const initCmd = findCommand(mem0, "init")!;
|
|
|
|
vi.stubEnv("OPENAI_API_KEY", "");
|
|
|
|
await initCmd._action!({ mode: "open-source", ossLlm: "openai" });
|
|
|
|
expect(consoleSpy.error).toHaveBeenCalledWith(
|
|
expect.stringContaining("--oss-llm-key"),
|
|
);
|
|
|
|
vi.unstubAllEnvs();
|
|
});
|
|
});
|
|
});
|