mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-08 02:12:30 +00:00
Merge pull request #1220 from kimjansheden/codex/configsync-lifecycle
Fix Customisation Sync readiness and suspended scans
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;
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user