refactor(test): use the shared WebDAV journey

This commit is contained in:
vorotamoroz
2026-08-02 07:59:14 +00:00
parent c76159ea50
commit ea0c6dca48
+17 -429
View File
@@ -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<ObsidianLiveSyncSession>;
};
function npmBinary(): string {
return process.platform === "win32" ? "npm.cmd" : "npm";
}
function runNpmScript(script: string): Promise<void> {
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<void> {
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<ObsidianLiveSyncSession> {
const session = await startObsidianLiveSyncSession({
binary: context.binary,
cliBinary: context.cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
});
context.activeSessions.add(session);
// 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<void> {
if (!context.activeSessions.has(session)) return;
await session.app.stop();
context.activeSessions.delete(session);
}
async function stopSessions(context: RunnerContext): Promise<void> {
for (const session of [...context.activeSessions]) await stopSession(context, session);
}
async function captureFailure(session: ObsidianLiveSyncSession, label: string): Promise<void> {
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<void> {
await evalObsidianJson<unknown>(
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<bytes.byteLength;i++){",
"state^=state<<13;state^=state>>>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<void> {
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<void> {
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<number> {
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<string[]> {
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<void> {
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<void> {
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<void> {
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);