mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-03 01:07:07 +00:00
- Use a plain emoji glyph for the password visibility toggle instead of embedded SVG paths, matching the same pragmatic approach already used for the browser build's menu icons in this codebase. - Scope the wider label width to the E2EE dialogue instead of changing it for every InputRow in every Svelte dialogue. - Extend the existing CouchDB manual setup E2E workflow to assert the passphrase and Obfuscate Properties fields are hidden until encryption is enabled, then appear, and that the passphrase value survives toggling its visibility.
406 lines
18 KiB
TypeScript
406 lines
18 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,
|
|
continueWithoutRemoteSettings,
|
|
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 });
|
|
assertEqual(
|
|
await encryption.locator('input[name="e2ee-passphrase"]').count(),
|
|
0,
|
|
"The passphrase field was present before end-to-end encryption was enabled."
|
|
);
|
|
assertEqual(
|
|
await encryption.locator("label.row").filter({ hasText: "Obfuscate Properties" }).count(),
|
|
0,
|
|
"The Obfuscate Properties row was present before end-to-end encryption was enabled."
|
|
);
|
|
await encryption
|
|
.locator("label.row")
|
|
.filter({ hasText: "End-to-End Encryption" })
|
|
.locator('input[type="checkbox"]')
|
|
.first()
|
|
.check({ timeout: uiTimeoutMs });
|
|
const passphraseInput = encryption.locator('input[name="e2ee-passphrase"]');
|
|
await passphraseInput.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
|
await encryption
|
|
.locator("label.row")
|
|
.filter({ hasText: "Obfuscate Properties" })
|
|
.locator('input[type="checkbox"]')
|
|
.first()
|
|
.check({ timeout: uiTimeoutMs });
|
|
const passphraseValue = randomBytes(24).toString("base64url");
|
|
await passphraseInput.fill(passphraseValue);
|
|
const passwordToggle = encryption.locator("button.sls-password-toggle");
|
|
await passwordToggle.click({ timeout: uiTimeoutMs });
|
|
assertEqual(
|
|
await passphraseInput.getAttribute("type"),
|
|
"text",
|
|
"Toggling visibility did not reveal the passphrase."
|
|
);
|
|
assertEqual(
|
|
await passphraseInput.inputValue(),
|
|
passphraseValue,
|
|
"Toggling visibility changed the passphrase value."
|
|
);
|
|
await passwordToggle.click({ timeout: uiTimeoutMs });
|
|
assertEqual(
|
|
await passphraseInput.getAttribute("type"),
|
|
"password",
|
|
"Toggling visibility again did not re-mask the passphrase."
|
|
);
|
|
assertEqual(
|
|
await passphraseInput.inputValue(),
|
|
passphraseValue,
|
|
"Re-masking the passphrase changed its value."
|
|
);
|
|
});
|
|
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 continueWithoutRemoteSettings(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);
|
|
});
|