fix(sync): honour readiness and suspension in Customisation Sync

(src/features/ConfigSync/CmdConfigSync.ts)
- Call the readiness predicate before accepting configuration file events.
- Stop the periodic sweep while settings are applied and let the resume lifecycle scan and restart it once.

(src/features/ConfigSync/CmdConfigSync.applySettings.unit.spec.ts)
- Exercise the ControlService settings path while active, suspended, and resumed.
- Verify that suspended settings application performs no scan and leaves the periodic sweep stopped.

(src/features/ConfigSync/CmdConfigSync.readiness.unit.spec.ts)
- Cover configuration file events before and after application readiness.

(updates.md)
- Document the readiness and suspension behaviour.
This commit is contained in:
Kim Jansheden
2026-10-03 15:05:17 +02:00
parent ea30ef7ebd
commit 893a0e26c3
4 changed files with 322 additions and 14 deletions
@@ -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<ServiceContext> {}
/**
* 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);
});
});
@@ -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));
});
});
+5 -14
View File
@@ -1198,20 +1198,11 @@ export class ConfigSync extends LiveSyncCommands {
}
return true;
}
async _everyRealizeSettingSyncMode(): Promise<boolean> {
_everyRealizeSettingSyncMode(): Promise<boolean> {
// 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;
+6
View File
@@ -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