mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-25 12:57:07 +00:00
Complete declarative settings runtime integration
This commit is contained in:
@@ -2,16 +2,17 @@
|
||||
date: 2026-08-24
|
||||
commonlib-version: "0.1.19"
|
||||
self-hosted-livesync-version: "1.0.18"
|
||||
status: proposed
|
||||
status: accepted
|
||||
---
|
||||
|
||||
# Architectural Decision Record: Adapt Standard Settings to Obsidian's Declarative API
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. The first implementation is deliberately limited to one-key,
|
||||
immediately persisted controls and one proof page. It does not attempt to
|
||||
describe every existing settings interaction through a new abstraction.
|
||||
Accepted and implemented through Stage C1. The implementation is deliberately
|
||||
limited to one-key, immediately persisted controls and one proof page. It does
|
||||
not attempt to describe every existing settings interaction through a new
|
||||
abstraction. Stage C2 remains an optional, page-by-page improvement.
|
||||
|
||||
## Context
|
||||
|
||||
@@ -133,6 +134,12 @@ This makes page names and visibility consistent without requiring every page
|
||||
to migrate at once. Page names must be unique because Obsidian uses them for
|
||||
nested navigation.
|
||||
|
||||
`SettingDefinitionPage` does not expose a separate icon field. The declarative
|
||||
renderer therefore prefixes each native page name with the emoji already held
|
||||
by the catalogue, while the imperative renderer continues to pass the same
|
||||
emoji to its existing menu button. This preserves the established visual
|
||||
identity without adding host-DOM manipulation.
|
||||
|
||||
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
|
||||
bundle reads Obsidian exports through its namespace object, but the import must
|
||||
@@ -441,11 +448,11 @@ C1.
|
||||
|
||||
## Verification
|
||||
|
||||
Stage A will run the maintained onboarding E2E scenario and an ordinary
|
||||
settings navigation scenario. A source check will confirm that no old wizard
|
||||
event, state, class, or message consumer remains.
|
||||
Stage A runs the maintained onboarding E2E scenario and an ordinary settings
|
||||
navigation scenario. A source check confirms that no old wizard event, state,
|
||||
class, or message consumer remains.
|
||||
|
||||
Stage B focused unit tests will verify:
|
||||
Stage B focused unit tests verify:
|
||||
|
||||
- only explicitly listed Advanced controls become specifications;
|
||||
- synthetic `OnDialogSettings` keys cannot be standard specifications;
|
||||
@@ -454,7 +461,7 @@ Stage B focused unit tests will verify:
|
||||
- rendering the Advanced specifications through `LiveSyncSetting` preserves
|
||||
the current save behaviour.
|
||||
|
||||
Stage C1 focused unit tests will verify:
|
||||
Stage C1 focused unit tests verify:
|
||||
|
||||
- the page catalogue contains all 12 existing pages with stable, unique
|
||||
identifiers and names;
|
||||
@@ -468,7 +475,7 @@ Stage C1 focused unit tests will verify:
|
||||
- importing and opening the imperative fallback does not evaluate or require
|
||||
`SettingPage` on Obsidian before 1.13.
|
||||
|
||||
Real-Obsidian verification on 1.13 or later will confirm:
|
||||
Real-Obsidian verification on 1.13 or later confirms:
|
||||
|
||||
- native page navigation opens every page;
|
||||
- Advanced controls appear in global settings search;
|
||||
@@ -486,11 +493,12 @@ still opens, navigates, saves one Advanced value, and opens one custom page. If
|
||||
the maintained E2E runner cannot install that runtime, the exact manual version
|
||||
and procedure must be recorded before the implementation is merged.
|
||||
|
||||
The current real-Obsidian runner defaults to Obsidian 1.12.7, so it already
|
||||
owns the fallback smoke path. The declarative path requires a separate 1.13-or-
|
||||
later AppImage selected through `E2E_OBSIDIAN_VERSION`. If a reviewable 1.13
|
||||
runtime is not available, the implementation may remain a branch proof but the
|
||||
new runtime path must not be merged on type-level evidence alone.
|
||||
The current real-Obsidian runner defaults to Obsidian 1.12.7, so it owns the
|
||||
fallback smoke path. The declarative path uses a separately installed
|
||||
1.13-or-later AppImage selected through `OBSIDIAN_BINARY` and `OBSIDIAN_CLI`.
|
||||
`E2E_OBSIDIAN_SETTINGS_ONLY=true` limits that run to the settings contract so
|
||||
the same scenario can validate a second Obsidian runtime without repeating its
|
||||
unrelated compatibility-review and mobile-layout coverage.
|
||||
|
||||
Existing E2E scenarios must use one shared settings-page navigation helper.
|
||||
That helper uses the current `.sls-setting-menu-btn` contract on the legacy
|
||||
@@ -498,6 +506,18 @@ runtime and accessible native page names on 1.13 or later. Individual scenarios
|
||||
must not duplicate version checks or retain selectors for a menu which the
|
||||
declarative renderer does not create.
|
||||
|
||||
The accepted implementation was exercised against the official Obsidian
|
||||
1.13.4 arm64 AppImage with SHA-256
|
||||
`20d0b13c6d40bb3d7e73d9b4be6d2e21dfcc145b2106a747d0c1b81e651dabfe`.
|
||||
That run opened all 12 pages from the native page catalogue, found the Advanced
|
||||
control through global settings search, persisted a numeric value on Enter,
|
||||
and restored it after the settings dialogue was closed and reopened. The
|
||||
complete default scenario also passed on Obsidian 1.12.7, including
|
||||
compatibility review, mobile layout, imperative page navigation, and immediate
|
||||
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.
|
||||
|
||||
## Expansion Checkpoints
|
||||
|
||||
Review the scope with the maintainer before any implementation adds one of the
|
||||
|
||||
+5
-1
@@ -105,6 +105,7 @@ vi.mock("./PanePatches.ts", () => ({ panePatches: vi.fn() }));
|
||||
vi.mock("./PaneMaintenance.ts", () => ({ paneMaintenance: vi.fn() }));
|
||||
|
||||
import { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
import { createSettingsPageCatalogue } from "./SettingsPageCatalogue.ts";
|
||||
|
||||
function isPage(item: SettingDefinitionItem): item is SettingDefinitionPage {
|
||||
return "type" in item && item.type === "page";
|
||||
@@ -151,7 +152,10 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
||||
const pages = tab.getSettingDefinitions().filter(isPage);
|
||||
|
||||
expect(pages).toHaveLength(12);
|
||||
const advanced = pages.find(({ name }) => name === "Advanced");
|
||||
expect(pages.map(({ name }) => name)).toEqual(
|
||||
createSettingsPageCatalogue().map((entry) => `${entry.icon} ${entry.name()}`)
|
||||
);
|
||||
const advanced = pages.find(({ name }) => name.endsWith(" Advanced"));
|
||||
expect(advanced?.items?.filter((item) => "type" in item && item.type === "group")).toHaveLength(4);
|
||||
expect(advanced?.items?.filter((item) => "action" in item && typeof item.action === "function")).toHaveLength(
|
||||
1
|
||||
|
||||
@@ -704,7 +704,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
return createSettingsPageCatalogue().map((entry): SettingDefinitionPage => {
|
||||
const page: SettingDefinitionPage = {
|
||||
type: "page",
|
||||
name: entry.name(),
|
||||
name: `${entry.icon} ${entry.name()}`,
|
||||
visible: () => this.isPageVisible(entry.level),
|
||||
};
|
||||
if (entry.content === "native") {
|
||||
|
||||
@@ -106,7 +106,18 @@ The underlying `test:e2e:obsidian:<scenario>` scripts remain available for an im
|
||||
|
||||
`test:e2e:obsidian:dialog-mounts` starts a temporary real Obsidian session and exercises remote selection and CouchDB settings through `SetupManager`, plus Setup URI entry through the registered command. It verifies the compatibility pause and remote-size review, the distinction between a central data-storage server and P2P signalling, the explicit tested and untested CouchDB save actions, the internal-API warning, the Setup URI controls, automatic adjustment when differences are limited to compatible chunk settings, and both manual configuration-mismatch routes. The same session opens the live log and generated full report, reaches the `Hatch` recovery controls, writes and removes its own persistent log, and runs the missing-chunk recreation and file-verification actions against the empty disposable Vault. It captures representative desktop and mobile dialogues, checks the mobile layout and vertically stacked actions, closes each route through its normal controls, and verifies that each mounted operation settles without an error. It does not apply a remote configuration, contact a remote service, or claim to repair a deliberately damaged database.
|
||||
|
||||
`test:e2e:obsidian:settings-ui` starts with a pending compatibility review and verifies the dedicated pause summary, its detailed explanation, and the explicit resume action in a temporary real Obsidian session. It captures the desktop summary and the iPhone-sized summary and detail dialogues; the mobile checks cover viewport containment, horizontal overflow, safe-area containment, and the close control's touch target. It confirms that the acknowledged internal version advances only after the review is accepted, and checks that the Change Log contains no acknowledgement control. It then enables Advanced mode and persists one numeric Advanced setting, covering the imperative settings fallback used by the `SettingSpec` proof. Finally, it selects the Synchronisation Settings pane and verifies that the deletion panel still exposes the effective 'Keep empty folder' setting without presenting the legacy `trashInsteadDelete` control, whose value no longer changes Obsidian deletion behaviour.
|
||||
`test:e2e:obsidian:settings-ui` starts with a pending compatibility review and verifies the dedicated pause summary, its detailed explanation, and the explicit resume action in a temporary real Obsidian session. It captures the desktop summary and the iPhone-sized summary and detail dialogues; the mobile checks cover viewport containment, horizontal overflow, safe-area containment, and the close control's touch target. It confirms that the acknowledged internal version advances only after the review is accepted, and checks that the Change Log contains no acknowledgement control. It then enables Advanced mode and persists one numeric Advanced setting. On Obsidian before 1.13, this covers the imperative settings fallback used by the `SettingSpec` proof. On Obsidian 1.13 or later, it opens all 12 pages from the native page catalogue, searches globally for the Advanced control, captures the catalogue, search result, and Advanced page, and verifies that the value remains after the settings dialogue is closed and reopened. Finally, it selects the Synchronisation Settings page and verifies that the deletion panel still exposes the effective 'Keep empty folder' setting without presenting the legacy `trashInsteadDelete` control, whose value no longer changes Obsidian deletion behaviour.
|
||||
|
||||
The default runner uses the maintained pre-1.13 Obsidian fixture and therefore exercises the complete compatibility-review, mobile-layout, and imperative-settings path. To exercise only the native settings contract against an additional Obsidian 1.13-or-later installation, supply its executable and companion CLI explicitly:
|
||||
|
||||
```bash
|
||||
OBSIDIAN_BINARY=/path/to/obsidian \
|
||||
OBSIDIAN_CLI=/path/to/obsidian-cli \
|
||||
E2E_OBSIDIAN_SETTINGS_ONLY=true \
|
||||
npm run test:e2e:obsidian:settings-ui
|
||||
```
|
||||
|
||||
The native run writes `settings-declarative-catalogue.png`, `settings-declarative-search.png`, and `settings-declarative-advanced.png` to `E2E_OBSIDIAN_DIAGNOSTICS_DIR`. All settings E2E scenarios open pages through the shared navigator in `runner/ui.ts`; scenario code must not select the legacy tab menu directly.
|
||||
|
||||
The mobile pass uses Obsidian's `app.emulateMobile(true)`, a 390 by 844 CSS-pixel viewport, and explicit iPhone-style safe-area insets of 47 pixels at the top and 34 pixels at the bottom. The public `@vrtmrz/obsidian-test-session` layout assertions require each modal to remain within the viewport and safe area without horizontal overflow. They also require the Obsidian Close control to remain within the safe area and provide at least a 44 by 44 CSS-pixel touch target. The runner clicks that control to verify actionability, then completes the explicit cancellation path. These simulated checks cover deterministic layout and interaction boundaries; they do not claim to reproduce a native operating-system overlay.
|
||||
|
||||
@@ -224,6 +235,7 @@ Useful environment variables:
|
||||
- `E2E_OBSIDIAN_DIALOG_TIMEOUT_MS`: timeout for a representative Svelte dialogue to mount, expose its principal controls, and close; default is 10 seconds.
|
||||
- `E2E_OBSIDIAN_REVISION_REPAIR_TIMEOUT_MS`: timeout for each visible revision-repair control and result; default is 15 seconds.
|
||||
- `E2E_OBSIDIAN_SETTINGS_TIMEOUT_MS`: timeout for the settings pane and its deletion controls to become visible; default is 10 seconds.
|
||||
- `E2E_OBSIDIAN_SETTINGS_ONLY=true`: skip compatibility-review and mobile-layout coverage when running `settings-ui` against an additional Obsidian 1.13-or-later installation.
|
||||
- `E2E_OBSIDIAN_REVIEW_HARNESS_TIMEOUT_MS`: timeout for Review Harness view and action boundaries; default is 15 seconds.
|
||||
- `E2E_OBSIDIAN_P2P_PANE_TIMEOUT_MS`: timeout for the P2P status pane and its principal connection control; default is 10 seconds.
|
||||
- `E2E_OBSIDIAN_P2P_WORKFLOW_TIMEOUT_MS`: timeout for each visible P2P Setup URI, peer-discovery, approval, and replication control; default is 60 seconds.
|
||||
|
||||
@@ -3,6 +3,31 @@ import { dirname, join } from "node:path";
|
||||
import { withObsidianPage } from "@vrtmrz/obsidian-test-session";
|
||||
import type { Locator, Page } from "playwright";
|
||||
|
||||
type ObsidianSettingsHost = typeof globalThis & {
|
||||
app?: {
|
||||
setting?: {
|
||||
open(): void;
|
||||
openTabById(tabId: string): void;
|
||||
close(): void;
|
||||
};
|
||||
vault?: {
|
||||
adapter?: { getBasePath?: () => string; basePath?: string };
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type LiveSyncSettingsRenderer = "imperative" | "declarative";
|
||||
|
||||
export type LiveSyncSettingsNavigator = {
|
||||
dialogue: Locator;
|
||||
page: Page;
|
||||
renderer: LiveSyncSettingsRenderer;
|
||||
close(): Promise<void>;
|
||||
openPage(name: string): Promise<Locator>;
|
||||
returnToCatalogue(): Promise<void>;
|
||||
isPageListed(name: string): Promise<boolean>;
|
||||
};
|
||||
|
||||
export {
|
||||
obsidianRemoteDebuggingPort,
|
||||
preseedTrustedVaultState,
|
||||
@@ -42,6 +67,181 @@ export async function captureObsidianDialogue(
|
||||
return await captureObsidianPage(port, filename, assertReady);
|
||||
}
|
||||
|
||||
function declarativePageEntry(dialogue: Locator, name: string): Locator {
|
||||
return dialogue
|
||||
.locator(".setting-item.mod-navigable")
|
||||
.filter({ hasText: new RegExp(`${escapeRegExp(name)}\\s*$`, "u") });
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the Self-hosted LiveSync settings tab through the renderer selected by
|
||||
* the running Obsidian version.
|
||||
*
|
||||
* Obsidian 1.13 may open settings in a separate window. The returned navigator
|
||||
* owns that renderer difference for both supported settings implementations.
|
||||
*/
|
||||
export async function openLiveSyncSettings(page: Page, timeoutMs = 10_000): Promise<LiveSyncSettingsNavigator> {
|
||||
await page.evaluate(() => {
|
||||
const host = globalThis as ObsidianSettingsHost;
|
||||
const setting = host.app?.setting;
|
||||
if (setting === undefined) throw new Error("Obsidian settings are unavailable");
|
||||
setting.open();
|
||||
setting.openTabById("obsidian-livesync");
|
||||
});
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let settingsPage: Page | undefined;
|
||||
while (Date.now() < deadline && settingsPage === undefined) {
|
||||
for (const candidate of page.context().pages()) {
|
||||
if (
|
||||
await candidate
|
||||
.locator(".modal.mod-settings:visible")
|
||||
.last()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
settingsPage = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (settingsPage === undefined) await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
if (settingsPage === undefined) throw new Error("Obsidian did not open its settings interface");
|
||||
|
||||
const dialogue = settingsPage.locator(".modal.mod-settings:visible").last();
|
||||
const imperativeRoot = dialogue.locator(".sls-setting:visible").last();
|
||||
const firstDeclarativeEntry = declarativePageEntry(dialogue, "Change Log");
|
||||
await settingsPage.waitForFunction(
|
||||
() =>
|
||||
document.querySelector(".modal.mod-settings .sls-setting") !== null ||
|
||||
Array.from(document.querySelectorAll(".modal.mod-settings .setting-item-name")).some(
|
||||
(element) => element.textContent?.trim().endsWith("Change Log") === true
|
||||
),
|
||||
undefined,
|
||||
{ timeout: timeoutMs }
|
||||
);
|
||||
const renderer: LiveSyncSettingsRenderer = (await imperativeRoot.isVisible()) ? "imperative" : "declarative";
|
||||
|
||||
const returnToCatalogue = async (): Promise<void> => {
|
||||
if (renderer === "imperative" || (await firstDeclarativeEntry.isVisible())) return;
|
||||
const backButton = dialogue.locator(".setting-page-back-button:visible, .modal-setting-back-button:visible");
|
||||
await backButton.last().click({ timeout: timeoutMs });
|
||||
await firstDeclarativeEntry.waitFor({ state: "visible", timeout: timeoutMs });
|
||||
};
|
||||
|
||||
const openPage = async (name: string): Promise<Locator> => {
|
||||
if (renderer === "imperative") {
|
||||
const root = dialogue.locator(".sls-setting:visible").last();
|
||||
await root.locator(`.sls-setting-menu-btn[title="${name}"]`).click({ timeout: timeoutMs });
|
||||
return root;
|
||||
}
|
||||
|
||||
await returnToCatalogue();
|
||||
const entry = declarativePageEntry(dialogue, name);
|
||||
await entry.waitFor({ state: "visible", timeout: timeoutMs });
|
||||
await entry.click({ timeout: timeoutMs });
|
||||
await entry.waitFor({ state: "hidden", timeout: timeoutMs });
|
||||
const content = dialogue.locator(".vertical-tab-content:visible").last();
|
||||
await content.waitFor({ state: "visible", timeout: timeoutMs });
|
||||
return content;
|
||||
};
|
||||
|
||||
const isPageListed = async (name: string): Promise<boolean> => {
|
||||
if (renderer === "imperative") {
|
||||
return await dialogue
|
||||
.locator(`.sls-setting-menu-btn[title="${name}"]`)
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
}
|
||||
await returnToCatalogue();
|
||||
return await declarativePageEntry(dialogue, name)
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
};
|
||||
|
||||
const close = async (): Promise<void> => {
|
||||
await page
|
||||
.evaluate(() => {
|
||||
const setting = (globalThis as ObsidianSettingsHost).app?.setting;
|
||||
if (setting === undefined) throw new Error("Obsidian settings are unavailable");
|
||||
setTimeout(() => setting.close(), 0);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!page.isClosed() && !settingsPage.isClosed()) throw error;
|
||||
});
|
||||
await Promise.race([
|
||||
dialogue.waitFor({ state: "hidden", timeout: timeoutMs }),
|
||||
settingsPage.waitForEvent("close", { timeout: timeoutMs }),
|
||||
]).catch((error: unknown) => {
|
||||
if (!settingsPage.isClosed()) throw error;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
dialogue,
|
||||
page: settingsPage,
|
||||
renderer,
|
||||
close,
|
||||
openPage,
|
||||
returnToCatalogue,
|
||||
isPageListed,
|
||||
};
|
||||
}
|
||||
|
||||
/** Allow only the isolated E2E Vault path in Obsidian's external-action prompt. */
|
||||
export async function allowPendingObsidianTestVaultOpenAction(
|
||||
port: number,
|
||||
expectedVaultPath: string,
|
||||
timeoutMs = 10_000
|
||||
): Promise<void> {
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const context = page.context();
|
||||
const action = page.locator(".modal.mod-uri-action:visible").last();
|
||||
const visible = await action
|
||||
.waitFor({ state: "visible", timeout: Math.min(timeoutMs, 2_000) })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (visible) {
|
||||
const actionText = await action.innerText();
|
||||
if (!actionText.includes(expectedVaultPath)) {
|
||||
throw new Error(`Refusing an unexpected Obsidian URI action: ${actionText}`);
|
||||
}
|
||||
await action.locator(".mod-checkbox").click({ timeout: timeoutMs });
|
||||
await action.getByRole("button", { name: "Continue" }).click({ timeout: timeoutMs });
|
||||
await action.waitFor({ state: "hidden", timeout: timeoutMs });
|
||||
}
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let vaultPage: Page | undefined;
|
||||
while (Date.now() < deadline && vaultPage === undefined) {
|
||||
for (const candidate of context.pages()) {
|
||||
const activePath = await candidate
|
||||
.evaluate(() => {
|
||||
const host = globalThis as ObsidianSettingsHost;
|
||||
const adapter = host.app?.vault?.adapter;
|
||||
return adapter?.getBasePath?.() ?? adapter?.basePath ?? null;
|
||||
})
|
||||
.catch(() => null);
|
||||
if (activePath === expectedVaultPath) {
|
||||
vaultPage = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (vaultPage === undefined) await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
if (vaultPage === undefined) {
|
||||
throw new Error(`Obsidian did not open the approved E2E Vault: ${expectedVaultPath}`);
|
||||
}
|
||||
for (const candidate of context.pages()) {
|
||||
if (candidate !== vaultPage && !candidate.isClosed()) await candidate.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function captureObsidianElement(
|
||||
port: number,
|
||||
filename: string,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
captureObsidianElement,
|
||||
captureObsidianPage,
|
||||
obsidianRemoteDebuggingPort,
|
||||
openLiveSyncSettings,
|
||||
withObsidianPage,
|
||||
} from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
@@ -46,11 +47,6 @@ type LiveSyncTestPlugin = {
|
||||
};
|
||||
};
|
||||
|
||||
type ObsidianSettingsController = {
|
||||
open(): void;
|
||||
openTabById(tabId: string): void;
|
||||
};
|
||||
|
||||
type ObsidianVaultFile = {
|
||||
path: string;
|
||||
};
|
||||
@@ -58,7 +54,6 @@ type ObsidianVaultFile = {
|
||||
type ObsidianTestApp = {
|
||||
commands?: { executeCommandById(commandId: string): boolean };
|
||||
plugins?: { plugins: Record<string, LiveSyncTestPlugin | undefined> };
|
||||
setting?: ObsidianSettingsController;
|
||||
vault?: {
|
||||
delete(file: ObsidianVaultFile, force: boolean): Promise<void>;
|
||||
getFiles(): ObsidianVaultFile[];
|
||||
@@ -748,17 +743,10 @@ async function verifyCompatibleAlignmentSettingDefault(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
await page.evaluate(() => {
|
||||
const setting = (globalThis as ObsidianTestGlobal).app?.setting;
|
||||
if (setting === undefined) throw new Error("Obsidian settings are unavailable");
|
||||
setting.open();
|
||||
setting.openTabById("obsidian-livesync");
|
||||
});
|
||||
const liveSyncSettings = page.locator(".sls-setting");
|
||||
await liveSyncSettings.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Advanced"]').click({ timeout: uiTimeoutMs });
|
||||
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
const liveSyncSettings = await settingsNavigator.openPage("Advanced");
|
||||
const settingItem = liveSyncSettings.locator(".setting-item").filter({
|
||||
has: page.getByText("Auto-accept compatible tweak mismatches", { exact: true }),
|
||||
has: settingsNavigator.page.getByText("Auto-accept compatible tweak mismatches", { exact: true }),
|
||||
});
|
||||
await settingItem.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const toggle = settingItem.locator(".checkbox-container");
|
||||
@@ -944,15 +932,8 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
|
||||
obsidianRemoteDebuggingPort(),
|
||||
"troubleshooting-hatch.png",
|
||||
async (page) => {
|
||||
await page.evaluate(() => {
|
||||
const setting = (globalThis as ObsidianTestGlobal).app?.setting;
|
||||
if (setting === undefined) throw new Error("Obsidian settings are unavailable");
|
||||
setting.open();
|
||||
setting.openTabById("obsidian-livesync");
|
||||
});
|
||||
const liveSyncSettings = page.locator(".sls-setting");
|
||||
await liveSyncSettings.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Hatch"]').click({ timeout: uiTimeoutMs });
|
||||
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
const liveSyncSettings = await settingsNavigator.openPage("Hatch");
|
||||
for (const label of [
|
||||
"Write logs into the file",
|
||||
"Recreate chunks for current Vault files",
|
||||
|
||||
@@ -9,7 +9,12 @@ import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import { assertMobileDialogueLayout, iPhoneSafeArea, setObsidianMobileTestMode } from "../runner/mobileUi.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { captureObsidianDialogue, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts";
|
||||
import {
|
||||
captureObsidianDialogue,
|
||||
obsidianRemoteDebuggingPort,
|
||||
openLiveSyncSettings,
|
||||
withObsidianPage,
|
||||
} from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_ONBOARDING_TIMEOUT_MS ?? 15000);
|
||||
@@ -25,15 +30,6 @@ type UnconfiguredStartupEvidence = {
|
||||
};
|
||||
};
|
||||
|
||||
type ObsidianTestApp = {
|
||||
setting?: {
|
||||
open(): void;
|
||||
openTabById(tabId: string): void;
|
||||
};
|
||||
};
|
||||
|
||||
type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp };
|
||||
|
||||
async function writeMarker(vaultPath: string): Promise<void> {
|
||||
const fullPath = join(vaultPath, markerPath);
|
||||
await mkdir(dirname(fullPath), { recursive: true });
|
||||
@@ -158,25 +154,17 @@ async function captureAndCloseIntro(filename: string, mobile: boolean): Promise<
|
||||
|
||||
async function openOnboardingFromSettings(): Promise<void> {
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
await page.evaluate(() => {
|
||||
const setting = (globalThis as ObsidianTestGlobal).app?.setting;
|
||||
if (setting === undefined) throw new Error("Obsidian settings are unavailable");
|
||||
setting.open();
|
||||
setting.openTabById("obsidian-livesync");
|
||||
});
|
||||
|
||||
const liveSyncSettings = page.locator(".sls-setting");
|
||||
await liveSyncSettings.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Setup"]').click({ timeout: uiTimeoutMs });
|
||||
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
const liveSyncSettings = await settingsNavigator.openPage("Setup");
|
||||
|
||||
const onboardingSetting = liveSyncSettings.locator(".setting-item").filter({
|
||||
has: page.locator(".setting-item-name").filter({ hasText: "Rerun Onboarding Wizard" }),
|
||||
has: settingsNavigator.page.locator(".setting-item-name").filter({ hasText: "Rerun Onboarding Wizard" }),
|
||||
});
|
||||
await onboardingSetting.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await onboardingSetting
|
||||
.getByRole("button", { name: "Rerun Wizard", exact: true })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
await onboardingDialogue(page).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await onboardingDialogue(settingsNavigator.page).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -197,11 +185,8 @@ async function dismissVisibleNotices(): Promise<void> {
|
||||
|
||||
async function closeSettings(): Promise<void> {
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const settingsContainer = page.locator(".modal-container").filter({
|
||||
has: page.locator(".sls-setting"),
|
||||
});
|
||||
await settingsContainer.locator(".modal-close-button").click({ timeout: uiTimeoutMs });
|
||||
await settingsContainer.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
await settingsNavigator.close();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
waitForLocalDatabaseEntry,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { captureObsidianElement, withObsidianPage } from "../runner/ui.ts";
|
||||
import { captureObsidianElement, openLiveSyncSettings, withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
import type { Locator, Page } from "playwright";
|
||||
|
||||
@@ -35,17 +35,6 @@ type VaultWinnerState = {
|
||||
winnerRevision: string;
|
||||
};
|
||||
|
||||
type ObsidianSettingsController = {
|
||||
open(): void;
|
||||
openTabById(tabId: string): void;
|
||||
};
|
||||
|
||||
type ObsidianTestGlobal = typeof globalThis & {
|
||||
app?: {
|
||||
setting?: ObsidianSettingsController;
|
||||
};
|
||||
};
|
||||
|
||||
async function createAndOpenBaseFile(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
@@ -359,17 +348,10 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await page.evaluate(() => {
|
||||
const setting = (globalThis as ObsidianTestGlobal).app?.setting;
|
||||
if (setting === undefined) throw new Error("Obsidian settings are unavailable");
|
||||
setting.open();
|
||||
setting.openTabById("obsidian-livesync");
|
||||
});
|
||||
const settings = page.locator(".sls-setting");
|
||||
await settings.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await settings.locator('.sls-setting-menu-btn[title="Hatch"]').click({ timeout: uiTimeoutMs });
|
||||
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
const settings = await settingsNavigator.openPage("Hatch");
|
||||
const verifySetting = settings.locator(".setting-item").filter({
|
||||
has: page.getByText("Inspect conflicts and file/database differences", {
|
||||
has: settingsNavigator.page.getByText("Inspect conflicts and file/database differences", {
|
||||
exact: true,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import { waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
|
||||
import { assertMobileDialogueLayout, setObsidianMobileTestMode } from "../runner/mobileUi.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { captureObsidianDialogue, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts";
|
||||
import {
|
||||
allowPendingObsidianTestVaultOpenAction,
|
||||
captureObsidianDialogue,
|
||||
obsidianRemoteDebuggingPort,
|
||||
openLiveSyncSettings,
|
||||
preseedTrustedVaultState,
|
||||
withObsidianPage,
|
||||
} from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETTINGS_TIMEOUT_MS ?? 10000);
|
||||
const compatibilityReviewMessage = "Review the internal database compatibility change before synchronisation resumes.";
|
||||
|
||||
type ObsidianSettingsController = {
|
||||
open(): void;
|
||||
openTabById(tabId: string): void;
|
||||
const settingsOnly = process.env.E2E_OBSIDIAN_SETTINGS_ONLY === "true";
|
||||
const diagnosticsDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
|
||||
const settingsScreenshotOptions = {
|
||||
animations: "disabled" as const,
|
||||
style: ".notice-container { visibility: hidden !important; }",
|
||||
};
|
||||
const compatibilityReviewMessage = "Review the internal database compatibility change before synchronisation resumes.";
|
||||
|
||||
type LiveSyncTestPlugin = {
|
||||
core: {
|
||||
@@ -34,12 +43,39 @@ type LiveSyncTestPlugin = {
|
||||
};
|
||||
|
||||
type ObsidianTestApp = {
|
||||
setting?: ObsidianSettingsController;
|
||||
plugins?: { plugins: Record<string, LiveSyncTestPlugin | undefined> };
|
||||
};
|
||||
|
||||
type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp };
|
||||
|
||||
const settingsPageNames = [
|
||||
"Change Log",
|
||||
"Setup",
|
||||
"General Settings",
|
||||
"Remote Configuration",
|
||||
"Sync Settings",
|
||||
"Selector",
|
||||
"Customisation sync",
|
||||
"Hatch",
|
||||
"Advanced",
|
||||
"Power users",
|
||||
"Patches",
|
||||
"Maintenance",
|
||||
] as const;
|
||||
|
||||
async function resumePendingCompatibilityReviewForSettings(): Promise<void> {
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const review = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({
|
||||
hasText: "Synchronisation paused for compatibility review",
|
||||
}),
|
||||
});
|
||||
if (!(await review.isVisible())) return;
|
||||
await review.getByRole("button", { name: "Resume synchronisation" }).click({ timeout: uiTimeoutMs });
|
||||
await review.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyCompatibilityReview(): Promise<void> {
|
||||
const port = obsidianRemoteDebuggingPort();
|
||||
const summaryScreenshot = await captureObsidianDialogue(port, "compatibility-review-summary.png", async (page) => {
|
||||
@@ -223,41 +259,26 @@ async function verifyEffectiveSettings(): Promise<void> {
|
||||
sleepPreferences.powerUserMode !== false ||
|
||||
sleepPreferences.edgeCaseMode !== false
|
||||
) {
|
||||
throw new Error(
|
||||
`Unexpected effective sleep preferences: ${JSON.stringify(sleepPreferences)}`
|
||||
);
|
||||
throw new Error(`Unexpected effective sleep preferences: ${JSON.stringify(sleepPreferences)}`);
|
||||
}
|
||||
|
||||
await page.evaluate(() => {
|
||||
const setting = (globalThis as ObsidianTestGlobal).app?.setting;
|
||||
if (setting === undefined) throw new Error("Obsidian settings are unavailable");
|
||||
setting.open();
|
||||
setting.openTabById("obsidian-livesync");
|
||||
});
|
||||
|
||||
const liveSyncSettings = page.locator(".sls-setting");
|
||||
await liveSyncSettings.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const settingsClass = await liveSyncSettings.getAttribute("class");
|
||||
for (const modeClass of [
|
||||
"menu-setting-advanced-disabled",
|
||||
"menu-setting-poweruser-disabled",
|
||||
"menu-setting-edgecase-disabled",
|
||||
]) {
|
||||
if (!settingsClass?.split(/\s+/u).includes(modeClass)) {
|
||||
throw new Error(`The settings UI did not disable ${modeClass}.`);
|
||||
let settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
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.`);
|
||||
}
|
||||
}
|
||||
|
||||
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Change Log"]').click();
|
||||
const removedAcknowledgements = liveSyncSettings.getByRole("button", {
|
||||
let settingsPage = await settingsNavigator.openPage("Change Log");
|
||||
const removedAcknowledgements = settingsPage.getByRole("button", {
|
||||
name: /I got it and updated|OK, I have read everything/u,
|
||||
});
|
||||
if ((await removedAcknowledgements.count()) !== 0) {
|
||||
throw new Error("The Change Log still contains a compatibility or release-note acknowledgement control.");
|
||||
}
|
||||
|
||||
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Remote Configuration"]').click();
|
||||
const connectionPanel = liveSyncSettings
|
||||
settingsPage = await settingsNavigator.openPage("Remote Configuration");
|
||||
const connectionPanel = settingsPage
|
||||
.locator("h4.sls-setting-panel-title")
|
||||
.filter({ hasText: "Connection settings" })
|
||||
.locator("..");
|
||||
@@ -267,41 +288,74 @@ async function verifyEffectiveSettings(): Promise<void> {
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
|
||||
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Sync Settings"]').click();
|
||||
const generalSleepSetting = liveSyncSettings.locator(".setting-item").filter({
|
||||
has: page.getByText("Allow sleep during synchronisation", { exact: true }),
|
||||
settingsPage = await settingsNavigator.openPage("Sync Settings");
|
||||
const generalSleepSetting = settingsPage.locator(".setting-item").filter({
|
||||
has: settingsNavigator.page.getByText("Allow sleep during synchronisation", { exact: true }),
|
||||
});
|
||||
await generalSleepSetting.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
if ((await generalSleepSetting.locator(".checkbox-container.is-enabled").count()) !== 0) {
|
||||
throw new Error("The general sleep preference must be disabled by default.");
|
||||
}
|
||||
|
||||
const desktopSleepSetting = liveSyncSettings.locator(".setting-item").filter({
|
||||
has: page.getByText("Allow sleep during synchronisation on the desktop", { exact: true }),
|
||||
const desktopSleepSetting = settingsPage.locator(".setting-item").filter({
|
||||
has: settingsNavigator.page.getByText("Allow sleep during synchronisation on the desktop", {
|
||||
exact: true,
|
||||
}),
|
||||
});
|
||||
await desktopSleepSetting.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
if ((await desktopSleepSetting.locator(".checkbox-container.is-enabled").count()) !== 1) {
|
||||
throw new Error("The desktop sleep preference must be enabled by default.");
|
||||
}
|
||||
|
||||
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Setup"]').click();
|
||||
const advancedModeSetting = liveSyncSettings.locator(".setting-item").filter({
|
||||
has: page.getByText("Enable advanced features", { exact: true }),
|
||||
settingsPage = await settingsNavigator.openPage("Setup");
|
||||
const advancedModeSetting = settingsPage.locator(".setting-item").filter({
|
||||
has: settingsNavigator.page.getByText("Enable advanced features", { exact: true }),
|
||||
});
|
||||
await advancedModeSetting.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await advancedModeSetting.locator(".checkbox-container").click();
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector(".sls-setting")?.classList.contains("menu-setting-advanced-enabled") === true,
|
||||
() => {
|
||||
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||
return plugin?.core.services.setting.currentSettings().useAdvancedMode === true;
|
||||
},
|
||||
undefined,
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
|
||||
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Advanced"]').click();
|
||||
const cacheSizeSetting = liveSyncSettings.locator(".setting-item").filter({
|
||||
has: page.getByText("Memory cache size (by total items)", { exact: true }),
|
||||
if (settingsNavigator.renderer === "declarative") {
|
||||
for (const mode of [
|
||||
{ label: "Enable poweruser features", key: "usePowerUserMode" },
|
||||
{ label: "Enable edge case treatment features", key: "useEdgeCaseMode" },
|
||||
] as const) {
|
||||
const modeSetting = settingsPage.locator(".setting-item").filter({
|
||||
has: settingsNavigator.page.getByText(mode.label, { exact: true }),
|
||||
});
|
||||
await modeSetting.locator(".checkbox-container").click({ timeout: uiTimeoutMs });
|
||||
await page.waitForFunction(
|
||||
(key) => {
|
||||
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||
return plugin?.core.services.setting.currentSettings()[key] === true;
|
||||
},
|
||||
mode.key,
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
settingsPage = await settingsNavigator.openPage("Advanced");
|
||||
const cacheSizeSetting = settingsPage.locator(".setting-item").filter({
|
||||
has: settingsNavigator.page.getByText("Memory cache size (by total items)", { exact: true }),
|
||||
});
|
||||
await cacheSizeSetting.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await cacheSizeSetting.locator('input[type="number"]').fill("321");
|
||||
const cacheSizeInput = cacheSizeSetting.locator('input[type="number"]');
|
||||
if (settingsNavigator.renderer === "declarative") {
|
||||
await cacheSizeInput.click();
|
||||
await cacheSizeInput.press("ControlOrMeta+A");
|
||||
await cacheSizeInput.pressSequentially("321");
|
||||
await cacheSizeInput.press("Enter");
|
||||
} else {
|
||||
await cacheSizeInput.fill("321");
|
||||
}
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||
@@ -311,9 +365,44 @@ async function verifyEffectiveSettings(): Promise<void> {
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
|
||||
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Sync Settings"]').click();
|
||||
if (settingsNavigator.renderer === "declarative") {
|
||||
await settingsNavigator.returnToCatalogue();
|
||||
await settingsNavigator.dialogue
|
||||
.locator(".vertical-tab-content:visible")
|
||||
.last()
|
||||
.evaluate((element) => {
|
||||
element.scrollTop = 0;
|
||||
});
|
||||
await settingsNavigator.dialogue.screenshot({
|
||||
...settingsScreenshotOptions,
|
||||
path: `${diagnosticsDirectory}/settings-declarative-catalogue.png`,
|
||||
});
|
||||
const search = settingsNavigator.dialogue.locator(".setting-search-container input");
|
||||
await search.fill("Memory cache size (by total items)");
|
||||
const searchResult = settingsNavigator.dialogue.locator(".setting-search-result-item").filter({
|
||||
has: settingsNavigator.page.getByText("Memory cache size (by total items)", { exact: true }),
|
||||
});
|
||||
await settingsNavigator.dialogue.screenshot({
|
||||
...settingsScreenshotOptions,
|
||||
path: `${diagnosticsDirectory}/settings-declarative-search.png`,
|
||||
});
|
||||
await searchResult.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await search.fill("");
|
||||
|
||||
const deletionPanel = liveSyncSettings
|
||||
for (const pageName of settingsPageNames) {
|
||||
const pageRoot = await settingsNavigator.openPage(pageName);
|
||||
await pageRoot.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
}
|
||||
settingsPage = await settingsNavigator.openPage("Advanced");
|
||||
await settingsNavigator.dialogue.screenshot({
|
||||
...settingsScreenshotOptions,
|
||||
path: `${diagnosticsDirectory}/settings-declarative-advanced.png`,
|
||||
});
|
||||
}
|
||||
|
||||
settingsPage = await settingsNavigator.openPage("Sync Settings");
|
||||
|
||||
const deletionPanel = settingsPage
|
||||
.locator("h4.sls-setting-panel-title")
|
||||
.filter({ hasText: "Deletion Propagation" })
|
||||
.locator("..");
|
||||
@@ -328,6 +417,22 @@ async function verifyEffectiveSettings(): Promise<void> {
|
||||
`The obsolete LiveSync trash toggle is still present in the settings UI (${obsoleteToggleCount} found).`
|
||||
);
|
||||
}
|
||||
|
||||
if (settingsNavigator.renderer === "declarative") {
|
||||
await settingsNavigator.close();
|
||||
settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
settingsPage = await settingsNavigator.openPage("Advanced");
|
||||
const restoredValue = await settingsPage
|
||||
.locator(".setting-item")
|
||||
.filter({
|
||||
has: settingsNavigator.page.getByText("Memory cache size (by total items)", { exact: true }),
|
||||
})
|
||||
.locator('input[type="number"]')
|
||||
.inputValue();
|
||||
if (restoredValue !== "321") {
|
||||
throw new Error(`The declarative Advanced value was not restored after reopening: ${restoredValue}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -338,6 +443,7 @@ async function main(): Promise<void> {
|
||||
throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
|
||||
}
|
||||
const vault = await createTemporaryVault();
|
||||
await mkdir(diagnosticsDirectory, { recursive: true });
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
try {
|
||||
session = await startObsidianLiveSyncSession({
|
||||
@@ -346,10 +452,10 @@ async function main(): Promise<void> {
|
||||
vault,
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
pluginData: {
|
||||
doctorProcessedVersion: "0.25.27",
|
||||
doctorProcessedVersion: settingsOnly ? "1.0.0" : "0.25.27",
|
||||
isConfigured: true,
|
||||
liveSync: false,
|
||||
versionUpFlash: compatibilityReviewMessage,
|
||||
versionUpFlash: settingsOnly ? "" : compatibilityReviewMessage,
|
||||
notifyThresholdOfRemoteStorageSize: 0,
|
||||
syncOnStart: false,
|
||||
syncOnSave: false,
|
||||
@@ -362,10 +468,22 @@ async function main(): Promise<void> {
|
||||
usePowerUserMode: false,
|
||||
useEdgeCaseMode: false,
|
||||
},
|
||||
lifecycle: settingsOnly
|
||||
? {
|
||||
afterLaunch: async ({ remoteDebuggingPort }) => {
|
||||
await preseedTrustedVaultState(remoteDebuggingPort, vault.id);
|
||||
await allowPendingObsidianTestVaultOpenAction(remoteDebuggingPort, vault.path, uiTimeoutMs);
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
|
||||
await verifyCompatibilityReview();
|
||||
await verifyConfigDoctorFollowsCompatibilityReview();
|
||||
if (settingsOnly) {
|
||||
await resumePendingCompatibilityReviewForSettings();
|
||||
} else {
|
||||
await verifyCompatibilityReview();
|
||||
await verifyConfigDoctorFollowsCompatibilityReview();
|
||||
}
|
||||
await verifyEffectiveSettings();
|
||||
console.log("Compatibility review and settings expose only effective user controls.");
|
||||
} finally {
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
captureObsidianDialogue,
|
||||
captureObsidianElement,
|
||||
captureObsidianPage,
|
||||
openLiveSyncSettings,
|
||||
withObsidianPage,
|
||||
} from "../runner/ui.ts";
|
||||
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
|
||||
@@ -90,12 +91,10 @@ function modalByTitle(page: Page, title: string): Locator {
|
||||
});
|
||||
}
|
||||
|
||||
function settingPanelByTitle(page: Page, title: string): Locator {
|
||||
return page
|
||||
.locator(".sls-setting")
|
||||
.locator("h4.sls-setting-panel-title:visible")
|
||||
.filter({ hasText: title })
|
||||
.locator("..");
|
||||
async function liveSyncSettingPanelByTitle(page: Page, pageName: string, title: string): Promise<Locator> {
|
||||
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
const settingsPage = await settingsNavigator.openPage(pageName);
|
||||
return settingsPage.locator("h4.sls-setting-panel-title:visible").filter({ hasText: title }).locator("..");
|
||||
}
|
||||
|
||||
async function captureGuideDialogue(port: number, filename: string, title: string): Promise<string> {
|
||||
@@ -535,58 +534,27 @@ async function captureHiddenFileGuideSettings(
|
||||
environment
|
||||
);
|
||||
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await page.evaluate(() => {
|
||||
const obsidian = globalThis as typeof globalThis & {
|
||||
app?: {
|
||||
setting?: {
|
||||
open(): void;
|
||||
openTabById(tabId: string): void;
|
||||
};
|
||||
};
|
||||
};
|
||||
const setting = obsidian.app?.setting;
|
||||
if (!setting) throw new Error("Obsidian settings are unavailable");
|
||||
setting.open();
|
||||
setting.openTabById("obsidian-livesync");
|
||||
});
|
||||
const settings = page.locator(".sls-setting");
|
||||
await settings.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await settings.locator('.sls-setting-menu-btn[title="Setup"]').click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
|
||||
const screenshots = [
|
||||
await captureObsidianElement(port, "guide-hidden-file-advanced-features.png", (page) =>
|
||||
settingPanelByTitle(page, "Enable extra and advanced features")
|
||||
liveSyncSettingPanelByTitle(page, "Setup", "Enable extra and advanced features")
|
||||
),
|
||||
];
|
||||
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await page
|
||||
.locator(".sls-setting")
|
||||
.locator('.sls-setting-menu-btn[title="Selector"]')
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
screenshots.push(
|
||||
await captureObsidianElement(port, "guide-hidden-file-selector.png", (page) =>
|
||||
settingPanelByTitle(page, "Hidden Files")
|
||||
liveSyncSettingPanelByTitle(page, "Selector", "Hidden Files")
|
||||
)
|
||||
);
|
||||
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await page
|
||||
.locator(".sls-setting")
|
||||
.locator('.sls-setting-menu-btn[title="Sync Settings"]')
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
screenshots.push(
|
||||
await captureObsidianElement(port, "guide-hidden-file-enable.png", (page) =>
|
||||
settingPanelByTitle(page, "Hidden Files")
|
||||
liveSyncSettingPanelByTitle(page, "Sync Settings", "Hidden Files")
|
||||
)
|
||||
);
|
||||
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await page.keyboard.press("Escape");
|
||||
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
await settingsNavigator.close();
|
||||
});
|
||||
return screenshots;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,12 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Interface and translation
|
||||
|
||||
#### Improved
|
||||
|
||||
- Settings pages and Advanced controls now use Obsidian 1.13's native page navigation and global settings search, while retaining their familiar icons. Earlier supported Obsidian versions continue to use the existing settings interface.
|
||||
|
||||
## 1.0.18
|
||||
|
||||
24th August, 2026
|
||||
|
||||
Reference in New Issue
Block a user