mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-27 05:47:07 +00:00
test: cover Adaptive WebDAV in real Obsidian
This commit is contained in:
@@ -0,0 +1,576 @@
|
||||
/**
|
||||
* 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<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,
|
||||
webDAV: WebDAVConfig,
|
||||
prefix: string,
|
||||
vaultPassphrase: string
|
||||
): Promise<string[]> {
|
||||
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<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[]): 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.");
|
||||
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<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;
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -31,6 +31,7 @@ const testSteps: Step[] = [
|
||||
args: ["run", "test:e2e:obsidian:cli-to-obsidian-sync"],
|
||||
},
|
||||
{ name: "Object Storage upload", args: ["run", "test:e2e:obsidian:minio-upload"] },
|
||||
{ name: "Adaptive WebDAV workflow", args: ["run", "test:e2e:obsidian:adaptive-webdav"] },
|
||||
{
|
||||
name: "Object Storage Setup URI workflow",
|
||||
args: ["run", "test:e2e:obsidian:object-storage-setup-uri-workflow"],
|
||||
@@ -47,10 +48,12 @@ const testSteps: Step[] = [
|
||||
const manageCouchDb = process.argv.includes("--manage-couchdb") || process.argv.includes("--manage-services");
|
||||
const manageMinio = process.argv.includes("--manage-minio") || process.argv.includes("--manage-services");
|
||||
const manageP2P = process.argv.includes("--manage-p2p") || process.argv.includes("--manage-services");
|
||||
const manageWebDAV = process.argv.includes("--manage-webdav") || process.argv.includes("--manage-services");
|
||||
const keepServices = process.argv.includes("--keep-services");
|
||||
const keepCouchDb = keepServices || process.argv.includes("--keep-couchdb");
|
||||
const keepMinio = keepServices || process.argv.includes("--keep-minio");
|
||||
const keepP2P = keepServices || process.argv.includes("--keep-p2p");
|
||||
const keepWebDAV = keepServices || process.argv.includes("--keep-webdav");
|
||||
|
||||
function npmBinary(): string {
|
||||
return process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
@@ -105,10 +108,19 @@ async function stopManagedP2P(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function stopManagedWebDAV(): Promise<void> {
|
||||
await runStep({
|
||||
name: "stop WebDAV fixture",
|
||||
args: ["run", "test:docker-webdav:stop"],
|
||||
optional: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
let shouldStopCouchDb = false;
|
||||
let shouldStopMinio = false;
|
||||
let shouldStopP2P = false;
|
||||
let shouldStopWebDAV = false;
|
||||
try {
|
||||
if (manageCouchDb) {
|
||||
await stopManagedCouchDb();
|
||||
@@ -125,11 +137,19 @@ async function main(): Promise<void> {
|
||||
await runStep({ name: "start P2P relay fixture", args: ["run", "test:docker-p2p:start"] });
|
||||
shouldStopP2P = !keepP2P;
|
||||
}
|
||||
if (manageWebDAV) {
|
||||
await stopManagedWebDAV();
|
||||
await runStep({ name: "start WebDAV fixture", args: ["run", "test:docker-webdav:start"] });
|
||||
shouldStopWebDAV = !keepWebDAV;
|
||||
}
|
||||
|
||||
for (const step of testSteps) {
|
||||
await runStep(step);
|
||||
}
|
||||
} finally {
|
||||
if (shouldStopWebDAV) {
|
||||
await stopManagedWebDAV();
|
||||
}
|
||||
if (shouldStopP2P) {
|
||||
await stopManagedP2P();
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ const focusedScenarios = new Set([
|
||||
"couchdb-manual-setup-workflow",
|
||||
"cli-to-obsidian-sync",
|
||||
"minio-upload",
|
||||
"adaptive-webdav",
|
||||
"object-storage-setup-uri-workflow",
|
||||
"p2p-setup-uri-workflow",
|
||||
"startup-scan",
|
||||
@@ -39,7 +40,7 @@ real-Obsidian scenario. Supported scenarios:
|
||||
|
||||
${[...focusedScenarios].map((scenario) => ` ${scenario}`).join("\n")}
|
||||
|
||||
This wrapper does not start CouchDB, Object Storage, or the P2P signalling
|
||||
This wrapper does not start CouchDB, Object Storage, WebDAV, or the P2P signalling
|
||||
relay. Use the documented service commands or the complete
|
||||
local-suite:services wrapper when required.`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user