Linux Bun has document without a window binding. Deleting the test stub makes ProseMirror throw window is not defined. Leave the stub in place when there was no previous window.
355 lines
14 KiB
JavaScript
355 lines
14 KiB
JavaScript
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 {
|
|
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: "",
|
|
};
|
|
|
|
expect(rewriteStagedResource(value, rewrites)).toEqual({
|
|
contentJson: { type: "doc", content: [{ type: "image", attrs: { src: "/api/v1/resources/resource-1/blob" } }] },
|
|
contentMarkdown: "",
|
|
});
|
|
});
|
|
|
|
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: "" },
|
|
], "stage-1")).toBe(false);
|
|
expect(isStagedResourceReferenced([
|
|
{ contentMarkdown: "" },
|
|
], "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);
|
|
});
|
|
});
|