Complete declarative settings runtime integration

This commit is contained in:
vorotamoroz
2026-08-24 18:11:46 +00:00
parent dd11a753d5
commit 8adc88b8cb
11 changed files with 460 additions and 184 deletions
+200
View File
@@ -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,