diff --git a/devs.md b/devs.md index aa15274a..5b47a25e 100644 --- a/devs.md +++ b/devs.md @@ -245,6 +245,12 @@ export class ModuleExample extends AbstractObsidianModule { - Settings are defined by Commonlib (`ObsidianLiveSyncSettings`) - 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 ### Database Operations diff --git a/docs/adr/2026_08_declarative_settings_adapter.md b/docs/adr/2026_08_declarative_settings_adapter.md index 7d06dfcc..bfcad712 100644 --- a/docs/adr/2026_08_declarative_settings_adapter.md +++ b/docs/adr/2026_08_declarative_settings_adapter.md @@ -1,7 +1,7 @@ --- date: 2026-08-25 commonlib-version: "0.1.19" -self-hosted-livesync-version: "1.0.18" +self-hosted-livesync-version: "1.0.20" 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. Settings pending application which require database initialisation now delegate 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 @@ -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 `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 `minAppVersion` of 1.7.2. It must therefore retain an imperative `display()` 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 the current task: -| Current state | First root sections | -| --------------------------- | ----------------------------------------------------------------------------------- | -| Synchronisation is inactive | Quick Setup, Synchronisation (Remote Configuration and Sync Settings), then General | -| Synchronisation is active | Synchronisation (Remote Configuration and Sync Settings), General, then Quick Setup | +| Configuration state | First root sections | +| ------------------- | --------------------------------------------------------------------------------------------------------- | +| Unconfigured | Quick Setup, Synchronisation (Remote Configuration and Sync Settings), then General | +| 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 -plug-in is configured. The remaining destinations are grouped explicitly: +Set up other devices is hidden until the plug-in is configured. The remaining +destinations are grouped explicitly: | 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, service, file, database, or network operation. -The imperative renderer retains its existing default-page selection: Quick Setup for -an inactive configuration and General for an active configuration. The landing -composition is therefore a native 1.13 improvement rather than a behaviour -change for earlier supported Obsidian versions. +The imperative renderer uses the same stable distinction for its default-page +selection: Quick Setup for an unconfigured installation and General for a +configured installation. The landing composition is therefore a native 1.13 +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 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 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 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; - Appearance, Logging, Extra menus, and Advanced are native-items child pages, 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 the separate Synchronisation group; - 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 `.sls-setting-menu-btn` interface. -The Stage C2 landing composition was then exercised on Obsidian 1.13.4. With -synchronisation inactive, the real interface rendered Quick Setup, a separate -Synchronisation group containing Remote Configuration and Sync Settings, and a -General Settings group containing Appearance, Logging, and Extra menus in the -specified order. It opened all 14 nested settings pages, found the Advanced -control through global settings search, and restored its saved value after -reopening settings. In mobile test mode, Remote Configuration remained inside -the initial viewport below the two Quick Setup actions and the Synchronisation -heading. The complete scenario also passed with the same bundle on Obsidian -1.12.7, confirming that the imperative fallback retained its navigation and -save behaviour. +Before the start-up lifecycle correction, the Stage C2 landing composition was +exercised on Obsidian 1.13.4 with a configured installation whose automatic +synchronisation triggers were disabled. Under the former predicate, the real +interface rendered Quick Setup, a separate Synchronisation group containing +Remote Configuration and Sync Settings, and a General Settings group containing +Appearance, Logging, and Extra menus in that order. It opened all 14 nested +settings pages, found the Advanced control through global settings search, and +restored its saved value after reopening settings. In mobile test mode, Remote +Configuration remained inside the initial viewport below the two Quick Setup +actions and the Synchronisation heading. The complete scenario also passed with +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 diff --git a/docs/settings.md b/docs/settings.md index 3bfe759a..98864c55 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -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: -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 | | :--: | ------------------------ | ------------------------------------------------------------- | diff --git a/src/modules/features/ModuleObsidianSettingTab.ts b/src/modules/features/ModuleObsidianSettingTab.ts index 09a82824..727eee81 100644 --- a/src/modules/features/ModuleObsidianSettingTab.ts +++ b/src/modules/features/ModuleObsidianSettingTab.ts @@ -8,8 +8,9 @@ import { openObsidianSettings } from "@/common/obsidianSettings.ts"; export class ModuleObsidianSettingDialogue extends AbstractObsidianModule { settingTab!: ObsidianLiveSyncSettingTab; - _everyOnloadStart(): Promise { + _everyOnloadAfterLoadSettings(): Promise { this.settingTab = new ObsidianLiveSyncSettingTab(this.app, this.plugin); + this.settingTab.reloadAllSettings(true); this.plugin.addSettingTab(this.settingTab); 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 : ""}`; } 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)); } } diff --git a/src/modules/features/ModuleObsidianSettingTab.unit.spec.ts b/src/modules/features/ModuleObsidianSettingTab.unit.spec.ts new file mode 100644 index 00000000..b1840ea0 --- /dev/null +++ b/src/modules/features/ModuleObsidianSettingTab.unit.spec.ts @@ -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) | undefined; + let settingsLoadedHandler: (() => Promise) | undefined; + const plugin = { + app: {}, + addSettingTab: vi.fn(() => settingTabState.callOrder.push("add-setting-tab")), + }; + const services = { + appLifecycle: { + onInitialise: { + addHandler: vi.fn((handler: () => Promise) => { + initialisationHandler = handler; + }), + }, + onSettingLoaded: { + addHandler: vi.fn((handler: () => Promise) => { + 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"]); + }); +}); diff --git a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.declarative.unit.spec.ts b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.declarative.unit.spec.ts index bac9214e..d45582bb 100644 --- a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.declarative.unit.spec.ts +++ b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.declarative.unit.spec.ts @@ -145,22 +145,36 @@ function findPage(tab: ObsidianLiveSyncSettingTab, name: string): SettingDefinit return page; } -function createSettingsTab(): ObsidianLiveSyncSettingTab { - const plugin = { - app: {}, - core: { - settings: { ...DEFAULT_SETTINGS, useAdvancedMode: true }, - confirm: { - askInPopup: vi.fn(), +type SettingsTabOptions = { + activeReplicatorGetter?: () => { syncStatus: "CONNECTED" | "PAUSED" } | undefined; + replicationStatus?: "CLOSED" | "CONNECTED" | "PAUSED"; +}; + +function createSettingsTab(options: SettingsTabOptions = {}): ObsidianLiveSyncSettingTab { + const core = { + settings: { ...DEFAULT_SETTINGS, useAdvancedMode: true }, + confirm: { + askInPopup: vi.fn(), + }, + services: { + setting: { + getDeviceAndVaultName: vi.fn(() => ""), + saveSettingData: vi.fn(async () => undefined), }, - services: { - setting: { - getDeviceAndVaultName: vi.fn(() => ""), - saveSettingData: vi.fn(async () => undefined), + replicator: { + replicationStatics: { + value: { syncStatus: options.replicationStatus ?? "CLOSED" }, }, }, }, }; + Object.defineProperty(core, "replicator", { + get: options.activeReplicatorGetter ?? (() => undefined), + }); + const plugin = { + app: {}, + core, + }; const tab = new ObsidianLiveSyncSettingTab({} as never, plugin as never); Object.assign(tab, { _editingSettings: { ...DEFAULT_SETTINGS, useAdvancedMode: true }, @@ -181,8 +195,27 @@ beforeEach(() => { }); 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(); + tab.editingSettings.isConfigured = false; const definitions = tab.getSettingDefinitions().filter(isGroup); 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(); - tab.editingSettings.liveSync = true; + tab.editingSettings.isConfigured = true; const definitions = tab.getSettingDefinitions().filter(isGroup); - expect(definitions.slice(0, 3).map(itemLabel)).toEqual([ + expect(definitions.slice(0, 4).map(itemLabel)).toEqual([ "🔄 Synchronisation", "⚙️ General Settings", + "📲 Set up other devices", "🧙‍♂️ Quick Setup", ]); }); diff --git a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts index 1f564928..38c1be92 100644 --- a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts +++ b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts @@ -40,7 +40,6 @@ import { eventHub, } from "@/common/events.ts"; import { - enableOnly, // findAttrFromParent, // getLevelStr, setLevelClass, @@ -587,19 +586,8 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { "encrypt", ]); } - isAnySyncEnabled() { - if (this.isConfiguredAs("isConfigured", false)) return false; - 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; + isLiveSyncConfigured() { + return this.isConfiguredAs("isConfigured", true); } private supportsDeclarativeSettings(): boolean { @@ -905,13 +893,20 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { getPage("help"), getPage("change-log"), ]); - const laterGroups = [setupOtherDevices, maintenance, extraFeatures, advancedSettings, helpAndInformation]; + const laterGroups = [maintenance, extraFeatures, advancedSettings, helpAndInformation]; const pendingInitialisation = this.createRebuildRequiredAction(); - if (this.isAnySyncEnabled()) { - return [pendingInitialisation, synchronisation, generalSettings, quickSetup, ...laterGroups]; + if (this.isLiveSyncConfigured()) { + 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 { @@ -936,8 +931,6 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { this.controlledElementFunc.length = 0; } - enableOnlySyncDisabled = enableOnly(() => !this.isAnySyncEnabled()); - onlyOnP2POrCouchDB = () => ({ visibility: @@ -1184,7 +1177,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { void yieldNextAnimationFrame().then(() => { if (this.selectedScreen == "") { - if (this.isAnySyncEnabled()) { + if (this.isLiveSyncConfigured()) { changeDisplay("20"); } else { changeDisplay("110"); diff --git a/test/e2e-obsidian/scripts/settings-ui.ts b/test/e2e-obsidian/scripts/settings-ui.ts index 091533f7..31de3061 100644 --- a/test/e2e-obsidian/scripts/settings-ui.ts +++ b/test/e2e-obsidian/scripts/settings-ui.ts @@ -14,7 +14,7 @@ import { withObsidianPage, } from "../runner/ui.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 settingsOnly = process.env.E2E_OBSIDIAN_SETTINGS_ONLY === "true"; @@ -34,6 +34,11 @@ type LiveSyncTestPlugin = { applySettings: () => Promise; isP2P: boolean; }) => Promise; + settingTab?: { + editingSettings: { isConfigured: boolean }; + initialSettings?: { isConfigured: boolean }; + requestCatalogueRefresh(): void; + }; }[]; settings: { handleFilenameCaseSensitive: boolean; @@ -84,17 +89,14 @@ const settingsPageNames = [ "Change Log", ] as const; -async function assertDeclarativeLandingOrder(root: Locator): Promise { +async function assertDeclarativeLandingOrder(root: Locator, configured: boolean): Promise { + 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 = [ - "Quick Setup", - "Synchronisation", - "Remote Configuration", - "Sync Settings", - "General Settings", - "Appearance", - "Logging", - "Extra menus", - "📲 Set up other devices", + ...setup, "Maintenance and recovery", "Maintenance", "Hatch", @@ -131,10 +133,31 @@ async function assertDeclarativeLandingOrder(root: Locator): Promise { }, labels); } -async function scrollDeclarativeLandingToTop(root: Locator): Promise { - const quickSetupHeading = root.locator(".setting-item-heading").filter({ hasText: "Quick Setup" }).first(); - await quickSetupHeading.waitFor({ state: "visible", timeout: uiTimeoutMs }); - await quickSetupHeading.scrollIntoViewIfNeeded(); +async function scrollDeclarativeLandingToTop(root: Locator, configured: boolean): Promise { + const firstHeading = root + .locator(".setting-item-heading") + .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 { + 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 { @@ -148,8 +171,8 @@ async function captureDeclarativeMobileLanding(): Promise { return undefined; } await settingsNavigator.returnToCatalogue(); - await scrollDeclarativeLandingToTop(settingsNavigator.dialogue); - await assertDeclarativeLandingOrder(settingsNavigator.dialogue); + await scrollDeclarativeLandingToTop(settingsNavigator.dialogue, true); + await assertDeclarativeLandingOrder(settingsNavigator.dialogue, true); const remoteConfiguration = settingsNavigator.dialogue .locator(".setting-item-name") .filter({ hasText: "Remote Configuration" }) @@ -437,8 +460,8 @@ async function verifyConfigDoctorFollowsCompatibilityReview(): Promise { }); } -async function verifyEffectiveSettings(): Promise { - await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { +async function verifyEffectiveSettings(): Promise<"declarative" | "imperative"> { + return await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { const sleepPreferences = await page.evaluate(() => { const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; if (plugin === undefined) throw new Error("Self-hosted LiveSync is unavailable"); @@ -462,6 +485,12 @@ async function verifyEffectiveSettings(): Promise { } 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"]) { if (await settingsNavigator.isPageListed(hiddenPage)) { throw new Error(`${hiddenPage} was visible before its feature level was enabled.`); @@ -568,12 +597,22 @@ async function verifyEffectiveSettings(): Promise { if (settingsNavigator.renderer === "declarative") { await settingsNavigator.returnToCatalogue(); - await scrollDeclarativeLandingToTop(settingsNavigator.dialogue); + await scrollDeclarativeLandingToTop(settingsNavigator.dialogue, true); await settingsNavigator.dialogue.screenshot({ ...settingsScreenshotOptions, 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 .locator(".setting-item-name") .filter({ hasText: "Rerun Onboarding Wizard" }) @@ -647,7 +686,9 @@ async function verifyEffectiveSettings(): Promise { } } + const renderer = settingsNavigator.renderer; 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 }), }); 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( settingsNavigator.page, @@ -798,10 +843,10 @@ async function main(): Promise { await verifyCompatibilityReview(); await verifyConfigDoctorFollowsCompatibilityReview(); } - await verifyEffectiveSettings(); + const settingsRenderer = await verifyEffectiveSettings(); const initialisation = await verifyPendingSettingsInitialisationFlow(); 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}`); console.log( `Pending-settings initialisation screenshots: ${initialisation.choice}, ${initialisation.fallback}, ${p2pInitialisation}` diff --git a/updates.md b/updates.md index 16692989..32ddfb58 100644 --- a/updates.md +++ b/updates.md @@ -12,6 +12,12 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi ## 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 25th August, 2026