diff --git a/src/features/ConfigSync/CmdConfigSync.applySettings.unit.spec.ts b/src/features/ConfigSync/CmdConfigSync.applySettings.unit.spec.ts new file mode 100644 index 00000000..76e8ab68 --- /dev/null +++ b/src/features/ConfigSync/CmdConfigSync.applySettings.unit.spec.ts @@ -0,0 +1,190 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/deps.ts", () => ({ + addIcon: vi.fn(), + diff_match_patch: class DiffMatchPatch {}, + normalizePath: vi.fn((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 {}, +})); +// The readiness and suspension helpers come from the real LiveSyncCommands, so only its log view dependency is replaced. +vi.mock("@/modules/features/ModuleLog.ts", () => ({ + MARK_DONE: "", +})); +vi.mock("@/common/types.ts", () => ({ + ICXHeader: "ix:", + PERIODIC_PLUGIN_SWEEP: 60, +})); +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: vi.fn((message: string) => message), +})); +vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({ + getObsidianCommunityPluginManager: vi.fn(), +})); + +import { createServiceContext, type ServiceContext } from "@vrtmrz/livesync-commonlib/context"; +import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService"; +import { InjectableAppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAppLifecycleService"; +import { InjectableSettingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableSettingService"; +import { ConfigSync } from "./CmdConfigSync"; + +type HarnessOptions = { + ready?: boolean; + suspended?: boolean; +}; + +const PERIODIC_SWEEP_INTERVAL = 60 * 1000; + +class TestAppLifecycleService extends InjectableAppLifecycleService {} +class TestSettingService extends InjectableSettingService {} + +/** + * Binds ConfigSync to Commonlib's real ControlService, so settings are applied in the order the plug-in uses. + */ +function setup(options: HarnessOptions = {}) { + const context = createServiceContext(); + const settings = { + isConfigured: true, + usePluginSync: true, + autoSweepPlugins: true, + autoSweepPluginsPeriodic: true, + watchInternalFileChanges: false, + pluginSyncExtendedSetting: {}, + }; + const API = { + addLog: vi.fn(), + getSystemConfigDir: () => ".obsidian", + }; + const appLifecycle = new TestAppLifecycleService(context, { + settingService: { currentSettings: () => settings }, + } as never); + const setting = new TestSettingService(context, { APIService: API } as never); + const control = new ControlService(context, { + APIService: API, + appLifecycleService: appLifecycle, + databaseService: { localDatabase: { refreshSettings: vi.fn() } }, + fileProcessingService: { commitPendingFileEvents: vi.fn(async () => true) }, + settingService: setting, + } as never); + const handler = () => ({ addHandler: vi.fn() }); + const services = { + API, + appLifecycle, + conflict: { getOptionalConflictCheckMethod: handler() }, + control, + databaseEvents: { onDatabaseInitialised: handler() }, + fileProcessing: { processOptionalFileEvent: handler() }, + replication: { onBeforeReplicate: handler(), processVirtualDocument: handler() }, + setting, + }; + const periodicPluginSweepProcessor = { disable: vi.fn(), enable: vi.fn() }; + const scanAllConfigFiles = vi.fn(async () => undefined); + const configSync = Object.create(ConfigSync.prototype) as ConfigSync; + Object.assign(configSync, { + core: { settings, services }, + periodicPluginSweepProcessor, + scanAllConfigFiles, + }); + configSync.onBindFunction(configSync.core, services as never); + + if (options.ready ?? true) appLifecycle.markIsReady(); + appLifecycle.setSuspended(options.suspended ?? false); + + // The periodic sweep is running when the last call left it enabled with a non-zero interval. + const isPeriodicSweepRunning = () => { + const calls = [ + ...periodicPluginSweepProcessor.disable.mock.invocationCallOrder.map((order) => ({ order, interval: 0 })), + ...periodicPluginSweepProcessor.enable.mock.calls.map(([interval], index) => ({ + order: periodicPluginSweepProcessor.enable.mock.invocationCallOrder[index], + interval: interval as number, + })), + ].sort((a, b) => a.order - b.order); + return (calls[calls.length - 1]?.interval ?? 0) > 0; + }; + + return { appLifecycle, control, isPeriodicSweepRunning, periodicPluginSweepProcessor, scanAllConfigFiles }; +} + +describe("ConfigSync when settings are applied", () => { + beforeEach(() => { + vi.stubGlobal("activeDocument", { querySelector: vi.fn(() => null) }); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it.each([true, false])( + "does not sweep configuration files while the plug-in is suspended (ready: %s)", + async (ready) => { + const { control, isPeriodicSweepRunning, periodicPluginSweepProcessor, scanAllConfigFiles } = setup({ + ready, + suspended: true, + }); + + await control.applySettings(); + + expect(periodicPluginSweepProcessor.disable).toHaveBeenCalled(); + expect(scanAllConfigFiles).not.toHaveBeenCalled(); + expect(periodicPluginSweepProcessor.enable).not.toHaveBeenCalled(); + expect(isPeriodicSweepRunning()).toBe(false); + } + ); + + it("sweeps configuration files once and starts the periodic sweep while the plug-in is running", async () => { + const { control, isPeriodicSweepRunning, periodicPluginSweepProcessor, scanAllConfigFiles } = setup(); + + await control.applySettings(); + + expect(scanAllConfigFiles).toHaveBeenCalledOnce(); + expect(periodicPluginSweepProcessor.enable).toHaveBeenCalledOnce(); + expect(periodicPluginSweepProcessor.enable).toHaveBeenCalledWith(PERIODIC_SWEEP_INTERVAL); + expect(isPeriodicSweepRunning()).toBe(true); + }); + + it("stops the periodic sweep when all synchronisation is suspended and restarts it once on resumption", async () => { + const { appLifecycle, control, isPeriodicSweepRunning, periodicPluginSweepProcessor, scanAllConfigFiles } = + setup(); + await control.applySettings(); + scanAllConfigFiles.mockClear(); + periodicPluginSweepProcessor.enable.mockClear(); + + appLifecycle.setSuspended(true); + await control.applySettings(); + + expect(scanAllConfigFiles).not.toHaveBeenCalled(); + expect(periodicPluginSweepProcessor.enable).not.toHaveBeenCalled(); + expect(isPeriodicSweepRunning()).toBe(false); + + appLifecycle.setSuspended(false); + await control.applySettings(); + + expect(scanAllConfigFiles).toHaveBeenCalledOnce(); + expect(periodicPluginSweepProcessor.enable).toHaveBeenCalledOnce(); + expect(isPeriodicSweepRunning()).toBe(true); + }); +}); diff --git a/src/features/ConfigSync/CmdConfigSync.readiness.unit.spec.ts b/src/features/ConfigSync/CmdConfigSync.readiness.unit.spec.ts new file mode 100644 index 00000000..5035a924 --- /dev/null +++ b/src/features/ConfigSync/CmdConfigSync.readiness.unit.spec.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/deps.ts", () => ({ + addIcon: vi.fn(), + diff_match_patch: class DiffMatchPatch {}, + normalizePath: vi.fn((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 {}, +})); +// The readiness helpers come from the real LiveSyncCommands, so only its log view dependency is replaced. +vi.mock("@/modules/features/ModuleLog.ts", () => ({ + MARK_DONE: "", +})); +vi.mock("@/common/types.ts", () => ({ + ICXHeader: "ix:", + PERIODIC_PLUGIN_SWEEP: 60, +})); +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: vi.fn((message: string) => message), +})); +vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({ + getObsidianCommunityPluginManager: vi.fn(), +})); + +import { scheduleTask } from "@/common/utils.ts"; +import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { ConfigSync } from "./CmdConfigSync"; + +type LifecycleState = { + ready: boolean; + suspended: boolean; +}; + +const CONFIG_FILE = ".obsidian/snippets/theme.css" as FilePath; + +function setup(state: LifecycleState) { + const appLifecycle = { + isReady: vi.fn(() => state.ready), + isSuspended: vi.fn(() => state.suspended), + }; + const statHidden = vi.fn(async () => ({ type: "file", ctime: 0, mtime: 1_000, size: 1 })); + const storeCustomizationFiles = vi.fn(async () => undefined); + // Obsidian adds Array.prototype.contains, which is not available under Node. + const recentProcessedInternalFiles = Object.assign([] as string[], { + contains: (key: string) => recentProcessedInternalFiles.includes(key), + }); + const configSync = Object.create(ConfigSync.prototype) as ConfigSync; + Object.assign(configSync, { + core: { + settings: { + usePluginSync: true, + pluginSyncExtendedSetting: {}, + }, + services: { + appLifecycle, + API: { getSystemConfigDir: () => ".obsidian" }, + }, + storageAccess: { statHidden }, + }, + recentProcessedInternalFiles, + storeCustomizationFiles, + filenameToUnifiedKey: (path: string) => `ix:${path}`, + }); + return { + appLifecycle, + configSync, + statHidden, + }; +} + +describe("ConfigSync readiness", () => { + beforeEach(() => { + vi.mocked(scheduleTask).mockClear(); + }); + + it("leaves a configuration file event unhandled before the plug-in is ready", async () => { + const { appLifecycle, configSync, statHidden } = setup({ ready: false, suspended: false }); + + const handled = await configSync.watchVaultRawEventsAsync(CONFIG_FILE); + + expect(scheduleTask).not.toHaveBeenCalled(); + expect(statHidden).not.toHaveBeenCalled(); + expect(handled).toBe(false); + expect(appLifecycle.isReady).toHaveBeenCalled(); + }); + + it("schedules a configuration file event for storing once the plug-in is ready", async () => { + const { configSync, statHidden } = setup({ ready: true, suspended: false }); + + await expect(configSync.watchVaultRawEventsAsync(CONFIG_FILE)).resolves.toBe(true); + + expect(statHidden).toHaveBeenCalledWith(CONFIG_FILE); + expect(scheduleTask).toHaveBeenCalledOnce(); + expect(scheduleTask).toHaveBeenCalledWith(`ix:${CONFIG_FILE}`, 100, expect.any(Function)); + }); +}); diff --git a/src/features/ConfigSync/CmdConfigSync.ts b/src/features/ConfigSync/CmdConfigSync.ts index b560dba6..0e1412ee 100644 --- a/src/features/ConfigSync/CmdConfigSync.ts +++ b/src/features/ConfigSync/CmdConfigSync.ts @@ -1198,20 +1198,11 @@ export class ConfigSync extends LiveSyncCommands { } return true; } - async _everyRealizeSettingSyncMode(): Promise { + _everyRealizeSettingSyncMode(): Promise { + // applySettings calls onResuming after this handler when synchronisation is active. + // That handler scans and restarts the sweep; doing so here would scan twice. this.periodicPluginSweepProcessor?.disable(); - if (!this._isMainReady) return true; - if (!this._isMainSuspended()) return true; - if (!this.isThisModuleEnabled()) return true; - if (this.settings.autoSweepPlugins) { - await this.scanAllConfigFiles(false); - } - this.periodicPluginSweepProcessor.enable( - this.settings.autoSweepPluginsPeriodic && !this.settings.watchInternalFileChanges - ? PERIODIC_PLUGIN_SWEEP * 1000 - : 0 - ); - return true; + return Promise.resolve(true); } recentProcessedInternalFiles = [] as string[]; @@ -1488,7 +1479,7 @@ export class ConfigSync extends LiveSyncCommands { } async watchVaultRawEventsAsync(path: FilePath) { - if (!this._isMainReady) return false; + if (!this._isMainReady()) return false; if (this._isMainSuspended()) return false; if (!this.isThisModuleEnabled()) return false; // if (!this.isTargetPath(path)) return false; diff --git a/updates.md b/updates.md index 64e84f08..7db9f4f8 100644 --- a/updates.md +++ b/updates.md @@ -12,6 +12,12 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi ## Unreleased +### Customisation Sync + +#### Fixed + +- Configuration file events are left for the next scan until the plug-in is ready. Applying settings while synchronisation is suspended stops the periodic scan without scanning configuration files. When synchronisation resumes, the normal resume handler scans once and restarts the periodic scan. + ## 1.0.34 2nd October, 2026