410 lines
12 KiB
TypeScript
410 lines
12 KiB
TypeScript
/// <reference types="jest" />
|
|
import * as fs from "fs";
|
|
import * as os from "os";
|
|
import * as path from "path";
|
|
|
|
jest.setTimeout(15000);
|
|
|
|
jest.mock("../src/embeddings/google", () => ({
|
|
GoogleEmbedder: jest.fn(),
|
|
}));
|
|
jest.mock("../src/llms/google", () => ({
|
|
GoogleLLM: jest.fn(),
|
|
}));
|
|
|
|
jest.mock("../src/llms/openai", () => ({
|
|
OpenAILLM: jest.fn().mockImplementation(() => ({
|
|
generateResponse: jest.fn().mockResolvedValue(
|
|
JSON.stringify({
|
|
memory: [{ id: "0", text: "stored fact", attributed_to: "user" }],
|
|
}),
|
|
),
|
|
})),
|
|
}));
|
|
|
|
const mockEmbedding = new Array(1536).fill(0.1);
|
|
jest.mock("../src/embeddings/openai", () => ({
|
|
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
|
|
embed: jest.fn().mockResolvedValue(mockEmbedding),
|
|
embedBatch: jest
|
|
.fn()
|
|
.mockImplementation((texts: string[]) =>
|
|
Promise.resolve(texts.map(() => mockEmbedding)),
|
|
),
|
|
embeddingDims: 1536,
|
|
})),
|
|
}));
|
|
|
|
const FIRST_RUN_COPY = "First-run CTA from PostHog";
|
|
|
|
function makeTempMem0Dir(): string {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), "mem0-node-first-run-"));
|
|
}
|
|
|
|
function firstRunPayload(overrides: Record<string, any> = {}) {
|
|
return {
|
|
notices: {
|
|
first_run: {
|
|
enabled: true,
|
|
notice_type: "log_line",
|
|
copy: FIRST_RUN_COPY,
|
|
...overrides,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function createFetchMock(options: {
|
|
variant?: string;
|
|
payload?: Record<string, any>;
|
|
failFlags?: boolean;
|
|
}) {
|
|
const calls: any[] = [];
|
|
const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
|
|
const target = String(url);
|
|
|
|
if (target.includes("/flags")) {
|
|
if (options.failFlags) {
|
|
throw new Error("flag evaluation failed");
|
|
}
|
|
return {
|
|
ok: true,
|
|
json: jest.fn().mockResolvedValue({
|
|
flags: {
|
|
"mem0-oss-notices": {
|
|
key: "mem0-oss-notices",
|
|
enabled: true,
|
|
variant: options.variant ?? "displayed",
|
|
metadata: {
|
|
payload: JSON.stringify(options.payload ?? firstRunPayload()),
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
};
|
|
}
|
|
|
|
if (target.includes("/i/v0/e/")) {
|
|
calls.push(JSON.parse(String(init?.body)));
|
|
return {
|
|
ok: true,
|
|
text: jest.fn().mockResolvedValue(""),
|
|
};
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
json: jest.fn().mockResolvedValue({}),
|
|
text: jest.fn().mockResolvedValue(""),
|
|
};
|
|
});
|
|
|
|
return { fetchMock, calls };
|
|
}
|
|
|
|
function noticeEvents(calls: any[]) {
|
|
return calls.filter((call) => call.event === "mem0.notice_displayed");
|
|
}
|
|
|
|
async function createMemory() {
|
|
const { Memory } = await import("../src/memory");
|
|
return new Memory({
|
|
version: "v1.1",
|
|
embedder: {
|
|
provider: "openai",
|
|
config: { apiKey: "test-key", model: "text-embedding-3-small" },
|
|
},
|
|
vectorStore: {
|
|
provider: "memory",
|
|
config: {
|
|
collectionName: `test-first-run-${Date.now()}-${Math.random()}`,
|
|
dimension: 1536,
|
|
dbPath: ":memory:",
|
|
},
|
|
},
|
|
llm: {
|
|
provider: "openai",
|
|
config: { apiKey: "test-key", model: "gpt-5-mini" },
|
|
},
|
|
historyDbPath: ":memory:",
|
|
});
|
|
}
|
|
|
|
describe("Node OSS first-run notice", () => {
|
|
let originalMem0Dir: string | undefined;
|
|
let originalTelemetry: string | undefined;
|
|
let originalSampleRate: string | undefined;
|
|
let originalFetch: typeof global.fetch;
|
|
let stderrSpy: jest.SpyInstance;
|
|
|
|
beforeEach(() => {
|
|
originalMem0Dir = process.env.MEM0_DIR;
|
|
originalTelemetry = process.env.MEM0_TELEMETRY;
|
|
originalSampleRate = process.env.MEM0_TELEMETRY_SAMPLE_RATE;
|
|
originalFetch = global.fetch;
|
|
|
|
process.env.MEM0_DIR = makeTempMem0Dir();
|
|
process.env.MEM0_TELEMETRY = "true";
|
|
process.env.MEM0_TELEMETRY_SAMPLE_RATE = "1";
|
|
stderrSpy = jest
|
|
.spyOn(process.stderr, "write")
|
|
.mockImplementation(() => true);
|
|
jest.resetModules();
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalMem0Dir === undefined) delete process.env.MEM0_DIR;
|
|
else process.env.MEM0_DIR = originalMem0Dir;
|
|
|
|
if (originalTelemetry === undefined) delete process.env.MEM0_TELEMETRY;
|
|
else process.env.MEM0_TELEMETRY = originalTelemetry;
|
|
|
|
if (originalSampleRate === undefined) {
|
|
delete process.env.MEM0_TELEMETRY_SAMPLE_RATE;
|
|
} else {
|
|
process.env.MEM0_TELEMETRY_SAMPLE_RATE = originalSampleRate;
|
|
}
|
|
|
|
global.fetch = originalFetch;
|
|
jest.restoreAllMocks();
|
|
jest.resetModules();
|
|
});
|
|
|
|
it("shows the displayed first-run copy once after a successful public call", async () => {
|
|
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
|
|
global.fetch = fetchMock as any;
|
|
const memory = await createMemory();
|
|
|
|
await memory.add("Direct storage content", {
|
|
userId: "first-run-user",
|
|
infer: false,
|
|
});
|
|
await memory.add("Second direct storage content", {
|
|
userId: "first-run-user",
|
|
infer: false,
|
|
});
|
|
|
|
const stderrOutput = stderrSpy.mock.calls.flat().join("");
|
|
expect(stderrOutput.match(new RegExp(FIRST_RUN_COPY, "g"))).toHaveLength(1);
|
|
|
|
const notices = noticeEvents(calls);
|
|
expect(notices).toHaveLength(1);
|
|
expect(notices[0].properties).toEqual(
|
|
expect.objectContaining({
|
|
notice_id: "first_run",
|
|
notice_type: "log_line",
|
|
flag_key: "mem0-oss-notices",
|
|
variant: "displayed",
|
|
displayed: true,
|
|
payload: FIRST_RUN_COPY,
|
|
notice_config_found: true,
|
|
sync_type: "async",
|
|
trigger_function: "add",
|
|
sample_rate: 1,
|
|
}),
|
|
);
|
|
|
|
const { __noticeTestHooks } = await import("../src/utils/notices");
|
|
const config = __noticeTestHooks.loadMem0Config();
|
|
expect(config.notice_state.first_run).toEqual(
|
|
expect.objectContaining({
|
|
consumed: true,
|
|
trigger_function: "add",
|
|
variant: "displayed",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("treats missing enabled as enabled, matching Python payload semantics", async () => {
|
|
const payload = firstRunPayload();
|
|
delete (payload.notices.first_run as Record<string, any>).enabled;
|
|
const { fetchMock, calls } = createFetchMock({
|
|
variant: "displayed",
|
|
payload,
|
|
});
|
|
global.fetch = fetchMock as any;
|
|
const memory = await createMemory();
|
|
|
|
await memory.add("Missing enabled payload content", {
|
|
userId: "first-run-missing-enabled",
|
|
infer: false,
|
|
});
|
|
|
|
const stderrOutput = stderrSpy.mock.calls.flat().join("");
|
|
expect(stderrOutput).toContain(FIRST_RUN_COPY);
|
|
expect(noticeEvents(calls)[0].properties).toEqual(
|
|
expect.objectContaining({
|
|
notice_id: "first_run",
|
|
displayed: true,
|
|
payload: FIRST_RUN_COPY,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("is silent for holdout but still emits and consumes the opportunity", async () => {
|
|
const { fetchMock, calls } = createFetchMock({ variant: "holdout" });
|
|
global.fetch = fetchMock as any;
|
|
const memory = await createMemory();
|
|
|
|
await memory.add("Holdout content", {
|
|
userId: "first-run-holdout",
|
|
infer: false,
|
|
});
|
|
|
|
expect(stderrSpy).not.toHaveBeenCalledWith(
|
|
expect.stringContaining(FIRST_RUN_COPY),
|
|
);
|
|
const notices = noticeEvents(calls);
|
|
expect(notices).toHaveLength(1);
|
|
expect(notices[0].properties).toEqual(
|
|
expect.objectContaining({
|
|
notice_id: "first_run",
|
|
variant: "holdout",
|
|
displayed: false,
|
|
bypass_reason: "holdout",
|
|
notice_config_found: true,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("is silent for disabled payloads and records a safe bypass", async () => {
|
|
const { fetchMock, calls } = createFetchMock({
|
|
variant: "displayed",
|
|
payload: firstRunPayload({ enabled: false }),
|
|
});
|
|
global.fetch = fetchMock as any;
|
|
const memory = await createMemory();
|
|
|
|
await memory.add("Disabled payload content", {
|
|
userId: "first-run-disabled",
|
|
infer: false,
|
|
});
|
|
|
|
const stderrOutput = stderrSpy.mock.calls.flat().join("");
|
|
expect(stderrOutput).not.toContain(FIRST_RUN_COPY);
|
|
const notices = noticeEvents(calls);
|
|
expect(notices).toHaveLength(1);
|
|
expect(notices[0].properties).toEqual(
|
|
expect.objectContaining({
|
|
notice_id: "first_run",
|
|
displayed: false,
|
|
bypass_reason: "payload_disabled",
|
|
disabled_reason: "payload_disabled",
|
|
notice_config_found: true,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("lets the payload disabled kill switch take precedence over holdout", async () => {
|
|
const { fetchMock, calls } = createFetchMock({
|
|
variant: "holdout",
|
|
payload: firstRunPayload({ enabled: false }),
|
|
});
|
|
global.fetch = fetchMock as any;
|
|
const memory = await createMemory();
|
|
|
|
await memory.add("Disabled holdout payload content", {
|
|
userId: "first-run-disabled-holdout",
|
|
infer: false,
|
|
});
|
|
|
|
const notices = noticeEvents(calls);
|
|
expect(notices).toHaveLength(1);
|
|
expect(notices[0].properties).toEqual(
|
|
expect.objectContaining({
|
|
notice_id: "first_run",
|
|
variant: "holdout",
|
|
displayed: false,
|
|
bypass_reason: "payload_disabled",
|
|
disabled_reason: "payload_disabled",
|
|
notice_config_found: true,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not evaluate flags, emit, or write state when telemetry is off", async () => {
|
|
process.env.MEM0_TELEMETRY = "false";
|
|
jest.resetModules();
|
|
|
|
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
|
|
global.fetch = fetchMock as any;
|
|
const memory = await createMemory();
|
|
|
|
await memory.add("Telemetry off content", {
|
|
userId: "first-run-off",
|
|
infer: false,
|
|
});
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(noticeEvents(calls)).toHaveLength(0);
|
|
const { __noticeTestHooks } = await import("../src/utils/notices");
|
|
expect(__noticeTestHooks.loadMem0Config().notice_state).toBeUndefined();
|
|
});
|
|
|
|
it("does not export internal notice test hooks from the public OSS entrypoint", async () => {
|
|
const publicOssEntry = await import("../src");
|
|
|
|
expect(publicOssEntry).not.toHaveProperty("__noticeTestHooks");
|
|
});
|
|
|
|
it("treats uppercase False as telemetry off", async () => {
|
|
process.env.MEM0_TELEMETRY = "False";
|
|
jest.resetModules();
|
|
|
|
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
|
|
global.fetch = fetchMock as any;
|
|
const memory = await createMemory();
|
|
|
|
await memory.add("Uppercase telemetry off content", {
|
|
userId: "first-run-off-uppercase",
|
|
infer: false,
|
|
});
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(noticeEvents(calls)).toHaveLength(0);
|
|
const { __noticeTestHooks } = await import("../src/utils/notices");
|
|
expect(__noticeTestHooks.loadMem0Config().notice_state).toBeUndefined();
|
|
});
|
|
|
|
it("does not run after a failed public call", async () => {
|
|
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
|
|
global.fetch = fetchMock as any;
|
|
const memory = await createMemory();
|
|
|
|
await expect(memory.add("Missing owner", {} as any)).rejects.toThrow(
|
|
"One of the filters: userId, agentId or runId is required!",
|
|
);
|
|
|
|
expect(
|
|
fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags")),
|
|
).toHaveLength(0);
|
|
expect(noticeEvents(calls)).toHaveLength(0);
|
|
});
|
|
|
|
it("does not break the successful operation when flag evaluation fails", async () => {
|
|
const { fetchMock, calls } = createFetchMock({ failFlags: true });
|
|
global.fetch = fetchMock as any;
|
|
const memory = await createMemory();
|
|
|
|
const result = await memory.add("Flag failure content", {
|
|
userId: "first-run-failure",
|
|
infer: false,
|
|
});
|
|
|
|
expect(result.results).toHaveLength(1);
|
|
expect(noticeEvents(calls)).toHaveLength(0);
|
|
});
|
|
|
|
it("can trigger from get() when get is the first successful public call", async () => {
|
|
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
|
|
global.fetch = fetchMock as any;
|
|
const memory = await createMemory();
|
|
|
|
await expect(memory.get("missing-memory-id")).resolves.toBeNull();
|
|
|
|
const notices = noticeEvents(calls);
|
|
expect(notices).toHaveLength(1);
|
|
expect(notices[0].properties.trigger_function).toBe("get");
|
|
});
|
|
});
|