/** * Exercises Adaptive Journal WebDAV through two sequential real Obsidian * devices. The first device uses visible manual onboarding and the endpoint * safety check. The second device imports only the Setup URI generated by the * first device. Text and binary payloads then make one return journey. * * Commonlib adapter tests own the HTTP failure matrix, and the CLI E2E owns * external Packs and both retrieval policies. This scenario remains focused * on the Obsidian composition, persisted profile, and onboarding boundaries. */ import { spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { parseWebDAVConnectionURI } from "@vrtmrz/livesync-commonlib/journal-storage"; import { evalObsidianJson } from "../runner/cli.ts"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { assertEqual, pushLocalChanges, waitForLiveSyncCoreReady, waitForLocalDatabaseEntry, } from "../runner/liveSyncWorkflow.ts"; import { REMOTE_ACTIVITY_EXPECTED_STATE, waitForRemoteActivityState } from "../runner/remoteActivity.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { acknowledgeDisabledOptionalFeatures, captureAndStartInitialisation, captureGuideDialogue, confirmFastFetch, confirmRebuild, enterSetupURI, finishInitialisation, generateSetupURIFromDevice, modalByTitle, readSetupState, resumeCompatibilityReviewIfShown, selectRadioOption, skipMissingRemoteConfiguration, type SetupArtifact, type SetupCaptureNames, type SetupState, } from "../runner/setupUri.ts"; import { captureObsidianPage, withObsidianPage } from "../runner/ui.ts"; import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts"; import { assertWebDAVReachable, deleteWebDAVPrefix, listWebDAVObjectKeys, loadWebDAVConfig, makeUniqueWebDAVPrefix, readWebDAVObjectText, type WebDAVConfig, } from "../runner/webDAV.ts"; process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000"; process.env.E2E_OBSIDIAN_SETUP_INITIALISATION_TIMEOUT_MS ??= "180000"; const supportedArguments = new Set(["--keep-webdav", "--manage-webdav"]); const unsupportedArguments = process.argv.slice(2).filter((argument) => !supportedArguments.has(argument)); if (unsupportedArguments.length > 0) { throw new Error(`Unsupported Adaptive WebDAV argument: ${unsupportedArguments.join(", ")}`); } const manageWebDAV = process.argv.includes("--manage-webdav"); const keepWebDAVFixture = process.argv.includes("--keep-webdav"); const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_URI_TIMEOUT_MS ?? 30000); const remoteTimeoutMs = Number(process.env.E2E_OBSIDIAN_WEBDAV_TIMEOUT_MS ?? 30000); const captures: SetupCaptureNames = { scenario: "adaptive-webdav", guide: "adaptive-webdav" }; const secondDeviceCaptures: SetupCaptureNames = { scenario: "adaptive-webdav-second-device", guide: "adaptive-webdav-second-device", }; const textPath = "E2E/adaptive-webdav/round-trip.md"; const binaryPath = "E2E/adaptive-webdav/round-trip.bin"; const firstText = "# Adaptive WebDAV\n\nCreated by the first real Obsidian device.\n"; const secondText = "# Adaptive WebDAV\n\nUpdated by the second real Obsidian device.\n"; const binaryLength = 256 * 1024; const firstBinarySeed = 0x1a2b3c4d; const secondBinarySeed = 0x5e6f7788; type RunnerContext = { binary: string; cliBinary: string; activeSessions: Set; }; function npmBinary(): string { return process.platform === "win32" ? "npm.cmd" : "npm"; } function runNpmScript(script: string): Promise { return new Promise((resolve, reject) => { const child = spawn(npmBinary(), ["run", script], { cwd: process.cwd(), env: process.env, stdio: "inherit", }); child.on("error", reject); child.on("exit", (code, signal) => { if (code === 0) { resolve(); return; } reject(new Error(`${script} failed with ${signal ? `signal ${signal}` : `exit code ${code}`}.`)); }); }); } async function dismissRedundantExternalOpenPrompt(port: number): Promise { await withObsidianPage(port, async (page) => { const prompt = modalByTitle(page, "Run action from external link?"); if (!(await prompt.isVisible({ timeout: 2000 }).catch(() => false))) return; await prompt.getByRole("button", { name: "Cancel", exact: true }).click({ timeout: uiTimeoutMs }); await prompt.waitFor({ state: "hidden", timeout: uiTimeoutMs }); }); } async function startSession(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); // Obsidian 1.12 can ask whether to repeat the CLI's 'open' action when the // isolated profile has already restored this exact Vault. The session has // already verified the active Vault, so dismiss only that redundant host // prompt before exercising LiveSync UI. await dismissRedundantExternalOpenPrompt(session.remoteDebuggingPort); return session; } async function stopSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise { if (!context.activeSessions.has(session)) return; await session.app.stop(); context.activeSessions.delete(session); } async function stopSessions(context: RunnerContext): Promise { for (const session of [...context.activeSessions]) await stopSession(context, session); } async function captureFailure(session: ObsidianLiveSyncSession, label: string): Promise { const screenshot = await captureObsidianPage( session.remoteDebuggingPort, `adaptive-webdav-${label}-failure.png`, async () => undefined ).catch(() => undefined); if (screenshot) console.error(`Adaptive WebDAV failure screenshot: ${screenshot}`); } async function enterManualAdaptiveWebDAVSettings( port: number, webDAV: WebDAVConfig, prefix: string, vaultPassphrase: 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 }); const method = modalByTitle(page, "Connection Method"); await method.waitFor({ state: "visible", timeout: uiTimeoutMs }); 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('input[name="e2ee-passphrase"]').fill(vaultPassphrase); }); screenshots.push(await captureGuideDialogue(port, "guide-adaptive-webdav-encryption.png", "End-to-End Encryption")); await withObsidianPage(port, async (page) => { await modalByTitle(page, "End-to-End Encryption") .getByRole("button", { name: "Proceed", exact: true }) .click({ timeout: uiTimeoutMs }); const remoteSelection = modalByTitle(page, "Choose a synchronisation remote"); await remoteSelection.waitFor({ state: "visible", timeout: uiTimeoutMs }); await selectRadioOption(remoteSelection, "WebDAV Journal"); await remoteSelection .getByRole("button", { name: "Continue to WebDAV setup", exact: true }) .click({ timeout: uiTimeoutMs }); const webDAVModal = modalByTitle(page, "WebDAV Journal Configuration"); await webDAVModal.waitFor({ state: "visible", timeout: uiTimeoutMs }); await webDAVModal.locator('input[name="webdav-endpoint"]').fill(webDAV.endpoint); await webDAVModal.locator('input[name="webdav-username"]').fill(webDAV.username); await webDAVModal.locator('input[name="webdav-password"]').fill(webDAV.password); await webDAVModal.locator('input[name="webdav-prefix"]').fill(prefix); await webDAVModal.locator('input[name="webdav-use-internal-api"]').check({ timeout: uiTimeoutMs }); await webDAVModal.locator("summary").filter({ hasText: "Advanced Settings" }).click(); await webDAVModal.locator('select[name="webdav-journal-format"]').selectOption("adaptive-v1"); const packPolicy = webDAVModal.locator('select[name="webdav-pack-read-policy"]'); await packPolicy.waitFor({ state: "visible", timeout: uiTimeoutMs }); assertEqual( await packPolicy.inputValue(), "whole-pack", "Adaptive WebDAV did not present complete Pack retrieval as the default." ); if ( (await webDAVModal .getByRole("button", { name: "Continue with verified settings", exact: true }) .count()) !== 0 ) { throw new Error("Adaptive WebDAV could continue before its endpoint safety check completed."); } await webDAVModal .getByRole("button", { name: "Run endpoint safety check", exact: true }) .click({ timeout: uiTimeoutMs }); await webDAVModal .getByText("Required Adaptive operations are supported by this WebDAV endpoint.", { exact: false }) .waitFor({ state: "visible", timeout: uiTimeoutMs }); await webDAVModal .getByText("Exact HTTP byte-range retrieval is supported.", { exact: false }) .waitFor({ state: "visible", timeout: uiTimeoutMs }); await webDAVModal .getByRole("button", { name: "Continue with verified settings", exact: true }) .waitFor({ state: "visible", timeout: uiTimeoutMs }); if ((await webDAVModal.getByRole("button", { name: "Save without connecting", exact: true }).count()) !== 0) { throw new Error("Adaptive WebDAV onboarding exposed the unverified Settings-only save action."); } }); screenshots.push( await captureGuideDialogue(port, "guide-adaptive-webdav-safety-check.png", "WebDAV Journal Configuration") ); await withObsidianPage(port, async (page) => { const webDAVModal = modalByTitle(page, "WebDAV Journal Configuration"); await webDAVModal .getByRole("button", { name: "Continue with verified settings", exact: true }) .click({ timeout: uiTimeoutMs }); await modalByTitle(page, "Setup Complete: Preparing to Initialise Server").waitFor({ state: "visible", timeout: uiTimeoutMs, }); }); return screenshots; } function assertAdaptiveWebDAVSettings(state: SetupState, webDAV: WebDAVConfig, prefix: string, label: string): void { assertEqual(state.remoteType, "WEBDAV", `${label} did not retain WebDAV as its active remote.`); assertEqual(state.journalFormat, "adaptive-v1", `${label} did not retain the Adaptive Journal format.`); assertEqual(state.packReadPolicy, "whole-pack", `${label} did not retain complete Pack retrieval.`); assertEqual(state.remoteConfigurationCount, 1, `${label} did not retain exactly one remote profile.`); const connection = parseWebDAVConnectionURI(state.webDAVactiveConnectionURI); assertEqual(connection.endpoint, webDAV.endpoint, `${label} retained a different WebDAV endpoint.`); assertEqual(connection.username, webDAV.username, `${label} retained a different WebDAV username.`); assertEqual(connection.password, webDAV.password, `${label} retained a different WebDAV password.`); assertEqual(connection.prefix, prefix, `${label} retained a different WebDAV prefix.`); assertEqual( connection.useCustomRequestHandler, true, `${label} did not retain the Obsidian internal request API selection.` ); } function deterministicBytes(length: number, seed: number): Uint8Array { const bytes = new Uint8Array(length); let state = seed; for (let index = 0; index < bytes.byteLength; index += 1) { state ^= state << 13; state ^= state >>> 17; state ^= state << 5; bytes[index] = state & 0xff; } return bytes; } async function writePayloadViaObsidian( cliBinary: string, environment: NodeJS.ProcessEnv, text: string, binarySeed: number ): Promise { await evalObsidianJson( cliBinary, [ "(async()=>{", `const textPath=${JSON.stringify(textPath)};`, `const binaryPath=${JSON.stringify(binaryPath)};`, `const text=${JSON.stringify(text)};`, `const binaryLength=${JSON.stringify(binaryLength)};`, `let state=${JSON.stringify(binarySeed)};`, "let folder='';", "for(const part of textPath.split('/').slice(0,-1)){", "folder=folder?`${folder}/${part}`:part;", "if(!(await app.vault.adapter.exists(folder))) await app.vault.createFolder(folder);", "}", "const existingText=app.vault.getAbstractFileByPath(textPath);", "if(existingText) await app.vault.modify(existingText,text);", "else await app.vault.create(textPath,text);", "const bytes=new Uint8Array(binaryLength);", "for(let i=0;i>>17;state^=state<<5;bytes[i]=state&0xff;", "}", "const existingBinary=app.vault.getAbstractFileByPath(binaryPath);", "if(existingBinary) await app.vault.modifyBinary(existingBinary,bytes.buffer);", "else await app.vault.createBinary(binaryPath,bytes.buffer);", "return JSON.stringify({ok:true});", "})()", ].join(""), environment ); await waitForLocalDatabaseEntry(cliBinary, environment, textPath); await waitForLocalDatabaseEntry(cliBinary, environment, binaryPath); } async function waitForText(vault: TemporaryVault, expected: string): Promise { const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 30000); let lastContent = ""; while (Date.now() < deadline) { try { lastContent = await readFile(join(vault.path, textPath), "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 ${textPath}. Last content:\n${lastContent}`); } async function waitForBinary(vault: TemporaryVault, expected: Uint8Array): Promise { const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 30000); let lastLength = -1; while (Date.now() < deadline) { try { const actual = await readFile(join(vault.path, binaryPath)); lastLength = actual.byteLength; if (actual.byteLength === expected.byteLength && actual.equals(Buffer.from(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 ${binaryPath}; last length was ${lastLength}.`); } async function pushAndObserve(session: ObsidianLiveSyncSession, cliBinary: string): Promise { const before = await waitForRemoteActivityState(session.remoteDebuggingPort, REMOTE_ACTIVITY_EXPECTED_STATE.idle); await pushLocalChanges(cliBinary, session.cliEnv); const after = await waitForRemoteActivityState(session.remoteDebuggingPort, REMOTE_ACTIVITY_EXPECTED_STATE.idle); if (after.requestCount <= before.requestCount) { throw new Error("Adaptive WebDAV synchronisation did not advance the tracked remote-request count."); } assertEqual( after.responseCount, after.requestCount, "Adaptive WebDAV remote-request counters did not rebalance after synchronisation." ); return after.requestCount - before.requestCount; } async function waitForAdaptiveObjects( webDAV: WebDAVConfig, prefix: string, minimumWriters: number, minimumCommits: number ): Promise { const deadline = Date.now() + remoteTimeoutMs; let keys: string[] = []; while (Date.now() < deadline) { keys = await listWebDAVObjectKeys(webDAV, prefix); const writerCount = keys.filter((key) => key.startsWith("a1~writer~")).length; const commitCount = keys.filter((key) => key.startsWith("a1~commit~")).length; if (keys.includes("a1~manifest.json") && writerCount >= minimumWriters && commitCount >= minimumCommits) { return keys; } await new Promise((resolve) => setTimeout(resolve, 500)); } throw new Error( `Timed out waiting for ${minimumWriters} Adaptive Writer object(s) and ${minimumCommits} Commit Bundle(s). Last keys: ${keys.join(", ")}` ); } async function assertAdaptiveRepository(webDAV: WebDAVConfig, prefix: string, keys: string[]): Promise { for (const legacyPrefix of ["a1~delta~", "a1~index~", "a1~metadata~"]) { if (keys.some((key) => key.startsWith(legacyPrefix))) { throw new Error(`Adaptive WebDAV wrote a retired object family ${legacyPrefix}: ${keys.join(", ")}`); } } if (keys.some((key) => key.startsWith("a1~probe~"))) { throw new Error(`Adaptive WebDAV left safety probe objects behind: ${keys.join(", ")}`); } if (keys.includes("_00000000-milestone.json")) { throw new Error("Adaptive WebDAV wrote the Opaque Journal milestone."); } const manifest = JSON.parse(await readWebDAVObjectText(webDAV, prefix, "a1~manifest.json")) as { format?: unknown; formatVersion?: unknown; manifestAuth?: unknown; objectLayout?: unknown; repositoryId?: unknown; }; assertEqual(manifest.format, "adaptive-journal", "Unexpected Adaptive WebDAV manifest format."); assertEqual(manifest.formatVersion, 1, "Unexpected Adaptive WebDAV manifest version."); assertEqual(manifest.objectLayout, "commit-bundle-v1", "Unexpected Adaptive WebDAV object layout."); if (typeof manifest.repositoryId !== "string" || manifest.repositoryId.length === 0) { throw new Error("Adaptive WebDAV manifest did not contain a repository ID."); } if (typeof manifest.manifestAuth !== "string" || manifest.manifestAuth.length === 0) { throw new Error("Adaptive WebDAV manifest did not contain its authentication value."); } } 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 webDAV = await loadWebDAVConfig(); const prefix = makeUniqueWebDAVPrefix("adaptive-obsidian"); const vaultPassphrase = randomBytes(24).toString("base64url"); const vaultA = await createTemporaryVault(); const vaultB = await createTemporaryVault(); const context: RunnerContext = { binary, cliBinary: cli.binary, activeSessions: new Set() }; const screenshots: string[] = []; let generatedSetup: SetupArtifact | undefined; let shouldStopWebDAV = false; let observedRequests = 0; try { if (manageWebDAV) { await runNpmScript("test:docker-webdav:start"); shouldStopWebDAV = !keepWebDAVFixture; } await assertWebDAVReachable(webDAV); console.log(`Using Obsidian executable: ${binary}`); console.log(`Temporary Vault A: ${vaultA.path}`); console.log(`Temporary Vault B: ${vaultB.path}`); console.log(`Temporary WebDAV target: ${webDAV.endpoint}/${prefix}`); let session = await startSession(context, vaultA); try { screenshots.push( ...(await enterManualAdaptiveWebDAVSettings( session.remoteDebuggingPort, webDAV, prefix, vaultPassphrase )) ); 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); assertAdaptiveWebDAVSettings(state, webDAV, prefix, "The first device"); await writePayloadViaObsidian(context.cliBinary, session.cliEnv, firstText, firstBinarySeed); observedRequests += await pushAndObserve(session, context.cliBinary); const keys = await waitForAdaptiveObjects(webDAV, prefix, 1, 1); await assertAdaptiveRepository(webDAV, prefix, keys); } catch (error) { await captureFailure(session, "first-device"); throw error; } finally { await stopSession(context, session); } session = await startSession(context, vaultA); try { await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort); const state = await readSetupState(context.cliBinary, session.cliEnv); assertAdaptiveWebDAVSettings(state, webDAV, prefix, "The restarted first device"); const generated = await generateSetupURIFromDevice( session.remoteDebuggingPort, randomBytes(24).toString("base64url"), captures ); generatedSetup = generated.artifact; screenshots.push(...generated.screenshots); } catch (error) { await captureFailure(session, "first-device-restart"); throw error; } finally { await stopSession(context, session); } session = await startSession(context, vaultB); try { if (!generatedSetup) throw new Error("The first device did not generate a Setup URI."); screenshots.push( await enterSetupURI(session.remoteDebuggingPort, "existing", generatedSetup, secondDeviceCaptures) ); screenshots.push( await captureAndStartInitialisation(session.remoteDebuggingPort, "existing", secondDeviceCaptures) ); screenshots.push(...(await confirmFastFetch(session.remoteDebuggingPort, secondDeviceCaptures))); // Journal remotes do not expose the legacy remote-configuration // document. The device-generated Setup URI is the authoritative // connection input, so acknowledge its expected absence explicitly. screenshots.push(await skipMissingRemoteConfiguration(session.remoteDebuggingPort, secondDeviceCaptures)); const state = await finishInitialisation(session.remoteDebuggingPort, context.cliBinary, session.cliEnv); await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort); assertAdaptiveWebDAVSettings(state, webDAV, prefix, "The second device"); observedRequests += await pushAndObserve(session, context.cliBinary); await waitForText(vaultB, firstText); await waitForBinary(vaultB, deterministicBytes(binaryLength, firstBinarySeed)); await writePayloadViaObsidian(context.cliBinary, session.cliEnv, secondText, secondBinarySeed); observedRequests += await pushAndObserve(session, context.cliBinary); const keys = await waitForAdaptiveObjects(webDAV, prefix, 2, 2); await assertAdaptiveRepository(webDAV, prefix, keys); } catch (error) { await captureFailure(session, "second-device"); throw error; } finally { await stopSession(context, session); } session = await startSession(context, vaultA); try { await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort); assertAdaptiveWebDAVSettings( await readSetupState(context.cliBinary, session.cliEnv), webDAV, prefix, "The final first-device session" ); observedRequests += await pushAndObserve(session, context.cliBinary); await waitForText(vaultA, secondText); await waitForBinary(vaultA, deterministicBytes(binaryLength, secondBinarySeed)); } catch (error) { await captureFailure(session, "return-journey"); throw error; } finally { await stopSession(context, session); } console.log( `Adaptive WebDAV passed visible safety-gated onboarding, restart persistence, a device-generated Setup URI, and a two-device text and binary return journey. Tracked requests across measured synchronisations: ${observedRequests}. Screenshots: ${screenshots.join(", ")}` ); } finally { await stopSessions(context).catch((error: unknown) => { console.warn(error instanceof Error ? error.message : error); }); await vaultA.dispose(); await vaultB.dispose(); if (process.env.E2E_OBSIDIAN_KEEP_WEBDAV !== "true") { await deleteWebDAVPrefix(webDAV, prefix).catch((error: unknown) => { console.warn(error instanceof Error ? error.message : error); }); } if (shouldStopWebDAV) { await runNpmScript("test:docker-webdav:stop").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); });