Files
obsidian-livesync/test/e2e-obsidian/scripts/p2p-pane.ts
T
2026-07-26 08:06:48 +00:00

416 lines
17 KiB
TypeScript

/**
* 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<string, unknown>;
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<void> {
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<string> {
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<ReturnType<typeof openP2PStatusPane>> | 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<void> {
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<void> {
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<void> {
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<void> {
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<string, unknown> {
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<string, unknown> {
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<string, unknown>,
verify: () => Promise<void>,
options: { mobileBeforePluginStart?: boolean } = {}
): Promise<void> {
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<void> {
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);
});