import { randomBytes } from "node:crypto"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { DEVICE_ID_PREFERRED, MILESTONE_DOCID } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { deriveIdKey, formatIdRecoveryCode } from "@vrtmrz/livesync-commonlib/settings"; import { evalObsidianJson } from "../runner/cli.ts"; import { assertCouchDbReachable, deleteCouchDbDatabase, fetchCouchDbDocument, loadCouchDbConfig, makeUniqueDatabaseName, putCouchDbDocument, waitForCouchDbDocs, type CouchDbConfig, } from "../runner/couchdb.ts"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { assertEqual, assertE2eCompatibilityUnpaused, pushLocalChanges, waitForLocalDatabaseEntry, } from "../runner/liveSyncWorkflow.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { acknowledgeDisabledOptionalFeatures, captureAndStartInitialisation, captureGuideDialogue, confirmFastFetch, confirmRebuild, enterSetupURI, finishInitialisation, generateSetupURIFromDevice, modalByTitle, selectRadioOption, continueWithoutRemoteSettings, type SetupArtifact, } from "../runner/setupUri.ts"; import { captureObsidianPage, openLiveSyncSettings, withObsidianPage } from "../runner/ui.ts"; import { dismissConfigDoctorIfShown } from "../runner/upgradeWorkflow.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 e2eePassphrase = `%${randomBytes(24).toString("base64url")}`; 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; const e2eeRebuildCaptures = { scenario: "couchdb-manual-setup-workflow", guide: "couchdb-manual-e2ee-rebuild", } as const; type RunnerContext = { binary: string; cliBinary: string; couchDb: CouchDbConfig; dbName: string; activeSessions: Set; }; async function startUnconfiguredSession( context: RunnerContext, vault: TemporaryVault ): Promise { 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 { if (!context.activeSessions.has(session)) return; await session.app.stop(); context.activeSessions.delete(session); } async function stopTrackedSessions(context: RunnerContext): Promise { for (const session of [...context.activeSessions]) { await stopTrackedSession(context, session); } } async function captureFailure(session: ObsidianLiveSyncSession, label: string): Promise { 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, independentIdSource?: string ): Promise { 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 }); assertEqual( await encryption.locator('input[name="e2ee-passphrase"]').count(), 0, "The passphrase field was present before end-to-end encryption was enabled." ); assertEqual( await encryption.locator("label.row").filter({ hasText: "Obfuscate Properties" }).count(), 0, "The Obfuscate Properties row was present before end-to-end encryption was enabled." ); const disabledIdChoices = encryption.locator("fieldset.sls-id-choices").first(); assertEqual( await disabledIdChoices.evaluate((element) => element.hasAttribute("disabled")), true, "The ID configuration was enabled while E2EE was off." ); for (const value of ["keep", "random", "custom"]) { assertEqual( await disabledIdChoices.locator(`input[value="${value}"]`).isDisabled(), true, `The ${value} ID configuration was enabled while E2EE was off.` ); } const disabledIdScreenshot = join( process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e", "guide-couchdb-manual-id-generation-disabled.png" ); await disabledIdChoices.screenshot({ path: disabledIdScreenshot }); screenshots.push(disabledIdScreenshot); assertEqual( await disabledIdChoices.evaluate((element) => Number(getComputedStyle(element).opacity) < 1), true, "The disabled ID configuration did not look disabled in the default theme." ); await encryption .locator("label.row") .filter({ hasText: "End-to-End Encryption" }) .locator('input[type="checkbox"]') .first() .check({ timeout: uiTimeoutMs }); assertEqual( await disabledIdChoices.evaluate((element) => Number(getComputedStyle(element).opacity)), 1, "The ID configuration remained dimmed after E2EE was enabled." ); const passphraseInput = encryption.locator('input[name="e2ee-passphrase"]'); await passphraseInput.waitFor({ state: "visible", timeout: uiTimeoutMs }); await encryption .locator("label.row") .filter({ hasText: "Obfuscate Properties" }) .locator('input[type="checkbox"]') .first() .check({ timeout: uiTimeoutMs }); await passphraseInput.fill(e2eePassphrase); const idChoices = encryption.locator('input[type="radio"][name="id-derivation-choice"]'); assertEqual(await idChoices.count(), 3, "The three ID configurations were not all shown."); for (const value of ["keep", "random", "custom"]) { assertEqual( await encryption.locator(`input[name="id-derivation-choice"][value="${value}"]`).isVisible(), true, `The ${value} ID configuration was not visible.` ); } const keepChoice = encryption.locator('input[name="id-derivation-choice"][value="keep"]'); const randomChoice = encryption.locator('input[name="id-derivation-choice"][value="random"]'); const customChoice = encryption.locator('input[name="id-derivation-choice"][value="custom"]'); assertEqual(await randomChoice.isChecked(), true, "The default ID configuration was not random."); assertEqual( await encryption.getByText("Keep current configuration", { exact: true }).count(), 1, "The current-configuration choice was not labelled consistently." ); assertEqual( await encryption.getByText("Current configuration: no ID key is saved.", { exact: false }).count(), 1, "The current legacy configuration was not explained." ); await keepChoice.check({ timeout: uiTimeoutMs }); assertEqual( await encryption.getByText("Changing the E2EE passphrase changes IDs", { exact: false }).count(), 1, "Keeping legacy IDs did not explain the effect of changing the E2EE passphrase." ); await randomChoice.check({ timeout: uiTimeoutMs }); if (independentIdSource) { await customChoice.check({ timeout: uiTimeoutMs }); const customChoices = encryption.locator('input[type="radio"][name="id-custom-choice"]'); assertEqual(await customChoices.count(), 3, "The three custom ID inputs were not all shown."); for (const value of ["passphrase", "source", "recovery"]) { assertEqual( await encryption.locator(`input[name="id-custom-choice"][value="${value}"]`).isVisible(), true, `The ${value} custom ID input was not visible.` ); } const sourceChoice = encryption.locator('input[name="id-custom-choice"][value="source"]'); assertEqual(await sourceChoice.isChecked(), true, "The custom ID input was not selected by default."); await encryption .locator('input[name="id-custom-choice"][value="passphrase"]') .check({ timeout: uiTimeoutMs }); assertEqual( await encryption.locator('input[name="id-derivation-source"]').count(), 0, "The E2EE passphrase choice exposed a second source input." ); await encryption .locator('input[name="id-custom-choice"][value="recovery"]') .check({ timeout: uiTimeoutMs }); assertEqual( await encryption.locator('input[name="id-derivation-source"]').getAttribute("placeholder"), "Enter an ID recovery code", "The recovery-code choice did not request a recovery code." ); const recoveryChoice = encryption.locator('input[name="id-custom-choice"][value="recovery"]'); await sourceChoice.check({ timeout: uiTimeoutMs }); const sourceInput = encryption.locator('input[name="id-derivation-source"]'); await sourceInput.fill(""); await encryption.getByRole("button", { name: "Proceed", exact: true }).click({ timeout: uiTimeoutMs }); assertEqual( await encryption.getByText("An ID source is required to enable this option.", { exact: false }).count(), 1, "A first-time independent ID configuration did not require a source." ); assertEqual( await encryption.isVisible(), true, "The E2EE dialogue closed after a first-time ID source was omitted." ); await recoveryChoice.check({ timeout: uiTimeoutMs }); await sourceChoice.check({ timeout: uiTimeoutMs }); await sourceInput.fill(independentIdSource); } const passwordToggle = passphraseInput.locator("..").locator("button.sls-password-toggle"); await passwordToggle.click({ timeout: uiTimeoutMs }); assertEqual( await passphraseInput.getAttribute("type"), "text", "Toggling visibility did not reveal the passphrase." ); assertEqual( await passphraseInput.inputValue(), e2eePassphrase, "Toggling visibility changed the passphrase value." ); await passwordToggle.click({ timeout: uiTimeoutMs }); assertEqual( await passphraseInput.getAttribute("type"), "password", "Toggling visibility again did not re-mask the passphrase." ); assertEqual(await passphraseInput.inputValue(), e2eePassphrase, "Re-masking the passphrase changed its value."); }); 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 }); await encryption.waitFor({ state: "hidden", 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 { await evalObsidianJson( 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 { 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 { 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 assertPersistedE2EE(vault: TemporaryVault, independentIdSource?: string): Promise { const rawSettings = await readFile( join(vault.path, ".obsidian", "plugins", "obsidian-livesync", "data.json"), "utf8" ); const persisted = JSON.parse(rawSettings) as { encrypt?: unknown; encryptedPassphrase?: unknown; passphrase?: unknown; idDerivationVersion?: unknown; idDerivationKey?: unknown; encryptedIdDerivationKey?: unknown; }; assertEqual(persisted.encrypt, true, "Manual CouchDB setup did not persist E2EE as enabled."); assertEqual(persisted.passphrase, "", "Manual CouchDB setup persisted the E2EE passphrase in plain text."); if (typeof persisted.encryptedPassphrase !== "string" || persisted.encryptedPassphrase.length === 0) { throw new Error("Manual CouchDB setup did not persist an encrypted E2EE passphrase."); } if (JSON.stringify(persisted).includes(e2eePassphrase)) { throw new Error("Manual CouchDB setup persisted the E2EE passphrase in plain text."); } assertEqual(persisted.idDerivationVersion, 1, "The independent ID mode was not persisted."); assertEqual(persisted.idDerivationKey, "", "The derived ID key was stored in plain text."); if (typeof persisted.encryptedIdDerivationKey !== "string" || !persisted.encryptedIdDerivationKey) { throw new Error("The derived ID key was not encrypted in local settings."); } if (independentIdSource) { if (rawSettings.includes(independentIdSource)) throw new Error("The ID source was stored in local settings."); } } async function assertRestoredE2EEPassphrase(session: ObsidianLiveSyncSession, cliBinary: string): Promise { const restored = await evalObsidianJson( cliBinary, [ "(()=>{", "const settings=app.plugins.plugins['obsidian-livesync'].core.services.setting.currentSettings();", `return JSON.stringify(settings.passphrase === ${JSON.stringify(e2eePassphrase)});`, "})()", ].join(""), session.cliEnv ); assertEqual(restored, true, "The E2EE passphrase was not restored after Obsidian restarted."); } async function assertCurrentIdDerivationKey( session: ObsidianLiveSyncSession, cliBinary: string, expected: string, context: string ): Promise { const settings = await evalObsidianJson<{ idDerivationVersion: number; idDerivationKey: string }>( cliBinary, [ "(()=>{", "const settings=app.plugins.plugins['obsidian-livesync'].core.services.setting.currentSettings();", "return JSON.stringify({idDerivationVersion:settings.idDerivationVersion,idDerivationKey:settings.idDerivationKey});", "})()", ].join(""), session.cliEnv ); assertEqual(settings.idDerivationVersion, 1, `${context}: the independent ID version was not retained.`); assertEqual(settings.idDerivationKey, expected, `${context}: the saved ID key changed.`); } async function assertRecoveryCodeCanBeRevealed( session: ObsidianLiveSyncSession, cliBinary: string, source: string ): Promise { const port = session.remoteDebuggingPort; const expected = formatIdRecoveryCode(await deriveIdKey(source)); await withObsidianPage(port, async (page) => { const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs); const remotePage = await settingsNavigator.openPage("Remote Configuration"); await remotePage .locator(".setting-item") .filter({ hasText: "Configure E2EE" }) .getByRole("button", { name: "Configure", exact: true }) .click({ timeout: uiTimeoutMs }); const encryption = modalByTitle(page, "End-to-End Encryption"); await encryption.waitFor({ state: "visible", timeout: uiTimeoutMs }); assertEqual( await encryption.locator('input[name="id-derivation-choice"][value="keep"]').isChecked(), true, "An existing ID key was not selected for reuse." ); assertEqual( await encryption.getByText("Current configuration: a saved ID key is used.").count(), 1, "The saved ID key was not explained." ); await encryption.getByRole("button", { name: "Show current recovery code" }).click({ timeout: uiTimeoutMs }); assertEqual( await encryption.getByRole("textbox", { name: "Current ID recovery code" }).inputValue(), expected, "The displayed recovery code did not contain the saved ID key." ); await encryption.locator('input[name="id-derivation-choice"][value="custom"]').check({ timeout: uiTimeoutMs }); await encryption.locator('input[name="id-custom-choice"][value="recovery"]').check({ timeout: uiTimeoutMs }); const recoveryInput = encryption.locator('input[name="id-derivation-source"]'); await recoveryInput.fill("not-a-recovery-code"); await encryption.getByRole("button", { name: "Proceed" }).click({ timeout: uiTimeoutMs }); assertEqual( await encryption.getByText("The ID source or recovery code is invalid.", { exact: false }).count(), 1, "The recovery-code choice accepted an ordinary source string." ); await recoveryInput.fill(expected); await encryption.getByRole("button", { name: "Proceed" }).click({ timeout: uiTimeoutMs }); await encryption.waitFor({ state: "hidden", timeout: uiTimeoutMs }); assertEqual( await modalByTitle(page, "Mostly Complete: Decision Required").count(), 0, "Recovering the existing ID key opened a new setup decision." ); }); const expectedIdKey = await deriveIdKey(source); await assertCurrentIdDerivationKey(session, cliBinary, expectedIdKey, "Recovering the saved ID key"); await withObsidianPage(port, async (page) => { const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs); const remotePage = await settingsNavigator.openPage("Remote Configuration"); await remotePage .locator(".setting-item") .filter({ hasText: "Configure E2EE" }) .getByRole("button", { name: "Configure", exact: true }) .click({ timeout: uiTimeoutMs }); const encryption = modalByTitle(page, "End-to-End Encryption"); await encryption.waitFor({ state: "visible", timeout: uiTimeoutMs }); await encryption.locator('input[name="id-derivation-choice"][value="custom"]').check({ timeout: uiTimeoutMs }); await encryption.locator('input[name="id-custom-choice"][value="source"]').check({ timeout: uiTimeoutMs }); const sourceInput = encryption.locator('input[name="id-derivation-source"]'); await sourceInput.fill(""); assertEqual(await sourceInput.inputValue(), "", "The independent ID source input was not empty."); assertEqual( await encryption.getByText("Leave this input empty to keep the saved ID key.", { exact: true }).count(), 1, "The configured ID source did not explain that an empty input keeps the saved ID key." ); await encryption.getByRole("button", { name: "Proceed", exact: true }).click({ timeout: uiTimeoutMs }); await encryption.waitFor({ state: "hidden", timeout: uiTimeoutMs }); assertEqual( await modalByTitle(page, "Mostly Complete: Decision Required").count(), 0, "Keeping the saved ID key after an empty source opened a new setup decision." ); }); await assertCurrentIdDerivationKey(session, cliBinary, expectedIdKey, "Saving an empty custom ID source"); } async function readDocumentId(cliBinary: string, environment: NodeJS.ProcessEnv, path: string): Promise { return await evalObsidianJson( cliBinary, [ "(async()=>{", `const path=${JSON.stringify(path)};`, "const core=app.plugins.plugins['obsidian-livesync'].core;", "return JSON.stringify(await core.services.path.path2id(path));", "})()", ].join(""), environment ); } async function setRemotePreferredE2EEDisabled(context: RunnerContext): Promise { const milestone = await fetchCouchDbDocument(context.couchDb, context.dbName, MILESTONE_DOCID); const tweakValues = milestone.tweak_values; if (typeof tweakValues !== "object" || tweakValues === null || Array.isArray(tweakValues)) { throw new Error("The existing CouchDB milestone did not contain synchronisation settings."); } const preferred = (tweakValues as Record)[DEVICE_ID_PREFERRED]; if (typeof preferred !== "object" || preferred === null || Array.isArray(preferred)) { throw new Error("The existing CouchDB milestone did not contain preferred synchronisation settings."); } await putCouchDbDocument(context.couchDb, context.dbName, { ...milestone, tweak_values: { ...tweakValues, [DEVICE_ID_PREFERRED]: { ...(preferred as Record), encrypt: false, }, }, }); } async function assertRemotePreferredE2EE(context: RunnerContext, expected: boolean): Promise { const milestone = await fetchCouchDbDocument(context.couchDb, context.dbName, MILESTONE_DOCID); const tweakValues = milestone.tweak_values; const preferred = typeof tweakValues === "object" && tweakValues !== null && !Array.isArray(tweakValues) ? (tweakValues as Record)[DEVICE_ID_PREFERRED] : undefined; const encrypt = typeof preferred === "object" && preferred !== null && !Array.isArray(preferred) ? (preferred as Record).encrypt : undefined; assertEqual(encrypt, expected, `The remote preferred E2EE setting was not ${expected ? "enabled" : "disabled"}.`); } async function scheduleRemoteOverwrite(port: number): Promise { await dismissConfigDoctorIfShown(port); await withObsidianPage(port, async (page) => { const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs); const maintenance = await settingsNavigator.openPage("Maintenance"); const overwrite = maintenance .locator(".setting-item") .filter({ hasText: "Overwrite Server Data with This Device's Files" }); await overwrite .getByRole("button", { name: "Schedule and Restart", exact: true }) .click({ timeout: uiTimeoutMs }); }); } async function assertRemoteEntryEncrypted( context: RunnerContext, entry: { id: string; path: string; children: string[] }, plaintextPath: string, plaintext: string ): Promise { const remoteMetadata = await fetchCouchDbDocument(context.couchDb, context.dbName, entry.id); const serialisedMetadata = JSON.stringify(remoteMetadata); if ( !remoteMetadata._id.startsWith("f:") || typeof remoteMetadata.path !== "string" || !remoteMetadata.path.startsWith("/\\:") || remoteMetadata.path === entry.path || serialisedMetadata.includes(plaintextPath) || !Array.isArray(remoteMetadata.children) || remoteMetadata.children.length !== 0 || remoteMetadata.mtime !== 0 || remoteMetadata.ctime !== 0 || remoteMetadata.size !== 0 ) { throw new Error("The directly fetched CouchDB Metadata document did not protect its properties."); } const childId = entry.children[0]; if (!childId) { throw new Error("The local E2EE test entry did not reference a Chunk document."); } if (!childId.startsWith("h:+")) { throw new Error(`The E2EE test entry used an unencrypted Chunk identifier: ${childId}`); } const remoteChunk = await fetchCouchDbDocument(context.couchDb, context.dbName, childId); assertEqual(remoteChunk.e_, true, "The directly fetched CouchDB Chunk was not marked as encrypted."); if ( typeof remoteChunk.data !== "string" || remoteChunk.data === plaintext || remoteChunk.data.includes(plaintext) ) { throw new Error("The directly fetched CouchDB Chunk contained readable Vault content."); } } async function main(): Promise { const binary = requireObsidianBinary(); const cli = discoverObsidianCli(); if (!cli.binary) { throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`); } const couchDb = await loadCouchDbConfig(); const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "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; const independentIdSource = process.env.E2E_OBSIDIAN_INDEPENDENT_IDS === "true" ? randomBytes(32).toString("base64url") : undefined; let firstEntryId: string | undefined; let returnEntryId: string | 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, independentIdSource)) ); screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "new", captures)); screenshots.push(await confirmRebuild(session.remoteDebuggingPort, captures)); screenshots.push(await continueWithoutRemoteSettings(session.remoteDebuggingPort, captures)); screenshots.push(await acknowledgeDisabledOptionalFeatures(session.remoteDebuggingPort, captures)); const state = await finishInitialisation(session.remoteDebuggingPort, context.cliBinary, session.cliEnv); await assertE2eCompatibilityUnpaused(context.cliBinary, session.cliEnv, 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 assertPersistedE2EE(vaultA, independentIdSource); if (independentIdSource) { await assertRecoveryCodeCanBeRevealed(session, context.cliBinary, independentIdSource); } await writeNoteViaObsidian(context.cliBinary, session.cliEnv, notePath, noteContent); const entry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, notePath); firstEntryId = entry.id; await pushLocalChanges(context.cliBinary, session.cliEnv); await waitForRemoteEntry(context, entry); } catch (error) { await captureFailure(session, "first-device"); throw error; } finally { await stopTrackedSession(context, session); } await setRemotePreferredE2EEDisabled(context); await assertRemotePreferredE2EE(context, false); session = await startUnconfiguredSession(context, vaultA); try { await assertRestoredE2EEPassphrase(session, context.cliBinary); await scheduleRemoteOverwrite(session.remoteDebuggingPort); screenshots.push(await confirmRebuild(session.remoteDebuggingPort, e2eeRebuildCaptures)); screenshots.push( await acknowledgeDisabledOptionalFeatures(session.remoteDebuggingPort, e2eeRebuildCaptures) ); await finishInitialisation(session.remoteDebuggingPort, context.cliBinary, session.cliEnv); await assertE2eCompatibilityUnpaused(context.cliBinary, session.cliEnv, session.remoteDebuggingPort); await assertPersistedE2EE(vaultA, independentIdSource); const rebuiltEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, notePath); if (independentIdSource) { assertEqual(rebuiltEntry.id, firstEntryId, "Rebuild changed the configured document ID."); } await waitForRemoteEntry(context, rebuiltEntry); await assertRemoteEntryEncrypted(context, rebuiltEntry, notePath, noteContent); await assertRemotePreferredE2EE(context, true); const generated = await generateSetupURIFromDevice( session.remoteDebuggingPort, randomBytes(24).toString("base64url"), captures ); secondDeviceArtifact = generated.artifact; screenshots.push(...generated.screenshots); } catch (error) { await captureFailure(session, "e2ee-rebuild"); 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 assertE2eCompatibilityUnpaused(context.cliBinary, session.cliEnv, session.remoteDebuggingPort); await assertPersistedE2EE(vaultB, independentIdSource); await pushLocalChanges(context.cliBinary, session.cliEnv); await waitForVaultFile(vaultB, notePath, noteContent); if (independentIdSource) { assertEqual( await readDocumentId(context.cliBinary, session.cliEnv, notePath), firstEntryId, "The Setup URI did not restore the document ID key on the second device." ); } await writeNoteViaObsidian(context.cliBinary, session.cliEnv, returnNotePath, returnNoteContent); const returnEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, returnNotePath); returnEntryId = returnEntry.id; 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 assertE2eCompatibilityUnpaused(context.cliBinary, session.cliEnv, session.remoteDebuggingPort); await pushLocalChanges(context.cliBinary, session.cliEnv); await waitForVaultFile(vaultA, returnNotePath, returnNoteContent); if (independentIdSource) { assertEqual( await readDocumentId(context.cliBinary, session.cliEnv, returnNotePath), returnEntryId, "The first device did not retain the shared document ID key." ); } } 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); });