mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-27 13:57:07 +00:00
321 lines
13 KiB
TypeScript
321 lines
13 KiB
TypeScript
import { mkdir } from "node:fs/promises";
|
|
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,
|
|
trustVaultIfPrompted,
|
|
withObsidianPage,
|
|
} from "@vrtmrz/obsidian-test-session";
|
|
|
|
export async function captureObsidianPage(
|
|
port: number,
|
|
filename: string,
|
|
assertReady: (page: Page) => Promise<void>
|
|
): Promise<string> {
|
|
const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
|
|
const screenshotPath = join(outputDirectory, filename);
|
|
await mkdir(dirname(screenshotPath), { recursive: true });
|
|
|
|
await withObsidianPage(port, async (page) => {
|
|
try {
|
|
await assertReady(page);
|
|
} catch (error) {
|
|
const failurePath = screenshotPath.replace(/\.png$/u, ".failure.png");
|
|
await page.screenshot({ path: failurePath, fullPage: true });
|
|
console.error(`UI failure screenshot: ${failurePath}`);
|
|
throw error;
|
|
}
|
|
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
});
|
|
|
|
return screenshotPath;
|
|
}
|
|
|
|
export async function captureObsidianDialogue(
|
|
port: number,
|
|
filename: string,
|
|
assertReady: (page: Page) => Promise<void>
|
|
): Promise<string> {
|
|
return await captureObsidianPage(port, filename, assertReady);
|
|
}
|
|
|
|
/** Wait for a visible Obsidian dialogue, including one opened outside the settings window. */
|
|
export async function waitForVisibleObsidianDialogue(page: Page, text: string, timeoutMs = 10_000): Promise<Locator> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
for (const candidate of page.context().pages()) {
|
|
const dialogue = candidate.locator(".modal-container").filter({ hasText: text }).last();
|
|
if (await dialogue.isVisible().catch(() => false)) return dialogue;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
}
|
|
throw new Error(`Obsidian did not open the expected dialogue: ${text}`);
|
|
}
|
|
|
|
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> {
|
|
let hostPage: Page | undefined;
|
|
for (const candidate of page.context().pages()) {
|
|
const hasSettingsHost = await candidate
|
|
.evaluate(() => (globalThis as ObsidianSettingsHost).app?.setting !== undefined)
|
|
.catch(() => false);
|
|
if (hasSettingsHost) {
|
|
hostPage = candidate;
|
|
break;
|
|
}
|
|
}
|
|
if (hostPage === undefined) throw new Error("Obsidian settings are unavailable");
|
|
|
|
await hostPage.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 hostPage
|
|
.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 (!hostPage.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,
|
|
resolveElement: (page: Page) => Locator | Promise<Locator>
|
|
): Promise<string> {
|
|
const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
|
|
const screenshotPath = join(outputDirectory, filename);
|
|
await mkdir(dirname(screenshotPath), { recursive: true });
|
|
|
|
await withObsidianPage(port, async (page) => {
|
|
try {
|
|
const element = await resolveElement(page);
|
|
await element.waitFor({ state: "visible", timeout: 10000 });
|
|
await element.screenshot({
|
|
path: screenshotPath,
|
|
animations: "disabled",
|
|
style: ".notice-container { visibility: hidden !important; }",
|
|
});
|
|
} catch (error) {
|
|
const failurePath = screenshotPath.replace(/\.png$/u, ".failure.png");
|
|
await page.screenshot({ path: failurePath, fullPage: true });
|
|
console.error(`UI element failure screenshot: ${failurePath}`);
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
return screenshotPath;
|
|
}
|
|
|
|
export async function captureJsonResolveDialogue(port: number): Promise<string> {
|
|
return await captureObsidianDialogue(port, "hidden-file-json-resolve-dialogue.png", async (page) => {
|
|
const optionAB = page.locator('label:has(input[name="disp"][value="AB"])');
|
|
const optionBA = page.locator('label:has(input[name="disp"][value="BA"])');
|
|
const applyButton = page.getByRole("button", { name: "Apply" });
|
|
await optionAB.waitFor({ state: "visible", timeout: 10000 });
|
|
await optionBA.waitFor({ state: "visible", timeout: 10000 });
|
|
await applyButton.waitFor({ state: "visible", timeout: 10000 });
|
|
});
|
|
}
|
|
|
|
export async function clickJsonResolveOption(port: number, mode: "AB" | "BA"): Promise<void> {
|
|
await withObsidianPage(port, async (page) => {
|
|
const option = page.locator(`label:has(input[name="disp"][value="${mode}"])`);
|
|
await option.click({ timeout: 10000 });
|
|
const checked = await page.locator(`input[name="disp"][value="${mode}"]`).isChecked({ timeout: 10000 });
|
|
if (!checked) {
|
|
throw new Error(`JSON Resolve option was not selected: ${mode}`);
|
|
}
|
|
await page.getByRole("button", { name: "Apply" }).click({ timeout: 10000 });
|
|
});
|
|
}
|