import { mkdir } from "node:fs/promises"; import { join } from "node:path"; import { DEVICE_ID_PREFERRED, MILESTONE_DOCID, NODEINFO_DOCID } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { evalObsidianJson } from "../runner/cli.ts"; import { assertCouchDbReachable, createCouchDbDatabase, deleteCouchDbDatabase, fetchCouchDbDocument, loadCouchDbConfig, makeUniqueDatabaseName, putCouchDbDocument, type CouchDbDocument, } from "../runner/couchdb.ts"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { assertEqual, assertE2eCompatibilityUnpaused, configureCouchDb, createE2eCouchDbPluginData, prepareRemote, waitForLiveSyncCoreReady, } from "../runner/liveSyncWorkflow.ts"; import { assertLocatorHasMinimumTouchTarget, assertLocatorWithinSafeArea, assertLocatorWithinViewport, assertNoHorizontalOverflow, } from "@vrtmrz/obsidian-test-session"; import { iPhoneSafeArea, setObsidianMobileTestMode } from "../runner/mobileUi.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { openLiveSyncSettings, waitForVisibleObsidianDialogue, withObsidianPage } from "../runner/ui.ts"; import { createTemporaryVault } from "../runner/vault.ts"; import type { Locator, Page } from "playwright"; const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_JOINED_DEVICES_TIMEOUT_MS ?? 10000); const commandId = "obsidian-livesync:joined-devices"; const targetId = "joined-devices-target"; const retainedId = "joined-devices-retained"; const initialPlatform = "E2E Android tablet"; const refreshedPlatform = "E2E refreshed platform"; const connectedAt = Date.UTC(2026, 0, 12, 9, 30, 0); type SelfDeviceInfo = { id: string; platform: string; pluginVersion: string; appVersion: string; }; type DeviceInfo = { device_name: string; vault_name: string; app_version: string; plugin_version: string; platform: string; progress: string; last_connected: number; }; type JoinedDevicesMilestone = CouchDbDocument & { accepted_nodes: string[]; node_info: Record; locked: boolean; cleaned: boolean; node_chunk_info: Record; tweak_values: Record>; }; type DeviceFixture = { milestone: JoinedDevicesMilestone; preferredTweaks: Record; }; function assert(condition: boolean, message: string): void { if (!condition) throw new Error(message); } function deviceCard(dialogue: Locator, id: string): Locator { return dialogue.locator(".device-card").filter({ hasText: id }); } async function assertDetail(card: Locator, label: string, expected: string): Promise { const rows = card.locator(".device-details > div"); for (let index = 0; index < (await rows.count()); index++) { const row = rows.nth(index); if ((await row.locator("dt").innerText()).trim() === label) { assertEqual((await row.locator("dd").innerText()).trim(), expected, `${label} did not match.`); return; } } throw new Error(`The device details did not contain '${label}'.`); } async function readSelfDeviceInfo(cliBinary: string, env: NodeJS.ProcessEnv): Promise { return await evalObsidianJson( cliBinary, [ "(async()=>{", "const core=app.plugins.plugins['obsidian-livesync'].core;", `const nodeInfo=await core.services.database.localDatabase.localDatabase.get(${JSON.stringify(NODEINFO_DOCID)});`, "return JSON.stringify({", "id:nodeInfo.nodeid,", "platform:core.services.API.getPlatform(),", "pluginVersion:core.services.API.getPluginVersion(),", "appVersion:core.services.API.getAppVersion(),", "});", "})()", ].join(""), env ); } function createFixture(self: SelfDeviceInfo): DeviceFixture { const preferredTweaks = { customChunkSize: 60, chunkSplitterVersion: "v3-rabin-karp" }; const milestone: JoinedDevicesMilestone = { _id: MILESTONE_DOCID, type: "milestoneinfo", created: Date.now(), // The current device is deliberately absent: it must still be listed first and identified as locked out. accepted_nodes: [targetId, retainedId], node_info: { [targetId]: { device_name: "E2E target device", vault_name: "Joined devices target fixture", app_version: "1.99.0-test", plugin_version: "999.0.0", platform: initialPlatform, progress: "ready", last_connected: connectedAt, }, [retainedId]: { device_name: "E2E retained device", vault_name: "Joined devices retained fixture", app_version: "1.88.0-test", plugin_version: "0.1.0", platform: "E2E Linux desktop", progress: "ready", last_connected: connectedAt - 86400000, }, }, locked: true, cleaned: true, node_chunk_info: { [targetId]: { min: 1, max: 3, current: 3 }, [retainedId]: { min: 1, max: 2, current: 2 }, }, tweak_values: { [targetId]: { customChunkSize: 48 }, [retainedId]: { customChunkSize: 60 }, [DEVICE_ID_PREFERRED]: preferredTweaks, }, }; assert(self.id !== targetId && self.id !== retainedId, "The local device identity collided with a fixture ID."); return { milestone, preferredTweaks }; } async function openFromRemoteSettings(page: Page): Promise { const settings = await openLiveSyncSettings(page, uiTimeoutMs); const remoteConfiguration = await settings.openPage("Remote Configuration"); await remoteConfiguration .getByRole("button", { name: "Review devices", exact: true }) .click({ timeout: uiTimeoutMs }); const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs); await dialogue.getByText(targetId, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs }); return dialogue; } async function assertInitialDialogue(dialogue: Locator, self: SelfDeviceInfo, page: Page): Promise { const cards = dialogue.locator(".device-card"); assertEqual( await cards.count(), 3, "The joined devices dialogue did not show the self, target, and retained records." ); const selfCard = cards.nth(0); assert(Boolean(self.id), "The current device identity was not read from the local NODEINFO_DOCID record."); assertEqual( (await selfCard.locator("code.device-id").innerText()).trim(), self.id, "The current device was not listed first." ); await selfCard.getByText("This device", { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs }); await selfCard.getByText("Locked out", { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs }); await selfCard.getByText("Not accepted", { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs }); await selfCard .getByText("This device has been locked out.", { exact: false }) .waitFor({ state: "visible", timeout: uiTimeoutMs }); await assertDetail(selfCard, "Platform", self.platform); await assertDetail(selfCard, "Plug-in version", self.pluginVersion); await assertDetail(selfCard, "App version", self.appVersion); assertEqual( await selfCard.getByRole("button", { name: "Forget device", exact: true }).count(), 0, "The current device exposed a forget control." ); const targetCard = deviceCard(dialogue, targetId); await assertDetail(targetCard, "Platform", initialPlatform); await assertDetail(targetCard, "Plug-in version", "999.0.0"); await assertDetail(targetCard, "App version", "1.99.0-test"); const targetDate = await page.evaluate((timestamp) => new Date(timestamp).toLocaleString(), connectedAt); await assertDetail(targetCard, "Last recorded", targetDate); await targetCard.getByText("Accepted", { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs }); const retainedCard = deviceCard(dialogue, retainedId); await assertDetail(retainedCard, "Platform", "E2E Linux desktop"); await assertDetail(retainedCard, "Plug-in version", "0.1.0"); await assertDetail(retainedCard, "App version", "1.88.0-test"); await dialogue .getByText( "This list shows device information recorded by the remote. It does not show whether a device is online now.", { exact: true } ) .waitFor({ state: "visible", timeout: uiTimeoutMs }); await dialogue .locator(".sls-info-note") .getByText( "Even compatible plug-in versions can create different Chunks for the same file. When files are rewritten, this can reduce Chunk reuse and increase storage use while preserving file contents. P2P synchronisation may also be unavailable between incompatible versions. Reviewing and aligning the plug-in versions across devices is recommended.", { exact: true } ) .waitFor({ state: "visible", timeout: uiTimeoutMs }); await dialogue .getByText("This remote is locked.", { exact: false }) .waitFor({ state: "visible", timeout: uiTimeoutMs }); await dialogue .getByText("This remote has been cleaned.", { exact: false }) .waitFor({ state: "visible", timeout: uiTimeoutMs }); } async function refreshRemotePlatform( page: Page, config: Awaited>, dbName: string ): Promise { const milestone = (await fetchCouchDbDocument(config, dbName, MILESTONE_DOCID)) as JoinedDevicesMilestone; await putCouchDbDocument(config, dbName, { ...milestone, node_info: { ...milestone.node_info, [targetId]: { ...milestone.node_info[targetId], platform: refreshedPlatform }, }, }); const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs); await dialogue.getByRole("button", { name: "Refresh", exact: true }).click({ timeout: uiTimeoutMs }); await deviceCard(dialogue, targetId).getByText(refreshedPlatform, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs, }); } async function showForgetConfirmation(dialogue: Locator): Promise { const targetCard = deviceCard(dialogue, targetId); await targetCard.getByRole("button", { name: "Forget device", exact: true }).click({ timeout: uiTimeoutMs }); await targetCard.getByText("Forget this device record?", { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs, }); } async function confirmForgetTarget(dialogue: Locator): Promise { const targetCard = deviceCard(dialogue, targetId); await targetCard.getByRole("button", { name: "Forget device", exact: true }).click({ timeout: uiTimeoutMs }); await dialogue.getByText("The device record was forgotten.", { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs, }); await targetCard.waitFor({ state: "detached", timeout: uiTimeoutMs }); } async function assertTargetForgotten( config: Awaited>, dbName: string, fixture: DeviceFixture ): Promise { const milestone = (await fetchCouchDbDocument(config, dbName, MILESTONE_DOCID)) as JoinedDevicesMilestone; assertEqual( JSON.stringify(milestone.accepted_nodes), JSON.stringify([retainedId]), "Forgetting changed the wrong accepted device records." ); assertEqual( JSON.stringify(Object.keys(milestone.node_info)), JSON.stringify([retainedId]), "Forgetting changed the wrong device metadata records." ); assertEqual( JSON.stringify(Object.keys(milestone.node_chunk_info)), JSON.stringify([retainedId]), "Forgetting changed the wrong chunk metadata records." ); assertEqual( JSON.stringify(Object.keys(milestone.tweak_values).sort()), JSON.stringify([DEVICE_ID_PREFERRED, retainedId].sort()), "Forgetting changed the wrong tweak metadata records." ); assertEqual(milestone.locked, true, "Forgetting changed the remote lock state."); assertEqual(milestone.cleaned, true, "Forgetting changed the remote cleaned state."); assertEqual( JSON.stringify(milestone.tweak_values[DEVICE_ID_PREFERRED]), JSON.stringify(fixture.preferredTweaks), "Forgetting changed the preferred-ID tweak record." ); assertEqual( JSON.stringify(milestone.node_info[retainedId]), JSON.stringify(fixture.milestone.node_info[retainedId]), "Forgetting changed the retained device metadata." ); } async function assertScrollableMobileDialogue(page: Page, container: Locator): Promise { const dialogue = container.locator(".modal").last(); const closeButton = container.locator(".modal-close-button, .modal-header-button").last(); await assertLocatorWithinViewport(page, dialogue, { label: "Joined devices dialogue" }); await assertNoHorizontalOverflow(page, dialogue, { label: "Joined devices dialogue" }); await assertLocatorWithinSafeArea(page, dialogue, { label: "Joined devices dialogue", safeAreaInsets: iPhoneSafeArea, }); const buttons = [closeButton, ...(await dialogue.locator("button:visible").all())]; for (const button of buttons) { // Device records form a scrollable list; every control must be reachable after scrolling. await button.scrollIntoViewIfNeeded(); const label = (await button.innerText()).trim() || "Close dialogue"; await assertLocatorWithinViewport(page, button, { label }); await assertLocatorWithinSafeArea(page, button, { label, safeAreaInsets: iPhoneSafeArea }); await assertNoHorizontalOverflow(page, button, { label }); await assertLocatorHasMinimumTouchTarget(page, button, { label }); } await container.locator(".modal-content").evaluate((element) => element.scrollTo(0, 0)); } async function dismissNotices(page: Page): Promise { const notices = page.locator(".notice:visible"); for (let remaining = 20; remaining > 0 && (await notices.count()) > 0; remaining--) { const notice = await notices.first().elementHandle(); if (!notice) continue; try { await notice.click({ position: { x: 20, y: 20 }, timeout: uiTimeoutMs }); await notice.waitForElementState("hidden", { timeout: uiTimeoutMs }); } finally { await notice.dispose(); } } assertEqual(await notices.count(), 0, "Notices still obscured the dialogue screenshot."); } async function captureJoinedDevices( port: number, filename: string, assertReady: (page: Page) => Promise ): Promise { const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e"; await mkdir(outputDirectory, { recursive: true }); await withObsidianPage(port, async (page) => { try { const dialogue = await assertReady(page); await dialogue.page().screenshot({ path: join(outputDirectory, filename), fullPage: true }); } catch (error) { const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", 500).catch(() => undefined); const failurePath = join(outputDirectory, filename.replace(/\.png$/u, ".failure.png")); await (dialogue?.page() ?? page).screenshot({ path: failurePath, fullPage: true }).catch(() => undefined); console.error(`Joined devices UI failure screenshot: ${failurePath}`); throw error; } }); } 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(", ")}`); const couchDb = await loadCouchDbConfig(); const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "joined-devices"); const vault = await createTemporaryVault(); let session: ObsidianLiveSyncSession | undefined; try { await assertCouchDbReachable(couchDb); await createCouchDbDatabase(couchDb, dbName); session = await startObsidianLiveSyncSession({ binary, cliBinary: cli.binary, vault, startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), pluginData: createE2eCouchDbPluginData({ uri: couchDb.uri, username: couchDb.username, password: couchDb.password, dbName, }), }); await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); await assertE2eCompatibilityUnpaused(cli.binary, session.cliEnv, session.remoteDebuggingPort); await configureCouchDb(cli.binary, session.cliEnv, { uri: couchDb.uri, username: couchDb.username, password: couchDb.password, dbName, }); await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); await prepareRemote(cli.binary, session.cliEnv); const self = await readSelfDeviceInfo(cli.binary, session.cliEnv); const fixture = createFixture(self); const preparedMilestone = await fetchCouchDbDocument(couchDb, dbName, MILESTONE_DOCID); assert(Boolean(preparedMilestone._rev), "Remote preparation did not create a revisioned milestone document."); await putCouchDbDocument(couchDb, dbName, { ...fixture.milestone, _rev: preparedMilestone._rev }); await captureJoinedDevices(session.remoteDebuggingPort, "joined-devices-notice.png", async (page) => { const dialogue = await openFromRemoteSettings(page); const dialoguePage = dialogue.page(); await dialoguePage.setViewportSize({ width: 1280, height: 1100 }); await assertInitialDialogue(dialogue, self, dialoguePage); await dialoguePage .locator(".notice") .getByText( "A newer plug-in version was detected in other device records: 999.0.0. Reviewing and aligning the plug-in versions across devices is recommended.", { exact: true } ) .waitFor({ state: "visible", timeout: uiTimeoutMs }); return dialogue; }); await captureJoinedDevices(session.remoteDebuggingPort, "joined-devices-desktop.png", async (page) => { const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs); await dialogue.getByRole("button", { name: "Close", exact: true }).click({ timeout: uiTimeoutMs }); await dialogue .page() .locator(".notice") .getByRole("button", { name: "Review devices", exact: true }) .first() .click(); const reopened = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs); await reopened.getByText(targetId, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs }); await dismissNotices(reopened.page()); return reopened; }); await captureJoinedDevices( session.remoteDebuggingPort, "joined-devices-forget-confirmation.png", async (page) => { await refreshRemotePlatform(page, couchDb, dbName); const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs); await showForgetConfirmation(dialogue); await deviceCard(dialogue, targetId).locator(".forget-confirmation").scrollIntoViewIfNeeded(); return dialogue; } ); await captureJoinedDevices(session.remoteDebuggingPort, "joined-devices-after-forget.png", async (page) => { const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs); await confirmForgetTarget(dialogue); await dialogue.locator(".modal-content").evaluate((element) => element.scrollTo(0, 0)); return dialogue; }); await withObsidianPage(session.remoteDebuggingPort, async (page) => { const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs); await dialogue.getByRole("button", { name: "Close", exact: true }).click({ timeout: uiTimeoutMs }); const settings = await openLiveSyncSettings(page, uiTimeoutMs); await settings.close(); }); await assertTargetForgotten(couchDb, dbName, fixture); await setObsidianMobileTestMode(session.remoteDebuggingPort, true, uiTimeoutMs); await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); await captureJoinedDevices(session.remoteDebuggingPort, "joined-devices-mobile.png", async (page) => { const opened = await page.evaluate((id) => { const app = ( window as typeof window & { app?: { commands?: { executeCommandById?(commandId: string): boolean } }; } ).app; return app?.commands?.executeCommandById?.(id) === true; }, commandId); assert(opened, "The Joined devices command did not open the dialogue."); const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs); await dialogue.getByText(retainedId, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs }); await assertScrollableMobileDialogue(dialogue.page(), dialogue); assertEqual( await dialogue.locator(".device-card").count(), 2, "The refreshed mobile list did not retain both remaining devices." ); await dismissNotices(dialogue.page()); return dialogue; }); console.log(`Joined devices scenario passed against CouchDB database ${dbName}.`); } finally { if (session) await session.app.stop(); await vault.dispose(); if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") { await deleteCouchDbDatabase(couchDb, dbName).catch((error: unknown) => { console.warn(error instanceof Error ? error.message : error); }); } } } main().catch((error: unknown) => { console.error(error instanceof Error ? error.stack : error); process.exit(1); });