mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-30 07:17:05 +00:00
Complete declarative settings runtime integration
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user