Clarify remote setup and verify manual CouchDB onboarding

This commit is contained in:
vorotamoroz
2026-07-23 16:46:50 +00:00
parent 1df034f12a
commit c8050322e4
14 changed files with 492 additions and 89 deletions
@@ -0,0 +1,369 @@
import { randomBytes } from "node:crypto";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { evalObsidianJson } from "../runner/cli.ts";
import {
assertCouchDbReachable,
deleteCouchDbDatabase,
loadCouchDbConfig,
makeUniqueDatabaseName,
waitForCouchDbDocs,
type CouchDbConfig,
} from "../runner/couchdb.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { assertEqual, pushLocalChanges, waitForLocalDatabaseEntry } from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import {
acknowledgeDisabledOptionalFeatures,
captureAndStartInitialisation,
captureGuideDialogue,
confirmFastFetch,
confirmRebuild,
enterSetupURI,
finishInitialisation,
generateSetupURIFromDevice,
modalByTitle,
resumeCompatibilityReviewIfShown,
selectRadioOption,
skipMissingRemoteConfiguration,
type SetupArtifact,
} from "../runner/setupUri.ts";
import { captureObsidianPage, withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "30000";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_URI_TIMEOUT_MS ?? 30000);
const notePath = "E2E/manual-couchdb/from-first-device.md";
const noteContent = "# Manual CouchDB setup\n\nThis note was sent by the manually configured first device.\n";
const returnNotePath = "E2E/manual-couchdb/from-second-device.md";
const returnNoteContent =
"# Manual CouchDB return journey\n\nThis note returned through a Setup URI generated by the first device.\n";
const captures = {
scenario: "couchdb-manual-setup-workflow",
guide: "couchdb-manual",
} as const;
type RunnerContext = {
binary: string;
cliBinary: string;
couchDb: CouchDbConfig;
dbName: string;
activeSessions: Set<ObsidianLiveSyncSession>;
};
async function startUnconfiguredSession(
context: RunnerContext,
vault: TemporaryVault
): Promise<ObsidianLiveSyncSession> {
const session = await startObsidianLiveSyncSession({
binary: context.binary,
cliBinary: context.cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
});
context.activeSessions.add(session);
return session;
}
async function stopTrackedSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise<void> {
if (!context.activeSessions.has(session)) return;
await session.app.stop();
context.activeSessions.delete(session);
}
async function stopTrackedSessions(context: RunnerContext): Promise<void> {
for (const session of [...context.activeSessions]) {
await stopTrackedSession(context, session);
}
}
async function captureFailure(session: ObsidianLiveSyncSession, label: string): Promise<void> {
const screenshot = await captureObsidianPage(
session.remoteDebuggingPort,
`couchdb-manual-${label}-failure.png`,
async () => undefined
).catch(() => undefined);
if (screenshot) {
console.error(`Manual CouchDB failure screenshot: ${screenshot}`);
}
}
async function enterManualCouchDBSettings(port: number, couchDb: CouchDbConfig, dbName: string): Promise<string[]> {
const screenshots: string[] = [];
await withObsidianPage(port, async (page) => {
const invitation = page.locator(".notice").filter({ hasText: "Welcome to Self-hosted LiveSync" });
await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs });
await invitation.locator(".sls-onboarding-invitation-action").click({ timeout: uiTimeoutMs });
const intro = modalByTitle(page, "Welcome to Self-hosted LiveSync");
await intro.waitFor({ state: "visible", timeout: uiTimeoutMs });
await selectRadioOption(intro, "I am setting this up for the first time");
await intro
.getByRole("button", { name: "Yes, I want to set up a new synchronisation" })
.click({ timeout: uiTimeoutMs });
});
screenshots.push(
await captureGuideDialogue(port, "guide-couchdb-manual-connection-method.png", "Connection Method")
);
await withObsidianPage(port, async (page) => {
const method = modalByTitle(page, "Connection Method");
await selectRadioOption(method, "Configure a remote manually");
await method
.getByRole("button", { name: "Proceed with manual configuration" })
.click({ timeout: uiTimeoutMs });
const encryption = modalByTitle(page, "End-to-End Encryption");
await encryption.waitFor({ state: "visible", timeout: uiTimeoutMs });
await encryption
.locator("label.row")
.filter({ hasText: "End-to-End Encryption" })
.locator('input[type="checkbox"]')
.first()
.check({ timeout: uiTimeoutMs });
await encryption
.locator("label.row")
.filter({ hasText: "Obfuscate Properties" })
.locator('input[type="checkbox"]')
.first()
.check({ timeout: uiTimeoutMs });
await encryption.locator('input[name="e2ee-passphrase"]').fill(randomBytes(24).toString("base64url"));
});
screenshots.push(await captureGuideDialogue(port, "guide-couchdb-manual-encryption.png", "End-to-End Encryption"));
await withObsidianPage(port, async (page) => {
const encryption = modalByTitle(page, "End-to-End Encryption");
await encryption.getByRole("button", { name: "Proceed", exact: true }).click({ timeout: uiTimeoutMs });
});
screenshots.push(
await captureGuideDialogue(port, "guide-couchdb-manual-remote-selection.png", "Choose a synchronisation remote")
);
await withObsidianPage(port, async (page) => {
const remoteSelection = modalByTitle(page, "Choose a synchronisation remote");
await selectRadioOption(remoteSelection, "CouchDB");
await remoteSelection
.getByRole("button", { name: "Continue to CouchDB setup", exact: true })
.click({ timeout: uiTimeoutMs });
const couchDB = modalByTitle(page, "CouchDB Configuration");
await couchDB.waitFor({ state: "visible", timeout: uiTimeoutMs });
await couchDB.locator('input[name="couchdb-url"]').fill(couchDb.uri);
await couchDB.locator('input[name="couchdb-username"]').fill(couchDb.username);
await couchDB.locator('input[name="couchdb-password"]').fill(couchDb.password);
await couchDB.locator('input[name="couchdb-database"]').fill(dbName);
});
screenshots.push(
await captureGuideDialogue(port, "guide-couchdb-manual-connection-details.png", "CouchDB Configuration")
);
await withObsidianPage(port, async (page) => {
const couchDB = modalByTitle(page, "CouchDB Configuration");
await couchDB
.getByRole("button", { name: "Check server requirements", exact: true })
.click({ timeout: uiTimeoutMs });
const summary = couchDB.locator(".check-results summary");
await summary.waitFor({ state: "visible", timeout: uiTimeoutMs });
await summary
.filter({ hasText: /All checks passed successfully!|issue\(s\) detected!/u })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
const errors = couchDB.locator(".check-result.error");
if ((await errors.count()) > 0) {
const messages = await errors.locator(".message").allTextContents();
throw new Error(`The documented CouchDB fixture failed its server requirements: ${messages.join(" | ")}`);
}
const details = couchDB.locator(".check-results details");
if (!(await details.evaluate((element) => (element as HTMLDetailsElement).open))) {
await summary.click({ timeout: uiTimeoutMs });
}
});
screenshots.push(
await captureGuideDialogue(port, "guide-couchdb-manual-server-requirements.png", "CouchDB Configuration")
);
await withObsidianPage(port, async (page) => {
const couchDB = modalByTitle(page, "CouchDB Configuration");
await couchDB
.getByRole("button", { name: "Create or connect to database and continue", exact: true })
.click({ timeout: uiTimeoutMs });
await modalByTitle(page, "Setup Complete: Preparing to Initialise Server").waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
});
return screenshots;
}
async function writeNoteViaObsidian(
cliBinary: string,
environment: NodeJS.ProcessEnv,
path: string,
content: string
): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
`const content=${JSON.stringify(content)};`,
"const folder=path.split('/').slice(0,-1).join('/');",
"if(folder&&!(await app.vault.adapter.exists(folder))) await app.vault.createFolder(folder);",
"const existing=app.vault.getAbstractFileByPath(path);",
"if(existing) await app.vault.modify(existing,content);",
"else await app.vault.create(path,content);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
environment
);
}
async function waitForVaultFile(
vault: TemporaryVault,
path: string,
expected: string,
timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 30000)
): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastContent = "";
while (Date.now() < deadline) {
try {
lastContent = await readFile(join(vault.path, path), "utf8");
if (lastContent === expected) return;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Timed out waiting for ${path}. Last content:\n${lastContent}`);
}
async function waitForRemoteEntry(context: RunnerContext, entry: { id: string; children: string[] }): Promise<void> {
await waitForCouchDbDocs(context.couchDb, context.dbName, (docs) => {
const ids = new Set(docs.map((doc) => doc._id));
return ids.has(entry.id) && entry.children.every((childId) => ids.has(childId));
});
}
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 couchDb = await loadCouchDbConfig();
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "manual-setup");
const vaultA = await createTemporaryVault();
const vaultB = await createTemporaryVault();
const context: RunnerContext = {
binary,
cliBinary: cli.binary,
couchDb,
dbName,
activeSessions: new Set(),
};
const screenshots: string[] = [];
let secondDeviceArtifact: SetupArtifact | undefined;
try {
await assertCouchDbReachable(couchDb);
console.log(`Using Obsidian executable: ${binary}`);
console.log(`Temporary Vault A: ${vaultA.path}`);
console.log(`Temporary Vault B: ${vaultB.path}`);
console.log(`CouchDB database to be created by the onboarding dialogue: ${dbName}`);
let session = await startUnconfiguredSession(context, vaultA);
try {
screenshots.push(...(await enterManualCouchDBSettings(session.remoteDebuggingPort, couchDb, dbName)));
screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "new", captures));
screenshots.push(await confirmRebuild(session.remoteDebuggingPort, captures));
screenshots.push(await skipMissingRemoteConfiguration(session.remoteDebuggingPort, captures));
screenshots.push(await acknowledgeDisabledOptionalFeatures(session.remoteDebuggingPort, captures));
const state = await finishInitialisation(session.remoteDebuggingPort, context.cliBinary, session.cliEnv);
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
assertEqual(state.activeConfigurationId !== "", true, "Manual CouchDB setup did not activate a profile.");
assertEqual(
state.remoteConfigurationCount,
1,
"Manual CouchDB setup did not persist exactly one remote profile."
);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, notePath, noteContent);
const entry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, notePath);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await waitForRemoteEntry(context, entry);
const generated = await generateSetupURIFromDevice(
session.remoteDebuggingPort,
randomBytes(24).toString("base64url"),
captures
);
secondDeviceArtifact = generated.artifact;
screenshots.push(...generated.screenshots);
} catch (error) {
await captureFailure(session, "first-device");
throw error;
} finally {
await stopTrackedSession(context, session);
}
session = await startUnconfiguredSession(context, vaultB);
try {
if (!secondDeviceArtifact) {
throw new Error("The manually configured first device did not generate a Setup URI.");
}
screenshots.push(
await enterSetupURI(session.remoteDebuggingPort, "existing", secondDeviceArtifact, captures)
);
screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "existing", captures));
screenshots.push(...(await confirmFastFetch(session.remoteDebuggingPort, captures)));
await finishInitialisation(session.remoteDebuggingPort, context.cliBinary, session.cliEnv);
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await waitForVaultFile(vaultB, notePath, noteContent);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, returnNotePath, returnNoteContent);
const returnEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, returnNotePath);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await waitForRemoteEntry(context, returnEntry);
} catch (error) {
await captureFailure(session, "second-device");
throw error;
} finally {
await stopTrackedSession(context, session);
}
session = await startUnconfiguredSession(context, vaultA);
try {
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await waitForVaultFile(vaultA, returnNotePath, returnNoteContent);
} catch (error) {
await captureFailure(session, "return-journey");
throw error;
} finally {
await stopTrackedSession(context, session);
}
console.log(
`Manual CouchDB onboarding created and tested its database, generated a second-device Setup URI, and completed a bidirectional note round-trip. Screenshots: ${screenshots.join(", ")}`
);
} finally {
await stopTrackedSessions(context).catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
await vaultA.dispose();
await vaultB.dispose();
if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") {
await deleteCouchDbDatabase(couchDb, dbName).catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
}
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});
+4 -4
View File
@@ -196,14 +196,14 @@ async function verifyRemoteSelectionDialogue(mode: DialogueMode): Promise<string
`setup-remote-selection-dialogue${mode === "mobile" ? "-mobile" : ""}.png`,
async (page) => {
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Enter Server Information" }),
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/MinIO/R2 Object Storage", "Peer-to-Peer only"]) {
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
@@ -222,7 +222,7 @@ async function verifyRemoteSelectionDialogue(mode: DialogueMode): Promise<string
);
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Enter Server Information" }),
has: page.locator(".modal-title").filter({ hasText: "Choose a synchronisation remote" }),
});
if (mode === "mobile") {
const previousDialogue = await modal.locator(".modal").last().elementHandle();
@@ -243,7 +243,7 @@ async function verifyCouchDBSettingsDialogue(mode: DialogueMode): Promise<string
await openRemoteSelectionDialogue();
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const remoteSelection = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Enter Server Information" }),
has: page.locator(".modal-title").filter({ hasText: "Choose a synchronisation remote" }),
});
await remoteSelection
.locator("label")
+4
View File
@@ -20,6 +20,10 @@ const testSteps: Step[] = [
{ name: "P2P status pane", args: ["run", "test:e2e:obsidian:p2p-pane"] },
{ name: "vault reflection", args: ["run", "test:e2e:obsidian:vault-reflection"] },
{ name: "CouchDB upload", args: ["run", "test:e2e:obsidian:couchdb-upload"] },
{
name: "manual CouchDB setup workflow",
args: ["run", "test:e2e:obsidian:couchdb-manual-setup-workflow"],
},
{
name: "CLI to real Obsidian synchronisation",
args: ["run", "test:e2e:obsidian:cli-to-obsidian-sync"],
+1
View File
@@ -13,6 +13,7 @@ const focusedScenarios = new Set([
"p2p-pane",
"vault-reflection",
"couchdb-upload",
"couchdb-manual-setup-workflow",
"cli-to-obsidian-sync",
"minio-upload",
"object-storage-setup-uri-workflow",