import { afterEach, describe, expect, it, vi } from "vitest"; vi.mock("@/deps.ts", () => ({})); vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ JsonResolveModal: class JsonResolveModal {} })); vi.mock("@/modules/features/ModuleLog.ts", () => ({ MARK_DONE: "" })); vi.mock("@/common/PeriodicProcessor.ts", () => ({ PeriodicProcessor: class PeriodicProcessor { disable = vi.fn(); }, })); vi.mock("./configureHiddenFileSyncMode.ts", () => ({ configureHiddenFileSyncMode: vi.fn() })); import { DEFAULT_SETTINGS, type DocumentID, type FilePathWithPrefix, type LoadedEntry, type MetaEntry, } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { HiddenFileSync } from "./CmdHiddenFileSync"; const queueOwners: HiddenFileSync[] = []; function deferred() { let resolve!: (value: T | PromiseLike) => void; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; } async function waitForCondition(predicate: () => boolean, message: string) { for (let attempt = 0; attempt < 30; attempt++) { if (predicate()) return; await Promise.resolve(); } throw new Error(message); } function createHiddenFileSync(localDatabase: object) { vi.spyOn(HiddenFileSync.prototype, "onBindFunction").mockImplementation(() => {}); const hiddenFileSync = new HiddenFileSync({ settings: { ...DEFAULT_SETTINGS, syncInternalFiles: true, syncInternalFileOverwritePatterns: "", }, localDatabase, services: { API: { addLog: vi.fn() }, context: { app: {} } }, } as unknown as ConstructorParameters[0]); hiddenFileSync._log = vi.fn(); queueOwners.push(hiddenFileSync); return { hiddenFileSync }; } afterEach(() => { for (const hiddenFileSync of queueOwners.splice(0)) { hiddenFileSync.conflictResolutionProcessor.dispose(); } vi.restoreAllMocks(); vi.useRealTimers(); }); describe("HiddenFileSync conflict queue", () => { it("keeps a self-requeued conflict check and waits for its follow-up", async () => { vi.useFakeTimers(); const path = "i:.obsidian/plugins/example/data.txt" as FilePathWithPrefix; const id = String(path) as DocumentID; const conflictDoc = { _id: id, _rev: "2-current", path, _conflicts: ["2-conflicted"], } as MetaEntry; const resolvedDoc = { _id: id, _rev: "3-resolved", path } as MetaEntry; let rawRead = 0; const localDatabase = { findEntries: vi.fn(async function* () { yield conflictDoc; }), getRaw: vi.fn(async () => (rawRead++ === 0 ? conflictDoc : resolvedDoc)), }; const { hiddenFileSync } = createHiddenFileSync(localDatabase); hiddenFileSync.path2id = async () => id; let resolutionCalls = 0; let requeued = false; hiddenFileSync.resolveByNewerEntry = vi.fn(async () => { resolutionCalls++; hiddenFileSync.requeueConflictCheck(path); requeued = true; }); let completed = false; hiddenFileSync.queueConflictCheck(path); try { await waitForCondition( () => hiddenFileSync.conflictResolutionProcessor.remaining === 1, "The initial conflict check was not queued" ); await vi.advanceTimersByTimeAsync(9); expect(localDatabase.getRaw).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); await waitForCondition( () => requeued && hiddenFileSync.conflictResolutionProcessor.nowProcessing === 0 && hiddenFileSync.conflictResolutionProcessor.remaining === 1, "The conflict follow-up was not retained after the first pass" ); void hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed().then(() => { completed = true; }); expect(completed).toBe(false); expect(localDatabase.getRaw).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(9); expect(localDatabase.getRaw).toHaveBeenCalledTimes(1); expect(completed).toBe(false); await vi.advanceTimersByTimeAsync(1); await vi.advanceTimersByTimeAsync(1_000); await waitForCondition(() => completed, "The conflict pipeline did not await its follow-up"); expect(localDatabase.getRaw).toHaveBeenCalledTimes(2); expect(resolutionCalls).toBe(1); expect(hiddenFileSync.pendingConflictChecks.has(path)).toBe(false); expect(hiddenFileSync.conflictResolutionProcessor.remaining).toBe(0); } finally { await vi.runAllTimersAsync(); } }); it("waits for a cancelled manual merge dialogue and then finishes without requeueing", async () => { vi.useFakeTimers(); const path = "i:.obsidian/plugins/example/data.json" as FilePathWithPrefix; const id = String(path) as DocumentID; const conflictDoc = { _id: id, _rev: "2-current", path, _conflicts: ["2-conflicted"], } as MetaEntry; const historyDoc = {} as unknown as MetaEntry; const loaded = { _id: id, _rev: "2-current", path } as LoadedEntry; const dialogue = deferred(); let dialogueOpened = false; const localDatabase = { findEntries: vi.fn(async function* () { yield conflictDoc; }), getRaw: vi.fn(async (_id: DocumentID, options?: { revs_info?: boolean }) => options?.revs_info ? historyDoc : conflictDoc ), getDBEntry: vi.fn(async () => loaded), managers: { conflictManager: { mergeObject: vi.fn(async () => false), }, }, }; const { hiddenFileSync } = createHiddenFileSync(localDatabase); hiddenFileSync.path2id = async () => id; hiddenFileSync.showJSONMergeDialogAndMerge = vi.fn(async () => { dialogueOpened = true; return await dialogue.promise; }); let completed = false; hiddenFileSync.queueConflictCheck(path); try { await waitForCondition( () => hiddenFileSync.conflictResolutionProcessor.remaining === 1, "The initial conflict check was not queued" ); await vi.advanceTimersByTimeAsync(100); expect(dialogueOpened).toBe(true); void hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed().then(() => { completed = true; }); expect(completed).toBe(false); expect(hiddenFileSync.conflictResolutionProcessor.totalRemaining).toBeGreaterThan(0); expect(localDatabase.managers.conflictManager.mergeObject).toHaveBeenCalledOnce(); expect(localDatabase.getDBEntry).toHaveBeenCalledTimes(2); dialogue.resolve(false); await vi.advanceTimersByTimeAsync(1_000); await waitForCondition(() => completed, "The cancelled dialogue did not release the pipeline"); expect(hiddenFileSync.pendingConflictChecks.has(path)).toBe(false); expect(hiddenFileSync.conflictResolutionProcessor.totalRemaining).toBe(0); } finally { dialogue.resolve(false); await vi.runAllTimersAsync(); } }); }); describe("HiddenFileSync pipeline contracts", () => { it("starts above the threshold, limits analysis to five, and retains FIFO input", async () => { vi.useFakeTimers(); const gates = Array.from({ length: 11 }, () => deferred()); const paths = gates.map((_, index) => `i:.obsidian/${index}.txt` as FilePathWithPrefix); const reads: string[] = []; const getRaw = vi.fn((id: DocumentID) => { reads.push(id); return gates[paths.indexOf(id as unknown as FilePathWithPrefix)].promise; }); const { hiddenFileSync } = createHiddenFileSync({ getRaw }); hiddenFileSync.path2id = async (path) => path as unknown as DocumentID; for (const path of paths.slice(0, 10)) hiddenFileSync.queueConflictCheck(path); await vi.advanceTimersByTimeAsync(0); expect(reads).toEqual([]); hiddenFileSync.queueConflictCheck(paths[10]); await vi.advanceTimersByTimeAsync(0); expect(reads).toEqual(paths.slice(0, 5)); hiddenFileSync.queueConflictCheck(paths[0]); expect(hiddenFileSync.pendingConflictChecks.size).toBe(11); gates[0].resolve({ path: paths[0] } as MetaEntry); await vi.advanceTimersByTimeAsync(0); expect(reads).toEqual(paths.slice(0, 6)); gates.forEach((gate, index) => gate.resolve({ path: paths[index] } as MetaEntry)); await vi.advanceTimersByTimeAsync(100); expect(await hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed()).toBe(true); expect(reads).toEqual(paths); expect(hiddenFileSync.pendingConflictChecks.size).toBe(0); }); it("continues analysis while one manual dialogue is open and serialises the dialogues", async () => { vi.useFakeTimers(); const paths = Array.from({ length: 12 }, (_, index) => `i:.obsidian/${index}.json` as FilePathWithPrefix); const gates = paths.map(() => deferred()); const localDatabase = { getRaw: vi.fn(async (id: DocumentID, options?: { revs_info?: boolean }) => options?.revs_info ? {} : { _id: id, path: id, _rev: "2-current", _conflicts: ["2-other"], } ), getDBEntry: vi.fn(async (path: FilePathWithPrefix) => ({ path }) as LoadedEntry), managers: { conflictManager: { mergeObject: vi.fn(async () => false) } }, }; const { hiddenFileSync } = createHiddenFileSync(localDatabase); hiddenFileSync.path2id = async (path) => path as unknown as DocumentID; const opened: FilePathWithPrefix[] = []; hiddenFileSync.showJSONMergeDialogAndMerge = vi.fn((doc) => { opened.push(doc.path); return gates[paths.indexOf(doc.path)].promise; }); paths.forEach((path) => hiddenFileSync.queueConflictCheck(path)); await vi.advanceTimersByTimeAsync(100); expect(localDatabase.managers.conflictManager.mergeObject).toHaveBeenCalledTimes(12); expect(opened).toEqual([paths[0]]); paths.forEach((path) => hiddenFileSync.queueConflictCheck(path)); expect(hiddenFileSync.pendingConflictChecks.size).toBe(12); let completed = false; void hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed().then(() => { completed = true; }); for (let index = 0; index < paths.length; index++) { expect(opened).toEqual(paths.slice(0, index + 1)); expect(completed).toBe(false); gates[index].resolve(false); await vi.advanceTimersByTimeAsync(100); } expect(completed).toBe(true); expect(hiddenFileSync.pendingConflictChecks.size).toBe(0); }); it("logs an analysis error and continues with later paths", async () => { vi.useFakeTimers(); const failure = new Error("Injected read failure"); const getRaw = vi.fn().mockRejectedValueOnce(failure).mockResolvedValue({}); const { hiddenFileSync } = createHiddenFileSync({ getRaw }); hiddenFileSync.path2id = async (path) => path as unknown as DocumentID; hiddenFileSync.queueConflictCheck("i:bad.txt" as FilePathWithPrefix); hiddenFileSync.queueConflictCheck("i:good.txt" as FilePathWithPrefix); await vi.advanceTimersByTimeAsync(100); expect(await hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed()).toBe(true); expect(getRaw).toHaveBeenCalledTimes(2); expect(hiddenFileSync._log).toHaveBeenCalledWith(failure, expect.anything()); expect(hiddenFileSync.pendingConflictChecks.size).toBe(0); }); it("collects a conflict enumeration before resuming analysis", async () => { vi.useFakeTimers(); const gate = deferred(); const path = "i:.obsidian/scanned.txt" as FilePathWithPrefix; const getRaw = vi.fn(async () => ({})); const { hiddenFileSync } = createHiddenFileSync({ getRaw, findEntries: async function* () { await gate.promise; yield { _id: path, path, _conflicts: ["2-other"] }; }, }); hiddenFileSync.path2id = async (value) => value as unknown as DocumentID; const result = hiddenFileSync.resolveConflictOnInternalFiles(); await vi.advanceTimersByTimeAsync(100); hiddenFileSync.queueConflictCheck(path); await vi.advanceTimersByTimeAsync(100); expect(getRaw).not.toHaveBeenCalled(); gate.resolve(); await vi.advanceTimersByTimeAsync(100); await result; expect(getRaw).toHaveBeenCalledOnce(); expect(hiddenFileSync.pendingConflictChecks.size).toBe(0); }); }); describe("HiddenFileSync conflict queue ownership", () => { it("stops delayed work on unload and rejects later conflict events", async () => { vi.useFakeTimers(); const getRaw = vi.fn(async () => ({})); const { hiddenFileSync } = createHiddenFileSync({ getRaw }); hiddenFileSync.path2id = async (path) => path as unknown as DocumentID; const path = "i:.obsidian/queued.txt" as FilePathWithPrefix; hiddenFileSync.queueConflictCheck(path); const idle = hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed(); hiddenFileSync.onunload(); hiddenFileSync.queueConflictCheck(path); await vi.advanceTimersByTimeAsync(100); expect(await idle).toBe(false); expect(getRaw).not.toHaveBeenCalled(); expect(hiddenFileSync.pendingConflictChecks.size).toBe(0); expect(vi.getTimerCount()).toBe(0); }); it("finishes an admitted analysis without handing its result to a new dialogue after unload", async () => { vi.useFakeTimers(); const gate = deferred(); const path = "i:.obsidian/active.json" as FilePathWithPrefix; const getRaw = vi .fn() .mockImplementationOnce(() => gate.promise) .mockResolvedValue({}); const getDBEntry = vi.fn(async () => ({ path })); const mergeObject = vi.fn(async () => false); const { hiddenFileSync } = createHiddenFileSync({ getRaw, getDBEntry, managers: { conflictManager: { mergeObject } }, }); hiddenFileSync.path2id = async () => path as unknown as DocumentID; hiddenFileSync.queueConflictCheck(path); await vi.advanceTimersByTimeAsync(10); expect(getRaw).toHaveBeenCalledOnce(); hiddenFileSync.onunload(); expect(await hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed()).toBe(false); gate.resolve({ _id: String(path) as DocumentID, path, _rev: "2-current", _conflicts: ["2-other"], } as MetaEntry); await vi.advanceTimersByTimeAsync(100); expect(mergeObject).toHaveBeenCalledOnce(); expect(getDBEntry).not.toHaveBeenCalled(); expect(hiddenFileSync.pendingConflictChecks.size).toBe(0); }); it("does not requeue a manual resolution which finishes after unload", async () => { vi.useFakeTimers(); const gate = deferred(); const path = "i:.obsidian/dialogue.json" as FilePathWithPrefix; const getRaw = vi.fn(async (_id: DocumentID, options?: { revs_info?: boolean }) => options?.revs_info ? {} : { _id: path, path, _rev: "2-current", _conflicts: ["2-other"], } ); const getDBEntry = vi.fn(async () => ({ path }) as LoadedEntry); const { hiddenFileSync } = createHiddenFileSync({ getRaw, getDBEntry, managers: { conflictManager: { mergeObject: async () => false } }, }); hiddenFileSync.path2id = async () => path as unknown as DocumentID; hiddenFileSync.showJSONMergeDialogAndMerge = vi.fn(() => gate.promise); hiddenFileSync.queueConflictCheck(path); await vi.advanceTimersByTimeAsync(20); expect(hiddenFileSync.showJSONMergeDialogAndMerge).toHaveBeenCalledOnce(); hiddenFileSync.onunload(); expect(await hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed()).toBe(false); gate.resolve(true); await vi.advanceTimersByTimeAsync(100); expect(getRaw).toHaveBeenCalledTimes(2); expect(getDBEntry).toHaveBeenCalledTimes(2); expect(hiddenFileSync.pendingConflictChecks.size).toBe(0); expect(hiddenFileSync.conflictResolutionProcessor.totalRemaining).toBe(0); }); });