mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-02 22:38:18 +00:00
Verify delayed optional-file queue consumers
This commit is contained in:
1 parent
312cbac933
commit
f325d2454d
2 files changed
+386
No files matched your search
@@ -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<void>((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<typeof ConfigSync>[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<typeof ConfigSync>[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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<T = void>() {
|
||||||
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||||
|
const promise = new Promise<T>((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<typeof HiddenFileSync>[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<boolean>();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user