Files
edgeever/apps/web/src/lib/desktop-sync.test.mjs
T

524 lines
21 KiB
JavaScript

import { describe, expect, test } from "bun:test";
const {
classifyDesktopSyncFailure,
discardDesktopConflicts,
createDesktopSyncIssueDetails,
createDesktopSyncDiagnosticText,
createDesktopSyncSummary,
isStagedResourceReferenced,
stagedIdsReferencedByUnsavedContent,
hasDesktopSyncStateReset,
mergeMemoIdMappings,
mergeSyncedMemos,
normalizeDesktopMemoPayload,
pauseDesktopSyncForImport,
orderBootstrapNotebooks,
orderDesktopSyncChanges,
resolveDesktopMemoSyncBase,
resolveDesktopStaleMemoUpdate,
desktopLocalRevisionWitnessesRemote,
rewriteStagedResource,
syncDesktopData,
shouldAttemptDesktopRecoveryPull,
shouldPullDesktopChanges,
} = await import("./desktop-sync.ts");
const { api, ApiRequestError } = await import("./api.ts");
test("desktop notebook create announces its durable id after sidecar acknowledgment", async () => {
const previousWindow = globalThis.window;
const previousNavigator = globalThis.navigator;
const originalCreateNotebook = api.createNotebook;
const events = [];
const calls = [];
let outboxReads = 0;
const item = { id: 4, version: 1, kind: "notebook.create", entityId: "nb_local_1", payload: { name: "测试" } };
globalThis.window = {
dispatchEvent: (event) => { events.push(event); return true; },
edgeeverDesktop: {
isAvailable: true,
listStagedResources: async () => [],
listStagedResourceAliases: async () => [],
sidecarRequest: async (method, params) => {
calls.push(method);
if (method === "sync.outbox.list") {
if (params.limit === 200) return { items: [] };
outboxReads += 1;
return { items: outboxReads === 1 ? [] : [item] };
}
if (method === "sync.status") return { pending: 1, syncing: 0 };
return { ok: true };
},
},
};
globalThis.navigator = { onLine: true };
api.createNotebook = async () => ({ notebook: { id: "nb_remote_1", name: "测试", parentId: null } });
try {
const result = await syncDesktopData();
expect(result.failed).toBe(0);
expect(calls).toContain("sync.outbox.ack");
expect(events.find((event) => event.type === "edgeever:notebook-id-remapped")?.detail).toEqual({
temporaryId: "nb_local_1",
remoteId: "nb_remote_1",
});
expect(calls.indexOf("sync.outbox.ack")).toBeLessThan(calls.indexOf("sync.status"));
} finally {
api.createNotebook = originalCreateNotebook;
globalThis.navigator = previousNavigator;
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
}
});
test("desktop creates notebooks before notes and rereads remapped queue versions", async () => {
const previousWindow = globalThis.window;
const previousNavigator = globalThis.navigator;
const originalCreateNotebook = api.createNotebook;
const originalCreateMemo = api.createMemo;
const calls = [];
let queue = [
{ id: 1, version: 1, kind: "notebook.create", entityId: "nb_local_parent", payload: { name: "对对对" } },
...[2, 3].map((id) => ({ id, version: 1, kind: "memo.create", entityId: `memo_local_${id}`, payload: { notebookId: "nb_local_parent", contentMarkdown: `body ${id}` } })),
];
globalThis.window = {
dispatchEvent: () => true,
edgeeverDesktop: {
isAvailable: true,
listStagedResources: async () => [],
listStagedResourceAliases: async () => [],
sidecarRequest: async (method, params) => {
if (method === "sync.outbox.list") return { items: queue.map((item) => ({ ...item, payload: { ...item.payload } })) };
if (method === "sync.status") return { pending: 1, syncing: 0 };
if (method === "sync.outbox.ack") {
const current = queue.find((item) => item.id === params.id);
expect(params.version).toBe(current.version);
queue = queue.filter((item) => item.id !== params.id);
if (params.remoteNotebook) {
queue = queue.map((item) => ({ ...item, version: item.version + 1, payload: { ...item.payload, notebookId: "nb_cloud" } }));
}
}
if (method === "sync.outbox.fail") throw new Error("No dependency should fail");
return { ok: true };
},
},
};
globalThis.navigator = { onLine: true };
api.createNotebook = async () => {
calls.push("notebook");
return { notebook: { id: "nb_cloud", name: "对对对", parentId: null } };
};
api.createMemo = async (payload) => {
expect(payload.notebookId).toBe("nb_cloud");
calls.push(payload.contentMarkdown);
return { memo: { id: `memo_cloud_${calls.length}`, contentMarkdown: payload.contentMarkdown, contentJson: { type: "doc", content: [] } } };
};
try {
const result = await syncDesktopData();
expect(result.failed).toBe(0);
expect(result.synced).toBe(3);
expect(calls).toEqual(["notebook", "body 2", "body 3"]);
expect(queue).toEqual([]);
} finally {
api.createNotebook = originalCreateNotebook;
api.createMemo = originalCreateMemo;
globalThis.navigator = previousNavigator;
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
}
});
test("desktop leaves dependent notes pending when their notebook upload fails", async () => {
const previousWindow = globalThis.window;
const previousNavigator = globalThis.navigator;
const originalCreateNotebook = api.createNotebook;
const originalCreateMemo = api.createMemo;
const failures = [];
let notebookEligible = true;
const notebook = { id: 1, version: 1, attemptCount: 0, kind: "notebook.create", entityId: "nb_local_failed", payload: { name: "Pending" } };
const note = { id: 2, version: 1, kind: "memo.create", entityId: "memo_local_waiting", payload: { notebookId: notebook.entityId } };
globalThis.window = {
dispatchEvent: () => true,
edgeeverDesktop: {
isAvailable: true,
listStagedResources: async () => [],
listStagedResourceAliases: async () => [],
sidecarRequest: async (method, params) => {
if (method === "sync.outbox.list") return { items: notebookEligible ? [notebook, note] : [note] };
if (method === "sync.status") return { pending: 1, syncing: 0, error: 1 };
if (method === "sync.outbox.fail") { failures.push(params.id); notebookEligible = false; }
return { ok: true };
},
},
};
globalThis.navigator = { onLine: true };
api.createNotebook = async () => { throw new TypeError("Network unavailable"); };
api.createMemo = async () => { throw new Error("Dependent notes must wait"); };
try {
const result = await syncDesktopData();
expect(result.failed).toBe(1);
expect(result.attempted).toBe(1);
expect(failures).toEqual([notebook.id]);
} finally {
api.createNotebook = originalCreateNotebook;
api.createMemo = originalCreateMemo;
globalThis.navigator = previousNavigator;
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
}
});
test("desktop explicit discard clears a conflict whose cloud note is gone", async () => {
const previousWindow = globalThis.window;
const originalGetMemo = api.getMemo;
const calls = [];
globalThis.window = {
edgeeverDesktop: {
isAvailable: true,
sidecarRequest: async (method, params) => {
calls.push({ method, params });
if (method === "sync.outbox.list") return { items: [{ id: 7, version: 2, kind: "memo.update", entityId: "memo-gone", status: "conflict" }] };
return { ok: true };
},
},
};
api.getMemo = async () => { throw new ApiRequestError("Not found", 404, "not_found"); };
try {
expect(await discardDesktopConflicts()).toBe(1);
expect(calls.map((call) => call.method)).toEqual(["sync.outbox.list", "sync.outbox.discard"]);
expect(calls[1].params).toEqual({ id: 7, version: 2 });
} finally {
api.getMemo = originalGetMemo;
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
}
});
describe("desktop staged resource sync", () => {
test("holds cloud sync until an imported screenshot is saved", async () => {
const originalWindow = globalThis.window;
const events = [];
globalThis.window = { dispatchEvent: (event) => { events.push(event.type); return true; } };
try {
const release = await pauseDesktopSyncForImport();
const result = await syncDesktopData();
expect(result.attempted).toBe(0);
expect(events).not.toContain("edgeever:sync-queue-changed");
release();
release();
expect(events.filter((event) => event === "edgeever:sync-queue-changed")).toHaveLength(1);
} finally {
if (originalWindow !== undefined) globalThis.window = originalWindow;
}
});
test("rewrites placeholders in memo JSON and markdown", () => {
const rewrites = [{ memoId: "memo-1", placeholder: "edgeever-staged://stage-1", url: "/api/v1/resources/resource-1/blob" }];
const value = {
contentJson: { type: "doc", content: [{ type: "image", attrs: { src: "edgeever-staged://stage-1" } }] },
contentMarkdown: "![photo](edgeever-staged://stage-1)",
};
expect(rewriteStagedResource(value, rewrites)).toEqual({
contentJson: { type: "doc", content: [{ type: "image", attrs: { src: "/api/v1/resources/resource-1/blob" } }] },
contentMarkdown: "![photo](/api/v1/resources/resource-1/blob)",
});
});
test("never sends desktop-only PDF URLs to the cloud", () => {
expect(normalizeDesktopMemoPayload({
contentJson: {
type: "doc",
content: [{ type: "pdfAttachment", attrs: { url: "edgeever-resource://resource/res_pdf" } }],
},
contentMarkdown: "[Attachment: report.pdf](edgeever-resource://resource/res_pdf)",
})).toMatchObject({
contentJson: {
content: [{ attrs: { url: "/api/v1/resources/res_pdf/blob" } }],
},
contentMarkdown: "[Attachment: report.pdf](/api/v1/resources/res_pdf/blob)",
});
});
test("does not consume a staged image before a saved memo update references it", () => {
const stagedId = "stage-1";
expect(isStagedResourceReferenced([], stagedId)).toBe(false);
expect(isStagedResourceReferenced([
{ contentJson: { type: "doc", content: [{ type: "image", attrs: { src: `edgeever-staged://${stagedId}` } }] } },
], stagedId)).toBe(true);
});
test("does not confuse one staged image id with a longer id that shares its prefix", () => {
expect(isStagedResourceReferenced([
{ contentMarkdown: "![photo](edgeever-staged://stage-10)" },
], "stage-1")).toBe(false);
expect(isStagedResourceReferenced([
{ contentMarkdown: "![photo](edgeever-staged://stage-1)" },
], "stage-1")).toBe(true);
});
test("keeps staged bytes while an editor draft or visible image still uses the old URL", () => {
const rewrites = [{ memoId: "memo-1", placeholder: "edgeever-staged://stage-1", url: "/api/v1/resources/res-1/blob" }];
expect(stagedIdsReferencedByUnsavedContent(rewrites, [
{ contentJson: { type: "doc", content: [{ type: "image", attrs: { src: "edgeever-staged://stage-1" } }] } },
])).toEqual(new Set(["stage-1"]));
expect(stagedIdsReferencedByUnsavedContent(rewrites, ["edgeever-staged://stage-1"])).toEqual(new Set(["stage-1"]));
expect(stagedIdsReferencedByUnsavedContent(rewrites, ["edgeever-staged://stage-10"])).toEqual(new Set());
});
test("retains a temporary id mapping when a later sync phase fails", () => {
const retained = mergeMemoIdMappings(new Map(), new Map([["memo_local_1", "memo_remote_1"]]));
expect(retained.get("memo_local_1")).toBe("memo_remote_1");
});
test("keeps the latest acknowledged memo base across sync phases", () => {
const created = { id: "memo_remote_1", revision: 0 };
const updated = { id: "memo_remote_1", revision: 1 };
const retained = mergeSyncedMemos(
new Map([[created.id, created]]),
new Map([[updated.id, updated]]),
);
expect(retained.get("memo_remote_1")).toEqual(updated);
});
});
describe("desktop bootstrap sync", () => {
test("rebuilds when the server cursor rewinds or its identity changes", () => {
const local = { cursor: 42, syncIdentity: "workspace-a" };
expect(hasDesktopSyncStateReset(local, { serverCursor: 7, syncIdentity: "workspace-a" })).toBe(true);
expect(hasDesktopSyncStateReset(local, { serverCursor: 42, syncIdentity: "workspace-b" })).toBe(true);
expect(hasDesktopSyncStateReset(local, { serverCursor: 64, syncIdentity: "workspace-a" })).toBe(false);
});
test("orders parent notebooks before their children", () => {
const child = { id: "child", parentId: "parent", name: "Child" };
const parent = { id: "parent", parentId: null, name: "Parent" };
const grandchild = { id: "grandchild", parentId: "child", name: "Grandchild" };
expect(orderBootstrapNotebooks([grandchild, child, parent]).map((notebook) => notebook.id)).toEqual([
"parent",
"child",
"grandchild",
]);
});
test("applies notebook upserts before memos in an incremental change page", () => {
const memo = { entityType: "memo", entityId: "memo-1", operation: "upsert", notebook: null };
const child = {
entityType: "notebook",
entityId: "child",
operation: "upsert",
notebook: { id: "child", parentId: "parent" },
};
const parent = {
entityType: "notebook",
entityId: "parent",
operation: "upsert",
notebook: { id: "parent", parentId: null },
};
const deleted = { entityType: "notebook", entityId: "gone", operation: "delete", notebook: null };
expect(orderDesktopSyncChanges([memo, child, deleted, parent]).map((change) => change.entityId)).toEqual([
"parent",
"child",
"gone",
"memo-1",
]);
});
});
describe("desktop memo sync base", () => {
test("repairs a legacy local autosave revision that is ahead of the cloud", () => {
expect(resolveDesktopMemoSyncBase(
{ revision: 3, contentHash: "cloud-3" },
{ expectedRevision: 9, expectedContentHash: "local-autosave-9" },
)).toEqual({ expectedRevision: 3, expectedContentHash: "cloud-3" });
});
test("keeps a genuinely stale base so the server update remains protected", () => {
expect(resolveDesktopMemoSyncBase(
{ revision: 9, contentHash: "cloud-9" },
{ expectedRevision: 3, expectedContentHash: "cloud-3" },
)).toEqual({ expectedRevision: 3, expectedContentHash: "cloud-3" });
});
test("acks a lost save when the cloud already has the queued payload", () => {
expect(resolveDesktopStaleMemoUpdate({
current: { revision: 10, contentHash: "hash-a" },
expected: { expectedRevision: 9, expectedContentHash: "hash-before" },
payload: { title: "", tags: [], contentMarkdown: "一期验收通过。", contentJson: { type: "doc" } },
remote: { title: "无标题笔记", tags: [], contentMarkdown: "一期验收通过。", contentHash: "hash-a", contentJson: { type: "doc" } },
localRevisions: [],
})).toBe("ack");
});
test("rebases a later local draft when a sidecar snapshot already contains the cloud body", () => {
const remote = {
title: "无标题笔记",
tags: [],
contentMarkdown: "一期验收通过。",
contentHash: "hash-a",
contentJson: { type: "doc", content: [{ type: "paragraph" }] },
};
expect(desktopLocalRevisionWitnessesRemote([
{
id: "revision_local_1",
revision: 9,
contentHash: "sidecar-a",
contentMarkdown: "一期验收通过。",
contentJson: remote.contentJson,
},
], remote, 9)).toBe(true);
expect(resolveDesktopStaleMemoUpdate({
current: { revision: 10, contentHash: "hash-a" },
expected: { expectedRevision: 9, expectedContentHash: "hash-before" },
payload: { title: "", tags: [], contentMarkdown: "一期验收通过。然后继续写。", contentJson: { type: "doc" } },
remote,
localRevisions: [{
id: "revision_local_1",
revision: 9,
contentHash: "sidecar-a",
contentMarkdown: "一期验收通过。",
contentJson: remote.contentJson,
}],
})).toBe("rebase");
});
test("does not treat an older local snapshot or a remote-cached revision as proof", () => {
const remote = {
title: "无标题笔记",
tags: [],
contentMarkdown: "别人改过的正文",
contentHash: "hash-other",
contentJson: { type: "doc" },
};
expect(desktopLocalRevisionWitnessesRemote([
{
id: "revision_local_old",
revision: 3,
contentHash: "hash-other",
contentMarkdown: "别人改过的正文",
contentJson: remote.contentJson,
},
{
id: "rev_remote_cached",
revision: 10,
contentHash: "hash-other",
contentMarkdown: "别人改过的正文",
contentJson: remote.contentJson,
},
], remote, 9)).toBe(false);
expect(resolveDesktopStaleMemoUpdate({
current: { revision: 10, contentHash: "hash-other" },
expected: { expectedRevision: 9, expectedContentHash: "hash-before" },
payload: { title: "", tags: [], contentMarkdown: "本地还在写的草稿", contentJson: { type: "doc" } },
remote,
localRevisions: [{
id: "revision_local_9",
revision: 9,
contentHash: "sidecar-local",
contentMarkdown: "本地还在写的草稿",
contentJson: { type: "doc" },
}],
})).toBe("conflict");
});
});
describe("desktop sync failure handling", () => {
test("stops retrying a missing memo update and keeps transient failures retryable", () => {
expect(classifyDesktopSyncFailure(
{ kind: "memo.update" },
new ApiRequestError("Memo not found", 404, "not_found"),
)).toEqual({ conflict: false, retryable: false, errorCode: "memo_not_found" });
expect(classifyDesktopSyncFailure(
{ kind: "memo.update" },
new ApiRequestError("Temporarily unavailable", 503),
)).toEqual({ conflict: false, retryable: true, errorCode: "http_503" });
});
test("copies diagnostics without exposing entity ids or payload content", () => {
const diagnostic = createDesktopSyncDiagnosticText([{
id: 7,
kind: "memo.update",
entityId: "private-memo-id",
payload: { contentMarkdown: "private note body" },
attemptCount: 3,
version: 1,
status: "error",
lastError: "Memo memo_private123 not found at https://private.example.test/api",
lastErrorCode: "memo_not_found",
retryable: false,
}]);
expect(diagnostic).toContain("memo_not_found");
expect(diagnostic).toContain('"totalItemCount": 1');
expect(diagnostic).not.toContain('"id": 7');
expect(diagnostic).not.toContain("private-memo-id");
expect(diagnostic).not.toContain("private note body");
expect(diagnostic).not.toContain("memo_private123");
expect(diagnostic).not.toContain("private.example.test");
});
test("keeps a global failure visible in both the summary and details", () => {
const globalIssue = {
phase: "pull_remote_changes",
message: "Request failed at https://private.example.test/api/v1/sync/changes",
errorCode: "http_500",
occurredAt: "2026-09-05T00:00:00.000Z",
};
expect(createDesktopSyncSummary({ pending: 0, syncing: 0, conflict: 0, error: 0 }, globalIssue)).toEqual({
total: 1,
pending: 0,
syncing: 0,
conflict: 0,
error: 1,
});
expect(createDesktopSyncIssueDetails([], globalIssue)).toEqual({ globalIssue, items: [] });
expect(createDesktopSyncDiagnosticText([], globalIssue)).toContain("pull_remote_changes");
expect(createDesktopSyncDiagnosticText([], globalIssue)).not.toContain("private.example.test");
});
test("bounds diagnostics for a prefilled GitHub Issue URL", () => {
const items = Array.from({ length: 200 }, (_, id) => ({
id,
kind: "memo.update",
entityId: `memo_private_${id}`,
payload: { contentMarkdown: "private note body" },
attemptCount: 99_999,
version: 1,
status: "error",
lastError: "Temporary failure ".repeat(40),
lastErrorCode: "network_error",
retryable: true,
}));
const diagnostic = createDesktopSyncDiagnosticText(items);
expect(diagnostic).toContain('"totalItemCount": 200');
expect(diagnostic).toContain('"includedItemCount": 5');
expect(encodeURIComponent(diagnostic).length).toBeLessThan(6_000);
});
test("allows remote pulls while durable errors are handled separately", () => {
expect(shouldPullDesktopChanges({ pending: 0, syncing: 0, error: 1, conflict: 1 }, true)).toBe(true);
expect(shouldPullDesktopChanges({ pending: 1, syncing: 0, error: 0, conflict: 0 }, true)).toBe(false);
expect(shouldPullDesktopChanges({ pending: 0, syncing: 0, error: 0, conflict: 0 }, false)).toBe(false);
});
test("attempts a recovery pull only when no local upload can be overwritten", () => {
expect(shouldAttemptDesktopRecoveryPull("sync_staged_resources", { pending: 0, syncing: 0 }, true)).toBe(true);
expect(shouldAttemptDesktopRecoveryPull("sync_staged_resources", { pending: 1, syncing: 0 }, true)).toBe(false);
expect(shouldAttemptDesktopRecoveryPull("sync_staged_resources", { pending: 0, syncing: 1 }, true)).toBe(false);
expect(shouldAttemptDesktopRecoveryPull("pull_remote_changes", { pending: 0, syncing: 0 }, true)).toBe(false);
expect(shouldAttemptDesktopRecoveryPull("finalize_resource_remap", { pending: 0, syncing: 0 }, true)).toBe(false);
expect(shouldAttemptDesktopRecoveryPull("sync_staged_resources", { pending: 0, syncing: 0 }, false)).toBe(false);
});
});