Files
wehub-resource-sync 979fb22d7c
CI / test (20, macos-latest) (push) Waiting to run
CI / test (20, ubuntu-latest) (push) Waiting to run
CI / test (22, macos-latest) (push) Waiting to run
CI / test (22, ubuntu-latest) (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:01:18 +08:00

434 lines
14 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
const writtenFiles = new Map<string, string>();
const createdDirs = new Set<string>();
vi.mock("node:fs/promises", () => ({
mkdir: vi.fn(async (dir: string) => {
createdDirs.add(dir);
}),
writeFile: vi.fn(async (path: string, content: string) => {
writtenFiles.set(path, content);
}),
}));
import { registerObsidianExportFunction } from "../src/functions/obsidian-export.js";
import type { Memory, Lesson, Crystal, Session } from "../src/types.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function mockSdk() {
const functions = new Map<string, Function>();
return {
registerFunction: (idOrOpts: string | { id: string }, handler: Function) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
const fn = functions.get(id);
if (!fn) throw new Error(`No function: ${id}`);
return fn(payload);
},
};
}
function makeMemory(id: string): Memory {
return {
id,
createdAt: "2026-04-01T00:00:00Z",
updatedAt: "2026-04-01T00:00:00Z",
type: "pattern",
title: `Memory ${id}`,
content: `Content for ${id}`,
concepts: ["testing"],
files: ["src/test.ts"],
sessionIds: ["ses_1"],
strength: 7,
version: 1,
isLatest: true,
};
}
function makeLesson(id: string): Lesson {
return {
id,
content: `Lesson ${id}`,
context: "Test context",
confidence: 0.8,
reinforcements: 2,
source: "manual",
sourceIds: [],
project: "/test",
tags: ["testing"],
createdAt: "2026-04-01T00:00:00Z",
updatedAt: "2026-04-01T00:00:00Z",
decayRate: 0.05,
};
}
function makeCrystal(id: string): Crystal {
return {
id,
narrative: `Crystal narrative ${id}`,
keyOutcomes: ["Outcome 1"],
filesAffected: ["src/test.ts"],
lessons: ["Learned something"],
sourceActionIds: ["act_1"],
sessionId: "ses_1",
project: "/test",
createdAt: "2026-04-01T00:00:00Z",
};
}
function makeSession(id: string): Session {
return {
id,
project: "/test",
cwd: "/Users/test/project",
startedAt: "2026-04-01T00:00:00Z",
endedAt: "2026-04-01T01:00:00Z",
status: "completed",
observationCount: 15,
};
}
describe("Obsidian Export", () => {
let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;
const exportRoot = "/tmp/agentmemory-export-root";
beforeEach(() => {
process.env.AGENTMEMORY_EXPORT_ROOT = exportRoot;
sdk = mockSdk();
kv = mockKV();
writtenFiles.clear();
createdDirs.clear();
registerObsidianExportFunction(sdk as never, kv as never);
});
it("creates directories and MOC.md with empty data", async () => {
const result = (await sdk.trigger("mem::obsidian-export", {})) as {
success: boolean;
exported: Record<string, number>;
vaultDir: string;
};
expect(result.success).toBe(true);
expect(result.exported.memories).toBe(0);
expect(result.exported.lessons).toBe(0);
expect(result.exported.crystals).toBe(0);
expect(result.exported.sessions).toBe(0);
expect(createdDirs.size).toBe(4);
const mocPath = [...writtenFiles.keys()].find((k) => k.endsWith("MOC.md"));
expect(mocPath).toBeDefined();
const moc = writtenFiles.get(mocPath!);
expect(moc).toContain("# agentmemory vault");
expect(moc).toContain("## Memories (0)");
});
it("exports memories with YAML frontmatter", async () => {
const mem = makeMemory("mem_001");
await kv.set("mem:memories", mem.id, mem);
const result = (await sdk.trigger("mem::obsidian-export", {})) as {
exported: Record<string, number>;
};
expect(result.exported.memories).toBe(1);
const memFile = [...writtenFiles.entries()].find(([k]) =>
k.includes("memories/mem_001.md"),
);
expect(memFile).toBeDefined();
const content = memFile![1];
expect(content).toContain("---");
expect(content).toContain('id: "mem_001"');
expect(content).toContain('type: "pattern"');
expect(content).toContain("# Memory mem_001");
expect(content).toContain("#testing");
});
it("exports lessons with confidence and source", async () => {
const lesson = makeLesson("lsn_001");
await kv.set("mem:lessons", lesson.id, lesson);
const result = (await sdk.trigger("mem::obsidian-export", {})) as {
exported: Record<string, number>;
};
expect(result.exported.lessons).toBe(1);
const lsnFile = [...writtenFiles.entries()].find(([k]) =>
k.includes("lessons/lsn_001.md"),
);
expect(lsnFile).toBeDefined();
const content = lsnFile![1];
expect(content).toContain("confidence: 0.8");
expect(content).toContain("reinforcements: 2");
expect(content).toContain('source: "manual"');
});
it("exports crystals with wikilinks to source actions", async () => {
const crystal = makeCrystal("crys_001");
await kv.set("mem:crystals", crystal.id, crystal);
await sdk.trigger("mem::obsidian-export", {});
const crysFile = [...writtenFiles.entries()].find(([k]) =>
k.includes("crystals/crys_001.md"),
);
expect(crysFile).toBeDefined();
expect(crysFile![1]).toContain("[[act_1]]");
expect(crysFile![1]).toContain("Outcome 1");
});
it("respects types filter", async () => {
await kv.set("mem:memories", "mem_001", makeMemory("mem_001"));
await kv.set("mem:lessons", "lsn_001", makeLesson("lsn_001"));
const result = (await sdk.trigger("mem::obsidian-export", {
types: ["lessons"],
})) as { exported: Record<string, number> };
expect(result.exported.lessons).toBe(1);
expect(result.exported.memories).toBe(0);
});
it("respects custom vaultDir", async () => {
await sdk.trigger("mem::obsidian-export", {
vaultDir: "/tmp/agentmemory-export-root/test-vault",
});
const hasCustomPath = [...createdDirs].some((d) =>
d.startsWith("/tmp/agentmemory-export-root/test-vault"),
);
expect(hasCustomPath).toBe(true);
});
it("rejects vaultDir outside the export root", async () => {
const result = (await sdk.trigger("mem::obsidian-export", {
vaultDir: "/tmp/outside-root",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain(exportRoot);
});
it("skips deleted lessons", async () => {
const lesson = makeLesson("lsn_deleted");
(lesson as any).deleted = true;
await kv.set("mem:lessons", lesson.id, lesson);
const result = (await sdk.trigger("mem::obsidian-export", {})) as {
exported: Record<string, number>;
};
expect(result.exported.lessons).toBe(0);
});
it("skips non-latest memories", async () => {
const mem = makeMemory("mem_old");
mem.isLatest = false;
await kv.set("mem:memories", mem.id, mem);
const result = (await sdk.trigger("mem::obsidian-export", {})) as {
exported: Record<string, number>;
};
expect(result.exported.memories).toBe(0);
});
it("MOC groups entries by type", async () => {
await kv.set("mem:memories", "mem_001", makeMemory("mem_001"));
await kv.set("mem:lessons", "lsn_001", makeLesson("lsn_001"));
await kv.set("mem:crystals", "crys_001", makeCrystal("crys_001"));
await kv.set("mem:sessions", "ses_001", makeSession("ses_001"));
await sdk.trigger("mem::obsidian-export", {});
const mocPath = [...writtenFiles.keys()].find((k) => k.endsWith("MOC.md"));
const moc = writtenFiles.get(mocPath!)!;
expect(moc).toContain("## Memories (1)");
expect(moc).toContain("## Lessons (1)");
expect(moc).toContain("## Crystals (1)");
expect(moc).toContain("## Sessions (1)");
expect(moc).toContain("[[memories/mem_001|");
expect(moc).toContain("[[lessons/lsn_001|");
expect(moc).toContain("[[crystals/crys_001|");
expect(moc).toContain("[[sessions/ses_001|");
});
it("returns undefined errors on success", async () => {
await kv.set("mem:memories", "mem_001", makeMemory("mem_001"));
const result = (await sdk.trigger("mem::obsidian-export", {})) as {
success: boolean;
errors?: unknown[];
};
expect(result.success).toBe(true);
expect(result.errors).toBeUndefined();
});
// #729: any record missing an id used to crash `sanitize(undefined.id)`
// outside the per-record try, escaping the handler entirely and
// returning HTTP 500 `{"error":"[object Object]"}` with zero files
// written. The hardened loops filter id-less records and the outer
// try/catch keeps thrown errors from ever reaching the HTTP serializer.
it("skips records that are missing an id and keeps exporting the rest", async () => {
await kv.set("mem:memories", "orphan-memory", { ...makeMemory("mem_missing"), id: undefined } as any);
await kv.set("mem:lessons", "orphan-lesson", { ...makeLesson("lsn_missing"), id: undefined } as any);
await kv.set("mem:crystals", "orphan-crystal", { ...makeCrystal("crys_missing"), id: undefined } as any);
await kv.set("mem:sessions", "orphan-session", { ...makeSession("ses_missing"), id: undefined } as any);
await kv.set("mem:sessions", "valid-session", makeSession("ses_valid"));
const result = (await sdk.trigger("mem::obsidian-export", {})) as {
success: boolean;
exported: Record<string, number>;
errors?: unknown[];
};
expect(result.success).toBe(true);
expect(result.exported.memories).toBe(0);
expect(result.exported.lessons).toBe(0);
expect(result.exported.crystals).toBe(0);
expect(result.exported.sessions).toBe(1);
expect(result.errors).toBeUndefined();
expect([...writtenFiles.keys()].some((path) => path.includes("undefined.md"))).toBe(false);
expect([...writtenFiles.keys()].some((path) => path.includes("sessions/ses_valid.md"))).toBe(true);
});
it("tolerates malformed startedAt timestamps when sorting sessions", async () => {
await kv.set("mem:sessions", "ses_recent", { ...makeSession("ses_recent"), startedAt: "2026-04-02T00:00:00Z" });
await kv.set("mem:sessions", "ses_bad", { ...makeSession("ses_bad"), startedAt: "not-a-date" } as any);
await kv.set("mem:sessions", "ses_undef", { ...makeSession("ses_undef"), startedAt: undefined } as any);
const result = (await sdk.trigger("mem::obsidian-export", {})) as {
success: boolean;
exported: Record<string, number>;
};
expect(result.success).toBe(true);
expect(result.exported.sessions).toBe(3);
});
it("exports memories whose optional array fields are missing or null", async () => {
const incomplete = {
...makeMemory("mem_incomplete"),
concepts: undefined,
files: null,
relatedIds: null,
supersedes: undefined,
} as any;
await kv.set("mem:memories", incomplete.id, incomplete);
const result = (await sdk.trigger("mem::obsidian-export", {})) as {
success: boolean;
exported: Record<string, number>;
};
expect(result.success).toBe(true);
expect(result.exported.memories).toBe(1);
const memFile = [...writtenFiles.entries()].find(([k]) =>
k.includes("memories/mem_incomplete.md"),
);
expect(memFile).toBeDefined();
const content = memFile![1];
expect(content).toContain("# Memory mem_incomplete");
expect(content).not.toContain("## Related");
expect(content).not.toContain("## Supersedes");
});
it("falls back to the id when title / content / narrative are missing", async () => {
await kv.set("mem:memories", "mem_no_title", {
...makeMemory("mem_no_title"),
title: undefined,
content: undefined,
} as any);
await kv.set("mem:lessons", "lsn_no_content", {
...makeLesson("lsn_no_content"),
content: undefined,
} as any);
await kv.set("mem:crystals", "crys_no_narr", {
...makeCrystal("crys_no_narr"),
narrative: undefined,
} as any);
const result = (await sdk.trigger("mem::obsidian-export", {})) as {
success: boolean;
exported: Record<string, number>;
};
expect(result.success).toBe(true);
expect(result.exported.memories).toBe(1);
expect(result.exported.lessons).toBe(1);
expect(result.exported.crystals).toBe(1);
const memFile = [...writtenFiles.entries()].find(([k]) =>
k.includes("memories/mem_no_title.md"),
);
expect(memFile![1]).toContain("# mem_no_title");
const lsnFile = [...writtenFiles.entries()].find(([k]) =>
k.includes("lessons/lsn_no_content.md"),
);
expect(lsnFile![1]).toContain("# Lesson: lsn_no_content");
const crysFile = [...writtenFiles.entries()].find(([k]) =>
k.includes("crystals/crys_no_narr.md"),
);
expect(crysFile![1]).toContain("# Crystal: crys_no_narr");
});
it("never throws out to the engine — returns {success: false, error: <string>} on internal failure", async () => {
// Force mkdir to throw to simulate an unexpected runtime error so we
// can assert the outer try/catch turns it into a serializable error.
const fsModule = await import("node:fs/promises");
const original = fsModule.mkdir;
(fsModule.mkdir as any) = vi.fn(async () => {
throw new TypeError("simulated disk failure");
});
try {
const result = (await sdk.trigger("mem::obsidian-export", {})) as {
success: boolean;
error?: string;
};
expect(result.success).toBe(false);
expect(typeof result.error).toBe("string");
expect(result.error).toContain("simulated disk failure");
} finally {
(fsModule.mkdir as any) = original;
}
});
});