From c7d1f9352db21af31c1af4440ab95b6d205cd502 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sun, 2 Aug 2026 07:41:53 +0000 Subject: [PATCH] test(obsidian): verify registered remote setup profiles --- package.json | 1 + test/e2e-obsidian/runner/remoteSetupUi.ts | 330 +++++++++++ test/e2e-obsidian/scripts/local-suite.ts | 1 + .../scripts/remote-setup-providers.ts | 524 ++++++++++++++++++ test/e2e-obsidian/scripts/run-focused.ts | 1 + 5 files changed, 857 insertions(+) create mode 100644 test/e2e-obsidian/runner/remoteSetupUi.ts create mode 100644 test/e2e-obsidian/scripts/remote-setup-providers.ts diff --git a/package.json b/package.json index 6967a252..2219d520 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "test:e2e:obsidian:revision-repair": "tsx test/e2e-obsidian/scripts/revision-repair.ts", "test:e2e:obsidian:document-history-nav": "tsx test/e2e-obsidian/scripts/document-history-nav.ts", "test:e2e:obsidian:settings-ui": "tsx test/e2e-obsidian/scripts/settings-ui.ts", + "test:e2e:obsidian:remote-setup-providers": "tsx test/e2e-obsidian/scripts/remote-setup-providers.ts", "test:e2e:obsidian:review-harness": "tsx test/e2e-obsidian/scripts/review-harness.ts", "test:e2e:obsidian:p2p-pane": "tsx test/e2e-obsidian/scripts/p2p-pane.ts", "test:e2e:obsidian:vault-reflection": "tsx test/e2e-obsidian/scripts/vault-reflection.ts", diff --git a/test/e2e-obsidian/runner/remoteSetupUi.ts b/test/e2e-obsidian/runner/remoteSetupUi.ts new file mode 100644 index 00000000..3dad1233 --- /dev/null +++ b/test/e2e-obsidian/runner/remoteSetupUi.ts @@ -0,0 +1,330 @@ +import type { Locator, Page } from "playwright"; +import { captureObsidianDialogue, withObsidianPage } from "./ui.ts"; + +const remoteSetupStateKey = "__livesyncE2ERemoteSetup"; + +export type RemoteInspectionMode = "failed" | "verified"; + +export type RemoteSetupCall = { + journalFormat: string; + operation: "create" | "inspect" | "test"; + remoteType: string; +}; + +type RemoteSetupBrowserState = { + calls: RemoteSetupCall[]; + inspectionMode: RemoteInspectionMode; + nextProfileName: string; + unregister?: () => void; +}; + +type RuntimeRemoteConfiguration = { + id: string; + isEncrypted: boolean; + name: string; + uri: string; +}; + +type RuntimeSettings = { + activeConfigurationId: string; + remoteConfigurations: Record; +}; + +type RuntimePlugin = { + core: { + services: { + replicator: { + getNewReplicator: { + addHandler: (...args: unknown[]) => () => void; + } & ((settingOverride?: Record) => Promise); + }; + setting: { + currentSettings(): RuntimeSettings; + }; + UI: { + confirm: { + askString( + title: string, + label: string, + placeholder: string, + isPassword?: boolean + ): Promise; + }; + }; + }; + }; +}; + +type RuntimeSettingsController = { + close(): void; + open(): void; + openTabById(tabId: string): void; +}; + +type RuntimeApp = { + plugins?: { plugins: Record }; + setting?: RuntimeSettingsController; +}; + +type RuntimeGlobal = typeof globalThis & { + app?: RuntimeApp; + [remoteSetupStateKey]?: RemoteSetupBrowserState; +}; + +export function remoteSelectionModal(page: Page): Locator { + return page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Choose a synchronisation remote" }), + }); +} + +export function remoteProviderModal(page: Page, title: string): Locator { + return page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: title }), + }); +} + +export function remoteConfigurationPanel(page: Page): Locator { + return page + .locator(".sls-setting h4.sls-setting-panel-title") + .filter({ hasText: "Connection settings" }) + .locator(".."); +} + +function remoteProfileRow(page: Page, profileName: string): Locator { + return remoteConfigurationPanel(page).locator(".sls-remote-list .setting-item").filter({ hasText: profileName }); +} + +async function tooltipButton(container: Locator, label: string, fallbackText: string): Promise { + const labelled = container.locator(`button[aria-label="${label}"], button[title="${label}"]`); + if ((await labelled.count()) > 0) return labelled.first(); + return container.locator("button").filter({ hasText: fallbackText }).first(); +} + +export async function installRemoteSetupTestSeam(port: number): Promise { + await withObsidianPage(port, async (page) => { + await page.evaluate((stateKey) => { + const runtime = globalThis as RuntimeGlobal; + const plugin = runtime.app?.plugins?.plugins["obsidian-livesync"]; + if (!plugin) throw new Error("Self-hosted LiveSync is not loaded"); + const state: RemoteSetupBrowserState = { + calls: [], + inspectionMode: "verified", + nextProfileName: "", + }; + runtime[stateKey as typeof remoteSetupStateKey] = state; + + const getNewReplicator = plugin.core.services.replicator.getNewReplicator; + const replacements = { + async createReplicator(settingOverride: Record = {}) { + const remoteType = String(settingOverride.remoteType ?? ""); + const journalFormat = String(settingOverride.journalFormat ?? ""); + state.calls.push({ journalFormat, operation: "create", remoteType }); + return { + async inspectJournalStorageConnection(settings: Record) { + state.calls.push({ + journalFormat: String(settings.journalFormat ?? ""), + operation: "inspect", + remoteType: String(settings.remoteType ?? ""), + }); + if (state.inspectionMode === "failed") { + return { + adaptiveCapabilities: { + byteRange: { status: "not-checked" }, + required: { missing: ["conditional-create"], status: "unsupported" }, + }, + available: false, + remoteFormat: "empty", + }; + } + return { + adaptiveCapabilities: { + byteRange: { status: "verified" }, + required: { status: "verified" }, + }, + available: true, + remoteFormat: "empty", + }; + }, + async tryConnectRemote(settings: Record) { + state.calls.push({ + journalFormat: String(settings.journalFormat ?? ""), + operation: "test", + remoteType: String(settings.remoteType ?? ""), + }); + return state.inspectionMode === "verified"; + }, + }; + }, + async askString(title: string, label: string, placeholder: string, isPassword: boolean = false) { + if (title !== "Remote name") return await originalAskString(title, label, placeholder, isPassword); + const name = state.nextProfileName; + state.nextProfileName = ""; + if (!name) throw new Error("The remote setup E2E did not supply a profile name"); + return name; + }, + }; + state.unregister = getNewReplicator.addHandler(replacements.createReplicator, -1000, true); + + const confirm = plugin.core.services.UI.confirm; + const originalAskString = confirm.askString.bind(confirm); + confirm.askString = replacements.askString; + }, remoteSetupStateKey); + }); +} + +export async function setRemoteInspectionMode(port: number, mode: RemoteInspectionMode): Promise { + await withObsidianPage(port, async (page) => { + await page.evaluate( + ({ mode, stateKey }) => { + const state = (globalThis as RuntimeGlobal)[stateKey as typeof remoteSetupStateKey]; + if (!state) throw new Error("The remote setup E2E seam is not installed"); + state.inspectionMode = mode; + }, + { mode, stateKey: remoteSetupStateKey } + ); + }); +} + +export async function remoteSetupCalls(port: number): Promise { + return await withObsidianPage(port, async (page) => { + return await page.evaluate((stateKey) => { + const state = (globalThis as RuntimeGlobal)[stateKey as typeof remoteSetupStateKey]; + if (!state) throw new Error("The remote setup E2E seam is not installed"); + return state.calls; + }, remoteSetupStateKey); + }); +} + +export async function openRemoteConfigurationSettings(port: number, timeoutMs: number): Promise { + await withObsidianPage(port, async (page) => { + await page.evaluate(() => { + const setting = (globalThis as RuntimeGlobal).app?.setting; + if (!setting) throw new Error("Obsidian settings are unavailable"); + setting.close(); + }); + await page.waitForTimeout(100); + await page.evaluate(() => { + const setting = (globalThis as RuntimeGlobal).app?.setting; + if (!setting) throw new Error("Obsidian settings are unavailable"); + setting.open(); + setting.openTabById("obsidian-livesync"); + }); + const settings = page.locator(".sls-setting"); + try { + await settings.waitFor({ state: "visible", timeout: timeoutMs }); + } catch (error) { + const modalTitles = await page.locator(".modal-title").allTextContents(); + const settingTabs = await page.locator(".vertical-tab-nav-item").allTextContents(); + const reason = error instanceof Error ? error.message : String(error); + throw new Error( + `The LiveSync settings pane did not become visible. Open modal titles: ${JSON.stringify(modalTitles)}. Settings tabs: ${JSON.stringify(settingTabs)}. Cause: ${reason}` + ); + } + await settings.locator('.sls-setting-menu-btn[title="Remote Configuration"]').click({ timeout: timeoutMs }); + await remoteConfigurationPanel(page).waitFor({ state: "visible", timeout: timeoutMs }); + }); +} + +export async function closeRemoteConfigurationSettings(port: number, timeoutMs: number): Promise { + await withObsidianPage(port, async (page) => { + await page.evaluate(() => { + const setting = (globalThis as RuntimeGlobal).app?.setting; + if (!setting) throw new Error("Obsidian settings are unavailable"); + setting.close(); + }); + await page.locator(".sls-setting").waitFor({ state: "hidden", timeout: timeoutMs }); + }); +} + +export async function beginRemoteProfileSetup(port: number, profileName: string, timeoutMs: number): Promise { + await withObsidianPage(port, async (page) => { + await page.evaluate( + ({ profileName, stateKey }) => { + const state = (globalThis as RuntimeGlobal)[stateKey as typeof remoteSetupStateKey]; + if (!state) throw new Error("The remote setup E2E seam is not installed"); + state.nextProfileName = profileName; + }, + { profileName, stateKey: remoteSetupStateKey } + ); + const add = await tooltipButton(remoteConfigurationPanel(page), "Add new connection", "➕"); + await add.click({ timeout: timeoutMs }); + await remoteSelectionModal(page).waitFor({ state: "visible", timeout: timeoutMs }); + }); +} + +export async function captureRemoteProviderChoices( + port: number, + filename: string, + labels: readonly string[], + timeoutMs: number +): Promise { + return await captureObsidianDialogue(port, filename, async (page) => { + const modal = remoteSelectionModal(page); + await modal.waitFor({ state: "visible", timeout: timeoutMs }); + for (const label of labels) { + await modal.getByText(label, { exact: true }).waitFor({ state: "visible", timeout: timeoutMs }); + } + }); +} + +export async function selectRemoteProvider( + port: number, + choiceLabel: string, + proceedLabel: string, + providerTitle: string, + timeoutMs: number +): Promise { + await withObsidianPage(port, async (page) => { + const selection = remoteSelectionModal(page); + await selection + .locator("label") + .filter({ hasText: choiceLabel }) + .locator('input[type="radio"]') + .first() + .check({ timeout: timeoutMs }); + await selection.getByRole("button", { name: proceedLabel, exact: true }).click({ timeout: timeoutMs }); + await remoteProviderModal(page, providerTitle).waitFor({ state: "visible", timeout: timeoutMs }); + }); +} + +export async function captureAndCancelRemoteProvider( + port: number, + filename: string, + providerTitle: string, + timeoutMs: number +): Promise { + const screenshot = await captureObsidianDialogue(port, filename, async (page) => { + await remoteProviderModal(page, providerTitle).waitFor({ state: "visible", timeout: timeoutMs }); + }); + await withObsidianPage(port, async (page) => { + const modal = remoteProviderModal(page, providerTitle); + await modal.getByRole("button", { name: "Cancel", exact: true }).click({ timeout: timeoutMs }); + await modal.waitFor({ state: "hidden", timeout: timeoutMs }); + }); + return screenshot; +} + +export async function waitForSavedRemoteProfile(port: number, profileName: string, timeoutMs: number): Promise { + await withObsidianPage(port, async (page) => { + await remoteProfileRow(page, profileName).waitFor({ state: "visible", timeout: timeoutMs }); + }); +} + +export async function openSavedRemoteProfile(port: number, profileName: string, timeoutMs: number): Promise { + await withObsidianPage(port, async (page) => { + const row = remoteProfileRow(page, profileName); + await row.waitFor({ state: "visible", timeout: timeoutMs }); + const configure = await tooltipButton(row, "Configure", "🔧"); + await configure.click({ timeout: timeoutMs }); + }); +} + +export async function runtimeRemoteSettings(port: number): Promise { + return await withObsidianPage(port, async (page) => { + return await page.evaluate(() => { + const plugin = (globalThis as RuntimeGlobal).app?.plugins?.plugins["obsidian-livesync"]; + if (!plugin) throw new Error("Self-hosted LiveSync is not loaded"); + return structuredClone(plugin.core.services.setting.currentSettings()); + }); + }); +} diff --git a/test/e2e-obsidian/scripts/local-suite.ts b/test/e2e-obsidian/scripts/local-suite.ts index 9dfc225f..6be73ffb 100644 --- a/test/e2e-obsidian/scripts/local-suite.ts +++ b/test/e2e-obsidian/scripts/local-suite.ts @@ -17,6 +17,7 @@ const testSteps: Step[] = [ { name: "Svelte dialogue mounts", args: ["run", "test:e2e:obsidian:dialog-mounts"] }, { name: "revision repair", args: ["run", "test:e2e:obsidian:revision-repair"] }, { name: "settings UI", args: ["run", "test:e2e:obsidian:settings-ui"] }, + { name: "remote setup providers", args: ["run", "test:e2e:obsidian:remote-setup-providers"] }, { name: "Review Harness", args: ["run", "test:e2e:obsidian:review-harness"] }, { name: "P2P status pane", args: ["run", "test:e2e:obsidian:p2p-pane"] }, { name: "vault reflection", args: ["run", "test:e2e:obsidian:vault-reflection"] }, diff --git a/test/e2e-obsidian/scripts/remote-setup-providers.ts b/test/e2e-obsidian/scripts/remote-setup-providers.ts new file mode 100644 index 00000000..20e31756 --- /dev/null +++ b/test/e2e-obsidian/scripts/remote-setup-providers.ts @@ -0,0 +1,524 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + defaultRemoteProviderRegistry, + type BuiltInRemoteConfiguration, + type RemoteConfiguration, +} from "@vrtmrz/livesync-commonlib/remote-configurations"; +import { parsePostgRESTConnectionURI, parseWebDAVConnectionURI } from "@vrtmrz/livesync-commonlib/journal-storage"; +import { enableAndReloadPlugin } from "@vrtmrz/obsidian-test-session"; +import type { Locator, Page } from "playwright"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { + createE2eCouchDbPluginData, + createE2eObsidianDeviceLocalState, + waitForLiveSyncCoreReady, +} from "../runner/liveSyncWorkflow.ts"; +import { + beginRemoteProfileSetup, + captureAndCancelRemoteProvider, + captureRemoteProviderChoices, + closeRemoteConfigurationSettings, + installRemoteSetupTestSeam, + openRemoteConfigurationSettings, + openSavedRemoteProfile, + remoteProviderModal, + remoteSetupCalls, + runtimeRemoteSettings, + selectRemoteProvider, + setRemoteInspectionMode, + waitForSavedRemoteProfile, +} from "../runner/remoteSetupUi.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { + captureObsidianDialogue, + captureObsidianElement, + obsidianRemoteDebuggingPort, + withObsidianPage, +} from "../runner/ui.ts"; +import { createTemporaryVault } from "../runner/vault.ts"; + +const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_REMOTE_SETUP_TIMEOUT_MS ?? 10000); +const repositoryA = "A".repeat(43); +const repositoryB = `${"A".repeat(42)}Q`; + +const profiles = { + postgrest: { + apiKey: "publishable-ui-key", + credential: "remote-setup-ui-credential", + endpoint: "https://postgrest.example.test/rest/v1", + expectedRepositoryId: repositoryB, + name: "PostgREST UI profile", + schema: "livesync_api", + vaultId: "remote-setup-vault-01", + }, + s3: { + accessKey: "remote-setup-access-key", + bucket: "remote-setup-bucket", + endpoint: "https://s3.example.test", + name: "S3 UI profile", + prefix: "adaptive-ui/", + region: "us-east-1", + secretKey: "remote-setup-secret-key", + }, + webdav: { + endpoint: "https://dav.example.test/remote.php/dav/files/tester", + expectedRepositoryId: repositoryA, + name: "WebDAV UI profile", + password: "remote-setup-password", + prefix: "adaptive-ui/", + username: "remote-setup-user", + }, +} as const; + +const providerChoices = [ + "CouchDB", + "S3-compatible Object Storage", + "WebDAV Journal", + "PostgREST Journal", + "Peer-to-Peer (P2P)", +] as const; + +type PersistedSettings = { + activeConfigurationId: string; + remoteConfigurations: Record; +}; + +function assertEqual(actual: unknown, expected: unknown, message: string): void { + if (actual !== expected) { + throw new Error(`${message}\nExpected: ${String(expected)}\nActual: ${String(actual)}`); + } +} + +function profileByName(settings: PersistedSettings, name: string): RemoteConfiguration { + const profile = Object.values(settings.remoteConfigurations).find((candidate) => candidate.name === name); + if (!profile) throw new Error(`Saved remote profile '${name}' was not found`); + return profile; +} + +function parseProfile(settings: PersistedSettings, name: string): BuiltInRemoteConfiguration { + return defaultRemoteProviderRegistry.parse(profileByName(settings, name).uri); +} + +function assertPersistedProfiles(settings: PersistedSettings): void { + const s3 = parseProfile(settings, profiles.s3.name); + assertEqual(s3.type, "s3", "The S3 dialogue returned the wrong provider type."); + if (s3.type !== "s3") return; + assertEqual(s3.settings.endpoint, profiles.s3.endpoint, "The S3 endpoint was not saved."); + assertEqual(s3.settings.bucket, profiles.s3.bucket, "The S3 bucket was not saved."); + assertEqual(s3.settings.bucketPrefix, profiles.s3.prefix, "The S3 prefix was not saved."); + assertEqual(s3.settings.journalFormat, "adaptive-v1", "The S3 Adaptive format was not saved."); + assertEqual(s3.settings.packReadPolicy, "range", "The S3 Range policy was not saved."); + + const webdav = parseProfile(settings, profiles.webdav.name); + assertEqual(webdav.type, "webdav", "The WebDAV dialogue returned the wrong provider type."); + if (webdav.type !== "webdav") return; + const webdavConnection = parseWebDAVConnectionURI(webdav.settings.webDAVactiveConnectionURI); + assertEqual(webdavConnection.endpoint, profiles.webdav.endpoint, "The WebDAV endpoint was not saved."); + assertEqual(webdavConnection.prefix, profiles.webdav.prefix, "The WebDAV prefix was not saved."); + assertEqual(webdavConnection.username, profiles.webdav.username, "The WebDAV username was not saved."); + assertEqual(webdav.settings.expectedRepositoryId, repositoryA, "The WebDAV repository ID was not saved."); + assertEqual(webdav.settings.journalFormat, "adaptive-v1", "The WebDAV Adaptive format was not saved."); + assertEqual(webdav.settings.packReadPolicy, "range", "The WebDAV Range policy was not saved."); + + const postgrest = parseProfile(settings, profiles.postgrest.name); + assertEqual(postgrest.type, "postgrest", "The PostgREST dialogue returned the wrong provider type."); + if (postgrest.type !== "postgrest") return; + const postgrestConnection = parsePostgRESTConnectionURI(postgrest.settings.postgrestActiveConnectionURI); + assertEqual(postgrestConnection.endpoint, profiles.postgrest.endpoint, "The PostgREST endpoint was not saved."); + assertEqual(postgrestConnection.schema, profiles.postgrest.schema, "The PostgREST schema was not saved."); + assertEqual(postgrestConnection.vaultId, profiles.postgrest.vaultId, "The PostgREST Vault ID was not saved."); + assertEqual(postgrest.settings.expectedRepositoryId, repositoryB, "The PostgREST repository ID was not saved."); + assertEqual(postgrest.settings.journalFormat, "adaptive-v1", "PostgREST did not retain its fixed format."); + assertEqual(postgrest.settings.packReadPolicy, "whole-pack", "PostgREST did not retain its fixed read policy."); +} + +function assertEncryptedProfilesAtRest(settings: PersistedSettings): void { + for (const fixture of [profiles.s3, profiles.webdav, profiles.postgrest]) { + const profile = profileByName(settings, fixture.name); + assertEqual(profile.isEncrypted, true, `Saved remote profile '${fixture.name}' was not encrypted at rest.`); + for (const exposed of [ + profiles.s3.endpoint, + profiles.s3.secretKey, + profiles.webdav.endpoint, + profiles.webdav.password, + profiles.postgrest.endpoint, + profiles.postgrest.credential, + ]) { + if (profile.uri.includes(exposed)) { + throw new Error(`Saved remote profile '${fixture.name}' exposed a connection secret or endpoint.`); + } + } + } +} + +async function fill(locator: Locator, value: string): Promise { + await locator.fill(value, { timeout: uiTimeoutMs }); +} + +async function openAdvanced(modal: Locator): Promise { + const details = modal.locator("details").filter({ hasText: "Advanced Settings" }).first(); + await details.waitFor({ state: "visible", timeout: uiTimeoutMs }); + if (!(await details.getAttribute("open"))) { + await details.locator("summary").click({ timeout: uiTimeoutMs }); + } +} + +async function expectInputValue(modal: Locator, name: string, expected: string): Promise { + const actual = await modal.locator(`[name="${name}"]`).inputValue({ timeout: uiTimeoutMs }); + assertEqual(actual, expected, `Reloaded control '${name}' has the wrong value.`); +} + +async function mountLegacyProvider( + port: number, + profileName: string, + choice: string, + proceed: string, + title: string, + screenshotName: string +): Promise { + await beginRemoteProfileSetup(port, profileName, uiTimeoutMs); + await selectRemoteProvider(port, choice, proceed, title, uiTimeoutMs); + return await captureAndCancelRemoteProvider(port, screenshotName, title, uiTimeoutMs); +} + +async function addS3Profile(port: number): Promise { + await beginRemoteProfileSetup(port, profiles.s3.name, uiTimeoutMs); + await selectRemoteProvider( + port, + "S3-compatible Object Storage", + "Continue to Object Storage setup", + "S3/MinIO/R2 Configuration", + uiTimeoutMs + ); + const screenshot = await captureObsidianDialogue(port, "remote-setup-s3-adaptive.png", async (page) => { + const modal = remoteProviderModal(page, "S3/MinIO/R2 Configuration"); + const testButton = modal.getByRole("button", { name: "Test Settings and Continue", exact: true }); + if (!(await testButton.isDisabled())) + throw new Error("Incomplete S3 settings did not disable connection testing."); + await fill(modal.locator('[name="s3-endpoint"]'), profiles.s3.endpoint); + await fill(modal.locator('[name="s3-access-key-id"]'), profiles.s3.accessKey); + await fill(modal.locator('[name="s3-secret-access-key"]'), profiles.s3.secretKey); + await fill(modal.locator('[name="s3-bucket-name"]'), profiles.s3.bucket); + await fill(modal.locator('[name="s3-region"]'), profiles.s3.region); + await fill(modal.locator('[name="s3-folder-prefix"]'), profiles.s3.prefix); + await openAdvanced(modal); + await modal.locator('[name="s3-journal-format"]').selectOption("adaptive-v1"); + await modal.locator('[name="s3-pack-read-policy"]').selectOption("range"); + if (await testButton.isDisabled()) throw new Error("Complete S3 settings did not enable connection testing."); + }); + await withObsidianPage(port, async (page) => { + const modal = remoteProviderModal(page, "S3/MinIO/R2 Configuration"); + await modal.getByRole("button", { name: "Test Settings and Continue", exact: true }).click({ + timeout: uiTimeoutMs, + }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + await waitForSavedRemoteProfile(port, profiles.s3.name, uiTimeoutMs); + return screenshot; +} + +async function addWebDAVProfile(port: number): Promise { + await beginRemoteProfileSetup(port, profiles.webdav.name, uiTimeoutMs); + await selectRemoteProvider( + port, + "WebDAV Journal", + "Continue to WebDAV setup", + "WebDAV Journal Configuration", + uiTimeoutMs + ); + await withObsidianPage(port, async (page) => { + const modal = remoteProviderModal(page, "WebDAV Journal Configuration"); + await fill(modal.locator('[name="webdav-endpoint"]'), profiles.webdav.endpoint); + await fill(modal.locator('[name="webdav-username"]'), profiles.webdav.username); + await fill(modal.locator('[name="webdav-password"]'), profiles.webdav.password); + await fill(modal.locator('[name="webdav-prefix"]'), profiles.webdav.prefix); + await modal.locator('[name="webdav-use-internal-api"]').check({ timeout: uiTimeoutMs }); + await openAdvanced(modal); + await modal.locator('[name="webdav-journal-format"]').selectOption("adaptive-v1"); + await fill(modal.locator('[name="webdav-expected-repository-id"]'), repositoryA); + await modal.locator('[name="webdav-pack-read-policy"]').selectOption("range"); + }); + + await setRemoteInspectionMode(port, "failed"); + await withObsidianPage(port, async (page) => { + const modal = remoteProviderModal(page, "WebDAV Journal Configuration"); + await modal.getByRole("button", { name: "Run endpoint safety check", exact: true }).click({ + timeout: uiTimeoutMs, + }); + await modal.getByText("missing required Adaptive operations", { exact: false }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + if ((await modal.getByRole("button", { name: "Save verified settings", exact: true }).count()) !== 0) { + throw new Error("A failed WebDAV safety check exposed the verified-save action."); + } + }); + + await setRemoteInspectionMode(port, "verified"); + await withObsidianPage(port, async (page) => { + const modal = remoteProviderModal(page, "WebDAV Journal Configuration"); + await modal.getByRole("button", { name: "Run endpoint safety check", exact: true }).click({ + timeout: uiTimeoutMs, + }); + await modal.getByRole("button", { name: "Save verified settings", exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + await fill(modal.locator('[name="webdav-prefix"]'), "stale-inspection/"); + if ((await modal.getByRole("button", { name: "Save verified settings", exact: true }).count()) !== 0) { + throw new Error("Editing WebDAV settings did not invalidate the previous safety check."); + } + await fill(modal.locator('[name="webdav-prefix"]'), profiles.webdav.prefix); + await modal.getByRole("button", { name: "Run endpoint safety check", exact: true }).click({ + timeout: uiTimeoutMs, + }); + await modal.getByRole("button", { name: "Save verified settings", exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + }); + const screenshot = await captureObsidianDialogue(port, "remote-setup-webdav-verified.png", async (page) => { + await remoteProviderModal(page, "WebDAV Journal Configuration") + .getByText("Required Adaptive operations are supported", { exact: false }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); + await withObsidianPage(port, async (page) => { + const modal = remoteProviderModal(page, "WebDAV Journal Configuration"); + await modal.getByRole("button", { name: "Save verified settings", exact: true }).click({ + timeout: uiTimeoutMs, + }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + await waitForSavedRemoteProfile(port, profiles.webdav.name, uiTimeoutMs); + return screenshot; +} + +async function addPostgRESTProfile(port: number): Promise { + await beginRemoteProfileSetup(port, profiles.postgrest.name, uiTimeoutMs); + await selectRemoteProvider( + port, + "PostgREST Journal", + "Continue to PostgREST setup", + "PostgREST Journal Configuration", + uiTimeoutMs + ); + await withObsidianPage(port, async (page) => { + const modal = remoteProviderModal(page, "PostgREST Journal Configuration"); + const check = modal.getByRole("button", { name: "Check PostgREST server", exact: true }); + if (!(await check.isDisabled())) { + throw new Error("Incomplete PostgREST settings did not disable the server check."); + } + await fill(modal.locator('[name="postgrest-endpoint"]'), profiles.postgrest.endpoint); + await fill(modal.locator('[name="postgrest-vault-id"]'), profiles.postgrest.vaultId); + await fill(modal.locator('[name="postgrest-vault-credential"]'), profiles.postgrest.credential); + await fill(modal.locator('[name="postgrest-schema"]'), profiles.postgrest.schema); + await fill(modal.locator('[name="postgrest-api-key"]'), profiles.postgrest.apiKey); + await modal.locator('[name="postgrest-use-internal-api"]').check({ timeout: uiTimeoutMs }); + await openAdvanced(modal); + await fill(modal.locator('[name="postgrest-expected-repository-id"]'), repositoryB); + if (await check.isDisabled()) throw new Error("Complete PostgREST settings did not enable the server check."); + await check.click({ timeout: uiTimeoutMs }); + try { + await modal.getByRole("button", { name: "Save verified settings", exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error( + `PostgREST verification did not enable saving. Dialogue text:\n${await modal.innerText()}\nCause: ${reason}` + ); + } + await fill(modal.locator('[name="postgrest-api-key"]'), "stale-publishable-key"); + if ((await modal.getByRole("button", { name: "Save verified settings", exact: true }).count()) !== 0) { + throw new Error("Editing PostgREST settings did not invalidate the previous server check."); + } + await fill(modal.locator('[name="postgrest-api-key"]'), profiles.postgrest.apiKey); + await modal.getByRole("button", { name: "Check PostgREST server", exact: true }).click({ + timeout: uiTimeoutMs, + }); + await modal.getByRole("button", { name: "Save verified settings", exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + }); + const screenshot = await captureObsidianDialogue(port, "remote-setup-postgrest-verified.png", async (page) => { + await remoteProviderModal(page, "PostgREST Journal Configuration") + .getByText("required PostgREST RPC operations", { exact: false }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); + await withObsidianPage(port, async (page) => { + const modal = remoteProviderModal(page, "PostgREST Journal Configuration"); + await modal.getByRole("button", { name: "Save verified settings", exact: true }).click({ + timeout: uiTimeoutMs, + }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + await waitForSavedRemoteProfile(port, profiles.postgrest.name, uiTimeoutMs); + return screenshot; +} + +async function assertReloadedS3(port: number): Promise { + await openSavedRemoteProfile(port, profiles.s3.name, uiTimeoutMs); + await withObsidianPage(port, async (page) => { + const modal = remoteProviderModal(page, "S3/MinIO/R2 Configuration"); + await expectInputValue(modal, "s3-endpoint", profiles.s3.endpoint); + await expectInputValue(modal, "s3-bucket-name", profiles.s3.bucket); + await openAdvanced(modal); + await expectInputValue(modal, "s3-journal-format", "adaptive-v1"); + await expectInputValue(modal, "s3-pack-read-policy", "range"); + await modal.getByRole("button", { name: "Cancel", exact: true }).click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); +} + +async function assertReloadedWebDAV(port: number): Promise { + await openSavedRemoteProfile(port, profiles.webdav.name, uiTimeoutMs); + await withObsidianPage(port, async (page) => { + const modal = remoteProviderModal(page, "WebDAV Journal Configuration"); + await expectInputValue(modal, "webdav-endpoint", profiles.webdav.endpoint); + await expectInputValue(modal, "webdav-prefix", profiles.webdav.prefix); + await openAdvanced(modal); + await expectInputValue(modal, "webdav-journal-format", "adaptive-v1"); + await expectInputValue(modal, "webdav-expected-repository-id", repositoryA); + await expectInputValue(modal, "webdav-pack-read-policy", "range"); + await modal.getByRole("button", { name: "Cancel", exact: true }).click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); +} + +async function assertReloadedPostgREST(port: number): Promise { + await openSavedRemoteProfile(port, profiles.postgrest.name, uiTimeoutMs); + await withObsidianPage(port, async (page) => { + const modal = remoteProviderModal(page, "PostgREST Journal Configuration"); + await expectInputValue(modal, "postgrest-endpoint", profiles.postgrest.endpoint); + await expectInputValue(modal, "postgrest-vault-id", profiles.postgrest.vaultId); + await expectInputValue(modal, "postgrest-schema", profiles.postgrest.schema); + await openAdvanced(modal); + await expectInputValue(modal, "postgrest-expected-repository-id", repositoryB); + await modal.getByRole("button", { name: "Cancel", exact: true }).click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); +} + +async function readPersistedSettings(pluginDir: string): Promise { + return JSON.parse(await readFile(join(pluginDir, "data.json"), "utf8")) as PersistedSettings; +} + +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("obsidian-livesync-remote-setup-e2e-"); + let session: ObsidianLiveSyncSession | undefined; + try { + session = await startObsidianLiveSyncSession({ + binary, + cliBinary: cli.binary, + localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), + pluginData: createE2eCouchDbPluginData( + { + dbName: "remote-setup-ui-only", + password: "", + uri: "http://127.0.0.1:5984", + username: "", + }, + { + notifyThresholdOfRemoteStorageSize: 0, + periodicReplication: false, + syncAfterMerge: false, + syncOnEditorSave: false, + syncOnFileOpen: false, + syncOnSave: false, + syncOnStart: false, + useAdvancedMode: true, + } + ), + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + vault, + }); + await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); + const port = obsidianRemoteDebuggingPort(); + await installRemoteSetupTestSeam(port); + await openRemoteConfigurationSettings(port, uiTimeoutMs); + + await beginRemoteProfileSetup(port, "Cancelled CouchDB profile", uiTimeoutMs); + const selectionScreenshot = await captureRemoteProviderChoices( + port, + "remote-setup-provider-selection.png", + providerChoices, + uiTimeoutMs + ); + await selectRemoteProvider(port, "CouchDB", "Continue to CouchDB setup", "CouchDB Configuration", uiTimeoutMs); + const couchdbScreenshot = await captureAndCancelRemoteProvider( + port, + "remote-setup-couchdb.png", + "CouchDB Configuration", + uiTimeoutMs + ); + const p2pScreenshot = await mountLegacyProvider( + port, + "Cancelled P2P profile", + "Peer-to-Peer (P2P)", + "Continue to P2P setup", + "P2P Configuration", + "remote-setup-p2p.png" + ); + + const s3Screenshot = await addS3Profile(port); + const webdavScreenshot = await addWebDAVProfile(port); + const postgrestScreenshot = await addPostgRESTProfile(port); + const firstRuntimeSettings = (await runtimeRemoteSettings(port)) as PersistedSettings; + assertPersistedProfiles(firstRuntimeSettings); + const calls = await remoteSetupCalls(port); + assertEqual( + calls.filter((call) => call.operation === "test").length, + 1, + "The S3 profile did not use the injected connection-test boundary exactly once." + ); + if (calls.filter((call) => call.operation === "inspect").length < 5) { + throw new Error("The WebDAV and PostgREST profiles did not exercise failure, stale, and verified checks."); + } + + const pluginDir = session.install.pluginDir; + assertEncryptedProfilesAtRest(await readPersistedSettings(pluginDir)); + await closeRemoteConfigurationSettings(port, uiTimeoutMs); + await enableAndReloadPlugin(port, "obsidian-livesync"); + await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); + await openRemoteConfigurationSettings(port, uiTimeoutMs); + await assertReloadedS3(port); + await assertReloadedWebDAV(port); + await assertReloadedPostgREST(port); + const reloadedSettings = (await runtimeRemoteSettings(port)) as PersistedSettings; + assertPersistedProfiles(reloadedSettings); + assertEqual( + Object.keys(reloadedSettings.remoteConfigurations).length, + Object.keys(firstRuntimeSettings.remoteConfigurations).length, + "Plug-in reload changed the saved remote profile count." + ); + const listScreenshot = await captureObsidianElement(port, "remote-setup-reloaded-profiles.png", (page: Page) => + page.locator(".sls-setting .sls-remote-list") + ); + + console.log( + `All registered providers mounted through the real Settings flow; S3, WebDAV, and PostgREST passed validation, injected checks, persistence, and plug-in reload. Screenshots: ${[ + selectionScreenshot, + couchdbScreenshot, + p2pScreenshot, + s3Screenshot, + webdavScreenshot, + postgrestScreenshot, + listScreenshot, + ].join(", ")}` + ); + } 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); +}); diff --git a/test/e2e-obsidian/scripts/run-focused.ts b/test/e2e-obsidian/scripts/run-focused.ts index 957d6fd8..32023d80 100644 --- a/test/e2e-obsidian/scripts/run-focused.ts +++ b/test/e2e-obsidian/scripts/run-focused.ts @@ -11,6 +11,7 @@ const focusedScenarios = new Set([ "revision-repair", "document-history-nav", "settings-ui", + "remote-setup-providers", "review-harness", "p2p-pane", "vault-reflection",