Refine P2P and manual setup workflows

This commit is contained in:
vorotamoroz
2026-07-23 15:23:47 +00:00
parent cd35858e01
commit 1df034f12a
60 changed files with 1884 additions and 770 deletions
+3 -3
View File
@@ -83,7 +83,7 @@ The underlying `test:e2e:obsidian:<scenario>` scripts remain available for an im
`test:e2e:obsidian:onboarding-invitation` starts an unconfigured temporary Vault with no plug-in data and verifies that startup selects Commonlib's new-Vault recommendations, offers the setup wizard without opening it, and does not scan Vault files automatically. It checks the invitation action and introduction in mobile test mode, then uses the permanent command to reopen the wizard on the desktop. This scenario owns the unconfigured-startup boundary only; configured compatibility review remains covered by `settings-ui`, and the setup workflows remain covered by their dedicated scenarios.
`test:e2e:obsidian:dialog-mounts` starts a temporary real Obsidian session and exercises two representative Svelte dialogue routes: remote server selection through `SetupManager`, and Setup URI entry through the registered command. The session pre-seeds a configured, inactive plug-in state so that onboarding and migration prompts do not interfere with the dialogues under test. It requires each dialogue title and its principal controls to be visible, captures desktop and mobile screenshots, closes them through their normal user controls, and verifies that the remote-selection promise settles without an error. This covers the host, context, component, and result boundaries moved during the Commonlib package extraction without applying settings or contacting a remote service.
`test:e2e:obsidian:dialog-mounts` starts a temporary real Obsidian session and exercises remote selection and CouchDB settings through `SetupManager`, plus Setup URI entry through the registered command. It verifies the compatibility pause and remote-size review, the distinction between a central data-storage server and P2P signalling, the explicit tested and untested CouchDB save actions, the internal-API warning, and the Setup URI controls. It captures the representative dialogues on desktop and mobile, checks the mobile layout and vertically stacked actions, closes each route through its normal controls, and verifies that the remote-selection promise settles without an error. These UI-only checks do not apply a remote configuration or contact a remote service.
`test:e2e:obsidian:settings-ui` starts with a pending compatibility review and verifies the dedicated pause summary, its detailed explanation, and the explicit resume action in a temporary real Obsidian session. It captures the desktop summary and the iPhone-sized summary and detail dialogues; the mobile checks cover viewport containment, horizontal overflow, safe-area containment, and the close control's touch target. It confirms that the acknowledged internal version advances only after the review is accepted, and checks that the Change Log contains no acknowledgement control. It then selects the Synchronisation Settings pane and verifies that the deletion panel still exposes the effective 'Keep empty folder' setting without presenting the legacy `trashInsteadDelete` control, whose value no longer changes Obsidian deletion behaviour.
@@ -91,7 +91,7 @@ The mobile pass uses Obsidian's `app.emulateMobile(true)`, a 390 by 844 CSS-pixe
`test:e2e:obsidian:review-harness` exercises only the boundaries owned by the opt-in maintainer Harness. It retains a real compatibility pause, uses the fixed Harness restart action to persist a device-local continuation and reload Obsidian, and requires the Harness to delete that state before reopening. It also runs the bounded local observations, confirms the dedicated Vault fixture root is removed, captures the copied privacy-bounded Markdown report, and checks the Harness layout and touch targets in mobile test mode. Compatibility explanation and persistence details remain owned by `settings-ui`, real P2P transfer remains owned by the dedicated P2P suites, and general Vault reflection remains owned by `vault-reflection`; the Harness test does not duplicate those workflows.
`test:e2e:obsidian:p2p-pane` opens the P2P status view in a temporary real Obsidian session, verifies its principal connection control and horizontal layout, and captures a screenshot. It deliberately uses no relay or peer: current-replicator replacement is covered by focused unit tests, the Deno and Compose CLI P2P lifecycle suite covers the headless transport, and `p2p-setup-uri-workflow` owns the visible two-device real-Obsidian path.
`test:e2e:obsidian:p2p-pane` starts one unconfigured CouchDB-only session and one configured P2P session. It proves that the command remains registered while the retired command, automatic pane, and unconfigured ribbon entry are absent. For the configured profile, it verifies that the ribbon and current status command reach the pane without opening it at start-up, checks its connection control and horizontal layout, and captures unobstructed desktop and mobile screenshots. It deliberately uses no relay or peer: replacement of the active replicator is covered by focused unit tests, the Deno and Compose CLI P2P lifecycle suite covers the headless transport, and `p2p-setup-uri-workflow` owns the visible transfer path between two real Obsidian sessions.
`test:e2e:obsidian:local-suite` builds the plug-in and, unless `LIVESYNC_CLI_COMMAND` selects an external CLI, the local LiveSync CLI. It then runs discovery, smoke, the onboarding invitation, Svelte dialogue mounting, settings UI, the Review Harness, the P2P status pane, Vault reflection, CouchDB upload, CLI-to-Obsidian synchronisation, Object Storage upload and Setup URI round-trip, P2P Setup URI round-trip, startup scan, provisioned CouchDB Setup URI, two-vault synchronisation, Hidden File Sync, Customisation Sync, and setting Markdown export in sequence. Start the local CouchDB, MinIO, and P2P relay fixtures before running it, or use `test:e2e:obsidian:local-suite:services` to let the wrapper stop leftover fixtures, start fresh fixtures, and stop them again after the run.
@@ -124,7 +124,7 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --
`test:e2e:obsidian:object-storage-setup-uri-workflow` generates a public Commonlib-backed bootstrap URI for a unique MinIO prefix, completes visible first-device initialisation, and then asks that working real Obsidian device to create a new Setup URI through the registered command. A second real Obsidian device imports only the device-generated URI. The workflow verifies A-to-B and B-to-A notes, captures the documented onboarding choices, and removes the Object Storage prefix only after both sessions have stopped.
`test:e2e:obsidian:p2p-setup-uri-workflow` runs two concurrent isolated real Obsidian sessions against the local Nostr relay fixture. The first device imports a generated bootstrap URI, creates the additional-device URI through the registered command, and remains online while the second device imports it. The workflow selects the expected peer, accepts each connection request visibly on the receiving device, verifies the initial A-to-B fetch, reconnects both finite P2P sessions in join order, and verifies the B-to-A return journey. Every started session remains tracked until teardown completes.
`test:e2e:obsidian:p2p-setup-uri-workflow` runs two concurrent isolated real Obsidian sessions against the local Compose Nostr relay fixture. The first device imports a generated bootstrap URI and completes its signalling test with zero peers, creates a Setup URI for the second device through the registered command, and remains online while the second device imports it. The second device must select the expected online source before Fetch can rebuild its local database. The workflow accepts each connection request visibly on the receiving device, verifies the initial A-to-B fetch, checks that the menu for the three persistent per-peer actions remains within the viewport, reconnects both finite P2P sessions in join order, and verifies the B-to-A return journey. Every started session remains tracked until teardown completes.
`test:e2e:obsidian:startup-scan` configures a temporary CouchDB database, stops Obsidian, writes a note directly into the vault, restarts Obsidian, and verifies from CouchDB that the boot-time scan picked up the offline file.
@@ -23,6 +23,10 @@ export type ConfiguredSettings = {
export type CoreReadiness = {
databaseReady: boolean;
appReady: boolean;
configured?: boolean;
remoteType?: string;
settingVersion?: number;
suspended?: boolean;
};
export type ReplicationAttempt = CoreReadiness & {
@@ -348,9 +352,14 @@ export async function waitForLiveSyncCoreReady(
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const settings=core.services.setting.currentSettings();",
"return JSON.stringify({",
"databaseReady:core.services.database.isDatabaseReady(),",
"appReady:core.services.appLifecycle.isReady(),",
"configured:settings?.isConfigured===true,",
"remoteType:settings?.remoteType??'',",
"settingVersion:settings?.settingVersion,",
"suspended:core.services.appLifecycle.isSuspended(),",
"});",
"})()",
].join(""),
+18
View File
@@ -177,16 +177,34 @@ export async function captureAndStartInitialisation(
captures: SetupCaptureNames
): Promise<string> {
const p2pFirstDevice = mode === "new" && captures.guide === "p2p-setup";
const p2pAdditionalDevice = mode === "existing" && captures.guide === "p2p-setup";
const title = p2pFirstDevice
? "Setup Complete: Preparing This P2P Device"
: p2pAdditionalDevice
? "Setup Complete: Preparing to Fetch from Another Device"
: mode === "new"
? "Setup Complete: Preparing to Initialise Server"
: "Setup Complete: Preparing to Fetch Synchronisation Data";
const button = p2pFirstDevice
? "Restart and Prepare This Device"
: p2pAdditionalDevice
? "Restart and Select Source Device"
: mode === "new"
? "Restart and Initialise Server"
: "Restart and Fetch Data";
if (p2pAdditionalDevice) {
await withObsidianPage(port, async (page) => {
const modal = modalByTitle(page, title);
await modal
.getByText("After restarting, select an online source device for the initial Fetch.", {
exact: false,
})
.waitFor({ state: "visible", timeout: uiTimeoutMs });
if ((await modal.getByText("downloaded from the server", { exact: false }).count()) !== 0) {
throw new Error("P2P additional-device setup still describes the initial Fetch as a server download.");
}
});
}
const screenshot = await captureGuideDialogue(
port,
`guide-${captures.guide}-${mode === "new" ? "first-initialise" : "second-fetch"}.png`,
+131 -8
View File
@@ -1,8 +1,15 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
import { createE2eCouchDbPluginData, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
import { assertMobileDialogueLayout, assertMobileNoticeLayout, setObsidianMobileTestMode } from "../runner/mobileUi.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { captureObsidianDialogue, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts";
import {
captureObsidianDialogue,
captureObsidianPage,
obsidianRemoteDebuggingPort,
withObsidianPage,
} from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
const dialogRunStateKey = "__livesyncE2EDialogMount";
@@ -199,6 +206,12 @@ async function verifyRemoteSelectionDialogue(mode: DialogueMode): Promise<string
for (const label of ["CouchDB", "S3/MinIO/R2 Object Storage", "Peer-to-Peer only"]) {
await dialogue.getByText(label, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
}
await dialogue
.getByText(
"No central data-storage server is required, but a signalling relay is required for peer discovery.",
{ exact: false }
)
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal
.getByRole("button", { name: "No, please take me back" })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
@@ -226,6 +239,78 @@ async function verifyRemoteSelectionDialogue(mode: DialogueMode): Promise<string
return screenshotPath;
}
async function verifyCouchDBSettingsDialogue(mode: DialogueMode): Promise<string> {
await openRemoteSelectionDialogue();
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const remoteSelection = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Enter Server Information" }),
});
await remoteSelection
.locator("label")
.filter({ hasText: "CouchDB" })
.locator('input[type="radio"]')
.first()
.check({ timeout: uiTimeoutMs });
await remoteSelection
.getByRole("button", { name: "Continue to CouchDB setup", exact: true })
.click({ timeout: uiTimeoutMs });
});
const screenshotPath = await captureObsidianDialogue(
obsidianRemoteDebuggingPort(),
`setup-couchdb-dialogue${mode === "mobile" ? "-mobile" : ""}.png`,
async (page) => {
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "CouchDB Configuration" }),
});
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
for (const label of [
"Check server requirements",
"Test connection and save",
"Save without connecting",
"Cancel",
]) {
await modal.getByRole("button", { name: label, exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
}
await modal
.getByText(
"This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server.",
{ exact: false }
)
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal
.getByText("CouchDB validates the database name when you connect.", { exact: false })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
if ((await modal.getByRole("button", { name: "Continue anyway", exact: true }).count()) !== 0) {
throw new Error("CouchDB onboarding still exposes the ambiguous Continue anyway action.");
}
const buttonGroups = modal.locator(".button-group");
for (let index = 0; index < (await buttonGroups.count()); index++) {
const flexDirection = await buttonGroups.nth(index).evaluate((element) => {
return getComputedStyle(element).flexDirection;
});
if (flexDirection !== "column") {
throw new Error(`Expected vertical CouchDB actions, received ${flexDirection}.`);
}
}
if (mode === "mobile") {
await assertMobileDialogueLayout(page, modal, "CouchDB settings dialogue");
}
}
);
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "CouchDB Configuration" }),
});
await modal.getByRole("button", { name: "Cancel", exact: true }).click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
await assertDialogueRunCompleted();
return screenshotPath;
}
async function verifySetupUriDialogue(mode: DialogueMode): Promise<string> {
await openSetupUriDialogue();
const screenshotPath = await captureObsidianDialogue(
@@ -337,10 +422,14 @@ async function main(): Promise<void> {
cliBinary: cli.binary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: {
doctorProcessedVersion: "0.25.27",
isConfigured: true,
liveSync: false,
pluginData: createE2eCouchDbPluginData(
{
uri: "http://127.0.0.1:5984",
username: "",
password: "",
dbName: "dialog-mounts-ui-only",
},
{
notifyThresholdOfRemoteStorageSize: -1,
syncOnStart: false,
syncOnSave: false,
@@ -348,9 +437,35 @@ async function main(): Promise<void> {
syncOnFileOpen: false,
syncAfterMerge: false,
periodicReplication: false,
},
}
),
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
try {
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
} catch (error) {
const screenshot = await captureObsidianPage(
obsidianRemoteDebuggingPort(),
"dialog-mounts-core-not-ready.png",
async () => undefined
);
console.error(`Core readiness diagnostic screenshot: ${screenshot}`);
const persistedSettings = JSON.parse(
await readFile(join(session.install.pluginDir, "data.json"), "utf8")
) as Record<string, unknown>;
console.error(
`Persisted readiness settings: ${JSON.stringify({
isConfigured: persistedSettings.isConfigured,
remoteType: persistedSettings.remoteType,
settingVersion: persistedSettings.settingVersion,
couchDB_URI: persistedSettings.couchDB_URI,
couchDB_DBNAME: persistedSettings.couchDB_DBNAME,
remoteConfigurationCount: Object.keys(
(persistedSettings.remoteConfigurations as Record<string, unknown> | undefined) ?? {}
).length,
})}`
);
throw error;
}
const remoteSizeScreenshots = await verifyRemoteSizeNoticeAndDialogue();
console.log(
@@ -359,6 +474,10 @@ async function main(): Promise<void> {
const remoteScreenshot = await verifyRemoteSelectionDialogue("desktop");
console.log(`Remote selection dialogue mounted and closed successfully. Screenshot: ${remoteScreenshot}`);
const couchDBScreenshot = await verifyCouchDBSettingsDialogue("desktop");
console.log(
`CouchDB settings mode exposed explicit connection, unverified-save, and server-check actions. Screenshot: ${couchDBScreenshot}`
);
const setupUriScreenshot = await verifySetupUriDialogue("desktop");
console.log(`Setup URI dialogue mounted and closed successfully. Screenshot: ${setupUriScreenshot}`);
@@ -372,6 +491,10 @@ async function main(): Promise<void> {
console.log(
`Mobile remote selection dialogue passed viewport, safe-area, touch-target, and close-control checks. Screenshot: ${mobileRemoteScreenshot}`
);
const mobileCouchDBScreenshot = await verifyCouchDBSettingsDialogue("mobile");
console.log(
`Mobile CouchDB settings dialogue passed viewport, touch-target, and vertical-action checks. Screenshot: ${mobileCouchDBScreenshot}`
);
const mobileSetupUriScreenshot = await verifySetupUriDialogue("mobile");
console.log(
`Mobile Setup URI dialogue passed viewport, safe-area, and touch-target checks. Screenshot: ${mobileSetupUriScreenshot}`
+143 -28
View File
@@ -1,6 +1,12 @@
import { assertNoHorizontalOverflow } from "@vrtmrz/obsidian-test-session";
import { assertLocatorWithinViewport, assertNoHorizontalOverflow } from "@vrtmrz/obsidian-test-session";
import type { Page } from "playwright";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { createE2eObsidianDeviceLocalState, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
import {
createE2eCouchDbPluginData,
createE2eObsidianDeviceLocalState,
waitForLiveSyncCoreReady,
} from "../runner/liveSyncWorkflow.ts";
import { setObsidianMobileTestMode } 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";
@@ -8,7 +14,10 @@ import { createTemporaryVault } from "../runner/vault.ts";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_P2P_PANE_TIMEOUT_MS ?? 10000);
type ObsidianTestApp = {
commands?: { executeCommandById(commandId: string): boolean };
commands?: {
commands?: Record<string, unknown>;
executeCommandById(commandId: string): boolean;
};
};
type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp };
@@ -25,9 +34,9 @@ async function openP2PStatusPane(): Promise<void> {
}
}
async function verifyP2PStatusPane(): Promise<string> {
async function verifyP2PStatusPane(filename: string, mobile: boolean): Promise<string> {
await openP2PStatusPane();
return await captureObsidianPage(obsidianRemoteDebuggingPort(), "p2p-status-pane.png", async (page) => {
return await captureObsidianPage(obsidianRemoteDebuggingPort(), filename, async (page) => {
const heading = page.getByRole("heading", { name: "Signalling Status" }).last();
await heading.waitFor({ state: "visible", timeout: uiTimeoutMs });
const pane = heading.locator(
@@ -39,42 +48,113 @@ async function verifyP2PStatusPane(): Promise<string> {
timeout: uiTimeoutMs,
});
await assertNoHorizontalOverflow(page, pane, { label: "P2P status pane" });
if (mobile) {
await assertLocatorWithinViewport(page, pane, { label: "mobile P2P status pane" });
}
await dismissOpenNotices(page);
});
}
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(", ")}`);
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 an unconfigured CouchDB user.");
}
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 dismissOpenNotices(page: Page): Promise<void> {
const deadline = Date.now() + uiTimeoutMs;
let quietSince = Date.now();
while (Date.now() < deadline) {
const notices = page.locator(".notice:visible");
if ((await notices.count()) === 0) {
if (Date.now() - quietSince >= 500) {
return;
}
await page.waitForTimeout(100);
continue;
}
quietSince = Date.now();
const closeButton = notices.first().locator(".notice-close-button");
if ((await closeButton.count()) > 0) {
await closeButton.click({ force: true, timeout: uiTimeoutMs });
} else {
// Obsidian 1.12 does not render a separate close control for every
// Notice; clicking the Notice itself is its standard dismiss action.
await notices.first().click({ force: true, position: { x: 2, y: 2 }, timeout: uiTimeoutMs });
}
}
throw new Error("Transient Obsidian notices did not become quiet before the P2P status screenshot.");
}
const basePluginData = 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,
}
);
async function withP2PSession(
binary: string,
cliBinary: string,
pluginData: Record<string, unknown>,
verify: () => Promise<void>
): Promise<void> {
const vault = await createTemporaryVault();
let session: ObsidianLiveSyncSession | undefined;
try {
session = await startObsidianLiveSyncSession({
binary,
cliBinary: cli.binary,
cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: {
doctorProcessedVersion: "0.25.27",
isConfigured: true,
liveSync: false,
notifyThresholdOfRemoteStorageSize: -1,
periodicReplication: false,
P2P_Enabled: false,
P2P_AutoStart: false,
syncAfterMerge: false,
syncOnEditorSave: false,
syncOnFileOpen: false,
syncOnSave: false,
syncOnStart: false,
},
pluginData,
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
const screenshot = await verifyP2PStatusPane();
console.log(`P2P status pane mounted without network fixtures. Screenshot: ${screenshot}`);
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
await verify();
} finally {
if (session) {
await session.app.stop();
@@ -83,6 +163,41 @@ async function main(): Promise<void> {
}
}
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, basePluginData, async () => {
await assertP2PUIIsOptIn();
});
await withP2PSession(
binary,
cli.binary,
{
...basePluginData,
P2P_roomID: "configured-p2p-room",
P2P_passphrase: "configured-p2p-passphrase",
},
async () => {
await assertConfiguredP2PUIIsAvailable();
const desktopScreenshot = await verifyP2PStatusPane("p2p-status-pane.png", false);
await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), true, uiTimeoutMs);
try {
const mobileScreenshot = await verifyP2PStatusPane("p2p-status-pane-mobile.png", true);
console.log(
`Configured P2P status UI remained opt-in and was reachable on desktop and mobile. Screenshots: ${desktopScreenshot}, ${mobileScreenshot}`
);
} finally {
await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), false, uiTimeoutMs);
}
}
);
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
@@ -419,6 +419,47 @@ async function waitForDetectedPeer(port: number): Promise<void> {
});
}
async function capturePeerActionsMenu(port: number): Promise<string> {
await withObsidianPage(port, async (page) => {
const moreActions = page.getByRole("button", { name: /^More actions for /u }).first();
await moreActions.waitFor({ state: "visible", timeout: uiTimeoutMs });
await moreActions.click({ timeout: uiTimeoutMs });
const menu = page.locator(".menu:visible").last();
await menu.waitFor({ state: "visible", timeout: uiTimeoutMs });
for (const label of [
"Synchronise when this device connects",
"Follow whenever this device connects",
"Include in the P2P synchronisation command",
]) {
await menu.getByText(label, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
}
const layout = await menu.evaluate((element) => {
const rect = element.getBoundingClientRect();
return {
insideViewport:
rect.left >= 0 &&
rect.top >= 0 &&
rect.right <= document.documentElement.clientWidth &&
rect.bottom <= document.documentElement.clientHeight,
hasHorizontalOverflow: element.scrollWidth > element.clientWidth,
};
});
if (!layout.insideViewport || layout.hasHorizontalOverflow) {
throw new Error(`P2P peer actions menu did not fit the viewport: ${JSON.stringify(layout)}`);
}
});
const screenshot = await captureObsidianElement(
port,
"guide-p2p-setup-peer-actions-menu.png",
(page) => page.locator(".menu:visible").last()
);
await withObsidianPage(port, async (page) => {
await page.keyboard.press("Escape");
await page.locator(".menu:visible").waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
return screenshot;
}
async function captureNote(port: number, path: string, text: string, filename: string): Promise<string> {
await withObsidianPage(port, async (page) => {
await page.evaluate((notePath) => {
@@ -496,12 +537,15 @@ async function main(): Promise<void> {
screenshots.push(
await captureNote(portB, noteFromFirst, "P2P from the first device", "guide-p2p-setup-first-to-second.png")
);
console.log("P2P workflow: initial Fetch from the first device completed.");
await writeNote(context.cliBinary, sessionB.cliEnv, noteFromSecond, secondContent);
await reconnectP2PStatus(portA);
await reconnectP2PStatus(portB);
await waitForDetectedPeer(portA);
screenshots.push(await openP2PStatus(portA, "guide-p2p-setup-devices-connected.png"));
screenshots.push(await capturePeerActionsMenu(portA));
console.log("P2P workflow: peer actions menu verified; starting the return journey.");
let returnJourneyFinished = false;
const returnJourneyAcceptor = acceptConnectionRequests(
[portA, portB],
@@ -510,10 +554,13 @@ async function main(): Promise<void> {
);
try {
await replicateFromStatusPane(portA);
console.log("P2P workflow: return replication requested; waiting for the second device's note.");
await waitForPathContent(vaultA, noteFromSecond, secondContent);
console.log("P2P workflow: return note reached the first device.");
} finally {
returnJourneyFinished = true;
await returnJourneyAcceptor;
console.log("P2P workflow: return connection approval loop stopped.");
}
screenshots.push(
await captureNote(
@@ -526,9 +573,11 @@ async function main(): Promise<void> {
console.log(`P2P Setup URI and two-device roundtrip succeeded. Screenshots: ${screenshots.join(", ")}`);
} finally {
console.log("P2P workflow: stopping tracked Obsidian sessions.");
await stopSessions(context).catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
console.log("P2P workflow: disposing temporary Vaults.");
await vaultA.dispose();
await vaultB.dispose();
}