import { describe, expect, test } from "bun:test"; const { classifyDesktopSyncFailure, 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 { ApiRequestError } = await import("./api.ts"); 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 { 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); }); });