mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-18 15:22:31 +00:00
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:
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1198,20 +1198,11 @@ export class ConfigSync extends LiveSyncCommands {
|
|||||||
}
|
}
|
||||||
return true;
|
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();
|
this.periodicPluginSweepProcessor?.disable();
|
||||||
if (!this._isMainReady) return true;
|
return Promise.resolve(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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
recentProcessedInternalFiles = [] as string[];
|
recentProcessedInternalFiles = [] as string[];
|
||||||
@@ -1488,7 +1479,7 @@ export class ConfigSync extends LiveSyncCommands {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async watchVaultRawEventsAsync(path: FilePath) {
|
async watchVaultRawEventsAsync(path: FilePath) {
|
||||||
if (!this._isMainReady) return false;
|
if (!this._isMainReady()) return false;
|
||||||
if (this._isMainSuspended()) return false;
|
if (this._isMainSuspended()) return false;
|
||||||
if (!this.isThisModuleEnabled()) return false;
|
if (!this.isThisModuleEnabled()) return false;
|
||||||
// if (!this.isTargetPath(path)) return false;
|
// if (!this.isTargetPath(path)) return false;
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
|||||||
|
|
||||||
## Unreleased
|
## 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
|
## 1.0.34
|
||||||
|
|
||||||
2nd October, 2026
|
2nd October, 2026
|
||||||
|
|||||||
Reference in New Issue
Block a user