Files
obsidian-livesync/src/features/ConfigSync/CmdConfigSync.applySettings.unit.spec.ts
T
Kim Jansheden 893a0e26c3 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.
2026-10-03 15:05:17 +02:00

191 lines
7.5 KiB
TypeScript

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);
});
});