chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Parity tests for `mem0 init --agent` (Agent Mode bootstrap).
|
||||
*
|
||||
* Mirror of `cli/python/tests/test_agent_mode.py` — both files MUST stay
|
||||
* in sync so that the Python and Node CLIs expose an identical surface
|
||||
* for the Agent Mode entrypoint. If you add a flag here, add the same
|
||||
* assertion on the Python side (and vice versa).
|
||||
*
|
||||
* Network-bound bootstrap is covered by the platform-side E2E suite
|
||||
* (`backend/tests/e2e/test_05_agent_mode.py`); these tests only verify
|
||||
* the CLI surface that ships in the binary.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { execSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
function run(
|
||||
args: string[],
|
||||
opts: { home?: string; env?: Record<string, string> } = {},
|
||||
): { stdout: string; stderr: string; exitCode: number } {
|
||||
const env = { ...process.env };
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key.startsWith("MEM0_")) delete env[key];
|
||||
}
|
||||
if (opts.home) env.HOME = opts.home;
|
||||
if (opts.env) Object.assign(env, opts.env);
|
||||
|
||||
try {
|
||||
const stdout = execSync(`npx tsx src/index.ts ${args.join(" ")}`, {
|
||||
cwd: path.join(__dirname, ".."),
|
||||
env,
|
||||
encoding: "utf-8",
|
||||
timeout: 15000,
|
||||
});
|
||||
return { stdout, stderr: "", exitCode: 0 };
|
||||
} catch (e: any) {
|
||||
return {
|
||||
stdout: e.stdout ?? "",
|
||||
stderr: e.stderr ?? "",
|
||||
exitCode: e.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function cleanHome(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "mem0-test-"));
|
||||
}
|
||||
|
||||
describe("init flag surface", () => {
|
||||
it("init --help lists --agent", () => {
|
||||
const result = run(["init", "--help"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("--agent");
|
||||
});
|
||||
|
||||
it("init --help describes Agent Mode", () => {
|
||||
const result = run(["init", "--help"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
// Description must mention what --agent actually does so an agent
|
||||
// reading the help can self-discover the bootstrap entrypoint.
|
||||
expect(
|
||||
result.stdout.includes("Agent Mode") ||
|
||||
result.stdout.toLowerCase().includes("unattended"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("init --help lists --source", () => {
|
||||
const result = run(["init", "--help"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("--source");
|
||||
});
|
||||
|
||||
it("init --help lists --email and --code", () => {
|
||||
const result = run(["init", "--help"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("--email");
|
||||
expect(result.stdout).toContain("--code");
|
||||
});
|
||||
});
|
||||
|
||||
describe("argv preprocessing — --agent reaches init subcommand", () => {
|
||||
// Regression for the bug where the global --agent JSON-alias swallowed
|
||||
// the init-level --agent flag, making `mem0 init --agent` behave like
|
||||
// the plain interactive wizard.
|
||||
|
||||
it("init --agent triggers bootstrap branch (not the wizard)", () => {
|
||||
const home = cleanHome();
|
||||
const result = run(["init", "--agent"], {
|
||||
home,
|
||||
env: {
|
||||
MEM0_BASE_URL: "http://127.0.0.1:1", // blackhole
|
||||
FORCE_COLOR: "0",
|
||||
},
|
||||
});
|
||||
const combined = (result.stdout + result.stderr).toLowerCase();
|
||||
// Either bootstrap-attempt error, or a connection/network error —
|
||||
// both prove the --agent path executed (the wizard would prompt for
|
||||
// input and succeed/hang, not surface a network error).
|
||||
expect(
|
||||
combined.includes("agent") ||
|
||||
combined.includes("connect") ||
|
||||
combined.includes("network") ||
|
||||
combined.includes("fetch") ||
|
||||
combined.includes("bootstrap"),
|
||||
).toBe(true);
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("JSON envelope on network failure", () => {
|
||||
it("init --agent --json does not leak a stack trace when backend is unreachable", () => {
|
||||
const home = cleanHome();
|
||||
const result = run(["init", "--agent", "--json"], {
|
||||
home,
|
||||
env: {
|
||||
MEM0_BASE_URL: "http://127.0.0.1:1",
|
||||
FORCE_COLOR: "0",
|
||||
},
|
||||
});
|
||||
const combined = result.stdout + result.stderr;
|
||||
// No raw Node stack should escape the agent-mode handler.
|
||||
expect(combined).not.toMatch(/at \w+\s*\(.+\.ts:\d+/);
|
||||
expect(combined).not.toContain("UnhandledPromiseRejection");
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("top-level help lists init", () => {
|
||||
// `mem0 --help` must list `init` so agents walking the top-level help
|
||||
// can discover the Agent Mode entrypoint without prior knowledge.
|
||||
|
||||
it("--help lists init", () => {
|
||||
const result = run(["--help"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("init");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Tests for branding utilities.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
BRAND_COLOR,
|
||||
SUCCESS_COLOR,
|
||||
ERROR_COLOR,
|
||||
TAGLINE,
|
||||
LOGO_MINI,
|
||||
printSuccess,
|
||||
printError,
|
||||
printWarning,
|
||||
printInfo,
|
||||
printScope,
|
||||
} from "../src/branding.js";
|
||||
|
||||
let output: string;
|
||||
let errOutput: string;
|
||||
const originalLog = console.log;
|
||||
const originalError = console.error;
|
||||
|
||||
beforeEach(() => {
|
||||
output = "";
|
||||
errOutput = "";
|
||||
console.log = (...args: unknown[]) => {
|
||||
output += args.map(String).join(" ") + "\n";
|
||||
};
|
||||
console.error = (...args: unknown[]) => {
|
||||
errOutput += args.map(String).join(" ") + "\n";
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
console.log = originalLog;
|
||||
console.error = originalError;
|
||||
});
|
||||
|
||||
describe("branding constants", () => {
|
||||
it("has correct brand color", () => {
|
||||
expect(BRAND_COLOR).toBe("#8b5cf6");
|
||||
});
|
||||
|
||||
it("has correct tagline", () => {
|
||||
expect(TAGLINE).toBe("The Memory Layer for AI Agents");
|
||||
});
|
||||
|
||||
it("has correct logo mini", () => {
|
||||
expect(LOGO_MINI).toBe("◆ mem0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("printSuccess", () => {
|
||||
it("prints success message", () => {
|
||||
printSuccess("Operation completed");
|
||||
expect(output).toContain("Operation completed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("printError", () => {
|
||||
it("prints error message to stderr", () => {
|
||||
printError("Something failed");
|
||||
expect(errOutput).toContain("Something failed");
|
||||
});
|
||||
|
||||
it("prints hint when provided to stderr", () => {
|
||||
printError("Failed", "Try again");
|
||||
expect(errOutput).toContain("Try again");
|
||||
});
|
||||
});
|
||||
|
||||
describe("printWarning", () => {
|
||||
it("prints warning message to stderr", () => {
|
||||
printWarning("Be careful");
|
||||
expect(errOutput).toContain("Be careful");
|
||||
});
|
||||
});
|
||||
|
||||
describe("printInfo", () => {
|
||||
it("prints info message", () => {
|
||||
printInfo("Important note");
|
||||
expect(errOutput).toContain("Important note");
|
||||
});
|
||||
});
|
||||
|
||||
describe("printScope", () => {
|
||||
it("prints scope when IDs present", () => {
|
||||
printScope({ user_id: "alice", agent_id: "bot" });
|
||||
expect(errOutput).toContain("alice");
|
||||
expect(errOutput).toContain("bot");
|
||||
});
|
||||
|
||||
it("prints nothing when no IDs", () => {
|
||||
printScope({});
|
||||
expect(errOutput).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Integration tests — invoke CLI as subprocess to test end-to-end.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { execSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
function run(
|
||||
args: string[],
|
||||
opts: { home?: string; env?: Record<string, string> } = {},
|
||||
): { stdout: string; stderr: string; exitCode: number } {
|
||||
const env = { ...process.env };
|
||||
// Strip MEM0_ env vars
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key.startsWith("MEM0_")) delete env[key];
|
||||
}
|
||||
if (opts.home) env.HOME = opts.home;
|
||||
if (opts.env) Object.assign(env, opts.env);
|
||||
|
||||
try {
|
||||
const stdout = execSync(
|
||||
`npx tsx src/index.ts ${args.join(" ")}`,
|
||||
{ cwd: path.join(__dirname, ".."), env, encoding: "utf-8", timeout: 15000 },
|
||||
);
|
||||
return { stdout, stderr: "", exitCode: 0 };
|
||||
} catch (e: any) {
|
||||
return {
|
||||
stdout: e.stdout ?? "",
|
||||
stderr: e.stderr ?? "",
|
||||
exitCode: e.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe("CLI Integration — help and version", () => {
|
||||
it("shows help with --help", () => {
|
||||
const result = run(["--help"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("mem0");
|
||||
expect(result.stdout).toContain("add");
|
||||
expect(result.stdout).toContain("search");
|
||||
});
|
||||
|
||||
it("help --json produces valid JSON", () => {
|
||||
const result = run(["help", "--json"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
// spec may have cli.name or top-level name
|
||||
const name = parsed.name ?? parsed.cli?.name;
|
||||
expect(name).toBe("mem0");
|
||||
});
|
||||
|
||||
it("shows add help", () => {
|
||||
const result = run(["add", "--help"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("user-id");
|
||||
expect(result.stdout).toContain("messages");
|
||||
});
|
||||
|
||||
it("shows search help", () => {
|
||||
const result = run(["search", "--help"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("top-k");
|
||||
});
|
||||
|
||||
it("shows list help", () => {
|
||||
const result = run(["list", "--help"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("page-size");
|
||||
});
|
||||
|
||||
it("shows delete help with --all, --entity, --project", () => {
|
||||
const result = run(["delete", "--help"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("--all");
|
||||
expect(result.stdout).toContain("--entity");
|
||||
expect(result.stdout).toContain("--project");
|
||||
expect(result.stdout).toContain("--force");
|
||||
expect(result.stdout.toLowerCase()).toContain("memory");
|
||||
});
|
||||
|
||||
it("delete with no args errors", () => {
|
||||
const result = run(["delete"]);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
const combined = result.stdout + result.stderr;
|
||||
expect(combined).toContain("--all");
|
||||
});
|
||||
|
||||
it("shows entity list help", () => {
|
||||
const result = run(["entity", "list", "--help"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout.toLowerCase()).toContain("entitytype");
|
||||
});
|
||||
|
||||
it("shows entity delete help", () => {
|
||||
const result = run(["entity", "delete", "--help"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("--user-id");
|
||||
expect(result.stdout).toContain("--force");
|
||||
});
|
||||
|
||||
it("shows import help", () => {
|
||||
const result = run(["import", "--help"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("add help has --output flag", () => {
|
||||
const result = run(["add", "--help"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("--output");
|
||||
});
|
||||
|
||||
it("search help has --rerank flag", () => {
|
||||
const result = run(["search", "--help"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("--rerank");
|
||||
});
|
||||
|
||||
it("list help has --category flag", () => {
|
||||
const result = run(["list", "--help"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("--category");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI Integration — isolated (clean home)", () => {
|
||||
function cleanHome(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "mem0-test-"));
|
||||
}
|
||||
|
||||
it("add without API key errors", () => {
|
||||
const home = cleanHome();
|
||||
const result = run(["add", "test", "--user-id", "alice"], { home });
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
const combined = result.stdout + result.stderr;
|
||||
expect(combined.toLowerCase()).toMatch(/api.key|error/i);
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("config show works with clean home", () => {
|
||||
const home = cleanHome();
|
||||
const result = run(["config", "show"], { home });
|
||||
expect(result.exitCode).toBe(0);
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,466 @@
|
||||
/**
|
||||
* Tests for CLI commands using mock backend.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Command } from "commander";
|
||||
import { createMockBackend } from "./setup.js";
|
||||
import type { Backend } from "../src/backend/base.js";
|
||||
import { setAgentMode } from "../src/state.js";
|
||||
|
||||
let mockBackend: Backend;
|
||||
|
||||
// Capture console.log and console.error output
|
||||
let output: string;
|
||||
let errOutput: string;
|
||||
const originalLog = console.log;
|
||||
const originalError = console.error;
|
||||
|
||||
beforeEach(() => {
|
||||
mockBackend = createMockBackend();
|
||||
output = "";
|
||||
errOutput = "";
|
||||
console.log = (...args: unknown[]) => {
|
||||
output += args.map(String).join(" ") + "\n";
|
||||
};
|
||||
console.error = (...args: unknown[]) => {
|
||||
errOutput += args.map(String).join(" ") + "\n";
|
||||
};
|
||||
});
|
||||
|
||||
// Restore after each test
|
||||
import { afterEach } from "vitest";
|
||||
afterEach(() => {
|
||||
console.log = originalLog;
|
||||
console.error = originalError;
|
||||
setAgentMode(false);
|
||||
});
|
||||
|
||||
describe("cmdAdd", () => {
|
||||
it("adds text memory", async () => {
|
||||
const { cmdAdd } = await import("../src/commands/memory.js");
|
||||
await cmdAdd(mockBackend, "I prefer dark mode", {
|
||||
userId: "alice",
|
||||
immutable: false,
|
||||
output: "text",
|
||||
});
|
||||
expect(mockBackend.add).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("adds from messages JSON", async () => {
|
||||
const { cmdAdd } = await import("../src/commands/memory.js");
|
||||
await cmdAdd(mockBackend, undefined, {
|
||||
userId: "alice",
|
||||
messages: JSON.stringify([{ role: "user", content: "I love Python" }]),
|
||||
immutable: false,
|
||||
output: "text",
|
||||
});
|
||||
expect(mockBackend.add).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("outputs json format", async () => {
|
||||
const { cmdAdd } = await import("../src/commands/memory.js");
|
||||
await cmdAdd(mockBackend, "test", {
|
||||
userId: "alice",
|
||||
immutable: false,
|
||||
output: "json",
|
||||
});
|
||||
expect(output).toContain("results");
|
||||
});
|
||||
|
||||
it("quiet mode produces no memory content", async () => {
|
||||
const { cmdAdd } = await import("../src/commands/memory.js");
|
||||
await cmdAdd(mockBackend, "test", {
|
||||
userId: "alice",
|
||||
immutable: false,
|
||||
output: "quiet",
|
||||
});
|
||||
expect(output).not.toContain("dark mode");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdAdd forwards --no-infer (regression for #5261)", () => {
|
||||
it("forwards infer: false when --no-infer is set", async () => {
|
||||
const { cmdAdd } = await import("../src/commands/memory.js");
|
||||
// `infer: false` is the shape Commander produces for `--no-infer`.
|
||||
await cmdAdd(mockBackend, "store me verbatim", {
|
||||
userId: "alice",
|
||||
immutable: false,
|
||||
infer: false,
|
||||
output: "text",
|
||||
});
|
||||
expect(mockBackend.add).toHaveBeenCalledWith(
|
||||
"store me verbatim",
|
||||
undefined,
|
||||
expect.objectContaining({ infer: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards infer: true by default (flag absent)", async () => {
|
||||
const { cmdAdd } = await import("../src/commands/memory.js");
|
||||
await cmdAdd(mockBackend, "infer me", {
|
||||
userId: "alice",
|
||||
immutable: false,
|
||||
output: "text",
|
||||
});
|
||||
expect(mockBackend.add).toHaveBeenCalledWith(
|
||||
"infer me",
|
||||
undefined,
|
||||
expect.objectContaining({ infer: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("Commander stores --no-infer as opts.infer, not opts.noInfer", () => {
|
||||
// Pins the assumption the fix relies on: Commander's `--no-X` option
|
||||
// populates the positive camelCase key (`infer`), never `noInfer`.
|
||||
const withFlag = new Command();
|
||||
withFlag.option("--no-infer", "Skip inference, store raw.").action(() => {});
|
||||
withFlag.parse(["--no-infer"], { from: "user" });
|
||||
expect(withFlag.opts().infer).toBe(false);
|
||||
expect(withFlag.opts().noInfer).toBeUndefined();
|
||||
|
||||
const withoutFlag = new Command();
|
||||
withoutFlag.option("--no-infer", "Skip inference, store raw.").action(() => {});
|
||||
withoutFlag.parse([], { from: "user" });
|
||||
expect(withoutFlag.opts().infer).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdAdd deduplicates PENDING", () => {
|
||||
const DUPLICATE_PENDING = {
|
||||
results: [
|
||||
{ status: "PENDING", event_id: "evt-dup" },
|
||||
{ status: "PENDING", event_id: "evt-dup" },
|
||||
],
|
||||
};
|
||||
|
||||
it("text shows one pending block", async () => {
|
||||
(mockBackend.add as ReturnType<typeof vi.fn>).mockResolvedValue(DUPLICATE_PENDING);
|
||||
const { cmdAdd } = await import("../src/commands/memory.js");
|
||||
await cmdAdd(mockBackend, "test", {
|
||||
userId: "alice",
|
||||
immutable: false,
|
||||
output: "text",
|
||||
});
|
||||
expect(output.match(/Queued/g)?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("json shows one pending entry", async () => {
|
||||
(mockBackend.add as ReturnType<typeof vi.fn>).mockResolvedValue(DUPLICATE_PENDING);
|
||||
const { cmdAdd } = await import("../src/commands/memory.js");
|
||||
await cmdAdd(mockBackend, "test", {
|
||||
userId: "alice",
|
||||
immutable: false,
|
||||
output: "json",
|
||||
});
|
||||
const data = JSON.parse(output);
|
||||
const pending = data.results.filter((r: Record<string, unknown>) => r.status === "PENDING");
|
||||
expect(pending).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("agent shows one pending entry", async () => {
|
||||
(mockBackend.add as ReturnType<typeof vi.fn>).mockResolvedValue(DUPLICATE_PENDING);
|
||||
setAgentMode(true);
|
||||
const { cmdAdd } = await import("../src/commands/memory.js");
|
||||
await cmdAdd(mockBackend, "test", {
|
||||
userId: "alice",
|
||||
immutable: false,
|
||||
output: "agent",
|
||||
});
|
||||
const data = JSON.parse(output);
|
||||
expect(data.count).toBe(1);
|
||||
expect(data.data).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdSearch", () => {
|
||||
it("searches and shows results in text mode", async () => {
|
||||
const { cmdSearch } = await import("../src/commands/memory.js");
|
||||
await cmdSearch(mockBackend, "preferences", {
|
||||
userId: "alice",
|
||||
topK: 10,
|
||||
threshold: 0.3,
|
||||
rerank: false,
|
||||
keyword: false,
|
||||
|
||||
output: "text",
|
||||
});
|
||||
expect(output).toContain("Found 2");
|
||||
});
|
||||
|
||||
it("outputs json format", async () => {
|
||||
const { cmdSearch } = await import("../src/commands/memory.js");
|
||||
await cmdSearch(mockBackend, "preferences", {
|
||||
userId: "alice",
|
||||
topK: 10,
|
||||
threshold: 0.3,
|
||||
rerank: false,
|
||||
keyword: false,
|
||||
|
||||
output: "json",
|
||||
});
|
||||
expect(output).toContain("memory");
|
||||
});
|
||||
|
||||
it("shows no results message", async () => {
|
||||
(mockBackend.search as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
const { cmdSearch } = await import("../src/commands/memory.js");
|
||||
await cmdSearch(mockBackend, "nonexistent", {
|
||||
userId: "alice",
|
||||
topK: 10,
|
||||
threshold: 0.3,
|
||||
rerank: false,
|
||||
keyword: false,
|
||||
|
||||
output: "text",
|
||||
});
|
||||
expect(errOutput).toContain("No memories found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdGet", () => {
|
||||
it("gets memory in text mode", async () => {
|
||||
const { cmdGet } = await import("../src/commands/memory.js");
|
||||
await cmdGet(mockBackend, "abc-123-def-456", { output: "text" });
|
||||
expect(output).toContain("dark mode");
|
||||
});
|
||||
|
||||
it("gets memory in json mode", async () => {
|
||||
const { cmdGet } = await import("../src/commands/memory.js");
|
||||
await cmdGet(mockBackend, "abc-123-def-456", { output: "json" });
|
||||
expect(output).toContain("memory");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdList", () => {
|
||||
it("lists in table mode", async () => {
|
||||
const { cmdList } = await import("../src/commands/memory.js");
|
||||
await cmdList(mockBackend, {
|
||||
userId: "alice",
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
|
||||
output: "table",
|
||||
});
|
||||
expect(output).toContain("dark mode");
|
||||
});
|
||||
|
||||
it("shows empty message", async () => {
|
||||
(mockBackend.listMemories as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
const { cmdList } = await import("../src/commands/memory.js");
|
||||
await cmdList(mockBackend, {
|
||||
userId: "alice",
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
|
||||
output: "text",
|
||||
});
|
||||
expect(errOutput).toContain("No memories found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdUpdate", () => {
|
||||
it("updates memory", async () => {
|
||||
const { cmdUpdate } = await import("../src/commands/memory.js");
|
||||
await cmdUpdate(mockBackend, "abc-123", "New text", { output: "text" });
|
||||
expect(output.toLowerCase()).toContain("updated");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdDelete", () => {
|
||||
it("deletes memory", async () => {
|
||||
const { cmdDelete } = await import("../src/commands/memory.js");
|
||||
await cmdDelete(mockBackend, "abc-123", { output: "text" });
|
||||
expect(output.toLowerCase()).toContain("deleted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdDeleteAll", () => {
|
||||
it("deletes all with force", async () => {
|
||||
const { cmdDeleteAll } = await import("../src/commands/memory.js");
|
||||
await cmdDeleteAll(mockBackend, {
|
||||
force: true,
|
||||
userId: "alice",
|
||||
output: "text",
|
||||
});
|
||||
expect(output.toLowerCase()).toContain("deleted");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("cmdEntitiesList", () => {
|
||||
it("lists users in table mode", async () => {
|
||||
const { cmdEntitiesList } = await import("../src/commands/entities.js");
|
||||
await cmdEntitiesList(mockBackend, "users", { output: "table" });
|
||||
expect(output).toContain("alice");
|
||||
});
|
||||
|
||||
it("lists in json mode", async () => {
|
||||
const { cmdEntitiesList } = await import("../src/commands/entities.js");
|
||||
await cmdEntitiesList(mockBackend, "users", { output: "json" });
|
||||
expect(output).toContain("alice");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdEventList", () => {
|
||||
it("lists events in table mode", async () => {
|
||||
const { cmdEventList } = await import("../src/commands/events.js");
|
||||
await cmdEventList(mockBackend, { output: "table" });
|
||||
expect(output).toContain("evt-abc-");
|
||||
expect(output).toContain("ADD");
|
||||
expect(output).toContain("SUCCEEDED");
|
||||
});
|
||||
|
||||
it("lists events in json mode", async () => {
|
||||
const { cmdEventList } = await import("../src/commands/events.js");
|
||||
await cmdEventList(mockBackend, { output: "json" });
|
||||
expect(output).toContain("evt-abc-123-def-456");
|
||||
expect(output).toContain("evt-def-456-ghi-789");
|
||||
});
|
||||
|
||||
it("shows empty message when no events", async () => {
|
||||
(mockBackend.listEvents as ReturnType<typeof vi.fn>).mockResolvedValueOnce([]);
|
||||
const { cmdEventList } = await import("../src/commands/events.js");
|
||||
await cmdEventList(mockBackend, { output: "table" });
|
||||
expect((output + errOutput).toLowerCase()).toContain("no events");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdEventStatus", () => {
|
||||
it("shows event details in text mode", async () => {
|
||||
const { cmdEventStatus } = await import("../src/commands/events.js");
|
||||
await cmdEventStatus(mockBackend, "evt-abc-123-def-456", { output: "text" });
|
||||
expect(output).toContain("evt-abc-123-def-456");
|
||||
expect(output).toContain("SUCCEEDED");
|
||||
});
|
||||
|
||||
it("shows event details in json mode", async () => {
|
||||
const { cmdEventStatus } = await import("../src/commands/events.js");
|
||||
await cmdEventStatus(mockBackend, "evt-abc-123-def-456", { output: "json" });
|
||||
expect(output).toContain("evt-abc-123-def-456");
|
||||
expect(output).toContain("ADD");
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent mode", () => {
|
||||
it("cmdAdd outputs JSON envelope", async () => {
|
||||
setAgentMode(true);
|
||||
const { cmdAdd } = await import("../src/commands/memory.js");
|
||||
await cmdAdd(mockBackend, "test preference", {
|
||||
userId: "alice",
|
||||
immutable: false,
|
||||
output: "agent",
|
||||
});
|
||||
const parsed = JSON.parse(output.trim());
|
||||
expect(parsed.status).toBe("success");
|
||||
expect(parsed.command).toBe("add");
|
||||
expect(parsed.data).toBeDefined();
|
||||
expect(parsed.scope).toMatchObject({ user_id: "alice" });
|
||||
expect(Object.keys(parsed.data[0]).sort()).toEqual(["event", "id", "memory"].sort());
|
||||
});
|
||||
|
||||
it("cmdSearch outputs JSON envelope", async () => {
|
||||
setAgentMode(true);
|
||||
const { cmdSearch } = await import("../src/commands/memory.js");
|
||||
await cmdSearch(mockBackend, "preferences", {
|
||||
userId: "alice",
|
||||
topK: 10,
|
||||
threshold: 0.3,
|
||||
rerank: false,
|
||||
keyword: false,
|
||||
|
||||
output: "agent",
|
||||
});
|
||||
const parsed = JSON.parse(output.trim());
|
||||
expect(parsed.status).toBe("success");
|
||||
expect(parsed.command).toBe("search");
|
||||
expect(Array.isArray(parsed.data)).toBe(true);
|
||||
expect(parsed.count).toBe(2);
|
||||
const keys = Object.keys(parsed.data[0]);
|
||||
expect(keys).toContain("id");
|
||||
expect(keys).toContain("memory");
|
||||
expect(keys).toContain("score");
|
||||
expect(keys).toContain("created_at");
|
||||
expect(keys).toContain("categories");
|
||||
expect(keys).not.toContain("user_id");
|
||||
expect(keys).not.toContain("agent_id");
|
||||
});
|
||||
|
||||
it("cmdList outputs JSON envelope", async () => {
|
||||
setAgentMode(true);
|
||||
const { cmdList } = await import("../src/commands/memory.js");
|
||||
await cmdList(mockBackend, {
|
||||
userId: "alice",
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
|
||||
output: "agent",
|
||||
});
|
||||
const parsed = JSON.parse(output.trim());
|
||||
expect(parsed.status).toBe("success");
|
||||
expect(parsed.command).toBe("list");
|
||||
expect(Array.isArray(parsed.data)).toBe(true);
|
||||
expect(parsed.count).toBe(2);
|
||||
expect(Object.keys(parsed.data[0]).sort()).toEqual(["categories", "created_at", "id", "memory"]);
|
||||
});
|
||||
|
||||
it("cmdGet outputs JSON envelope", async () => {
|
||||
setAgentMode(true);
|
||||
const { cmdGet } = await import("../src/commands/memory.js");
|
||||
await cmdGet(mockBackend, "abc-123-def-456", { output: "agent" });
|
||||
const parsed = JSON.parse(output.trim());
|
||||
expect(parsed.status).toBe("success");
|
||||
expect(parsed.command).toBe("get");
|
||||
expect(parsed.data).toBeDefined();
|
||||
expect(parsed.data).toMatchObject({ id: "abc-123-def-456" });
|
||||
expect(Object.keys(parsed.data)).not.toContain("user_id");
|
||||
});
|
||||
|
||||
it("cmdUpdate outputs JSON envelope", async () => {
|
||||
setAgentMode(true);
|
||||
const { cmdUpdate } = await import("../src/commands/memory.js");
|
||||
await cmdUpdate(mockBackend, "abc-123", "Updated text", { output: "agent" });
|
||||
const parsed = JSON.parse(output.trim());
|
||||
expect(parsed.status).toBe("success");
|
||||
expect(parsed.command).toBe("update");
|
||||
expect(parsed.data).toBeDefined();
|
||||
});
|
||||
|
||||
it("cmdDelete outputs JSON envelope", async () => {
|
||||
setAgentMode(true);
|
||||
const { cmdDelete } = await import("../src/commands/memory.js");
|
||||
await cmdDelete(mockBackend, "abc-123", { output: "agent" });
|
||||
const parsed = JSON.parse(output.trim());
|
||||
expect(parsed.status).toBe("success");
|
||||
expect(parsed.command).toBe("delete");
|
||||
expect(parsed.data).toBeDefined();
|
||||
});
|
||||
|
||||
it("cmdEventList outputs JSON envelope", async () => {
|
||||
setAgentMode(true);
|
||||
const { cmdEventList } = await import("../src/commands/events.js");
|
||||
await cmdEventList(mockBackend, { output: "agent" });
|
||||
const parsed = JSON.parse(output.trim());
|
||||
expect(parsed.status).toBe("success");
|
||||
expect(parsed.command).toBe("event list");
|
||||
expect(Array.isArray(parsed.data)).toBe(true);
|
||||
expect(parsed.count).toBe(2);
|
||||
expect(Object.keys(parsed.data[0]).sort()).toEqual(
|
||||
["created_at", "event_type", "id", "latency", "status"],
|
||||
);
|
||||
expect(Object.keys(parsed.data[0])).not.toContain("updated_at");
|
||||
});
|
||||
|
||||
it("cmdEventStatus outputs JSON envelope", async () => {
|
||||
setAgentMode(true);
|
||||
const { cmdEventStatus } = await import("../src/commands/events.js");
|
||||
await cmdEventStatus(mockBackend, "evt-abc-123-def-456", { output: "agent" });
|
||||
const parsed = JSON.parse(output.trim());
|
||||
expect(parsed.status).toBe("success");
|
||||
expect(parsed.command).toBe("event status");
|
||||
expect(parsed.data).toBeDefined();
|
||||
expect(parsed.data).toMatchObject({ id: "evt-abc-123-def-456" });
|
||||
expect(parsed.data.results[0]).toHaveProperty("memory");
|
||||
expect(parsed.data.results[0]).not.toHaveProperty("data");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Tests for configuration management.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
createDefaultConfig,
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
redactKey,
|
||||
getNestedValue,
|
||||
setNestedValue,
|
||||
CONFIG_DIR,
|
||||
CONFIG_FILE,
|
||||
} from "../src/config.js";
|
||||
|
||||
// Use a temp directory for config during tests
|
||||
let origConfigDir: string;
|
||||
let origConfigFile: string;
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-test-"));
|
||||
// Monkey-patch the module-level constants
|
||||
// We'll use env vars and direct file manipulation instead
|
||||
// Clear MEM0_ env vars
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (key.startsWith("MEM0_")) {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("redactKey", () => {
|
||||
it("returns '(not set)' for empty key", () => {
|
||||
expect(redactKey("")).toBe("(not set)");
|
||||
});
|
||||
|
||||
it("redacts short key", () => {
|
||||
expect(redactKey("abc")).toBe("ab***");
|
||||
});
|
||||
|
||||
it("redacts normal key", () => {
|
||||
const result = redactKey("m0-abcdefgh12345678");
|
||||
expect(result).toBe("m0-a...5678");
|
||||
expect(result).not.toContain("abcdefgh");
|
||||
});
|
||||
|
||||
it("redacts exactly 8-char key as short", () => {
|
||||
expect(redactKey("12345678")).toBe("12***");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDefaultConfig", () => {
|
||||
it("has correct defaults", () => {
|
||||
const config = createDefaultConfig();
|
||||
expect(config.platform.baseUrl).toBe("https://api.mem0.ai");
|
||||
expect(config.platform.apiKey).toBe("");
|
||||
expect(config.defaults.userId).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNestedValue", () => {
|
||||
it("gets platform.api_key", () => {
|
||||
const config = createDefaultConfig();
|
||||
config.platform.apiKey = "test-key";
|
||||
expect(getNestedValue(config, "platform.api_key")).toBe("test-key");
|
||||
});
|
||||
|
||||
it("returns undefined for nonexistent key", () => {
|
||||
const config = createDefaultConfig();
|
||||
expect(getNestedValue(config, "nonexistent.key")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gets defaults.user_id", () => {
|
||||
const config = createDefaultConfig();
|
||||
config.defaults.userId = "alice";
|
||||
expect(getNestedValue(config, "defaults.user_id")).toBe("alice");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setNestedValue", () => {
|
||||
it("sets platform.api_key", () => {
|
||||
const config = createDefaultConfig();
|
||||
expect(setNestedValue(config, "platform.api_key", "new-key")).toBe(true);
|
||||
expect(config.platform.apiKey).toBe("new-key");
|
||||
});
|
||||
|
||||
it("returns false for nonexistent key", () => {
|
||||
const config = createDefaultConfig();
|
||||
expect(setNestedValue(config, "nonexistent.key", "val")).toBe(false);
|
||||
});
|
||||
|
||||
it("sets defaults.user_id", () => {
|
||||
const config = createDefaultConfig();
|
||||
expect(setNestedValue(config, "defaults.user_id", "bob")).toBe(true);
|
||||
expect(config.defaults.userId).toBe("bob");
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Unit tests for init internals — decision tree primitives + plugin sync.
|
||||
*
|
||||
* Mirror of `cli/python/tests/test_init_internals.py`. Both files MUST stay
|
||||
* in sync — if you add a behavioral assertion here, mirror it on the Python
|
||||
* side and vice versa.
|
||||
*
|
||||
* - `pingKey` must NOT treat network errors as "invalid key" (else a VPN
|
||||
* flap silently mints a new shadow over a working key).
|
||||
* - `plugin_sync` must only update entries that already exist, preserve
|
||||
* trailing newlines, and never mangle other lines.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { pingKey } from "../src/commands/init.js";
|
||||
import { updateClaudeSettings, updateShellRc } from "../src/plugin-sync.js";
|
||||
|
||||
// ── pingKey ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("pingKey — network vs auth distinction", () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = origFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns true for 200", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ status: 200 } as Response);
|
||||
await expect(pingKey("k", "http://x")).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for 401 (definitively invalid)", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ status: 401 } as Response);
|
||||
await expect(pingKey("k", "http://x")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for 403 (definitively invalid)", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ status: 403 } as Response);
|
||||
await expect(pingKey("k", "http://x")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for 5xx (transient upstream — prefer reuse)", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ status: 503 } as Response);
|
||||
await expect(pingKey("k", "http://x")).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("returns true on network error (prefer reuse over re-mint)", async () => {
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
|
||||
await expect(pingKey("k", "http://x")).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("returns true on timeout (prefer reuse)", async () => {
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new Error("aborted"));
|
||||
await expect(pingKey("k", "http://x")).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateShellRc ────────────────────────────────────────────────────────
|
||||
|
||||
describe("updateShellRc — exists-only contract", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-test-"));
|
||||
});
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("updates existing export and preserves trailing newline", () => {
|
||||
const rc = path.join(tmpDir, ".zshrc");
|
||||
fs.writeFileSync(rc, 'export MEM0_API_KEY="old"\n');
|
||||
expect(updateShellRc(rc, "newkey")).toBe(true);
|
||||
expect(fs.readFileSync(rc, "utf-8")).toBe('export MEM0_API_KEY="newkey"\n');
|
||||
});
|
||||
|
||||
it("does NOT create a new export when none exists", () => {
|
||||
const rc = path.join(tmpDir, ".zshrc");
|
||||
fs.writeFileSync(rc, "alias ll='ls -la'\n");
|
||||
expect(updateShellRc(rc, "newkey")).toBe(false);
|
||||
expect(fs.readFileSync(rc, "utf-8")).toBe("alias ll='ls -la'\n");
|
||||
});
|
||||
|
||||
it("preserves surrounding content", () => {
|
||||
const rc = path.join(tmpDir, ".zshrc");
|
||||
const original =
|
||||
"# my zshrc\n" +
|
||||
"alias ll='ls -la'\n" +
|
||||
"export MEM0_API_KEY='old'\n" +
|
||||
"export OTHER=keepme\n";
|
||||
fs.writeFileSync(rc, original);
|
||||
updateShellRc(rc, "newkey");
|
||||
const after = fs.readFileSync(rc, "utf-8");
|
||||
expect(after).toContain("alias ll='ls -la'\n");
|
||||
expect(after).toContain("export OTHER=keepme\n");
|
||||
expect(after).toContain("# my zshrc\n");
|
||||
expect(after).toContain('export MEM0_API_KEY="newkey"\n');
|
||||
});
|
||||
|
||||
it("is idempotent when value already matches", () => {
|
||||
const rc = path.join(tmpDir, ".zshrc");
|
||||
fs.writeFileSync(rc, 'export MEM0_API_KEY="same"\n');
|
||||
expect(updateShellRc(rc, "same")).toBe(false);
|
||||
});
|
||||
|
||||
it("is a no-op for missing files", () => {
|
||||
const rc = path.join(tmpDir, ".zshrc"); // does not exist
|
||||
expect(updateShellRc(rc, "x")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateClaudeSettings ─────────────────────────────────────────────────
|
||||
|
||||
describe("updateClaudeSettings — never creates entries", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-test-"));
|
||||
});
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("does not create env block when none exists", () => {
|
||||
const settings = path.join(tmpDir, "settings.json");
|
||||
fs.writeFileSync(settings, JSON.stringify({ otherKey: 1 }));
|
||||
expect(updateClaudeSettings(settings, "newkey")).toBe(false);
|
||||
expect(JSON.parse(fs.readFileSync(settings, "utf-8"))).toEqual({
|
||||
otherKey: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not create MEM0_API_KEY entry in existing env block", () => {
|
||||
const settings = path.join(tmpDir, "settings.json");
|
||||
fs.writeFileSync(settings, JSON.stringify({ env: { OTHER_KEY: "x" } }));
|
||||
expect(updateClaudeSettings(settings, "newkey")).toBe(false);
|
||||
});
|
||||
|
||||
it("updates existing entry and preserves siblings", () => {
|
||||
const settings = path.join(tmpDir, "settings.json");
|
||||
fs.writeFileSync(
|
||||
settings,
|
||||
JSON.stringify({ env: { MEM0_API_KEY: "old", OTHER: "y" } }, null, 2),
|
||||
);
|
||||
expect(updateClaudeSettings(settings, "fresh")).toBe(true);
|
||||
const data = JSON.parse(fs.readFileSync(settings, "utf-8"));
|
||||
expect(data.env.MEM0_API_KEY).toBe("fresh");
|
||||
expect(data.env.OTHER).toBe("y");
|
||||
});
|
||||
|
||||
it("is idempotent when value already matches", () => {
|
||||
const settings = path.join(tmpDir, "settings.json");
|
||||
fs.writeFileSync(
|
||||
settings,
|
||||
JSON.stringify({ env: { MEM0_API_KEY: "same" } }),
|
||||
);
|
||||
expect(updateClaudeSettings(settings, "same")).toBe(false);
|
||||
});
|
||||
|
||||
it("is a no-op for malformed JSON", () => {
|
||||
const settings = path.join(tmpDir, "settings.json");
|
||||
fs.writeFileSync(settings, "{ this is not json");
|
||||
expect(updateClaudeSettings(settings, "x")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Tests for output formatting.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
formatMemoriesText,
|
||||
formatMemoriesTable,
|
||||
formatJson,
|
||||
formatSingleMemory,
|
||||
formatAddResult,
|
||||
printResultSummary,
|
||||
sanitizeAgentData,
|
||||
} from "../src/output.js";
|
||||
|
||||
let output: string;
|
||||
const originalLog = console.log;
|
||||
|
||||
beforeEach(() => {
|
||||
output = "";
|
||||
console.log = (...args: unknown[]) => {
|
||||
output += args.map(String).join(" ") + "\n";
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
console.log = originalLog;
|
||||
});
|
||||
|
||||
const sampleMemories = [
|
||||
{
|
||||
id: "abc-123-def-456",
|
||||
memory: "User prefers dark mode",
|
||||
score: 0.92,
|
||||
created_at: "2026-02-15T10:30:00Z",
|
||||
categories: ["preferences"],
|
||||
},
|
||||
{
|
||||
id: "ghi-789-jkl-012",
|
||||
memory: "User uses vim keybindings",
|
||||
score: 0.78,
|
||||
created_at: "2026-03-01T14:00:00Z",
|
||||
categories: ["tools"],
|
||||
},
|
||||
];
|
||||
|
||||
describe("formatMemoriesText", () => {
|
||||
it("shows count and memory content", () => {
|
||||
formatMemoriesText(sampleMemories);
|
||||
expect(output).toContain("Found 2");
|
||||
expect(output).toContain("dark mode");
|
||||
expect(output).toContain("vim keybindings");
|
||||
});
|
||||
|
||||
it("shows scores and IDs", () => {
|
||||
formatMemoriesText(sampleMemories);
|
||||
expect(output).toContain("0.92");
|
||||
expect(output).toContain("abc-123-");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMemoriesTable", () => {
|
||||
it("renders a table with memory content", () => {
|
||||
formatMemoriesTable(sampleMemories);
|
||||
expect(output).toContain("dark mode");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatJson", () => {
|
||||
it("outputs valid JSON", () => {
|
||||
formatJson({ key: "value" });
|
||||
expect(JSON.parse(output)).toEqual({ key: "value" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSingleMemory", () => {
|
||||
it("shows memory text in text mode", () => {
|
||||
formatSingleMemory(sampleMemories[0], "text");
|
||||
expect(output).toContain("dark mode");
|
||||
});
|
||||
|
||||
it("outputs JSON in json mode", () => {
|
||||
formatSingleMemory(sampleMemories[0], "json");
|
||||
expect(output).toContain("memory");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatAddResult", () => {
|
||||
it("shows ADD event", () => {
|
||||
formatAddResult({
|
||||
results: [{ id: "abc-123", memory: "Test", event: "ADD" }],
|
||||
});
|
||||
expect(output).toContain("Added");
|
||||
});
|
||||
|
||||
it("shows PENDING event", () => {
|
||||
formatAddResult({
|
||||
results: [{ status: "PENDING", event_id: "evt-12345678" }],
|
||||
});
|
||||
expect(output).toContain("Queued");
|
||||
});
|
||||
|
||||
it("deduplicates PENDING entries with same event_id", () => {
|
||||
formatAddResult({
|
||||
results: [
|
||||
{ status: "PENDING", event_id: "evt-dup" },
|
||||
{ status: "PENDING", event_id: "evt-dup" },
|
||||
],
|
||||
});
|
||||
// Should show only one PENDING block despite two entries with same event_id
|
||||
expect(output.match(/Queued/g)?.length).toBe(1);
|
||||
expect(output.match(/evt-dup/g)?.length).toBe(2); // event_id line + status hint line
|
||||
});
|
||||
});
|
||||
|
||||
describe("printResultSummary", () => {
|
||||
it("shows count and duration", () => {
|
||||
printResultSummary({ count: 5, durationSecs: 1.23 });
|
||||
expect(output).toContain("5 results");
|
||||
expect(output).toContain("1.23s");
|
||||
});
|
||||
|
||||
it("handles singular", () => {
|
||||
printResultSummary({ count: 1 });
|
||||
expect(output).toContain("1 result");
|
||||
expect(output).not.toContain("results");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeAgentData", () => {
|
||||
it("projects add results", () => {
|
||||
const raw = [{ id: "abc", memory: "test", event: "ADD", metadata: { x: 1 }, categories: ["a"] }];
|
||||
const result = sanitizeAgentData("add", raw) as Record<string, unknown>[];
|
||||
expect(result).toEqual([{ id: "abc", memory: "test", event: "ADD" }]);
|
||||
});
|
||||
|
||||
it("passes through PENDING add items", () => {
|
||||
const raw = [{ status: "PENDING", event_id: "evt-123", noise: "x" }];
|
||||
const result = sanitizeAgentData("add", raw) as Record<string, unknown>[];
|
||||
expect(result).toEqual([{ status: "PENDING", event_id: "evt-123" }]);
|
||||
});
|
||||
|
||||
it("projects search results", () => {
|
||||
const raw = [{ id: "abc", memory: "test", score: 0.9, created_at: "2026-01-01", categories: ["a"], user_id: "u1" }];
|
||||
const result = sanitizeAgentData("search", raw) as Record<string, unknown>[];
|
||||
expect(result[0]).not.toHaveProperty("user_id");
|
||||
expect(result[0]).toHaveProperty("score");
|
||||
});
|
||||
|
||||
it("projects list results", () => {
|
||||
const raw = [{ id: "abc", memory: "test", created_at: "2026-01-01", categories: ["a"], user_id: "u1" }];
|
||||
const result = sanitizeAgentData("list", raw) as Record<string, unknown>[];
|
||||
expect(Object.keys(result[0]).sort()).toEqual(["categories", "created_at", "id", "memory"]);
|
||||
});
|
||||
|
||||
it("projects get result", () => {
|
||||
const raw = { id: "abc", memory: "test", created_at: "2026-01-01", updated_at: "2026-01-02", categories: ["a"], metadata: { k: "v" }, user_id: "u1" };
|
||||
const result = sanitizeAgentData("get", raw) as Record<string, unknown>;
|
||||
expect(result).not.toHaveProperty("user_id");
|
||||
expect(result).toHaveProperty("metadata");
|
||||
});
|
||||
|
||||
it("projects update result", () => {
|
||||
const raw = { id: "abc", memory: "updated", extra: "noise" };
|
||||
const result = sanitizeAgentData("update", raw);
|
||||
expect(result).toEqual({ id: "abc", memory: "updated" });
|
||||
});
|
||||
|
||||
it("projects event list results", () => {
|
||||
const raw = [{ id: "evt-1", event_type: "ADD", status: "SUCCEEDED", graph_status: null, latency: 100, created_at: "2026-01-01", updated_at: "2026-01-02" }];
|
||||
const result = sanitizeAgentData("event list", raw) as Record<string, unknown>[];
|
||||
expect(result[0]).not.toHaveProperty("updated_at");
|
||||
expect(result[0]).not.toHaveProperty("graph_status");
|
||||
});
|
||||
|
||||
it("flattens event status results", () => {
|
||||
const raw = {
|
||||
id: "evt-1", event_type: "ADD", status: "SUCCEEDED",
|
||||
latency: 100, created_at: "2026-01-01", updated_at: "2026-01-02",
|
||||
results: [{ id: "mem-1", event: "ADD", user_id: "alice", data: { memory: "dark mode" } }],
|
||||
};
|
||||
const result = sanitizeAgentData("event status", raw) as Record<string, unknown>;
|
||||
const firstResult = (result.results as Record<string, unknown>[])[0];
|
||||
expect(firstResult).toHaveProperty("memory", "dark mode");
|
||||
expect(firstResult).not.toHaveProperty("data");
|
||||
});
|
||||
|
||||
it("passes through status/config/import commands unchanged", () => {
|
||||
const data = { key: "value", other: "stuff" };
|
||||
for (const cmd of ["status", "import", "config show", "config get", "config set"]) {
|
||||
expect(sanitizeAgentData(cmd, data)).toEqual(data);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles null data", () => {
|
||||
expect(sanitizeAgentData("add", null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Tests for the Platform backend (mem0 Platform API client).
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PlatformBackend } from "../src/backend/platform.js";
|
||||
import { createDefaultConfig } from "../src/config.js";
|
||||
|
||||
function makeBackend(): PlatformBackend {
|
||||
// apiKey/baseUrl only build request headers; every test spies on _request,
|
||||
// so no real network calls are made.
|
||||
return new PlatformBackend(createDefaultConfig().platform);
|
||||
}
|
||||
|
||||
function mockFetch() {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: vi.fn().mockReturnValue(null) },
|
||||
json: vi.fn().mockResolvedValue({ message: "ok" }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("deleteEntities", () => {
|
||||
it("returns all results keyed by entity type for a multi-entity delete", async () => {
|
||||
const backend = makeBackend();
|
||||
const responses: Record<string, unknown> = {
|
||||
"/v2/entities/user/alice/": { message: "user deleted" },
|
||||
"/v2/entities/agent/bob/": { message: "agent deleted" },
|
||||
};
|
||||
const spy = vi
|
||||
// biome-ignore lint/suspicious/noExplicitAny: spying on a private method
|
||||
.spyOn(backend as any, "_request")
|
||||
.mockImplementation(async (_method: string, path: string) => responses[path]);
|
||||
|
||||
const result = await backend.deleteEntities({ userId: "alice", agentId: "bob" });
|
||||
|
||||
// Regression: previously only the last entity's response survived.
|
||||
expect(result).toEqual({
|
||||
user: { message: "user deleted" },
|
||||
agent: { message: "agent deleted" },
|
||||
});
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keys a single-entity delete by its type", async () => {
|
||||
const backend = makeBackend();
|
||||
// biome-ignore lint/suspicious/noExplicitAny: spying on a private method
|
||||
vi.spyOn(backend as any, "_request").mockResolvedValue({ message: "user deleted" });
|
||||
|
||||
const result = await backend.deleteEntities({ userId: "alice" });
|
||||
expect(result).toEqual({ user: { message: "user deleted" } });
|
||||
});
|
||||
|
||||
it("throws when no entity id is provided", async () => {
|
||||
const backend = makeBackend();
|
||||
await expect(backend.deleteEntities({})).rejects.toThrow(
|
||||
"At least one entity ID is required",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlatformBackend path encoding", () => {
|
||||
it("encodes memory IDs before interpolating them into paths", async () => {
|
||||
const fetchMock = mockFetch();
|
||||
const backend = makeBackend();
|
||||
|
||||
await backend.get("mem/a?b#c");
|
||||
await backend.update("mem/a?b#c", "updated");
|
||||
await backend.delete("mem/a?b#c");
|
||||
|
||||
const urls = fetchMock.mock.calls.map((call) => call[0]);
|
||||
expect(urls).toEqual([
|
||||
"https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/?source=CLI",
|
||||
"https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/",
|
||||
"https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/?source=CLI",
|
||||
]);
|
||||
});
|
||||
|
||||
it("encodes entity and event IDs before interpolating them into paths", async () => {
|
||||
const fetchMock = mockFetch();
|
||||
const backend = makeBackend();
|
||||
|
||||
await backend.deleteEntities({ userId: "org/team?active#frag" });
|
||||
await backend.getEvent("evt/a?b#c");
|
||||
|
||||
const urls = fetchMock.mock.calls.map((call) => call[0]);
|
||||
expect(urls).toEqual([
|
||||
"https://api.mem0.ai/v2/entities/user/org%2Fteam%3Factive%23frag/?source=CLI",
|
||||
"https://api.mem0.ai/v1/event/evt%2Fa%3Fb%23c/",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Shared test helpers and mock factories for mem0 CLI tests.
|
||||
*/
|
||||
|
||||
import { vi } from "vitest";
|
||||
import type { Backend } from "../src/backend/base.js";
|
||||
|
||||
/** Create a mock backend with all methods stubbed with sensible defaults. */
|
||||
export function createMockBackend(): Backend {
|
||||
return {
|
||||
add: vi.fn().mockResolvedValue({
|
||||
results: [
|
||||
{
|
||||
id: "abc-123-def-456",
|
||||
memory: "User prefers dark mode",
|
||||
event: "ADD",
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
search: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "abc-123-def-456",
|
||||
memory: "User prefers dark mode",
|
||||
score: 0.92,
|
||||
created_at: "2026-02-15T10:30:00Z",
|
||||
categories: ["preferences"],
|
||||
},
|
||||
{
|
||||
id: "ghi-789-jkl-012",
|
||||
memory: "User uses vim keybindings",
|
||||
score: 0.78,
|
||||
created_at: "2026-03-01T14:00:00Z",
|
||||
categories: ["tools"],
|
||||
},
|
||||
]),
|
||||
|
||||
get: vi.fn().mockResolvedValue({
|
||||
id: "abc-123-def-456",
|
||||
memory: "User prefers dark mode",
|
||||
created_at: "2026-02-15T10:30:00Z",
|
||||
updated_at: "2026-02-20T08:00:00Z",
|
||||
metadata: { source: "onboarding" },
|
||||
categories: ["preferences"],
|
||||
}),
|
||||
|
||||
listMemories: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "abc-123-def-456",
|
||||
memory: "User prefers dark mode",
|
||||
created_at: "2026-02-15T10:30:00Z",
|
||||
categories: ["preferences"],
|
||||
},
|
||||
{
|
||||
id: "ghi-789-jkl-012",
|
||||
memory: "User uses vim keybindings",
|
||||
created_at: "2026-03-01T14:00:00Z",
|
||||
categories: ["tools"],
|
||||
},
|
||||
]),
|
||||
|
||||
update: vi.fn().mockResolvedValue({ id: "abc-123-def-456", memory: "Updated memory" }),
|
||||
delete: vi.fn().mockResolvedValue({ status: "deleted" }),
|
||||
status: vi.fn().mockResolvedValue({
|
||||
connected: true,
|
||||
backend: "platform",
|
||||
base_url: "https://api.mem0.ai",
|
||||
}),
|
||||
deleteEntities: vi.fn().mockResolvedValue({ message: "Entity deleted" }),
|
||||
entities: vi.fn().mockResolvedValue([
|
||||
{ name: "alice", count: 5 },
|
||||
{ name: "bob", count: 3 },
|
||||
]),
|
||||
listEvents: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "evt-abc-123-def-456",
|
||||
event_type: "ADD",
|
||||
status: "SUCCEEDED",
|
||||
graph_status: null,
|
||||
latency: 1234.5,
|
||||
created_at: "2026-04-01T10:00:00Z",
|
||||
updated_at: "2026-04-01T10:00:01Z",
|
||||
},
|
||||
{
|
||||
id: "evt-def-456-ghi-789",
|
||||
event_type: "SEARCH",
|
||||
status: "PENDING",
|
||||
graph_status: null,
|
||||
latency: null,
|
||||
created_at: "2026-04-01T10:01:00Z",
|
||||
updated_at: "2026-04-01T10:01:00Z",
|
||||
},
|
||||
]),
|
||||
getEvent: vi.fn().mockResolvedValue({
|
||||
id: "evt-abc-123-def-456",
|
||||
event_type: "ADD",
|
||||
status: "SUCCEEDED",
|
||||
graph_status: "SUCCEEDED",
|
||||
latency: 1234.5,
|
||||
created_at: "2026-04-01T10:00:00Z",
|
||||
updated_at: "2026-04-01T10:00:01Z",
|
||||
results: [
|
||||
{
|
||||
id: "mem-abc-123",
|
||||
event: "ADD",
|
||||
user_id: "alice",
|
||||
data: { memory: "User prefers dark mode" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockLoadConfig = vi.fn();
|
||||
const mockSaveConfig = vi.fn();
|
||||
const mockSpawn = vi.fn();
|
||||
|
||||
vi.mock("../src/config.js", () => ({
|
||||
CONFIG_FILE: "/tmp/mem0-config.json",
|
||||
loadConfig: mockLoadConfig,
|
||||
saveConfig: mockSaveConfig,
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
spawn: mockSpawn,
|
||||
}));
|
||||
|
||||
describe("captureEvent", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mockLoadConfig.mockReset();
|
||||
mockSaveConfig.mockReset();
|
||||
mockSpawn.mockReset();
|
||||
delete process.env.MEM0_TELEMETRY;
|
||||
});
|
||||
|
||||
it("pipes the telemetry context through stdin instead of argv", async () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
platform: {
|
||||
apiKey: "m0-node-secret",
|
||||
baseUrl: "https://api.mem0.ai",
|
||||
userEmail: "",
|
||||
},
|
||||
telemetry: {
|
||||
anonymousId: "cli-anon-node",
|
||||
},
|
||||
});
|
||||
|
||||
const stdin = { end: vi.fn() };
|
||||
const child = { stdin, unref: vi.fn() };
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const { captureEvent } = await import("../src/telemetry.js");
|
||||
captureEvent("node_test_event", { case: "stdin-secret" });
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
||||
const [execPath, args, options] = mockSpawn.mock.calls[0];
|
||||
expect(execPath).toBe(process.execPath);
|
||||
expect(args).toHaveLength(1);
|
||||
expect(String(args[0])).toContain("telemetry-sender.cjs");
|
||||
expect(JSON.stringify(args)).not.toContain("m0-node-secret");
|
||||
expect(options).toMatchObject({ detached: true, stdio: ["pipe", "ignore", "ignore"] });
|
||||
|
||||
expect(stdin.end).toHaveBeenCalledTimes(1);
|
||||
const payload = JSON.parse(stdin.end.mock.calls[0][0]);
|
||||
expect(payload.mem0ApiKey).toBe("m0-node-secret");
|
||||
expect(payload.payload.event).toBe("node_test_event");
|
||||
expect(child.unref).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user