Files
Hy-Memory/memhub/apps/cli/src/ui-write-cli.test.ts
T
2026-10-16 23:00:44 +08:00

480 lines
13 KiB
TypeScript

import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { run } from "./cli.js";
const transport = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
}));
vi.mock("axios", () => ({
default: { create: vi.fn(() => transport), get: vi.fn() },
}));
const runtime = vi.hoisted(() => ({
inspectRuntimeStatus: vi.fn(),
discoverRuntimeAttachment: vi.fn(),
}));
vi.mock("@one-memory/runtime-control", async (importOriginal) => ({
...(await importOriginal<typeof import("@one-memory/runtime-control")>()),
...runtime,
}));
const EVENT = "11111111-1111-4111-8111-111111111111";
const UPLOAD = "22222222-2222-4222-8222-222222222222";
const NOTE = "33333333-3333-4333-8333-333333333333";
beforeEach(() => {
vi.clearAllMocks();
// clearAllMocks keeps prior mock implementations, so a mockResolvedValue
// from an earlier test would leak into the next one and fake its response.
transport.get.mockReset();
transport.post.mockReset();
transport.put.mockReset();
transport.patch.mockReset();
transport.delete.mockReset();
runtime.inspectRuntimeStatus.mockResolvedValue({
state: "running",
code: "runtime_verified",
instanceId: "instance-1",
host: "127.0.0.1",
port: 8765,
version: "1.2.3",
});
runtime.discoverRuntimeAttachment.mockResolvedValue({
instanceId: "instance-1",
apiToken: "synthetic-runtime-token",
host: "127.0.0.1",
port: 8765,
servicePid: 4242,
health: {
status: "ok",
service: "one-memory-core",
version: "1.2.3",
instance_id: "instance-1",
},
});
});
describe("calls clear", () => {
it("reports runtime_stopped (exit 3) without attempting a write", async () => {
runtime.inspectRuntimeStatus.mockResolvedValue({
state: "stopped",
code: "runtime_not_running",
});
const result = await capturedRun([
"calls",
"clear",
"--all",
"--confirm",
"--json",
]);
expect(result.code).toBe(3);
expect(JSON.parse(result.stdout).code).toBe("runtime_not_running");
expect(transport.post).not.toHaveBeenCalled();
});
it("previews without contacting Core", async () => {
const result = await capturedRun([
"calls",
"clear",
"--event",
EVENT,
"--json",
]);
expect(result.code).toBe(0);
const document = JSON.parse(result.stdout);
expect(document.state).toBe("preview");
expect(document.plan.kind).toBe("call_events_clear");
expect(transport.post).not.toHaveBeenCalled();
expect(transport.delete).not.toHaveBeenCalled();
});
it("sends the exact Core body with --confirm", async () => {
transport.post.mockResolvedValue({ status: 200, data: { removed: 1 } });
const result = await capturedRun([
"calls",
"clear",
"--event",
EVENT,
"--confirm",
"--json",
]);
expect(result.code).toBe(0);
expect(transport.post).toHaveBeenCalledWith(
"/api/v1/call-events/clear",
{ event_ids: [EVENT], user_confirmed: true },
undefined,
);
expect(JSON.parse(result.stdout).state).toBe("committed");
});
it("clears all without naming events", async () => {
transport.post.mockResolvedValue({ status: 200, data: { removed: 4 } });
const result = await capturedRun([
"calls",
"clear",
"--all",
"--confirm",
"--json",
]);
expect(result.code).toBe(0);
expect(transport.post).toHaveBeenCalledWith(
"/api/v1/call-events/clear-all",
{ user_confirmed: true },
undefined,
);
});
it("rejects a malformed event id without contacting Core", async () => {
const result = await capturedRun([
"calls",
"clear",
"--event",
"not-a-uuid",
"--json",
]);
expect(result.code).toBe(2);
expect(transport.post).not.toHaveBeenCalled();
});
it("never reports committed for a malformed success body", async () => {
transport.post.mockResolvedValue({ status: 200, data: { wrong: true } });
const result = await capturedRun([
"calls",
"clear",
"--event",
EVENT,
"--confirm",
"--json",
]);
expect(result.code).toBe(1);
const document = JSON.parse(result.stdout);
expect(document.state).toBe("unknown");
expect(document.code).toBe("ui_write_receipt_invalid");
});
});
describe("import-file", () => {
it("never uploads without --confirm", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "om-import-"));
const file = path.join(directory, "export.json");
await writeFile(file, '{"conversations":[]}', { mode: 0o600 });
const result = await capturedRun([
"import-file",
"upload",
"--file",
file,
"--json",
]);
expect(result.code).toBe(0);
const document = JSON.parse(result.stdout);
expect(document.state).toBe("preview");
expect(document.plan.operation_id).toBeDefined();
expect(transport.post).not.toHaveBeenCalled();
});
it("uploads the file with a deterministic operation id on confirm", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "om-import-"));
const file = path.join(directory, "export.json");
await writeFile(file, '{"conversations":[]}', { mode: 0o600 });
transport.post.mockResolvedValue({
status: 201,
data: {
schema_version: 1,
operation_id: "ignored-here",
state: "completed",
upload_id: UPLOAD,
upload_state: "staged",
},
});
const preview = await capturedRun([
"import-file",
"upload",
"--file",
file,
"--json",
]);
const plan = JSON.parse(preview.stdout).plan;
transport.post.mockResolvedValue({
status: 201,
data: {
schema_version: 1,
operation_id: plan.operation_id,
state: "completed",
upload_id: UPLOAD,
upload_state: "staged",
},
});
const result = await capturedRun([
"import-file",
"upload",
"--file",
file,
"--confirm",
"--json",
]);
expect(result.code).toBe(0);
const url = transport.post.mock.calls.at(-1)?.[0] as string;
expect(url).toContain("/api/v1/import-files?");
expect(url).toContain(`operation_id=${plan.operation_id}`);
expect(url).toContain("filename=export.json");
expect(JSON.parse(result.stdout).state).toBe("committed");
});
it("commits and discards only with explicit confirmation", async () => {
const preview = await capturedRun([
"import-file",
"commit",
UPLOAD,
"--json",
]);
expect(preview.code).toBe(0);
expect(JSON.parse(preview.stdout).state).toBe("preview");
expect(transport.post).not.toHaveBeenCalled();
transport.post.mockResolvedValue({
status: 200,
data: {
upload_id: UPLOAD,
filename: "x.json",
vendor: "generic",
source_id: "generic-export",
messages: 0,
conversations: 0,
human_messages: 0,
agent_messages: 0,
skipped_records: 0,
ignored_files: 0,
checksum: "a".repeat(64),
created_at: "T",
committed: true,
},
});
const committed = await capturedRun([
"import-file",
"commit",
UPLOAD,
"--confirm",
"--json",
]);
expect(committed.code).toBe(0);
expect(transport.post).toHaveBeenCalledWith(
`/api/v1/import-files/${UPLOAD}/commit`,
{ confirmed: true },
undefined,
);
transport.delete.mockResolvedValue({ status: 204, data: "" });
const discarded = await capturedRun([
"import-file",
"discard",
UPLOAD,
"--confirm",
"--json",
]);
expect(discarded.code).toBe(0);
expect(JSON.parse(discarded.stdout).state).toBe("committed");
expect(transport.delete).toHaveBeenCalledWith(
`/api/v1/import-files/${UPLOAD}`,
undefined,
);
});
it("maps a Core 404 to a stable rejected code, not a fake success", async () => {
transport.post.mockResolvedValue({
status: 404,
data: { detail: "import_file_not_found" },
});
const result = await capturedRun([
"import-file",
"commit",
UPLOAD,
"--confirm",
"--json",
]);
expect(result.code).toBe(1);
const document = JSON.parse(result.stdout);
expect(document.state).toBe("rejected");
expect(document.code).toBe("import_file_not_found");
});
});
describe("notes / extraction / graph / manual extract", () => {
it("adds a note from a bounded file and re-verifies content on apply", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "om-note-"));
const content = path.join(directory, "note.txt");
await writeFile(content, "记住这个决定", { mode: 0o600 });
const preview = await capturedRun([
"notes",
"add",
"--content-file",
content,
"--json",
]);
expect(preview.code).toBe(0);
const planFile = path.join(directory, "plan.json");
await writeFile(planFile, preview.stdout, { mode: 0o600 });
transport.post.mockResolvedValue({
status: 201,
// A realistic Core `MemoryNoteReceipt`: the shared parser requires
// `scope` and `memory_ids` too, so a minimal `{evidence_id,state}` stub
// is a malformed body, not a success.
data: {
evidence_id: NOTE,
text: "记住这个决定",
scope: "all-apps",
created_at: "2026-09-19T00:00:00Z",
state: "pending",
task_id: null,
memory_ids: [],
error: null,
},
});
const applied = await capturedRun([
"notes",
"apply",
"--plan-file",
planFile,
"--content-file",
content,
"--confirm",
"--json",
]);
expect(applied.code).toBe(0);
const body = transport.post.mock.calls.at(-1)?.[1] as Record<
string,
unknown
>;
expect(body.text).toBe("记住这个决定");
expect(body.scope).toBe("all-apps");
expect(JSON.parse(applied.stdout).state).toBe("committed");
});
it("plans then applies the extraction window", async () => {
const preview = await capturedRun([
"extraction",
"set",
"--window",
"6m",
"--json",
]);
expect(preview.code).toBe(0);
expect(JSON.parse(preview.stdout).state).toBe("preview");
expect(transport.put).not.toHaveBeenCalled();
transport.put.mockResolvedValue({
status: 200,
data: { window_preset: "6m" },
});
const applied = await capturedRun([
"extraction",
"set",
"--window",
"6m",
"--confirm",
"--json",
]);
expect(applied.code).toBe(0);
expect(transport.put).toHaveBeenCalledWith(
"/api/v1/extraction-settings",
{ window_preset: "6m" },
undefined,
);
});
it("does not call graph width without --confirm", async () => {
const preview = await capturedRun([
"graph",
"width",
"--decision",
"rebuild_with_new",
"--json",
]);
expect(preview.code).toBe(0);
expect(transport.post).not.toHaveBeenCalled();
transport.post.mockResolvedValue({
status: 200,
data: { archived_path: "/tmp/old", exported_path: null, mismatch: null },
});
const applied = await capturedRun([
"graph",
"width",
"--decision",
"rebuild_with_new",
"--confirm",
"--json",
]);
expect(applied.code).toBe(0);
expect(transport.post).toHaveBeenCalledWith(
"/api/v1/graph-width",
{ decision: "rebuild_with_new" },
undefined,
);
});
it("plans manual extraction and applies it only on confirm", async () => {
const preview = await capturedRun([
"processing",
"extract",
"--evidence",
NOTE,
"--json",
]);
expect(preview.code).toBe(0);
expect(JSON.parse(preview.stdout).state).toBe("preview");
expect(transport.post).not.toHaveBeenCalled();
transport.post.mockResolvedValue({
status: 200,
data: { promoted: 1, batches_planned: 2, batches_current: 1 },
});
const applied = await capturedRun([
"processing",
"extract",
"--evidence",
NOTE,
"--confirm",
"--json",
]);
expect(applied.code).toBe(0);
expect(transport.post).toHaveBeenCalledWith(
"/api/v1/memory-processing/manual-extract",
{ evidence_ids: [NOTE] },
undefined,
);
});
});
async function capturedRun(arguments_: string[]) {
let stdout = "";
let stderr = "";
const stdoutSpy = vi
.spyOn(process.stdout, "write")
.mockImplementation((chunk) => {
stdout += String(chunk);
return true;
});
const stderrSpy = vi
.spyOn(process.stderr, "write")
.mockImplementation((chunk) => {
stderr += String(chunk);
return true;
});
try {
return {
code: await run(arguments_, {
ONE_MEMORY_DATA_DIR: "/synthetic/data",
}),
stdout,
stderr,
};
} finally {
stdoutSpy.mockRestore();
stderrSpy.mockRestore();
}
}