diff --git a/test/e2e-obsidian/scripts/adaptive-webdav.ts b/test/e2e-obsidian/scripts/adaptive-webdav.ts index ad35ca1c..5e9633fc 100644 --- a/test/e2e-obsidian/scripts/adaptive-webdav.ts +++ b/test/e2e-obsidian/scripts/adaptive-webdav.ts @@ -1,61 +1,22 @@ /** - * 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. + * Exercises Adaptive Journal WebDAV through visible safety-gated onboarding. + * The shared Adaptive Journal runner owns restart persistence, device-generated + * Setup URI transfer, Fast Fetch, and a two-device text and binary return + * journey. This scenario retains only the WebDAV UI and live endpoint boundary. */ -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 { runAdaptiveJournalObsidianRoundTrip, runNpmScript } from "../runner/adaptiveJournal.ts"; +import { assertEqual } from "../runner/liveSyncWorkflow.ts"; +import { captureGuideDialogue, modalByTitle, selectRadioOption, type SetupState } from "../runner/setupUri.ts"; +import { withObsidianPage } from "../runner/ui.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) { @@ -65,91 +26,6 @@ if (unsupportedArguments.length > 0) { 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, @@ -254,22 +130,10 @@ async function enterManualAdaptiveWebDAVSettings( return screenshots; } -function assertAdaptiveWebDAVSettings( - state: SetupState, - webDAV: WebDAVConfig, - prefix: string, - label: string, - expectedRepositoryId?: string -): string { +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.`); - if (!/^[A-Za-z0-9_-]{43}$/u.test(state.expectedRepositoryId)) { - throw new Error(`${label} did not retain a canonical repository ID.`); - } - if (expectedRepositoryId !== undefined) { - assertEqual(state.expectedRepositoryId, expectedRepositoryId, `${label} retained a different repository ID.`); - } 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.`); @@ -281,181 +145,12 @@ function assertAdaptiveWebDAVSettings( true, `${label} did not retain the Obsidian internal request API selection.` ); - return state.expectedRepositoryId; -} - -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[], - expectedRepositoryId: 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."); - assertEqual( - manifest.repositoryId, - expectedRepositoryId, - "Adaptive WebDAV did not create the repository with the preselected identity." - ); - 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; - let repositoryId = ""; try { if (manageWebDAV) { @@ -463,122 +158,15 @@ async function main(): Promise { 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); - repositoryId = 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, repositoryId); - } 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", repositoryId); - 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", repositoryId); - 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, repositoryId); - } 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", - repositoryId - ); - 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 runAdaptiveJournalObsidianRoundTrip({ + label: "Adaptive WebDAV", + slug: "adaptive-webdav", + targetDescription: `Temporary WebDAV target: ${webDAV.endpoint}/${prefix}`, + enterManualSettings: async (port, vaultPassphrase) => + await enterManualAdaptiveWebDAVSettings(port, webDAV, prefix, vaultPassphrase), + assertSettings: (state, label) => assertAdaptiveWebDAVSettings(state, webDAV, prefix, label), }); - await vaultA.dispose(); - await vaultB.dispose(); + } finally { if (process.env.E2E_OBSIDIAN_KEEP_WEBDAV !== "true") { await deleteWebDAVPrefix(webDAV, prefix).catch((error: unknown) => { console.warn(error instanceof Error ? error.message : error);