/** * Verifies the complete user-visible contract of the P2P status pane in real * Obsidian: a configured CouchDB-only Vault with no P2P profile is not * presented with P2P controls, while configured P2P devices can deliberately * open the current pane in the appropriate workspace area. * * Desktop and mobile use separate Vaults, profiles, and Obsidian processes. * Mobile mode is enabled before LiveSync's first load so that command and view * registration observe the mobile application state, and no desktop workspace * state can make a misplaced or restored pane appear correct. * * Command registration, automatic-opening policy, ribbon availability, * workspace ownership, layout, and screenshots are kept in one scenario * because together they describe one navigation path. Checking them in * isolation could miss a pane which is registered correctly but opens in the * wrong area, or one which is visible only because another session restored it. */ import { assertLocatorWithinViewport, assertNoHorizontalOverflow } from "@vrtmrz/obsidian-test-session"; import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type"; import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations"; import type { ConsoleMessage, Page } from "playwright"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { createE2eCouchDbPluginData, createE2eObsidianDeviceLocalState, waitForLiveSyncCoreReady, } from "../runner/liveSyncWorkflow.ts"; import { setObsidianMobileTestModeBeforePluginStart } from "../runner/mobileUi.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { captureObsidianPage, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts"; import { createTemporaryVault } from "../runner/vault.ts"; const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_P2P_PANE_TIMEOUT_MS ?? 10000); type ObsidianTestLeaf = { containerEl?: HTMLElement; view?: { getViewType?: () => string }; }; type ObsidianTestWorkspace = { activeLeaf?: ObsidianTestLeaf; getLeavesOfType?: (type: string) => ObsidianTestLeaf[]; getRightLeaf?: (split: boolean) => ObsidianTestLeaf | null; rightSplit?: { containerEl?: HTMLElement }; }; type ObsidianTestApp = { commands?: { commands?: Record; executeCommandById(commandId: string): boolean; }; isMobile?: boolean; plugins?: { plugins?: Record< string, { core?: { services?: { API?: { isMobile?: () => boolean; }; }; }; } >; }; workspace?: ObsidianTestWorkspace; }; type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp }; async function openP2PStatusPane(page: Page) { return await page.evaluate((commandId) => { const app = (globalThis as ObsidianTestGlobal).app; const plugin = app?.plugins?.plugins?.["obsidian-livesync"]; return { opened: app?.commands?.executeCommandById(commandId) === true, appIsMobile: app?.isMobile ?? null, apiIsMobile: plugin?.core?.services?.API?.isMobile?.() ?? null, bodyIsMobile: document.body.classList.contains("is-mobile"), }; }, "obsidian-livesync:open-p2p-server-status"); } async function collectP2PWorkspaceState(page: Page) { return await page.evaluate(() => { const workspace = (globalThis as ObsidianTestGlobal).app?.workspace; const activeLeaf = workspace?.activeLeaf; const p2pLeaves = workspace?.getLeavesOfType?.("p2p-server-status") ?? []; const rightLeaf = workspace?.getRightLeaf?.(false); return { bodyClasses: document.body.className, activeLeaf: { type: activeLeaf?.view?.getViewType?.() ?? null, visible: activeLeaf?.containerEl?.checkVisibility?.() ?? null, classes: activeLeaf?.containerEl?.className ?? null, }, p2pLeaves: p2pLeaves.map((leaf) => ({ type: leaf.view?.getViewType?.() ?? null, visible: leaf.containerEl?.checkVisibility?.() ?? null, classes: leaf.containerEl?.className ?? null, })), rightLeaf: { type: rightLeaf?.view?.getViewType?.() ?? null, visible: rightLeaf?.containerEl?.checkVisibility?.() ?? null, classes: rightLeaf?.containerEl?.className ?? null, }, visibleP2PContents: document.querySelectorAll( ".workspace-leaf-content[data-type='p2p-server-status']:not(.is-hidden)" ).length, }; }); } async function assertMobileP2PPlacement(page: Page): Promise { const placement = await page.evaluate(() => { const workspace = (globalThis as ObsidianTestGlobal).app?.workspace; const p2pLeaves = workspace?.getLeavesOfType?.("p2p-server-status") ?? []; const rightSplit = workspace?.rightSplit?.containerEl; const rightLeaf = workspace?.getRightLeaf?.(false); return { p2pLeafCount: p2pLeaves.length, inRightSplit: p2pLeaves.some( (leaf) => (rightSplit?.contains(leaf.containerEl ?? null) ?? false) || (leaf.containerEl?.closest(".mod-right-split, .workspace-drawer.mod-right") ?? null) !== null ), rightLeafType: rightLeaf?.view?.getViewType?.() ?? null, p2pLeafClasses: p2pLeaves.map((leaf) => leaf.containerEl?.className ?? null), rightSplitClasses: rightSplit?.className ?? null, }; }); if (!placement.inRightSplit) { throw new Error(`The mobile P2P status view was not opened in the right leaf: ${JSON.stringify(placement)}`); } } async function verifyP2PStatusPane(filename: string, mobile: boolean): Promise { return await captureObsidianPage(obsidianRemoteDebuggingPort(), filename, async (page) => { const runtimeErrors: string[] = []; const onPageError = (error: Error) => runtimeErrors.push(`pageerror: ${error.message}`); const onConsole = (message: ConsoleMessage) => { if (message.type() === "error") runtimeErrors.push(`console: ${message.text()}`); }; page.on("pageerror", onPageError); page.on("console", onConsole); let dispatchState: Awaited> | undefined; const heading = page.getByRole("heading", { name: "Signalling Status" }).last(); try { dispatchState = await openP2PStatusPane(page); if (!dispatchState.opened) { throw new Error("The P2P status command was not registered or could not be executed."); } if ( mobile && (dispatchState.appIsMobile !== true || dispatchState.apiIsMobile !== true || dispatchState.bodyIsMobile !== true) ) { throw new Error( `The mobile P2P command did not observe a fully mobile application state: ${JSON.stringify(dispatchState)}` ); } await heading.waitFor({ state: "visible", timeout: uiTimeoutMs }); } catch (error) { const workspaceState = await collectP2PWorkspaceState(page); console.error( `P2P command state after failed open: ${JSON.stringify({ dispatchState, runtimeErrors })}` ); console.error(`P2P workspace state after failed open: ${JSON.stringify(workspaceState)}`); throw error; } finally { page.off("pageerror", onPageError); page.off("console", onConsole); } if (mobile) { await assertMobileP2PPlacement(page); } const pane = heading.locator( "xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]" ); await pane.getByText("Connection:", { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs }); await pane.getByRole("button", { name: "Open connection" }).waitFor({ state: "visible", timeout: uiTimeoutMs, }); const remoteSelector = pane.getByRole("combobox", { name: "Select active P2P remote" }); await remoteSelector.waitFor({ state: "visible", timeout: uiTimeoutMs }); const remoteSelectionDeadline = Date.now() + uiTimeoutMs; let remoteConfigurationId = ""; while (Date.now() < remoteSelectionDeadline) { remoteConfigurationId = (await remoteSelector.inputValue()).trim(); if (remoteConfigurationId !== "") break; await page.waitForTimeout(50); } if (remoteConfigurationId === "") { throw new Error("The configured P2P status pane did not select an active P2P remote."); } if ( (await pane.getByText("Please select an active P2P remote configuration to change P2P sync targets.").count()) !== 0 ) { throw new Error("The configured P2P status pane still requested an active P2P remote."); } await assertNoHorizontalOverflow(page, pane, { label: "P2P status pane" }); if (mobile) { await assertLocatorWithinViewport(page, pane, { label: "mobile P2P status pane" }); } await dismissOpenNotices(page); }); } async function assertP2PUIIsOptIn(): Promise { await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { const state = await page.evaluate(() => { const commands = (globalThis as ObsidianTestGlobal).app?.commands?.commands ?? {}; return { currentCommand: commands["obsidian-livesync:open-p2p-server-status"] !== undefined, legacyCommand: commands["obsidian-livesync:open-p2p-replicator"] !== undefined, }; }); if (!state.currentCommand) { throw new Error("The current P2P status command was not registered."); } if (state.legacyCommand) { throw new Error("The retired P2P pane command is still exposed."); } if ((await page.locator(".workspace-leaf-content[data-type='p2p-server-status']:visible").count()) !== 0) { throw new Error("The P2P status pane opened automatically for a CouchDB user without P2P configured."); } if ((await page.locator(".livesync-ribbon-p2p-server-status").count()) !== 0) { throw new Error("The P2P ribbon icon was shown without a P2P configuration."); } }); } async function assertConfiguredP2PUIIsAvailable(): Promise { await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { await page.locator(".livesync-ribbon-p2p-server-status").waitFor({ state: "visible", timeout: uiTimeoutMs, }); if ((await page.locator(".workspace-leaf-content[data-type='p2p-server-status']:visible").count()) !== 0) { throw new Error("The configured P2P status pane opened before the user requested it."); } }); } async function assertConfiguredP2PCommandIsAvailable(): Promise { await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { const state = await page.evaluate(() => { const app = (globalThis as ObsidianTestGlobal).app; const commands = app?.commands?.commands ?? {}; return { commandRegistered: commands["obsidian-livesync:open-p2p-server-status"] !== undefined, openPaneCount: app?.workspace?.getLeavesOfType?.("p2p-server-status").length ?? 0, }; }); if (!state.commandRegistered) { throw new Error("The configured P2P status command was not registered in mobile mode."); } if (state.openPaneCount !== 0) { throw new Error("The configured P2P status pane opened before the mobile user requested it."); } }); } async function dismissOpenNotices(page: Page): Promise { const deadline = Date.now() + uiTimeoutMs; let quietSince = Date.now(); while (Date.now() < deadline) { const dismissed = await page.evaluate(() => { const notices = (Array.from(document.querySelectorAll(".notice")) as HTMLElement[]).filter( (notice) => notice.checkVisibility?.() ?? notice.offsetParent !== null ); for (const notice of notices) { const closeButton = notice.querySelector(".notice-close-button") as HTMLElement | null; // Obsidian 1.12 does not render a separate close control for // every Notice; clicking the Notice itself is its standard // dismiss action. (closeButton ?? notice).click(); } return notices.length; }); if (dismissed === 0) { if (Date.now() - quietSince >= 500) { return; } await page.waitForTimeout(100); continue; } quietSince = Date.now(); await page.waitForTimeout(50); } throw new Error("Transient Obsidian notices did not become quiet before the P2P status screenshot."); } function createBaseP2PPluginData(): Record { return createE2eCouchDbPluginData( { uri: "http://127.0.0.1:5984", username: "", password: "", dbName: "p2p-pane-ui-only", }, { notifyThresholdOfRemoteStorageSize: -1, periodicReplication: false, P2P_Enabled: false, P2P_AutoStart: false, syncAfterMerge: false, syncOnEditorSave: false, syncOnFileOpen: false, syncOnSave: false, syncOnStart: false, } ); } function createConfiguredP2PPluginData(): Record { const pluginData = { ...createBaseP2PPluginData(), P2P_roomID: "configured-p2p-room", P2P_passphrase: "configured-p2p-passphrase", }; upsertRemoteConfigurationInPlace(pluginData as ObsidianLiveSyncSettings, "p2p", { id: "e2e-p2p", name: "P2P Remote", activateForP2P: true, }); return pluginData; } async function withP2PSession( binary: string, cliBinary: string, pluginData: Record, verify: () => Promise, options: { mobileBeforePluginStart?: boolean } = {} ): Promise { const vault = await createTemporaryVault(); let session: ObsidianLiveSyncSession | undefined; try { session = await startObsidianLiveSyncSession({ binary, cliBinary, vault, startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), pluginData, localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), lifecycle: options.mobileBeforePluginStart ? { beforePluginStart: async ({ remoteDebuggingPort }) => { await setObsidianMobileTestModeBeforePluginStart( remoteDebuggingPort, true, uiTimeoutMs ); }, } : undefined, }); await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); await verify(); } finally { if (session) { await session.app.stop(); } await vault.dispose(); } } async function main(): Promise { const binary = requireObsidianBinary(); const cli = discoverObsidianCli(); if (!cli.binary) { throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`); } await withP2PSession(binary, cli.binary, createBaseP2PPluginData(), async () => { await assertP2PUIIsOptIn(); }); await withP2PSession( binary, cli.binary, createConfiguredP2PPluginData(), async () => { await assertConfiguredP2PUIIsAvailable(); const desktopScreenshot = await verifyP2PStatusPane("p2p-status-pane.png", false); console.log( `Configured P2P status UI remained opt-in and was reachable on desktop. Screenshot: ${desktopScreenshot}` ); } ); await withP2PSession( binary, cli.binary, createConfiguredP2PPluginData(), async () => { await assertConfiguredP2PCommandIsAvailable(); const mobileScreenshot = await verifyP2PStatusPane("p2p-status-pane-mobile.png", true); console.log( `Configured P2P status UI remained opt-in and was reachable on mobile. Screenshot: ${mobileScreenshot}` ); }, { mobileBeforePluginStart: true } ); } main().catch((error: unknown) => { console.error(error instanceof Error ? error.stack : error); process.exit(1); });