import { execFile } from "node:child_process"; import { randomBytes } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { promisify } from "node:util"; import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types"; import type { Locator, Page } from "playwright"; 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, type LocalDatabaseEntry, } from "../runner/liveSyncWorkflow.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { assertVerticalActionLayout, generateSetupURIFromDevice, resumeCompatibilityReviewIfShown, } from "../runner/setupUri.ts"; import { captureObsidianDialogue, captureObsidianElement, 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 execFileAsync = promisify(execFile); const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_URI_TIMEOUT_MS ?? 30000); const initialisationTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_INITIALISATION_TIMEOUT_MS ?? 120000); const hiddenFileCliTimeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_CLI_TIMEOUT_MS ?? 90000); const notePath = "E2E/setup-uri/provisioned-workflow.md"; const noteContent = "# Provisioned Setup URI\n\nThis note travelled through the generated CouchDB Setup URI.\n"; const returnNotePath = "E2E/setup-uri/from-second-device.md"; const returnNoteContent = "# CouchDB from the second device\n\nThis note completed the return journey through CouchDB.\n"; const snippetPath = ".obsidian/snippets/setup-uri-workflow.css"; const snippetContent = [ "body {", " --setup-uri-workflow-colour: #245a70;", "}", "", ".setup-uri-workflow {", " color: var(--setup-uri-workflow-colour);", "}", "", ].join("\n"); type SetupArtifact = { setupURI: string; setupPassphrase: string; }; type SetupState = { configured: boolean; databaseReady: boolean; appReady: boolean; suspended: boolean; remoteType: string; activeConfigurationId: string; remoteConfigurationCount: number; syncInternalFiles: boolean; syncInternalFilesBeforeReplication: boolean; }; type RunnerContext = { binary: string; cliBinary: string; couchDb: CouchDbConfig; dbName: string; activeSessions: Set; }; function modalByTitle(page: Page, title: string): Locator { return page.locator(".modal-container").filter({ has: page.locator(".modal-title").filter({ hasText: title }), }); } function settingPanelByTitle(page: Page, title: string): Locator { return page .locator(".sls-setting") .locator("h4.sls-setting-panel-title:visible") .filter({ hasText: title }) .locator(".."); } async function captureGuideDialogue(port: number, filename: string, title: string): Promise { return await captureObsidianElement(port, filename, (page) => modalByTitle(page, title).locator(".modal").first()); } async function selectRadioOption(modal: Locator, title: string): Promise { const radio = modal.locator("label").filter({ hasText: title }).locator('input[type="radio"]').first(); await radio.check({ timeout: uiTimeoutMs }); } async function selectCheckbox(modal: Locator, title: string): Promise { const checkbox = modal.locator("label").filter({ hasText: title }).locator('input[type="checkbox"]').first(); await checkbox.check({ timeout: uiTimeoutMs }); } async function writeVaultFile(vaultPath: string, path: string, content: string): Promise { const fullPath = join(vaultPath, path); await mkdir(dirname(fullPath), { recursive: true }); await writeFile(fullPath, content, "utf8"); } async function readVaultFile(vaultPath: string, path: string): Promise { return await readFile(join(vaultPath, path), "utf8"); } async function waitForPathContent( vaultPath: string, 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 readVaultFile(vaultPath, path); if (lastContent === expected) return lastContent; } 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 runDeno(script: string, permissions: string[], environment: NodeJS.ProcessEnv): Promise { const { stdout } = await execFileAsync( "deno", [ "run", "--minimum-dependency-age=0", "--config=utils/flyio/deno.jsonc", "--frozen", "--lock=utils/flyio/deno.lock", ...permissions, script, ], { cwd: process.cwd(), env: environment, maxBuffer: 4 * 1024 * 1024, } ); return stdout; } async function provisionAndGenerateSetupURI(couchDb: CouchDbConfig, dbName: string): Promise { const setupPassphrase = randomBytes(24).toString("base64url"); const environment = { ...process.env, hostname: couchDb.uri, username: couchDb.username, password: couchDb.password, database: dbName, passphrase: randomBytes(24).toString("base64url"), uri_passphrase: setupPassphrase, remote_type: "couchdb", retry_count: "3", retry_delay_ms: "250", }; await runDeno("utils/couchdb/provision.ts", ["--allow-env", "--allow-net"], environment); const output = await runDeno("utils/setup/generate_setup_uri.ts", ["--allow-env"], environment); const setupURI = output.split(/\r?\n/u).find((line) => line.startsWith("obsidian://setuplivesync?settings=")); if (!setupURI) throw new Error("The public Setup URI generator did not emit a Setup URI."); return { setupURI, setupPassphrase }; } 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 enterSetupURI(port: number, mode: "new" | "existing", artifact: SetupArtifact): Promise { 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 }); if (mode === "new") { 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 }); } else { await selectRadioOption(intro, "I am adding a device to an existing synchronisation setup"); await intro .getByRole("button", { name: "Yes, I want to add this device to my existing synchronisation" }) .click({ timeout: uiTimeoutMs }); } const method = modalByTitle(page, mode === "new" ? "Connection Method" : "Device Setup Method"); await method.waitFor({ state: "visible", timeout: uiTimeoutMs }); await selectRadioOption(method, "Use a Setup URI (Recommended)"); await method.getByRole("button", { name: "Proceed with Setup URI" }).click({ timeout: uiTimeoutMs }); const setup = modalByTitle(page, "Enter Setup URI"); await setup.waitFor({ state: "visible", timeout: uiTimeoutMs }); await setup.locator('input[placeholder^="obsidian://setuplivesync"]').fill(artifact.setupURI); await setup.locator('input[name="password"]').fill(artifact.setupPassphrase); }); await captureGuideDialogue( port, `guide-quick-setup-${mode === "new" ? "first" : "second"}-setup-uri.png`, "Enter Setup URI" ); await withObsidianPage(port, async (page) => { await modalByTitle(page, "Enter Setup URI") .getByRole("button", { name: "Test Settings and Continue" }) .click({ timeout: uiTimeoutMs }); }); } async function captureAndStartInitialisation(port: number, mode: "new" | "existing"): Promise { const title = mode === "new" ? "Setup Complete: Preparing to Initialise Server" : "Setup Complete: Preparing to Fetch Synchronisation Data"; const button = mode === "new" ? "Restart and Initialise Server" : "Restart and Fetch Data"; const screenshot = await captureObsidianDialogue( port, `setup-uri-${mode === "new" ? "first-initialise" : "second-fetch"}.png`, async (page) => { await modalByTitle(page, title).waitFor({ state: "visible", timeout: uiTimeoutMs }); } ); await captureGuideDialogue( port, `guide-quick-setup-${mode === "new" ? "first-initialise" : "second-fetch"}.png`, title ); await withObsidianPage(port, async (page) => { await modalByTitle(page, title).getByRole("button", { name: button }).click({ timeout: uiTimeoutMs }); }); return screenshot; } async function confirmRebuild(port: number): Promise { const title = "Final Confirmation: Overwrite Server Data with This Device's Files"; const screenshot = await captureObsidianDialogue(port, "setup-uri-first-rebuild-confirmation.png", async (page) => { await modalByTitle(page, title).waitFor({ state: "visible", timeout: uiTimeoutMs }); }); await captureGuideDialogue(port, "guide-quick-setup-first-rebuild-confirmation.png", title); await withObsidianPage(port, async (page) => { const modal = modalByTitle(page, title); await selectCheckbox( modal, "I understand that all changes made on other smartphones or computers possibly could be lost." ); await selectCheckbox( modal, "I understand that other devices will no longer be able to synchronise, and will need to be reset the synchronisation information." ); await selectCheckbox(modal, "I understand that this action is irreversible once performed."); await selectRadioOption(modal, "I understand the risks and will proceed without a backup."); await modal.getByRole("button", { name: "I Understand, Overwrite Server" }).click({ timeout: uiTimeoutMs }); }); return screenshot; } async function skipMissingRemoteConfiguration(port: number): Promise { const title = "Fetch Remote Configuration Failed"; const screenshot = await captureObsidianDialogue( port, "setup-uri-first-missing-remote-configuration.png", async (page) => { const modal = modalByTitle(page, title); await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); await modal .getByText("If you are new to the Self-hosted LiveSync, this might be expected.", { exact: false, }) .waitFor({ state: "visible", timeout: uiTimeoutMs }); } ); await captureGuideDialogue(port, "guide-quick-setup-missing-remote-configuration.png", title); await withObsidianPage(port, async (page) => { await modalByTitle(page, title) .getByRole("button", { name: "Skip and proceed" }) .click({ timeout: uiTimeoutMs }); }); return screenshot; } async function acknowledgeDisabledOptionalFeatures(port: number): Promise { const title = "All optional features are disabled"; const screenshot = await captureObsidianDialogue( port, "setup-uri-first-optional-features-disabled.png", async (page) => { const modal = modalByTitle(page, title); await modal.waitFor({ state: "visible", timeout: initialisationTimeoutMs }); await modal .getByText("Please enable them from the settings screen after setup is complete.", { exact: false, }) .waitFor({ state: "visible", timeout: uiTimeoutMs }); } ); await captureGuideDialogue(port, "guide-quick-setup-optional-features-disabled.png", title); await withObsidianPage(port, async (page) => { await modalByTitle(page, title).getByRole("button", { name: "OK" }).click({ timeout: uiTimeoutMs }); }); return screenshot; } async function confirmFastFetch(port: number): Promise { const firstTitle = "Data retrieval scheduled"; await assertVerticalActionLayout(port, firstTitle); const firstScreenshot = await captureObsidianDialogue( port, "setup-uri-second-retrieval-method.png", async (page) => { await modalByTitle(page, firstTitle).waitFor({ state: "visible", timeout: uiTimeoutMs }); } ); await captureGuideDialogue(port, "guide-quick-setup-retrieval-method.png", firstTitle); await withObsidianPage(port, async (page) => { await modalByTitle(page, firstTitle) .getByRole("button", { name: "Overwrite all with remote files" }) .click({ timeout: uiTimeoutMs }); }); const secondTitle = "How to handle extra existing local files?"; await assertVerticalActionLayout(port, secondTitle); const secondScreenshot = await captureObsidianDialogue( port, "setup-uri-second-local-file-policy.png", async (page) => { await modalByTitle(page, secondTitle).waitFor({ state: "visible", timeout: uiTimeoutMs }); } ); await captureGuideDialogue(port, "guide-quick-setup-local-file-policy.png", secondTitle); await withObsidianPage(port, async (page) => { await modalByTitle(page, secondTitle) .getByRole("button", { name: "Keep local files even if not on remote" }) .click({ timeout: uiTimeoutMs }); }); return [firstScreenshot, secondScreenshot]; } function isConfiguredSetupReady(state: SetupState): boolean { return ( state.configured && state.databaseReady && state.appReady && !state.suspended && state.activeConfigurationId !== "" && state.remoteConfigurationCount === 1 ); } async function finishInitialisation( port: number, filename: string, cliBinary: string, environment: NodeJS.ProcessEnv ): Promise<{ state: SetupState; screenshot?: string }> { const message = "Do you want to resume file and database processing, and restart obsidian now?"; const deadline = Date.now() + initialisationTimeoutMs; let lastState: SetupState | undefined; let lastError: unknown; while (Date.now() < deadline) { const resumeVisible = await withObsidianPage(port, async (page) => { return await modalByTitle(page, "Confirmation").filter({ hasText: message }).isVisible(); }).catch(() => false); if (resumeVisible) { const screenshot = await captureObsidianDialogue(port, filename, async (page) => { await modalByTitle(page, "Confirmation") .filter({ hasText: message }) .waitFor({ state: "visible", timeout: uiTimeoutMs }); }); await withObsidianPage(port, async (page) => { const modal = modalByTitle(page, "Confirmation").filter({ hasText: message }); await modal.getByText(message, { exact: true }).click({ timeout: uiTimeoutMs }); await modal.getByRole("button", { name: "Yes", exact: true }).click({ timeout: uiTimeoutMs }); }); return { state: await waitForConfiguredSetup(cliBinary, environment, initialisationTimeoutMs), screenshot, }; } try { lastState = await readSetupState(cliBinary, environment); if (isConfiguredSetupReady(lastState)) return { state: lastState }; } catch (error) { lastError = error; } await new Promise((resolve) => setTimeout(resolve, 500)); } throw new Error( `Timed out waiting for Setup URI initialisation to finish: ${JSON.stringify(lastState)}${ lastError instanceof Error ? `; last error: ${lastError.message}` : "" }` ); } async function readSetupState(cliBinary: string, environment: NodeJS.ProcessEnv): Promise { return await evalObsidianJson( cliBinary, [ "(()=>{", "const core=app.plugins.plugins['obsidian-livesync'].core;", "const settings=core.services.setting.currentSettings();", "return JSON.stringify({", "configured:settings.isConfigured===true,", "databaseReady:core.services.database.isDatabaseReady(),", "appReady:core.services.appLifecycle.isReady(),", "suspended:core.services.appLifecycle.isSuspended(),", "remoteType:settings.remoteType,", "activeConfigurationId:settings.activeConfigurationId||'',", "remoteConfigurationCount:Object.keys(settings.remoteConfigurations||{}).length,", "syncInternalFiles:settings.syncInternalFiles===true,", "syncInternalFilesBeforeReplication:settings.syncInternalFilesBeforeReplication===true,", "});", "})()", ].join(""), environment ); } async function waitForConfiguredSetup( cliBinary: string, environment: NodeJS.ProcessEnv, timeoutMs = initialisationTimeoutMs ): Promise { const deadline = Date.now() + timeoutMs; let lastState: SetupState | undefined; let lastError: unknown; while (Date.now() < deadline) { try { lastState = await readSetupState(cliBinary, environment); if (isConfiguredSetupReady(lastState)) { return lastState; } } catch (error) { lastError = error; } await new Promise((resolve) => setTimeout(resolve, 500)); } throw new Error( `Timed out waiting for configured Setup URI state: ${JSON.stringify(lastState)}${ lastError instanceof Error ? `; last error: ${lastError.message}` : "" }` ); } async function enableHiddenFileSync(cliBinary: string, environment: NodeJS.ProcessEnv): Promise { await evalObsidianJson( cliBinary, [ "(async()=>{", "const core=app.plugins.plugins['obsidian-livesync'].core;", "await core.services.setting.applyPartial({", "syncInternalFiles:true,", "syncInternalFilesBeforeReplication:true,", "},true);", "await core.services.control.applySettings();", "return JSON.stringify({ok:true});", "})()", ].join(""), environment ); const state = await waitForConfiguredSetup(cliBinary, environment); if (!state.syncInternalFiles || !state.syncInternalFilesBeforeReplication) { throw new Error(`Hidden File Sync was not enabled after setup: ${JSON.stringify(state)}`); } return state; } async function captureHiddenFileGuideSettings( port: number, cliBinary: string, environment: NodeJS.ProcessEnv ): Promise { await evalObsidianJson( cliBinary, [ "(async()=>{", "const core=app.plugins.plugins['obsidian-livesync'].core;", "await core.services.setting.applyPartial({", "useAdvancedMode:true,", "syncInternalFilesTargetPatterns:'^\\\\.obsidian(?:$|/snippets(?:/|$))',", "},true);", "await core.services.control.applySettings();", "return JSON.stringify({ok:true});", "})()", ].join(""), environment ); await withObsidianPage(port, async (page) => { await page.evaluate(() => { const obsidian = globalThis as typeof globalThis & { app?: { setting?: { open(): void; openTabById(tabId: string): void; }; }; }; const setting = obsidian.app?.setting; if (!setting) throw new Error("Obsidian settings are unavailable"); setting.open(); setting.openTabById("obsidian-livesync"); }); const settings = page.locator(".sls-setting"); await settings.waitFor({ state: "visible", timeout: uiTimeoutMs }); await settings.locator('.sls-setting-menu-btn[title="Setup"]').click({ timeout: uiTimeoutMs }); }); const screenshots = [ await captureObsidianElement(port, "guide-hidden-file-advanced-features.png", (page) => settingPanelByTitle(page, "Enable extra and advanced features") ), ]; await withObsidianPage(port, async (page) => { await page .locator(".sls-setting") .locator('.sls-setting-menu-btn[title="Selector"]') .click({ timeout: uiTimeoutMs }); }); screenshots.push( await captureObsidianElement(port, "guide-hidden-file-selector.png", (page) => settingPanelByTitle(page, "Hidden Files") ) ); await withObsidianPage(port, async (page) => { await page .locator(".sls-setting") .locator('.sls-setting-menu-btn[title="Sync Settings"]') .click({ timeout: uiTimeoutMs }); }); screenshots.push( await captureObsidianElement(port, "guide-hidden-file-enable.png", (page) => settingPanelByTitle(page, "Hidden Files") ) ); await withObsidianPage(port, async (page) => { await page.keyboard.press("Escape"); }); 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 scanHiddenStorage(cliBinary: string, environment: NodeJS.ProcessEnv): Promise { await evalObsidianJson( cliBinary, [ "(async()=>{", "const core=app.plugins.plugins['obsidian-livesync'].core;", "const addOn=core.getAddOn('HiddenFileSync');", "await addOn.scanAllStorageChanges(true);", "return JSON.stringify({ok:true});", "})()", ].join(""), environment, hiddenFileCliTimeoutMs ); } async function scanHiddenDatabase(cliBinary: string, environment: NodeJS.ProcessEnv): Promise { await evalObsidianJson( cliBinary, [ "(async()=>{", "const core=app.plugins.plugins['obsidian-livesync'].core;", "const addOn=core.getAddOn('HiddenFileSync');", "await addOn.scanAllDatabaseChanges(true);", "return JSON.stringify({ok:true});", "})()", ].join(""), environment, hiddenFileCliTimeoutMs ); } async function waitForRemoteEntry(context: RunnerContext, entry: LocalDatabaseEntry): 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 uploadWorkflowFiles( context: RunnerContext, session: ObsidianLiveSyncSession, vault: TemporaryVault ): Promise { await writeNoteViaObsidian(context.cliBinary, session.cliEnv, notePath, noteContent); await writeVaultFile(vault.path, snippetPath, snippetContent); await scanHiddenStorage(context.cliBinary, session.cliEnv); const noteEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, notePath); const snippetEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, snippetPath, { hidden: true, }); await pushLocalChanges(context.cliBinary, session.cliEnv); await waitForRemoteEntry(context, noteEntry); await waitForRemoteEntry(context, snippetEntry); } async function captureSynchronisedNote(port: number): Promise { await withObsidianPage(port, async (page) => { await page.evaluate((path) => { const obsidian = globalThis as typeof globalThis & { app?: { workspace?: { openLinkText(path: string, sourcePath: string, newLeaf: boolean): Promise }; }; }; return obsidian.app?.workspace?.openLinkText(path, "", false); }, notePath); }); await captureObsidianPage(port, "setup-uri-synchronised-note.png", async (page) => { await page.getByText("Provisioned Setup URI", { exact: false }).first().waitFor({ state: "visible", timeout: uiTimeoutMs, }); }); return await captureObsidianElement(port, "guide-quick-setup-synchronised-note.png", (page) => page.locator(".workspace-leaf.mod-active").first() ); } async function captureFailure(session: ObsidianLiveSyncSession): Promise { await captureObsidianPage( session.remoteDebuggingPort, "setup-uri-workflow.failure.png", async () => undefined ).catch(() => undefined); } 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, "setup-uri-workflow"); 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); const artifact = await provisionAndGenerateSetupURI(couchDb, dbName); const provisionedDocs = await waitForCouchDbDocs(couchDb, dbName, (docs) => docs.some((doc) => doc._id === "obsydian_livesync_version" && doc.version === VER) ); if (!provisionedDocs.some((doc) => doc._id === "obsydian_livesync_version" && doc.version === VER)) { throw new Error("The public provisioning tool did not initialise the Commonlib database version."); } console.log(`Using Obsidian executable: ${binary}`); console.log(`Temporary vault A: ${vaultA.path}`); console.log(`Temporary vault B: ${vaultB.path}`); console.log(`Temporary provisioned CouchDB database: ${dbName}`); let session = await startUnconfiguredSession(context, vaultA); try { await enterSetupURI(session.remoteDebuggingPort, "new", artifact); screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "new")); screenshots.push(await confirmRebuild(session.remoteDebuggingPort)); screenshots.push(await skipMissingRemoteConfiguration(session.remoteDebuggingPort)); screenshots.push(await acknowledgeDisabledOptionalFeatures(session.remoteDebuggingPort)); const firstCompletion = await finishInitialisation( session.remoteDebuggingPort, "setup-uri-first-initialisation-complete.png", context.cliBinary, session.cliEnv ); if (firstCompletion.screenshot) screenshots.push(firstCompletion.screenshot); const firstState = firstCompletion.state; await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort); assertEqual(firstState.remoteType, "", "The first device did not activate the CouchDB remote profile."); assertEqual( firstState.syncInternalFiles, false, "Rebuild did not retain the documented optional-feature safety boundary." ); screenshots.push( ...(await captureHiddenFileGuideSettings( session.remoteDebuggingPort, context.cliBinary, session.cliEnv )) ); await enableHiddenFileSync(context.cliBinary, session.cliEnv); await uploadWorkflowFiles(context, session, vaultA); const generated = await generateSetupURIFromDevice( session.remoteDebuggingPort, randomBytes(24).toString("base64url"), { scenario: "setup-uri-workflow", guide: "quick-setup" } ); if (generated.artifact.setupURI === artifact.setupURI) { throw new Error("The first device returned the bootstrap Setup URI instead of generating a new one."); } secondDeviceArtifact = generated.artifact; screenshots.push(...generated.screenshots); } catch (error) { await captureFailure(session); throw error; } finally { await stopTrackedSession(context, session); } session = await startUnconfiguredSession(context, vaultB); try { if (!secondDeviceArtifact) throw new Error("The first device did not generate the second-device Setup URI."); await enterSetupURI(session.remoteDebuggingPort, "existing", secondDeviceArtifact); screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "existing")); screenshots.push(...(await confirmFastFetch(session.remoteDebuggingPort))); const secondCompletion = await finishInitialisation( session.remoteDebuggingPort, "setup-uri-second-initialisation-complete.png", context.cliBinary, session.cliEnv ); if (secondCompletion.screenshot) screenshots.push(secondCompletion.screenshot); const secondState = secondCompletion.state; await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort); assertEqual(secondState.remoteType, "", "The second device did not activate the CouchDB remote profile."); await enableHiddenFileSync(context.cliBinary, session.cliEnv); await pushLocalChanges(context.cliBinary, session.cliEnv); await scanHiddenDatabase(context.cliBinary, session.cliEnv); const receivedNote = await waitForPathContent(vaultB.path, notePath, noteContent); const receivedSnippet = await waitForPathContent(vaultB.path, snippetPath, snippetContent); assertEqual(receivedNote, noteContent, "The ordinary note did not reach the second Setup URI device."); assertEqual( receivedSnippet, snippetContent, "The hidden snippet did not reach the second Setup URI device." ); screenshots.push(await captureSynchronisedNote(session.remoteDebuggingPort)); 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); throw error; } finally { await stopTrackedSession(context, session); } session = await startUnconfiguredSession(context, vaultA); try { await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort); await pushLocalChanges(context.cliBinary, session.cliEnv); const receivedReturnNote = await waitForPathContent(vaultA.path, returnNotePath, returnNoteContent); assertEqual( receivedReturnNote, returnNoteContent, "The second device's ordinary note did not return to the first Setup URI device." ); } catch (error) { await captureFailure(session); throw error; } finally { await stopTrackedSession(context, session); } console.log( `The public provisioning and first-device-generated Setup URI workflow configured two fresh devices, completed an ordinary-note round-trip, and synchronised a hidden snippet. 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); });