Merge pull request #1136 from vrtmrz/fix/declarative-settings-startup-evaluation

Fix premature settings evaluation at start-up
This commit is contained in:
vorotamoroz authored and GitHub committed 2026-08-25 23:25:13 +09:00
commit 1a84b08de5
9 files changed
+306 -87

No files matched your search

+6
View File
@@ -245,6 +245,12 @@ export class ModuleExample extends AbstractObsidianModule {
- Settings are defined by Commonlib (`ObsidianLiveSyncSettings`) - Settings are defined by Commonlib (`ObsidianLiveSyncSettings`)
- Configuration metadata is supplied by the Commonlib settings exports - Configuration metadata is supplied by the Commonlib settings exports
- Obsidian may request declarative definitions immediately from
`Plugin.addSettingTab()`. Register a settings tab which reads persisted values
from the sequential `onSettingLoaded` lifecycle, seed its editing snapshot
before registration, and keep definition construction independent of local
database and replicator readiness. See
[the declarative settings adapter ADR](docs/adr/2026_08_declarative_settings_adapter.md).
- Use `this.services.setting.saveSettingData()` instead of using plugin methods directly - Use `this.services.setting.saveSettingData()` instead of using plugin methods directly
### Database Operations ### Database Operations
@@ -1,7 +1,7 @@
--- ---
date: 2026-08-25 date: 2026-08-25
commonlib-version: "0.1.19" commonlib-version: "0.1.19"
self-hosted-livesync-version: "1.0.18" self-hosted-livesync-version: "1.0.20"
status: accepted status: accepted
--- ---
@@ -15,7 +15,8 @@ limited to one-key, immediately persisted controls. Complex pages retain their
existing renderers instead of being forced through a general abstraction. existing renderers instead of being forced through a general abstraction.
Settings pending application which require database initialisation now delegate Settings pending application which require database initialisation now delegate
their decision, scheduling, and restart boundary to `SetupManager` and their decision, scheduling, and restart boundary to `SetupManager` and
`Rebuilder`. `Rebuilder`. Setting-tab registration and definition construction also follow
the persisted-settings lifecycle rather than transient runtime readiness.
## Context ## Context
@@ -25,6 +26,15 @@ native rendering, validation, navigation, and global settings search. When the
method returns a non-empty array, Obsidian does not call the existing method returns a non-empty array, Obsidian does not call the existing
`display()` implementation. `display()` implementation.
Obsidian may call `getSettingDefinitions()` as soon as a tab is passed to
`Plugin.addSettingTab()`. Registering the tab during initialisation therefore
allowed definition construction to observe constructor defaults before
persisted settings had loaded. The former landing-page predicate also inspected
the active replicator, although the local database and replicator are created
only after the settings-loaded lifecycle. On start-up this ordering could emit
a spurious missing-replicator warning and produce a landing-page order from
transient state.
Self-hosted LiveSync still supports Obsidian versions before 1.13 through its Self-hosted LiveSync still supports Obsidian versions before 1.13 through its
`minAppVersion` of 1.7.2. It must therefore retain an imperative `display()` `minAppVersion` of 1.7.2. It must therefore retain an imperative `display()`
fallback unless the minimum supported Obsidian version is raised separately. fallback unless the minimum supported Obsidian version is raised separately.
@@ -168,13 +178,13 @@ for narrow mobile displays while preventing the unheaded page entries from
appearing to continue the preceding Quick Setup group. The root order reflects appearing to continue the preceding Quick Setup group. The root order reflects
the current task: the current task:
| Current state | First root sections | | Configuration state | First root sections |
| --------------------------- | ----------------------------------------------------------------------------------- | | ------------------- | --------------------------------------------------------------------------------------------------------- |
| Synchronisation is inactive | Quick Setup, Synchronisation (Remote Configuration and Sync Settings), then General | | Unconfigured | Quick Setup, Synchronisation (Remote Configuration and Sync Settings), then General |
| Synchronisation is active | Synchronisation (Remote Configuration and Sync Settings), General, then Quick Setup | | Configured | Synchronisation (Remote Configuration and Sync Settings), General, Set up other devices, then Quick Setup |
Set up other devices follows the Quick Setup and General groups when the Set up other devices is hidden until the plug-in is configured. The remaining
plug-in is configured. The remaining destinations are grouped explicitly: destinations are grouped explicitly:
| Group | Pages | | Group | Pages |
| ------------------------ | ---------------------------------------- | | ------------------------ | ---------------------------------------- |
@@ -189,10 +199,11 @@ requests a catalogue refresh after persistence. External setting reloads use
the same boundary. Constructing the definitions still performs no persistence, the same boundary. Constructing the definitions still performs no persistence,
service, file, database, or network operation. service, file, database, or network operation.
The imperative renderer retains its existing default-page selection: Quick Setup for The imperative renderer uses the same stable distinction for its default-page
an inactive configuration and General for an active configuration. The landing selection: Quick Setup for an unconfigured installation and General for a
composition is therefore a native 1.13 improvement rather than a behaviour configured installation. The landing composition is therefore a native 1.13
change for earlier supported Obsidian versions. improvement rather than a separate interpretation of synchronisation state on
earlier supported Obsidian versions.
The custom `SettingPage` adapter class will be constructed lazily from the The custom `SettingPage` adapter class will be constructed lazily from the
1.13-or-later path. `SettingPage` may remain a normal runtime import because the 1.13-or-later path. `SettingPage` may remain a normal runtime import because the
@@ -355,6 +366,23 @@ and side-effect free. Obsidian calls the method during search indexing and
again on updates; it must perform no file, database, network, or settings again on updates; it must perform no file, database, network, or settings
write. write.
### Register the setting tab after persisted settings load
The settings module registers its `PluginSettingTab` from the sequential
`onSettingLoaded` lifecycle, not from `onInitialise`. Immediately before
registration, it seeds the tab's editing and initial snapshots through
`reloadAllSettings(true)`. Skipping the update request is intentional because
the tab is not yet owned by Obsidian; `addSettingTab()` may request definitions
immediately after this seeding step.
This lifecycle still precedes local database opening and replicator activation.
Definition construction must therefore depend only on the seeded setting
snapshot, static catalogue data, and translations. In particular, root-page
ordering is based on the persisted `isConfigured` value. It must not inspect
automatic synchronisation triggers, the active replicator, replication status,
database readiness, files, or the network. Runtime operations remain explicit
actions which run after the user selects them.
### Give imperative pages an explicit lifetime and refresh boundary ### Give imperative pages an explicit lifetime and refresh boundary
The present `display()` renders every pane together, so arrays of The present `display()` renders every pane together, so arrays of
@@ -549,7 +577,12 @@ Stage C1 and the landing-page focused unit tests verify:
identifiers and names; identifiers and names;
- Appearance, Logging, Extra menus, and Advanced are native-items child pages, - Appearance, Logging, Extra menus, and Advanced are native-items child pages,
and ten child pages retain custom factories; and ten child pages retain custom factories;
- inactive and active configurations use their specified landing-page order; - configured and unconfigured installations use their specified landing-page
order regardless of transient replication status;
- definition construction does not request the active replicator before the
database is ready;
- the settings tab is registered only after persisted settings load, and its
editing snapshot is seeded before registration without requesting a render;
- Remote Configuration and Sync Settings remain native navigable pages inside - Remote Configuration and Sync Settings remain native navigable pages inside
the separate Synchronisation group; the separate Synchronisation group;
- maintenance, extra features, advanced settings, and help have explicit page - maintenance, extra features, advanced settings, and help have explicit page
@@ -626,17 +659,27 @@ persistence of the same Advanced value. The shared E2E navigator owns both the
separate settings renderer used by Obsidian 1.13 and the legacy separate settings renderer used by Obsidian 1.13 and the legacy
`.sls-setting-menu-btn` interface. `.sls-setting-menu-btn` interface.
The Stage C2 landing composition was then exercised on Obsidian 1.13.4. With Before the start-up lifecycle correction, the Stage C2 landing composition was
synchronisation inactive, the real interface rendered Quick Setup, a separate exercised on Obsidian 1.13.4 with a configured installation whose automatic
Synchronisation group containing Remote Configuration and Sync Settings, and a synchronisation triggers were disabled. Under the former predicate, the real
General Settings group containing Appearance, Logging, and Extra menus in the interface rendered Quick Setup, a separate Synchronisation group containing
specified order. It opened all 14 nested settings pages, found the Advanced Remote Configuration and Sync Settings, and a General Settings group containing
control through global settings search, and restored its saved value after Appearance, Logging, and Extra menus in that order. It opened all 14 nested
reopening settings. In mobile test mode, Remote Configuration remained inside settings pages, found the Advanced control through global settings search, and
the initial viewport below the two Quick Setup actions and the Synchronisation restored its saved value after reopening settings. In mobile test mode, Remote
heading. The complete scenario also passed with the same bundle on Obsidian Configuration remained inside the initial viewport below the two Quick Setup
1.12.7, confirming that the imperative fallback retained its navigation and actions and the Synchronisation heading. The complete scenario also passed with
save behaviour. the same bundle on Obsidian 1.12.7, confirming that the imperative fallback
retained its navigation and save behaviour.
The start-up lifecycle correction was subsequently exercised with the same
official Obsidian 1.13.4 build. The settings scenario captured and verified the
exact configured and unconfigured root-group orders, including Set up other
devices before Quick Setup for a configured installation. The same bundle
opened General Settings by default through the imperative fallback on Obsidian
1.12.7. Focused unit tests own the earlier lifecycle boundary: persisted
settings are copied before registration, and definition construction does not
request an active replicator.
## Expansion Checkpoints ## Expansion Checkpoints
+1 -1
View File
@@ -4,7 +4,7 @@ NOTE: This document not completed. I'll improve this doc in a while. but your co
There are many settings in Self-hosted LiveSync. This document describes each setting in detail (not how-to). Configuration and settings are divided into several categories and indicated by icons. The icon is as follows: There are many settings in Self-hosted LiveSync. This document describes each setting in detail (not how-to). Configuration and settings are divided into several categories and indicated by icons. The icon is as follows:
On Obsidian 1.13 or later, the root settings page is organised by task. When synchronisation is inactive, **Quick Setup** appears first. Once any synchronisation mode is active, **Synchronisation** and **General Settings** move ahead of **Quick Setup**. **Set up other devices** appears after this plug-in has been configured. Earlier supported Obsidian versions retain a pane-based interface with the same controls. On Obsidian 1.13 or later, the root settings page is organised by task. On an unconfigured installation, **Quick Setup** appears first, followed by **Synchronisation** and **General Settings**. Once this plug-in has been configured, **Synchronisation** and **General Settings** appear first, followed by **Set up other devices** and **Quick Setup**. Earlier supported Obsidian versions retain a pane-based interface with the same controls; they open **Quick Setup** when unconfigured and **General Settings** when configured.
| Icon | Root group | Contents or availability | | Icon | Root group | Contents or availability |
| :--: | ------------------------ | ------------------------------------------------------------- | | :--: | ------------------------ | ------------------------------------------------------------- |
@@ -8,8 +8,9 @@ import { openObsidianSettings } from "@/common/obsidianSettings.ts";
export class ModuleObsidianSettingDialogue extends AbstractObsidianModule { export class ModuleObsidianSettingDialogue extends AbstractObsidianModule {
settingTab!: ObsidianLiveSyncSettingTab; settingTab!: ObsidianLiveSyncSettingTab;
_everyOnloadStart(): Promise<boolean> { _everyOnloadAfterLoadSettings(): Promise<boolean> {
this.settingTab = new ObsidianLiveSyncSettingTab(this.app, this.plugin); this.settingTab = new ObsidianLiveSyncSettingTab(this.app, this.plugin);
this.settingTab.reloadAllSettings(true);
this.plugin.addSettingTab(this.settingTab); this.plugin.addSettingTab(this.settingTab);
eventHub.onEvent(EVENT_REQUEST_OPEN_SETTINGS, () => this.openSetting()); eventHub.onEvent(EVENT_REQUEST_OPEN_SETTINGS, () => this.openSetting());
@@ -24,6 +25,6 @@ export class ModuleObsidianSettingDialogue extends AbstractObsidianModule {
return `${"appId" in this.app ? this.app.appId : ""}`; return `${"appId" in this.app ? this.app.appId : ""}`;
} }
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void { override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this)); services.appLifecycle.onSettingLoaded.addHandler(this._everyOnloadAfterLoadSettings.bind(this));
} }
} }
@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const settingTabState = vi.hoisted(() => ({
callOrder: [] as string[],
reloadAllSettings: vi.fn<(skipUpdate?: boolean) => void>(),
}));
const eventHubState = vi.hoisted(() => ({
onEvent: vi.fn(),
}));
vi.mock("./SettingDialogue/ObsidianLiveSyncSettingTab.ts", () => ({
ObsidianLiveSyncSettingTab: class ObsidianLiveSyncSettingTab {
reloadAllSettings(skipUpdate?: boolean) {
settingTabState.callOrder.push(`reload:${String(skipUpdate)}`);
settingTabState.reloadAllSettings(skipUpdate);
}
},
}));
vi.mock("@/common/events.ts", () => ({
EVENT_REQUEST_OPEN_SETTINGS: "request-open-settings",
eventHub: eventHubState,
}));
import { ModuleObsidianSettingDialogue } from "./ModuleObsidianSettingTab.ts";
function createModuleHarness() {
let initialisationHandler: (() => Promise<boolean>) | undefined;
let settingsLoadedHandler: (() => Promise<boolean>) | undefined;
const plugin = {
app: {},
addSettingTab: vi.fn(() => settingTabState.callOrder.push("add-setting-tab")),
};
const services = {
appLifecycle: {
onInitialise: {
addHandler: vi.fn((handler: () => Promise<boolean>) => {
initialisationHandler = handler;
}),
},
onSettingLoaded: {
addHandler: vi.fn((handler: () => Promise<boolean>) => {
settingsLoadedHandler = handler;
}),
},
},
};
const module = Object.assign(Object.create(ModuleObsidianSettingDialogue.prototype), {
plugin,
core: { services },
}) as ModuleObsidianSettingDialogue;
module.onBindFunction(module.core as never, services as never);
return {
initialisationHandler: () => initialisationHandler,
module,
plugin,
services,
settingsLoadedHandler: () => settingsLoadedHandler,
};
}
describe("ModuleObsidianSettingDialogue startup lifecycle", () => {
beforeEach(() => {
settingTabState.callOrder.length = 0;
settingTabState.reloadAllSettings.mockClear();
eventHubState.onEvent.mockClear();
});
it("registers the setting tab after persisted settings have loaded", () => {
const { initialisationHandler, services, settingsLoadedHandler } = createModuleHarness();
expect(services.appLifecycle.onInitialise.addHandler).not.toHaveBeenCalled();
expect(services.appLifecycle.onSettingLoaded.addHandler).toHaveBeenCalledOnce();
expect(initialisationHandler()).toBeUndefined();
expect(settingsLoadedHandler()).toBeTypeOf("function");
});
it("seeds the setting editor without requesting a render before registration", async () => {
const { initialisationHandler, settingsLoadedHandler } = createModuleHarness();
const handler = settingsLoadedHandler() ?? initialisationHandler();
expect(handler).toBeTypeOf("function");
await handler!();
expect(settingTabState.reloadAllSettings).toHaveBeenCalledWith(true);
expect(settingTabState.callOrder).toEqual(["reload:true", "add-setting-tab"]);
});
});
@@ -145,22 +145,36 @@ function findPage(tab: ObsidianLiveSyncSettingTab, name: string): SettingDefinit
return page; return page;
} }
function createSettingsTab(): ObsidianLiveSyncSettingTab { type SettingsTabOptions = {
const plugin = { activeReplicatorGetter?: () => { syncStatus: "CONNECTED" | "PAUSED" } | undefined;
app: {}, replicationStatus?: "CLOSED" | "CONNECTED" | "PAUSED";
core: { };
settings: { ...DEFAULT_SETTINGS, useAdvancedMode: true },
confirm: { function createSettingsTab(options: SettingsTabOptions = {}): ObsidianLiveSyncSettingTab {
askInPopup: vi.fn(), const core = {
settings: { ...DEFAULT_SETTINGS, useAdvancedMode: true },
confirm: {
askInPopup: vi.fn(),
},
services: {
setting: {
getDeviceAndVaultName: vi.fn(() => ""),
saveSettingData: vi.fn(async () => undefined),
}, },
services: { replicator: {
setting: { replicationStatics: {
getDeviceAndVaultName: vi.fn(() => ""), value: { syncStatus: options.replicationStatus ?? "CLOSED" },
saveSettingData: vi.fn(async () => undefined),
}, },
}, },
}, },
}; };
Object.defineProperty(core, "replicator", {
get: options.activeReplicatorGetter ?? (() => undefined),
});
const plugin = {
app: {},
core,
};
const tab = new ObsidianLiveSyncSettingTab({} as never, plugin as never); const tab = new ObsidianLiveSyncSettingTab({} as never, plugin as never);
Object.assign(tab, { Object.assign(tab, {
_editingSettings: { ...DEFAULT_SETTINGS, useAdvancedMode: true }, _editingSettings: { ...DEFAULT_SETTINGS, useAdvancedMode: true },
@@ -181,8 +195,27 @@ beforeEach(() => {
}); });
describe("ObsidianLiveSyncSettingTab native page lifecycle", () => { describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
it("keeps Quick Setup first while synchronisation is inactive and separates synchronisation pages from it", () => { it("builds definitions before database readiness without requesting the active replicator", () => {
const activeReplicatorGetter = vi.fn(() => {
throw new Error("The active replicator is not ready");
});
const tab = createSettingsTab({ activeReplicatorGetter });
expect(() => tab.getSettingDefinitions()).not.toThrow();
expect(activeReplicatorGetter).not.toHaveBeenCalled();
});
it("keeps Quick Setup first while LiveSync is not configured, regardless of transient replication status", () => {
const tab = createSettingsTab({ replicationStatus: "CONNECTED" });
tab.editingSettings.isConfigured = false;
const definitions = tab.getSettingDefinitions().filter(isGroup);
expect(definitions[0]?.heading).toBe("🧙‍♂️ Quick Setup");
});
it("keeps Quick Setup first while LiveSync is not configured and separates synchronisation pages from it", () => {
const tab = createSettingsTab(); const tab = createSettingsTab();
tab.editingSettings.isConfigured = false;
const definitions = tab.getSettingDefinitions().filter(isGroup); const definitions = tab.getSettingDefinitions().filter(isGroup);
expect(definitions.slice(0, 3).map(itemLabel)).toEqual([ expect(definitions.slice(0, 3).map(itemLabel)).toEqual([
@@ -192,14 +225,15 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
]); ]);
}); });
it("keeps the synchronisation group first and orders General Settings before Quick Setup while synchronisation is active", () => { it("keeps the synchronisation group first for a configured device with automatic triggers disabled", () => {
const tab = createSettingsTab(); const tab = createSettingsTab();
tab.editingSettings.liveSync = true; tab.editingSettings.isConfigured = true;
const definitions = tab.getSettingDefinitions().filter(isGroup); const definitions = tab.getSettingDefinitions().filter(isGroup);
expect(definitions.slice(0, 3).map(itemLabel)).toEqual([ expect(definitions.slice(0, 4).map(itemLabel)).toEqual([
"🔄 Synchronisation", "🔄 Synchronisation",
"⚙️ General Settings", "⚙️ General Settings",
"📲 Set up other devices",
"🧙‍♂️ Quick Setup", "🧙‍♂️ Quick Setup",
]); ]);
}); });
@@ -40,7 +40,6 @@ import {
eventHub, eventHub,
} from "@/common/events.ts"; } from "@/common/events.ts";
import { import {
enableOnly,
// findAttrFromParent, // findAttrFromParent,
// getLevelStr, // getLevelStr,
setLevelClass, setLevelClass,
@@ -587,19 +586,8 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
"encrypt", "encrypt",
]); ]);
} }
isAnySyncEnabled() { isLiveSyncConfigured() {
if (this.isConfiguredAs("isConfigured", false)) return false; return this.isConfiguredAs("isConfigured", true);
if (this.isConfiguredAs("liveSync", true)) return true;
if (this.isConfiguredAs("periodicReplication", true)) return true;
if (this.isConfiguredAs("syncOnFileOpen", true)) return true;
if (this.isConfiguredAs("syncOnSave", true)) return true;
if (this.isConfiguredAs("syncOnEditorSave", true)) return true;
if (this.isConfiguredAs("syncOnStart", true)) return true;
if (this.isConfiguredAs("syncAfterMerge", true)) return true;
if (this.isConfiguredAs("syncOnFileOpen", true)) return true;
if (this.core?.replicator?.syncStatus == "CONNECTED") return true;
if (this.core?.replicator?.syncStatus == "PAUSED") return true;
return false;
} }
private supportsDeclarativeSettings(): boolean { private supportsDeclarativeSettings(): boolean {
@@ -905,13 +893,20 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
getPage("help"), getPage("help"),
getPage("change-log"), getPage("change-log"),
]); ]);
const laterGroups = [setupOtherDevices, maintenance, extraFeatures, advancedSettings, helpAndInformation]; const laterGroups = [maintenance, extraFeatures, advancedSettings, helpAndInformation];
const pendingInitialisation = this.createRebuildRequiredAction(); const pendingInitialisation = this.createRebuildRequiredAction();
if (this.isAnySyncEnabled()) { if (this.isLiveSyncConfigured()) {
return [pendingInitialisation, synchronisation, generalSettings, quickSetup, ...laterGroups]; return [
pendingInitialisation,
synchronisation,
generalSettings,
setupOtherDevices,
quickSetup,
...laterGroups,
];
} }
return [pendingInitialisation, quickSetup, synchronisation, generalSettings, ...laterGroups]; return [pendingInitialisation, quickSetup, synchronisation, generalSettings, setupOtherDevices, ...laterGroups];
} }
private beginRenderScope(refresh: () => void): Component { private beginRenderScope(refresh: () => void): Component {
@@ -936,8 +931,6 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
this.controlledElementFunc.length = 0; this.controlledElementFunc.length = 0;
} }
enableOnlySyncDisabled = enableOnly(() => !this.isAnySyncEnabled());
onlyOnP2POrCouchDB = () => onlyOnP2POrCouchDB = () =>
({ ({
visibility: visibility:
@@ -1184,7 +1177,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
void yieldNextAnimationFrame().then(() => { void yieldNextAnimationFrame().then(() => {
if (this.selectedScreen == "") { if (this.selectedScreen == "") {
if (this.isAnySyncEnabled()) { if (this.isLiveSyncConfigured()) {
changeDisplay("20"); changeDisplay("20");
} else { } else {
changeDisplay("110"); changeDisplay("110");
+69 -24
View File
@@ -14,7 +14,7 @@ import {
withObsidianPage, withObsidianPage,
} from "../runner/ui.ts"; } from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts"; import { createTemporaryVault } from "../runner/vault.ts";
import type { Locator } from "playwright"; import type { Locator, Page } from "playwright";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETTINGS_TIMEOUT_MS ?? 10000); const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETTINGS_TIMEOUT_MS ?? 10000);
const settingsOnly = process.env.E2E_OBSIDIAN_SETTINGS_ONLY === "true"; const settingsOnly = process.env.E2E_OBSIDIAN_SETTINGS_ONLY === "true";
@@ -34,6 +34,11 @@ type LiveSyncTestPlugin = {
applySettings: () => Promise<void>; applySettings: () => Promise<void>;
isP2P: boolean; isP2P: boolean;
}) => Promise<unknown>; }) => Promise<unknown>;
settingTab?: {
editingSettings: { isConfigured: boolean };
initialSettings?: { isConfigured: boolean };
requestCatalogueRefresh(): void;
};
}[]; }[];
settings: { settings: {
handleFilenameCaseSensitive: boolean; handleFilenameCaseSensitive: boolean;
@@ -84,17 +89,14 @@ const settingsPageNames = [
"Change Log", "Change Log",
] as const; ] as const;
async function assertDeclarativeLandingOrder(root: Locator): Promise<void> { async function assertDeclarativeLandingOrder(root: Locator, configured: boolean): Promise<void> {
const synchronisation = ["Synchronisation", "Remote Configuration", "Sync Settings"];
const generalSettings = ["General Settings", "Appearance", "Logging", "Extra menus"];
const setup = configured
? [...synchronisation, ...generalSettings, "📲 Set up other devices", "Quick Setup"]
: ["Quick Setup", ...synchronisation, ...generalSettings, "📲 Set up other devices"];
const labels = [ const labels = [
"Quick Setup", ...setup,
"Synchronisation",
"Remote Configuration",
"Sync Settings",
"General Settings",
"Appearance",
"Logging",
"Extra menus",
"📲 Set up other devices",
"Maintenance and recovery", "Maintenance and recovery",
"Maintenance", "Maintenance",
"Hatch", "Hatch",
@@ -131,10 +133,31 @@ async function assertDeclarativeLandingOrder(root: Locator): Promise<void> {
}, labels); }, labels);
} }
async function scrollDeclarativeLandingToTop(root: Locator): Promise<void> { async function scrollDeclarativeLandingToTop(root: Locator, configured: boolean): Promise<void> {
const quickSetupHeading = root.locator(".setting-item-heading").filter({ hasText: "Quick Setup" }).first(); const firstHeading = root
await quickSetupHeading.waitFor({ state: "visible", timeout: uiTimeoutMs }); .locator(".setting-item-heading")
await quickSetupHeading.scrollIntoViewIfNeeded(); .filter({ hasText: configured ? "Synchronisation" : "Quick Setup" })
.first();
await firstHeading.waitFor({ state: "visible", timeout: uiTimeoutMs });
await firstHeading.scrollIntoViewIfNeeded();
}
async function setConfiguredStateForLandingInspection(page: Page, configured: boolean): Promise<void> {
await page.evaluate((nextConfigured) => {
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
if (plugin === undefined) throw new Error("Self-hosted LiveSync is unavailable");
const settingDialogue = plugin.core.modules.find(
(module) => module.constructor.name === "ModuleObsidianSettingDialogue"
);
if (settingDialogue?.settingTab === undefined) {
throw new Error("The Self-hosted LiveSync setting tab is unavailable");
}
settingDialogue.settingTab.editingSettings.isConfigured = nextConfigured;
if (settingDialogue.settingTab.initialSettings !== undefined) {
settingDialogue.settingTab.initialSettings.isConfigured = nextConfigured;
}
settingDialogue.settingTab.requestCatalogueRefresh();
}, configured);
} }
async function captureDeclarativeMobileLanding(): Promise<string | undefined> { async function captureDeclarativeMobileLanding(): Promise<string | undefined> {
@@ -148,8 +171,8 @@ async function captureDeclarativeMobileLanding(): Promise<string | undefined> {
return undefined; return undefined;
} }
await settingsNavigator.returnToCatalogue(); await settingsNavigator.returnToCatalogue();
await scrollDeclarativeLandingToTop(settingsNavigator.dialogue); await scrollDeclarativeLandingToTop(settingsNavigator.dialogue, true);
await assertDeclarativeLandingOrder(settingsNavigator.dialogue); await assertDeclarativeLandingOrder(settingsNavigator.dialogue, true);
const remoteConfiguration = settingsNavigator.dialogue const remoteConfiguration = settingsNavigator.dialogue
.locator(".setting-item-name") .locator(".setting-item-name")
.filter({ hasText: "Remote Configuration" }) .filter({ hasText: "Remote Configuration" })
@@ -437,8 +460,8 @@ async function verifyConfigDoctorFollowsCompatibilityReview(): Promise<void> {
}); });
} }
async function verifyEffectiveSettings(): Promise<void> { async function verifyEffectiveSettings(): Promise<"declarative" | "imperative"> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { return await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const sleepPreferences = await page.evaluate(() => { const sleepPreferences = await page.evaluate(() => {
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
if (plugin === undefined) throw new Error("Self-hosted LiveSync is unavailable"); if (plugin === undefined) throw new Error("Self-hosted LiveSync is unavailable");
@@ -462,6 +485,12 @@ async function verifyEffectiveSettings(): Promise<void> {
} }
let settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs); let settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
if (settingsNavigator.renderer === "imperative") {
await settingsNavigator.dialogue.screenshot({
...settingsScreenshotOptions,
path: `${diagnosticsDirectory}/settings-imperative-landing.png`,
});
}
for (const hiddenPage of ["Selector", "Customisation sync", "Advanced", "Power users", "Patches"]) { for (const hiddenPage of ["Selector", "Customisation sync", "Advanced", "Power users", "Patches"]) {
if (await settingsNavigator.isPageListed(hiddenPage)) { if (await settingsNavigator.isPageListed(hiddenPage)) {
throw new Error(`${hiddenPage} was visible before its feature level was enabled.`); throw new Error(`${hiddenPage} was visible before its feature level was enabled.`);
@@ -568,12 +597,22 @@ async function verifyEffectiveSettings(): Promise<void> {
if (settingsNavigator.renderer === "declarative") { if (settingsNavigator.renderer === "declarative") {
await settingsNavigator.returnToCatalogue(); await settingsNavigator.returnToCatalogue();
await scrollDeclarativeLandingToTop(settingsNavigator.dialogue); await scrollDeclarativeLandingToTop(settingsNavigator.dialogue, true);
await settingsNavigator.dialogue.screenshot({ await settingsNavigator.dialogue.screenshot({
...settingsScreenshotOptions, ...settingsScreenshotOptions,
path: `${diagnosticsDirectory}/settings-declarative-landing.png`, path: `${diagnosticsDirectory}/settings-declarative-landing.png`,
}); });
await assertDeclarativeLandingOrder(settingsNavigator.dialogue); await assertDeclarativeLandingOrder(settingsNavigator.dialogue, true);
await setConfiguredStateForLandingInspection(page, false);
await scrollDeclarativeLandingToTop(settingsNavigator.dialogue, false);
await assertDeclarativeLandingOrder(settingsNavigator.dialogue, false);
await settingsNavigator.dialogue.screenshot({
...settingsScreenshotOptions,
path: `${diagnosticsDirectory}/settings-declarative-landing-unconfigured.png`,
});
await setConfiguredStateForLandingInspection(page, true);
await scrollDeclarativeLandingToTop(settingsNavigator.dialogue, true);
await assertDeclarativeLandingOrder(settingsNavigator.dialogue, true);
const rerunOnboarding = settingsNavigator.dialogue const rerunOnboarding = settingsNavigator.dialogue
.locator(".setting-item-name") .locator(".setting-item-name")
.filter({ hasText: "Rerun Onboarding Wizard" }) .filter({ hasText: "Rerun Onboarding Wizard" })
@@ -647,7 +686,9 @@ async function verifyEffectiveSettings(): Promise<void> {
} }
} }
const renderer = settingsNavigator.renderer;
await settingsNavigator.close(); await settingsNavigator.close();
return renderer;
}); });
} }
@@ -690,7 +731,11 @@ async function verifyPendingSettingsInitialisationFlow(): Promise<{ choice: stri
has: settingsNavigator.page.getByText("Changes need to be applied!", { exact: true }), has: settingsNavigator.page.getByText("Changes need to be applied!", { exact: true }),
}); });
await applySetting.waitFor({ state: "visible", timeout: uiTimeoutMs }); await applySetting.waitFor({ state: "visible", timeout: uiTimeoutMs });
await applySetting.getByRole("button", { name: "Apply", exact: true }).click({ timeout: uiTimeoutMs }); if (settingsNavigator.renderer === "declarative") {
await applySetting.click({ timeout: uiTimeoutMs });
} else {
await applySetting.getByRole("button", { name: "Apply", exact: true }).click({ timeout: uiTimeoutMs });
}
const choiceDialogue = await waitForVisibleObsidianDialogue( const choiceDialogue = await waitForVisibleObsidianDialogue(
settingsNavigator.page, settingsNavigator.page,
@@ -798,10 +843,10 @@ async function main(): Promise<void> {
await verifyCompatibilityReview(); await verifyCompatibilityReview();
await verifyConfigDoctorFollowsCompatibilityReview(); await verifyConfigDoctorFollowsCompatibilityReview();
} }
await verifyEffectiveSettings(); const settingsRenderer = await verifyEffectiveSettings();
const initialisation = await verifyPendingSettingsInitialisationFlow(); const initialisation = await verifyPendingSettingsInitialisationFlow();
const p2pInitialisation = await captureP2PSettingsInitialisationDialogue(); const p2pInitialisation = await captureP2PSettingsInitialisationDialogue();
const mobileLanding = await captureDeclarativeMobileLanding(); const mobileLanding = settingsRenderer === "declarative" ? await captureDeclarativeMobileLanding() : undefined;
if (mobileLanding) console.log(`Declarative mobile settings landing page: ${mobileLanding}`); if (mobileLanding) console.log(`Declarative mobile settings landing page: ${mobileLanding}`);
console.log( console.log(
`Pending-settings initialisation screenshots: ${initialisation.choice}, ${initialisation.fallback}, ${p2pInitialisation}` `Pending-settings initialisation screenshots: ${initialisation.choice}, ${initialisation.fallback}, ${p2pInitialisation}`
+6
View File
@@ -12,6 +12,12 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
## Unreleased ## Unreleased
### Interface and translation
#### Fixed
- Obsidian 1.13 settings discovery now waits until persisted settings have loaded and no longer queries the active replicator before database initialisation, preventing a spurious start-up warning.
## 1.0.19 ## 1.0.19
25th August, 2026 25th August, 2026