mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-07 19:27:05 +00:00
348 lines
13 KiB
TypeScript
348 lines
13 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
AUTO_MERGED,
|
|
DEFAULT_SETTINGS,
|
|
REMOTE_P2P,
|
|
type FilePathWithPrefix,
|
|
type ObsidianLiveSyncSettings,
|
|
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
|
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
|
import { EVENT_FILE_SAVED, EVENT_SETTING_SAVED, eventHub } from "@/common/events";
|
|
|
|
const taskMocks = vi.hoisted(() => ({
|
|
scheduleTask: vi.fn((_key: string, _delay: number, task: () => unknown) => task()),
|
|
}));
|
|
|
|
vi.mock("octagonal-wheels/concurrency/task", () => taskMocks);
|
|
|
|
import {
|
|
createConflictResolutionOperations,
|
|
type ConflictResolutionOperationsDependencies,
|
|
} from "@/serviceFeatures/conflictResolution";
|
|
import { ModuleObsidianEvents } from "@/modules/essentialObsidian/ModuleObsidianEvents";
|
|
import {
|
|
createReplicationSchedulingContext,
|
|
realiseReplicationScheduling,
|
|
resumeReplicationScheduling,
|
|
runPeriodicReplication,
|
|
} from "@/serviceFeatures/replicationScheduling";
|
|
import { createAutomaticReplicationTriggers } from "./automaticTriggers";
|
|
|
|
function createApi() {
|
|
return {
|
|
addLog: vi.fn(),
|
|
addCommand: vi.fn(),
|
|
registerWindow: vi.fn(),
|
|
addRibbonIcon: vi.fn(),
|
|
registerProtocolHandler: vi.fn(),
|
|
setInterval: vi.fn(),
|
|
clearInterval: vi.fn(),
|
|
};
|
|
}
|
|
|
|
function p2pSettings(overrides: Partial<typeof DEFAULT_SETTINGS> = {}) {
|
|
return {
|
|
...DEFAULT_SETTINGS,
|
|
remoteType: REMOTE_P2P,
|
|
isConfigured: true,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createObsidianEventHarness(settings: Partial<typeof DEFAULT_SETTINGS>) {
|
|
const save = vi.fn();
|
|
const saveCommand = { callback: save };
|
|
const replicateUnattendedByEvent = vi.fn(async () => ({ status: "completed" as const }));
|
|
const queueCheckForIfOpen = vi.fn(async () => undefined);
|
|
const services = {
|
|
API: createApi(),
|
|
appLifecycle: {
|
|
isReady: vi.fn(() => true),
|
|
isSuspended: vi.fn(() => false),
|
|
},
|
|
conflict: { queueCheckForIfOpen },
|
|
control: { hasUnloaded: vi.fn(() => false) },
|
|
fileProcessing: { commitPendingFileEvents: vi.fn(async () => true) },
|
|
replication: { replicateUnattendedByEvent },
|
|
};
|
|
const core = {
|
|
_services: services,
|
|
services,
|
|
settings: p2pSettings(settings),
|
|
} as any;
|
|
const plugin = {
|
|
app: {
|
|
commands: {
|
|
commands: { "editor:save-file": saveCommand },
|
|
executeCommandById: vi.fn(),
|
|
},
|
|
},
|
|
} as any;
|
|
|
|
return {
|
|
module: new ModuleObsidianEvents(plugin, core),
|
|
queueCheckForIfOpen,
|
|
replicateUnattendedByEvent,
|
|
save,
|
|
saveCommand,
|
|
services,
|
|
};
|
|
}
|
|
|
|
describe("automatic replication triggers while P2P is active", () => {
|
|
afterEach(() => {
|
|
eventHub.offAll();
|
|
taskMocks.scheduleTask.mockClear();
|
|
});
|
|
|
|
it("keeps periodic synchronisation on the provider-independent replication boundary", async () => {
|
|
const replicateUnattended = vi.fn(async () => ({ status: "completed" as const }));
|
|
const services = {
|
|
API: createApi(),
|
|
control: { hasUnloaded: vi.fn(() => false) },
|
|
replication: { replicateUnattended },
|
|
};
|
|
const core = {
|
|
_services: services,
|
|
services,
|
|
settings: p2pSettings({ periodicReplication: true, syncOnStart: false }),
|
|
} as any;
|
|
const context = createReplicationSchedulingContext({
|
|
isReady: vi.fn(() => true),
|
|
isSuspended: vi.fn(() => false),
|
|
currentSettings: vi.fn(() => core.settings),
|
|
replicateUnattended,
|
|
startContinuous: vi.fn(async () => ({ status: "completed" as const })),
|
|
timer: { enable: vi.fn(), disable: vi.fn() },
|
|
log: vi.fn(),
|
|
});
|
|
|
|
resumeReplicationScheduling(context);
|
|
await runPeriodicReplication(context);
|
|
|
|
expect(replicateUnattended).toHaveBeenCalledOnce();
|
|
expect(replicateUnattended).toHaveBeenCalledWith({
|
|
trigger: "periodic",
|
|
interaction: NO_INTERACTION,
|
|
});
|
|
});
|
|
|
|
it("keeps database-save synchronisation on the event replication boundary", async () => {
|
|
const replicateUnattendedByEvent = vi.fn(async (_request: unknown) => ({ status: "completed" as const }));
|
|
const settings = p2pSettings({ syncOnSave: true });
|
|
const initialise = createAutomaticReplicationTriggers({
|
|
currentSettings: () => settings,
|
|
isSuspended: vi.fn(() => false),
|
|
replicateDatabaseEvent: () =>
|
|
replicateUnattendedByEvent({
|
|
trigger: "database-event",
|
|
interaction: NO_INTERACTION,
|
|
}),
|
|
reprocessStoredDocuments: vi.fn(async () => 0),
|
|
resumeResultApplication: vi.fn(),
|
|
suspendResultApplication: vi.fn(),
|
|
});
|
|
|
|
await initialise();
|
|
eventHub.emitEvent(EVENT_FILE_SAVED);
|
|
|
|
await vi.waitFor(() => expect(replicateUnattendedByEvent).toHaveBeenCalledOnce());
|
|
expect(replicateUnattendedByEvent).toHaveBeenCalledWith({
|
|
trigger: "database-event",
|
|
interaction: NO_INTERACTION,
|
|
});
|
|
});
|
|
|
|
it("reprocesses stored documents when normal-file target filters change", async () => {
|
|
const settings = {
|
|
...DEFAULT_SETTINGS,
|
|
ignoreFiles: ".gitignore",
|
|
syncOnlyRegEx: "^E2E/allowed/.*",
|
|
} as ObsidianLiveSyncSettings;
|
|
const reprocessStoredDocuments = vi.fn(async () => 1);
|
|
const resumeResultApplication = vi.fn();
|
|
const suspendResultApplication = vi.fn();
|
|
const initialise = createAutomaticReplicationTriggers({
|
|
currentSettings: () => settings,
|
|
isSuspended: vi.fn(() => false),
|
|
replicateDatabaseEvent: vi.fn(async () => undefined),
|
|
reprocessStoredDocuments,
|
|
resumeResultApplication,
|
|
suspendResultApplication,
|
|
});
|
|
|
|
await initialise();
|
|
eventHub.emitEvent(EVENT_SETTING_SAVED, { ...settings });
|
|
await Promise.resolve();
|
|
expect(reprocessStoredDocuments).not.toHaveBeenCalled();
|
|
expect(resumeResultApplication).toHaveBeenCalledOnce();
|
|
expect(suspendResultApplication).not.toHaveBeenCalled();
|
|
|
|
eventHub.emitEvent(EVENT_SETTING_SAVED, { ...settings, suspendParseReplicationResult: true });
|
|
expect(suspendResultApplication).toHaveBeenCalledOnce();
|
|
|
|
Object.assign(settings, { syncOnlyRegEx: "" });
|
|
eventHub.emitEvent(EVENT_SETTING_SAVED, { ...settings });
|
|
await vi.waitFor(() => expect(reprocessStoredDocuments).toHaveBeenCalledOnce());
|
|
|
|
settings.syncMaxSizeInMB = 10;
|
|
eventHub.emitEvent(EVENT_SETTING_SAVED, { ...settings });
|
|
await vi.waitFor(() => expect(reprocessStoredDocuments).toHaveBeenCalledTimes(2));
|
|
});
|
|
|
|
it("keeps editor-save synchronisation on the event replication boundary", async () => {
|
|
const { module, replicateUnattendedByEvent, save, saveCommand } = createObsidianEventHarness({
|
|
syncOnEditorSave: true,
|
|
});
|
|
|
|
module.swapSaveCommand();
|
|
saveCommand.callback();
|
|
|
|
expect(save).toHaveBeenCalledOnce();
|
|
await vi.waitFor(() => expect(replicateUnattendedByEvent).toHaveBeenCalledOnce());
|
|
expect(replicateUnattendedByEvent).toHaveBeenCalledWith({
|
|
trigger: "editor-save",
|
|
interaction: NO_INTERACTION,
|
|
});
|
|
});
|
|
|
|
it("keeps file-open synchronisation on the event replication boundary", async () => {
|
|
const { module, queueCheckForIfOpen, replicateUnattendedByEvent, services } = createObsidianEventHarness({
|
|
syncOnFileOpen: true,
|
|
});
|
|
const file = { path: "opened.md" } as never;
|
|
|
|
await module.watchWorkspaceOpenAsync(file);
|
|
|
|
expect(services.fileProcessing.commitPendingFileEvents).toHaveBeenCalledOnce();
|
|
expect(replicateUnattendedByEvent).toHaveBeenCalledOnce();
|
|
expect(replicateUnattendedByEvent).toHaveBeenCalledWith({
|
|
trigger: "file-open",
|
|
interaction: NO_INTERACTION,
|
|
});
|
|
expect(queueCheckForIfOpen).toHaveBeenCalledWith("opened.md");
|
|
});
|
|
|
|
it("keeps post-merge synchronisation on the event replication boundary", async () => {
|
|
const replicateUnattendedByEvent = vi.fn(async () => ({ status: "completed" as const }));
|
|
const queueCheckFor = vi.fn(async () => undefined);
|
|
const path = "merged.md" as FilePathWithPrefix;
|
|
const operations = createConflictResolutionOperations({
|
|
events: { emitEvent: vi.fn() },
|
|
databaseFileAccess: {
|
|
fetchEntryMeta: vi.fn(),
|
|
getConflictedRevs: vi.fn(async () => []),
|
|
storeContent: vi.fn(async () => true),
|
|
},
|
|
fileHandler: {
|
|
deleteRevisionFromDB: vi.fn(async () => true),
|
|
dbToStorage: vi.fn(async () => true),
|
|
},
|
|
localDatabase: () => ({
|
|
tryAutoMerge: vi.fn(async () => ({ ok: AUTO_MERGED })),
|
|
}),
|
|
conflict: {
|
|
queueCheckFor,
|
|
resolveByDeletingRevision: vi.fn(async () => AUTO_MERGED),
|
|
resolveByUserInteraction: vi.fn(async () => false),
|
|
},
|
|
replication: { replicateUnattendedByEvent },
|
|
appLifecycle: { isSuspended: vi.fn(() => false) },
|
|
vault: { getActiveFilePath: vi.fn(() => undefined) },
|
|
storageAccess: { getFileNames: vi.fn(async () => []) },
|
|
currentSettings: () => p2pSettings({ syncAfterMerge: true }),
|
|
log: vi.fn(),
|
|
} as unknown as ConflictResolutionOperationsDependencies);
|
|
|
|
await operations.resolve(path);
|
|
|
|
expect(replicateUnattendedByEvent).toHaveBeenCalledOnce();
|
|
expect(replicateUnattendedByEvent).toHaveBeenCalledWith({
|
|
trigger: "merge",
|
|
interaction: NO_INTERACTION,
|
|
});
|
|
expect(queueCheckFor).toHaveBeenCalledWith(path);
|
|
});
|
|
});
|
|
|
|
describe("recurring replication scheduling precedence", () => {
|
|
afterEach(() => {
|
|
eventHub.offAll();
|
|
});
|
|
|
|
function createRecurringSchedulingHarness() {
|
|
let resolveContinuous!: (
|
|
outcome: { status: "completed" } | { status: "blocked"; reason: "capability-not-applicable" }
|
|
) => void;
|
|
const startContinuous = vi.fn(
|
|
() =>
|
|
new Promise<{ status: "completed" } | { status: "blocked"; reason: "capability-not-applicable" }>(
|
|
(resolve) => {
|
|
resolveContinuous = resolve;
|
|
}
|
|
)
|
|
);
|
|
const API = createApi();
|
|
const settings = {
|
|
...DEFAULT_SETTINGS,
|
|
isConfigured: true,
|
|
liveSync: true,
|
|
syncOnStart: true,
|
|
periodicReplication: true,
|
|
periodicReplicationInterval: 60,
|
|
};
|
|
const context = createReplicationSchedulingContext({
|
|
isReady: vi.fn(() => true),
|
|
isSuspended: vi.fn(() => false),
|
|
currentSettings: vi.fn(() => settings),
|
|
startContinuous,
|
|
replicateUnattended: vi.fn(async () => ({ status: "completed" as const })),
|
|
timer: {
|
|
enable: (interval) => {
|
|
API.setInterval(vi.fn(), interval);
|
|
},
|
|
disable: () => {
|
|
API.clearInterval(0);
|
|
},
|
|
},
|
|
log: vi.fn(),
|
|
});
|
|
|
|
return {
|
|
API,
|
|
resolveContinuous: (
|
|
outcome: { status: "completed" } | { status: "blocked"; reason: "capability-not-applicable" }
|
|
) => resolveContinuous(outcome),
|
|
resume: async () => {
|
|
resumeReplicationScheduling(context);
|
|
await Promise.resolve();
|
|
},
|
|
realiseSettings: async () => {
|
|
realiseReplicationScheduling(context);
|
|
await Promise.resolve();
|
|
},
|
|
};
|
|
}
|
|
|
|
it("does not enable the generic periodic timer while Continuous owns recurring synchronisation", async () => {
|
|
const harness = createRecurringSchedulingHarness();
|
|
|
|
await harness.resume();
|
|
await harness.realiseSettings();
|
|
|
|
expect(harness.API.setInterval).not.toHaveBeenCalled();
|
|
harness.resolveContinuous({ status: "completed" });
|
|
await vi.waitFor(() => expect(harness.API.setInterval).not.toHaveBeenCalled());
|
|
});
|
|
|
|
it("restores the generic periodic timer when Continuous is not applicable", async () => {
|
|
const harness = createRecurringSchedulingHarness();
|
|
|
|
await harness.resume();
|
|
await harness.realiseSettings();
|
|
harness.resolveContinuous({ status: "blocked", reason: "capability-not-applicable" });
|
|
|
|
await vi.waitFor(() => expect(harness.API.setInterval).toHaveBeenCalledOnce());
|
|
});
|
|
});
|