test(obsidian): verify registered remote setup profiles

This commit is contained in:
vorotamoroz
2026-08-02 07:52:00 +00:00
parent de61425864
commit c7d1f9352d
5 changed files with 857 additions and 0 deletions
+1
View File
@@ -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",
+330
View File
@@ -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<string, RuntimeRemoteConfiguration>;
};
type RuntimePlugin = {
core: {
services: {
replicator: {
getNewReplicator: {
addHandler: (...args: unknown[]) => () => void;
} & ((settingOverride?: Record<string, unknown>) => Promise<unknown>);
};
setting: {
currentSettings(): RuntimeSettings;
};
UI: {
confirm: {
askString(
title: string,
label: string,
placeholder: string,
isPassword?: boolean
): Promise<string | false>;
};
};
};
};
};
type RuntimeSettingsController = {
close(): void;
open(): void;
openTabById(tabId: string): void;
};
type RuntimeApp = {
plugins?: { plugins: Record<string, RuntimePlugin | undefined> };
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<Locator> {
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<void> {
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<string, unknown> = {}) {
const remoteType = String(settingOverride.remoteType ?? "");
const journalFormat = String(settingOverride.journalFormat ?? "");
state.calls.push({ journalFormat, operation: "create", remoteType });
return {
async inspectJournalStorageConnection(settings: Record<string, unknown>) {
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<string, unknown>) {
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<void> {
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<RemoteSetupCall[]> {
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<void> {
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<void> {
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<void> {
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<string> {
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<void> {
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<string> {
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<void> {
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<void> {
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<RuntimeSettings> {
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());
});
});
}
+1
View File
@@ -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"] },
@@ -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<string, RemoteConfiguration>;
};
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<void> {
await locator.fill(value, { timeout: uiTimeoutMs });
}
async function openAdvanced(modal: Locator): Promise<void> {
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<void> {
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<string> {
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<string> {
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<string> {
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<string> {
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<void> {
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<void> {
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<void> {
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<PersistedSettings> {
return JSON.parse(await readFile(join(pluginDir, "data.json"), "utf8")) as PersistedSettings;
}
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
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);
});
+1
View File
@@ -11,6 +11,7 @@ const focusedScenarios = new Set([
"revision-repair",
"document-history-nav",
"settings-ui",
"remote-setup-providers",
"review-harness",
"p2p-pane",
"vault-reflection",