Compare commits

..
Author SHA1 Message Date
vorotamoroz 9825ad29a8 test(obsidian): integrate representative adaptive journeys
# Conflicts:
#	package.json
#	test/e2e-obsidian/README.md
#	test/e2e-obsidian/runner/setupUri.ts
#	test/e2e-obsidian/scripts/local-suite.ts
#	test/e2e-obsidian/scripts/run-focused.ts
2026-08-02 08:10:48 +00:00
vorotamoroz ef41cc3486 test(obsidian): focus adaptive PostgREST journey 2026-08-02 08:05:11 +00:00
vorotamoroz b90541b687 test(obsidian): verify PostgREST repository identity 2026-08-02 08:03:49 +00:00
vorotamoroz 50ddc94593 docs(test): document the PostgREST E2E fixture 2026-08-02 08:03:49 +00:00
vorotamoroz ce6dc67975 test(obsidian): cover Adaptive PostgREST remotes 2026-08-02 08:02:41 +00:00
vorotamoroz ea0c6dca48 refactor(test): use the shared WebDAV journey 2026-08-02 07:59:14 +00:00
vorotamoroz c76159ea50 test(obsidian): verify WebDAV repository identity 2026-08-02 07:57:39 +00:00
vorotamoroz d6fd2fa4c8 test: cover Adaptive WebDAV in real Obsidian 2026-08-02 07:57:16 +00:00
vorotamoroz 1fb32cb625 test(obsidian): add shared adaptive journal journey 2026-08-02 07:54:29 +00:00
vorotamoroz c7d1f9352d test(obsidian): verify registered remote setup profiles 2026-08-02 07:52:00 +00:00
13 changed files with 1973 additions and 10 deletions
+7
View File
@@ -63,6 +63,7 @@
"test:e2e:obsidian:revision-repair": "tsx test/e2e-obsidian/scripts/revision-repair.ts",
"test:e2e:obsidian:document-history-nav": "tsx test/e2e-obsidian/scripts/document-history-nav.ts",
"test:e2e:obsidian:settings-ui": "tsx test/e2e-obsidian/scripts/settings-ui.ts",
"test:e2e:obsidian:remote-setup-providers": "tsx test/e2e-obsidian/scripts/remote-setup-providers.ts",
"test:e2e:obsidian:review-harness": "tsx test/e2e-obsidian/scripts/review-harness.ts",
"test:e2e:obsidian:p2p-pane": "tsx test/e2e-obsidian/scripts/p2p-pane.ts",
"test:e2e:obsidian:vault-reflection": "tsx test/e2e-obsidian/scripts/vault-reflection.ts",
@@ -70,6 +71,10 @@
"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:adaptive-postgrest": "tsx test/e2e-obsidian/scripts/adaptive-postgrest.ts",
"test:e2e:obsidian:adaptive-postgrest:services": "tsx test/e2e-obsidian/scripts/adaptive-postgrest.ts --manage-postgrest",
"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",
@@ -97,6 +102,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);
+13 -3
View File
@@ -76,9 +76,11 @@ 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
npm run test:e2e:obsidian:focused -- adaptive-postgrest --manage-postgrest
```
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, PostgREST, WebDAV, or the P2P signalling relay unless a focused scenario exposes its own explicit service-management argument. Start the required fixture first, pass the provider's service-management argument to the Adaptive WebDAV or PostgREST scenario, or use the complete service-managed suite.
The principal entry points are:
@@ -114,7 +116,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 and PostgREST workflows, 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, PostgreSQL/PostgREST, 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 +149,10 @@ 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:adaptive-postgrest` applies the two-device real-Obsidian workflow to the Adaptive-only PostgREST provider. Visible manual onboarding must withhold continuation until the packaged RPC and binary-semantics check succeeds, and it must not expose Opaque or Pack retrieval choices. The first device retains its profile across a real Obsidian restart, generates a Setup URI for the second device, and completes Fast Fetch and a text and binary return journey. This representative native-store journey verifies Host composition and user-observable Vault reflection. Commonlib integration tests own the RPC failure matrix, SQL transaction semantics, repository binding, and remote layout. Rebuild resets all Adaptive data for the supplied Vault ID, so external settings must identify a disposable test Vault. Run `test:e2e:obsidian:adaptive-postgrest:services`, or pass `--manage-postgrest` through the focused wrapper, to start and stop the shared PostgreSQL and PostgREST 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 +204,13 @@ 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-postgrest: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 +254,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.
- `POSTGREST_ENDPOINT`, `POSTGREST_SCHEMA`, `POSTGREST_VAULT_ID`, `POSTGREST_VAULT_CREDENTIAL`, and `POSTGREST_API_KEY`: external PostgREST fixture settings. The API key must be publishable or otherwise client-safe. The managed fixture supplies disposable defaults.
- `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.
+384
View File
@@ -0,0 +1,384 @@
import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { evalObsidianJson } from "./cli.ts";
import { discoverObsidianCli, requireObsidianBinary } from "./environment.ts";
import {
assertEqual,
pushLocalChanges,
waitForLiveSyncCoreReady,
waitForLocalDatabaseEntry,
} from "./liveSyncWorkflow.ts";
import { REMOTE_ACTIVITY_EXPECTED_STATE, waitForRemoteActivityState } from "./remoteActivity.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "./session.ts";
import {
acknowledgeDisabledOptionalFeatures,
captureAndStartInitialisation,
confirmFastFetch,
confirmRebuild,
enterSetupURI,
finishInitialisation,
generateSetupURIFromDevice,
modalByTitle,
readSetupState,
resumeCompatibilityReviewIfShown,
skipMissingRemoteConfiguration,
type SetupArtifact,
type SetupCaptureNames,
type SetupState,
} from "./setupUri.ts";
import { captureObsidianPage, withObsidianPage } from "./ui.ts";
import { createTemporaryVault, type TemporaryVault } from "./vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
process.env.E2E_OBSIDIAN_SETUP_INITIALISATION_TIMEOUT_MS ??= "180000";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_URI_TIMEOUT_MS ?? 30000);
const binaryLength = 256 * 1024;
const firstBinarySeed = 0x1a2b3c4d;
const secondBinarySeed = 0x5e6f7788;
type RunnerContext = {
binary: string;
cliBinary: string;
activeSessions: Set<ObsidianLiveSyncSession>;
};
export type AdaptiveJournalObsidianScenario = {
/** Human-readable provider name, for example, 'Adaptive WebDAV'. */
label: string;
/** Stable lowercase name used for paths, screenshots, and setup captures. */
slug: string;
targetDescription: string;
enterManualSettings(port: number, vaultPassphrase: string): Promise<string[]>;
assertSettings(state: SetupState, label: string): void;
};
function assertRepositoryIdentity(state: SetupState, label: string, expectedRepositoryId?: string): string {
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.`);
}
return state.expectedRepositoryId;
}
function npmBinary(): string {
return process.platform === "win32" ? "npm.cmd" : "npm";
}
export 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,
scenario: AdaptiveJournalObsidianScenario,
label: string
): Promise<void> {
const screenshot = await captureObsidianPage(
session.remoteDebuggingPort,
`${scenario.slug}-${label}-failure.png`,
async () => undefined
).catch(() => undefined);
if (screenshot) console.error(`${scenario.label} failure screenshot: ${screenshot}`);
}
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,
textPath: string,
binaryPath: string,
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, textPath: string, 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, binaryPath: string, 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(
scenario: AdaptiveJournalObsidianScenario,
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(`${scenario.label} synchronisation did not advance the tracked remote-request count.`);
}
assertEqual(
after.responseCount,
after.requestCount,
`${scenario.label} remote-request counters did not rebalance after synchronisation.`
);
return after.requestCount - before.requestCount;
}
export async function runAdaptiveJournalObsidianRoundTrip(scenario: AdaptiveJournalObsidianScenario): 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 vaultPassphrase = randomBytes(24).toString("base64url");
const vaultA = await createTemporaryVault();
const vaultB = await createTemporaryVault();
const context: RunnerContext = { binary, cliBinary: cli.binary, activeSessions: new Set() };
const captures: SetupCaptureNames = { scenario: scenario.slug, guide: scenario.slug };
const secondDeviceCaptures: SetupCaptureNames = {
scenario: `${scenario.slug}-second-device`,
guide: `${scenario.slug}-second-device`,
};
const textPath = `E2E/${scenario.slug}/round-trip.md`;
const binaryPath = `E2E/${scenario.slug}/round-trip.bin`;
const firstText = `# ${scenario.label}\n\nCreated by the first real Obsidian device.\n`;
const secondText = `# ${scenario.label}\n\nUpdated by the second real Obsidian device.\n`;
const screenshots: string[] = [];
let generatedSetup: SetupArtifact | undefined;
let observedRequests = 0;
let repositoryId = "";
try {
console.log(`Using Obsidian executable: ${binary}`);
console.log(`Temporary Vault A: ${vaultA.path}`);
console.log(`Temporary Vault B: ${vaultB.path}`);
console.log(scenario.targetDescription);
let session = await startSession(context, vaultA);
try {
screenshots.push(...(await scenario.enterManualSettings(session.remoteDebuggingPort, 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);
scenario.assertSettings(state, "The first device");
repositoryId = assertRepositoryIdentity(state, "The first device");
await writePayloadViaObsidian(
context.cliBinary,
session.cliEnv,
textPath,
binaryPath,
firstText,
firstBinarySeed
);
observedRequests += await pushAndObserve(scenario, session, context.cliBinary);
} catch (error) {
await captureFailure(session, scenario, "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);
scenario.assertSettings(state, "The restarted first device");
assertRepositoryIdentity(state, "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, scenario, "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);
scenario.assertSettings(state, "The second device");
assertRepositoryIdentity(state, "The second device", repositoryId);
observedRequests += await pushAndObserve(scenario, session, context.cliBinary);
await waitForText(vaultB, textPath, firstText);
await waitForBinary(vaultB, binaryPath, deterministicBytes(binaryLength, firstBinarySeed));
await writePayloadViaObsidian(
context.cliBinary,
session.cliEnv,
textPath,
binaryPath,
secondText,
secondBinarySeed
);
observedRequests += await pushAndObserve(scenario, session, context.cliBinary);
} catch (error) {
await captureFailure(session, scenario, "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);
const state = await readSetupState(context.cliBinary, session.cliEnv);
scenario.assertSettings(state, "The final first-device session");
assertRepositoryIdentity(state, "The final first-device session", repositoryId);
observedRequests += await pushAndObserve(scenario, session, context.cliBinary);
await waitForText(vaultA, textPath, secondText);
await waitForBinary(vaultA, binaryPath, deterministicBytes(binaryLength, secondBinarySeed));
} catch (error) {
await captureFailure(session, scenario, "return-journey");
throw error;
} finally {
await stopSession(context, session);
}
console.log(
`${scenario.label} 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();
}
}
+330
View File
@@ -0,0 +1,330 @@
import type { Locator, Page } from "playwright";
import { captureObsidianDialogue, withObsidianPage } from "./ui.ts";
const remoteSetupStateKey = "__livesyncE2ERemoteSetup";
export type RemoteInspectionMode = "failed" | "verified";
export type RemoteSetupCall = {
journalFormat: string;
operation: "create" | "inspect" | "test";
remoteType: string;
};
type RemoteSetupBrowserState = {
calls: RemoteSetupCall[];
inspectionMode: RemoteInspectionMode;
nextProfileName: string;
unregister?: () => void;
};
type RuntimeRemoteConfiguration = {
id: string;
isEncrypted: boolean;
name: string;
uri: string;
};
type RuntimeSettings = {
activeConfigurationId: string;
remoteConfigurations: Record<string, RuntimeRemoteConfiguration>;
};
type RuntimePlugin = {
core: {
services: {
replicator: {
getNewReplicator: {
addHandler: (...args: unknown[]) => () => void;
} & ((settingOverride?: Record<string, unknown>) => Promise<unknown>);
};
setting: {
currentSettings(): RuntimeSettings;
};
UI: {
confirm: {
askString(
title: string,
label: string,
placeholder: string,
isPassword?: boolean
): Promise<string | false>;
};
};
};
};
};
type RuntimeSettingsController = {
close(): void;
open(): void;
openTabById(tabId: string): void;
};
type RuntimeApp = {
plugins?: { plugins: Record<string, RuntimePlugin | undefined> };
setting?: RuntimeSettingsController;
};
type RuntimeGlobal = typeof globalThis & {
app?: RuntimeApp;
[remoteSetupStateKey]?: RemoteSetupBrowserState;
};
export function remoteSelectionModal(page: Page): Locator {
return page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Choose a synchronisation remote" }),
});
}
export function remoteProviderModal(page: Page, title: string): Locator {
return page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: title }),
});
}
export function remoteConfigurationPanel(page: Page): Locator {
return page
.locator(".sls-setting h4.sls-setting-panel-title")
.filter({ hasText: "Connection settings" })
.locator("..");
}
function remoteProfileRow(page: Page, profileName: string): Locator {
return remoteConfigurationPanel(page).locator(".sls-remote-list .setting-item").filter({ hasText: profileName });
}
async function tooltipButton(container: Locator, label: string, fallbackText: string): Promise<Locator> {
const labelled = container.locator(`button[aria-label="${label}"], button[title="${label}"]`);
if ((await labelled.count()) > 0) return labelled.first();
return container.locator("button").filter({ hasText: fallbackText }).first();
}
export async function installRemoteSetupTestSeam(port: number): Promise<void> {
await withObsidianPage(port, async (page) => {
await page.evaluate((stateKey) => {
const runtime = globalThis as RuntimeGlobal;
const plugin = runtime.app?.plugins?.plugins["obsidian-livesync"];
if (!plugin) throw new Error("Self-hosted LiveSync is not loaded");
const state: RemoteSetupBrowserState = {
calls: [],
inspectionMode: "verified",
nextProfileName: "",
};
runtime[stateKey as typeof remoteSetupStateKey] = state;
const getNewReplicator = plugin.core.services.replicator.getNewReplicator;
const replacements = {
async createReplicator(settingOverride: Record<string, unknown> = {}) {
const remoteType = String(settingOverride.remoteType ?? "");
const journalFormat = String(settingOverride.journalFormat ?? "");
state.calls.push({ journalFormat, operation: "create", remoteType });
return {
async inspectJournalStorageConnection(settings: Record<string, unknown>) {
state.calls.push({
journalFormat: String(settings.journalFormat ?? ""),
operation: "inspect",
remoteType: String(settings.remoteType ?? ""),
});
if (state.inspectionMode === "failed") {
return {
adaptiveCapabilities: {
byteRange: { status: "not-checked" },
required: { missing: ["conditional-create"], status: "unsupported" },
},
available: false,
remoteFormat: "empty",
};
}
return {
adaptiveCapabilities: {
byteRange: { status: "verified" },
required: { status: "verified" },
},
available: true,
remoteFormat: "empty",
};
},
async tryConnectRemote(settings: Record<string, unknown>) {
state.calls.push({
journalFormat: String(settings.journalFormat ?? ""),
operation: "test",
remoteType: String(settings.remoteType ?? ""),
});
return state.inspectionMode === "verified";
},
};
},
async askString(title: string, label: string, placeholder: string, isPassword: boolean = false) {
if (title !== "Remote name") return await originalAskString(title, label, placeholder, isPassword);
const name = state.nextProfileName;
state.nextProfileName = "";
if (!name) throw new Error("The remote setup E2E did not supply a profile name");
return name;
},
};
state.unregister = getNewReplicator.addHandler(replacements.createReplicator, -1000, true);
const confirm = plugin.core.services.UI.confirm;
const originalAskString = confirm.askString.bind(confirm);
confirm.askString = replacements.askString;
}, remoteSetupStateKey);
});
}
export async function setRemoteInspectionMode(port: number, mode: RemoteInspectionMode): Promise<void> {
await withObsidianPage(port, async (page) => {
await page.evaluate(
({ mode, stateKey }) => {
const state = (globalThis as RuntimeGlobal)[stateKey as typeof remoteSetupStateKey];
if (!state) throw new Error("The remote setup E2E seam is not installed");
state.inspectionMode = mode;
},
{ mode, stateKey: remoteSetupStateKey }
);
});
}
export async function remoteSetupCalls(port: number): Promise<RemoteSetupCall[]> {
return await withObsidianPage(port, async (page) => {
return await page.evaluate((stateKey) => {
const state = (globalThis as RuntimeGlobal)[stateKey as typeof remoteSetupStateKey];
if (!state) throw new Error("The remote setup E2E seam is not installed");
return state.calls;
}, remoteSetupStateKey);
});
}
export async function openRemoteConfigurationSettings(port: number, timeoutMs: number): Promise<void> {
await withObsidianPage(port, async (page) => {
await page.evaluate(() => {
const setting = (globalThis as RuntimeGlobal).app?.setting;
if (!setting) throw new Error("Obsidian settings are unavailable");
setting.close();
});
await page.waitForTimeout(100);
await page.evaluate(() => {
const setting = (globalThis as RuntimeGlobal).app?.setting;
if (!setting) throw new Error("Obsidian settings are unavailable");
setting.open();
setting.openTabById("obsidian-livesync");
});
const settings = page.locator(".sls-setting");
try {
await settings.waitFor({ state: "visible", timeout: timeoutMs });
} catch (error) {
const modalTitles = await page.locator(".modal-title").allTextContents();
const settingTabs = await page.locator(".vertical-tab-nav-item").allTextContents();
const reason = error instanceof Error ? error.message : String(error);
throw new Error(
`The LiveSync settings pane did not become visible. Open modal titles: ${JSON.stringify(modalTitles)}. Settings tabs: ${JSON.stringify(settingTabs)}. Cause: ${reason}`
);
}
await settings.locator('.sls-setting-menu-btn[title="Remote Configuration"]').click({ timeout: timeoutMs });
await remoteConfigurationPanel(page).waitFor({ state: "visible", timeout: timeoutMs });
});
}
export async function closeRemoteConfigurationSettings(port: number, timeoutMs: number): Promise<void> {
await withObsidianPage(port, async (page) => {
await page.evaluate(() => {
const setting = (globalThis as RuntimeGlobal).app?.setting;
if (!setting) throw new Error("Obsidian settings are unavailable");
setting.close();
});
await page.locator(".sls-setting").waitFor({ state: "hidden", timeout: timeoutMs });
});
}
export async function beginRemoteProfileSetup(port: number, profileName: string, timeoutMs: number): Promise<void> {
await withObsidianPage(port, async (page) => {
await page.evaluate(
({ profileName, stateKey }) => {
const state = (globalThis as RuntimeGlobal)[stateKey as typeof remoteSetupStateKey];
if (!state) throw new Error("The remote setup E2E seam is not installed");
state.nextProfileName = profileName;
},
{ profileName, stateKey: remoteSetupStateKey }
);
const add = await tooltipButton(remoteConfigurationPanel(page), "Add new connection", "");
await add.click({ timeout: timeoutMs });
await remoteSelectionModal(page).waitFor({ state: "visible", timeout: timeoutMs });
});
}
export async function captureRemoteProviderChoices(
port: number,
filename: string,
labels: readonly string[],
timeoutMs: number
): Promise<string> {
return await captureObsidianDialogue(port, filename, async (page) => {
const modal = remoteSelectionModal(page);
await modal.waitFor({ state: "visible", timeout: timeoutMs });
for (const label of labels) {
await modal.getByText(label, { exact: true }).waitFor({ state: "visible", timeout: timeoutMs });
}
});
}
export async function selectRemoteProvider(
port: number,
choiceLabel: string,
proceedLabel: string,
providerTitle: string,
timeoutMs: number
): Promise<void> {
await withObsidianPage(port, async (page) => {
const selection = remoteSelectionModal(page);
await selection
.locator("label")
.filter({ hasText: choiceLabel })
.locator('input[type="radio"]')
.first()
.check({ timeout: timeoutMs });
await selection.getByRole("button", { name: proceedLabel, exact: true }).click({ timeout: timeoutMs });
await remoteProviderModal(page, providerTitle).waitFor({ state: "visible", timeout: timeoutMs });
});
}
export async function captureAndCancelRemoteProvider(
port: number,
filename: string,
providerTitle: string,
timeoutMs: number
): Promise<string> {
const screenshot = await captureObsidianDialogue(port, filename, async (page) => {
await remoteProviderModal(page, providerTitle).waitFor({ state: "visible", timeout: timeoutMs });
});
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, providerTitle);
await modal.getByRole("button", { name: "Cancel", exact: true }).click({ timeout: timeoutMs });
await modal.waitFor({ state: "hidden", timeout: timeoutMs });
});
return screenshot;
}
export async function waitForSavedRemoteProfile(port: number, profileName: string, timeoutMs: number): Promise<void> {
await withObsidianPage(port, async (page) => {
await remoteProfileRow(page, profileName).waitFor({ state: "visible", timeout: timeoutMs });
});
}
export async function openSavedRemoteProfile(port: number, profileName: string, timeoutMs: number): Promise<void> {
await withObsidianPage(port, async (page) => {
const row = remoteProfileRow(page, profileName);
await row.waitFor({ state: "visible", timeout: timeoutMs });
const configure = await tooltipButton(row, "Configure", "🔧");
await configure.click({ timeout: timeoutMs });
});
}
export async function runtimeRemoteSettings(port: number): Promise<RuntimeSettings> {
return await withObsidianPage(port, async (page) => {
return await page.evaluate(() => {
const plugin = (globalThis as RuntimeGlobal).app?.plugins?.plugins["obsidian-livesync"];
if (!plugin) throw new Error("Self-hosted LiveSync is not loaded");
return structuredClone(plugin.core.services.setting.currentSettings());
});
});
}
+16 -6
View File
@@ -18,9 +18,14 @@ export type SetupState = {
endpoint: string;
bucket: string;
bucketPrefix: string;
expectedRepositoryId: string;
journalFormat: string;
packReadPolicy: string;
p2pEnabled: boolean;
p2pRelays: string;
p2pRoomId: string;
postgrestActiveConnectionURI: string;
webDAVactiveConnectionURI: string;
};
export type SetupCaptureNames = {
@@ -182,16 +187,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 +347,14 @@ export async function readSetupState(cliBinary: string, environment: NodeJS.Proc
"endpoint:settings.endpoint||'',",
"bucket:settings.bucket||'',",
"bucketPrefix:settings.bucketPrefix||'',",
"expectedRepositoryId:settings.expectedRepositoryId||'',",
"journalFormat:settings.journalFormat||'',",
"packReadPolicy:settings.packReadPolicy||'',",
"p2pEnabled:settings.P2P_Enabled===true,",
"p2pRelays:settings.P2P_relays||'',",
"p2pRoomId:settings.P2P_roomID||'',",
"postgrestActiveConnectionURI:settings.postgrestActiveConnectionURI||'',",
"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,262 @@
/**
* Exercises the Adaptive-only PostgREST Journal provider 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.
*/
import { parsePostgRESTConnectionURI } from "@vrtmrz/livesync-commonlib/journal-storage";
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";
type PostgRESTConfig = {
apiKey: string;
endpoint: string;
schema: string;
vaultCredential: string;
vaultId: string;
};
const supportedArguments = new Set(["--keep-postgrest", "--manage-postgrest"]);
const unsupportedArguments = process.argv.slice(2).filter((argument) => !supportedArguments.has(argument));
if (unsupportedArguments.length > 0) {
throw new Error(`Unsupported Adaptive PostgREST argument: ${unsupportedArguments.join(", ")}`);
}
const managePostgREST = process.argv.includes("--manage-postgrest");
const keepPostgRESTFixture = process.argv.includes("--keep-postgrest");
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_URI_TIMEOUT_MS ?? 30000);
function environmentValue(primary: string, legacy: string, fallback: string): string {
return process.env[primary] ?? process.env[legacy] ?? fallback;
}
function loadPostgRESTConfig(): PostgRESTConfig {
const endpoint = environmentValue("POSTGREST_ENDPOINT", "postgrestEndpoint", "http://127.0.0.1:3001").replace(
/\/+$/u,
""
);
const endpointUrl = new URL(endpoint);
if (
(endpointUrl.protocol !== "http:" && endpointUrl.protocol !== "https:") ||
endpointUrl.username !== "" ||
endpointUrl.password !== "" ||
endpointUrl.search !== "" ||
endpointUrl.hash !== ""
) {
throw new Error("Adaptive PostgREST requires a complete HTTP or HTTPS endpoint without embedded credentials.");
}
const config = {
apiKey: environmentValue("POSTGREST_API_KEY", "postgrestApiKey", ""),
endpoint,
schema: environmentValue("POSTGREST_SCHEMA", "postgrestSchema", "livesync_api"),
vaultCredential: environmentValue(
"POSTGREST_VAULT_CREDENTIAL",
"postgrestVaultCredential",
"adaptive-cli-vault-credential-0000000000001"
),
vaultId: environmentValue("POSTGREST_VAULT_ID", "postgrestVaultId", "adaptive-cli-vault-01"),
};
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(config.schema)) {
throw new Error("Adaptive PostgREST requires a valid exposed schema identifier.");
}
if (!/^[A-Za-z0-9_-]{16,128}$/u.test(config.vaultId) || config.vaultCredential.length === 0) {
throw new Error("Adaptive PostgREST requires a provisioned Vault ID and Vault credential.");
}
return config;
}
function postgRESTHeaders(config: PostgRESTConfig): Headers {
const headers = new Headers({
Accept: "application/json",
"Accept-Profile": config.schema,
"Content-Profile": config.schema,
"X-LiveSync-Vault-Credential": config.vaultCredential,
"X-LiveSync-Vault-ID": config.vaultId,
});
if (config.apiKey) headers.set("apikey", config.apiKey);
return headers;
}
async function readEstimatedSize(config: PostgRESTConfig): Promise<number> {
const response = await fetch(`${config.endpoint}/rpc/livesync_adaptive_status`, {
headers: postgRESTHeaders(config),
method: "GET",
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
await response.body?.cancel().catch(() => undefined);
throw new Error(`Adaptive PostgREST status failed with HTTP ${response.status}.`);
}
const value = (await response.json()) as unknown;
const body = Array.isArray(value) && value.length === 1 ? value[0] : value;
const estimatedSize = Number(
body && typeof body === "object" && !Array.isArray(body)
? (body as { estimated_size?: unknown }).estimated_size
: Number.NaN
);
if (!Number.isFinite(estimatedSize) || estimatedSize < 0) {
throw new Error("Adaptive PostgREST status returned an invalid estimated size.");
}
return estimatedSize;
}
async function assertPostgRESTReachable(config: PostgRESTConfig): Promise<void> {
await readEstimatedSize(config);
}
async function enterManualAdaptivePostgRESTSettings(
port: number,
config: PostgRESTConfig,
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-postgrest-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, "PostgREST Journal");
await remoteSelection
.getByRole("button", { name: "Continue to PostgREST setup", exact: true })
.click({ timeout: uiTimeoutMs });
const postgRESTModal = modalByTitle(page, "PostgREST Journal Configuration");
await postgRESTModal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await postgRESTModal.locator('input[name="postgrest-endpoint"]').fill(config.endpoint);
await postgRESTModal.locator('input[name="postgrest-vault-id"]').fill(config.vaultId);
await postgRESTModal.locator('input[name="postgrest-vault-credential"]').fill(config.vaultCredential);
await postgRESTModal.locator('input[name="postgrest-schema"]').fill(config.schema);
if (config.apiKey) await postgRESTModal.locator('input[name="postgrest-api-key"]').fill(config.apiKey);
await postgRESTModal.locator('input[name="postgrest-use-internal-api"]').check({ timeout: uiTimeoutMs });
if (
(await postgRESTModal.locator('select[name="postgrest-journal-format"]').count()) !== 0 ||
(await postgRESTModal.locator('select[name="postgrest-pack-read-policy"]').count()) !== 0
) {
throw new Error("Adaptive-only PostgREST onboarding exposed an Opaque or Pack retrieval choice.");
}
if (
(await postgRESTModal
.getByRole("button", { name: "Continue with verified settings", exact: true })
.count()) !== 0
) {
throw new Error("Adaptive PostgREST could continue before its server safety check completed.");
}
await postgRESTModal
.getByRole("button", { name: "Check PostgREST server", exact: true })
.click({ timeout: uiTimeoutMs });
await postgRESTModal
.getByText("The required PostgREST RPC operations and binary semantics were verified.", { exact: false })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await postgRESTModal
.getByRole("button", { name: "Continue with verified settings", exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
if (
(await postgRESTModal.getByRole("button", { name: "Save without connecting", exact: true }).count()) !== 0
) {
throw new Error("Adaptive PostgREST onboarding exposed the unverified Settings-only save action.");
}
});
screenshots.push(
await captureGuideDialogue(port, "guide-adaptive-postgrest-safety-check.png", "PostgREST Journal Configuration")
);
await withObsidianPage(port, async (page) => {
const postgRESTModal = modalByTitle(page, "PostgREST Journal Configuration");
await postgRESTModal
.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 assertAdaptivePostgRESTSettings(state: SetupState, config: PostgRESTConfig, label: string): void {
assertEqual(state.remoteType, "POSTGREST", `${label} did not retain PostgREST 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 = parsePostgRESTConnectionURI(state.postgrestActiveConnectionURI);
assertEqual(connection.endpoint, config.endpoint, `${label} retained a different PostgREST endpoint.`);
assertEqual(connection.schema, config.schema, `${label} retained a different PostgREST schema.`);
if (connection.vaultId !== config.vaultId || connection.vaultCredential !== config.vaultCredential) {
throw new Error(`${label} did not retain the provisioned PostgREST Vault ID and Vault credential.`);
}
if (connection.apiKey !== config.apiKey) {
throw new Error(`${label} did not retain the configured PostgREST client API key.`);
}
assertEqual(
connection.useCustomRequestHandler,
true,
`${label} did not retain the Obsidian internal request API selection.`
);
}
async function main(): Promise<void> {
const config = loadPostgRESTConfig();
let shouldStopPostgREST = false;
try {
if (managePostgREST) {
await runNpmScript("test:docker-postgrest:start");
shouldStopPostgREST = !keepPostgRESTFixture;
}
await assertPostgRESTReachable(config);
await runAdaptiveJournalObsidianRoundTrip({
label: "Adaptive PostgREST",
slug: "adaptive-postgrest",
targetDescription: `Temporary PostgREST target: ${config.endpoint}`,
enterManualSettings: async (port, vaultPassphrase) =>
await enterManualAdaptivePostgRESTSettings(port, config, vaultPassphrase),
assertSettings: (state, label) => assertAdaptivePostgRESTSettings(state, config, label),
});
} finally {
if (shouldStopPostgREST) {
await runNpmScript("test:docker-postgrest: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);
});
@@ -0,0 +1,186 @@
/**
* 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 { parseWebDAVConnectionURI } from "@vrtmrz/livesync-commonlib/journal-storage";
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,
loadWebDAVConfig,
makeUniqueWebDAVPrefix,
type WebDAVConfig,
} from "../runner/webDAV.ts";
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);
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.`
);
}
async function main(): Promise<void> {
const webDAV = await loadWebDAVConfig();
const prefix = makeUniqueWebDAVPrefix("adaptive-obsidian");
let shouldStopWebDAV = false;
try {
if (manageWebDAV) {
await runNpmScript("test:docker-webdav:start");
shouldStopWebDAV = !keepWebDAVFixture;
}
await assertWebDAVReachable(webDAV);
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),
});
} finally {
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);
});
+41
View File
@@ -17,6 +17,7 @@ const testSteps: Step[] = [
{ name: "Svelte dialogue mounts", args: ["run", "test:e2e:obsidian:dialog-mounts"] },
{ name: "revision repair", args: ["run", "test:e2e:obsidian:revision-repair"] },
{ name: "settings UI", args: ["run", "test:e2e:obsidian:settings-ui"] },
{ name: "remote setup providers", args: ["run", "test:e2e:obsidian:remote-setup-providers"] },
{ name: "Review Harness", args: ["run", "test:e2e:obsidian:review-harness"] },
{ name: "P2P status pane", args: ["run", "test:e2e:obsidian:p2p-pane"] },
{ name: "vault reflection", args: ["run", "test:e2e:obsidian:vault-reflection"] },
@@ -30,6 +31,8 @@ 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: "Adaptive PostgREST workflow", args: ["run", "test:e2e:obsidian:adaptive-postgrest"] },
{
name: "Object Storage Setup URI workflow",
args: ["run", "test:e2e:obsidian:object-storage-setup-uri-workflow"],
@@ -46,10 +49,14 @@ 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 managePostgREST = process.argv.includes("--manage-postgrest") || 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");
const keepPostgREST = keepServices || process.argv.includes("--keep-postgrest");
function npmBinary(): string {
return process.platform === "win32" ? "npm.cmd" : "npm";
@@ -104,10 +111,28 @@ 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 stopManagedPostgREST(): Promise<void> {
await runStep({
name: "stop PostgREST fixture",
args: ["run", "test:docker-postgrest:stop"],
optional: true,
});
}
async function main(): Promise<void> {
let shouldStopCouchDb = false;
let shouldStopMinio = false;
let shouldStopP2P = false;
let shouldStopWebDAV = false;
let shouldStopPostgREST = false;
try {
if (manageCouchDb) {
await stopManagedCouchDb();
@@ -124,11 +149,27 @@ 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;
}
if (managePostgREST) {
await stopManagedPostgREST();
await runStep({ name: "start PostgREST fixture", args: ["run", "test:docker-postgrest:start"] });
shouldStopPostgREST = !keepPostgREST;
}
for (const step of testSteps) {
await runStep(step);
}
} finally {
if (shouldStopPostgREST) {
await stopManagedPostgREST();
}
if (shouldStopWebDAV) {
await stopManagedWebDAV();
}
if (shouldStopP2P) {
await stopManagedP2P();
}
@@ -0,0 +1,524 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import {
defaultRemoteProviderRegistry,
type BuiltInRemoteConfiguration,
type RemoteConfiguration,
} from "@vrtmrz/livesync-commonlib/remote-configurations";
import { parsePostgRESTConnectionURI, parseWebDAVConnectionURI } from "@vrtmrz/livesync-commonlib/journal-storage";
import { enableAndReloadPlugin } from "@vrtmrz/obsidian-test-session";
import type { Locator, Page } from "playwright";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
createE2eCouchDbPluginData,
createE2eObsidianDeviceLocalState,
waitForLiveSyncCoreReady,
} from "../runner/liveSyncWorkflow.ts";
import {
beginRemoteProfileSetup,
captureAndCancelRemoteProvider,
captureRemoteProviderChoices,
closeRemoteConfigurationSettings,
installRemoteSetupTestSeam,
openRemoteConfigurationSettings,
openSavedRemoteProfile,
remoteProviderModal,
remoteSetupCalls,
runtimeRemoteSettings,
selectRemoteProvider,
setRemoteInspectionMode,
waitForSavedRemoteProfile,
} from "../runner/remoteSetupUi.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import {
captureObsidianDialogue,
captureObsidianElement,
obsidianRemoteDebuggingPort,
withObsidianPage,
} from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_REMOTE_SETUP_TIMEOUT_MS ?? 10000);
const repositoryA = "A".repeat(43);
const repositoryB = `${"A".repeat(42)}Q`;
const profiles = {
postgrest: {
apiKey: "publishable-ui-key",
credential: "remote-setup-ui-credential",
endpoint: "https://postgrest.example.test/rest/v1",
expectedRepositoryId: repositoryB,
name: "PostgREST UI profile",
schema: "livesync_api",
vaultId: "remote-setup-vault-01",
},
s3: {
accessKey: "remote-setup-access-key",
bucket: "remote-setup-bucket",
endpoint: "https://s3.example.test",
name: "S3 UI profile",
prefix: "adaptive-ui/",
region: "us-east-1",
secretKey: "remote-setup-secret-key",
},
webdav: {
endpoint: "https://dav.example.test/remote.php/dav/files/tester",
expectedRepositoryId: repositoryA,
name: "WebDAV UI profile",
password: "remote-setup-password",
prefix: "adaptive-ui/",
username: "remote-setup-user",
},
} as const;
const providerChoices = [
"CouchDB",
"S3-compatible Object Storage",
"WebDAV Journal",
"PostgREST Journal",
"Peer-to-Peer (P2P)",
] as const;
type PersistedSettings = {
activeConfigurationId: string;
remoteConfigurations: Record<string, RemoteConfiguration>;
};
function assertEqual(actual: unknown, expected: unknown, message: string): void {
if (actual !== expected) {
throw new Error(`${message}\nExpected: ${String(expected)}\nActual: ${String(actual)}`);
}
}
function profileByName(settings: PersistedSettings, name: string): RemoteConfiguration {
const profile = Object.values(settings.remoteConfigurations).find((candidate) => candidate.name === name);
if (!profile) throw new Error(`Saved remote profile '${name}' was not found`);
return profile;
}
function parseProfile(settings: PersistedSettings, name: string): BuiltInRemoteConfiguration {
return defaultRemoteProviderRegistry.parse(profileByName(settings, name).uri);
}
function assertPersistedProfiles(settings: PersistedSettings): void {
const s3 = parseProfile(settings, profiles.s3.name);
assertEqual(s3.type, "s3", "The S3 dialogue returned the wrong provider type.");
if (s3.type !== "s3") return;
assertEqual(s3.settings.endpoint, profiles.s3.endpoint, "The S3 endpoint was not saved.");
assertEqual(s3.settings.bucket, profiles.s3.bucket, "The S3 bucket was not saved.");
assertEqual(s3.settings.bucketPrefix, profiles.s3.prefix, "The S3 prefix was not saved.");
assertEqual(s3.settings.journalFormat, "adaptive-v1", "The S3 Adaptive format was not saved.");
assertEqual(s3.settings.packReadPolicy, "range", "The S3 Range policy was not saved.");
const webdav = parseProfile(settings, profiles.webdav.name);
assertEqual(webdav.type, "webdav", "The WebDAV dialogue returned the wrong provider type.");
if (webdav.type !== "webdav") return;
const webdavConnection = parseWebDAVConnectionURI(webdav.settings.webDAVactiveConnectionURI);
assertEqual(webdavConnection.endpoint, profiles.webdav.endpoint, "The WebDAV endpoint was not saved.");
assertEqual(webdavConnection.prefix, profiles.webdav.prefix, "The WebDAV prefix was not saved.");
assertEqual(webdavConnection.username, profiles.webdav.username, "The WebDAV username was not saved.");
assertEqual(webdav.settings.expectedRepositoryId, repositoryA, "The WebDAV repository ID was not saved.");
assertEqual(webdav.settings.journalFormat, "adaptive-v1", "The WebDAV Adaptive format was not saved.");
assertEqual(webdav.settings.packReadPolicy, "range", "The WebDAV Range policy was not saved.");
const postgrest = parseProfile(settings, profiles.postgrest.name);
assertEqual(postgrest.type, "postgrest", "The PostgREST dialogue returned the wrong provider type.");
if (postgrest.type !== "postgrest") return;
const postgrestConnection = parsePostgRESTConnectionURI(postgrest.settings.postgrestActiveConnectionURI);
assertEqual(postgrestConnection.endpoint, profiles.postgrest.endpoint, "The PostgREST endpoint was not saved.");
assertEqual(postgrestConnection.schema, profiles.postgrest.schema, "The PostgREST schema was not saved.");
assertEqual(postgrestConnection.vaultId, profiles.postgrest.vaultId, "The PostgREST Vault ID was not saved.");
assertEqual(postgrest.settings.expectedRepositoryId, repositoryB, "The PostgREST repository ID was not saved.");
assertEqual(postgrest.settings.journalFormat, "adaptive-v1", "PostgREST did not retain its fixed format.");
assertEqual(postgrest.settings.packReadPolicy, "whole-pack", "PostgREST did not retain its fixed read policy.");
}
function assertEncryptedProfilesAtRest(settings: PersistedSettings): void {
for (const fixture of [profiles.s3, profiles.webdav, profiles.postgrest]) {
const profile = profileByName(settings, fixture.name);
assertEqual(profile.isEncrypted, true, `Saved remote profile '${fixture.name}' was not encrypted at rest.`);
for (const exposed of [
profiles.s3.endpoint,
profiles.s3.secretKey,
profiles.webdav.endpoint,
profiles.webdav.password,
profiles.postgrest.endpoint,
profiles.postgrest.credential,
]) {
if (profile.uri.includes(exposed)) {
throw new Error(`Saved remote profile '${fixture.name}' exposed a connection secret or endpoint.`);
}
}
}
}
async function fill(locator: Locator, value: string): Promise<void> {
await locator.fill(value, { timeout: uiTimeoutMs });
}
async function openAdvanced(modal: Locator): Promise<void> {
const details = modal.locator("details").filter({ hasText: "Advanced Settings" }).first();
await details.waitFor({ state: "visible", timeout: uiTimeoutMs });
if (!(await details.getAttribute("open"))) {
await details.locator("summary").click({ timeout: uiTimeoutMs });
}
}
async function expectInputValue(modal: Locator, name: string, expected: string): Promise<void> {
const actual = await modal.locator(`[name="${name}"]`).inputValue({ timeout: uiTimeoutMs });
assertEqual(actual, expected, `Reloaded control '${name}' has the wrong value.`);
}
async function mountLegacyProvider(
port: number,
profileName: string,
choice: string,
proceed: string,
title: string,
screenshotName: string
): Promise<string> {
await beginRemoteProfileSetup(port, profileName, uiTimeoutMs);
await selectRemoteProvider(port, choice, proceed, title, uiTimeoutMs);
return await captureAndCancelRemoteProvider(port, screenshotName, title, uiTimeoutMs);
}
async function addS3Profile(port: number): Promise<string> {
await beginRemoteProfileSetup(port, profiles.s3.name, uiTimeoutMs);
await selectRemoteProvider(
port,
"S3-compatible Object Storage",
"Continue to Object Storage setup",
"S3/MinIO/R2 Configuration",
uiTimeoutMs
);
const screenshot = await captureObsidianDialogue(port, "remote-setup-s3-adaptive.png", async (page) => {
const modal = remoteProviderModal(page, "S3/MinIO/R2 Configuration");
const testButton = modal.getByRole("button", { name: "Test Settings and Continue", exact: true });
if (!(await testButton.isDisabled()))
throw new Error("Incomplete S3 settings did not disable connection testing.");
await fill(modal.locator('[name="s3-endpoint"]'), profiles.s3.endpoint);
await fill(modal.locator('[name="s3-access-key-id"]'), profiles.s3.accessKey);
await fill(modal.locator('[name="s3-secret-access-key"]'), profiles.s3.secretKey);
await fill(modal.locator('[name="s3-bucket-name"]'), profiles.s3.bucket);
await fill(modal.locator('[name="s3-region"]'), profiles.s3.region);
await fill(modal.locator('[name="s3-folder-prefix"]'), profiles.s3.prefix);
await openAdvanced(modal);
await modal.locator('[name="s3-journal-format"]').selectOption("adaptive-v1");
await modal.locator('[name="s3-pack-read-policy"]').selectOption("range");
if (await testButton.isDisabled()) throw new Error("Complete S3 settings did not enable connection testing.");
});
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, "S3/MinIO/R2 Configuration");
await modal.getByRole("button", { name: "Test Settings and Continue", exact: true }).click({
timeout: uiTimeoutMs,
});
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
await waitForSavedRemoteProfile(port, profiles.s3.name, uiTimeoutMs);
return screenshot;
}
async function addWebDAVProfile(port: number): Promise<string> {
await beginRemoteProfileSetup(port, profiles.webdav.name, uiTimeoutMs);
await selectRemoteProvider(
port,
"WebDAV Journal",
"Continue to WebDAV setup",
"WebDAV Journal Configuration",
uiTimeoutMs
);
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, "WebDAV Journal Configuration");
await fill(modal.locator('[name="webdav-endpoint"]'), profiles.webdav.endpoint);
await fill(modal.locator('[name="webdav-username"]'), profiles.webdav.username);
await fill(modal.locator('[name="webdav-password"]'), profiles.webdav.password);
await fill(modal.locator('[name="webdav-prefix"]'), profiles.webdav.prefix);
await modal.locator('[name="webdav-use-internal-api"]').check({ timeout: uiTimeoutMs });
await openAdvanced(modal);
await modal.locator('[name="webdav-journal-format"]').selectOption("adaptive-v1");
await fill(modal.locator('[name="webdav-expected-repository-id"]'), repositoryA);
await modal.locator('[name="webdav-pack-read-policy"]').selectOption("range");
});
await setRemoteInspectionMode(port, "failed");
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, "WebDAV Journal Configuration");
await modal.getByRole("button", { name: "Run endpoint safety check", exact: true }).click({
timeout: uiTimeoutMs,
});
await modal.getByText("missing required Adaptive operations", { exact: false }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
if ((await modal.getByRole("button", { name: "Save verified settings", exact: true }).count()) !== 0) {
throw new Error("A failed WebDAV safety check exposed the verified-save action.");
}
});
await setRemoteInspectionMode(port, "verified");
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, "WebDAV Journal Configuration");
await modal.getByRole("button", { name: "Run endpoint safety check", exact: true }).click({
timeout: uiTimeoutMs,
});
await modal.getByRole("button", { name: "Save verified settings", exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await fill(modal.locator('[name="webdav-prefix"]'), "stale-inspection/");
if ((await modal.getByRole("button", { name: "Save verified settings", exact: true }).count()) !== 0) {
throw new Error("Editing WebDAV settings did not invalidate the previous safety check.");
}
await fill(modal.locator('[name="webdav-prefix"]'), profiles.webdav.prefix);
await modal.getByRole("button", { name: "Run endpoint safety check", exact: true }).click({
timeout: uiTimeoutMs,
});
await modal.getByRole("button", { name: "Save verified settings", exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
});
const screenshot = await captureObsidianDialogue(port, "remote-setup-webdav-verified.png", async (page) => {
await remoteProviderModal(page, "WebDAV Journal Configuration")
.getByText("Required Adaptive operations are supported", { exact: false })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
});
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, "WebDAV Journal Configuration");
await modal.getByRole("button", { name: "Save verified settings", exact: true }).click({
timeout: uiTimeoutMs,
});
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
await waitForSavedRemoteProfile(port, profiles.webdav.name, uiTimeoutMs);
return screenshot;
}
async function addPostgRESTProfile(port: number): Promise<string> {
await beginRemoteProfileSetup(port, profiles.postgrest.name, uiTimeoutMs);
await selectRemoteProvider(
port,
"PostgREST Journal",
"Continue to PostgREST setup",
"PostgREST Journal Configuration",
uiTimeoutMs
);
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, "PostgREST Journal Configuration");
const check = modal.getByRole("button", { name: "Check PostgREST server", exact: true });
if (!(await check.isDisabled())) {
throw new Error("Incomplete PostgREST settings did not disable the server check.");
}
await fill(modal.locator('[name="postgrest-endpoint"]'), profiles.postgrest.endpoint);
await fill(modal.locator('[name="postgrest-vault-id"]'), profiles.postgrest.vaultId);
await fill(modal.locator('[name="postgrest-vault-credential"]'), profiles.postgrest.credential);
await fill(modal.locator('[name="postgrest-schema"]'), profiles.postgrest.schema);
await fill(modal.locator('[name="postgrest-api-key"]'), profiles.postgrest.apiKey);
await modal.locator('[name="postgrest-use-internal-api"]').check({ timeout: uiTimeoutMs });
await openAdvanced(modal);
await fill(modal.locator('[name="postgrest-expected-repository-id"]'), repositoryB);
if (await check.isDisabled()) throw new Error("Complete PostgREST settings did not enable the server check.");
await check.click({ timeout: uiTimeoutMs });
try {
await modal.getByRole("button", { name: "Save verified settings", exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(
`PostgREST verification did not enable saving. Dialogue text:\n${await modal.innerText()}\nCause: ${reason}`
);
}
await fill(modal.locator('[name="postgrest-api-key"]'), "stale-publishable-key");
if ((await modal.getByRole("button", { name: "Save verified settings", exact: true }).count()) !== 0) {
throw new Error("Editing PostgREST settings did not invalidate the previous server check.");
}
await fill(modal.locator('[name="postgrest-api-key"]'), profiles.postgrest.apiKey);
await modal.getByRole("button", { name: "Check PostgREST server", exact: true }).click({
timeout: uiTimeoutMs,
});
await modal.getByRole("button", { name: "Save verified settings", exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
});
const screenshot = await captureObsidianDialogue(port, "remote-setup-postgrest-verified.png", async (page) => {
await remoteProviderModal(page, "PostgREST Journal Configuration")
.getByText("required PostgREST RPC operations", { exact: false })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
});
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, "PostgREST Journal Configuration");
await modal.getByRole("button", { name: "Save verified settings", exact: true }).click({
timeout: uiTimeoutMs,
});
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
await waitForSavedRemoteProfile(port, profiles.postgrest.name, uiTimeoutMs);
return screenshot;
}
async function assertReloadedS3(port: number): Promise<void> {
await openSavedRemoteProfile(port, profiles.s3.name, uiTimeoutMs);
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, "S3/MinIO/R2 Configuration");
await expectInputValue(modal, "s3-endpoint", profiles.s3.endpoint);
await expectInputValue(modal, "s3-bucket-name", profiles.s3.bucket);
await openAdvanced(modal);
await expectInputValue(modal, "s3-journal-format", "adaptive-v1");
await expectInputValue(modal, "s3-pack-read-policy", "range");
await modal.getByRole("button", { name: "Cancel", exact: true }).click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
}
async function assertReloadedWebDAV(port: number): Promise<void> {
await openSavedRemoteProfile(port, profiles.webdav.name, uiTimeoutMs);
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, "WebDAV Journal Configuration");
await expectInputValue(modal, "webdav-endpoint", profiles.webdav.endpoint);
await expectInputValue(modal, "webdav-prefix", profiles.webdav.prefix);
await openAdvanced(modal);
await expectInputValue(modal, "webdav-journal-format", "adaptive-v1");
await expectInputValue(modal, "webdav-expected-repository-id", repositoryA);
await expectInputValue(modal, "webdav-pack-read-policy", "range");
await modal.getByRole("button", { name: "Cancel", exact: true }).click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
}
async function assertReloadedPostgREST(port: number): Promise<void> {
await openSavedRemoteProfile(port, profiles.postgrest.name, uiTimeoutMs);
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, "PostgREST Journal Configuration");
await expectInputValue(modal, "postgrest-endpoint", profiles.postgrest.endpoint);
await expectInputValue(modal, "postgrest-vault-id", profiles.postgrest.vaultId);
await expectInputValue(modal, "postgrest-schema", profiles.postgrest.schema);
await openAdvanced(modal);
await expectInputValue(modal, "postgrest-expected-repository-id", repositoryB);
await modal.getByRole("button", { name: "Cancel", exact: true }).click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
}
async function readPersistedSettings(pluginDir: string): Promise<PersistedSettings> {
return JSON.parse(await readFile(join(pluginDir, "data.json"), "utf8")) as PersistedSettings;
}
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 vault = await createTemporaryVault("obsidian-livesync-remote-setup-e2e-");
let session: ObsidianLiveSyncSession | undefined;
try {
session = await startObsidianLiveSyncSession({
binary,
cliBinary: cli.binary,
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
pluginData: createE2eCouchDbPluginData(
{
dbName: "remote-setup-ui-only",
password: "",
uri: "http://127.0.0.1:5984",
username: "",
},
{
notifyThresholdOfRemoteStorageSize: 0,
periodicReplication: false,
syncAfterMerge: false,
syncOnEditorSave: false,
syncOnFileOpen: false,
syncOnSave: false,
syncOnStart: false,
useAdvancedMode: true,
}
),
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
vault,
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
const port = obsidianRemoteDebuggingPort();
await installRemoteSetupTestSeam(port);
await openRemoteConfigurationSettings(port, uiTimeoutMs);
await beginRemoteProfileSetup(port, "Cancelled CouchDB profile", uiTimeoutMs);
const selectionScreenshot = await captureRemoteProviderChoices(
port,
"remote-setup-provider-selection.png",
providerChoices,
uiTimeoutMs
);
await selectRemoteProvider(port, "CouchDB", "Continue to CouchDB setup", "CouchDB Configuration", uiTimeoutMs);
const couchdbScreenshot = await captureAndCancelRemoteProvider(
port,
"remote-setup-couchdb.png",
"CouchDB Configuration",
uiTimeoutMs
);
const p2pScreenshot = await mountLegacyProvider(
port,
"Cancelled P2P profile",
"Peer-to-Peer (P2P)",
"Continue to P2P setup",
"P2P Configuration",
"remote-setup-p2p.png"
);
const s3Screenshot = await addS3Profile(port);
const webdavScreenshot = await addWebDAVProfile(port);
const postgrestScreenshot = await addPostgRESTProfile(port);
const firstRuntimeSettings = (await runtimeRemoteSettings(port)) as PersistedSettings;
assertPersistedProfiles(firstRuntimeSettings);
const calls = await remoteSetupCalls(port);
assertEqual(
calls.filter((call) => call.operation === "test").length,
1,
"The S3 profile did not use the injected connection-test boundary exactly once."
);
if (calls.filter((call) => call.operation === "inspect").length < 5) {
throw new Error("The WebDAV and PostgREST profiles did not exercise failure, stale, and verified checks.");
}
const pluginDir = session.install.pluginDir;
assertEncryptedProfilesAtRest(await readPersistedSettings(pluginDir));
await closeRemoteConfigurationSettings(port, uiTimeoutMs);
await enableAndReloadPlugin(port, "obsidian-livesync");
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
await openRemoteConfigurationSettings(port, uiTimeoutMs);
await assertReloadedS3(port);
await assertReloadedWebDAV(port);
await assertReloadedPostgREST(port);
const reloadedSettings = (await runtimeRemoteSettings(port)) as PersistedSettings;
assertPersistedProfiles(reloadedSettings);
assertEqual(
Object.keys(reloadedSettings.remoteConfigurations).length,
Object.keys(firstRuntimeSettings.remoteConfigurations).length,
"Plug-in reload changed the saved remote profile count."
);
const listScreenshot = await captureObsidianElement(port, "remote-setup-reloaded-profiles.png", (page: Page) =>
page.locator(".sls-setting .sls-remote-list")
);
console.log(
`All registered providers mounted through the real Settings flow; S3, WebDAV, and PostgREST passed validation, injected checks, persistence, and plug-in reload. Screenshots: ${[
selectionScreenshot,
couchdbScreenshot,
p2pScreenshot,
s3Screenshot,
webdavScreenshot,
postgrestScreenshot,
listScreenshot,
].join(", ")}`
);
} finally {
if (session) await session.app.stop();
await vault.dispose();
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});
+4 -1
View File
@@ -11,6 +11,7 @@ const focusedScenarios = new Set([
"revision-repair",
"document-history-nav",
"settings-ui",
"remote-setup-providers",
"review-harness",
"p2p-pane",
"vault-reflection",
@@ -18,6 +19,8 @@ const focusedScenarios = new Set([
"couchdb-manual-setup-workflow",
"cli-to-obsidian-sync",
"minio-upload",
"adaptive-webdav",
"adaptive-postgrest",
"object-storage-setup-uri-workflow",
"p2p-setup-uri-workflow",
"startup-scan",
@@ -38,7 +41,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, PostgREST, WebDAV, or the P2P signalling
relay. Use the documented service commands or the complete
local-suite:services wrapper when required.`;
}