test: cover Adaptive WebDAV in real Obsidian

This commit is contained in:
vorotamoroz
2026-08-02 07:57:16 +00:00
parent 1fb32cb625
commit d6fd2fa4c8
9 changed files with 828 additions and 10 deletions
+4
View File
@@ -71,6 +71,8 @@
"test:e2e:obsidian:couchdb-manual-setup-workflow": "tsx test/e2e-obsidian/scripts/couchdb-manual-setup-workflow.ts",
"test:e2e:obsidian:cli-to-obsidian-sync": "tsx test/e2e-obsidian/scripts/cli-to-obsidian-sync.ts",
"test:e2e:obsidian:minio-upload": "tsx test/e2e-obsidian/scripts/minio-upload.ts",
"test:e2e:obsidian:adaptive-webdav": "tsx test/e2e-obsidian/scripts/adaptive-webdav.ts",
"test:e2e:obsidian:adaptive-webdav:services": "tsx test/e2e-obsidian/scripts/adaptive-webdav.ts --manage-webdav",
"test:e2e:obsidian:object-storage-setup-uri-workflow": "tsx test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts",
"test:e2e:obsidian:p2p-setup-uri-workflow": "tsx test/e2e-obsidian/scripts/p2p-setup-uri-workflow.ts",
"pretest:e2e:obsidian:p2p-connection-check": "npm run build && npm run build --workspace webpeer",
@@ -98,6 +100,8 @@
"test:docker-s3:start": "npm run test:docker-s3:up && sleep 3 && npm run test:docker-s3:init",
"test:docker-s3:down": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/minio-stop.sh",
"test:docker-s3:stop": "npm run test:docker-s3:down",
"test:docker-webdav:start": "npx dotenv-cli -e .env -e .test.env -- deno run --env-file=src/apps/cli/testdeno/.test.env -A --no-check src/apps/cli/testdeno/manage-webdav-fixture.ts start",
"test:docker-webdav:stop": "npx dotenv-cli -e .env -e .test.env -- deno run --env-file=src/apps/cli/testdeno/.test.env -A --no-check src/apps/cli/testdeno/manage-webdav-fixture.ts stop",
"test:docker-postgrest:start": "npx dotenv-cli -e .env -e .test.env -- deno run --env-file=src/apps/cli/testdeno/.test.env -A --no-check src/apps/cli/testdeno/manage-postgrest-fixture.ts start",
"test:docker-postgrest:stop": "npx dotenv-cli -e .env -e .test.env -- deno run --env-file=src/apps/cli/testdeno/.test.env -A --no-check src/apps/cli/testdeno/manage-postgrest-fixture.ts stop",
"test:docker-all:up": "npm run test:docker-couchdb:up ; npm run test:docker-s3:up",
@@ -0,0 +1,27 @@
import { startWebDAV, stopWebDAV } from "./helpers/docker.ts";
const action = Deno.args[0];
const endpoint = (
Deno.env.get("WEBDAV_ENDPOINT") ??
Deno.env.get("webdavEndpoint") ??
"http://127.0.0.1:8088/dav"
).replace(/\/+$/u, "");
try {
if (action === "start") {
await startWebDAV(endpoint);
} else if (action === "stop") {
await stopWebDAV();
} else {
throw new Error("Usage: manage-webdav-fixture.ts <start|stop>");
}
} catch (error) {
if (action === "start") await stopWebDAV().catch(() => undefined);
console.error(error instanceof Error ? error.stack : error);
Deno.exit(1);
}
// The shared Docker helper installs signal cleanup listeners for long-running
// Deno tests. This one-shot fixture command intentionally leaves the container
// running after 'start'; the matching 'stop' command owns its removal.
Deno.exit(0);
+8 -3
View File
@@ -76,9 +76,10 @@ After changing plug-in source, use the focused wrapper rather than invoking a sc
npm run test:e2e:obsidian:focused -- settings-ui
npm run test:e2e:obsidian:focused -- two-vault-sync
npm run test:e2e:obsidian:focused -- security-seed-reconnect
npm run test:e2e:obsidian:focused -- adaptive-webdav --manage-webdav
```
The wrapper accepts only maintained real-Obsidian scenario names; run it with `--help` for the current list. It deliberately does not manage CouchDB, Object Storage, or the P2P signalling relay. Start the required fixture first, or use the complete service-managed suite.
The wrapper accepts only maintained real-Obsidian scenario names; run it with `--help` for the current list. It deliberately does not manage CouchDB, Object Storage, WebDAV, or the P2P signalling relay unless a focused scenario exposes its own explicit service-management argument. Start the required fixture first, pass `--manage-webdav` to the Adaptive WebDAV scenario, or use the complete service-managed suite.
The principal entry points are:
@@ -114,7 +115,7 @@ The mobile pass uses Obsidian's `app.emulateMobile(true)`, a 390 by 844 CSS-pixe
`test:e2e:obsidian:p2p-pane` starts one configured CouchDB-only session with no P2P profile and separate configured P2P sessions for desktop and mobile. It proves that the command remains registered while the retired command, automatic pane, and ribbon entry without a P2P configuration are absent. For the configured P2P profiles, it verifies that the desktop ribbon is available, the current status command reaches the pane without it opening at start-up, checks its connection control and horizontal layout, and captures unobstructed desktop and mobile screenshots. The mobile session uses a fresh Vault, profile, and Obsidian process, enters `app.emulateMobile(true)` through `lifecycle.beforePluginStart`, and requires the P2P view to belong to the right drawer rather than inheriting desktop workspace state. It deliberately uses no relay or peer: replacement of the active replicator is covered by focused unit tests, the Deno and Compose CLI P2P lifecycle suite covers the headless transport, and `p2p-setup-uri-workflow` owns the visible transfer path between two real Obsidian sessions.
`test:e2e:obsidian:local-suite` builds the plug-in and, unless `LIVESYNC_CLI_COMMAND` selects an external CLI, the local LiveSync CLI. It then runs discovery, smoke, the onboarding invitation, Svelte dialogue mounting, revision repair, settings UI, the Review Harness, the P2P status pane, Vault reflection, CouchDB upload and manual setup, CLI-to-Obsidian synchronisation, Object Storage upload and Setup URI round-trip, P2P Setup URI round-trip, startup scan, provisioned CouchDB Setup URI, two-vault synchronisation, Hidden File Sync, Customisation Sync, and setting Markdown export in sequence. Start the local CouchDB, MinIO, and P2P relay fixtures before running it, or use `test:e2e:obsidian:local-suite:services` to let the wrapper stop leftover fixtures, start fresh fixtures, and stop them again after the run.
`test:e2e:obsidian:local-suite` builds the plug-in and, unless `LIVESYNC_CLI_COMMAND` selects an external CLI, the local LiveSync CLI. It then runs discovery, smoke, the onboarding invitation, Svelte dialogue mounting, revision repair, settings UI, the remote-setup provider flow, the Review Harness, the P2P status pane, Vault reflection, CouchDB upload and manual setup, CLI-to-Obsidian synchronisation, Object Storage upload, the Adaptive WebDAV workflow, Object Storage and P2P Setup URI round trips, startup scan, provisioned CouchDB Setup URI, two-vault synchronisation, Hidden File Sync, Customisation Sync, and setting Markdown export in sequence. Start the local CouchDB, MinIO, Apache WebDAV, and P2P relay fixtures before running it, or use `test:e2e:obsidian:local-suite:services` to let the wrapper stop leftover fixtures, start fresh fixtures, and stop them again after the run.
`test:e2e:obsidian:couchdb-upload` reuses the CouchDB variables from `.test.env` or the process environment. It expects a reachable CouchDB service, creates a unique database, starts from configured plug-in data without the device-local compatibility marker, and verifies the copied-or-restored Vault explanation in the actual compatibility dialogue. It captures the summary and details, resumes explicitly, confirms that the marker was recorded, creates a note in real Obsidian, commits the note into the local database, runs one-shot synchronisation, and verifies that the remote database contains both the metadata document and its chunk documents.
@@ -147,6 +148,7 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --
`test:e2e:obsidian:minio-upload` reuses the Object Storage variables from `.test.env` or the process environment. It expects a reachable S3-compatible service and starts with isolated Object Storage settings and the device-local compatibility acknowledgement already in place, keeping the scenario focused on upload rather than unconfigured start-up or setup. It confirms those settings through `obsidian-cli eval`, creates a note in real Obsidian, runs one-shot Journal Sync, and verifies through the AWS SDK that objects were written under a unique bucket prefix. Adapter tests separately observe an in-progress SDK command, while this real-runtime workflow verifies the resulting request counters advance and rebalance.
`test:e2e:obsidian:adaptive-webdav` uses visible manual onboarding to configure a unique collection on a reachable WebDAV endpoint. It requires the endpoint safety check before onboarding can continue, retains the WebDAV profile across a real Obsidian restart, generates a Setup URI on that working device, and completes Fast Fetch and a text and binary return journey through a second device. This representative object-store journey exercises Obsidian's internal request bridge and verifies user-observable Vault reflection. Commonlib integration tests own the Adaptive remote layout, capability semantics, and failure matrix. Run `test:e2e:obsidian:adaptive-webdav:services`, or pass `--manage-webdav` through the focused wrapper, to start and stop the shared local Apache fixture around this scenario.
`test:e2e:obsidian:object-storage-setup-uri-workflow` uses the public Commonlib-backed tool to generate the initial Setup URI for a unique MinIO prefix, completes visible initialisation on the first device, and then asks that working real Obsidian device to create a new Setup URI through the registered command. A second real Obsidian device imports only the device-generated URI. The workflow verifies A-to-B and B-to-A notes, captures the documented onboarding choices, and removes the Object Storage prefix only after both sessions have stopped.
`test:e2e:obsidian:p2p-setup-uri-workflow` runs two concurrent isolated real Obsidian sessions against the local Compose Nostr relay fixture. The first device imports a generated initial Setup URI and completes its signalling test with zero peers, creates a Setup URI for the second device through the registered command, and remains online while the second device imports it. The second device must select the expected online source before Fetch can rebuild its local database. The workflow accepts each connection request visibly on the receiving device, verifies the initial A-to-B fetch, checks that the menu for the three persistent per-peer actions remains within the viewport, reconnects both P2P sessions in join order, and verifies the B-to-A return journey. Every started session remains tracked until teardown completes.
@@ -198,11 +200,12 @@ Start the local fixtures first when they are not already running:
```bash
npm run test:docker-couchdb:start
npm run test:docker-s3:start
npm run test:docker-webdav:start
npm run test:docker-p2p:start
npm run test:e2e:obsidian:local-suite
```
Or let the wrapper manage both fixtures:
Or let the wrapper manage the required fixtures:
```bash
npm run test:e2e:obsidian:local-suite:services
@@ -246,8 +249,10 @@ Useful environment variables:
- `E2E_OBSIDIAN_REMOTE_ACTIVITY_TIMEOUT_MS`: timeout for an observed remote activity to enter or leave its status boundary; default is 30 seconds.
- `E2E_OBSIDIAN_DIAGNOSTICS_DIR`: directory for screenshots and status snapshots, including the Security Seed reconnect stages; default is `/tmp/obsidian-livesync-e2e`.
- `E2E_OBSIDIAN_OBJECT_STORAGE_TIMEOUT_MS`: timeout for waiting until Object Storage contains uploaded E2E objects.
- `E2E_OBSIDIAN_WEBDAV_TIMEOUT_MS`: timeout for waiting until the Adaptive WebDAV collection contains the expected immutable objects; default is 30 seconds.
- `E2E_OBSIDIAN_KEEP_COUCHDB=true`: keep the temporary CouchDB database for inspection.
- `E2E_OBSIDIAN_KEEP_OBJECT_STORAGE=true`: keep the temporary Object Storage prefix for inspection.
- `E2E_OBSIDIAN_KEEP_WEBDAV=true`: keep the temporary WebDAV collection for inspection while the endpoint remains available.
- `E2E_OBSIDIAN_STARTUP_GRACE_MS`: early process-exit detection window in milliseconds.
- `E2E_OBSIDIAN_KEEP_VAULT=true`: keep the temporary vault for inspection.
- `E2E_OBSIDIAN_USE_XVFB=false`: disable automatic `xvfb-run` on headless Linux.
+12 -6
View File
@@ -18,9 +18,12 @@ export type SetupState = {
endpoint: string;
bucket: string;
bucketPrefix: string;
journalFormat: string;
packReadPolicy: string;
p2pEnabled: boolean;
p2pRelays: string;
p2pRoomId: string;
webDAVactiveConnectionURI: string;
};
export type SetupCaptureNames = {
@@ -182,16 +185,16 @@ export async function captureAndStartInitialisation(
? "Setup Complete: Preparing This P2P Device"
: p2pAdditionalDevice
? "Setup Complete: Preparing to Fetch from Another Device"
: mode === "new"
? "Setup Complete: Preparing to Initialise Server"
: "Setup Complete: Preparing to Fetch Synchronisation Data";
: mode === "new"
? "Setup Complete: Preparing to Initialise Server"
: "Setup Complete: Preparing to Fetch Synchronisation Data";
const button = p2pFirstDevice
? "Restart and Prepare This Device"
: p2pAdditionalDevice
? "Restart and Select Source Device"
: mode === "new"
? "Restart and Initialise Server"
: "Restart and Fetch Data";
: mode === "new"
? "Restart and Initialise Server"
: "Restart and Fetch Data";
if (p2pAdditionalDevice) {
await withObsidianPage(port, async (page) => {
const modal = modalByTitle(page, title);
@@ -342,9 +345,12 @@ export async function readSetupState(cliBinary: string, environment: NodeJS.Proc
"endpoint:settings.endpoint||'',",
"bucket:settings.bucket||'',",
"bucketPrefix:settings.bucketPrefix||'',",
"journalFormat:settings.journalFormat||'',",
"packReadPolicy:settings.packReadPolicy||'',",
"p2pEnabled:settings.P2P_Enabled===true,",
"p2pRelays:settings.P2P_relays||'',",
"p2pRoomId:settings.P2P_roomID||'',",
"webDAVactiveConnectionURI:settings.webDAVactiveConnectionURI||'',",
"});",
"})()",
].join(""),
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { parseWebDAVObjectKeys, webDAVCollectionUrl } from "./webDAV.ts";
describe("WebDAV E2E helpers", () => {
it("builds an encoded collection URL below the configured endpoint", () => {
expect(
webDAVCollectionUrl({ endpoint: "http://127.0.0.1:8088/dav/" }, "Adaptive Journal/run one/").toString()
).toBe("http://127.0.0.1:8088/dav/Adaptive%20Journal/run%20one/");
});
it("extracts only flat object keys below the exact collection", () => {
const collection = new URL("http://127.0.0.1:8088/dav/run/");
const xml = `<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response><d:href>/dav/run/</d:href></d:response>
<d:response><d:href>/dav/run/a1~manifest.json</d:href></d:response>
<d:response><d:href>/dav/run/a1~commit~writer~1.bin</d:href></d:response>
<d:response><d:href>/dav/run/nested/ignored.bin</d:href></d:response>
<d:response><d:href>/dav/sibling/ignored.bin</d:href></d:response>
</d:multistatus>`;
expect(parseWebDAVObjectKeys(xml, collection)).toEqual(["a1~commit~writer~1.bin", "a1~manifest.json"]);
});
});
+156
View File
@@ -0,0 +1,156 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
export type WebDAVConfig = {
endpoint: string;
username: string;
password: string;
};
function parseEnvFile(content: string): Record<string, string> {
const entries = content
.split(/\r?\n/u)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#"))
.map((line) => {
const equalsAt = line.indexOf("=");
if (equalsAt < 0) return undefined;
const key = line.slice(0, equalsAt).trim();
const rawValue = line.slice(equalsAt + 1).trim();
return [key, rawValue.replace(/^['"]|['"]$/gu, "")] as const;
})
.filter((entry): entry is readonly [string, string] => entry !== undefined);
return Object.fromEntries(entries);
}
function firstValue(values: Record<string, string | undefined>, keys: readonly string[]): string | undefined {
for (const key of keys) {
const value = values[key]?.trim();
if (value) return value;
}
return undefined;
}
export async function loadWebDAVConfig(envFile = ".test.env"): Promise<WebDAVConfig> {
let fileValues: Record<string, string> = {};
try {
fileValues = parseEnvFile(await readFile(resolve(envFile), "utf8"));
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
const values = { ...fileValues, ...process.env };
return {
endpoint: (firstValue(values, ["WEBDAV_ENDPOINT", "webdavEndpoint"]) ?? "http://127.0.0.1:8088/dav").replace(
/\/+$/u,
""
),
username: firstValue(values, ["WEBDAV_USERNAME", "webdavUsername"]) ?? "",
password: firstValue(values, ["WEBDAV_PASSWORD", "webdavPassword"]) ?? "",
};
}
function normalisePrefix(prefix: string): string[] {
const parts = prefix
.trim()
.split("/")
.filter((part) => part.length > 0);
if (parts.some((part) => part === "." || part === "..")) {
throw new TypeError("WebDAV E2E prefix must not contain dot path segments.");
}
return parts;
}
export function webDAVCollectionUrl(config: Pick<WebDAVConfig, "endpoint">, prefix: string): URL {
const url = new URL(`${config.endpoint.replace(/\/+$/u, "")}/`);
if (url.search || url.hash) throw new TypeError("WebDAV E2E endpoint must not contain a query or fragment.");
const baseParts = url.pathname.split("/").filter((part) => part.length > 0);
const prefixParts = normalisePrefix(prefix);
url.pathname = `/${[...baseParts, ...prefixParts].map((part) => encodeURIComponent(decodeURIComponent(part))).join("/")}/`;
return url;
}
function requestHeaders(config: WebDAVConfig, additional: HeadersInit = {}): Headers {
const headers = new Headers(additional);
if (config.username || config.password) {
headers.set(
"Authorization",
`Basic ${Buffer.from(`${config.username}:${config.password}`, "utf8").toString("base64")}`
);
}
return headers;
}
export async function assertWebDAVReachable(config: WebDAVConfig): Promise<void> {
const response = await fetch(`${config.endpoint}/`, {
method: "PROPFIND",
headers: requestHeaders(config, { Depth: "0" }),
});
await response.body?.cancel().catch(() => undefined);
if (response.status !== 207) {
throw new Error(`WebDAV fixture is not reachable: HTTP ${response.status}.`);
}
}
function decodeXmlText(value: string): string {
return value
.replace(/&amp;/giu, "&")
.replace(/&lt;/giu, "<")
.replace(/&gt;/giu, ">")
.replace(/&quot;/giu, '"')
.replace(/&apos;/giu, "'");
}
export function parseWebDAVObjectKeys(xml: string, collectionUrl: URL): string[] {
const hrefs = [
...xml.matchAll(/<(?:[A-Za-z_][\w.-]*:)?href\b[^>]*>([\s\S]*?)<\/(?:[A-Za-z_][\w.-]*:)?href>/giu),
].map((match) => decodeXmlText(match[1].trim()));
const basePath = decodeURIComponent(collectionUrl.pathname);
const keys = new Set<string>();
for (const href of hrefs) {
const path = decodeURIComponent(new URL(href, collectionUrl).pathname);
if (!path.startsWith(basePath)) continue;
const key = path.slice(basePath.length).replace(/\/$/u, "");
if (key && !key.includes("/")) keys.add(key);
}
return [...keys].sort();
}
export async function listWebDAVObjectKeys(config: WebDAVConfig, prefix: string): Promise<string[]> {
const collectionUrl = webDAVCollectionUrl(config, prefix);
const response = await fetch(collectionUrl, {
method: "PROPFIND",
headers: requestHeaders(config, { Depth: "1" }),
});
if (response.status !== 207) {
await response.body?.cancel().catch(() => undefined);
throw new Error(`Could not list WebDAV E2E objects: HTTP ${response.status}.`);
}
return parseWebDAVObjectKeys(await response.text(), collectionUrl);
}
export async function readWebDAVObjectText(config: WebDAVConfig, prefix: string, key: string): Promise<string> {
if (key.includes("/") || key === "." || key === "..") {
throw new TypeError("WebDAV E2E object keys must be flat names.");
}
const response = await fetch(new URL(encodeURIComponent(key), webDAVCollectionUrl(config, prefix)), {
headers: requestHeaders(config),
});
if (!response.ok) throw new Error(`Could not read WebDAV E2E object ${key}: HTTP ${response.status}.`);
return await response.text();
}
export async function deleteWebDAVPrefix(config: WebDAVConfig, prefix: string): Promise<void> {
const response = await fetch(webDAVCollectionUrl(config, prefix), {
method: "DELETE",
headers: requestHeaders(config),
});
await response.body?.cancel().catch(() => undefined);
if (!response.ok && response.status !== 404 && response.status !== 410) {
throw new Error(`Could not remove WebDAV E2E prefix: HTTP ${response.status}.`);
}
}
export function makeUniqueWebDAVPrefix(label: string): string {
const random = Math.random().toString(36).slice(2, 8);
return `${label}-${Date.now()}-${random}/`;
}
@@ -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);
});
+20
View File
@@ -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();
}
+2 -1
View File
@@ -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.`;
}