Merge remote-tracking branch 'upstream/main' into feat/syncguard-bulk-sync-edit-guard

# Conflicts:
#	src/common/messages/LiveSyncProvisionalMessages.ts
#	styles.css
#	updates.md
This commit is contained in:
Artem Ovchinnikov committed 2026-10-27 15:28:22 +03:00
commit acd3b0ade6
157 files changed
+15431 -1108

No files matched your search

@@ -23,6 +23,7 @@ export function createAdvancedSettingSpecGroups({
control: { type: "dropdown", options: () => ChunkAlgorithmNames },
},
{ key: "customChunkSize", control: { type: "number", min: 0, allowZero: true } },
{ key: "strictContentCheckOnStartup", control: { type: "toggle" } },
{
key: "pathNormalizationVersion",
holdValue: true,
@@ -1,3 +1,4 @@
import { MemoryValueStore } from "@vrtmrz/value-store";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { SettingDefinitionGroup, SettingDefinitionItem, SettingDefinitionPage } from "obsidian";
@@ -158,6 +159,7 @@ function createSettingsTab(options: SettingsTabOptions = {}): ObsidianLiveSyncSe
},
services: {
setting: {
deviceValues: new MemoryValueStore(),
getDeviceAndVaultName: vi.fn(() => ""),
saveSettingData: vi.fn(async () => undefined),
},
@@ -459,7 +461,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
expect(runtime.pageCleanup).toHaveBeenCalledOnce();
});
it("rebuilds the catalogue when an externally loaded setting changes page visibility", () => {
it("rebuilds the catalogue when an externally loaded setting changes page visibility", async () => {
const tab = createSettingsTab();
const changeLog = findPage(tab, "Change Log");
if (!changeLog.page) {
@@ -468,7 +470,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
changeLog.page().display();
tab.core.settings.usePowerUserMode = !tab.editingSettings.usePowerUserMode;
tab.requestReload();
await tab.requestReload();
expect(tab.update).toHaveBeenCalledOnce();
});
@@ -482,7 +484,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
expect(tab.update).toHaveBeenCalledOnce();
});
it("rebuilds translated catalogue names when the display language changes externally", () => {
it("rebuilds translated catalogue names when the display language changes externally", async () => {
const tab = createSettingsTab();
const changeLog = findPage(tab, "Change Log");
if (!changeLog.page) {
@@ -491,12 +493,12 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
changeLog.page().display();
tab.core.settings.displayLanguage = "ja";
tab.requestReload();
await tab.requestReload();
expect(tab.update).toHaveBeenCalledOnce();
});
it("rebuilds the catalogue after accepting an external page-visibility setting over a dirty value", () => {
it("rebuilds the catalogue after accepting an external page-visibility setting over a dirty value", async () => {
const tab = createSettingsTab();
const changeLog = findPage(tab, "Change Log");
if (!changeLog.page) {
@@ -507,7 +509,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
tab.editingSettings.usePowerUserMode = true;
tab.core.settings.usePowerUserMode = true;
tab.requestReload();
await tab.requestReload();
const configureAnchor = vi.mocked(tab.core.confirm.askInPopup).mock.calls[0]?.[2];
expect(configureAnchor).toBeTypeOf("function");
let acceptExternalSetting: (() => void) | undefined;
@@ -52,7 +52,6 @@ import {
type PageFunctions,
type UpdateFunction,
} from "./SettingPane.ts";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { JournalSyncCore } from "@vrtmrz/livesync-commonlib/compat/replication/journal/JournalSyncCore";
import { MinioStorageAdapter } from "@vrtmrz/livesync-commonlib/compat/replication/journal/objectstore/MinioStorageAdapter";
import { closeObsidianSettings } from "@/common/obsidianSettings.ts";
@@ -100,6 +99,9 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
}
selectedScreen = "";
private localConfigurationPassphrase = "";
private localSettingsReadVersion = 0;
_editingSettings?: AllSettings;
// Buffered Settings for editing
get editingSettings(): AllSettings {
@@ -162,8 +164,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
async saveLocalSetting(key: keyof typeof OnDialogSettingsDefault) {
if (key == "deviceAndVaultName") {
this.services.setting.setDeviceAndVaultName(this.editingSettings?.[key] ?? "");
this.services.setting.saveDeviceAndVaultName();
return await Promise.resolve();
await this.services.setting.saveDeviceAndVaultName();
}
}
/**
@@ -225,6 +226,10 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
appliedConfigurationEncryption.configPassphrase
);
if (appliedConfigurationEncryption.configPassphraseStore === "LOCALSTORAGE") {
this.localSettingsReadVersion++;
this.localConfigurationPassphrase = appliedConfigurationEncryption.configPassphrase;
}
this.copySettingValue(this.initialSettings, appliedConfigurationEncryption, "configPassphraseStore");
this.copySettingValue(this.initialSettings, appliedConfigurationEncryption, "configPassphrase");
}
@@ -285,9 +290,21 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
}
}
private async readLocalSettings(): Promise<boolean> {
const version = ++this.localSettingsReadVersion;
const passphrase = await this.services.setting.deviceValues.getValue("ls-setting-passphrase", "");
if (version !== this.localSettingsReadVersion) return false;
this.localConfigurationPassphrase = passphrase;
return true;
}
async loadSettings(): Promise<void> {
if (await this.readLocalSettings()) this.reloadAllSettings(true);
}
reloadAllLocalSettings() {
const ret = { ...OnDialogSettingsDefault };
ret.configPassphrase = compatGlobal.localStorage.getItem("ls-setting-passphrase") || "";
ret.configPassphrase = this.localConfigurationPassphrase;
ret.preset = "";
ret.deviceAndVaultName = this.services.setting.getDeviceAndVaultName();
return ret;
@@ -402,7 +419,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
this.plugin = plugin;
Setting.env = this;
eventHub.onEvent(EVENT_REQUEST_RELOAD_SETTING_TAB, () => {
this.requestReload();
void this.requestReload().catch((error: unknown) => Logger(error, LOG_LEVEL_NOTICE));
});
this.addOnSaved("displayLanguage", () => this.requestCatalogueRefresh());
this.addOnSaved("showStatusOnEditor", () => eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR));
@@ -545,7 +562,8 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
);
}
requestReload() {
async requestReload(): Promise<void> {
if (!(await this.readLocalSettings())) return;
const nativeTabIsShown =
this.supportsDeclarativeSettings() && this.containerEl !== undefined && this.containerEl.isShown();
if (this.isShown || nativeTabIsShown) {
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS, LOG_LEVEL_NOTICE, REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { MemoryValueStore } from "@vrtmrz/value-store";
const settingsInitialisationMocks = vi.hoisted(() => ({
applySettingsWithInitialisationChoice: vi.fn(),
@@ -76,8 +76,6 @@ beforeEach(() => {
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockReset();
loggerMocks.Logger.mockClear();
vi.mocked($msg).mockClear();
vi.mocked(compatGlobal.localStorage.getItem).mockReset().mockReturnValue(null);
vi.mocked(compatGlobal.localStorage.setItem).mockReset();
});
describe("ObsidianLiveSyncSettingTab passphrase verification", () => {
@@ -280,6 +278,7 @@ describe("ObsidianLiveSyncSettingTab pending-setting initialisation", () => {
},
services: {
setting: {
deviceValues: new MemoryValueStore(),
saveSettingData,
getDeviceAndVaultName: vi.fn(() => ""),
suspendAllSync,
@@ -665,6 +664,7 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
},
services: {
setting: {
deviceValues: new MemoryValueStore(),
saveSettingData,
getDeviceAndVaultName: vi.fn(() => ""),
},
@@ -692,6 +692,54 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
return { tab, saveSettingData };
}
it("awaits the stored passphrase before initialising the editing buffer", async () => {
const { tab } = createSettingsTab();
tab.resetEditingSettings();
let resolveRead!: (value: string) => void;
vi.spyOn(tab.services.setting.deviceValues, "getValue").mockReturnValueOnce(
new Promise<string>((resolve) => {
resolveRead = resolve;
})
);
const loading = tab.loadSettings();
expect(tab.initialSettings).toBeUndefined();
resolveRead("stored passphrase");
await loading;
expect(tab.editingSettings.configPassphrase).toBe("stored passphrase");
expect(tab.initialSettings!.configPassphrase).toBe("stored passphrase");
});
it("preserves the loaded settings when device storage cannot be read", async () => {
const { tab } = createSettingsTab();
await tab.services.setting.deviceValues.setValue("ls-setting-passphrase", "stored passphrase");
await tab.loadSettings();
const failure = new Error("device storage unavailable");
vi.spyOn(tab.services.setting.deviceValues, "getValue").mockRejectedValueOnce(failure);
await expect(tab.requestReload()).rejects.toBe(failure);
expect(tab.editingSettings.configPassphrase).toBe("stored passphrase");
expect(tab.initialSettings!.configPassphrase).toBe("stored passphrase");
});
it("does not let a delayed read overwrite a successfully saved passphrase", async () => {
const { tab } = createSettingsTab();
await tab.loadSettings();
let resolveRead!: (value: string) => void;
vi.spyOn(tab.services.setting.deviceValues, "getValue").mockReturnValueOnce(
new Promise<string>((resolve) => {
resolveRead = resolve;
})
);
const loading = tab.loadSettings();
Object.assign(tab.services.setting, { changeConfigurationEncryption: vi.fn(async () => undefined) });
tab.editingSettings.configPassphraseStore = "LOCALSTORAGE";
tab.editingSettings.configPassphrase = "new passphrase";
await tab.saveSettings(["configPassphraseStore", "configPassphrase"]);
resolveRead("old passphrase");
await loading;
tab.reloadAllSettings();
expect(tab.initialSettings!.configPassphrase).toBe("new passphrase");
});
it("loads the imperative fallback without a SettingPage runtime export", () => {
const { tab } = createSettingsTab();
@@ -779,7 +827,7 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
it("saves a custom configuration passphrase edit through the setting service", async () => {
const { tab } = createSettingsTab();
const changeConfigurationEncryption = vi.fn(async () => {
vi.mocked(compatGlobal.localStorage.getItem).mockReturnValue("new custom passphrase");
await tab.services.setting.deviceValues.setValue("ls-setting-passphrase", "new custom passphrase");
});
Object.assign(tab.core.services.setting, { changeConfigurationEncryption });
const store = "LOCALSTORAGE";
@@ -787,13 +835,13 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
tab.initialSettings!.configPassphraseStore = store;
tab.editingSettings.configPassphrase = "new custom passphrase";
tab.initialSettings!.configPassphrase = "old custom passphrase";
vi.mocked(compatGlobal.localStorage.setItem).mockClear();
const setValue = vi.spyOn(tab.services.setting.deviceValues, "setValue");
await tab.saveSettings(["configPassphrase"]);
expect(changeConfigurationEncryption).toHaveBeenCalledOnce();
expect(changeConfigurationEncryption).toHaveBeenCalledWith(store, "new custom passphrase");
expect(compatGlobal.localStorage.setItem).not.toHaveBeenCalled();
expect(setValue).toHaveBeenCalledOnce();
expect(tab.initialSettings!.configPassphrase).toBe("new custom passphrase");
});
@@ -811,7 +859,6 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
tab.initialSettings!.configPassphraseStore = "LOCALSTORAGE";
tab.initialSettings!.configPassphrase = "previous passphrase";
tab.editingSettings.configPassphrase = "passphrase A";
vi.mocked(compatGlobal.localStorage.getItem).mockReturnValue("passphrase A");
const apply = tab.saveSettings(["configPassphrase"]);
expect(changeConfigurationEncryption).toHaveBeenCalledWith("LOCALSTORAGE", "passphrase A");
@@ -840,13 +887,13 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
tab.editingSettings.configPassphraseStore = editedStore;
tab.initialSettings!.configPassphrase = "old custom passphrase";
tab.editingSettings.configPassphrase = "new custom passphrase";
vi.mocked(compatGlobal.localStorage.setItem).mockClear();
const setValue = vi.spyOn(tab.services.setting.deviceValues, "setValue");
await expect(tab.saveSettings(["configPassphraseStore", "configPassphrase"])).rejects.toBe(failure);
expect(changeConfigurationEncryption).toHaveBeenCalledOnce();
expect(changeConfigurationEncryption).toHaveBeenCalledWith(editedStore, "new custom passphrase");
expect(compatGlobal.localStorage.setItem).not.toHaveBeenCalled();
expect(setValue).not.toHaveBeenCalled();
expect(tab.core.settings.configPassphraseStore).toBe(savedStore);
expect(tab.initialSettings!.configPassphraseStore).toBe(savedStore);
expect(tab.initialSettings!.configPassphrase).toBe("old custom passphrase");
@@ -43,7 +43,7 @@ describe("paneAdvanced", () => {
};
paneAdvanced.call(host as never, {} as HTMLElement, { addPanel } as never);
await vi.waitFor(() => expect(settingHarness.rendered).toHaveLength(10));
await vi.waitFor(() => expect(settingHarness.rendered).toHaveLength(11));
expect(addPanel.mock.calls.map(([, heading]) => heading)).toEqual([
"Memory cache",
@@ -56,6 +56,7 @@ describe("paneAdvanced", () => {
panelElements.get("Local Database Tweak"),
panelElements.get("Local Database Tweak"),
panelElements.get("Local Database Tweak"),
panelElements.get("Local Database Tweak"),
panelElements.get("Transfer Tweak"),
panelElements.get("Transfer Tweak"),
panelElements.get("Transfer Tweak"),
@@ -67,6 +68,7 @@ describe("paneAdvanced", () => {
"hashCacheMaxCount",
"chunkSplitterVersion",
"customChunkSize",
"strictContentCheckOnStartup",
"pathNormalizationVersion",
"readChunksOnline",
"useOnlyLocalChunk",
@@ -7,6 +7,7 @@ import {
type ObsidianLiveSyncSettings,
LOG_LEVEL_VERBOSE,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { OPEN_JOINED_DEVICES } from "@/serviceFeatures/joinedDevicesCommand";
import { Menu, type ButtonComponent } from "@/deps.ts";
import { $msg } from "@/common/translation";
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
@@ -101,6 +102,22 @@ export function paneRemoteConfig(
paneEl: HTMLElement,
{ addPanel }: PageFunctions
): void {
new Setting(paneEl)
.setName($msg("Joined devices"))
.setDesc($msg("Review device records for the active CouchDB or Object Storage connection."))
.addButton((button) =>
button.setButtonText($msg("Review devices")).onClick(async () => {
const result = await this.services.UI.commands.invoke(OPEN_JOINED_DEVICES, undefined);
if (result.status === "unavailable" || result.value === "unavailable") {
this.services.API.addLog(
$msg(
"Joined devices are available after database startup with an active CouchDB or Object Storage connection."
),
LOG_LEVEL_NOTICE
);
}
})
);
{
/* E2EE */
const E2EEInitialProps = {
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
const runtime = vi.hoisted(() => ({
buttonClasses: [] as string[],
clickHandlers: [] as Array<() => Promise<void> | void>,
clickHandlers: [] as Array<{ setting: string; run: () => Promise<void> | void }>,
panels: [] as Array<{ destroy: ReturnType<typeof vi.fn> }>,
settingClasses: [] as string[],
}));
@@ -32,7 +32,10 @@ vi.mock("./LiveSyncSetting.ts", () => ({
},
};
setName() {
settingName = "";
setName(name: string) {
this.settingName = name;
return this;
}
@@ -41,6 +44,7 @@ vi.mock("./LiveSyncSetting.ts", () => ({
}
addButton(callback: (button: unknown) => void) {
const setting = this.settingName;
const button = {
buttonEl: {
classList: {
@@ -53,7 +57,7 @@ vi.mock("./LiveSyncSetting.ts", () => ({
return this;
},
onClick(callback: () => Promise<void> | void) {
runtime.clickHandlers.push(callback);
runtime.clickHandlers.push({ setting, run: callback });
return this;
},
setButtonText() {
@@ -107,6 +111,7 @@ vi.mock("./remoteConfigBuffer.ts", () => ({
syncActivatedRemoteSettings: vi.fn(),
}));
import { OPEN_JOINED_DEVICES } from "@/serviceFeatures/joinedDevicesCommand";
import { paneRemoteConfig } from "./PaneRemoteConfig.ts";
function createPanelElement(): HTMLElement {
@@ -124,6 +129,18 @@ afterEach(() => {
});
describe("paneRemoteConfig", () => {
it("opens joined devices through the shared UI command", async () => {
const invoke = vi.fn(async () => ({ status: "handled", value: "closed" }));
const host = {
editingSettings: { remoteConfigurations: {} },
services: { UI: { commands: { invoke } } },
};
const addPanel = vi.fn(() => ({ then: vi.fn() }));
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
await runtime.clickHandlers.find((handler) => handler.setting === "Joined devices")!.run();
expect(invoke).toHaveBeenCalledWith(OPEN_JOINED_DEVICES, undefined);
});
it("destroys the E2EE info panel when the settings page lifetime unloads", async () => {
const callbacks: Array<() => unknown> = [];
const lifetimeComponent = {
@@ -187,7 +204,7 @@ describe("paneRemoteConfig", () => {
}));
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
await runtime.clickHandlers[0]();
await runtime.clickHandlers.find((handler) => handler.setting === "Configure E2EE")!.run();
expect(setupManager.onlyE2EEConfiguration).toHaveBeenCalledOnce();
expect(host.editingSettings.encryptInternalMetadata).toBe(true);
@@ -233,7 +250,7 @@ describe("paneRemoteConfig", () => {
}));
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
await runtime.clickHandlers[0]();
await runtime.clickHandlers.find((handler) => handler.setting === "Configure E2EE")!.run();
expect(host.editingSettings.idDerivationVersion).toBe(1);
expect(host.editingSettings.idDerivationKey).toBe(nextIdKey);
@@ -13,7 +13,7 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
.setName($msg("Synchronising files"))
.setDesc(
$msg(
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files."
"(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns."
)
);
const syncFilesControl = mount(MultipleRegExpControl, {
@@ -32,9 +32,7 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
const nonSyncFilesSetting = new Setting(paneEl)
.setName($msg("Non-Synchronising files"))
.setDesc(
$msg("(RegExp) If this is set, any changes to local and remote files that match this will be skipped.")
);
.setDesc($msg("(RegExp) Skip changes to matching normal and hidden files, locally and remotely."));
const nonSyncFilesControl = mount(MultipleRegExpControl, {
target: nonSyncFilesSetting.controlEl,
@@ -171,20 +171,18 @@ export function paneSyncSettings(
}
});
void addPanel(
paneEl,
$msg("obsidianLiveSyncSettingTab.titleUpdateThinning"),
undefined,
visibleOnly(() => !this.isConfiguredAs("syncMode", "LIVESYNC"))
).then((paneEl) => {
new Setting(paneEl).autoWireToggle("batchSave");
void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleUpdateThinning")).then((paneEl) => {
const inLiveSyncMode = () => this.isConfiguredAs("syncMode", "LIVESYNC");
const batchEnabled = () => this.isConfiguredAs(inLiveSyncMode() ? "batchSaveInLiveSync" : "batchSave", true);
new Setting(paneEl).autoWireToggle("batchSave", { onUpdate: visibleOnly(() => !inLiveSyncMode()) });
new Setting(paneEl).autoWireToggle("batchSaveInLiveSync", { onUpdate: visibleOnly(inLiveSyncMode) });
new Setting(paneEl).autoWireNumeric("batchSaveMinimumDelay", {
acceptZero: true,
onUpdate: visibleOnly(() => this.isConfiguredAs("batchSave", true)),
onUpdate: visibleOnly(batchEnabled),
});
new Setting(paneEl).autoWireNumeric("batchSaveMaximumDelay", {
acceptZero: true,
onUpdate: visibleOnly(() => this.isConfiguredAs("batchSave", true)),
onUpdate: visibleOnly(batchEnabled),
});
});
@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab";
import type { OnUpdateFunc, PageFunctions } from "./SettingPane";
const controls = vi.hoisted(() => new Map<string, { onUpdate?: () => { visibility?: boolean } }>());
vi.mock("@/common/translation", () => ({ $msg: (key: string) => key }));
vi.mock("@vrtmrz/livesync-commonlib/compat/common/logger", () => ({ Logger: vi.fn() }));
vi.mock("./LiveSyncSetting.ts", () => ({
LiveSyncSetting: class {
autoWireDropDown(key: string, options = {}) {
controls.set(key, options);
return this;
}
autoWireToggle(key: string, options = {}) {
controls.set(key, options);
return this;
}
autoWireNumeric(key: string, options = {}) {
controls.set(key, options);
return this;
}
},
}));
import { paneSyncSettings } from "./PaneSyncSettings";
function setup(liveSync: boolean, batchSave: boolean, batchSaveInLiveSync: boolean) {
const callbacks = new Map<string, (value: string) => Promise<void>>();
const panels = new Map<string, OnUpdateFunc | undefined>();
const tab = {
editingSettings: { ...DEFAULT_SETTINGS, liveSync, batchSave, batchSaveInLiveSync },
services: { API: { isMobile: () => false }, control: { applySettings: vi.fn(async () => {}) } },
addOnSaved: (key: string, callback: (value: string) => Promise<void>) => callbacks.set(key, callback),
isConfiguredAs(key: string, value: unknown) {
if (key === "syncMode") {
return (
(this.editingSettings.liveSync
? "LIVESYNC"
: this.editingSettings.periodicReplication
? "PERIODIC"
: "ONEVENTS") === value
);
}
return this.editingSettings[key as keyof typeof this.editingSettings] === value;
},
saveSettings: vi.fn(async () => {}),
requestCatalogueRefresh: vi.fn(),
};
const functions = {
addPanel: (_element: HTMLElement, title: string, _callback: unknown, onUpdate?: OnUpdateFunc) => ({
then(callback: (element: HTMLElement) => void) {
if (!title.endsWith("titleSynchronizationMethod") && !title.endsWith("titleUpdateThinning")) return;
panels.set(title, onUpdate);
callback({} as HTMLElement);
},
}),
} as unknown as PageFunctions;
paneSyncSettings.call(tab as unknown as ObsidianLiveSyncSettingTab, {} as HTMLElement, functions);
return { tab, callbacks, panels };
}
function visible(key: string): boolean {
const control = controls.get(key);
if (!control) throw new Error(`Missing setting control: ${key}`);
return control.onUpdate?.().visibility !== false;
}
beforeEach(() => controls.clear());
describe("mode-specific database batching settings", () => {
it.each([
{ liveSync: true, batchSave: true, batchSaveInLiveSync: false, delays: false },
{ liveSync: true, batchSave: false, batchSaveInLiveSync: true, delays: true },
{ liveSync: false, batchSave: true, batchSaveInLiveSync: false, delays: true },
{ liveSync: false, batchSave: false, batchSaveInLiveSync: true, delays: false },
])("shows the current mode choice and its shared delays: %j", ({ delays, ...settings }) => {
const { panels } = setup(settings.liveSync, settings.batchSave, settings.batchSaveInLiveSync);
expect(panels.get("obsidianLiveSyncSettingTab.titleUpdateThinning")?.().visibility).not.toBe(false);
expect(visible("batchSave")).toBe(!settings.liveSync);
expect(visible("batchSaveInLiveSync")).toBe(settings.liveSync);
expect(visible("batchSaveMinimumDelay")).toBe(delays);
expect(visible("batchSaveMaximumDelay")).toBe(delays);
});
it.each([
[true, false],
[false, true],
])("retains both batching choices across mode changes: %j", async (batchSave, batchSaveInLiveSync) => {
const { tab, callbacks } = setup(false, batchSave, batchSaveInLiveSync);
const switchMode = callbacks.get("syncMode")!;
for (const mode of ["LIVESYNC", "PERIODIC", "ONEVENTS", "LIVESYNC"]) {
await switchMode(mode);
expect(tab.editingSettings).toMatchObject({ batchSave, batchSaveInLiveSync });
}
expect(tab.services.control.applySettings).toHaveBeenCalledTimes(4);
});
});
@@ -17,7 +17,7 @@ const rangeMessages = {
};
describe("Advanced setting specifications", () => {
it("lists only the ten controls currently exposed by the Advanced page", () => {
it("lists only the eleven controls currently exposed by the Advanced page", () => {
const groups = createAdvancedSettingSpecGroups({ isCouchDB: () => true });
expect(groups.map(({ heading }) => heading)).toEqual([
@@ -30,6 +30,7 @@ describe("Advanced setting specifications", () => {
"hashCacheMaxCount",
"chunkSplitterVersion",
"customChunkSize",
"strictContentCheckOnStartup",
"pathNormalizationVersion",
"readChunksOnline",
"useOnlyLocalChunk",
@@ -40,6 +41,17 @@ describe("Advanced setting specifications", () => {
]);
});
it("exposes strict content checking as an ungated Advanced toggle", () => {
const spec = createAdvancedSettingSpecGroups({ isCouchDB: () => false })
.find(({ heading }) => heading === "Local Database Tweak")
?.items.find(({ key }) => key === "strictContentCheckOnStartup");
if (!spec) throw new Error("Strict content checking is not exposed on the Advanced page");
expect(spec.control).toEqual({ type: "toggle" });
expect(spec.visible).toBeUndefined();
expect(spec.disabled).toBeUndefined();
});
it("keeps path identity and the four remote chunk controls CouchDB-only", () => {
let couchDB = false;
const groups = createAdvancedSettingSpecGroups({ isCouchDB: () => couchDB });
@@ -86,6 +86,7 @@ describe("settings page catalogue", () => {
"hashCacheMaxCount",
"chunkSplitterVersion",
"customChunkSize",
"strictContentCheckOnStartup",
"pathNormalizationVersion",
"readChunksOnline",
"useOnlyLocalChunk",