From f325d2454d53a1fe54690fd8697e81aa431641d6 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sat, 31 Oct 2026 08:49:49 +0000 Subject: [PATCH] Verify delayed optional-file queue consumers --- .../CmdConfigSync.queue.unit.spec.ts | 188 +++++++++++++++++ .../CmdHiddenFileSync.queue.unit.spec.ts | 198 ++++++++++++++++++ 2 files changed, 386 insertions(+) create mode 100644 src/features/ConfigSync/CmdConfigSync.queue.unit.spec.ts create mode 100644 src/features/HiddenFileSync/CmdHiddenFileSync.queue.unit.spec.ts diff --git a/src/features/ConfigSync/CmdConfigSync.queue.unit.spec.ts b/src/features/ConfigSync/CmdConfigSync.queue.unit.spec.ts new file mode 100644 index 00000000..5674f803 --- /dev/null +++ b/src/features/ConfigSync/CmdConfigSync.queue.unit.spec.ts @@ -0,0 +1,188 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/deps.ts", () => ({ + addIcon: vi.fn(), + diff_match_patch: class DiffMatchPatch {}, + normalizePath: (path: string) => path, + parseYaml: vi.fn(), + Platform: {}, +})); +vi.mock("./PluginDialogModal.ts", () => ({ PluginDialogModal: class PluginDialogModal {} })); +vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ JsonResolveModal: class JsonResolveModal {} })); +vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({ + ConflictResolveModal: class ConflictResolveModal {}, +})); +vi.mock("@/modules/features/ModuleLog.ts", () => ({ MARK_DONE: "" })); +vi.mock("@/common/utils.ts", () => ({ + cancelTask: vi.fn(), + EVEN: Symbol("even"), + isCustomisationSyncMetadata: vi.fn(), + isPluginMetadata: vi.fn(), + scheduleTask: vi.fn(), +})); +vi.mock("@/common/PeriodicProcessor.ts", () => ({ PeriodicProcessor: class PeriodicProcessor {} })); +vi.mock("@/common/events.ts", () => ({ + EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync", + eventHub: { onEvent: vi.fn() }, +})); +vi.mock("@/common/translation", () => ({ $msg: (message: string) => message })); +vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({ getObsidianCommunityPluginManager: vi.fn() })); + +import { get } from "svelte/store"; +import { DEFAULT_SETTINGS, type FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { createOptionalFileSyncDatabase, optionalFileEntry } from "../../../test/fixtures/optionalFileSyncDatabase"; +import { ConfigSync, pluginIsEnumerating, pluginList } from "./CmdConfigSync"; + +const queueOwners: ConfigSync[] = []; + +function deferred() { + let resolve!: () => 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); +} + +afterEach(() => { + for (const configSync of queueOwners.splice(0)) { + configSync.pluginScanProcessor.terminateAll(); + configSync.pluginScanProcessorV2.terminateAll(); + } + vi.restoreAllMocks(); + pluginList.set([]); + pluginIsEnumerating.set(false); + vi.useRealTimers(); +}); + +describe("ConfigSync legacy catalogue queue", () => { + it("loads legacy catalogue contents through the database with native timers", async () => { + const fixture = await createOptionalFileSyncDatabase(); + const path = "ix:device/PLUGIN_MAIN/native-timers" as FilePathWithPrefix; + const data = { + category: "PLUGIN_MAIN", + name: "native-timers", + term: "device", + mtime: 2_000, + files: [{ filename: "main.js", data: ["stored content"], mtime: 2_000, size: 14, hash: "stored-hash" }], + }; + let configSync: ConfigSync | undefined; + try { + fixture.settings.usePluginSyncV2 = false; + await expect( + fixture.localDatabase.putDBEntry(optionalFileEntry(path, JSON.stringify(data))) + ).resolves.toMatchObject({ ok: true }); + vi.spyOn(ConfigSync.prototype, "onBindFunction").mockImplementation(() => {}); + configSync = new ConfigSync({ + settings: fixture.settings, + localDatabase: fixture.localDatabase, + services: { + ...fixture.services, + API: { ...fixture.services.API, addRibbonIcon: vi.fn() }, + }, + } as unknown as ConstructorParameters[0]); + queueOwners.push(configSync); + await configSync.updatePluginList(false); + expect(await configSync.pluginScanProcessor.waitForAllProcessed(2_000)).toBe(true); + expect(configSync.pluginList).toEqual([ + { + ...data, + documentPath: path, + files: [{ ...data.files[0], data: ["stored-hash"] }], + }, + ]); + expect(get(pluginList)).toEqual(configSync.pluginList); + expect(configSync.pluginScanProcessor.remaining).toBe(0); + } finally { + configSync?.pluginScanProcessor.terminateAll(); + configSync?.pluginScanProcessorV2.terminateAll(); + await fixture.dispose(); + } + }); + + it("returns after enumeration, then loads a queued legacy catalogue item after its configured delay", async () => { + vi.useFakeTimers(); + pluginList.set([]); + pluginIsEnumerating.set(false); + + const path = "ix:device/PLUGIN_MAIN/example" as FilePathWithPrefix; + const catalogueData = { + category: "PLUGIN_MAIN", + name: "example", + term: "device", + mtime: 2_000, + files: [ + { filename: "main.js", data: ["legacy plugin source"], mtime: 2_000, size: 20, hash: "legacy-hash" }, + ], + }; + const entry = { _id: path, path, mtime: 2_000, data: [JSON.stringify(catalogueData)] }; + const readGate = deferred(); + let loadHasStarted = false; + const localDatabase = { + findEntries: vi.fn(async function* () { + yield entry; + }), + getDBEntry: vi.fn(async () => { + loadHasStarted = true; + await readGate.promise; + return entry; + }), + }; + vi.spyOn(ConfigSync.prototype, "onBindFunction").mockImplementation(() => {}); + const configSync = new ConfigSync({ + settings: { ...DEFAULT_SETTINGS, usePluginSync: true, usePluginSyncV2: false }, + localDatabase, + services: { + API: { addLog: vi.fn(), addRibbonIcon: vi.fn() }, + path: { getPath: (document: { path: string }) => document.path }, + }, + } as unknown as ConstructorParameters[0]); + queueOwners.push(configSync); + + let drained = false; + try { + await configSync.updatePluginList(false); + + expect(configSync.pluginScanProcessor.delay).toBe(100); + expect(configSync.pluginScanProcessor.remaining).toBe(1); + expect(configSync.pluginList).toEqual([]); + expect(get(pluginIsEnumerating)).toBe(false); + + await vi.advanceTimersByTimeAsync(99); + expect(loadHasStarted).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + expect(loadHasStarted).toBe(true); + expect(configSync.pluginScanProcessor.remaining).toBe(1); + const drain = configSync.pluginScanProcessor.waitForAllProcessed(); + void drain.then((result) => { + drained = result; + }); + + readGate.resolve(); + await vi.advanceTimersByTimeAsync(1_000); + await waitForCondition(() => drained, "The legacy catalogue queue did not drain"); + + expect(configSync.pluginList).toEqual([ + { + ...catalogueData, + documentPath: path, + files: [{ ...catalogueData.files[0], data: ["legacy-hash"] }], + }, + ]); + expect(get(pluginList)).toEqual(configSync.pluginList); + expect(configSync.pluginScanProcessor.remaining).toBe(0); + expect(get(pluginIsEnumerating)).toBe(false); + } finally { + readGate.resolve(); + await vi.advanceTimersByTimeAsync(1_000); + } + }); +}); diff --git a/src/features/HiddenFileSync/CmdHiddenFileSync.queue.unit.spec.ts b/src/features/HiddenFileSync/CmdHiddenFileSync.queue.unit.spec.ts new file mode 100644 index 00000000..fbf68283 --- /dev/null +++ b/src/features/HiddenFileSync/CmdHiddenFileSync.queue.unit.spec.ts @@ -0,0 +1,198 @@ +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 {} })); +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.terminateAll(); + } + 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 { + expect(hiddenFileSync.conflictResolutionProcessor.delay).toBe(10); + 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 { + expect(hiddenFileSync.conflictResolutionProcessor.delay).toBe(10); + 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(); + } + }); +});