import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.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, captureObsidianElement, captureObsidianPage, obsidianRemoteDebuggingPort, withObsidianPage, } from "../runner/ui.ts"; import { createTemporaryVault } from "../runner/vault.ts"; const dialogRunStateKey = "__livesyncE2EDialogMount"; const repairRunStateKey = "__livesyncE2ETroubleshootingRepair"; const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_DIALOG_TIMEOUT_MS ?? 10000); type DialogueMode = "desktop" | "mobile"; type DialogueRunState = { done: boolean; error?: string; expected?: unknown; kind: string; result?: unknown; }; type SetupManagerHandle = { constructor: { name: string }; onSelectServer?: (settings: unknown, remoteType: string) => Promise; _askUseRemoteConfiguration?: (settings: unknown, preferred: unknown) => Promise; _checkAndAskResolvingMismatchedTweaks?: (preferred: unknown) => Promise; __addLog?: (message: string) => void; }; type LiveSyncTestPlugin = { core: { fileHandler: { createAllChunks(force: boolean): Promise; }; modules: SetupManagerHandle[]; settings: Record; }; }; type ObsidianSettingsController = { open(): void; openTabById(tabId: string): void; }; type ObsidianVaultFile = { path: string; }; type ObsidianTestApp = { commands?: { executeCommandById(commandId: string): boolean }; plugins?: { plugins: Record }; setting?: ObsidianSettingsController; vault?: { delete(file: ObsidianVaultFile, force: boolean): Promise; getFiles(): ObsidianVaultFile[]; read(file: ObsidianVaultFile): Promise; }; }; type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp }; async function openRemoteSelectionDialogue(): Promise { await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { await page.evaluate((stateKey) => { const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded"); const manager = plugin.core.modules.find((module) => module.constructor.name === "SetupManager"); if (typeof manager?.onSelectServer !== "function") throw new Error("Could not find SetupManager"); const state: DialogueRunState = { kind: "remote-selection", done: false }; (globalThis as unknown as Record)[stateKey] = state; void manager.onSelectServer(plugin.core.settings, "unknown").then( (result) => { state.result = result; state.done = true; }, (error: unknown) => { state.error = error instanceof Error ? error.message : String(error); state.done = true; } ); }, dialogRunStateKey); }); } async function openSetupUriDialogue(): Promise { const opened = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { return await page.evaluate( (commandId) => (globalThis as ObsidianTestGlobal).app?.commands?.executeCommandById(commandId) === true, "obsidian-livesync:livesync-opensetupuri" ); }); if (!opened) { throw new Error("The Setup URI command was not registered or could not be executed."); } } async function openConfigurationMismatchDialogue( kind: "connected" | "connected-rebuild-recommended" | "remote-configuration" ): Promise { await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { await page.evaluate( ({ stateKey, kind }) => { const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded"); const resolver = plugin.core.modules.find( (module) => module.constructor.name === "ModuleResolvingMismatchedTweaks" ); if (resolver === undefined) throw new Error("Could not find ModuleResolvingMismatchedTweaks"); if (kind === "connected-rebuild-recommended") { plugin.core.settings.autoAcceptCompatibleTweak = false; } const preferred = kind === "connected-rebuild-recommended" ? { ...plugin.core.settings, hashAlg: plugin.core.settings.hashAlg === "xxhash32" ? "xxhash64" : "xxhash32", } : { ...plugin.core.settings, enableCompression: !Boolean(plugin.core.settings.enableCompression), }; const state: DialogueRunState = { kind: `configuration-mismatch-${kind}`, done: false, expected: preferred, }; (globalThis as unknown as Record)[stateKey] = state; const operation = kind === "remote-configuration" ? resolver._askUseRemoteConfiguration?.(plugin.core.settings, preferred) : resolver._checkAndAskResolvingMismatchedTweaks?.(preferred); if (operation === undefined) { throw new Error(`The configuration mismatch resolver does not support ${kind}.`); } void operation.then( (result) => { state.result = result; state.done = true; }, (error: unknown) => { state.error = error instanceof Error ? error.message : String(error); state.done = true; } ); }, { stateKey: dialogRunStateKey, kind } ); }); } async function assertDialogueRunCompleted(): Promise { const state = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { await page.waitForFunction( (stateKey) => (globalThis as unknown as Record)[stateKey]?.done === true, dialogRunStateKey, { timeout: uiTimeoutMs } ); return await page.evaluate( (stateKey) => (globalThis as unknown as Record)[stateKey], dialogRunStateKey ); }); if (!state) { throw new Error("The mounted dialogue did not record its completion state."); } if (state.error) { throw new Error(`The mounted dialogue failed: ${state.error}`); } return state; } async function verifyRemoteSizeNoticeAndDialogue(): Promise<{ compatibilityReview: string; notice: string; dialogue: string; }> { const compatibilityReviewScreenshot = await captureObsidianDialogue( obsidianRemoteDebuggingPort(), "compatibility-review-dialogue.png", async (page) => { const compatibilityReview = page.locator(".modal-container").filter({ has: page .locator(".modal-title") .filter({ hasText: "Synchronisation paused for compatibility review" }), }); await compatibilityReview.waitFor({ state: "visible", timeout: uiTimeoutMs }); const actions = compatibilityReview.locator(".vpk-action-dialog__actions--vertical"); await actions.waitFor({ state: "visible", timeout: uiTimeoutMs }); const flexDirection = await actions.evaluate((element) => getComputedStyle(element).flexDirection); if (flexDirection !== "column") { throw new Error(`Expected vertically stacked compatibility actions, received ${flexDirection}.`); } } ); await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { const compatibilityReview = page.locator(".modal-container").filter({ has: page.locator(".modal-title").filter({ hasText: "Synchronisation paused for compatibility review" }), }); await compatibilityReview .getByRole("button", { name: "Keep synchronisation paused" }) .click({ timeout: uiTimeoutMs }); await compatibilityReview.waitFor({ state: "hidden", timeout: uiTimeoutMs }); }); const noticeScreenshot = await captureObsidianDialogue( obsidianRemoteDebuggingPort(), "remote-size-startup-notice.png", async (page) => { const notice = page.locator(".notice").filter({ hasText: "Remote storage size notifications are not configured.", }); await notice.waitFor({ state: "visible", timeout: uiTimeoutMs }); await notice.getByRole("link", { name: "Review options" }).waitFor({ state: "visible", timeout: uiTimeoutMs, }); } ); await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { const notice = page.locator(".notice").filter({ hasText: "Remote storage size notifications are not configured.", }); await notice.getByRole("link", { name: "Review options" }).click({ timeout: uiTimeoutMs }); await notice.waitFor({ state: "hidden", timeout: uiTimeoutMs }); }); const dialogueScreenshot = await captureObsidianDialogue( obsidianRemoteDebuggingPort(), "remote-size-review-dialogue.png", async (page) => { const modal = page.locator(".modal-container").filter({ has: page.locator(".modal-title").filter({ hasText: "Setting up database size notification" }), }); await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); for (const action of [ "No, never warn please", "800MB (Cloudant, fly.io)", "2GB (Standard)", "Ask me later", ]) { await modal.getByRole("button", { name: action }).waitFor({ state: "visible", timeout: uiTimeoutMs }); } } ); await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { const modal = page.locator(".modal-container").filter({ has: page.locator(".modal-title").filter({ hasText: "Setting up database size notification" }), }); await modal.getByRole("button", { name: "Ask me later" }).click({ timeout: uiTimeoutMs }); await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); }); return { compatibilityReview: compatibilityReviewScreenshot, notice: noticeScreenshot, dialogue: dialogueScreenshot, }; } async function verifyRemoteSelectionDialogue(mode: DialogueMode): Promise { await openRemoteSelectionDialogue(); const screenshotPath = await captureObsidianDialogue( obsidianRemoteDebuggingPort(), `setup-remote-selection-dialogue${mode === "mobile" ? "-mobile" : ""}.png`, async (page) => { const modal = page.locator(".modal-container").filter({ has: page.locator(".modal-title").filter({ hasText: "Choose a synchronisation remote" }), }); const dialogue = modal.locator(".dialog-host"); await modal.waitFor({ state: "visible", timeout: uiTimeoutMs, }); for (const label of ["CouchDB", "S3-compatible Object Storage", "Peer-to-Peer (P2P)"]) { 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 }); if (mode === "mobile") { await assertMobileDialogueLayout(page, modal, "remote selection dialogue"); } } ); await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { const modal = page.locator(".modal-container").filter({ has: page.locator(".modal-title").filter({ hasText: "Choose a synchronisation remote" }), }); if (mode === "mobile") { const previousDialogue = await modal.locator(".modal").last().elementHandle(); if (previousDialogue === null) { throw new Error("The remote selection dialogue did not expose a close control."); } await modal.locator(".modal-close-button").click({ timeout: uiTimeoutMs }); await page.waitForFunction((element) => !element.isConnected, previousDialogue, { timeout: uiTimeoutMs }); await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); } await modal.getByRole("button", { name: "No, please take me back" }).click({ timeout: uiTimeoutMs }); }); await assertDialogueRunCompleted(); return screenshotPath; } async function verifyCouchDBSettingsDialogue(mode: DialogueMode): Promise { await openRemoteSelectionDialogue(); await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { const remoteSelection = page.locator(".modal-container").filter({ has: page.locator(".modal-title").filter({ hasText: "Choose a synchronisation remote" }), }); 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 { await openSetupUriDialogue(); const screenshotPath = await captureObsidianDialogue( obsidianRemoteDebuggingPort(), `setup-uri-dialogue${mode === "mobile" ? "-mobile" : ""}.png`, async (page) => { const modal = page.locator(".modal-container").filter({ has: page.locator(".modal-title").filter({ hasText: "Enter Setup URI" }), }); await modal.waitFor({ state: "visible", timeout: uiTimeoutMs, }); await modal .locator('input[placeholder^="obsidian://setuplivesync"]') .waitFor({ state: "visible", timeout: uiTimeoutMs }); await modal.locator('input[name="password"]').waitFor({ state: "visible", timeout: uiTimeoutMs }); await modal .getByRole("button", { name: "Test Settings and Continue" }) .waitFor({ state: "visible", timeout: uiTimeoutMs }); await modal.getByRole("button", { name: "Cancel" }).waitFor({ state: "visible", timeout: uiTimeoutMs }); if (mode === "mobile") { await assertMobileDialogueLayout(page, modal, "Setup URI dialogue"); } } ); await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { const modal = page.locator(".modal-container").filter({ has: page.locator(".modal-title").filter({ hasText: "Enter Setup URI" }), }); await modal.getByRole("button", { name: "Cancel" }).click({ timeout: uiTimeoutMs }); await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); }); return screenshotPath; } async function verifyCompatibleMismatchAutoAdjustment(): Promise { await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { await page.evaluate((stateKey) => { const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded"); const resolver = plugin.core.modules.find( (module) => module.constructor.name === "ModuleResolvingMismatchedTweaks" ); if (typeof resolver?._checkAndAskResolvingMismatchedTweaks !== "function") { throw new Error("Could not find the configuration mismatch resolver"); } plugin.core.settings.autoAcceptCompatibleTweak = undefined; const currentModified = typeof plugin.core.settings.tweakModified === "number" ? plugin.core.settings.tweakModified : 0; const preferred = { ...plugin.core.settings, hashAlg: plugin.core.settings.hashAlg === "xxhash32" ? "xxhash64" : "xxhash32", tweakModified: currentModified + 1, }; const state: DialogueRunState = { kind: "configuration-mismatch-compatible-auto-adjustment", done: false, expected: preferred, }; (globalThis as unknown as Record)[stateKey] = state; void resolver._checkAndAskResolvingMismatchedTweaks(preferred).then( (result) => { state.result = result; state.done = true; }, (error: unknown) => { state.error = error instanceof Error ? error.message : String(error); state.done = true; } ); }, dialogRunStateKey); }); const state = await assertDialogueRunCompleted(); if (!Array.isArray(state.result) || state.result.length !== 2) { throw new Error("The compatible mismatch did not return its settings and rebuild decision."); } const [appliedSettings, shouldRebuild] = state.result; const expectedSettings = state.expected; if ( typeof appliedSettings !== "object" || appliedSettings === null || typeof expectedSettings !== "object" || expectedSettings === null || !("hashAlg" in appliedSettings) || !("hashAlg" in expectedSettings) || appliedSettings.hashAlg !== expectedSettings.hashAlg || shouldRebuild !== false ) { throw new Error("The compatible mismatch was not adjusted to the newer setting without a rebuild."); } await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { const autoAcceptEnabled = await page.evaluate(() => { const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; return plugin?.core.settings.autoAcceptCompatibleTweak; }); if (autoAcceptEnabled !== true) { throw new Error("Compatible mismatch auto-adjustment was not persisted as the default."); } for (const title of ["Auto-Accept Available", "Configuration Mismatch Detected"]) { const dialogue = page.locator(".modal-container").filter({ has: page.locator(".modal-title").filter({ hasText: title }), }); if ((await dialogue.count()) !== 0) { throw new Error(`Compatible mismatch auto-adjustment unexpectedly opened '${title}'.`); } } }); } async function verifyCompatibleAlignmentSettingDefault(): Promise { await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { const persistedValue = await page.evaluate(() => { const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded"); return plugin.core.settings.autoAcceptCompatibleTweak; }); if (persistedValue !== undefined) { throw new Error( `The default-display fixture expected an undefined preference, received ${persistedValue}.` ); } await page.evaluate(() => { const setting = (globalThis as ObsidianTestGlobal).app?.setting; if (setting === undefined) throw new Error("Obsidian settings are unavailable"); setting.open(); setting.openTabById("obsidian-livesync"); }); const liveSyncSettings = page.locator(".sls-setting"); await liveSyncSettings.waitFor({ state: "visible", timeout: uiTimeoutMs }); await liveSyncSettings.locator('.sls-setting-menu-btn[title="Advanced"]').click({ timeout: uiTimeoutMs }); const settingItem = liveSyncSettings.locator(".setting-item").filter({ has: page.getByText("Auto-accept compatible tweak mismatches", { exact: true }), }); await settingItem.waitFor({ state: "visible", timeout: uiTimeoutMs }); const toggle = settingItem.locator(".checkbox-container"); if (!(await toggle.evaluate((element) => element.classList.contains("is-enabled")))) { throw new Error("The automatic compatible-setting policy was displayed as disabled while still undefined."); } }); } async function verifyConfigurationMismatchDialogues(): Promise<{ general: string; fetch: string }> { await verifyCompatibleMismatchAutoAdjustment(); await openConfigurationMismatchDialogue("remote-configuration"); await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { const modal = page.locator(".modal-container").filter({ has: page.locator(".modal-title").filter({ hasText: "Use Remote Configuration" }), }); await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); await modal .getByRole("button", { name: "Use configured settings", exact: true }) .waitFor({ state: "visible", timeout: uiTimeoutMs }); await modal.getByRole("button", { name: "Dismiss", exact: true }).click({ timeout: uiTimeoutMs }); await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); }); await assertDialogueRunCompleted(); await openConfigurationMismatchDialogue("connected"); const generalScreenshotPath = await captureObsidianElement( obsidianRemoteDebuggingPort(), "troubleshooting-configuration-mismatch-dialogue.png", async (page) => { const container = page.locator(".modal-container").filter({ has: page.locator(".modal-title").filter({ hasText: "Configuration Mismatch Detected" }), }); const modal = container.locator(".modal").last(); await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); for (const action of ["Apply settings to this device", "Update remote database settings"]) { await modal .getByRole("button", { name: action, exact: true }) .waitFor({ state: "visible", timeout: uiTimeoutMs }); } await modal.getByRole("button", { name: /Dismiss$/u }).waitFor({ state: "visible", timeout: uiTimeoutMs, }); for (const retiredAction of ["Use configured", "Update with mine"]) { if ((await modal.getByRole("button", { name: retiredAction, exact: true }).count()) !== 0) { throw new Error(`The mismatch dialogue still exposes the retired action '${retiredAction}'.`); } } const actions = modal.locator(".setting-item-control").last(); const flexDirection = await actions.evaluate((element) => getComputedStyle(element).flexDirection); if (flexDirection !== "column") { throw new Error(`Expected vertically stacked mismatch actions, received ${flexDirection}.`); } return modal; } ); await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { const modal = page.locator(".modal-container").filter({ has: page.locator(".modal-title").filter({ hasText: "Configuration Mismatch Detected" }), }); await modal.getByRole("button", { name: /Dismiss$/u }).click({ timeout: uiTimeoutMs }); await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); }); await assertDialogueRunCompleted(); await openConfigurationMismatchDialogue("connected-rebuild-recommended"); const fetchScreenshotPath = await captureObsidianElement( obsidianRemoteDebuggingPort(), "troubleshooting-configuration-mismatch-fetch-dialogue.png", async (page) => { const container = page.locator(".modal-container").filter({ has: page.locator(".modal-title").filter({ hasText: "Configuration Mismatch Detected" }), }); const modal = container.locator(".modal").last(); await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); await modal .getByRole("button", { name: "Apply settings to this device, and fetch again", exact: true }) .waitFor({ state: "visible", timeout: uiTimeoutMs }); return modal; } ); await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { const modal = page.locator(".modal-container").filter({ has: page.locator(".modal-title").filter({ hasText: "Configuration Mismatch Detected" }), }); await modal .getByRole("button", { name: "Apply settings to this device, and fetch again", exact: true }) .click({ timeout: uiTimeoutMs }); await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); }); const fetchResult = await assertDialogueRunCompleted(); if (!Array.isArray(fetchResult.result) || fetchResult.result.length !== 2) { throw new Error("The configuration-mismatch Fetch action did not return its settings and Fetch decision."); } const [appliedSettings, shouldFetch] = fetchResult.result; const expectedSettings = fetchResult.expected; if ( typeof appliedSettings !== "object" || appliedSettings === null || typeof expectedSettings !== "object" || expectedSettings === null || !("hashAlg" in appliedSettings) || !("hashAlg" in expectedSettings) || appliedSettings.hashAlg !== expectedSettings.hashAlg || shouldFetch !== true ) { throw new Error("The configuration-mismatch Fetch action did not apply the remote setting before Fetch."); } return { general: generalScreenshotPath, fetch: fetchScreenshotPath }; } async function executeRegisteredCommand(commandId: string): Promise { const opened = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { return await page.evaluate( (id) => (globalThis as ObsidianTestGlobal).app?.commands?.executeCommandById(id) === true, commandId ); }); if (!opened) { throw new Error(`The command was not registered or could not be executed: ${commandId}`); } } async function verifyLogAndReportSurfaces(): Promise<{ log: string; report: string }> { await executeRegisteredCommand("obsidian-livesync:view-log"); const logScreenshot = await captureObsidianElement( obsidianRemoteDebuggingPort(), "troubleshooting-show-log.png", async (page) => { const logPane = page.locator(".logpane"); await logPane.waitFor({ state: "visible", timeout: uiTimeoutMs }); for (const label of ["Wrap", "Auto scroll", "Pause"]) { await logPane.getByText(label, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs }); } await logPane.getByRole("button", { name: "Close", exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs, }); return logPane; } ); await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { const logPane = page.locator(".logpane"); await logPane.getByRole("button", { name: "Close", exact: true }).click({ timeout: uiTimeoutMs }); await logPane.waitFor({ state: "hidden", timeout: uiTimeoutMs }); }); await executeRegisteredCommand("obsidian-livesync:dump-debug-info"); const reportScreenshot = await captureObsidianElement( obsidianRemoteDebuggingPort(), "troubleshooting-full-report.png", async (page) => { const modal = page.locator(".modal-container").filter({ hasText: "Your Debug info is ready to be copied", }); await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); const report = await modal.locator("textarea").inputValue({ timeout: uiTimeoutMs }); if (!report.includes("# ---- Debug Info Dump ----")) { throw new Error("The full-report dialogue did not contain the generated debug report."); } await modal.getByRole("button", { name: "OK", exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs, }); return modal.locator(".modal").last(); } ); await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { const modal = page.locator(".modal-container").filter({ hasText: "Your Debug info is ready to be copied", }); await modal.getByRole("button", { name: "OK", exact: true }).click({ timeout: uiTimeoutMs }); await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); }); return { log: logScreenshot, report: reportScreenshot }; } async function verifyHatchSurfacesAndSafeActions(): Promise { const screenshotPath = await captureObsidianElement( obsidianRemoteDebuggingPort(), "troubleshooting-hatch.png", async (page) => { await page.evaluate(() => { const setting = (globalThis as ObsidianTestGlobal).app?.setting; if (setting === undefined) throw new Error("Obsidian settings are unavailable"); setting.open(); setting.openTabById("obsidian-livesync"); }); const liveSyncSettings = page.locator(".sls-setting"); await liveSyncSettings.waitFor({ state: "visible", timeout: uiTimeoutMs }); await liveSyncSettings.locator('.sls-setting-menu-btn[title="Hatch"]').click({ timeout: uiTimeoutMs }); for (const label of [ "Write logs into the file", "Recreate chunks for current Vault files", "Verify and repair all files", ]) { await liveSyncSettings.locator(".setting-item-name", { hasText: label }).waitFor({ state: "visible", timeout: uiTimeoutMs, }); } await liveSyncSettings.getByRole("button", { name: "Recreate current chunks", exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs, }); await liveSyncSettings.getByRole("button", { name: "Verify all", exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs, }); await liveSyncSettings .locator(".setting-item-name", { hasText: "Recreate chunks for current Vault files" }) .scrollIntoViewIfNeeded(); return liveSyncSettings; } ); await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { const liveSyncSettings = page.locator(".sls-setting"); const logSetting = liveSyncSettings.locator(".setting-item").filter({ has: page.getByText("Write logs into the file", { exact: true }), }); await logSetting.locator(".checkbox-container").click({ timeout: uiTimeoutMs }); await page.waitForFunction( () => { const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; return plugin?.core.settings.writeLogToTheFile === true; }, undefined, { timeout: uiTimeoutMs } ); const persistentLogMarker = "E2E persistent troubleshooting log"; await page.evaluate((marker) => { const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded"); const module = plugin.core.modules.find((candidate) => candidate.constructor.name === "ModuleLog"); if (typeof module?.__addLog !== "function") throw new Error("Could not find ModuleLog"); module.__addLog(marker); }, persistentLogMarker); await page.waitForFunction( async (marker) => { const vault = (globalThis as ObsidianTestGlobal).app?.vault; if (vault === undefined) return false; const logFile = vault.getFiles().find((file) => file.path.startsWith("livesync_log_")); if (logFile === undefined) return false; return (await vault.read(logFile)).includes(marker); }, persistentLogMarker, { timeout: uiTimeoutMs } ); // Saving a toggle refreshes the settings pane. Resolve the visible control again so the // second action does not target the detached pre-save element. const refreshedLogToggle = page .locator(".sls-setting:visible .setting-item:visible") .filter({ has: page.getByText("Write logs into the file", { exact: true }), }) .locator(".checkbox-container:visible") .last(); await refreshedLogToggle.click({ timeout: uiTimeoutMs }); await page.waitForFunction( () => { const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; return plugin?.core.settings.writeLogToTheFile === false; }, undefined, { timeout: uiTimeoutMs } ); await page.evaluate(async () => { const vault = (globalThis as ObsidianTestGlobal).app?.vault; if (vault === undefined) throw new Error("Obsidian Vault is unavailable"); const logFile = vault.getFiles().find((file) => file.path.startsWith("livesync_log_")); if (logFile === undefined) throw new Error("The persistent troubleshooting log was not created"); await vault.delete(logFile, true); }); await page.waitForFunction( () => !(globalThis as ObsidianTestGlobal).app?.vault ?.getFiles() .some((file) => file.path.startsWith("livesync_log_")), undefined, { timeout: uiTimeoutMs } ); await page.evaluate((stateKey) => { const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded"); const original = plugin.core.fileHandler.createAllChunks.bind(plugin.core.fileHandler); const state: DialogueRunState = { kind: "recreate-missing-chunks", done: false }; (globalThis as unknown as Record)[stateKey] = state; plugin.core.fileHandler.createAllChunks = async (force) => { try { state.result = await original(force); } catch (error) { state.error = error instanceof Error ? error.message : String(error); } finally { state.done = true; plugin.core.fileHandler.createAllChunks = original; } }; }, repairRunStateKey); await liveSyncSettings.getByRole("button", { name: "Recreate all", exact: true }).click({ timeout: uiTimeoutMs, }); await page.waitForFunction( (stateKey) => (globalThis as unknown as Record)[stateKey]?.done === true, repairRunStateKey, { timeout: uiTimeoutMs } ); const repairState = await page.evaluate( (stateKey) => (globalThis as unknown as Record)[stateKey], repairRunStateKey ); if (repairState?.error) { throw new Error(`Recreate missing chunks failed: ${repairState.error}`); } await liveSyncSettings.getByRole("button", { name: "Verify all", exact: true }).click({ timeout: uiTimeoutMs, }); await page .locator(".notice") .filter({ hasText: /^done$/u }) .waitFor({ state: "visible", timeout: uiTimeoutMs, }); }); return screenshotPath; } async function verifyMobileStartupReviews(): Promise<{ compatibilityReview: string; remoteSizeReview: string }> { const compatibilityReviewScreenshot = await captureObsidianDialogue( obsidianRemoteDebuggingPort(), "compatibility-review-dialogue-mobile.png", async (page) => { const modal = page.locator(".modal-container").filter({ has: page .locator(".modal-title") .filter({ hasText: "Synchronisation paused for compatibility review" }), }); await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); await modal.locator(".vpk-action-dialog__actions--vertical").waitFor({ state: "visible", timeout: uiTimeoutMs, }); await assertMobileDialogueLayout(page, modal, "compatibility review dialogue"); } ); await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { const modal = page.locator(".modal-container").filter({ has: page.locator(".modal-title").filter({ hasText: "Synchronisation paused for compatibility review" }), }); await modal.getByRole("button", { name: "Keep synchronisation paused" }).click({ timeout: uiTimeoutMs }); await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); const compatibilityReminder = page.locator(".livesync-compatibility-review-notice"); await compatibilityReminder.waitFor({ state: "visible", timeout: uiTimeoutMs }); await assertMobileNoticeLayout(page, compatibilityReminder, "compatibility review reminder"); const notice = page.locator(".notice").filter({ hasText: "Remote storage size notifications are not configured.", }); await notice.getByRole("link", { name: "Review options" }).click({ timeout: uiTimeoutMs }); await notice.waitFor({ state: "hidden", timeout: uiTimeoutMs }); }); const remoteSizeReviewScreenshot = await captureObsidianDialogue( obsidianRemoteDebuggingPort(), "remote-size-review-dialogue-mobile.png", async (page) => { const modal = page.locator(".modal-container").filter({ has: page.locator(".modal-title").filter({ hasText: "Setting up database size notification" }), }); await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); await assertMobileDialogueLayout(page, modal, "remote size review dialogue"); } ); await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { const modal = page.locator(".modal-container").filter({ has: page.locator(".modal-title").filter({ hasText: "Setting up database size notification" }), }); await modal.getByRole("button", { name: "Ask me later" }).click({ timeout: uiTimeoutMs }); await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); }); return { compatibilityReview: compatibilityReviewScreenshot, remoteSizeReview: remoteSizeReviewScreenshot, }; } 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 vault = await createTemporaryVault(); let session: ObsidianLiveSyncSession | undefined; try { session = await startObsidianLiveSyncSession({ binary, cliBinary: cli.binary, vault, startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), pluginData: createE2eCouchDbPluginData( { uri: "http://127.0.0.1:5984", username: "", password: "", dbName: "dialog-mounts-ui-only", }, { notifyThresholdOfRemoteStorageSize: -1, syncOnStart: false, syncOnSave: false, syncOnEditorSave: false, syncOnFileOpen: false, syncAfterMerge: false, periodicReplication: false, useAdvancedMode: true, } ), }); 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; 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 | undefined) ?? {} ).length, })}` ); throw error; } const remoteSizeScreenshots = await verifyRemoteSizeNoticeAndDialogue(); console.log( `Compatibility review actions were stacked vertically, and the remote-size startup notice opened an untimed review dialogue successfully. Screenshots: ${remoteSizeScreenshots.compatibilityReview}, ${remoteSizeScreenshots.notice}, ${remoteSizeScreenshots.dialogue}` ); 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}`); await verifyCompatibleAlignmentSettingDefault(); console.log("The undefined compatible-setting preference is displayed with its effective enabled default."); const mismatchScreenshots = await verifyConfigurationMismatchDialogues(); console.log( `A mismatch limited to compatible chunk settings was adjusted without a dialogue, current manual mismatch actions mounted successfully, and the Fetch action applied the remote setting before scheduling Fetch. Screenshots: ${mismatchScreenshots.general}, ${mismatchScreenshots.fetch}` ); const troubleshootingScreenshots = await verifyLogAndReportSurfaces(); console.log( `Show log and the generated full-report dialogue were reached through their registered commands. Screenshots: ${troubleshootingScreenshots.log}, ${troubleshootingScreenshots.report}` ); const hatchScreenshot = await verifyHatchSurfacesAndSafeActions(); console.log( `Hatch repair controls were reachable, safe empty-fixture runs completed, and persistent logging was enabled, verified, disabled, and removed. Screenshot: ${hatchScreenshot}` ); await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), true, uiTimeoutMs); try { const mobileStartupScreenshots = await verifyMobileStartupReviews(); console.log( `Mobile compatibility and remote-size reviews passed viewport, safe-area, touch-target, and vertical-action checks. Screenshots: ${mobileStartupScreenshots.compatibilityReview}, ${mobileStartupScreenshots.remoteSizeReview}` ); const mobileRemoteScreenshot = await verifyRemoteSelectionDialogue("mobile"); 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}` ); } finally { await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), false, uiTimeoutMs); } } finally { if (session) { await session.app.stop(); } await vault.dispose(); } } main().catch((error: unknown) => { console.error(error instanceof Error ? error.stack : error); process.exit(1); });