mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-25 05:53:00 +00:00
370 lines
16 KiB
TypeScript
370 lines
16 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
|
import { readFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { evalObsidianJson } from "../runner/cli.ts";
|
|
import {
|
|
assertCouchDbReachable,
|
|
deleteCouchDbDatabase,
|
|
loadCouchDbConfig,
|
|
makeUniqueDatabaseName,
|
|
waitForCouchDbDocs,
|
|
type CouchDbConfig,
|
|
} from "../runner/couchdb.ts";
|
|
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
|
import { assertEqual, pushLocalChanges, waitForLocalDatabaseEntry } from "../runner/liveSyncWorkflow.ts";
|
|
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
|
import {
|
|
acknowledgeDisabledOptionalFeatures,
|
|
captureAndStartInitialisation,
|
|
captureGuideDialogue,
|
|
confirmFastFetch,
|
|
confirmRebuild,
|
|
enterSetupURI,
|
|
finishInitialisation,
|
|
generateSetupURIFromDevice,
|
|
modalByTitle,
|
|
resumeCompatibilityReviewIfShown,
|
|
selectRadioOption,
|
|
skipMissingRemoteConfiguration,
|
|
type SetupArtifact,
|
|
} from "../runner/setupUri.ts";
|
|
import { captureObsidianPage, withObsidianPage } from "../runner/ui.ts";
|
|
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
|
|
|
|
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
|
|
process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "30000";
|
|
|
|
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_URI_TIMEOUT_MS ?? 30000);
|
|
const notePath = "E2E/manual-couchdb/from-first-device.md";
|
|
const noteContent = "# Manual CouchDB setup\n\nThis note was sent by the manually configured first device.\n";
|
|
const returnNotePath = "E2E/manual-couchdb/from-second-device.md";
|
|
const returnNoteContent =
|
|
"# Manual CouchDB return journey\n\nThis note returned through a Setup URI generated by the first device.\n";
|
|
const captures = {
|
|
scenario: "couchdb-manual-setup-workflow",
|
|
guide: "couchdb-manual",
|
|
} as const;
|
|
|
|
type RunnerContext = {
|
|
binary: string;
|
|
cliBinary: string;
|
|
couchDb: CouchDbConfig;
|
|
dbName: string;
|
|
activeSessions: Set<ObsidianLiveSyncSession>;
|
|
};
|
|
|
|
async function startUnconfiguredSession(
|
|
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);
|
|
return session;
|
|
}
|
|
|
|
async function stopTrackedSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise<void> {
|
|
if (!context.activeSessions.has(session)) return;
|
|
await session.app.stop();
|
|
context.activeSessions.delete(session);
|
|
}
|
|
|
|
async function stopTrackedSessions(context: RunnerContext): Promise<void> {
|
|
for (const session of [...context.activeSessions]) {
|
|
await stopTrackedSession(context, session);
|
|
}
|
|
}
|
|
|
|
async function captureFailure(session: ObsidianLiveSyncSession, label: string): Promise<void> {
|
|
const screenshot = await captureObsidianPage(
|
|
session.remoteDebuggingPort,
|
|
`couchdb-manual-${label}-failure.png`,
|
|
async () => undefined
|
|
).catch(() => undefined);
|
|
if (screenshot) {
|
|
console.error(`Manual CouchDB failure screenshot: ${screenshot}`);
|
|
}
|
|
}
|
|
|
|
async function enterManualCouchDBSettings(port: number, couchDb: CouchDbConfig, dbName: 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 });
|
|
});
|
|
|
|
screenshots.push(
|
|
await captureGuideDialogue(port, "guide-couchdb-manual-connection-method.png", "Connection Method")
|
|
);
|
|
await withObsidianPage(port, async (page) => {
|
|
const method = modalByTitle(page, "Connection Method");
|
|
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("label.row")
|
|
.filter({ hasText: "Obfuscate Properties" })
|
|
.locator('input[type="checkbox"]')
|
|
.first()
|
|
.check({ timeout: uiTimeoutMs });
|
|
await encryption.locator('input[name="e2ee-passphrase"]').fill(randomBytes(24).toString("base64url"));
|
|
});
|
|
screenshots.push(await captureGuideDialogue(port, "guide-couchdb-manual-encryption.png", "End-to-End Encryption"));
|
|
await withObsidianPage(port, async (page) => {
|
|
const encryption = modalByTitle(page, "End-to-End Encryption");
|
|
await encryption.getByRole("button", { name: "Proceed", exact: true }).click({ timeout: uiTimeoutMs });
|
|
});
|
|
|
|
screenshots.push(
|
|
await captureGuideDialogue(port, "guide-couchdb-manual-remote-selection.png", "Choose a synchronisation remote")
|
|
);
|
|
await withObsidianPage(port, async (page) => {
|
|
const remoteSelection = modalByTitle(page, "Choose a synchronisation remote");
|
|
await selectRadioOption(remoteSelection, "CouchDB");
|
|
await remoteSelection
|
|
.getByRole("button", { name: "Continue to CouchDB setup", exact: true })
|
|
.click({ timeout: uiTimeoutMs });
|
|
|
|
const couchDB = modalByTitle(page, "CouchDB Configuration");
|
|
await couchDB.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
|
await couchDB.locator('input[name="couchdb-url"]').fill(couchDb.uri);
|
|
await couchDB.locator('input[name="couchdb-username"]').fill(couchDb.username);
|
|
await couchDB.locator('input[name="couchdb-password"]').fill(couchDb.password);
|
|
await couchDB.locator('input[name="couchdb-database"]').fill(dbName);
|
|
});
|
|
screenshots.push(
|
|
await captureGuideDialogue(port, "guide-couchdb-manual-connection-details.png", "CouchDB Configuration")
|
|
);
|
|
|
|
await withObsidianPage(port, async (page) => {
|
|
const couchDB = modalByTitle(page, "CouchDB Configuration");
|
|
await couchDB
|
|
.getByRole("button", { name: "Check server requirements", exact: true })
|
|
.click({ timeout: uiTimeoutMs });
|
|
const summary = couchDB.locator(".check-results summary");
|
|
await summary.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
|
await summary
|
|
.filter({ hasText: /All checks passed successfully!|issue\(s\) detected!/u })
|
|
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
|
const errors = couchDB.locator(".check-result.error");
|
|
if ((await errors.count()) > 0) {
|
|
const messages = await errors.locator(".message").allTextContents();
|
|
throw new Error(`The documented CouchDB fixture failed its server requirements: ${messages.join(" | ")}`);
|
|
}
|
|
const details = couchDB.locator(".check-results details");
|
|
if (!(await details.evaluate((element) => (element as HTMLDetailsElement).open))) {
|
|
await summary.click({ timeout: uiTimeoutMs });
|
|
}
|
|
});
|
|
screenshots.push(
|
|
await captureGuideDialogue(port, "guide-couchdb-manual-server-requirements.png", "CouchDB Configuration")
|
|
);
|
|
|
|
await withObsidianPage(port, async (page) => {
|
|
const couchDB = modalByTitle(page, "CouchDB Configuration");
|
|
await couchDB
|
|
.getByRole("button", { name: "Create or connect to database and continue", exact: true })
|
|
.click({ timeout: uiTimeoutMs });
|
|
await modalByTitle(page, "Setup Complete: Preparing to Initialise Server").waitFor({
|
|
state: "visible",
|
|
timeout: uiTimeoutMs,
|
|
});
|
|
});
|
|
return screenshots;
|
|
}
|
|
|
|
async function writeNoteViaObsidian(
|
|
cliBinary: string,
|
|
environment: NodeJS.ProcessEnv,
|
|
path: string,
|
|
content: string
|
|
): Promise<void> {
|
|
await evalObsidianJson<unknown>(
|
|
cliBinary,
|
|
[
|
|
"(async()=>{",
|
|
`const path=${JSON.stringify(path)};`,
|
|
`const content=${JSON.stringify(content)};`,
|
|
"const folder=path.split('/').slice(0,-1).join('/');",
|
|
"if(folder&&!(await app.vault.adapter.exists(folder))) await app.vault.createFolder(folder);",
|
|
"const existing=app.vault.getAbstractFileByPath(path);",
|
|
"if(existing) await app.vault.modify(existing,content);",
|
|
"else await app.vault.create(path,content);",
|
|
"return JSON.stringify({ok:true});",
|
|
"})()",
|
|
].join(""),
|
|
environment
|
|
);
|
|
}
|
|
|
|
async function waitForVaultFile(
|
|
vault: TemporaryVault,
|
|
path: string,
|
|
expected: string,
|
|
timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 30000)
|
|
): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
let lastContent = "";
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
lastContent = await readFile(join(vault.path, path), "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 ${path}. Last content:\n${lastContent}`);
|
|
}
|
|
|
|
async function waitForRemoteEntry(context: RunnerContext, entry: { id: string; children: string[] }): Promise<void> {
|
|
await waitForCouchDbDocs(context.couchDb, context.dbName, (docs) => {
|
|
const ids = new Set(docs.map((doc) => doc._id));
|
|
return ids.has(entry.id) && entry.children.every((childId) => ids.has(childId));
|
|
});
|
|
}
|
|
|
|
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 couchDb = await loadCouchDbConfig();
|
|
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "manual-setup");
|
|
const vaultA = await createTemporaryVault();
|
|
const vaultB = await createTemporaryVault();
|
|
const context: RunnerContext = {
|
|
binary,
|
|
cliBinary: cli.binary,
|
|
couchDb,
|
|
dbName,
|
|
activeSessions: new Set(),
|
|
};
|
|
const screenshots: string[] = [];
|
|
let secondDeviceArtifact: SetupArtifact | undefined;
|
|
|
|
try {
|
|
await assertCouchDbReachable(couchDb);
|
|
console.log(`Using Obsidian executable: ${binary}`);
|
|
console.log(`Temporary Vault A: ${vaultA.path}`);
|
|
console.log(`Temporary Vault B: ${vaultB.path}`);
|
|
console.log(`CouchDB database to be created by the onboarding dialogue: ${dbName}`);
|
|
|
|
let session = await startUnconfiguredSession(context, vaultA);
|
|
try {
|
|
screenshots.push(...(await enterManualCouchDBSettings(session.remoteDebuggingPort, couchDb, dbName)));
|
|
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);
|
|
assertEqual(state.activeConfigurationId !== "", true, "Manual CouchDB setup did not activate a profile.");
|
|
assertEqual(
|
|
state.remoteConfigurationCount,
|
|
1,
|
|
"Manual CouchDB setup did not persist exactly one remote profile."
|
|
);
|
|
|
|
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, notePath, noteContent);
|
|
const entry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, notePath);
|
|
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
|
await waitForRemoteEntry(context, entry);
|
|
|
|
const generated = await generateSetupURIFromDevice(
|
|
session.remoteDebuggingPort,
|
|
randomBytes(24).toString("base64url"),
|
|
captures
|
|
);
|
|
secondDeviceArtifact = generated.artifact;
|
|
screenshots.push(...generated.screenshots);
|
|
} catch (error) {
|
|
await captureFailure(session, "first-device");
|
|
throw error;
|
|
} finally {
|
|
await stopTrackedSession(context, session);
|
|
}
|
|
|
|
session = await startUnconfiguredSession(context, vaultB);
|
|
try {
|
|
if (!secondDeviceArtifact) {
|
|
throw new Error("The manually configured first device did not generate a Setup URI.");
|
|
}
|
|
screenshots.push(
|
|
await enterSetupURI(session.remoteDebuggingPort, "existing", secondDeviceArtifact, captures)
|
|
);
|
|
screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "existing", captures));
|
|
screenshots.push(...(await confirmFastFetch(session.remoteDebuggingPort, captures)));
|
|
await finishInitialisation(session.remoteDebuggingPort, context.cliBinary, session.cliEnv);
|
|
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
|
|
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
|
await waitForVaultFile(vaultB, notePath, noteContent);
|
|
|
|
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, returnNotePath, returnNoteContent);
|
|
const returnEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, returnNotePath);
|
|
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
|
await waitForRemoteEntry(context, returnEntry);
|
|
} catch (error) {
|
|
await captureFailure(session, "second-device");
|
|
throw error;
|
|
} finally {
|
|
await stopTrackedSession(context, session);
|
|
}
|
|
|
|
session = await startUnconfiguredSession(context, vaultA);
|
|
try {
|
|
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
|
|
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
|
await waitForVaultFile(vaultA, returnNotePath, returnNoteContent);
|
|
} catch (error) {
|
|
await captureFailure(session, "return-journey");
|
|
throw error;
|
|
} finally {
|
|
await stopTrackedSession(context, session);
|
|
}
|
|
|
|
console.log(
|
|
`Manual CouchDB onboarding created and tested its database, generated a second-device Setup URI, and completed a bidirectional note round-trip. Screenshots: ${screenshots.join(", ")}`
|
|
);
|
|
} finally {
|
|
await stopTrackedSessions(context).catch((error: unknown) => {
|
|
console.warn(error instanceof Error ? error.message : error);
|
|
});
|
|
await vaultA.dispose();
|
|
await vaultB.dispose();
|
|
if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") {
|
|
await deleteCouchDbDatabase(couchDb, dbName).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);
|
|
});
|