Files
openclaw/extensions/memory-wiki/src/cli.test.ts
Yuval Dinodia 623a015928 fix(memory-wiki): preserve user edits when rolling back ChatGPT imports (#116517)
* fix(memory-wiki): preserve user edits when rolling back ChatGPT imports

Rollback deleted created pages and overwrote updated pages unconditionally,
destroying content the user added after the import with no recovery copy.
Import runs now record a content hash of each written page after vault
compile, and rollback preserves any page whose current content no longer
matches into the run's recovered directory before deleting or restoring.
Legacy run records without hashes preserve unconditionally.

Fixes #116457

* fix(memory-wiki): move pages aside atomically during rollback

Rollback now renames the live page into the recovery location before
inspecting it, so a concurrent external save cannot land between the
content read and the delete or snapshot restore. Matching pages drop
the moved copy; mismatching pages keep it as the recovery file.

* fix(memory-wiki): make rollback snapshot restore collision-safe

The snapshot restore wrote directly to the page path after the
move-aside, so a page recreated by an editor in that window was
overwritten with no recovery copy. Restore now creates the snapshot
exclusively and on collision moves the recreated page aside and
retries; recovery filenames are uniqued so a second move-aside cannot
clobber an earlier preserved copy.

* fix(memory-wiki): record rollback hashes at write time

* fix(memory-wiki): make ChatGPT rollback retry-safe

* fix(memory-wiki): fence ChatGPT rollback phases

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-07-31 08:01:52 +00:00

1020 lines
34 KiB
TypeScript

// Memory Wiki tests cover cli plugin behavior.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import { Command } from "commander";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { registerWikiCli } from "./cli.js";
import type { MemoryWikiPluginConfig, ResolvedMemoryWikiConfig } from "./config.js";
import { parseWikiMarkdown, renderWikiMarkdown } from "./markdown.js";
import {
renderMemoryWikiStatus,
type MemoryWikiDoctorReport,
type MemoryWikiStatus,
} from "./status.js";
import { createMemoryWikiTestHarness } from "./test-helpers.js";
const callGatewayFromCliMock = vi.hoisted(() => vi.fn());
const afterCompileHook = vi.hoisted(
() =>
({ run: undefined }) as {
run: (() => Promise<void>) | undefined;
},
);
vi.mock("openclaw/plugin-sdk/gateway-runtime", () => ({
callGatewayFromCli: callGatewayFromCliMock,
}));
vi.mock("./compile.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./compile.js")>();
return {
...actual,
compileMemoryWikiVault: async (
...args: Parameters<typeof actual.compileMemoryWikiVault>
): ReturnType<typeof actual.compileMemoryWikiVault> => {
const result = await actual.compileMemoryWikiVault(...args);
await afterCompileHook.run?.();
return result;
},
};
});
const { createVault } = createMemoryWikiTestHarness();
let suiteRoot = "";
let caseIndex = 0;
let stdoutWriteMock: ReturnType<typeof vi.fn>;
function resolveLegacyImportRunRecordPath(vaultRoot: string, runId: string): string {
return path.join(vaultRoot, ".openclaw-wiki", "import-runs", `${runId}.json`);
}
describe("memory-wiki cli", () => {
beforeAll(async () => {
suiteRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-cli-suite-"));
});
afterAll(async () => {
if (suiteRoot) {
await fs.rm(suiteRoot, { recursive: true, force: true });
}
});
beforeEach(() => {
callGatewayFromCliMock.mockReset();
stdoutWriteMock = vi.fn(() => true);
vi.spyOn(process.stdout, "write").mockImplementation(
stdoutWriteMock as unknown as typeof process.stdout.write,
);
process.exitCode = undefined;
});
afterEach(() => {
afterCompileHook.run = undefined;
vi.restoreAllMocks();
process.exitCode = undefined;
});
async function createCliVault(options?: {
config?: MemoryWikiPluginConfig;
initialize?: boolean;
}) {
return createVault({
prefix: "memory-wiki-cli-",
rootDir: path.join(suiteRoot, `case-${caseIndex++}`),
initialize: options?.initialize,
config: options?.config,
});
}
async function runRegisteredWikiCommand(
config: ResolvedMemoryWikiConfig,
args: string[],
): Promise<string> {
stdoutWriteMock.mockClear();
const program = new Command();
program.name("test");
registerWikiCli(program, { config });
await program.parseAsync(["wiki", ...args], { from: "user" });
return stdoutWriteMock.mock.calls.map(([chunk]) => String(chunk)).join("");
}
async function createChatGptExport(rootDir: string) {
const exportDir = path.join(rootDir, "chatgpt-export");
await fs.mkdir(exportDir, { recursive: true });
const conversations = [
{
conversation_id: "12345678-1234-1234-1234-1234567890ab",
title: "Travel preference check",
create_time: 1_712_363_200,
update_time: 1_712_366_800,
current_node: "assistant-1",
mapping: {
root: {},
"user-1": {
parent: "root",
message: {
author: { role: "user" },
content: {
parts: ["I prefer aisle seats and I don't want a hotel far from the airport."],
},
},
},
"assistant-1": {
parent: "user-1",
message: {
author: { role: "assistant" },
content: {
parts: ["Noted. I will keep travel options close to the airport."],
},
},
},
},
},
];
await fs.writeFile(
path.join(exportDir, "conversations.json"),
`${JSON.stringify(conversations, null, 2)}\n`,
"utf8",
);
return exportDir;
}
async function findImportedSourceFile(rootDir: string): Promise<string> {
return expectDefined(
(await fs.readdir(path.join(rootDir, "sources"))).find((entry) => entry !== "index.md"),
"imported ChatGPT source file",
);
}
function createGatewayStatus(config: {
agentId?: string;
vault: { path: string; scope?: MemoryWikiStatus["vaultScope"] };
bridge: MemoryWikiStatus["bridge"];
}): MemoryWikiStatus {
return {
vaultScope: config.vault.scope ?? "global",
agentId: config.agentId ?? null,
vaultMode: "bridge",
renderMode: "native",
vaultPath: config.vault.path,
vaultExists: true,
bridge: config.bridge,
bridgePublicArtifactCount: 2,
obsidianCli: {
enabled: false,
requested: false,
available: false,
command: null,
},
unsafeLocal: {
allowPrivateMemoryCoreAccess: false,
pathCount: 0,
},
pageCounts: {
source: 0,
entity: 0,
concept: 0,
synthesis: 0,
report: 0,
},
sourceCounts: {
native: 0,
bridge: 0,
bridgeEvents: 0,
unsafeLocal: 0,
other: 0,
},
warnings: [],
};
}
it("registers apply synthesis and writes a synthesis page", async () => {
const { rootDir, config } = await createCliVault();
const program = new Command();
program.name("test");
registerWikiCli(program, { config });
await program.parseAsync(
[
"wiki",
"apply",
"synthesis",
"CLI Alpha",
"--body",
"Alpha from CLI.",
"--source-id",
"source.alpha",
"--source-id",
"source.beta",
],
{ from: "user" },
);
const page = await fs.readFile(path.join(rootDir, "syntheses", "cli-alpha.md"), "utf8");
expect(page).toContain("Alpha from CLI.");
expect(page).toContain("source.alpha");
await expect(fs.readFile(path.join(rootDir, "index.md"), "utf8")).resolves.toContain(
"[CLI Alpha](syntheses/cli-alpha.md)",
);
});
it("resolves --agent for local commands and requires it with multiple agent vaults", async () => {
const { rootDir, config } = await createCliVault({
config: { vault: { scope: "agent" } },
});
const appConfig = {
agents: { list: [{ id: "support", default: true }, { id: "marketing" }] },
};
const program = new Command();
program.name("test");
program.exitOverride();
registerWikiCli(program, { config, getAppConfig: () => appConfig });
await program.parseAsync(["wiki", "--agent", "marketing", "init", "--json"], {
from: "user",
});
await expect(fs.stat(path.join(rootDir, "marketing", "index.md"))).resolves.toBeDefined();
const missingAgentProgram = new Command();
missingAgentProgram.name("test");
missingAgentProgram.exitOverride();
registerWikiCli(missingAgentProgram, { config, getAppConfig: () => appConfig });
await expect(
missingAgentProgram.parseAsync(["wiki", "status", "--json"], { from: "user" }),
).rejects.toThrow("agentId is required for memory-wiki when vault.scope=agent.");
});
it("forwards --agent through every bridge Gateway call", async () => {
const { config } = await createCliVault({
config: {
vaultMode: "bridge",
vault: { scope: "agent" },
bridge: { enabled: true, readMemoryArtifacts: true },
},
});
const appConfig = {
agents: { list: [{ id: "support", default: true }, { id: "marketing" }] },
};
const status = createGatewayStatus(config);
const report: MemoryWikiDoctorReport = {
healthy: true,
warningCount: 0,
status,
fixes: [],
};
callGatewayFromCliMock
.mockResolvedValueOnce(status)
.mockResolvedValueOnce(report)
.mockResolvedValueOnce({
importedCount: 0,
updatedCount: 0,
skippedCount: 0,
removedCount: 0,
artifactCount: 0,
workspaces: 0,
pagePaths: [],
indexesRefreshed: false,
indexUpdatedFiles: [],
indexRefreshReason: "no-import-changes",
});
const register = () => {
const program = new Command();
program.name("test");
registerWikiCli(program, { config, getAppConfig: () => appConfig });
return program;
};
await register().parseAsync(["wiki", "--agent", "marketing", "status", "--json"], {
from: "user",
});
await register().parseAsync(["wiki", "--agent", "marketing", "doctor", "--json"], {
from: "user",
});
await register().parseAsync(["wiki", "--agent", "marketing", "bridge", "import", "--json"], {
from: "user",
});
expect(callGatewayFromCliMock.mock.calls.map(([method, , params]) => [method, params])).toEqual(
[
["wiki.status", { agentId: "marketing" }],
["wiki.doctor", { agentId: "marketing" }],
["wiki.bridge.import", { agentId: "marketing" }],
],
);
});
it("registers OKF import and searches imported concepts", async () => {
const { rootDir, config } = await createCliVault();
const bundlePath = path.join(rootDir, "okf-bundle");
await fs.mkdir(path.join(bundlePath, "tables"), { recursive: true });
await fs.writeFile(path.join(bundlePath, "index.md"), "# Sales OKF\n", "utf8");
await fs.writeFile(
path.join(bundlePath, "tables", "customers.md"),
`---
type: BigQuery Table
title: Customers
---
Customer rows.
`,
"utf8",
);
await fs.writeFile(
path.join(bundlePath, "tables", "orders.md"),
`---
type: BigQuery Table
title: Orders
description: One row per completed order.
---
Orders join to [customers](/tables/customers.md).
`,
"utf8",
);
const program = new Command();
program.name("test");
registerWikiCli(program, { config });
await program.parseAsync(["wiki", "okf", "import", bundlePath, "--json"], { from: "user" });
const importOutput = String(stdoutWriteMock.mock.calls.at(-1)?.[0] ?? "");
const importResult = JSON.parse(importOutput) as {
importedCount: number;
pagePaths: string[];
};
expect(importResult.importedCount).toBe(2);
expect(importResult.pagePaths).toEqual(
expect.arrayContaining([
expect.stringMatching(/^concepts\/okf-okf-bundle-[0-9a-f]{8}-tables-orders-/),
]),
);
stdoutWriteMock.mockClear();
await program.parseAsync(["wiki", "search", "completed order", "--json"], { from: "user" });
const searchOutput = String(stdoutWriteMock.mock.calls.at(-1)?.[0] ?? "");
const searchResults = JSON.parse(searchOutput) as Array<{ path: string; title: string }>;
expect(searchResults).toEqual(
expect.arrayContaining([
expect.objectContaining({
title: "Orders",
path: expect.stringMatching(/^concepts\/okf-okf-bundle-[0-9a-f]{8}-tables-orders-/),
}),
]),
);
});
it("rejects apply confidence values outside the documented range", async () => {
const { config } = await createCliVault();
const program = new Command();
program.name("test");
program.exitOverride();
program.configureOutput({
writeErr: () => {},
writeOut: () => {},
});
registerWikiCli(program, { config });
await expect(
program.parseAsync(
[
"wiki",
"apply",
"synthesis",
"CLI Alpha",
"--body",
"Alpha from CLI.",
"--source-id",
"source.alpha",
"--confidence",
"Infinity",
],
{ from: "user" },
),
).rejects.toThrow("--confidence must be a number between 0 and 1.");
await expect(
program.parseAsync(["wiki", "apply", "metadata", "entity.alpha", "--confidence", "1.5"], {
from: "user",
}),
).rejects.toThrow("--confidence must be a number between 0 and 1.");
await expect(
program.parseAsync(["wiki", "apply", "metadata", "entity.alpha", "--confidence", "1e-1"], {
from: "user",
}),
).rejects.toThrow("--confidence must be a number between 0 and 1.");
});
it("rejects non-decimal wiki search and get numeric options before dispatch", async () => {
const { config } = await createCliVault();
const program = new Command();
program.name("test");
program.exitOverride();
program.configureOutput({
writeErr: () => {},
writeOut: () => {},
});
registerWikiCli(program, { config });
await expect(
program.parseAsync(["wiki", "search", "alpha", "--max-results", "0x10"], {
from: "user",
}),
).rejects.toThrow("--max-results must be a positive integer.");
await expect(
program.parseAsync(["wiki", "get", "entity.alpha", "--from", "1e2"], { from: "user" }),
).rejects.toThrow("--from must be a positive integer.");
await expect(
program.parseAsync(["wiki", "get", "entity.alpha", "--lines", "1.5"], { from: "user" }),
).rejects.toThrow("--lines must be a positive integer.");
});
it("accepts signed and zero-padded wiki get line options", async () => {
const { rootDir, config } = await createCliVault();
const targetPath = path.join(rootDir, "syntheses", "cli-lines.md");
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.writeFile(targetPath, "# CLI Lines\n\nfirst\nsecond\n", "utf8");
const program = new Command();
program.name("test");
registerWikiCli(program, { config });
await program.parseAsync(
["wiki", "get", "syntheses/cli-lines.md", "--from", "+01", "--lines", "02"],
{
from: "user",
},
);
});
it("registers apply metadata and preserves the page body", async () => {
const { rootDir, config } = await createCliVault();
const targetPath = path.join(rootDir, "entities", "alpha.md");
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.writeFile(
targetPath,
renderWikiMarkdown({
frontmatter: {
pageType: "entity",
id: "entity.alpha",
title: "Alpha",
sourceIds: ["source.old"],
confidence: 0.2,
},
body: `# Alpha
## Notes
<!-- openclaw:human:start -->
cli note
<!-- openclaw:human:end -->
`,
}),
"utf8",
);
const program = new Command();
program.name("test");
registerWikiCli(program, { config });
await program.parseAsync(
[
"wiki",
"apply",
"metadata",
"entity.alpha",
"--source-id",
"source.new",
"--contradiction",
"Conflicts with source.beta",
"--question",
"Still active?",
"--status",
"review",
"--clear-confidence",
],
{ from: "user" },
);
const page = await fs.readFile(path.join(rootDir, "entities", "alpha.md"), "utf8");
const parsed = parseWikiMarkdown(page);
expect(parsed.frontmatter.sourceIds).toEqual(["source.new"]);
expect(parsed.frontmatter.contradictions).toEqual(["Conflicts with source.beta"]);
expect(parsed.frontmatter.questions).toEqual(["Still active?"]);
expect(parsed.frontmatter.status).toBe("review");
expect(parsed.frontmatter).not.toHaveProperty("confidence");
expect(parsed.body).toContain("cli note");
});
it("runs wiki doctor and sets a non-zero exit code when warnings exist", async () => {
const { rootDir, config } = await createCliVault({
config: {
vaultMode: "bridge",
bridge: { enabled: false },
},
});
const program = new Command();
program.name("test");
registerWikiCli(program, { config });
await fs.rm(rootDir, { recursive: true, force: true });
await program.parseAsync(["wiki", "doctor", "--json"], { from: "user" });
expect(process.exitCode).toBe(1);
expect(callGatewayFromCliMock).not.toHaveBeenCalled();
});
it("routes active bridge status and doctor through the gateway", async () => {
const { config } = await createCliVault({
config: {
vaultMode: "bridge",
bridge: { enabled: true, readMemoryArtifacts: true },
},
initialize: true,
});
const status = createGatewayStatus(config);
const report: MemoryWikiDoctorReport = {
healthy: false,
warningCount: 1,
status: {
...status,
warnings: [
{
code: "bridge-artifacts-missing",
message: "No exported artifacts.",
},
],
},
fixes: [
{
code: "bridge-artifacts-missing",
message: "Create memory artifacts.",
},
],
};
callGatewayFromCliMock.mockResolvedValueOnce(status).mockResolvedValueOnce(report);
const statusOutput = await runRegisteredWikiCommand(config, ["status", "--json"]);
const doctorOutput = await runRegisteredWikiCommand(config, ["doctor", "--json"]);
expect(JSON.parse(statusOutput)).toEqual(status);
expect(JSON.parse(doctorOutput)).toEqual(report);
expect(process.exitCode).toBe(1);
expect(callGatewayFromCliMock).toHaveBeenNthCalledWith(
1,
"wiki.status",
{ timeout: "30000" },
undefined,
{ progress: false },
);
expect(callGatewayFromCliMock).toHaveBeenNthCalledWith(
2,
"wiki.doctor",
{ timeout: "30000" },
undefined,
{ progress: false },
);
});
it("sanitizes gateway status text output without changing JSON output", async () => {
const { config } = await createCliVault({
config: {
vaultMode: "bridge",
bridge: { enabled: true, readMemoryArtifacts: true },
},
initialize: true,
});
const unsafeStatus = createGatewayStatus({
...config,
vault: { path: "\u001B[2J/tmp/wiki\nforged prompt\u202E" },
});
unsafeStatus.warnings = [
{
code: "bridge-artifacts-missing",
message: "missing artifacts\r\nfake success\u001B[31m\u202E",
},
];
callGatewayFromCliMock.mockResolvedValueOnce(unsafeStatus);
const renderedText = await runRegisteredWikiCommand(config, ["status"]);
expect(renderedText).not.toContain("\u001B");
expect(renderedText).not.toContain("\u202E");
expect(renderedText).toContain("(/tmp/wiki forged prompt)");
expect(renderedText).toContain("- missing artifacts fake success");
callGatewayFromCliMock.mockResolvedValueOnce(unsafeStatus);
const renderedJson = await runRegisteredWikiCommand(config, ["status", "--json"]);
expect(renderedJson).not.toContain("\u001B");
expect(renderedJson).not.toContain("\u202E");
expect(renderedJson).not.toContain("\r");
expect(renderedJson).toContain("\\u001b[2J/tmp/wiki\\nforged prompt\\u202e");
expect(renderedJson).toContain("missing artifacts\\r\\nfake success\\u001b[31m\\u202e");
const parsed = JSON.parse(renderedJson) as MemoryWikiStatus;
expect(parsed.vaultPath).toBe("\u001B[2J/tmp/wiki\nforged prompt\u202E");
expect(parsed.warnings[0]?.message).toBe("missing artifacts\r\nfake success\u001B[31m\u202E");
});
it("rejects malformed gateway responses before rendering", async () => {
const { config } = await createCliVault({
config: {
vaultMode: "bridge",
bridge: { enabled: true, readMemoryArtifacts: true },
},
initialize: true,
});
callGatewayFromCliMock.mockResolvedValueOnce({ vaultMode: "bridge" });
await expect(runRegisteredWikiCommand(config, ["status"])).rejects.toThrow(
"Invalid Gateway response for wiki.status.",
);
});
it("rejects oversized gateway strings before rendering", async () => {
const { config } = await createCliVault({
config: {
vaultMode: "bridge",
bridge: { enabled: true, readMemoryArtifacts: true },
},
initialize: true,
});
const status = createGatewayStatus(config);
status.warnings = [
{
code: "bridge-artifacts-missing",
message: "x".repeat(10_001),
},
];
callGatewayFromCliMock.mockResolvedValueOnce(status);
await expect(runRegisteredWikiCommand(config, ["status"])).rejects.toThrow(
"Invalid Gateway response for wiki.status.",
);
});
it("truncates gateway status text output without splitting surrogate pairs", async () => {
const { config } = await createCliVault({
config: {
vaultMode: "bridge",
bridge: { enabled: true, readMemoryArtifacts: true },
},
initialize: true,
});
const status = createGatewayStatus(config);
const warningWithoutMessage = renderMemoryWikiStatus({
...status,
warnings: [{ code: "bridge-artifacts-missing", message: "" }],
});
const warningPrefix = "x".repeat(1_999 - warningWithoutMessage.length);
status.warnings = [
{
code: "bridge-artifacts-missing",
message: `${warningPrefix}\u{1F600}tail`,
},
];
const renderedStatus = renderMemoryWikiStatus(status);
expect(renderedStatus.charCodeAt(1_999)).toBe(0xd83d);
expect(renderedStatus.charCodeAt(2_000)).toBe(0xde00);
callGatewayFromCliMock.mockResolvedValueOnce(status);
const renderedText = await runRegisteredWikiCommand(config, ["status"]);
const truncationMarker = "... [truncated]\n";
expect(renderedText.length).toBeLessThanOrEqual(2_000 + truncationMarker.length);
expect(renderedText.length).toBeGreaterThan(1_990 + truncationMarker.length);
expect(renderedText.endsWith(truncationMarker)).toBe(true);
expect(renderedText).not.toContain("\ud83d");
expect(Buffer.from(renderedText).includes(Buffer.from([0xef, 0xbf, 0xbd]))).toBe(false);
expect(renderedText).not.toContain("tail");
});
it("routes active bridge imports through the gateway and keeps disabled bridge imports local", async () => {
const active = await createCliVault({
config: {
vaultMode: "bridge",
bridge: { enabled: true, readMemoryArtifacts: true },
},
initialize: true,
});
callGatewayFromCliMock.mockResolvedValueOnce({
importedCount: 1,
updatedCount: 0,
skippedCount: 0,
removedCount: 0,
artifactCount: 1,
workspaces: 1,
pagePaths: ["sources/bridge-alpha.md"],
indexesRefreshed: true,
indexUpdatedFiles: ["index.md"],
indexRefreshReason: "import-changed",
});
const activeResult = JSON.parse(
await runRegisteredWikiCommand(active.config, ["bridge", "import", "--json"]),
) as { importedCount: number };
expect(activeResult.importedCount).toBe(1);
expect(callGatewayFromCliMock).toHaveBeenCalledWith(
"wiki.bridge.import",
{ timeout: "30000" },
undefined,
{ progress: false },
);
callGatewayFromCliMock.mockClear();
const disabled = await createCliVault({
config: {
vaultMode: "bridge",
bridge: { enabled: false },
},
});
const disabledResult = JSON.parse(
await runRegisteredWikiCommand(disabled.config, ["bridge", "import", "--json"]),
) as { artifactCount: number };
expect(disabledResult.artifactCount).toBe(0);
expect(callGatewayFromCliMock).not.toHaveBeenCalled();
});
it("imports ChatGPT exports with dry-run, apply, and rollback", async () => {
const { rootDir, config } = await createCliVault({ initialize: true });
const exportDir = await createChatGptExport(rootDir);
const dryRun = JSON.parse(
await runRegisteredWikiCommand(config, [
"chatgpt",
"import",
"--export",
exportDir,
"--dry-run",
"--json",
]),
) as { dryRun: boolean; createdCount: number };
expect(dryRun.dryRun).toBe(true);
expect(dryRun.createdCount).toBe(1);
await expect(fs.readdir(path.join(rootDir, "sources"))).resolves.toStrictEqual([]);
const applied = JSON.parse(
await runRegisteredWikiCommand(config, [
"chatgpt",
"import",
"--export",
exportDir,
"--json",
]),
) as { runId?: string; createdCount: number };
expect(applied.runId).toMatch(/^chatgpt-[a-f0-9]{12}$/u);
expect(applied.createdCount).toBe(1);
await expect(
fs.stat(resolveLegacyImportRunRecordPath(rootDir, applied.runId ?? "")),
).rejects.toMatchObject({ code: "ENOENT" });
const sourceFile = await findImportedSourceFile(rootDir);
const pageContent = await fs.readFile(path.join(rootDir, "sources", sourceFile), "utf8");
expect(pageContent).toContain("ChatGPT Export: Travel preference check");
expect(pageContent).toContain("I prefer aisle seats");
expect(pageContent).toContain("Preference signals:");
const secondDryRun = JSON.parse(
await runRegisteredWikiCommand(config, [
"chatgpt",
"import",
"--export",
exportDir,
"--dry-run",
"--json",
]),
) as { createdCount: number; updatedCount: number; skippedCount: number };
expect(secondDryRun.createdCount).toBe(0);
expect(secondDryRun.updatedCount).toBe(0);
expect(secondDryRun.skippedCount).toBe(1);
if (!applied.runId) {
throw new Error("Expected ChatGPT import dry-run apply runId");
}
const rollback = JSON.parse(
await runRegisteredWikiCommand(config, ["chatgpt", "rollback", applied.runId, "--json"]),
) as { alreadyRolledBack: boolean; preservedPaths: unknown[] };
expect(rollback.alreadyRolledBack).toBe(false);
expect(rollback.preservedPaths).toStrictEqual([]);
await expect(
fs
.readdir(path.join(rootDir, "sources"))
.then((entries) => entries.filter((entry) => entry !== "index.md")),
).resolves.toStrictEqual([]);
});
it("preserves user edits made after import when rolling back a created page", async () => {
const { rootDir, config } = await createCliVault({ initialize: true });
const exportDir = await createChatGptExport(rootDir);
const applied = JSON.parse(
await runRegisteredWikiCommand(config, [
"chatgpt",
"import",
"--export",
exportDir,
"--json",
]),
) as { runId?: string };
const runId = expectDefined(applied.runId, "ChatGPT import runId");
const sourceFile = await findImportedSourceFile(rootDir);
const pagePath = path.join(rootDir, "sources", sourceFile);
const edited = `${await fs.readFile(pagePath, "utf8")}\nUser note added after import.\n`;
await fs.writeFile(pagePath, edited, "utf8");
const rollback = JSON.parse(
await runRegisteredWikiCommand(config, ["chatgpt", "rollback", runId, "--json"]),
) as { preservedPaths: Array<{ path: string; recoveryPath: string }> };
await expect(fs.stat(pagePath)).rejects.toMatchObject({ code: "ENOENT" });
expect(rollback.preservedPaths).toHaveLength(1);
const preserved = expectDefined(rollback.preservedPaths[0], "preserved rollback page");
expect(preserved.path).toBe(`sources/${sourceFile}`);
await expect(fs.readFile(path.join(rootDir, preserved.recoveryPath), "utf8")).resolves.toBe(
edited,
);
});
it("preserves a user save after compile before the import run record is written", async () => {
const { rootDir, config } = await createCliVault({ initialize: true });
const exportDir = await createChatGptExport(rootDir);
let pagePath = "";
let edited = "";
afterCompileHook.run = async () => {
if (pagePath) {
return;
}
pagePath = path.join(rootDir, "sources", await findImportedSourceFile(rootDir));
edited = `${await fs.readFile(pagePath, "utf8")}\nUser save after compile.\n`;
await fs.writeFile(pagePath, edited, "utf8");
};
const applied = JSON.parse(
await runRegisteredWikiCommand(config, [
"chatgpt",
"import",
"--export",
exportDir,
"--json",
]),
) as { runId?: string };
afterCompileHook.run = undefined;
const runId = expectDefined(applied.runId, "ChatGPT import runId");
const rollback = JSON.parse(
await runRegisteredWikiCommand(config, ["chatgpt", "rollback", runId, "--json"]),
) as { preservedPaths: Array<{ path: string; recoveryPath: string }> };
expect(pagePath).not.toBe("");
await expect(fs.stat(pagePath)).rejects.toMatchObject({ code: "ENOENT" });
expect(rollback.preservedPaths).toHaveLength(1);
const preserved = expectDefined(rollback.preservedPaths[0], "preserved post-compile save");
await expect(fs.readFile(path.join(rootDir, preserved.recoveryPath), "utf8")).resolves.toBe(
edited,
);
});
it("preserves user edits made after a re-import when rolling back an updated page", async () => {
const { rootDir, config } = await createCliVault({ initialize: true });
const exportDir = await createChatGptExport(rootDir);
await runRegisteredWikiCommand(config, ["chatgpt", "import", "--export", exportDir, "--json"]);
const sourceFile = await findImportedSourceFile(rootDir);
const pagePath = path.join(rootDir, "sources", sourceFile);
const firstImportContent = await fs.readFile(pagePath, "utf8");
const conversationsPath = path.join(exportDir, "conversations.json");
const conversationsText = await fs.readFile(conversationsPath, "utf8");
await fs.writeFile(
conversationsPath,
conversationsText.replace(
"Noted. I will keep travel options close to the airport.",
"Noted. I will keep hotel options within a short ride of the airport.",
),
"utf8",
);
const secondApplied = JSON.parse(
await runRegisteredWikiCommand(config, [
"chatgpt",
"import",
"--export",
exportDir,
"--json",
]),
) as { runId?: string; updatedCount: number };
expect(secondApplied.updatedCount).toBe(1);
const secondRunId = expectDefined(secondApplied.runId, "second ChatGPT import runId");
const edited = `${await fs.readFile(pagePath, "utf8")}\nUser note added after re-import.\n`;
await fs.writeFile(pagePath, edited, "utf8");
const rollback = JSON.parse(
await runRegisteredWikiCommand(config, ["chatgpt", "rollback", secondRunId, "--json"]),
) as { restoredCount: number; preservedPaths: Array<{ path: string; recoveryPath: string }> };
expect(rollback.restoredCount).toBe(1);
await expect(fs.readFile(pagePath, "utf8")).resolves.toBe(firstImportContent);
expect(rollback.preservedPaths).toHaveLength(1);
const preserved = expectDefined(rollback.preservedPaths[0], "preserved rollback page");
expect(preserved.path).toBe(`sources/${sourceFile}`);
await expect(fs.readFile(path.join(rootDir, preserved.recoveryPath), "utf8")).resolves.toBe(
edited,
);
});
it("preserves a page recreated between the rollback move-aside and the snapshot restore", async () => {
const { rootDir, config } = await createCliVault({ initialize: true });
const exportDir = await createChatGptExport(rootDir);
await runRegisteredWikiCommand(config, ["chatgpt", "import", "--export", exportDir, "--json"]);
const sourceFile = await findImportedSourceFile(rootDir);
const pagePath = path.join(rootDir, "sources", sourceFile);
const firstImportContent = await fs.readFile(pagePath, "utf8");
const conversationsPath = path.join(exportDir, "conversations.json");
const conversationsText = await fs.readFile(conversationsPath, "utf8");
await fs.writeFile(
conversationsPath,
conversationsText.replace(
"Noted. I will keep travel options close to the airport.",
"Noted. I will keep hotel options within a short ride of the airport.",
),
"utf8",
);
const secondApplied = JSON.parse(
await runRegisteredWikiCommand(config, [
"chatgpt",
"import",
"--export",
exportDir,
"--json",
]),
) as { runId?: string };
const secondRunId = expectDefined(secondApplied.runId, "second ChatGPT import runId");
const concurrentSave = "Concurrent editor save during rollback.\n";
let recreated = false;
const recoveryDestinations: string[] = [];
const realRename = fs.rename;
const renameSpy = vi
.spyOn(fs, "rename")
.mockImplementation(async (from: Parameters<typeof fs.rename>[0], to) => {
await realRename(from, to);
if (!recreated && String(to).includes("recovered")) {
recreated = true;
await fs.writeFile(from, concurrentSave, "utf8");
}
if (path.basename(String(to)) === "content") {
recoveryDestinations.push(String(to));
}
});
const rollback = JSON.parse(
await runRegisteredWikiCommand(config, ["chatgpt", "rollback", secondRunId, "--json"]),
) as { restoredCount: number; preservedPaths: Array<{ path: string; recoveryPath: string }> };
renameSpy.mockRestore();
expect(recreated).toBe(true);
expect(recoveryDestinations).toHaveLength(2);
expect(new Set(recoveryDestinations).size).toBe(2);
expect(rollback.restoredCount).toBe(1);
await expect(fs.readFile(pagePath, "utf8")).resolves.toBe(firstImportContent);
expect(rollback.preservedPaths).toHaveLength(1);
const preserved = expectDefined(rollback.preservedPaths[0], "preserved recreated page");
expect(preserved.path).toBe(`sources/${sourceFile}`);
await expect(fs.readFile(path.join(rootDir, preserved.recoveryPath), "utf8")).resolves.toBe(
concurrentSave,
);
});
it("imports ChatGPT exports with out-of-range Unix timestamps", async () => {
const { rootDir, config } = await createCliVault({ initialize: true });
const exportDir = await createChatGptExport(rootDir);
const conversationsPath = path.join(exportDir, "conversations.json");
const conversations = JSON.parse(await fs.readFile(conversationsPath, "utf8")) as Array<
Record<string, unknown>
>;
expectDefined(conversations[0], "first ChatGPT conversation").update_time = 9_000_000_000_000;
await fs.writeFile(conversationsPath, `${JSON.stringify(conversations, null, 2)}\n`, "utf8");
const result = JSON.parse(
await runRegisteredWikiCommand(config, [
"chatgpt",
"import",
"--export",
exportDir,
"--json",
]),
) as { createdCount: number };
expect(result.createdCount).toBe(1);
const sourceFile = (await fs.readdir(path.join(rootDir, "sources"))).find(
(entry) => entry !== "index.md",
);
expect(sourceFile).toBeDefined();
const pageContent = await fs.readFile(path.join(rootDir, "sources", sourceFile ?? ""), "utf8");
expect(pageContent).toContain("- Created: 2024-04-06T00:26:40.000Z");
expect(pageContent).toContain("- Updated: 2024-04-06T00:26:40.000Z");
});
});