chore: merge upstream main into history revision branch

This commit is contained in:
SeleiXi
2026-07-29 23:45:12 +08:00
996 changed files with 75371 additions and 43489 deletions
@@ -0,0 +1,355 @@
import { spawn } from "node:child_process";
import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import {
assertCouchDbReachable,
createCouchDbDatabase,
deleteCouchDbDatabase,
loadCouchDbConfig,
makeUniqueDatabaseName,
waitForCouchDbDocs,
} from "../runner/couchdb.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertEqual,
createE2eCouchDbPluginData,
createE2eObsidianDeviceLocalState,
prepareRemote,
pushLocalChanges,
waitForLiveSyncCoreReady,
} from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { createTemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "30000";
process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ??= "30000";
const liveSyncCli = resolve("src/apps/cli/dist/index.cjs");
const notePath = "E2E/cli-to-obsidian.md";
const noteContent = [
"# CLI to real Obsidian",
"",
"This note was created by the Self-hosted LiveSync CLI.",
"The real Obsidian plug-in must retrieve the same content from CouchDB.",
"0123456789 abcdefghijklmnopqrstuvwxyz 0123456789 abcdefghijklmnopqrstuvwxyz",
"",
].join("\n");
const e2eePassphrase = "real-obsidian-cli-compatibility-e2e";
type LiveSyncCliCommand = {
executable: string;
prefixArgs: string[];
};
type CliResult = {
stdout: string;
stderr: string;
};
type CliFileInfo = {
id: string;
children: string[];
};
function parseCommandLine(value: string): string[] {
const trimmed = value.trim();
if (trimmed.startsWith("[")) {
const parsed = JSON.parse(trimmed) as unknown;
if (!Array.isArray(parsed) || parsed.length === 0 || parsed.some((part) => typeof part !== "string")) {
throw new Error("LIVESYNC_CLI_COMMAND JSON form must be a non-empty array of strings.");
}
return parsed;
}
const parts: string[] = [];
let current = "";
let quote: "'" | '"' | undefined;
let tokenStarted = false;
for (let index = 0; index < trimmed.length; index++) {
const character = trimmed[index];
if (quote) {
if (character === quote) {
quote = undefined;
continue;
}
if (character === "\\" && quote === '"' && ['"', "\\"].includes(trimmed[index + 1] ?? "")) {
current += trimmed[++index];
continue;
}
current += character;
continue;
}
if (character === "'" || character === '"') {
quote = character;
tokenStarted = true;
continue;
}
if (character === "\\" && ["'", '"', "\\", " ", "\t"].includes(trimmed[index + 1] ?? "")) {
current += trimmed[++index];
tokenStarted = true;
continue;
}
if (/\s/u.test(character)) {
if (tokenStarted) {
parts.push(current);
current = "";
tokenStarted = false;
}
continue;
}
current += character;
tokenStarted = true;
}
if (quote) {
throw new Error("LIVESYNC_CLI_COMMAND contains an unterminated quoted value.");
}
if (tokenStarted) {
parts.push(current);
}
if (parts.length === 0) {
throw new Error("LIVESYNC_CLI_COMMAND must not be empty.");
}
return parts;
}
function resolveLiveSyncCliCommand(): LiveSyncCliCommand {
const override = process.env.LIVESYNC_CLI_COMMAND;
if (override !== undefined) {
const [executable, ...prefixArgs] = parseCommandLine(override);
return { executable, prefixArgs };
}
return { executable: process.execPath, prefixArgs: [liveSyncCli] };
}
async function runLiveSyncCli(command: LiveSyncCliCommand, args: string[]): Promise<CliResult> {
return await new Promise((resolvePromise, reject) => {
const timeoutMs = Number(process.env.E2E_LIVESYNC_CLI_TIMEOUT_MS ?? 60000);
const child = spawn(command.executable, [...command.prefixArgs, ...args], {
cwd: process.cwd(),
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString("utf-8");
});
child.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf-8");
});
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
}, timeoutMs);
child.on("error", (error) => {
clearTimeout(timeout);
reject(error);
});
child.on("exit", (code, signal) => {
clearTimeout(timeout);
const result = {
stdout,
stderr,
};
if (timedOut) {
reject(
new Error(
`LiveSync CLI timed out after ${timeoutMs} ms\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`
)
);
return;
}
if (code === 0) {
resolvePromise(result);
return;
}
reject(
new Error(
`LiveSync CLI failed with ${signal ? `signal ${signal}` : `exit code ${String(code)}`}\n` +
`stdout:\n${result.stdout}\nstderr:\n${result.stderr}`
)
);
});
});
}
async function configureLiveSyncCli(
command: LiveSyncCliCommand,
settingsPath: string,
couchDb: Awaited<ReturnType<typeof loadCouchDbConfig>>,
dbName: string
): Promise<void> {
await runLiveSyncCli(command, ["init-settings", "--force", settingsPath]);
const settings = JSON.parse(await readFile(settingsPath, "utf-8")) as Record<string, unknown>;
Object.assign(settings, {
couchDB_URI: couchDb.uri,
couchDB_USER: couchDb.username,
couchDB_PASSWORD: couchDb.password,
couchDB_DBNAME: dbName,
remoteType: "",
liveSync: false,
syncOnStart: false,
syncOnSave: false,
usePluginSync: false,
usePluginSyncV2: true,
useEden: false,
customChunkSize: 60,
sendChunksBulk: false,
sendChunksBulkMaxSize: 1,
chunkSplitterVersion: "v3-rabin-karp",
readChunksOnline: true,
disableCheckingConfigMismatch: false,
enableCompression: false,
hashAlg: "xxhash64",
handleFilenameCaseSensitive: false,
doNotUseFixedRevisionForChunks: true,
E2EEAlgorithm: "v2",
encrypt: true,
passphrase: e2eePassphrase,
usePathObfuscation: true,
doctorProcessedVersion: "0.25.27",
isConfigured: true,
});
await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf-8");
}
async function writeCliNote(
command: LiveSyncCliCommand,
databasePath: string,
settingsPath: string,
sourcePath: string
): Promise<CliFileInfo> {
await mkdir(dirname(sourcePath), { recursive: true });
await writeFile(sourcePath, noteContent, "utf-8");
await runLiveSyncCli(command, [databasePath, "--settings", settingsPath, "push", sourcePath, notePath]);
const info = await runLiveSyncCli(command, [databasePath, "--settings", settingsPath, "info", notePath]);
const fileInfo = JSON.parse(info.stdout) as CliFileInfo;
if (!fileInfo.id || !Array.isArray(fileInfo.children) || fileInfo.children.length === 0) {
throw new Error(`LiveSync CLI did not create complete metadata for ${notePath}: ${info.stdout}`);
}
await runLiveSyncCli(command, [databasePath, "--settings", settingsPath, "sync"]);
return fileInfo;
}
async function waitForVaultContent(
vaultPath: string,
path: string,
timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS)
): Promise<string> {
const fullPath = join(vaultPath, path);
const deadline = Date.now() + timeoutMs;
let lastContent = "";
while (Date.now() < deadline) {
try {
lastContent = await readFile(fullPath, "utf-8");
if (lastContent === noteContent) {
return lastContent;
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
}
await new Promise((resolvePromise) => setTimeout(resolvePromise, 250));
}
throw new Error(`Timed out waiting for CLI-created note at ${fullPath}. Last content:\n${lastContent}`);
}
async function main(): Promise<void> {
const liveSyncCliCommand = resolveLiveSyncCliCommand();
if (process.env.LIVESYNC_CLI_COMMAND === undefined) {
await access(liveSyncCli).catch(() => {
throw new Error(
`Built LiveSync CLI was not found at ${liveSyncCli}. Run 'npm run build -w self-hosted-livesync-cli' first, or set LIVESYNC_CLI_COMMAND.`
);
});
}
const binary = requireObsidianBinary();
const obsidianCli = discoverObsidianCli();
if (!obsidianCli.binary) {
throw new Error(`Could not find obsidian-cli. Checked paths: ${obsidianCli.checked.join(", ")}`);
}
const couchDb = await loadCouchDbConfig();
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "cli-to-obsidian");
const cliState = await mkdtemp(join(tmpdir(), "livesync-cli-to-obsidian-e2e-"));
const cliDatabasePath = join(cliState, "database");
const cliSettingsPath = join(cliState, "settings.json");
const cliSourcePath = join(cliState, "source", "cli-to-obsidian.md");
const vault = await createTemporaryVault();
let session: ObsidianLiveSyncSession | undefined;
try {
await assertCouchDbReachable(couchDb);
await createCouchDbDatabase(couchDb, dbName);
await mkdir(cliDatabasePath, { recursive: true });
await configureLiveSyncCli(liveSyncCliCommand, cliSettingsPath, couchDb, dbName);
if (process.env.LIVESYNC_CLI_COMMAND === undefined) {
console.log(`Using locally built LiveSync CLI: ${liveSyncCli}`);
} else {
console.log(
`Using LiveSync CLI command override: ${JSON.stringify(liveSyncCliCommand.executable)} ` +
`with ${liveSyncCliCommand.prefixArgs.length} prefix argument(s)`
);
}
console.log(`Using Obsidian executable: ${binary}`);
console.log(`Temporary Obsidian vault: ${vault.path}`);
console.log(`Temporary CouchDB database: ${dbName}`);
const cliFileInfo = await writeCliNote(liveSyncCliCommand, cliDatabasePath, cliSettingsPath, cliSourcePath);
await waitForCouchDbDocs(couchDb, dbName, (docs) => {
const ids = new Set(docs.map((doc) => doc._id));
return ids.has(cliFileInfo.id) && cliFileInfo.children.every((childId) => ids.has(childId));
});
session = await startObsidianLiveSyncSession({
binary,
cliBinary: obsidianCli.binary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: createE2eCouchDbPluginData(
{
uri: couchDb.uri,
username: couchDb.username,
password: couchDb.password,
dbName,
},
{
encrypt: true,
passphrase: e2eePassphrase,
usePathObfuscation: true,
E2EEAlgorithm: "v2",
}
),
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
});
await waitForLiveSyncCoreReady(obsidianCli.binary, session.cliEnv);
await prepareRemote(obsidianCli.binary, session.cliEnv);
await pushLocalChanges(obsidianCli.binary, session.cliEnv);
const received = await waitForVaultContent(vault.path, notePath);
assertEqual(received, noteContent, "The real Obsidian plug-in did not materialise the CLI-created note.");
console.log("CLI-created encrypted note was retrieved by the real Obsidian plug-in with identical content.");
} finally {
if (session) {
await session.app.stop();
}
await vault.dispose();
await rm(cliState, { recursive: true, force: true });
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);
});
@@ -0,0 +1,434 @@
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { evalObsidianJson } from "../runner/cli.ts";
import {
createE2eObsidianDeviceLocalState,
waitForLiveSyncCoreReady,
waitForLocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { captureObsidianElement, withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
const path = "conflict-dialog-policy.md";
const baseContent = "Conflict dialogue policy\n\nShared base.\n";
const leftContent = "Conflict dialogue policy\n\nChanged on the left.\n";
const rightContent = "Conflict dialogue policy\n\nChanged on the right.\n";
const thirdContent = "Conflict dialogue policy\n\nChanged on the third branch.\n";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_CONFLICT_DIALOG_TIMEOUT_MS ?? 10000);
type ConflictFixture = {
currentRev: string;
currentParentRev?: string;
conflicts: string[];
};
type ObsidianTestApp = {
commands?: { executeCommandById(commandId: string): boolean };
};
type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp };
async function createAndOpenBaseFile(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
`const content=${JSON.stringify(baseContent)};`,
"let file=app.vault.getAbstractFileByPath(path);",
"if(!file) file=await app.vault.create(path,content);",
"await app.workspace.getLeaf(false).openFile(file);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env
);
}
async function createManualConflict(
cliBinary: string,
env: NodeJS.ProcessEnv,
baseRev: string,
contents: readonly string[]
): Promise<ConflictFixture> {
return await evalObsidianJson<ConflictFixture>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
`const baseRev=${JSON.stringify(baseRev)};`,
`const contents=${JSON.stringify(contents)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const id=await core.services.path.path2id(path);",
"for(const [index,content] of contents.entries()){",
" const blob=new Blob([content],{type:'text/plain'});",
" const now=Date.now()+index;",
" const result=await core.localDatabase.putDBEntry({",
" _id:id,path,data:blob,ctime:now,mtime:now,",
" size:(await blob.arrayBuffer()).byteLength,children:[],",
" datatype:'plain',type:'plain',eden:{},",
" },false,baseRev);",
" if(!result?.ok) throw new Error(`Could not create conflict branch: ${path}`);",
"}",
"const meta=await core.localDatabase.getDBEntryMeta(path,{conflicts:true},true);",
"if(!meta?._rev||!meta._conflicts?.length){",
" throw new Error(`Conflict fixture did not produce multiple live leaves: ${path}`);",
"}",
"return JSON.stringify({currentRev:meta._rev,conflicts:meta._conflicts});",
"})()",
].join(""),
env
);
}
async function readConflictFixture(cliBinary: string, env: NodeJS.ProcessEnv): Promise<ConflictFixture> {
return await evalObsidianJson<ConflictFixture>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const meta=await core.localDatabase.getDBEntryMeta(path,{conflicts:true,revs:true},true);",
"if(!meta?._rev){",
" throw new Error(`Could not read the conflict fixture: ${path}`);",
"}",
"const revisions=meta._revisions;",
"const currentParentRev=revisions?.ids?.length>1",
" ? `${revisions.start-1}-${revisions.ids[1]}`",
" : undefined;",
"return JSON.stringify({currentRev:meta._rev,currentParentRev,conflicts:meta._conflicts??[]});",
"})()",
].join(""),
env
);
}
async function waitForConflictCount(
cliBinary: string,
env: NodeJS.ProcessEnv,
expectedConflictCount: number
): Promise<ConflictFixture> {
const deadline = Date.now() + uiTimeoutMs;
let fixture = await readConflictFixture(cliBinary, env);
while (fixture.conflicts.length !== expectedConflictCount && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 100));
fixture = await readConflictFixture(cliBinary, env);
}
if (fixture.conflicts.length !== expectedConflictCount) {
throw new Error(
`Expected ${expectedConflictCount + 1} live version(s), but found ${fixture.conflicts.length + 1}: ${JSON.stringify(fixture)}`
);
}
return fixture;
}
async function requestConflictCheck(cliBinary: string, env: NodeJS.ProcessEnv, waitForCompletion = false) {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
`const waitForCompletion=${JSON.stringify(waitForCompletion)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"await core.services.conflict.queueCheckFor(path);",
"if(waitForCompletion){",
" await core.services.conflict.ensureAllProcessed();",
"}",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env
);
}
async function waitForConflictChecks(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"await core.services.conflict.ensureAllProcessed();",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env
);
}
async function applyReplicatedConflictResolution(
cliBinary: string,
env: NodeJS.ProcessEnv,
revisionToDelete: string,
expectedConflictCount = 0
): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
`const revisionToDelete=${JSON.stringify(revisionToDelete)};`,
`const expectedConflictCount=${JSON.stringify(expectedConflictCount)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"if(!(await core.fileHandler.deleteRevisionFromDB(path,revisionToDelete))){",
" throw new Error(`Could not apply the replicated conflict resolution: ${path} ${revisionToDelete}`);",
"}",
"const entry=await core.databaseFileAccess.fetchEntryMeta(path,undefined,true);",
"if(!entry){",
" throw new Error(`Could not read the surviving revision after replicated resolution: ${path}`);",
"}",
// This is the same Commonlib consumer boundary invoked after a remote
// document has already entered the local database. Calling it here
// isolates the dialogue policy from transport and second-device setup.
"await core.fileHandler._anyProcessReplicatedDoc(entry);",
"const conflicts=await core.databaseFileAccess.getConflictedRevs(path);",
"if(conflicts.length!==expectedConflictCount){",
" throw new Error(`Replicated resolution left an unexpected conflict count: ${path} ${JSON.stringify(conflicts)}`);",
"}",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env
);
}
function conflictDialogue(page: Parameters<Parameters<typeof withObsidianPage>[1]>[0]) {
return page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Conflicting changes" }),
});
}
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 cliBinary = cli.binary;
const vault = await createTemporaryVault("obsidian-livesync-conflict-dialog-");
let session: ObsidianLiveSyncSession | undefined;
try {
session = await startObsidianLiveSyncSession({
binary,
cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: {
doctorProcessedVersion: "1.0.0",
isConfigured: true,
liveSync: false,
remoteType: "",
couchDB_URI: "http://127.0.0.1:5984",
couchDB_DBNAME: "conflict-dialog-policy",
couchDB_USER: "",
couchDB_PASSWORD: "",
notifyThresholdOfRemoteStorageSize: -1,
periodicReplication: false,
syncAfterMerge: false,
syncOnEditorSave: false,
syncOnFileOpen: false,
syncOnSave: false,
syncOnStart: false,
disableMarkdownAutoMerge: true,
showMergeDialogOnlyOnActive: true,
showStatusOnEditor: true,
},
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
});
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
await createAndOpenBaseFile(cliBinary, session.cliEnv);
const base = await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, path);
const fixture = await createManualConflict(cliBinary, session.cliEnv, base.rev, [
leftContent,
rightContent,
thirdContent,
]);
if (fixture.conflicts.length !== 2) {
throw new Error(`Expected exactly three live leaves: ${JSON.stringify(fixture)}`);
}
await requestConflictCheck(cliBinary, session.cliEnv);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const modal = conflictDialogue(page);
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await page
.locator(".livesync-status-messagearea")
.filter({
hasText: "This file has 3 unresolved versions. They will be reviewed one pair at a time.",
})
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal.getByRole("button", { name: "Concat both", exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
const actionButtonBounds = await modal.locator(".conflict-action-button").evaluateAll((buttons) =>
buttons.map((button) => {
const bounds = button.getBoundingClientRect();
return { top: bounds.top, bottom: bounds.bottom };
})
);
if (
actionButtonBounds.length !== 4 ||
actionButtonBounds.some(
(bounds, index) => index > 0 && bounds.top < actionButtonBounds[index - 1].bottom
)
) {
throw new Error(
`Conflict action buttons are not stacked vertically: ${JSON.stringify(actionButtonBounds)}`
);
}
});
const firstDialogueScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"conflict-dialog-three-versions.png",
(page) => conflictDialogue(page).locator(".modal").first()
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const modal = conflictDialogue(page);
await modal.getByRole("button", { name: "Concat both", exact: true }).click({ timeout: uiTimeoutMs });
});
const remainingAfterConcatenation = await waitForConflictCount(cliBinary, session.cliEnv, 1);
if (
remainingAfterConcatenation.currentRev === fixture.currentRev ||
remainingAfterConcatenation.currentParentRev !== fixture.currentRev
) {
throw new Error(
`Concatenation did not extend the compared winner before retaining the remaining branch: ${JSON.stringify(
{
before: fixture,
after: remainingAfterConcatenation,
}
)}`
);
}
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const modal = conflictDialogue(page);
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
const warning = page.locator(".livesync-status-messagearea").filter({
hasText: "This file has unresolved conflicts.",
});
await warning.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal.getByRole("button", { name: "Not now", exact: true }).click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
const warningScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"conflict-dialog-postponed-warning.png",
(page) =>
page.locator(".livesync-status-messagearea").filter({
hasText: "This file has unresolved conflicts.",
})
);
await session.app.stop();
session = undefined;
session = await startObsidianLiveSyncSession({
binary,
cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
});
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
await createAndOpenBaseFile(cliBinary, session.cliEnv);
const remainingAfterRestart = await waitForConflictCount(cliBinary, session.cliEnv, 1);
await requestConflictCheck(cliBinary, session.cliEnv);
const restartedSession = session;
await withObsidianPage(restartedSession.remoteDebuggingPort, async (page) => {
const modal = conflictDialogue(page);
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await page
.locator(".livesync-status-messagearea")
.filter({ hasText: "This file has unresolved conflicts." })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await applyReplicatedConflictResolution(
cliBinary,
restartedSession.cliEnv,
remainingAfterRestart.conflicts[0]
);
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
await page
.locator(".livesync-status-messagearea")
.filter({ hasText: "This file has unresolved conflicts." })
.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
// End the replicated-resolution episode before creating another
// conflict at the same path. This prevents a late cancellation event
// from the first episode from closing the later episode's dialogue.
await session.app.stop();
session = undefined;
session = await startObsidianLiveSyncSession({
binary,
cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
});
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
await createAndOpenBaseFile(cliBinary, session.cliEnv);
const resolved = await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, path);
const laterFixture = await createManualConflict(cliBinary, session.cliEnv, resolved.rev, [
leftContent,
rightContent,
]);
if (laterFixture.conflicts.length !== 1) {
throw new Error(`Expected a later conflict with exactly two live leaves: ${JSON.stringify(laterFixture)}`);
}
await requestConflictCheck(cliBinary, session.cliEnv);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const modal = conflictDialogue(page);
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal.getByRole("button", { name: "Not now", exact: true }).click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
await waitForConflictChecks(cliBinary, session.cliEnv);
await requestConflictCheck(cliBinary, session.cliEnv, true);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.waitForTimeout(1500);
if (await conflictDialogue(page).isVisible()) {
throw new Error("The postponed conflict dialogue reopened during an ordinary conflict check.");
}
});
await waitForConflictCount(cliBinary, session.cliEnv, 1);
const laterCommandExecuted = await withObsidianPage(session.remoteDebuggingPort, async (page) => {
return await page.evaluate(
(commandId) => (globalThis as ObsidianTestGlobal).app?.commands?.executeCommandById(commandId) === true,
"obsidian-livesync:livesync-checkdoc-conflicted"
);
});
if (!laterCommandExecuted) {
throw new Error("The explicit conflict-resolution command was not registered for the active editor.");
}
const laterActiveSession = session;
await withObsidianPage(laterActiveSession.remoteDebuggingPort, async (page) => {
const modal = conflictDialogue(page);
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await applyReplicatedConflictResolution(cliBinary, laterActiveSession.cliEnv, laterFixture.conflicts[0]);
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
await page
.locator(".livesync-status-messagearea")
.filter({ hasText: "This file has unresolved conflicts." })
.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
console.log(
"Real Obsidian reviewed three versions pairwise, retained the completed stage across restart, suppressed an ordinary repeat prompt after Not now, reopened the dialogue after the explicit command, and cleared both postponed and open-dialogue states after replicated resolutions."
);
console.log(`Dialogue screenshot: ${firstDialogueScreenshot}`);
console.log(`Postponed warning screenshot: ${warningScreenshot}`);
} 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);
});
@@ -0,0 +1,369 @@
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);
});
+144 -2
View File
@@ -5,17 +5,35 @@ import {
deleteCouchDbDatabase,
loadCouchDbConfig,
makeUniqueDatabaseName,
putCouchDbDocument,
waitForCouchDbDocs,
} from "../runner/couchdb.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertEqual,
assertE2eCompatibilityMarker,
assertE2eCompatibilityReviewPending,
configureCouchDb,
createE2eCouchDbPluginData,
prepareRemote,
pushLocalChanges,
resumeCompatibilityReview,
waitForLiveSyncCoreReady,
type LocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
import {
REMOTE_ACTIVITY_EXPECTED_STATE,
captureRemoteActivityDiagnostics,
waitForRemoteActivityState,
} from "../runner/remoteActivity.ts";
import {
cleanUpHeldRemoteActivity,
clearHeldRemoteActivity,
finishHeldRemoteActivity,
startHeldChunkFetch,
startHeldOneShotReplication,
startHeldTrackedRequest,
waitForRestoredChunk,
} from "../runner/remoteActivityWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { createTemporaryVault } from "../runner/vault.ts";
@@ -72,6 +90,7 @@ async function main(): Promise<void> {
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "obsidian-upload");
const vault = await createTemporaryVault();
let session: ObsidianLiveSyncSession | undefined;
let activityStage = "session-startup";
try {
await assertCouchDbReachable(couchDb);
@@ -86,8 +105,20 @@ async function main(): Promise<void> {
cliBinary: cli.binary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: createE2eCouchDbPluginData({
uri: couchDb.uri,
username: couchDb.username,
password: couchDb.password,
dbName,
}),
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
await assertE2eCompatibilityReviewPending(cli.binary, session.cliEnv);
await resumeCompatibilityReview(session.remoteDebuggingPort, {
verifyMissingDeviceMarkerExplanation: true,
screenshotPrefix: "compatibility-review-copied-vault",
});
await assertE2eCompatibilityMarker(cli.binary, session.cliEnv);
const configured = await configureCouchDb(cli.binary, session.cliEnv, {
uri: couchDb.uri,
@@ -104,8 +135,50 @@ async function main(): Promise<void> {
assertEqual(configured.syncOnSave, false, "Sync on save should remain disabled during this workflow.");
await prepareRemote(cli.binary, session.cliEnv);
activityStage = "initial-idle";
const initialIdle = await waitForRemoteActivityState(
session.remoteDebuggingPort,
REMOTE_ACTIVITY_EXPECTED_STATE.idle
);
const localEntry = await createNoteAndWaitForLocalDb(cli.binary, session.cliEnv);
await pushLocalChanges(cli.binary, session.cliEnv);
activityStage = REMOTE_ACTIVITY_EXPECTED_STATE.trackedRequestActive;
await startHeldTrackedRequest(cli.binary, session.cliEnv);
const trackedRequestActive = await waitForRemoteActivityState(
session.remoteDebuggingPort,
REMOTE_ACTIVITY_EXPECTED_STATE.trackedRequestActive
);
const trackedRequestResult = await finishHeldRemoteActivity(cli.binary, session.cliEnv);
assertEqual(trackedRequestResult.error, undefined, "The observed CouchDB request failed.");
assertEqual(trackedRequestResult.result, true, "The observed CouchDB request did not report success.");
activityStage = "tracked-request-idle";
const trackedRequestIdle = await waitForRemoteActivityState(
session.remoteDebuggingPort,
REMOTE_ACTIVITY_EXPECTED_STATE.idle
);
if (trackedRequestIdle.requestCount <= initialIdle.requestCount) {
throw new Error("The held CouchDB request did not advance the tracked remote-request count.");
}
await clearHeldRemoteActivity(cli.binary, session.cliEnv);
activityStage = "one-shot-active";
await startHeldOneShotReplication(cli.binary, session.cliEnv);
const oneShotActive = await waitForRemoteActivityState(
session.remoteDebuggingPort,
REMOTE_ACTIVITY_EXPECTED_STATE.finiteReplicationActive
);
const oneShotResult = await finishHeldRemoteActivity(cli.binary, session.cliEnv);
assertEqual(oneShotResult.error, undefined, "One-shot replication failed while its activity was observed.");
assertEqual(oneShotResult.result, true, "One-shot replication did not report success.");
activityStage = "one-shot-idle";
const oneShotIdle = await waitForRemoteActivityState(
session.remoteDebuggingPort,
REMOTE_ACTIVITY_EXPECTED_STATE.idle
);
if (oneShotIdle.requestCount <= trackedRequestIdle.requestCount) {
throw new Error("One-shot replication did not make an observed remote request.");
}
await clearHeldRemoteActivity(cli.binary, session.cliEnv);
const remoteDocs = await waitForCouchDbDocs(couchDb, dbName, (docs) => {
const ids = new Set(docs.map((doc) => doc._id));
@@ -118,11 +191,80 @@ async function main(): Promise<void> {
"Remote metadata path did not match the local database entry."
);
const sourceChunkId = localEntry.children[0];
if (!sourceChunkId) throw new Error("The uploaded note did not produce a chunk for the fetch workflow.");
const sourceChunk = remoteDocs.find((document) => document._id === sourceChunkId);
if (!sourceChunk || sourceChunk.type !== "leaf") {
throw new Error(`The uploaded source chunk was not found in CouchDB: ${sourceChunkId}`);
}
const { _rev: _sourceRevision, ...remoteOnlyChunk } = sourceChunk;
const chunkId = `h:e2e-remote-activity-${Date.now().toString(36)}`;
await putCouchDbDocument(couchDb, dbName, { ...remoteOnlyChunk, _id: chunkId });
activityStage = REMOTE_ACTIVITY_EXPECTED_STATE.chunkFetchActive;
await startHeldChunkFetch(cli.binary, session.cliEnv, chunkId);
const chunkFetchActive = await waitForRemoteActivityState(
session.remoteDebuggingPort,
REMOTE_ACTIVITY_EXPECTED_STATE.chunkFetchActive
);
const chunkFetchResult = await finishHeldRemoteActivity(cli.binary, session.cliEnv);
assertEqual(
chunkFetchResult.error,
undefined,
"On-demand chunk fetching failed while its activity was observed."
);
if (!chunkFetchResult.requestedIds?.includes(chunkId)) {
throw new Error(`The on-demand chunk request did not include the selected chunk: ${chunkId}`);
}
if ((chunkFetchResult.resultCount ?? 0) < 1) {
throw new Error(`The remote did not return the selected chunk: ${chunkId}`);
}
const restoredChunk = await waitForRestoredChunk(cli.binary, session.cliEnv, chunkId);
assertEqual(restoredChunk.id, chunkId, "The restored chunk ID did not match the requested chunk.");
activityStage = "chunk-fetch-idle";
const chunkFetchIdle = await waitForRemoteActivityState(
session.remoteDebuggingPort,
REMOTE_ACTIVITY_EXPECTED_STATE.idle
);
if (chunkFetchIdle.requestCount <= oneShotIdle.requestCount) {
throw new Error("On-demand chunk fetching did not make an observed remote request.");
}
await clearHeldRemoteActivity(cli.binary, session.cliEnv);
console.log(
`Uploaded metadata ${localEntry.id} and ${localEntry.children.length} chunk(s) to CouchDB database ${dbName}`
);
console.log(
[
`Tracked request: ${trackedRequestActive.statusBarText.trim()} -> idle`,
`One-shot activity: ${oneShotActive.statusBarText.trim()} -> idle`,
`Chunk-fetch activity: ${chunkFetchActive.statusBarText.trim()} -> idle`,
`Balanced remote requests: ${chunkFetchIdle.requestCount}/${chunkFetchIdle.responseCount}`,
].join("\n")
);
} catch (error) {
if (session) {
const diagnostics = await captureRemoteActivityDiagnostics(
session.remoteDebuggingPort,
`couchdb-upload-${activityStage}`
).catch((diagnosticError: unknown) => {
console.warn(
`Could not capture remote activity diagnostics: ${
diagnosticError instanceof Error ? diagnosticError.message : String(diagnosticError)
}`
);
return undefined;
});
if (diagnostics) {
console.error(`Remote activity screenshot: ${diagnostics.screenshotPath}`);
console.error(`Remote activity snapshot: ${diagnostics.snapshotPath}`);
}
}
throw error;
} finally {
if (session) {
await cleanUpHeldRemoteActivity(cli.binary, session.cliEnv).catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
await session.app.stop();
}
await vault.dispose();
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,11 @@
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import {
assertLocatorHasMinimumTouchTarget,
assertLocatorWithinSafeArea,
assertLocatorWithinViewport,
assertNoHorizontalOverflow,
} from "@vrtmrz/obsidian-test-session";
import { evalObsidianJson } from "../runner/cli.ts";
import {
assertCouchDbReachable,
@@ -13,7 +19,10 @@ import {
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertEqual,
assertE2eCompatibilityMarker,
configureCouchDb,
createE2eCouchDbPluginData,
createE2eObsidianDeviceLocalState,
prepareRemote,
pushLocalChanges,
waitForLiveSyncCoreReady,
@@ -21,7 +30,14 @@ import {
type LocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { clickJsonResolveOption, obsidianRemoteDebuggingPort } from "../runner/ui.ts";
import {
captureObsidianPage,
captureJsonResolveDialogue,
clickJsonResolveOption,
obsidianRemoteDebuggingPort,
withObsidianPage,
} from "../runner/ui.ts";
import { iPhoneSafeArea, setObsidianMobileTestMode } from "../runner/mobileUi.ts";
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
@@ -43,6 +59,7 @@ const mergeJsonPath = ".obsidian/livesync-e2e-merge.json";
const manualMergeJsonPath = ".obsidian/livesync-e2e-manual-merge.json";
const targetPath = ".obsidian/livesync-targeted/only-a.json";
const hiddenFileCliTimeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_CLI_TIMEOUT_MS ?? 90000);
const hiddenFileInitialisationStateKey = "__livesyncE2EHiddenFileInitialisation";
type RunnerContext = {
binary: string;
@@ -300,31 +317,33 @@ async function startConfiguredSession(
vault: TemporaryVault,
overrides: Record<string, unknown> = {}
): Promise<ObsidianLiveSyncSession> {
const couchDbSettings = {
uri: context.couchDb.uri,
username: context.couchDb.username,
password: context.couchDb.password,
dbName: context.dbName,
};
const hiddenFileSettings = {
syncInternalFiles: true,
syncInternalFilesBeforeReplication: true,
watchInternalFileChanges: false,
syncInternalFilesTargetPatterns: "",
...overrides,
};
const session = await startObsidianLiveSyncSession({
binary: context.binary,
cliBinary: context.cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
// A fresh Vault waits for onboarding before opening its local database.
// Seed the same isolated settings used by configureCouchDb so that the
// application lifecycle can become ready without a user interaction.
pluginData: createE2eCouchDbPluginData(couchDbSettings, hiddenFileSettings),
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
});
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await configureCouchDb(
context.cliBinary,
session.cliEnv,
{
uri: context.couchDb.uri,
username: context.couchDb.username,
password: context.couchDb.password,
dbName: context.dbName,
},
{
syncInternalFiles: true,
syncInternalFilesBeforeReplication: true,
watchInternalFileChanges: false,
syncInternalFilesTargetPatterns: "",
...overrides,
}
);
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await assertE2eCompatibilityMarker(context.cliBinary, session.cliEnv);
await configureCouchDb(context.cliBinary, session.cliEnv, couchDbSettings, hiddenFileSettings);
await prepareRemote(context.cliBinary, session.cliEnv);
return session;
}
@@ -431,6 +450,7 @@ async function runJsonManualConflictResolution(context: RunnerContext, vault: Te
const session = await startConfiguredSession(context, vault);
await createHiddenJsonConflict(context, session, vault, manualMergeJsonPath, base, left, right);
await openHiddenJsonResolveModal(context.cliBinary, session.cliEnv, manualMergeJsonPath);
const screenshotPath = await captureJsonResolveDialogue(obsidianRemoteDebuggingPort());
await clickJsonResolveOption(obsidianRemoteDebuggingPort(), "AB");
const merged = await waitForPathContent(vault.path, manualMergeJsonPath, (content) =>
@@ -442,7 +462,7 @@ async function runJsonManualConflictResolution(context: RunnerContext, vault: Te
assertEqual(parsed.shared, "right", "Manual JSON conflict resolution did not apply the selected merged result.");
assertEqual(parsed.fromA, true, "Manual JSON conflict resolution lost the first-side value.");
assertEqual(parsed.fromB, true, "Manual JSON conflict resolution lost the second-side value.");
console.log("Hidden JSON conflict modal applied the selected merged result.");
console.log(`Hidden JSON conflict modal applied the selected merged result. Screenshot: ${screenshotPath}`);
}
async function runTargetMismatch(
@@ -489,6 +509,342 @@ async function runTargetMismatch(
console.log("Hidden target mismatch respected per-device target patterns, then applied after enabling the target.");
}
async function setHiddenFileNoticeFixtures(port: number, itemIds: string[], includeRestart: boolean): Promise<void> {
await withObsidianPage(port, async (page) => {
await page.evaluate(
({ nextItemIds, nextIncludeRestart }) => {
const obsidianApp = (globalThis as typeof globalThis & { app: any }).app;
const plugin = obsidianApp.plugins.plugins["obsidian-livesync"];
const core = plugin.core;
const addOn = core.getAddOn("HiddenFileSync");
for (const id of ["alpha", "beta", "gamma"]) {
const pluginId = `livesync-e2e-${id}`;
obsidianApp.plugins.manifests[pluginId] = {
id: pluginId,
name: `E2E ${id[0]?.toUpperCase()}${id.slice(1)}`,
version: "1.0.0",
minAppVersion: "1.0.0",
description: "E2E fixture",
author: "Self-hosted LiveSync",
isDesktopOnly: false,
dir: `.obsidian/plugins/${pluginId}`,
};
obsidianApp.plugins.enabledPlugins.add(pluginId);
}
addOn.queuedNotificationFiles.clear();
for (const id of nextItemIds) {
addOn.queuedNotificationFiles.add(`.obsidian/plugins/livesync-e2e-${id}`);
}
if (nextIncludeRestart) {
addOn.queuedNotificationFiles.add(core.services.API.getSystemConfigDir());
}
addOn.notifyConfigChange();
},
{ nextItemIds: itemIds, nextIncludeRestart: includeRestart }
);
});
}
async function clearHiddenFileNoticeFixtures(port: number): Promise<void> {
await withObsidianPage(port, async (page) => {
await page.evaluate(() => {
const obsidianApp = (globalThis as typeof globalThis & { app: any }).app;
const plugin = obsidianApp.plugins.plugins["obsidian-livesync"];
plugin.core.services.context.noticeGroups.hide("hidden-file-changes");
for (const id of ["alpha", "beta", "gamma"]) {
const pluginId = `livesync-e2e-${id}`;
obsidianApp.plugins.enabledPlugins.delete(pluginId);
delete obsidianApp.plugins.manifests[pluginId];
}
});
});
}
async function runInitialisationNoticeGrouping(context: RunnerContext, vault: TemporaryVault): Promise<void> {
const session = await startConfiguredSession(context, vault, {
syncInternalFiles: false,
syncInternalFilesBeforeReplication: false,
});
const port = session.remoteDebuggingPort;
const timeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_NOTICE_TIMEOUT_MS ?? 10_000);
try {
await withObsidianPage(port, async (page) => {
const deadline = Date.now() + timeoutMs;
while ((await page.locator(".notice:visible").count()) > 0 && Date.now() < deadline) {
await page.locator(".notice:visible").first().click({
force: true,
position: { x: 2, y: 2 },
timeout: timeoutMs,
});
}
assertEqual(
await page.locator(".notice:visible").count(),
0,
"Transient start-up Notices remained before the Hidden File Sync initialisation check."
);
});
await withObsidianPage(port, async (page) => {
await page.evaluate((stateKey) => {
const obsidianApp = (globalThis as typeof globalThis & { app: any }).app;
const plugin = obsidianApp.plugins.plugins["obsidian-livesync"];
const core = plugin.core;
const addOn = core.getAddOn("HiddenFileSync");
const setting = core.services.setting;
const originalApplyPartial = setting.applyPartial;
const originalRebuildMerging = addOn.rebuildMerging;
const state = {
done: false,
reachedPreparation: false,
reachedInitialisation: false,
maxVisibleProgressNotices: 0,
visibleProgressNoticeTexts: [] as string[],
sawStandaloneGatheringNotice: false,
sawStandaloneRestartNotice: false,
releasePreparation: undefined as (() => void) | undefined,
releaseInitialisation: undefined as (() => void) | undefined,
error: undefined as string | undefined,
};
(globalThis as unknown as Record<string, typeof state>)[stateKey] = state;
const observer = new MutationObserver(() => {
const notices = Array.from(document.querySelectorAll<HTMLElement>(".notice"));
const progressNotices = notices.filter((notice) => notice.textContent?.includes("[⚙"));
state.sawStandaloneGatheringNotice ||= notices.some((notice) =>
notice.textContent?.includes("Gathering files for enabling Hidden File Sync")
);
state.sawStandaloneRestartNotice ||= notices.some((notice) =>
notice.textContent?.includes("Done! Restarting the app is strongly recommended!")
);
if (progressNotices.length > state.maxVisibleProgressNotices) {
state.maxVisibleProgressNotices = progressNotices.length;
state.visibleProgressNoticeTexts = progressNotices.map(
(notice) => notice.textContent?.trim() ?? ""
);
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true,
});
setting.applyPartial = async (...args: unknown[]) => {
const update = args[0] as { syncInternalFiles?: unknown } | undefined;
if (update?.syncInternalFiles === true && !state.reachedPreparation) {
state.reachedPreparation = true;
await new Promise<void>((resolve) => {
state.releasePreparation = resolve;
});
}
return await originalApplyPartial.apply(setting, args);
};
addOn.rebuildMerging = async (...args: unknown[]) => {
state.reachedInitialisation = true;
await new Promise<void>((resolve) => {
state.releaseInitialisation = resolve;
});
return await originalRebuildMerging.apply(addOn, args);
};
void core.services.setting
.enableOptionalFeature("MERGE")
.then(
() => {
state.done = true;
},
(error: unknown) => {
state.error = error instanceof Error ? error.message : String(error);
state.done = true;
}
)
.finally(() => {
setting.applyPartial = originalApplyPartial;
addOn.rebuildMerging = originalRebuildMerging;
const notices = Array.from(document.querySelectorAll<HTMLElement>(".notice"));
const progressNotices = notices.filter((notice) => notice.textContent?.includes("[⚙"));
state.sawStandaloneGatheringNotice ||= notices.some((notice) =>
notice.textContent?.includes("Gathering files for enabling Hidden File Sync")
);
state.sawStandaloneRestartNotice ||= notices.some((notice) =>
notice.textContent?.includes("Done! Restarting the app is strongly recommended!")
);
if (progressNotices.length > state.maxVisibleProgressNotices) {
state.maxVisibleProgressNotices = progressNotices.length;
state.visibleProgressNoticeTexts = progressNotices.map(
(notice) => notice.textContent?.trim() ?? ""
);
}
observer.disconnect();
});
}, hiddenFileInitialisationStateKey);
});
const screenshotPath = await captureObsidianPage(
port,
"hidden-file-initial-scan-progress.png",
async (page) => {
await page.waitForFunction(
(stateKey) =>
(globalThis as unknown as Record<string, { reachedPreparation?: boolean } | undefined>)[
stateKey
]?.reachedPreparation === true,
hiddenFileInitialisationStateKey,
{ timeout: timeoutMs }
);
const progressNotices = page.locator(".notice").filter({ hasText: "[⚙" });
await progressNotices.first().waitFor({ state: "visible", timeout: timeoutMs });
assertEqual(
await progressNotices.count(),
1,
"Hidden File Sync showed more than one progress Notice before saving its enabled setting."
);
await progressNotices
.filter({ hasText: "Preparing Hidden File Sync..." })
.waitFor({ state: "visible", timeout: timeoutMs });
}
);
const result = await withObsidianPage(port, async (page) => {
await page.evaluate((stateKey) => {
const state = (globalThis as unknown as Record<
string,
{ releasePreparation?: () => void } | undefined
>)[stateKey];
state?.releasePreparation?.();
}, hiddenFileInitialisationStateKey);
await page.waitForFunction(
(stateKey) =>
(globalThis as unknown as Record<string, { reachedInitialisation?: boolean } | undefined>)[
stateKey
]?.reachedInitialisation === true,
hiddenFileInitialisationStateKey,
{ timeout: timeoutMs }
);
const progressNotices = page.locator(".notice").filter({ hasText: "[⚙" });
assertEqual(
await progressNotices.count(),
1,
"Hidden File Sync replaced its parent progress Notice when the first child phase started."
);
await page.evaluate((stateKey) => {
const state = (
globalThis as unknown as Record<string, { releaseInitialisation?: () => void } | undefined>
)[stateKey];
state?.releaseInitialisation?.();
}, hiddenFileInitialisationStateKey);
await page.waitForFunction(
(stateKey) =>
(globalThis as unknown as Record<string, { done?: boolean } | undefined>)[stateKey]?.done === true,
hiddenFileInitialisationStateKey,
{ timeout: hiddenFileCliTimeoutMs }
);
return await page.evaluate(
(stateKey) =>
(
globalThis as unknown as Record<
string,
{
error?: string;
maxVisibleProgressNotices: number;
visibleProgressNoticeTexts: string[];
sawStandaloneGatheringNotice: boolean;
sawStandaloneRestartNotice: boolean;
}
>
)[stateKey],
hiddenFileInitialisationStateKey
);
});
if (result.error) {
throw new Error(`Hidden File Sync initialisation failed: ${result.error}`);
}
assertEqual(
result.maxVisibleProgressNotices,
1,
`Hidden File Sync split initialisation across multiple progress Notices: ${JSON.stringify(
result.visibleProgressNoticeTexts
)}`
);
assertEqual(
result.sawStandaloneGatheringNotice,
false,
"Hidden File Sync showed the old standalone gathering Notice."
);
assertEqual(
result.sawStandaloneRestartNotice,
false,
"Hidden File Sync showed the old standalone restart recommendation."
);
console.log(
`Hidden File Sync showed one progress Notice before settings were saved and retained it throughout initialisation. Screenshot: ${screenshotPath}`
);
} finally {
await session.app.stop();
}
}
async function runConfigurationNoticeGrouping(context: RunnerContext, vault: TemporaryVault): Promise<void> {
const session = await startConfiguredSession(context, vault);
const port = session.remoteDebuggingPort;
const timeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_NOTICE_TIMEOUT_MS ?? 10_000);
try {
await setObsidianMobileTestMode(port, true, timeoutMs);
await setHiddenFileNoticeFixtures(port, ["alpha", "beta"], true);
await withObsidianPage(port, async (page) => {
const visibleGroups = page.locator(".notice:has(.vpk-keyed-notice-group):visible");
await visibleGroups.first().waitFor({ state: "visible", timeout: timeoutMs });
assertEqual(await visibleGroups.count(), 1, "Hidden File Sync created more than one visible Notice group.");
const notice = visibleGroups.first();
const rows = notice.locator(".vpk-keyed-notice-group__item");
assertEqual(await rows.count(), 3, "Hidden File Sync did not group every configuration-change action.");
await notice.getByText("Files in E2E Alpha were updated.", { exact: true }).waitFor();
await notice.getByText("Files in E2E Beta were updated.", { exact: true }).waitFor();
await notice.getByText("Other Obsidian settings files were updated.", { exact: true }).waitFor();
await assertLocatorWithinViewport(page, notice, { label: "Hidden File Sync notification group" });
await assertNoHorizontalOverflow(page, notice, { label: "Hidden File Sync notification group" });
await assertLocatorWithinSafeArea(page, notice, {
label: "Hidden File Sync notification group",
safeAreaInsets: iPhoneSafeArea,
});
const buttons = notice.getByRole("button");
for (let index = 0; index < (await buttons.count()); index += 1) {
await assertLocatorHasMinimumTouchTarget(page, buttons.nth(index), {
label: `Hidden File Sync notification action ${index + 1}`,
});
}
const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
const screenshotPath = join(outputDirectory, "hidden-file-notice-group-mobile.png");
await mkdir(dirname(screenshotPath), { recursive: true });
await page.screenshot({ path: screenshotPath, fullPage: true, animations: "disabled" });
await rows.first().getByText("Files in E2E Alpha were updated.", { exact: true }).click();
await notice.waitFor({ state: "hidden", timeout: timeoutMs });
});
await setHiddenFileNoticeFixtures(port, ["gamma"], false);
await withObsidianPage(port, async (page) => {
const notice = page.locator(".notice:has(.vpk-keyed-notice-group):visible").first();
await notice.waitFor({ state: "visible", timeout: timeoutMs });
const rows = notice.locator(".vpk-keyed-notice-group__item");
assertEqual(await rows.count(), 1, "A dismissed Hidden File Sync Notice repeated acknowledged rows.");
await rows.getByText("Files in E2E Gamma were updated.", { exact: true }).waitFor();
});
console.log(
"Hidden File Sync grouped configuration notifications passed the mobile regression for issue #555."
);
} finally {
await clearHiddenFileNoticeFixtures(port).catch(() => undefined);
await setObsidianMobileTestMode(port, false, timeoutMs).catch(() => undefined);
await session.app.stop();
}
}
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
@@ -516,6 +872,8 @@ async function main(): Promise<void> {
await runJsonConflictRoundTrip(context, vaultA, vaultB);
await runJsonManualConflictResolution(context, vaultB);
await runTargetMismatch(context, vaultA, vaultB);
await runInitialisationNoticeGrouping(context, vaultB);
await runConfigurationNoticeGrouping(context, vaultB);
} finally {
await vaultA.dispose();
await vaultB.dispose();
+42
View File
@@ -8,12 +8,35 @@ type Step = {
const testSteps: Step[] = [
{ name: "build", args: ["run", "build"] },
...(process.env.LIVESYNC_CLI_COMMAND === undefined
? [{ name: "CLI build", args: ["run", "build", "-w", "self-hosted-livesync-cli"] }]
: []),
{ name: "discover", args: ["run", "test:e2e:obsidian:discover"] },
{ name: "smoke", args: ["run", "test:e2e:obsidian:smoke"] },
{ name: "onboarding invitation", args: ["run", "test:e2e:obsidian:onboarding-invitation"] },
{ 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: "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"] },
{ name: "CouchDB upload", args: ["run", "test:e2e:obsidian:couchdb-upload"] },
{
name: "manual CouchDB setup workflow",
args: ["run", "test:e2e:obsidian:couchdb-manual-setup-workflow"],
},
{
name: "CLI to real Obsidian synchronisation",
args: ["run", "test:e2e:obsidian:cli-to-obsidian-sync"],
},
{ name: "Object Storage upload", args: ["run", "test:e2e:obsidian:minio-upload"] },
{
name: "Object Storage Setup URI workflow",
args: ["run", "test:e2e:obsidian:object-storage-setup-uri-workflow"],
},
{ name: "P2P Setup URI workflow", args: ["run", "test:e2e:obsidian:p2p-setup-uri-workflow"] },
{ name: "startup scan", args: ["run", "test:e2e:obsidian:startup-scan"] },
{ name: "provisioned Setup URI workflow", args: ["run", "test:e2e:obsidian:setup-uri-workflow"] },
{ name: "two-vault synchronisation", args: ["run", "test:e2e:obsidian:two-vault-sync"] },
{ name: "hidden file snippet synchronisation", args: ["run", "test:e2e:obsidian:hidden-file-snippet-sync"] },
{ name: "Customisation Sync", args: ["run", "test:e2e:obsidian:customisation-sync"] },
@@ -22,9 +45,11 @@ 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 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");
function npmBinary(): string {
return process.platform === "win32" ? "npm.cmd" : "npm";
@@ -71,9 +96,18 @@ async function stopManagedMinio(): Promise<void> {
});
}
async function stopManagedP2P(): Promise<void> {
await runStep({
name: "stop P2P relay fixture",
args: ["run", "test:docker-p2p:stop"],
optional: true,
});
}
async function main(): Promise<void> {
let shouldStopCouchDb = false;
let shouldStopMinio = false;
let shouldStopP2P = false;
try {
if (manageCouchDb) {
await stopManagedCouchDb();
@@ -85,11 +119,19 @@ async function main(): Promise<void> {
await runStep({ name: "start MinIO fixture", args: ["run", "test:docker-s3:start"] });
shouldStopMinio = !keepMinio;
}
if (manageP2P) {
await stopManagedP2P();
await runStep({ name: "start P2P relay fixture", args: ["run", "test:docker-p2p:start"] });
shouldStopP2P = !keepP2P;
}
for (const step of testSteps) {
await runStep(step);
}
} finally {
if (shouldStopP2P) {
await stopManagedP2P();
}
if (shouldStopMinio) {
await stopManagedMinio();
}
+42 -1
View File
@@ -1,8 +1,27 @@
/**
* Verifies one complete Object Storage upload from a real Obsidian Vault,
* through LiveSync's local database and Journal Sync, to an S3-compatible
* service observed independently through the AWS SDK.
*
* The isolated Vault starts with Object Storage settings and the device-local
* compatibility acknowledgement already in place. Unconfigured start-up is
* intentionally inert and belongs to the onboarding scenario; compatibility
* review and visible setup have their own dedicated workflows. Supplying those
* prerequisites here keeps this scenario focused on the upload boundary.
*
* Note creation, local-database observation, one-shot synchronisation, request
* accounting, remote-object inspection, and prefix cleanup remain in one
* scenario so that a pass proves the same payload crossed every boundary.
* Separate successes would not prove that those observations belonged to the
* same upload.
*/
import { evalObsidianJson } from "../runner/cli.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertEqual,
configureObjectStorage,
createE2eObjectStoragePluginData,
createE2eObsidianDeviceLocalState,
prepareRemote,
pushLocalChanges,
waitForLiveSyncCoreReady,
@@ -17,6 +36,7 @@ import {
} from "../runner/objectStorage.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { createTemporaryVault } from "../runner/vault.ts";
import { REMOTE_ACTIVITY_EXPECTED_STATE, waitForRemoteActivityState } from "../runner/remoteActivity.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
@@ -99,6 +119,11 @@ async function main(): Promise<void> {
cliBinary: cli.binary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: createE2eObjectStoragePluginData({
...objectStorage,
bucketPrefix,
}),
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
@@ -115,13 +140,29 @@ async function main(): Promise<void> {
assertEqual(configured.liveSync, false, "LiveSync should remain disabled during this one-shot workflow.");
await prepareRemote(cli.binary, session.cliEnv);
const activityBeforeUpload = await waitForRemoteActivityState(
session.remoteDebuggingPort,
REMOTE_ACTIVITY_EXPECTED_STATE.idle
);
const localEntry = await createNoteAndWaitForLocalDb(cli.binary, session.cliEnv);
await pushLocalChanges(cli.binary, session.cliEnv);
const activityAfterUpload = await waitForRemoteActivityState(
session.remoteDebuggingPort,
REMOTE_ACTIVITY_EXPECTED_STATE.idle
);
if (activityAfterUpload.requestCount <= activityBeforeUpload.requestCount) {
throw new Error("Object Storage synchronisation did not advance the tracked remote-request count.");
}
assertEqual(
activityAfterUpload.responseCount,
activityAfterUpload.requestCount,
"Object Storage remote-request counters did not rebalance after synchronisation."
);
const keys = await waitForObjectStorageObjects(bucketPrefix);
console.log(
`Uploaded ${localEntry.path} through Journal Sync to ${objectStorage.bucket}/${bucketPrefix} (${keys.length} object(s))`
`Uploaded ${localEntry.path} through Journal Sync to ${objectStorage.bucket}/${bucketPrefix} (${keys.length} object(s)); tracked requests: ${activityAfterUpload.requestCount - activityBeforeUpload.requestCount}`
);
} finally {
if (session) {
@@ -0,0 +1,324 @@
import { execFile } from "node:child_process";
import { randomBytes } from "node:crypto";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { evalObsidianJson } from "../runner/cli.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertEqual,
pushLocalChanges,
waitForLiveSyncCoreReady,
waitForLocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
import {
deleteObjectStoragePrefix,
ensureObjectStorageBucket,
listObjectStorageObjects,
loadObjectStorageConfig,
makeUniqueBucketPrefix,
type ObjectStorageConfig,
} from "../runner/objectStorage.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import {
acknowledgeDisabledOptionalFeatures,
captureAndStartInitialisation,
confirmFastFetch,
confirmRebuild,
enterSetupURI,
finishInitialisation,
generateSetupURIFromDevice,
resumeCompatibilityReviewIfShown,
skipMissingRemoteConfiguration,
type SetupArtifact,
type SetupCaptureNames,
} from "../runner/setupUri.ts";
import {
captureObsidianElement,
captureObsidianPage,
obsidianRemoteDebuggingPort,
withObsidianPage,
} from "../runner/ui.ts";
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
const execFileAsync = promisify(execFile);
const captures: SetupCaptureNames = { scenario: "object-storage-setup-uri", guide: "object-storage-setup" };
const noteFromFirst = "E2E/object-storage/from-first.md";
const noteFromSecond = "E2E/object-storage/from-second.md";
const firstContent =
"# Object Storage from the first device\n\nThis note travelled through the first device's Setup URI.\n";
const secondContent = "# Object Storage from the second device\n\nThis note completed the return journey.\n";
type RunnerContext = {
binary: string;
cliBinary: string;
activeSessions: Set<ObsidianLiveSyncSession>;
};
function sessionEnvironment(port: number): NodeJS.ProcessEnv {
return { ...process.env, E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: String(port) };
}
function sessionPorts(): readonly [number, number] {
const first = obsidianRemoteDebuggingPort(process.env);
const second = Number(process.env.E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT ?? first + 1);
if (!Number.isInteger(second) || second < 1 || second > 65535 || second === first) {
throw new Error(`Invalid secondary Obsidian remote debugging port: ${second}`);
}
return [first, second];
}
async function runDeno(script: string, environment: NodeJS.ProcessEnv): Promise<string> {
const { stdout } = await execFileAsync(
"deno",
[
"run",
"--minimum-dependency-age=0",
"--config=utils/flyio/deno.jsonc",
"--frozen",
"--lock=utils/flyio/deno.lock",
"--allow-env",
script,
],
{ cwd: process.cwd(), env: environment, maxBuffer: 4 * 1024 * 1024 }
);
return stdout;
}
async function generateBootstrapSetupURI(
objectStorage: ObjectStorageConfig,
bucketPrefix: string
): Promise<SetupArtifact> {
const setupPassphrase = randomBytes(24).toString("base64url");
const output = await runDeno("utils/setup/generate_setup_uri.ts", {
...process.env,
remote_type: "s3",
endpoint: objectStorage.endpoint,
access_key: objectStorage.accessKey,
secret_key: objectStorage.secretKey,
bucket: objectStorage.bucket,
region: objectStorage.region,
force_path_style: String(objectStorage.forcePathStyle),
bucket_prefix: bucketPrefix,
passphrase: randomBytes(24).toString("base64url"),
uri_passphrase: setupPassphrase,
});
const setupURI = output.split(/\r?\n/u).find((line) => line.startsWith("obsidian://setuplivesync?settings="));
if (!setupURI) throw new Error("The public Setup URI generator did not emit an Object Storage Setup URI.");
return { setupURI, setupPassphrase };
}
async function startSession(
context: RunnerContext,
vault: TemporaryVault,
port: number
): Promise<ObsidianLiveSyncSession> {
const session = await startObsidianLiveSyncSession({
binary: context.binary,
cliBinary: context.cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
env: sessionEnvironment(port),
});
context.activeSessions.add(session);
return session;
}
async function stopSessions(context: RunnerContext): Promise<void> {
for (const session of [...context.activeSessions]) {
await stopSession(context, 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 writeNote(
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
);
await waitForLocalDatabaseEntry(cliBinary, environment, path);
}
async function waitForPathContent(vault: TemporaryVault, path: 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, 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 waitForObjectStorageData(config: ObjectStorageConfig, prefix: string): Promise<void> {
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_OBJECT_STORAGE_TIMEOUT_MS ?? 30000);
while (Date.now() < deadline) {
if ((await listObjectStorageObjects(config, prefix)).length > 0) return;
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`Timed out waiting for Object Storage data under ${prefix}.`);
}
async function captureNote(port: number, path: string, text: string, filename: string): Promise<string> {
await withObsidianPage(port, async (page) => {
await page.evaluate((notePath) => {
const obsidian = globalThis as typeof globalThis & {
app?: {
workspace?: { openLinkText(path: string, sourcePath: string, newLeaf: boolean): Promise<void> };
};
};
return obsidian.app?.workspace?.openLinkText(notePath, "", false);
}, path);
});
await captureObsidianPage(port, `${filename}.full.png`, async (page) => {
await page.getByText(text, { exact: false }).first().waitFor({ state: "visible", timeout: 30000 });
});
return await captureObsidianElement(port, filename, (page) => page.locator(".workspace-leaf.mod-active").first());
}
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 objectStorage = await loadObjectStorageConfig();
const bucketPrefix = makeUniqueBucketPrefix("setup-uri-workflow");
const bootstrapArtifact = await generateBootstrapSetupURI(objectStorage, bucketPrefix);
const vaultA = await createTemporaryVault();
const vaultB = await createTemporaryVault();
const [portA, portB] = sessionPorts();
const context: RunnerContext = { binary, cliBinary: cli.binary, activeSessions: new Set() };
const screenshots: string[] = [];
try {
await ensureObjectStorageBucket(objectStorage);
console.log(`Temporary Object Storage target: ${objectStorage.bucket}/${bucketPrefix}`);
const sessionA = await startSession(context, vaultA, portA);
screenshots.push(await enterSetupURI(portA, "new", bootstrapArtifact, captures));
screenshots.push(await captureAndStartInitialisation(portA, "new", captures));
screenshots.push(await confirmRebuild(portA, captures));
screenshots.push(await skipMissingRemoteConfiguration(portA, captures));
screenshots.push(await acknowledgeDisabledOptionalFeatures(portA, captures));
const firstState = await finishInitialisation(portA, context.cliBinary, sessionA.cliEnv);
await resumeCompatibilityReviewIfShown(portA);
assertEqual(
firstState.endpoint,
objectStorage.endpoint,
"The first device did not activate the Object Storage endpoint."
);
assertEqual(
firstState.bucket,
objectStorage.bucket,
"The first device did not activate the Object Storage bucket."
);
assertEqual(
firstState.bucketPrefix,
bucketPrefix,
"The first device did not activate the unique bucket prefix."
);
await writeNote(context.cliBinary, sessionA.cliEnv, noteFromFirst, firstContent);
await pushLocalChanges(context.cliBinary, sessionA.cliEnv);
await waitForObjectStorageData(objectStorage, bucketPrefix);
const generated = await generateSetupURIFromDevice(portA, randomBytes(24).toString("base64url"), captures);
if (generated.artifact.setupURI === bootstrapArtifact.setupURI) {
throw new Error("The first device returned the bootstrap Setup URI instead of generating a new one.");
}
screenshots.push(...generated.screenshots);
await stopSession(context, sessionA);
const sessionB = await startSession(context, vaultB, portB);
screenshots.push(await enterSetupURI(portB, "existing", generated.artifact, captures));
screenshots.push(await captureAndStartInitialisation(portB, "existing", captures));
screenshots.push(...(await confirmFastFetch(portB, captures)));
const secondState = await finishInitialisation(portB, context.cliBinary, sessionB.cliEnv);
await resumeCompatibilityReviewIfShown(portB);
assertEqual(
secondState.endpoint,
objectStorage.endpoint,
"The second device did not import the Object Storage endpoint."
);
assertEqual(
secondState.bucketPrefix,
bucketPrefix,
"The second device did not import the unique bucket prefix."
);
await pushLocalChanges(context.cliBinary, sessionB.cliEnv);
await waitForPathContent(vaultB, noteFromFirst, firstContent);
screenshots.push(
await captureNote(
portB,
noteFromFirst,
"Object Storage from the first device",
"guide-object-storage-setup-first-to-second.png"
)
);
await writeNote(context.cliBinary, sessionB.cliEnv, noteFromSecond, secondContent);
await pushLocalChanges(context.cliBinary, sessionB.cliEnv);
await stopSession(context, sessionB);
const returningSessionA = await startSession(context, vaultA, portA);
await waitForLiveSyncCoreReady(context.cliBinary, returningSessionA.cliEnv);
await resumeCompatibilityReviewIfShown(portA);
await pushLocalChanges(context.cliBinary, returningSessionA.cliEnv);
await waitForPathContent(vaultA, noteFromSecond, secondContent);
screenshots.push(
await captureNote(
portA,
noteFromSecond,
"Object Storage from the second device",
"guide-object-storage-setup-second-to-first.png"
)
);
console.log(
`Object Storage Setup URI and two-device roundtrip succeeded. Screenshots: ${screenshots.join(", ")}`
);
} finally {
await stopSessions(context).catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
await vaultA.dispose();
await vaultB.dispose();
if (process.env.E2E_OBSIDIAN_KEEP_OBJECT_STORAGE !== "true") {
await deleteObjectStoragePrefix(objectStorage, bucketPrefix).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,246 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import {
assertLocatorHasMinimumTouchTarget,
assertLocatorWithinSafeArea,
assertNoHorizontalOverflow,
} from "@vrtmrz/obsidian-test-session";
import { evalObsidianJson } from "../runner/cli.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { assertMobileDialogueLayout, iPhoneSafeArea, setObsidianMobileTestMode } from "../runner/mobileUi.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { captureObsidianDialogue, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_ONBOARDING_TIMEOUT_MS ?? 15000);
const markerPath = "E2E/unconfigured-startup-must-not-scan.md";
type UnconfiguredStartupEvidence = {
configured: boolean;
markerInDatabase: boolean;
offlineScanInitialised: boolean;
recommendedDefaults: {
usePluginSyncV2: boolean;
handleFilenameCaseSensitive: boolean;
};
};
type ObsidianTestApp = {
setting?: {
open(): void;
openTabById(tabId: string): void;
};
};
type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp };
async function writeMarker(vaultPath: string): Promise<void> {
const fullPath = join(vaultPath, markerPath);
await mkdir(dirname(fullPath), { recursive: true });
await writeFile(fullPath, "# This file must remain outside the database until setup completes.\n", "utf8");
}
async function inspectUnconfiguredStartup(
cliBinary: string,
env: NodeJS.ProcessEnv
): Promise<UnconfiguredStartupEvidence> {
return await evalObsidianJson<UnconfiguredStartupEvidence>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
`const markerPath=${JSON.stringify(markerPath)};`,
"let entry=false;",
"try{entry=await core.localDatabase.getDBEntry(markerPath,undefined,false,false);}catch{}",
"let initialised=false;",
"try{initialised=(await core.kvDB.get('initialized'))===true;}catch{}",
"const settings=core.services.setting.currentSettings();",
"return JSON.stringify({",
"configured:settings?.isConfigured===true,",
"markerInDatabase:Boolean(entry&&entry._id),",
"offlineScanInitialised:initialised,",
"recommendedDefaults:{",
"usePluginSyncV2:settings?.usePluginSyncV2,",
"handleFilenameCaseSensitive:settings?.handleFilenameCaseSensitive,",
"},",
"});",
"})()",
].join(""),
env
);
}
function onboardingNotice(page: Parameters<Parameters<typeof withObsidianPage>[1]>[0]) {
return page.locator(".notice").filter({ hasText: "Welcome to Self-hosted LiveSync" });
}
function onboardingDialogue(page: Parameters<Parameters<typeof withObsidianPage>[1]>[0]) {
return page.locator(".modal-container").filter({ hasText: "Welcome to Self-hosted LiveSync" });
}
async function requireInvitationWithoutDialogue(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const invitation = onboardingNotice(page);
await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs });
await invitation.locator(".sls-onboarding-invitation-action").waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
if ((await onboardingDialogue(page).count()) !== 0) {
throw new Error("The onboarding dialogue opened before the user selected the invitation.");
}
const compatibilityReview = page.locator(".modal-container").filter({
hasText: "Synchronisation paused for compatibility review",
});
if ((await compatibilityReview.count()) !== 0) {
throw new Error("A new unconfigured Vault was incorrectly treated as an existing compatibility state.");
}
});
}
async function captureDesktopInvitation(): Promise<string> {
return await captureObsidianDialogue(
obsidianRemoteDebuggingPort(),
"onboarding-invitation-desktop.png",
async (page) => {
const invitation = onboardingNotice(page);
await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs });
await assertNoHorizontalOverflow(page, invitation, { label: "desktop onboarding invitation" });
}
);
}
async function captureAndSelectMobileInvitation(): Promise<string> {
const port = obsidianRemoteDebuggingPort();
await setObsidianMobileTestMode(port, true, uiTimeoutMs);
const screenshot = await captureObsidianDialogue(port, "onboarding-invitation-mobile.png", async (page) => {
const invitation = onboardingNotice(page);
const action = invitation.locator(".sls-onboarding-invitation-action");
await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs });
await assertLocatorWithinSafeArea(page, invitation, {
label: "mobile onboarding invitation",
safeAreaInsets: iPhoneSafeArea,
});
await assertNoHorizontalOverflow(page, invitation, { label: "mobile onboarding invitation" });
await assertLocatorHasMinimumTouchTarget(page, action, {
label: "mobile onboarding invitation action",
});
});
await withObsidianPage(port, async (page) => {
await onboardingNotice(page).locator(".sls-onboarding-invitation-action").click({ timeout: uiTimeoutMs });
});
return screenshot;
}
async function captureAndCloseIntro(filename: string, mobile: boolean): Promise<string> {
const port = obsidianRemoteDebuggingPort();
const screenshot = await captureObsidianDialogue(port, filename, async (page) => {
const container = onboardingDialogue(page);
await container.waitFor({ state: "visible", timeout: uiTimeoutMs });
await container.getByText("I am setting this up for the first time", { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await container
.getByText("I am adding a device to an existing synchronisation setup", { exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
if (mobile) await assertMobileDialogueLayout(page, container, "mobile onboarding introduction");
});
await withObsidianPage(port, async (page) => {
const container = onboardingDialogue(page);
await container.getByRole("button", { name: "No, please take me back" }).click({ timeout: uiTimeoutMs });
await container.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
return screenshot;
}
async function openOnboardingFromSettings(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
await page.evaluate(() => {
const setting = (globalThis as ObsidianTestGlobal).app?.setting;
if (setting === undefined) throw new Error("Obsidian settings are unavailable");
setting.open();
setting.openTabById("obsidian-livesync");
});
const liveSyncSettings = page.locator(".sls-setting");
await liveSyncSettings.waitFor({ state: "visible", timeout: uiTimeoutMs });
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Setup"]').click({ timeout: uiTimeoutMs });
const onboardingSetting = liveSyncSettings.locator(".setting-item").filter({
has: page.locator(".setting-item-name").filter({ hasText: "Rerun Onboarding Wizard" }),
});
await onboardingSetting.waitFor({ state: "visible", timeout: uiTimeoutMs });
await onboardingSetting
.getByRole("button", { name: "Rerun Wizard", exact: true })
.click({ timeout: uiTimeoutMs });
await onboardingDialogue(page).waitFor({ state: "visible", timeout: uiTimeoutMs });
});
}
async function closeSettings(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const settingsContainer = page.locator(".modal-container").filter({
has: page.locator(".sls-setting"),
});
await settingsContainer.locator(".modal-close-button").click({ timeout: uiTimeoutMs });
await settingsContainer.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
}
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();
let session: ObsidianLiveSyncSession | undefined;
try {
await writeMarker(vault.path);
session = await startObsidianLiveSyncSession({
binary,
cliBinary: cli.binary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
});
await requireInvitationWithoutDialogue();
const evidence = await inspectUnconfiguredStartup(cli.binary, session.cliEnv);
if (
evidence.configured ||
evidence.markerInDatabase ||
evidence.offlineScanInitialised ||
evidence.recommendedDefaults.usePluginSyncV2 !== true ||
evidence.recommendedDefaults.handleFilenameCaseSensitive !== false
) {
throw new Error(`Fresh Vault startup state did not match its contract: ${JSON.stringify(evidence)}`);
}
console.log(`Fresh Vault startup evidence: ${JSON.stringify(evidence)}`);
const desktopInvitation = await captureDesktopInvitation();
await openOnboardingFromSettings();
const settingsIntro = await captureAndCloseIntro("onboarding-intro-settings-desktop.png", false);
await closeSettings();
const mobileInvitation = await captureAndSelectMobileInvitation();
const mobileIntro = await captureAndCloseIntro("onboarding-intro-mobile.png", true);
console.log(
`Onboarding remained opt-in and kept unconfigured startup inert. Screenshots: ${[
desktopInvitation,
mobileInvitation,
mobileIntro,
settingsIntro,
].join(", ")}`
);
} finally {
if (session) {
await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), false, uiTimeoutMs).catch(() => undefined);
await session.app.stop();
}
await vault.dispose();
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});
+415
View File
@@ -0,0 +1,415 @@
/**
* Verifies the complete user-visible contract of the P2P status pane in real
* Obsidian: a configured CouchDB-only Vault with no P2P profile is not
* presented with P2P controls, while configured P2P devices can deliberately
* open the current pane in the appropriate workspace area.
*
* Desktop and mobile use separate Vaults, profiles, and Obsidian processes.
* Mobile mode is enabled before LiveSync's first load so that command and view
* registration observe the mobile application state, and no desktop workspace
* state can make a misplaced or restored pane appear correct.
*
* Command registration, automatic-opening policy, ribbon availability,
* workspace ownership, layout, and screenshots are kept in one scenario
* because together they describe one navigation path. Checking them in
* isolation could miss a pane which is registered correctly but opens in the
* wrong area, or one which is visible only because another session restored it.
*/
import { assertLocatorWithinViewport, assertNoHorizontalOverflow } from "@vrtmrz/obsidian-test-session";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations";
import type { ConsoleMessage, Page } from "playwright";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
createE2eCouchDbPluginData,
createE2eObsidianDeviceLocalState,
waitForLiveSyncCoreReady,
} from "../runner/liveSyncWorkflow.ts";
import { setObsidianMobileTestModeBeforePluginStart } from "../runner/mobileUi.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { captureObsidianPage, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_P2P_PANE_TIMEOUT_MS ?? 10000);
type ObsidianTestLeaf = {
containerEl?: HTMLElement;
view?: { getViewType?: () => string };
};
type ObsidianTestWorkspace = {
activeLeaf?: ObsidianTestLeaf;
getLeavesOfType?: (type: string) => ObsidianTestLeaf[];
getRightLeaf?: (split: boolean) => ObsidianTestLeaf | null;
rightSplit?: { containerEl?: HTMLElement };
};
type ObsidianTestApp = {
commands?: {
commands?: Record<string, unknown>;
executeCommandById(commandId: string): boolean;
};
isMobile?: boolean;
plugins?: {
plugins?: Record<
string,
{
core?: {
services?: {
API?: {
isMobile?: () => boolean;
};
};
};
}
>;
};
workspace?: ObsidianTestWorkspace;
};
type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp };
async function openP2PStatusPane(page: Page) {
return await page.evaluate((commandId) => {
const app = (globalThis as ObsidianTestGlobal).app;
const plugin = app?.plugins?.plugins?.["obsidian-livesync"];
return {
opened: app?.commands?.executeCommandById(commandId) === true,
appIsMobile: app?.isMobile ?? null,
apiIsMobile: plugin?.core?.services?.API?.isMobile?.() ?? null,
bodyIsMobile: document.body.classList.contains("is-mobile"),
};
}, "obsidian-livesync:open-p2p-server-status");
}
async function collectP2PWorkspaceState(page: Page) {
return await page.evaluate(() => {
const workspace = (globalThis as ObsidianTestGlobal).app?.workspace;
const activeLeaf = workspace?.activeLeaf;
const p2pLeaves = workspace?.getLeavesOfType?.("p2p-server-status") ?? [];
const rightLeaf = workspace?.getRightLeaf?.(false);
return {
bodyClasses: document.body.className,
activeLeaf: {
type: activeLeaf?.view?.getViewType?.() ?? null,
visible: activeLeaf?.containerEl?.checkVisibility?.() ?? null,
classes: activeLeaf?.containerEl?.className ?? null,
},
p2pLeaves: p2pLeaves.map((leaf) => ({
type: leaf.view?.getViewType?.() ?? null,
visible: leaf.containerEl?.checkVisibility?.() ?? null,
classes: leaf.containerEl?.className ?? null,
})),
rightLeaf: {
type: rightLeaf?.view?.getViewType?.() ?? null,
visible: rightLeaf?.containerEl?.checkVisibility?.() ?? null,
classes: rightLeaf?.containerEl?.className ?? null,
},
visibleP2PContents: document.querySelectorAll(
".workspace-leaf-content[data-type='p2p-server-status']:not(.is-hidden)"
).length,
};
});
}
async function assertMobileP2PPlacement(page: Page): Promise<void> {
const placement = await page.evaluate(() => {
const workspace = (globalThis as ObsidianTestGlobal).app?.workspace;
const p2pLeaves = workspace?.getLeavesOfType?.("p2p-server-status") ?? [];
const rightSplit = workspace?.rightSplit?.containerEl;
const rightLeaf = workspace?.getRightLeaf?.(false);
return {
p2pLeafCount: p2pLeaves.length,
inRightSplit: p2pLeaves.some(
(leaf) =>
(rightSplit?.contains(leaf.containerEl ?? null) ?? false) ||
(leaf.containerEl?.closest(".mod-right-split, .workspace-drawer.mod-right") ?? null) !== null
),
rightLeafType: rightLeaf?.view?.getViewType?.() ?? null,
p2pLeafClasses: p2pLeaves.map((leaf) => leaf.containerEl?.className ?? null),
rightSplitClasses: rightSplit?.className ?? null,
};
});
if (!placement.inRightSplit) {
throw new Error(`The mobile P2P status view was not opened in the right leaf: ${JSON.stringify(placement)}`);
}
}
async function verifyP2PStatusPane(filename: string, mobile: boolean): Promise<string> {
return await captureObsidianPage(obsidianRemoteDebuggingPort(), filename, async (page) => {
const runtimeErrors: string[] = [];
const onPageError = (error: Error) => runtimeErrors.push(`pageerror: ${error.message}`);
const onConsole = (message: ConsoleMessage) => {
if (message.type() === "error") runtimeErrors.push(`console: ${message.text()}`);
};
page.on("pageerror", onPageError);
page.on("console", onConsole);
let dispatchState: Awaited<ReturnType<typeof openP2PStatusPane>> | undefined;
const heading = page.getByRole("heading", { name: "Signalling Status" }).last();
try {
dispatchState = await openP2PStatusPane(page);
if (!dispatchState.opened) {
throw new Error("The P2P status command was not registered or could not be executed.");
}
if (
mobile &&
(dispatchState.appIsMobile !== true ||
dispatchState.apiIsMobile !== true ||
dispatchState.bodyIsMobile !== true)
) {
throw new Error(
`The mobile P2P command did not observe a fully mobile application state: ${JSON.stringify(dispatchState)}`
);
}
await heading.waitFor({ state: "visible", timeout: uiTimeoutMs });
} catch (error) {
const workspaceState = await collectP2PWorkspaceState(page);
console.error(
`P2P command state after failed open: ${JSON.stringify({ dispatchState, runtimeErrors })}`
);
console.error(`P2P workspace state after failed open: ${JSON.stringify(workspaceState)}`);
throw error;
} finally {
page.off("pageerror", onPageError);
page.off("console", onConsole);
}
if (mobile) {
await assertMobileP2PPlacement(page);
}
const pane = heading.locator(
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
);
await pane.getByText("Connection:", { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
await pane.getByRole("button", { name: "Open connection" }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
const remoteSelector = pane.getByRole("combobox", { name: "Select active P2P remote" });
await remoteSelector.waitFor({ state: "visible", timeout: uiTimeoutMs });
const remoteSelectionDeadline = Date.now() + uiTimeoutMs;
let remoteConfigurationId = "";
while (Date.now() < remoteSelectionDeadline) {
remoteConfigurationId = (await remoteSelector.inputValue()).trim();
if (remoteConfigurationId !== "") break;
await page.waitForTimeout(50);
}
if (remoteConfigurationId === "") {
throw new Error("The configured P2P status pane did not select an active P2P remote.");
}
if (
(await pane.getByText("Please select an active P2P remote configuration to change P2P sync targets.").count()) !==
0
) {
throw new Error("The configured P2P status pane still requested an active P2P remote.");
}
await assertNoHorizontalOverflow(page, pane, { label: "P2P status pane" });
if (mobile) {
await assertLocatorWithinViewport(page, pane, { label: "mobile P2P status pane" });
}
await dismissOpenNotices(page);
});
}
async function assertP2PUIIsOptIn(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const state = await page.evaluate(() => {
const commands = (globalThis as ObsidianTestGlobal).app?.commands?.commands ?? {};
return {
currentCommand: commands["obsidian-livesync:open-p2p-server-status"] !== undefined,
legacyCommand: commands["obsidian-livesync:open-p2p-replicator"] !== undefined,
};
});
if (!state.currentCommand) {
throw new Error("The current P2P status command was not registered.");
}
if (state.legacyCommand) {
throw new Error("The retired P2P pane command is still exposed.");
}
if ((await page.locator(".workspace-leaf-content[data-type='p2p-server-status']:visible").count()) !== 0) {
throw new Error("The P2P status pane opened automatically for a CouchDB user without P2P configured.");
}
if ((await page.locator(".livesync-ribbon-p2p-server-status").count()) !== 0) {
throw new Error("The P2P ribbon icon was shown without a P2P configuration.");
}
});
}
async function assertConfiguredP2PUIIsAvailable(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
await page.locator(".livesync-ribbon-p2p-server-status").waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
if ((await page.locator(".workspace-leaf-content[data-type='p2p-server-status']:visible").count()) !== 0) {
throw new Error("The configured P2P status pane opened before the user requested it.");
}
});
}
async function assertConfiguredP2PCommandIsAvailable(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const state = await page.evaluate(() => {
const app = (globalThis as ObsidianTestGlobal).app;
const commands = app?.commands?.commands ?? {};
return {
commandRegistered: commands["obsidian-livesync:open-p2p-server-status"] !== undefined,
openPaneCount: app?.workspace?.getLeavesOfType?.("p2p-server-status").length ?? 0,
};
});
if (!state.commandRegistered) {
throw new Error("The configured P2P status command was not registered in mobile mode.");
}
if (state.openPaneCount !== 0) {
throw new Error("The configured P2P status pane opened before the mobile user requested it.");
}
});
}
async function dismissOpenNotices(page: Page): Promise<void> {
const deadline = Date.now() + uiTimeoutMs;
let quietSince = Date.now();
while (Date.now() < deadline) {
const dismissed = await page.evaluate(() => {
const notices = (Array.from(document.querySelectorAll(".notice")) as HTMLElement[]).filter(
(notice) => notice.checkVisibility?.() ?? notice.offsetParent !== null
);
for (const notice of notices) {
const closeButton = notice.querySelector(".notice-close-button") as HTMLElement | null;
// Obsidian 1.12 does not render a separate close control for
// every Notice; clicking the Notice itself is its standard
// dismiss action.
(closeButton ?? notice).click();
}
return notices.length;
});
if (dismissed === 0) {
if (Date.now() - quietSince >= 500) {
return;
}
await page.waitForTimeout(100);
continue;
}
quietSince = Date.now();
await page.waitForTimeout(50);
}
throw new Error("Transient Obsidian notices did not become quiet before the P2P status screenshot.");
}
function createBaseP2PPluginData(): Record<string, unknown> {
return createE2eCouchDbPluginData(
{
uri: "http://127.0.0.1:5984",
username: "",
password: "",
dbName: "p2p-pane-ui-only",
},
{
notifyThresholdOfRemoteStorageSize: -1,
periodicReplication: false,
P2P_Enabled: false,
P2P_AutoStart: false,
syncAfterMerge: false,
syncOnEditorSave: false,
syncOnFileOpen: false,
syncOnSave: false,
syncOnStart: false,
}
);
}
function createConfiguredP2PPluginData(): Record<string, unknown> {
const pluginData = {
...createBaseP2PPluginData(),
P2P_roomID: "configured-p2p-room",
P2P_passphrase: "configured-p2p-passphrase",
};
upsertRemoteConfigurationInPlace(pluginData as ObsidianLiveSyncSettings, "p2p", {
id: "e2e-p2p",
name: "P2P Remote",
activateForP2P: true,
});
return pluginData;
}
async function withP2PSession(
binary: string,
cliBinary: string,
pluginData: Record<string, unknown>,
verify: () => Promise<void>,
options: { mobileBeforePluginStart?: boolean } = {}
): Promise<void> {
const vault = await createTemporaryVault();
let session: ObsidianLiveSyncSession | undefined;
try {
session = await startObsidianLiveSyncSession({
binary,
cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData,
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
lifecycle: options.mobileBeforePluginStart
? {
beforePluginStart: async ({ remoteDebuggingPort }) => {
await setObsidianMobileTestModeBeforePluginStart(
remoteDebuggingPort,
true,
uiTimeoutMs
);
},
}
: undefined,
});
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
await verify();
} finally {
if (session) {
await session.app.stop();
}
await vault.dispose();
}
}
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(", ")}`);
}
await withP2PSession(binary, cli.binary, createBaseP2PPluginData(), async () => {
await assertP2PUIIsOptIn();
});
await withP2PSession(
binary,
cli.binary,
createConfiguredP2PPluginData(),
async () => {
await assertConfiguredP2PUIIsAvailable();
const desktopScreenshot = await verifyP2PStatusPane("p2p-status-pane.png", false);
console.log(
`Configured P2P status UI remained opt-in and was reachable on desktop. Screenshot: ${desktopScreenshot}`
);
}
);
await withP2PSession(
binary,
cli.binary,
createConfiguredP2PPluginData(),
async () => {
await assertConfiguredP2PCommandIsAvailable();
const mobileScreenshot = await verifyP2PStatusPane("p2p-status-pane-mobile.png", true);
console.log(
`Configured P2P status UI remained opt-in and was reachable on mobile. Screenshot: ${mobileScreenshot}`
);
},
{ mobileBeforePluginStart: true }
);
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,589 @@
import { execFile } from "node:child_process";
import { randomBytes } from "node:crypto";
import { readFile } from "node:fs/promises";
import { connect } from "node:net";
import { join } from "node:path";
import { promisify } from "node:util";
import { evalObsidianJson } from "../runner/cli.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { assertEqual, waitForLocalDatabaseEntry } from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import {
acknowledgeDisabledOptionalFeatures,
captureAndStartInitialisation,
captureGuideDialogue,
confirmFastFetch,
confirmRebuild,
enterSetupURI,
finishInitialisation,
generateSetupURIFromDevice,
modalByTitle,
resumeCompatibilityReviewIfShown,
type SetupArtifact,
type SetupCaptureNames,
} from "../runner/setupUri.ts";
import {
captureObsidianElement,
captureObsidianPage,
obsidianRemoteDebuggingPort,
withObsidianPage,
} from "../runner/ui.ts";
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
const execFileAsync = promisify(execFile);
const captures: SetupCaptureNames = { scenario: "p2p-setup-uri", guide: "p2p-setup" };
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_P2P_WORKFLOW_TIMEOUT_MS ?? 60000);
const noteFromFirst = "E2E/p2p/from-first.md";
const noteFromSecond = "E2E/p2p/from-second.md";
const firstContent = "# P2P from the first device\n\nThis note was fetched directly from the first device.\n";
const secondContent = "# P2P from the second device\n\nThis note completed the return journey.\n";
type RunnerContext = {
binary: string;
cliBinary: string;
activeSessions: Set<ObsidianLiveSyncSession>;
};
function sessionEnvironment(port: number): NodeJS.ProcessEnv {
return { ...process.env, E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: String(port) };
}
function sessionPorts(): readonly [number, number] {
const first = obsidianRemoteDebuggingPort(process.env);
const second = Number(process.env.E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT ?? first + 1);
if (!Number.isInteger(second) || second < 1 || second > 65535 || second === first) {
throw new Error(`Invalid secondary Obsidian remote debugging port: ${second}`);
}
return [first, second];
}
async function runDeno(script: string, environment: NodeJS.ProcessEnv): Promise<string> {
const { stdout } = await execFileAsync(
"deno",
[
"run",
"--minimum-dependency-age=0",
"--config=utils/flyio/deno.jsonc",
"--frozen",
"--lock=utils/flyio/deno.lock",
"--allow-env",
script,
],
{ cwd: process.cwd(), env: environment, maxBuffer: 4 * 1024 * 1024 }
);
return stdout;
}
async function generateBootstrapSetupURI(relay: string): Promise<SetupArtifact> {
const setupPassphrase = randomBytes(24).toString("base64url");
const output = await runDeno("utils/setup/generate_setup_uri.ts", {
...process.env,
remote_type: "p2p",
p2p_relays: relay,
p2p_room_id: `real-obsidian-${randomBytes(12).toString("hex")}`,
p2p_passphrase: randomBytes(24).toString("base64url"),
p2p_app_id: "self-hosted-livesync-real-obsidian-e2e",
p2p_auto_start: "false",
p2p_auto_broadcast: "false",
passphrase: randomBytes(24).toString("base64url"),
uri_passphrase: setupPassphrase,
});
const setupURI = output.split(/\r?\n/u).find((line) => line.startsWith("obsidian://setuplivesync?settings="));
if (!setupURI) throw new Error("The public Setup URI generator did not emit a P2P Setup URI.");
return { setupURI, setupPassphrase };
}
async function waitForRelay(relay: string): Promise<void> {
const endpoint = new URL(relay);
const port = Number(endpoint.port || (endpoint.protocol === "wss:" ? 443 : 80));
const host = endpoint.hostname === "localhost" ? "127.0.0.1" : endpoint.hostname;
const deadline = Date.now() + Number(process.env.E2E_P2P_RELAY_READY_TIMEOUT_MS ?? 30000);
let lastError: unknown;
while (Date.now() < deadline) {
try {
await new Promise<void>((resolve, reject) => {
const socket = connect({ host, port });
socket.setTimeout(1000);
socket.once("connect", () => {
socket.destroy();
resolve();
});
socket.once("timeout", () => {
socket.destroy();
reject(new Error("connection timed out"));
});
socket.once("error", reject);
});
await new Promise((resolve) => setTimeout(resolve, 1500));
return;
} catch (error) {
lastError = error;
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
throw new Error(
`P2P relay is not ready at ${relay}: ${lastError instanceof Error ? lastError.message : lastError}`
);
}
async function startSession(
context: RunnerContext,
vault: TemporaryVault,
port: number
): Promise<ObsidianLiveSyncSession> {
const session = await startObsidianLiveSyncSession({
binary: context.binary,
cliBinary: context.cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
env: sessionEnvironment(port),
});
context.activeSessions.add(session);
return session;
}
async function stopSessions(context: RunnerContext): Promise<void> {
for (const session of [...context.activeSessions]) {
await session.app.stop();
context.activeSessions.delete(session);
}
}
async function writeNote(
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
);
await waitForLocalDatabaseEntry(cliBinary, environment, path);
}
async function waitForPathContent(vault: TemporaryVault, path: string, expected: string): Promise<void> {
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 60000);
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 readReflectionDiagnostics(
cliBinary: string,
environment: NodeJS.ProcessEnv,
path: string
): Promise<unknown> {
return await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const settings=core.services.setting.currentSettings();",
"const entry=await core.localDatabase.getDBEntry(path,undefined,false,true).catch(()=>false);",
"const chunks=entry&&Array.isArray(entry.children)?await Promise.all(entry.children.map(async(id)=>{",
"const chunk=await core.localDatabase.getDBEntry(id,undefined,false,true).catch(()=>false);",
"return {id,found:!!chunk};",
"})):[];",
"return JSON.stringify({",
"suspendFileWatching:settings.suspendFileWatching,",
"suspendParseReplicationResult:settings.suspendParseReplicationResult,",
"configured:settings.isConfigured,",
"entry:entry?{id:entry._id,path:entry.path,children:entry.children||[]}:false,",
"chunks,",
"databaseQueueCount:core.services.replication.databaseQueueCount?.value,",
"storageApplyingCount:core.services.replication.storageApplyingCount?.value,",
"replicationResultCount:core.services.replication.replicationResultCount?.value,",
"});",
"})()",
].join(""),
environment
);
}
async function executeCommand(port: number, commandId: string): Promise<void> {
const opened = await withObsidianPage(port, async (page) => {
return await page.evaluate(
(id) =>
(
globalThis as typeof globalThis & {
app?: { commands?: { executeCommandById(commandId: string): boolean } };
}
).app?.commands?.executeCommandById(id) === true,
commandId
);
});
if (!opened) throw new Error(`Obsidian command was not available: ${commandId}`);
}
async function openP2PStatus(port: number, filename: string): Promise<string> {
await executeCommand(port, "obsidian-livesync:open-p2p-server-status");
await withObsidianPage(port, async (page) => {
const heading = page.getByRole("heading", { name: "Signalling Status" }).last();
await heading.waitFor({ state: "visible", timeout: uiTimeoutMs });
const pane = heading.locator(
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
);
const open = pane.getByRole("button", { name: "Open connection" });
if (await open.isVisible()) {
const blockingDialogues = await page.locator(".modal-container:visible").evaluateAll((elements) =>
elements.map((element) => ({
title: element.querySelector(".modal-title")?.textContent?.trim() ?? "",
text: element.textContent?.trim().replace(/\s+/gu, " ").slice(0, 240) ?? "",
}))
);
if (blockingDialogues.length > 0) {
throw new Error(
`P2P connection control is blocked by a dialogue: ${JSON.stringify(blockingDialogues)}`
);
}
await open.click({ timeout: uiTimeoutMs });
}
await pane.locator(".status-value.connected").waitFor({ state: "visible", timeout: uiTimeoutMs });
});
return await captureObsidianElement(port, filename, (page) => {
const heading = page.getByRole("heading", { name: "Signalling Status" }).last();
return heading.locator(
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
);
});
}
async function reconnectP2PStatus(port: number): Promise<void> {
await executeCommand(port, "obsidian-livesync:open-p2p-server-status");
await withObsidianPage(port, async (page) => {
const heading = page.getByRole("heading", { name: "Signalling Status" }).last();
await heading.waitFor({ state: "visible", timeout: uiTimeoutMs });
const pane = heading.locator(
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
);
const disconnect = pane.getByRole("button", { name: "Disconnect", exact: true });
if (await disconnect.isVisible()) {
await disconnect.click({ timeout: uiTimeoutMs });
}
const open = pane.getByRole("button", { name: "Open connection", exact: true });
await open.waitFor({ state: "visible", timeout: uiTimeoutMs });
await open.click({ timeout: uiTimeoutMs });
await pane.locator(".status-value.connected").waitFor({ state: "visible", timeout: uiTimeoutMs });
});
}
async function acceptConnectionRequests(
ports: readonly number[],
stop: () => boolean,
screenshots: string[]
): Promise<void> {
const captured = new Set<number>();
while (!stop()) {
for (const port of ports) {
const visible = await withObsidianPage(port, async (page) => {
return await modalByTitle(page, "P2P Connection Request").isVisible();
}).catch(() => false);
if (!visible) continue;
if (!captured.has(port)) {
const requestNumber =
screenshots.filter((filename) => filename.includes("guide-p2p-setup-connection-request-")).length +
1;
screenshots.push(
await captureGuideDialogue(
port,
`guide-p2p-setup-connection-request-${requestNumber}.png`,
"P2P Connection Request"
)
);
captured.add(port);
}
await withObsidianPage(port, async (page) => {
await modalByTitle(page, "P2P Connection Request")
.getByRole("button", { name: "Accept", exact: true })
.click({ timeout: uiTimeoutMs });
});
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
}
async function fetchFromFirstPeer(
sessionA: ObsidianLiveSyncSession,
portA: number,
portB: number,
screenshots: string[]
): Promise<void> {
try {
await withObsidianPage(portB, async (page) => {
const modal = modalByTitle(page, "P2P Rebuild");
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal.locator(".peer-item").first().waitFor({ state: "visible", timeout: uiTimeoutMs });
});
} catch (error) {
const firstDeviceAlive = sessionA.app.process.exitCode === null && sessionA.app.process.signalCode === null;
const firstDeviceUi = await withObsidianPage(portA, async (page) => {
return await page.locator("body").innerText();
}).catch(() => undefined);
const secondDeviceDialogue = await withObsidianPage(portB, async (page) => {
return await modalByTitle(page, "P2P Rebuild").innerText();
}).catch(() => undefined);
throw new Error(
[
error instanceof Error ? error.message : String(error),
`First Obsidian process alive: ${firstDeviceAlive}`,
`First Obsidian CDP reachable: ${firstDeviceUi !== undefined}`,
firstDeviceUi === undefined ? undefined : `First device UI: ${firstDeviceUi.slice(0, 1_500)}`,
secondDeviceDialogue === undefined
? undefined
: `Second-device P2P Rebuild dialogue: ${secondDeviceDialogue.slice(0, 1_500)}`,
sessionA.app.output().stderr
? `First Obsidian stderr: ${sessionA.app.output().stderr.slice(-2_000)}`
: undefined,
]
.filter(Boolean)
.join("\n")
);
}
screenshots.push(await captureGuideDialogue(portB, "guide-p2p-setup-select-first-device.png", "P2P Rebuild"));
let finished = false;
const acceptor = acceptConnectionRequests([portA, portB], () => finished, screenshots);
try {
await withObsidianPage(portB, async (page) => {
const modal = modalByTitle(page, "P2P Rebuild");
await modal
.locator(".peer-item")
.first()
.getByRole("button", { name: "Sync", exact: true })
.click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
} finally {
finished = true;
await acceptor;
}
await withObsidianPage(portB, async (page) => {
const modal = modalByTitle(page, "P2P Rebuild");
if (await modal.isVisible()) {
await modal.getByRole("button", { name: "Skip and close" }).click({ timeout: uiTimeoutMs });
}
});
}
async function replicateFromStatusPane(port: number): Promise<void> {
await withObsidianPage(port, async (page) => {
const heading = page.getByRole("heading", { name: "Detected Peers" }).last();
await heading.waitFor({ state: "visible", timeout: uiTimeoutMs });
const pane = heading.locator(
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
);
await pane.getByRole("button", { name: "Refresh", exact: true }).click({ timeout: uiTimeoutMs });
const replicate = pane.getByRole("button", { name: "Replicate now" }).first();
await replicate.waitFor({ state: "visible", timeout: uiTimeoutMs });
await replicate.click({ timeout: uiTimeoutMs });
});
}
async function waitForDetectedPeer(port: number): Promise<void> {
await withObsidianPage(port, async (page) => {
const heading = page.getByRole("heading", { name: "Detected Peers" }).last();
await heading.waitFor({ state: "visible", timeout: uiTimeoutMs });
const pane = heading.locator(
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
);
await pane.getByRole("button", { name: "Refresh", exact: true }).click({ timeout: uiTimeoutMs });
await pane.getByRole("button", { name: "Replicate now" }).first().waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
});
}
async function capturePeerActionsMenu(port: number): Promise<string> {
await withObsidianPage(port, async (page) => {
const moreActions = page.getByRole("button", { name: /^More actions for /u }).first();
await moreActions.waitFor({ state: "visible", timeout: uiTimeoutMs });
await moreActions.click({ timeout: uiTimeoutMs });
const menu = page.locator(".menu:visible").last();
await menu.waitFor({ state: "visible", timeout: uiTimeoutMs });
for (const label of [
"Synchronise when this device connects",
"Follow whenever this device connects",
"Include in the P2P synchronisation command",
]) {
await menu.getByText(label, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
}
const layout = await menu.evaluate((element) => {
const rect = element.getBoundingClientRect();
return {
insideViewport:
rect.left >= 0 &&
rect.top >= 0 &&
rect.right <= document.documentElement.clientWidth &&
rect.bottom <= document.documentElement.clientHeight,
hasHorizontalOverflow: element.scrollWidth > element.clientWidth,
};
});
if (!layout.insideViewport || layout.hasHorizontalOverflow) {
throw new Error(`P2P peer actions menu did not fit the viewport: ${JSON.stringify(layout)}`);
}
});
const screenshot = await captureObsidianElement(
port,
"guide-p2p-setup-peer-actions-menu.png",
(page) => page.locator(".menu:visible").last()
);
await withObsidianPage(port, async (page) => {
await page.keyboard.press("Escape");
await page.locator(".menu:visible").waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
return screenshot;
}
async function captureNote(port: number, path: string, text: string, filename: string): Promise<string> {
await withObsidianPage(port, async (page) => {
await page.evaluate((notePath) => {
const obsidian = globalThis as typeof globalThis & {
app?: {
workspace?: { openLinkText(path: string, sourcePath: string, newLeaf: boolean): Promise<void> };
};
};
return obsidian.app?.workspace?.openLinkText(notePath, "", false);
}, path);
});
await captureObsidianPage(port, `${filename}.full.png`, async (page) => {
await page.getByText(text, { exact: false }).first().waitFor({ state: "visible", timeout: uiTimeoutMs });
});
return await captureObsidianElement(port, filename, (page) => page.locator(".workspace-leaf.mod-active").first());
}
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 relay = process.env.E2E_P2P_RELAY_URL ?? `ws://127.0.0.1:${process.env.E2E_P2P_RELAY_PORT ?? "4010"}/`;
await waitForRelay(relay);
const bootstrapArtifact = await generateBootstrapSetupURI(relay);
const vaultA = await createTemporaryVault();
const vaultB = await createTemporaryVault();
const [portA, portB] = sessionPorts();
const context: RunnerContext = { binary, cliBinary: cli.binary, activeSessions: new Set() };
const screenshots: string[] = [];
try {
console.log(`Temporary P2P relay: ${relay}`);
console.log(`Temporary P2P devices: ${vaultA.name}, ${vaultB.name}`);
const sessionA = await startSession(context, vaultA, portA);
screenshots.push(await enterSetupURI(portA, "new", bootstrapArtifact, captures));
screenshots.push(await captureAndStartInitialisation(portA, "new", captures));
screenshots.push(await confirmRebuild(portA, captures));
screenshots.push(await acknowledgeDisabledOptionalFeatures(portA, captures));
const firstState = await finishInitialisation(portA, context.cliBinary, sessionA.cliEnv);
await resumeCompatibilityReviewIfShown(portA);
assertEqual(firstState.p2pEnabled, true, "The first device did not enable P2P.");
assertEqual(firstState.p2pRelays, relay, "The first device did not activate the P2P relay.");
await writeNote(context.cliBinary, sessionA.cliEnv, noteFromFirst, firstContent);
const generated = await generateSetupURIFromDevice(portA, randomBytes(24).toString("base64url"), captures);
if (generated.artifact.setupURI === bootstrapArtifact.setupURI) {
throw new Error("The first device returned the bootstrap Setup URI instead of generating a new one.");
}
screenshots.push(...generated.screenshots);
screenshots.push(await openP2PStatus(portA, "guide-p2p-setup-first-device-connected.png"));
const sessionB = await startSession(context, vaultB, portB);
screenshots.push(await enterSetupURI(portB, "existing", generated.artifact, captures));
screenshots.push(await captureAndStartInitialisation(portB, "existing", captures));
screenshots.push(...(await confirmFastFetch(portB, captures)));
await fetchFromFirstPeer(sessionA, portA, portB, screenshots);
await waitForLocalDatabaseEntry(context.cliBinary, sessionB.cliEnv, noteFromFirst, {
timeoutMs: uiTimeoutMs,
});
const secondState = await finishInitialisation(portB, context.cliBinary, sessionB.cliEnv);
await resumeCompatibilityReviewIfShown(portB);
assertEqual(secondState.p2pEnabled, true, "The second device did not enable P2P.");
assertEqual(secondState.p2pRelays, relay, "The second device did not import the P2P relay.");
assertEqual(secondState.p2pRoomId, firstState.p2pRoomId, "The two devices did not join the same P2P room.");
try {
await waitForPathContent(vaultB, noteFromFirst, firstContent);
} catch (error) {
const diagnostics = await readReflectionDiagnostics(context.cliBinary, sessionB.cliEnv, noteFromFirst);
throw new Error(
`${error instanceof Error ? error.message : String(error)}\nReflection diagnostics: ${JSON.stringify(diagnostics)}`
);
}
screenshots.push(
await captureNote(portB, noteFromFirst, "P2P from the first device", "guide-p2p-setup-first-to-second.png")
);
console.log("P2P workflow: initial Fetch from the first device completed.");
await writeNote(context.cliBinary, sessionB.cliEnv, noteFromSecond, secondContent);
await reconnectP2PStatus(portA);
await reconnectP2PStatus(portB);
await waitForDetectedPeer(portA);
screenshots.push(await openP2PStatus(portA, "guide-p2p-setup-devices-connected.png"));
screenshots.push(await capturePeerActionsMenu(portA));
console.log("P2P workflow: peer actions menu verified; starting the return journey.");
let returnJourneyFinished = false;
const returnJourneyAcceptor = acceptConnectionRequests(
[portA, portB],
() => returnJourneyFinished,
screenshots
);
try {
await replicateFromStatusPane(portA);
console.log("P2P workflow: return replication requested; waiting for the second device's note.");
await waitForPathContent(vaultA, noteFromSecond, secondContent);
console.log("P2P workflow: return note reached the first device.");
} finally {
returnJourneyFinished = true;
await returnJourneyAcceptor;
console.log("P2P workflow: return connection approval loop stopped.");
}
screenshots.push(
await captureNote(
portA,
noteFromSecond,
"P2P from the second device",
"guide-p2p-setup-second-to-first.png"
)
);
console.log(`P2P Setup URI and two-device roundtrip succeeded. Screenshots: ${screenshots.join(", ")}`);
} finally {
console.log("P2P workflow: stopping tracked Obsidian sessions.");
await stopSessions(context).catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
console.log("P2P workflow: disposing temporary Vaults.");
await vaultA.dispose();
await vaultB.dispose();
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});
+469
View File
@@ -0,0 +1,469 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import {
assertLocatorHasMinimumTouchTarget,
assertLocatorWithinSafeArea,
assertNoHorizontalOverflow,
} from "@vrtmrz/obsidian-test-session";
import { CURRENT_SETTING_VERSION } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
import { REVIEW_HARNESS_STATE_KEY } from "../../../src/features/ReviewHarness/reviewHarnessController.ts";
import { REVIEW_HARNESS_FIXTURE_ROOT } from "../../../src/features/ReviewHarness/reviewHarnessVaultFixture.ts";
import { evalObsidianJson } from "../runner/cli.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
import { iPhoneSafeArea, setObsidianMobileTestMode } from "../runner/mobileUi.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import {
captureObsidianDialogue,
captureObsidianPage,
obsidianRemoteDebuggingPort,
withObsidianPage,
} from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_REVIEW_HARNESS_TIMEOUT_MS ?? 15000);
type ObsidianTestApp = {
commands?: { executeCommandById(commandId: string): boolean };
plugins?: { plugins: Record<string, unknown> };
vault?: { getAbstractFileByPath(path: string): unknown | null };
};
type ReviewHarnessTestGlobal = typeof globalThis & {
app?: ObsidianTestApp;
reviewHarnessCopiedReport?: string;
};
type ReviewHarnessReadinessSnapshot = {
coreAvailable: boolean;
databaseReady?: boolean;
appReady?: boolean;
configured?: boolean;
remoteType?: string;
settingVersion?: number;
suspended?: boolean;
unresolvedMessages: string[];
};
const sensitiveDiagnosticLine =
/security seed|passphrase|password|credential|secret|access.?key|jwt.?key|authori[sz]ation|obsidian:\/\/setuplivesync|sls\+/iu;
const interruptedStartupMessages = [
"No replicator has been activated or has not been initialised yet.",
"Self-hosted LiveSync cannot be initialised, exiting loading.",
];
function redactDiagnosticLine(line: string): string {
if (sensitiveDiagnosticLine.test(line)) return "[REDACTED SENSITIVE LOG LINE]";
return line.replace(/\bhttps?:\/\/[^/\s:@]+:[^@\s/]+@/giu, "https://[REDACTED]@");
}
async function assertNoInterruptedStartupNotice(stage: string): Promise<void> {
const notices = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
await page.waitForTimeout(1500);
return await page.locator(".notice").allTextContents();
});
const interrupted = notices.filter((notice) =>
interruptedStartupMessages.some((message) => notice.includes(message))
);
if (interrupted.length > 0) {
throw new Error(`LiveSync emitted an interrupted-startup Notice during ${stage}: ${interrupted.join(" | ")}`);
}
console.log(`No interrupted-startup Notice observed during ${stage}.`);
}
async function captureReadinessFailure(
cliBinary: string,
session: ObsidianLiveSyncSession,
readinessError: unknown
): Promise<void> {
const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
await mkdir(outputDirectory, { recursive: true });
const captureErrors: string[] = [];
let screenshotPath: string | undefined;
try {
screenshotPath = await captureObsidianPage(
obsidianRemoteDebuggingPort(),
"review-harness-core-not-ready.png",
async () => undefined
);
} catch (error) {
captureErrors.push(`screenshot: ${error instanceof Error ? error.message : String(error)}`);
}
let readiness: ReviewHarnessReadinessSnapshot | undefined;
try {
readiness = await evalObsidianJson<ReviewHarnessReadinessSnapshot>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync']?.core;",
"if(!core)return JSON.stringify({coreAvailable:false,unresolvedMessages:[]});",
"const settings=core.services.setting.currentSettings();",
"let unresolvedMessages=[];",
"try{",
"unresolvedMessages=(await core.services.appLifecycle.getUnresolvedMessages()).flat()",
".filter((message)=>message!==undefined&&message!==null)",
".map((message)=>String(message)).slice(-50);",
"}catch(error){unresolvedMessages=[`Could not inspect unresolved messages: ${String(error)}`];}",
"return JSON.stringify({",
"coreAvailable:true,",
"databaseReady:core.services.database.isDatabaseReady(),",
"appReady:core.services.appLifecycle.isReady(),",
"configured:settings?.isConfigured===true,",
"remoteType:settings?.remoteType??'',",
"settingVersion:settings?.settingVersion,",
"suspended:core.services.appLifecycle.isSuspended(),",
"unresolvedMessages,",
"});",
"})()",
].join(""),
session.cliEnv
);
readiness.unresolvedMessages = readiness.unresolvedMessages.map(redactDiagnosticLine);
} catch (error) {
captureErrors.push(`readiness snapshot: ${error instanceof Error ? error.message : String(error)}`);
}
let recentLog: string[] = [];
try {
recentLog = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const opened = await page.evaluate(
(commandId) =>
(globalThis as ReviewHarnessTestGlobal).app?.commands?.executeCommandById(commandId) === true,
"obsidian-livesync:view-log"
);
if (!opened) throw new Error("The Show log command was not registered.");
const logPane = page.locator(".logpane");
await logPane.waitFor({ state: "visible", timeout: 5000 });
return (await logPane.locator(".log pre").allTextContents()).slice(-80).map(redactDiagnosticLine);
});
} catch (error) {
captureErrors.push(`recent log: ${error instanceof Error ? error.message : String(error)}`);
}
const resultPath = join(outputDirectory, "review-harness-core-not-ready.json");
await writeFile(
resultPath,
`${JSON.stringify(
{
capturedAt: new Date().toISOString(),
failure: readinessError instanceof Error ? readinessError.message : String(readinessError),
screenshotPath,
readiness,
recentLog,
captureErrors,
},
null,
2
)}\n`,
"utf8"
);
if (screenshotPath) console.error(`Review Harness core readiness screenshot: ${screenshotPath}`);
console.error(`Review Harness core readiness diagnostics: ${resultPath}`);
}
async function openHarness(): Promise<void> {
const opened = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
return await page.evaluate(
(commandId) => (globalThis as ReviewHarnessTestGlobal).app?.commands?.executeCommandById(commandId) === true,
"obsidian-livesync:open-review-harness"
);
});
if (!opened) throw new Error("The Review Harness command was not registered.");
}
async function waitForHarness(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
await page.locator('[data-testid="review-harness"]').waitFor({ state: "visible", timeout: uiTimeoutMs });
});
}
async function keepCompatibilityPaused(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const summary = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Synchronisation paused for compatibility review",
}),
});
await summary.waitFor({ state: "visible", timeout: uiTimeoutMs });
await summary.getByRole("button", { name: "Keep synchronisation paused" }).click({ timeout: uiTimeoutMs });
await summary.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
}
async function runAutomaticScenarios(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const harness = page.locator('[data-testid="review-harness"]');
await harness.locator('[data-testid="review-harness-run-automatic"]').click({ timeout: uiTimeoutMs });
for (const id of ["settings-lifecycle", "p2p-composition"]) {
await harness
.locator(`[data-testid="review-harness-result-${id}"]`)
.getByText("Passed:", { exact: false })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
}
});
}
async function runVaultFixture(): Promise<string> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
await page
.locator('[data-testid="review-harness-run-vault-round-trip"]')
.click({ timeout: uiTimeoutMs });
const confirmation = page.locator(".modal-container").filter({
has: page.getByText("Review Harness: Vault fixture access", { exact: true }),
});
await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs });
});
const screenshot = await captureObsidianDialogue(
obsidianRemoteDebuggingPort(),
"review-harness-vault-confirmation.png",
async (page) => {
const confirmation = page.locator(".modal-container").filter({
has: page.getByText("Review Harness: Vault fixture access", { exact: true }),
});
await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs });
await assertNoHorizontalOverflow(page, confirmation, { label: "Vault fixture confirmation" });
}
);
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const harness = page.locator('[data-testid="review-harness"]');
const confirmation = page.locator(".modal-container").filter({
has: page.getByText("Review Harness: Vault fixture access", { exact: true }),
});
await confirmation.getByRole("button", { name: "Yes" }).click({ timeout: uiTimeoutMs });
await harness
.locator('[data-testid="review-harness-result-vault-round-trip"]')
.getByText("Passed:", { exact: false })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
const fixtureRemoved = await page.evaluate(
(root) => (globalThis as ReviewHarnessTestGlobal).app?.vault?.getAbstractFileByPath(root) === null,
REVIEW_HARNESS_FIXTURE_ROOT
);
if (!fixtureRemoved) throw new Error("The Review Harness fixture root remained after the scenario.");
});
return screenshot;
}
async function restartAndResumeHarness(): Promise<string> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const harness = page.locator('[data-testid="review-harness"]');
await harness
.locator('[data-testid="review-harness-run-compatibility-review"]')
.click({ timeout: uiTimeoutMs });
await harness
.locator('[data-testid="review-harness-result-compatibility-review"]')
.getByText("Waiting for review:", { exact: false })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await harness.locator('[data-testid="review-harness-restart"]').click({ timeout: uiTimeoutMs });
});
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
await page.waitForFunction(
() => {
const plugin = (globalThis as ReviewHarnessTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
if (typeof plugin !== "object" || plugin === null || !("core" in plugin)) return false;
const core = (plugin as { core: { services: { appLifecycle: { isReady(): boolean } } } }).core;
return core.services.appLifecycle.isReady();
},
undefined,
{ timeout: uiTimeoutMs * 2 }
);
});
await keepCompatibilityPaused();
await waitForHarness();
return await captureObsidianDialogue(
obsidianRemoteDebuggingPort(),
"review-harness-resumed.png",
async (page) => {
const harness = page.locator('[data-testid="review-harness"]');
await harness
.locator('[data-testid="review-harness-resumed"]')
.waitFor({ state: "visible", timeout: uiTimeoutMs });
const continuationRemoved = await page.evaluate((stateKey) => {
const plugin = (globalThis as ReviewHarnessTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
if (typeof plugin !== "object" || plugin === null || !("core" in plugin)) {
throw new Error("Self-hosted LiveSync is unavailable after restart.");
}
const core = (plugin as { core: { services: { setting: { getSmallConfig(key: string): string } } } })
.core;
return core.services.setting.getSmallConfig(stateKey) === "";
}, REVIEW_HARNESS_STATE_KEY);
if (!continuationRemoved) throw new Error("The one-shot continuation was not removed before use.");
await assertNoHorizontalOverflow(page, harness, { label: "resumed Review Harness" });
}
);
}
async function completeResumedCompatibilityStep(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const harness = page.locator('[data-testid="review-harness"]');
await harness
.locator('[data-testid="review-harness-open-compatibility-review"]')
.click({ timeout: uiTimeoutMs });
const summary = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Synchronisation paused for compatibility review",
}),
});
await summary.waitFor({ state: "visible", timeout: uiTimeoutMs });
await summary.getByRole("button", { name: "Resume synchronisation" }).click({ timeout: uiTimeoutMs });
await summary.waitFor({ state: "hidden", timeout: uiTimeoutMs });
await harness
.locator('[data-testid="review-harness-result-compatibility-review"]')
.getByText("The device-local compatibility pause was reviewed and cleared.", { exact: false })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
});
}
async function copyAndReadReport(): Promise<string> {
return await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
await page.evaluate(`
globalThis.reviewHarnessCopiedReport = undefined;
navigator.clipboard.writeText = function (value) {
globalThis.reviewHarnessCopiedReport = value;
return Promise.resolve();
};
`);
await page.locator('[data-testid="review-harness-copy-report"]').click({ timeout: uiTimeoutMs });
await page.waitForFunction(
() => typeof (globalThis as ReviewHarnessTestGlobal).reviewHarnessCopiedReport === "string",
undefined,
{ timeout: uiTimeoutMs }
);
return await page.evaluate(
() => (globalThis as ReviewHarnessTestGlobal).reviewHarnessCopiedReport ?? ""
);
});
}
async function verifyMobileHarness(): Promise<string> {
await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), true, uiTimeoutMs);
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const harness = page.locator('[data-testid="review-harness"]');
if (await harness.isVisible()) return;
await page.evaluate(async (viewType) => {
const plugin = (globalThis as ReviewHarnessTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
if (typeof plugin !== "object" || plugin === null || !("core" in plugin)) {
throw new Error("Self-hosted LiveSync is unavailable in mobile test mode.");
}
const core = (plugin as {
core: { services: { API: { showWindow(type: string): Promise<void> } } };
}).core;
await core.services.API.showWindow(viewType);
}, "self-hosted-livesync-review-harness");
});
return await captureObsidianDialogue(
obsidianRemoteDebuggingPort(),
"review-harness-mobile.png",
async (page) => {
const harness = page.locator('[data-testid="review-harness"]');
await harness.waitFor({ state: "visible", timeout: uiTimeoutMs });
await assertNoHorizontalOverflow(page, harness, { label: "mobile Review Harness" });
const heading = harness.getByRole("heading", { name: "Self-hosted LiveSync review harness" });
await assertLocatorWithinSafeArea(page, heading, {
label: "mobile Review Harness heading",
safeAreaInsets: iPhoneSafeArea,
});
for (const testId of [
"review-harness-run-automatic",
"review-harness-run-full",
"review-harness-copy-report",
]) {
await assertLocatorHasMinimumTouchTarget(page, harness.locator(`[data-testid="${testId}"]`), {
label: testId,
});
}
}
);
}
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();
let session: ObsidianLiveSyncSession | undefined;
try {
session = await startObsidianLiveSyncSession({
binary,
cliBinary: cli.binary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: {
doctorProcessedVersion: "1.0.0",
settingVersion: CURRENT_SETTING_VERSION,
isConfigured: true,
additionalSuffixOfDatabaseName: "",
enableDebugTools: true,
notifyThresholdOfRemoteStorageSize: 0,
P2P_Enabled: false,
P2P_AutoStart: false,
liveSync: false,
syncOnSave: false,
syncOnEditorSave: true,
syncOnStart: false,
syncOnFileOpen: true,
syncAfterMerge: false,
periodicReplication: true,
},
});
await assertNoInterruptedStartupNotice("plug-in session start");
try {
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
} catch (error) {
await captureReadinessFailure(cli.binary, session, error).catch((diagnosticError: unknown) => {
console.error(
`Could not capture Review Harness readiness diagnostics: ${
diagnosticError instanceof Error ? diagnosticError.message : String(diagnosticError)
}`
);
});
throw error;
}
await assertNoInterruptedStartupNotice("core readiness");
await keepCompatibilityPaused();
await openHarness();
await waitForHarness();
const initialScreenshot = await captureObsidianDialogue(
obsidianRemoteDebuggingPort(),
"review-harness-initial.png",
async (page) => {
const harness = page.locator('[data-testid="review-harness"]');
await harness.waitFor({ state: "visible", timeout: uiTimeoutMs });
await assertNoHorizontalOverflow(page, harness, { label: "Review Harness" });
}
);
await runAutomaticScenarios();
const vaultConfirmationScreenshot = await runVaultFixture();
const resumedScreenshot = await restartAndResumeHarness();
await completeResumedCompatibilityStep();
const report = await copyAndReadReport();
if (!report.includes("## Self-hosted LiveSync Review Harness report")) {
throw new Error("The copied Review Harness report was not Markdown evidence.");
}
for (const forbidden of [vault.name, REVIEW_HARNESS_FIXTURE_ROOT]) {
if (report.includes(forbidden)) throw new Error(`The Review Harness report exposed local state: ${forbidden}`);
}
const mobileScreenshot = await verifyMobileHarness();
console.log(
`Review Harness passed one-shot, fixture, report, and mobile checks. Screenshots: ${[
initialScreenshot,
vaultConfirmationScreenshot,
resumedScreenshot,
mobileScreenshot,
].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);
});
@@ -0,0 +1,735 @@
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { evalObsidianJson } from "../runner/cli.ts";
import {
createE2eObsidianDeviceLocalState,
waitForLiveSyncCoreReady,
waitForLocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { captureObsidianElement, withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
import type { Locator, Page } from "playwright";
const path = "revision-repair.md";
const healthyDeletedPath = "healthy-logical-deletion.md";
const baseContent = "Revision repair\n\nShared base.\n";
const branchContents = [
`Revision repair\n\nLeft branch.\n${"L".repeat(4096)}\n`,
`Revision repair\n\nRight branch.\n${"R".repeat(4096)}\n`,
] as const;
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_REVISION_REPAIR_TIMEOUT_MS ?? 15000);
type BrokenRevisionFixture = {
winnerRevision: string;
conflictRevision: string;
missingChunkId: string;
};
type RevisionTree = {
winnerRevision: string;
conflictRevisions: string[];
};
type VaultWinnerState = {
matches: boolean;
winnerRevision: string;
};
type ObsidianSettingsController = {
open(): void;
openTabById(tabId: string): void;
};
type ObsidianTestGlobal = typeof globalThis & {
app?: {
setting?: ObsidianSettingsController;
};
};
async function createAndOpenBaseFile(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
`const content=${JSON.stringify(baseContent)};`,
"let file=app.vault.getAbstractFileByPath(path);",
"if(!file) file=await app.vault.create(path,content);",
"await app.workspace.getLeaf(false).openFile(file);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env
);
}
async function createHealthyLogicalDeletion(cliBinary: string, env: NodeJS.ProcessEnv): Promise<string> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(healthyDeletedPath)};`,
`const content=${JSON.stringify(`Healthy logical deletion\n\n${"D".repeat(4096)}\n`)};`,
"let file=app.vault.getAbstractFileByPath(path);",
"if(!file) file=await app.vault.create(path,content);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env
);
await waitForLocalDatabaseEntry(cliBinary, env, healthyDeletedPath);
return await evalObsidianJson<string>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(healthyDeletedPath)};`,
`const timeoutMs=${JSON.stringify(uiTimeoutMs)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const file=app.vault.getAbstractFileByPath(path);",
"if(!file) throw new Error(`Logical-deletion fixture is missing from the Vault: ${path}`);",
"await app.vault.delete(file);",
"const id=await core.services.path.path2id(path);",
"const deadline=Date.now()+timeoutMs;",
"const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));",
"while(Date.now()<deadline){",
" await core.services.fileProcessing.commitPendingFileEvents();",
" const doc=await core.localDatabase.localDatabase.get(id,{conflicts:true}).catch(()=>false);",
" if(!app.vault.getAbstractFileByPath(path)&&doc?.deleted&&(doc._conflicts??[]).length===0){",
" return JSON.stringify(doc._rev);",
" }",
" await sleep(250);",
"}",
"throw new Error(`Timed out waiting for a healthy logical deletion: ${path}`);",
"})()",
].join(""),
env
);
}
async function createBrokenConflict(
cliBinary: string,
env: NodeJS.ProcessEnv,
baseRevision: string
): Promise<BrokenRevisionFixture> {
return await evalObsidianJson<BrokenRevisionFixture>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
`const baseRevision=${JSON.stringify(baseRevision)};`,
`const contents=${JSON.stringify(branchContents)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const id=await core.services.path.path2id(path);",
"for(const [index,content] of contents.entries()){",
" const blob=new Blob([content],{type:'text/plain'});",
" const now=Date.now()+index;",
" const result=await core.localDatabase.putDBEntry({",
" _id:id,path,data:blob,ctime:now,mtime:now,",
" size:(await blob.arrayBuffer()).byteLength,children:[],",
" datatype:'plain',type:'plain',eden:{},",
" },false,baseRevision);",
" if(!result?.ok) throw new Error(`Could not create repair conflict: ${path}`);",
"}",
"const tree=await core.localDatabase.localDatabase.get(id,{conflicts:true});",
"const conflictRevision=tree._conflicts?.[0];",
"if(!tree._rev||!conflictRevision){",
" throw new Error(`Repair fixture did not produce two live revisions: ${path}`);",
"}",
"const conflict=await core.localDatabase.localDatabase.get(id,{rev:conflictRevision});",
"const embedded=new Set(Object.keys(conflict.eden??{}));",
"const missingChunkId=(conflict.children??[]).find((child)=>!embedded.has(child));",
"if(!missingChunkId){",
" throw new Error(`Repair fixture did not create an independent chunk: ${conflictRevision}`);",
"}",
"const chunk=await core.localDatabase.localDatabase.get(missingChunkId);",
"await core.localDatabase.localDatabase.remove(chunk);",
"core.localDatabase.clearCaches();",
"const unreadable=await core.localDatabase.getDBEntry(path,{rev:conflictRevision},false,true,true);",
"if(unreadable!==false){",
" throw new Error(`The selected revision remained readable after its chunk was removed: ${conflictRevision}`);",
"}",
"return JSON.stringify({",
" winnerRevision:tree._rev,",
" conflictRevision,",
" missingChunkId,",
"});",
"})()",
].join(""),
env
);
}
async function readRevisionTree(cliBinary: string, env: NodeJS.ProcessEnv): Promise<RevisionTree> {
return await evalObsidianJson<RevisionTree>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const id=await core.services.path.path2id(path);",
"const tree=await core.localDatabase.localDatabase.get(id,{conflicts:true});",
"return JSON.stringify({",
" winnerRevision:tree._rev,",
" conflictRevisions:tree._conflicts??[],",
"});",
"})()",
].join(""),
env
);
}
async function readVaultWinnerState(cliBinary: string, env: NodeJS.ProcessEnv): Promise<VaultWinnerState> {
return await evalObsidianJson<VaultWinnerState>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const file=app.vault.getAbstractFileByPath(path);",
"if(!file) throw new Error(`Vault file is missing: ${path}`);",
"const entry=await core.localDatabase.getDBEntry(path,undefined,false,true,true);",
"if(!entry||!entry._rev) throw new Error(`Database winner is missing: ${path}`);",
"const vaultContent=await app.vault.read(file);",
"const data=Array.isArray(entry.data)?entry.data:[entry.data];",
"const databaseContent=await new Blob(data).text();",
"return JSON.stringify({",
" matches:vaultContent===databaseContent,",
" winnerRevision:entry._rev,",
"});",
"})()",
].join(""),
env
);
}
async function readFileReflectionProvenance(
cliBinary: string,
env: NodeJS.ProcessEnv,
targetPath = path
): Promise<{ revision: string; observedStorageMtime?: number } | null> {
return await evalObsidianJson<{ revision: string; observedStorageMtime?: number } | null>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(targetPath)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const store=core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1');",
"return JSON.stringify((await store.get(path))??null);",
"})()",
].join(""),
env
);
}
function repairCard(settings: Locator): Locator {
return settings.locator(".sls-repair-result").filter({ hasText: path });
}
function revisionCard(settings: Locator, revision: string): Locator {
return repairCard(settings).locator(".sls-repair-revision").filter({ hasText: revision });
}
async function openRevisionActionMenu(page: Page, settings: Locator, revision: string): Promise<Locator> {
const actionButton = revisionCard(settings, revision).getByRole("button", {
name: `More actions for revision ${revision}`,
exact: true,
});
await actionButton.locator("svg.lucide-wrench").waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await actionButton.click({ timeout: uiTimeoutMs });
const menu = page.locator(".menu:visible").last();
await menu.waitFor({ state: "visible", timeout: uiTimeoutMs });
const box = await menu.boundingBox();
const viewport = await page.evaluate(() => ({
width: window.innerWidth,
height: window.innerHeight,
}));
if (
box === null ||
box.y < 0 ||
box.y + box.height > viewport.height - 4
) {
throw new Error(
`Revision action menu is outside the viewport: ${JSON.stringify({
box,
viewport,
})}`
);
}
return menu;
}
async function selectRevisionAction(page: Page, settings: Locator, revision: string, action: string): Promise<void> {
const menu = await openRevisionActionMenu(page, settings, revision);
const item = menu.getByText(action, { exact: true });
await item.waitFor({ state: "visible", timeout: uiTimeoutMs });
await item.click({ timeout: uiTimeoutMs });
}
async function requestConflictCheck(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"core.localDatabase.clearCaches();",
"await core.services.conflict.queueCheckFor(path);",
"await core.services.conflict.ensureAllProcessed();",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env
);
}
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 cliBinary = cli.binary;
const vault = await createTemporaryVault("obsidian-livesync-revision-repair-");
let session: ObsidianLiveSyncSession | undefined;
try {
session = await startObsidianLiveSyncSession({
binary,
cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: {
doctorProcessedVersion: "1.0.0",
isConfigured: true,
liveSync: false,
remoteType: "",
couchDB_URI: "",
couchDB_DBNAME: "",
couchDB_USER: "",
couchDB_PASSWORD: "",
remoteConfigurations: {},
activeConfigurationId: "",
notifyThresholdOfRemoteStorageSize: -1,
periodicReplication: false,
syncAfterMerge: false,
syncOnEditorSave: false,
syncOnFileOpen: false,
syncOnSave: false,
syncOnStart: false,
disableMarkdownAutoMerge: true,
showMergeDialogOnlyOnActive: true,
useEden: false,
},
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
});
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
await createAndOpenBaseFile(cliBinary, session.cliEnv);
const base = await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, path);
const healthyDeletionRevision = await createHealthyLogicalDeletion(cliBinary, session.cliEnv);
const fixture = await createBrokenConflict(cliBinary, session.cliEnv, base.rev);
const healthyDeletionProvenance = await readFileReflectionProvenance(
cliBinary,
session.cliEnv,
healthyDeletedPath
);
if (healthyDeletionProvenance !== null) {
throw new Error(
`A healthy logical deletion retained Vault provenance indefinitely: ${JSON.stringify({
healthyDeletedPath,
healthyDeletionRevision,
healthyDeletionProvenance,
})}`
);
}
await requestConflictCheck(cliBinary, session.cliEnv);
const afterAutomaticCheck = await readRevisionTree(cliBinary, session.cliEnv);
if (
afterAutomaticCheck.winnerRevision !== fixture.winnerRevision ||
!afterAutomaticCheck.conflictRevisions.includes(fixture.conflictRevision)
) {
throw new Error(
`Automatic conflict checking discarded the unreadable revision: ${JSON.stringify({
fixture,
afterAutomaticCheck,
})}`
);
}
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.evaluate(() => {
const setting = (globalThis as ObsidianTestGlobal).app?.setting;
if (setting === undefined) throw new Error("Obsidian settings are unavailable");
setting.open();
setting.openTabById("obsidian-livesync");
});
const settings = page.locator(".sls-setting");
await settings.waitFor({ state: "visible", timeout: uiTimeoutMs });
await settings.locator('.sls-setting-menu-btn[title="Hatch"]').click({ timeout: uiTimeoutMs });
const verifySetting = settings.locator(".setting-item").filter({
has: page.getByText("Inspect conflicts and file/database differences", {
exact: true,
}),
});
await verifySetting.getByRole("button", { name: "Begin inspection", exact: true }).click({
timeout: uiTimeoutMs,
});
const card = repairCard(settings);
await card.waitFor({ state: "visible", timeout: uiTimeoutMs });
if ((await settings.locator(".sls-repair-result").filter({ hasText: healthyDeletedPath }).count()) !== 0) {
throw new Error(
`File/database inspection reported the healthy logical deletion ${healthyDeletedPath} (${healthyDeletionRevision}).`
);
}
const winnerRevision = revisionCard(settings, fixture.winnerRevision);
const brokenRevision = revisionCard(settings, fixture.conflictRevision);
await brokenRevision
.getByText(/🧩 Missing chunks: 1/u)
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await brokenRevision.getByText(fixture.missingChunkId, { exact: false }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
if ((await card.locator(".sls-repair-revision").count()) !== 2) {
throw new Error("Verify and Repair did not render the winner and conflict revision separately.");
}
for (const label of [
/📦 DB: recorded/u,
/📁 Vault:/u,
/Δsize vs DB/u,
/🕒 DB /u,
/Δtime /u,
/ Differs from Vault/u,
]) {
await winnerRevision.getByText(label).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
}
await brokenRevision.getByText(/decoded unavailable/u).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
const winnerMenu = await openRevisionActionMenu(page, settings, fixture.winnerRevision);
for (const label of [
"Compare with Vault",
"Apply this revision to Vault",
"Store Vault file as a child of this revision",
"Discard this branch",
]) {
await winnerMenu.getByText(label, { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
}
if (
(await winnerMenu
.getByText("Mark this revision as the Vault version", {
exact: true,
})
.count()) !== 0
) {
throw new Error("A differing revision incorrectly offered to record an exact Vault match.");
}
await page.keyboard.press("Escape");
});
const repairCardScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"revision-repair-unreadable-conflict.png",
(page) => page.locator(".sls-repair-result").filter({ hasText: path })
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const card = page.locator(".sls-repair-result").filter({ hasText: path });
await card.evaluate((element) => {
const htmlElement = element as HTMLElement;
htmlElement.dataset.e2eOriginalStyle = htmlElement.getAttribute("style") ?? "";
htmlElement.style.width = "360px";
htmlElement.style.maxWidth = "100%";
});
const dimensions = await card.evaluate((element) => ({
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
}));
if (dimensions.scrollWidth > dimensions.clientWidth + 1) {
throw new Error(
`Revision repair card overflowed at mobile width: ${JSON.stringify(dimensions)}`
);
}
});
const mobileWidthScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"revision-repair-mobile-width.png",
(page) => page.locator(".sls-repair-result").filter({ hasText: path })
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const card = page.locator(".sls-repair-result").filter({ hasText: path });
await card.evaluate((element) => {
const htmlElement = element as HTMLElement;
const originalStyle = htmlElement.dataset.e2eOriginalStyle ?? "";
if (originalStyle.length > 0) {
htmlElement.setAttribute("style", originalStyle);
} else {
htmlElement.removeAttribute("style");
}
delete htmlElement.dataset.e2eOriginalStyle;
});
});
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const settings = page.locator(".sls-setting");
await openRevisionActionMenu(page, settings, fixture.winnerRevision);
});
const readableMenuScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"revision-repair-readable-actions.png",
(page) => page.locator(".menu:visible").last()
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.keyboard.press("Escape");
const settings = page.locator(".sls-setting");
await selectRevisionAction(page, settings, fixture.winnerRevision, "Compare with Vault");
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Vault and database revision",
}),
});
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal.getByText(path, { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await modal.getByText(/Vault file:/u).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await modal.getByText(/Database revision:/u).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
const actions = modal.locator(".conflict-action-container");
await actions.getByRole("button", { name: "Close", exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
for (const action of ["Use Vault file", "Use Database revision", "Concat both", "Not now"]) {
if ((await actions.getByRole("button", { name: action, exact: true }).count()) !== 0) {
throw new Error(`Read-only comparison exposed the resolution action '${action}'.`);
}
}
});
const comparisonScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"revision-repair-read-only-comparison.png",
(page) =>
page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Vault and database revision",
}),
})
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Vault and database revision",
}),
});
await modal
.locator(".conflict-action-container")
.getByRole("button", { name: "Close", exact: true })
.click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
const beforeApply = await readRevisionTree(cliBinary, session.cliEnv);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const settings = page.locator(".sls-setting");
await selectRevisionAction(page, settings, fixture.winnerRevision, "Apply this revision to Vault");
const confirmation = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Apply database revision to Vault",
}),
});
await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs });
await confirmation.getByRole("button", { name: "Yes", exact: true }).click({ timeout: uiTimeoutMs });
await revisionCard(settings, fixture.winnerRevision)
.getByText("✅ Matches Vault", { exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
const status = repairCard(settings).locator(".sls-repair-status");
await status
.getByText("✅ Vault matches winner", { exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await status
.getByText("⚠️ Conflicts: 1", { exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
});
const matchedWinnerWithConflictScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"revision-repair-winner-match-with-conflict.png",
(page) => page.locator(".sls-repair-result").filter({ hasText: path })
);
const afterApply = await readRevisionTree(cliBinary, session.cliEnv);
if (JSON.stringify(afterApply) !== JSON.stringify(beforeApply)) {
throw new Error(
`Applying a live revision to the Vault changed the revision tree: ${JSON.stringify({
beforeApply,
afterApply,
})}`
);
}
const vaultWinner = await readVaultWinnerState(cliBinary, session.cliEnv);
const appliedProvenance = await readFileReflectionProvenance(cliBinary, session.cliEnv);
if (
!vaultWinner.matches ||
vaultWinner.winnerRevision !== fixture.winnerRevision ||
appliedProvenance?.revision !== fixture.winnerRevision
) {
throw new Error(
`Applying the winner did not preserve exact Vault provenance: ${JSON.stringify({
vaultWinner,
appliedProvenance,
fixture,
})}`
);
}
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const settings = page.locator(".sls-setting");
const menu = await openRevisionActionMenu(page, settings, fixture.winnerRevision);
await menu
.getByText("Mark this revision as the Vault version", { exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await menu
.getByText("Discard this branch", { exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await menu
.getByText("Mark this revision as the Vault version", { exact: true })
.click({ timeout: uiTimeoutMs });
await revisionCard(settings, fixture.winnerRevision)
.getByText("✅ Matches Vault", { exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
});
const afterExactMark = await readRevisionTree(cliBinary, session.cliEnv);
const markedProvenance = await readFileReflectionProvenance(cliBinary, session.cliEnv);
if (
JSON.stringify(afterExactMark) !== JSON.stringify(beforeApply) ||
markedProvenance?.revision !== fixture.winnerRevision
) {
throw new Error(
`Recording an exact Vault match changed the tree or lost provenance: ${JSON.stringify({
beforeApply,
afterExactMark,
markedProvenance,
})}`
);
}
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const settings = page.locator(".sls-setting");
const menu = await openRevisionActionMenu(page, settings, fixture.conflictRevision);
for (const label of [
"Store Vault file as a child of this revision",
"Retry reading revision",
"Discard this branch",
]) {
await menu.getByText(label, { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
}
});
const unreadableMenuScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"revision-repair-unreadable-actions-context.png",
(page) => page.locator("body")
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.keyboard.press("Escape");
const settings = page.locator(".sls-setting");
await selectRevisionAction(page, settings, fixture.conflictRevision, "Retry reading revision");
await revisionCard(settings, fixture.conflictRevision)
.getByText(/🧩 Missing chunks:/u)
.waitFor({ state: "visible", timeout: uiTimeoutMs });
});
const afterRetry = await readRevisionTree(cliBinary, session.cliEnv);
if (JSON.stringify(afterRetry) !== JSON.stringify(beforeApply)) {
throw new Error(`Retry changed the revision tree: ${JSON.stringify(afterRetry)}`);
}
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const settings = page.locator(".sls-setting");
await selectRevisionAction(page, settings, fixture.conflictRevision, "Discard this branch");
const confirmation = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Discard branch" }),
});
await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs });
await confirmation.getByRole("button", { name: "No", exact: true }).click({ timeout: uiTimeoutMs });
await confirmation.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
const afterCancellation = await readRevisionTree(cliBinary, session.cliEnv);
if (JSON.stringify(afterCancellation) !== JSON.stringify(beforeApply)) {
throw new Error(`Cancelling discard changed the revision tree: ${JSON.stringify(afterCancellation)}`);
}
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const settings = page.locator(".sls-setting");
await selectRevisionAction(page, settings, fixture.conflictRevision, "Discard this branch");
const confirmation = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Discard branch" }),
});
await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs });
await confirmation.getByRole("button", { name: "Yes", exact: true }).click({ timeout: uiTimeoutMs });
await repairCard(settings).waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
const afterDiscard = await readRevisionTree(cliBinary, session.cliEnv);
if (afterDiscard.winnerRevision !== fixture.winnerRevision || afterDiscard.conflictRevisions.length !== 0) {
throw new Error(
`Explicit discard did not remove only the selected unreadable revision: ${JSON.stringify({
fixture,
afterDiscard,
})}`
);
}
const finalVaultWinner = await readVaultWinnerState(cliBinary, session.cliEnv);
const finalProvenance = await readFileReflectionProvenance(cliBinary, session.cliEnv);
if (
!finalVaultWinner.matches ||
finalVaultWinner.winnerRevision !== fixture.winnerRevision ||
finalProvenance?.revision !== fixture.winnerRevision
) {
throw new Error(
`Discarding the unreadable branch disturbed the healthy Vault reflection: ${JSON.stringify({
finalVaultWinner,
finalProvenance,
fixture,
})}`
);
}
console.log(
"Real Obsidian omitted a healthy logical deletion; rendered each live revision with compact actions and diagnostics; showed that the Vault matched the winner while one conflict remained; compared and applied an exact readable revision without changing the tree; preserved Vault provenance; kept an unreadable branch through automatic checking, retry, and cancelled discard; and discarded only the selected branch after confirmation."
);
console.log(`Repair card screenshot: ${repairCardScreenshot}`);
console.log(`Mobile-width repair card screenshot: ${mobileWidthScreenshot}`);
console.log(`Readable revision actions screenshot: ${readableMenuScreenshot}`);
console.log(`Read-only comparison screenshot: ${comparisonScreenshot}`);
console.log(`Matching winner with conflict screenshot: ${matchedWinnerWithConflictScreenshot}`);
console.log(`Unreadable revision actions screenshot: ${unreadableMenuScreenshot}`);
} 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);
});
+89
View File
@@ -0,0 +1,89 @@
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
// Keep the public wrapper deliberately narrower than package.json. Discovery,
// installation, runner contracts, and the complete suite have different setup
// requirements and remain separate entry points.
const focusedScenarios = new Set([
"smoke",
"onboarding-invitation",
"dialog-mounts",
"revision-repair",
"settings-ui",
"review-harness",
"p2p-pane",
"vault-reflection",
"couchdb-upload",
"couchdb-manual-setup-workflow",
"cli-to-obsidian-sync",
"minio-upload",
"object-storage-setup-uri-workflow",
"p2p-setup-uri-workflow",
"startup-scan",
"setup-uri-workflow",
"two-vault-sync",
"security-seed-reconnect",
"hidden-file-snippet-sync",
"customisation-sync",
"setting-markdown-export",
"upgrade-from-stable",
]);
function usage(): string {
return `Usage: npm run test:e2e:obsidian:focused -- <scenario> [scenario arguments]
Builds the current Self-hosted LiveSync plug-in before running one maintained
real-Obsidian scenario. Supported scenarios:
${[...focusedScenarios].map((scenario) => ` ${scenario}`).join("\n")}
This wrapper does not start CouchDB, Object Storage, or the P2P signalling
relay. Use the documented service commands or the complete
local-suite:services wrapper when required.`;
}
// npm receives each argument directly. In particular, environment values and
// scenario arguments never pass through a shell for re-interpretation.
function runNpm(args: string[]): void {
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
const result = spawnSync(npm, args, {
cwd: fileURLToPath(new URL("../../..", import.meta.url)),
stdio: "inherit",
});
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error(`npm ${args.join(" ")} failed with exit code ${result.status ?? "unknown"}.`);
}
}
function main(): void {
const [scenario, ...scenarioArguments] = process.argv.slice(2);
if (!scenario || scenario === "-h" || scenario === "--help") {
process.stdout.write(`${usage()}\n`);
return;
}
if (!focusedScenarios.has(scenario)) {
throw new Error(`Unsupported focused real-Obsidian scenario: ${scenario}\n\n${usage()}`);
}
// Individual scenario scripts intentionally remain fast, raw entry points.
// The wrapper owns the freshness guarantee which was previously easy to
// miss after changing TypeScript source.
runNpm(["run", "build"]);
// The compatibility scenario defaults to the repository CLI. Build it only
// when the caller has not selected an external CLI distribution.
if (scenario === "cli-to-obsidian-sync" && !process.env.LIVESYNC_CLI_COMMAND) {
runNpm(["run", "build", "--workspace", "self-hosted-livesync-cli"]);
}
const script = `test:e2e:obsidian:${scenario}`;
runNpm(["run", script, ...(scenarioArguments.length > 0 ? ["--", ...scenarioArguments] : [])]);
}
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
}
@@ -0,0 +1,768 @@
/**
* Provides release evidence for the Security Seed refresh behaviour shared by
* supported platforms in real Obsidian. It verifies that an already-open
* device keeps its deliberately stale cached Seed until replication, refreshes
* from the managed CouchDB fixture before encrypting, and never restores the
* old Seed to the remote synchronisation-parameter document.
*
* The scenario uses isolated Vaults, profiles, and a random database because
* settings, the local database, the renderer process, and CouchDB must all
* participate in the result. Device A is restarted with the same Vault and
* profile, while device B is fresh. The devices run sequentially after the
* same-process stale-cache assertion because desktop Obsidian may enforce a
* single application instance; running them concurrently would test launcher
* behaviour rather than LiveSync's shared plug-in implementation.
*
* Seed replacement, A-to-B decryption, B-to-A return synchronisation, final
* remote-document comparison, error-log inspection, screenshots, and strict
* teardown remain one scenario. Together they prove that the same replacement
* Seed was used across the complete encrypted round trip and was not later
* rolled back. Independent passing checks would not establish that continuity.
* The result records fingerprints only and does not claim to cover an
* iPadOS-specific background or reconnect lifecycle.
*/
import { execFileSync } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { evalObsidianJson } from "../runner/cli.ts";
import {
assertCouchDbReachable,
couchDbDatabaseExists,
createCouchDbDatabase,
deleteCouchDbDatabase,
fetchAllCouchDbDocs,
fetchCouchDbDocument,
loadCouchDbConfig,
makeUniqueDatabaseName,
putCouchDbDocument,
waitForCouchDbDocs,
type CouchDbConfig,
type CouchDbDocument,
} from "../runner/couchdb.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
createE2eCouchDbPluginData,
createE2eObsidianDeviceLocalState,
prepareRemote,
pushLocalChanges,
waitForLiveSyncCoreReady,
waitForLocalDatabaseEntry,
type LocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
import {
SECURITY_SEED_DOCUMENT_ID,
changedSynchronisationParameterFields,
createSecuritySeed,
fingerprintSecuritySeed,
replaceSecuritySeed,
requireSecuritySeedDocument,
snapshotSecuritySeedDocument,
type SecuritySeedDocument,
type SecuritySeedDocumentSnapshot,
} from "../runner/securitySeed.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { captureObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "20000";
process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ??= "15000";
const outboundPath = "E2E/security-seed/device-a.md";
const returnPath = "E2E/security-seed/device-b.md";
const hkdfErrorMessages = [
"Encryption with HKDF failed",
"Decryption with HKDF failed",
"Failed to initialise the encryption key",
"Failed to obtain PBKDF2 salt",
] as const;
type RunnerContext = {
binary: string;
cliBinary: string;
artifactRoot: string;
couchDb: CouchDbConfig;
dbName: string;
activeSessions: Set<ObsidianLiveSyncSession>;
allSessions: ObsidianLiveSyncSession[];
screenshots: string[];
};
type DeviceLabel = "device-a" | "device-a-return" | "device-b";
type SourceEvidence = {
exactCommit: string;
revisionSource: "git-worktree" | "provided-artifact";
workingTreeClean: boolean | null;
pluginVersion: string;
pluginArtifactSha256: string;
};
type ReplicationSettingsState = {
liveSync: boolean;
syncOnStart: boolean;
syncOnSave: boolean;
periodicReplication: boolean;
syncOnFileOpen: boolean;
syncOnEditorSave: boolean;
};
type SessionHealth = {
matchingErrorMessages: string[];
};
type ScenarioEvidence = {
source: SourceEvidence;
securitySeed: {
initial: SecuritySeedDocumentSnapshot;
replacement: SecuritySeedDocumentSnapshot;
final: SecuritySeedDocumentSnapshot;
cachedBeforeReplacement: string;
cachedAfterRemoteReplacement: string;
cachedAfterReplication: string;
replacementChangedFields: string[];
finalChangedFields: string[];
};
synchronisation: {
deviceAToDeviceB: boolean;
deviceBToDeviceA: boolean;
deviceAEncryptedPayload: boolean;
deviceBEncryptedPayload: boolean;
};
health: {
deviceA: SessionHealth;
deviceB: SessionHealth;
};
screenshots: string[];
};
type TeardownEvidence = {
sessionsStopped: boolean;
vaultRemoved: boolean;
profileRemoved: boolean;
databaseRemoved: boolean;
remainingTrackedSessions: number;
};
class MultipleErrors extends Error {
readonly errors: unknown[];
constructor(message: string, errors: unknown[]) {
super(message);
this.name = "MultipleErrors";
this.errors = errors;
}
}
function assertEqual(actual: unknown, expected: unknown, message: string): void {
if (actual !== expected) {
throw new Error(`${message}\nExpected: ${String(expected)}\nActual: ${String(actual)}`);
}
}
function inspectGitRevision(
artifactRoot: string
): Pick<SourceEvidence, "exactCommit" | "revisionSource" | "workingTreeClean"> {
try {
const exactCommit = execFileSync("git", ["-C", artifactRoot, "rev-parse", "HEAD"], {
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
const status = execFileSync("git", ["-C", artifactRoot, "status", "--porcelain"], {
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
return {
exactCommit,
revisionSource: "git-worktree",
workingTreeClean: status.length === 0,
};
} catch {
const exactCommit = process.env.E2E_OBSIDIAN_ARTIFACT_REVISION?.trim();
if (!exactCommit) {
throw new Error(
"E2E_OBSIDIAN_ARTIFACT_REVISION is required when the plug-in artefact is not in a Git worktree."
);
}
return {
exactCommit,
revisionSource: "provided-artifact",
workingTreeClean: null,
};
}
}
async function inspectSourceEvidence(artifactRoot: string): Promise<SourceEvidence> {
const manifest = JSON.parse(await readFile(join(artifactRoot, "manifest.json"), "utf-8")) as {
version?: unknown;
};
if (typeof manifest.version !== "string" || manifest.version.length === 0) {
throw new Error("The plug-in manifest does not have a version.");
}
const mainJs = await readFile(join(artifactRoot, "main.js"));
return {
...inspectGitRevision(artifactRoot),
pluginVersion: manifest.version,
pluginArtifactSha256: createHash("sha256").update(Uint8Array.from(mainJs)).digest("hex"),
};
}
function e2eeSettings(passphrase: string): Record<string, unknown> {
return {
encrypt: true,
passphrase,
usePathObfuscation: true,
E2EEAlgorithm: "v2",
};
}
async function captureStage(context: RunnerContext, session: ObsidianLiveSyncSession, filename: string): Promise<void> {
const screenshot = await captureObsidianPage(session.remoteDebuggingPort, filename, async () => undefined);
context.screenshots.push(screenshot);
console.log(`Security Seed E2E screenshot: ${screenshot}`);
}
async function startConfiguredSession(
context: RunnerContext,
vault: TemporaryVault,
passphrase: string,
deviceLabel: DeviceLabel
): Promise<ObsidianLiveSyncSession> {
const couchDbSettings = {
uri: context.couchDb.uri,
username: context.couchDb.username,
password: context.couchDb.password,
dbName: context.dbName,
};
const overrides = e2eeSettings(passphrase);
const session = await startObsidianLiveSyncSession({
binary: context.binary,
cliBinary: context.cliBinary,
artifactRoot: context.artifactRoot,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: createE2eCouchDbPluginData(couchDbSettings, overrides),
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
});
context.activeSessions.add(session);
context.allSessions.push(session);
try {
await captureStage(context, session, `security-seed-${deviceLabel}-startup.png`);
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await prepareRemote(context.cliBinary, session.cliEnv);
await captureStage(context, session, `security-seed-${deviceLabel}-configured.png`);
return session;
} catch (error) {
await captureStage(context, session, `security-seed-${deviceLabel}-setup-failure.png`).catch(() => undefined);
await stopTrackedSession(context, session);
throw error;
}
}
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> {
const errors: unknown[] = [];
for (const session of [...context.activeSessions]) {
try {
await stopTrackedSession(context, session);
} catch (error) {
errors.push(error);
}
}
if (errors.length > 0) {
throw new MultipleErrors("Could not stop every Real Obsidian session.", errors);
}
}
async function pauseAutomaticReplication(cliBinary: string, env: NodeJS.ProcessEnv): Promise<ReplicationSettingsState> {
const state = await evalObsidianJson<ReplicationSettingsState>(
cliBinary,
[
"(()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"core.services.replicator.getActiveReplicator()?.closeReplication();",
"const settings=core.services.setting.currentSettings();",
"return JSON.stringify({",
"liveSync:Boolean(settings.liveSync),",
"syncOnStart:Boolean(settings.syncOnStart),",
"syncOnSave:Boolean(settings.syncOnSave),",
"periodicReplication:Boolean(settings.periodicReplication),",
"syncOnFileOpen:Boolean(settings.syncOnFileOpen),",
"syncOnEditorSave:Boolean(settings.syncOnEditorSave),",
"});",
"})()",
].join(""),
env
);
for (const [name, enabled] of Object.entries(state)) {
if (enabled) {
throw new Error(`Automatic replication remained enabled through ${name}.`);
}
}
return state;
}
async function cachedSecuritySeedFingerprint(cliBinary: string, env: NodeJS.ProcessEnv): Promise<string> {
const result = await evalObsidianJson<{ fingerprint: string }>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const settings=core.services.setting.currentSettings();",
"const replicator=core.services.replicator.getActiveReplicator();",
"const seed=await replicator.getReplicationPBKDF2Salt(settings,false);",
"const digest=await crypto.subtle.digest('SHA-256',seed);",
"const fingerprint='sha256:'+Array.from(new Uint8Array(digest))",
".map((value)=>value.toString(16).padStart(2,'0')).join('').slice(0,16);",
"return JSON.stringify({fingerprint});",
"})()",
].join(""),
env
);
return result.fingerprint;
}
async function writeNoteViaObsidian(
cliBinary: string,
env: 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(""),
env
);
}
async function openNoteViaObsidian(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
"const file=app.vault.getAbstractFileByPath(path);",
"if(!file) throw new Error(`Could not find note to open: ${path}`);",
"await app.workspace.getLeaf(false).openFile(file);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env
);
}
async function pathExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return false;
}
throw error;
}
}
async function waitForPathContent(
vaultPath: string,
path: string,
expected: string,
timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 15000)
): Promise<void> {
const fullPath = join(vaultPath, path);
const deadline = Date.now() + timeoutMs;
let lastContent = "";
while (Date.now() < deadline) {
if (await pathExists(fullPath)) {
lastContent = await readFile(fullPath, "utf-8");
if (lastContent === expected) {
return;
}
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Timed out waiting for ${path}. Last content:\n${lastContent}`);
}
function remoteContainsEntry(documents: CouchDbDocument[], entry: LocalDatabaseEntry): boolean {
const ids = new Set(documents.map((document) => document._id));
return ids.has(entry.id) && entry.children.every((childId) => ids.has(childId));
}
async function assertEntryNotRemote(context: RunnerContext, entry: LocalDatabaseEntry): Promise<void> {
const response = await fetchAllCouchDbDocs(context.couchDb, context.dbName);
const documents = response.rows.flatMap((row) => (row.doc ? [row.doc] : []));
if (remoteContainsEntry(documents, entry)) {
throw new Error("The pending device-A document reached CouchDB before the Security Seed replacement.");
}
}
async function waitForEncryptedRemoteEntry(context: RunnerContext, entry: LocalDatabaseEntry): Promise<boolean> {
const documents = await waitForCouchDbDocs(context.couchDb, context.dbName, (docs) =>
remoteContainsEntry(docs, entry)
);
const byId = new Map(documents.map((document) => [document._id, document]));
const encrypted = entry.children.every((childId) => {
const data = byId.get(childId)?.data;
return typeof data === "string" && data.startsWith("%=");
});
if (!encrypted) {
throw new Error("A replicated chunk did not use the expected HKDF-encrypted payload format.");
}
return true;
}
async function inspectSessionHealth(cliBinary: string, env: NodeJS.ProcessEnv): Promise<SessionHealth> {
return await evalObsidianJson<SessionHealth>(
cliBinary,
[
"(async()=>{",
`const patterns=${JSON.stringify(hkdfErrorMessages)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"await core.services.API.showWindow('log-log');",
"const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));",
"let text='';",
"for(let i=0;i<20;i++){",
"text=Array.from(document.querySelectorAll('.logpane .log pre'))",
".map((element)=>element.textContent??'').join('\\n');",
"if(text.length>0) break;",
"await sleep(50);",
"}",
"const unresolved=JSON.stringify((await core.services.appLifecycle.getUnresolvedMessages()).flat());",
"const matchingErrorMessages=patterns.filter((pattern)=>text.includes(pattern)||unresolved.includes(pattern));",
"for(const leaf of app.workspace.getLeavesOfType('log-log')) leaf.detach();",
"return JSON.stringify({matchingErrorMessages});",
"})()",
].join(""),
env
);
}
async function fetchSecuritySeedDocument(context: RunnerContext): Promise<SecuritySeedDocument> {
return requireSecuritySeedDocument(
await fetchCouchDbDocument(context.couchDb, context.dbName, SECURITY_SEED_DOCUMENT_ID)
);
}
async function replaceRemoteSecuritySeed(
context: RunnerContext,
before: SecuritySeedDocument,
replacementSeed: string
): Promise<SecuritySeedDocument> {
const replacement = replaceSecuritySeed(before, replacementSeed);
const putResult = await putCouchDbDocument(context.couchDb, context.dbName, replacement);
const after = await fetchSecuritySeedDocument(context);
assertEqual(after._rev, putResult.rev, "The replacement Security Seed revision was not stored.");
assertEqual(
fingerprintSecuritySeed(after.pbkdf2salt),
fingerprintSecuritySeed(replacementSeed),
"The replacement Security Seed was not stored."
);
const changedFields = changedSynchronisationParameterFields(before, after);
assertEqual(
JSON.stringify(changedFields),
JSON.stringify(["pbkdf2salt"]),
"Replacing the remote Security Seed changed another synchronisation parameter."
);
return after;
}
async function runScenario(
context: RunnerContext,
vaultA: TemporaryVault,
vaultB: TemporaryVault
): Promise<ScenarioEvidence> {
const passphrase = `security-seed-e2e-${randomUUID()}`;
const source = await inspectSourceEvidence(context.artifactRoot);
let sessionA = await startConfiguredSession(context, vaultA, passphrase, "device-a");
await pushLocalChanges(context.cliBinary, sessionA.cliEnv);
await captureStage(context, sessionA, "security-seed-device-a-initial-sync.png");
const initialDocument = await fetchSecuritySeedDocument(context);
const initial = snapshotSecuritySeedDocument(initialDocument);
const cachedBeforeReplacement = await cachedSecuritySeedFingerprint(context.cliBinary, sessionA.cliEnv);
assertEqual(
cachedBeforeReplacement,
initial.fingerprint,
"Device A did not cache the initial remote Security Seed."
);
await pauseAutomaticReplication(context.cliBinary, sessionA.cliEnv);
const outboundContent = `Encrypted from device A: ${randomUUID()}\n`;
await writeNoteViaObsidian(context.cliBinary, sessionA.cliEnv, outboundPath, outboundContent);
const outboundEntry = await waitForLocalDatabaseEntry(context.cliBinary, sessionA.cliEnv, outboundPath);
await assertEntryNotRemote(context, outboundEntry);
const replacementSeed = createSecuritySeed();
const replacementDocument = await replaceRemoteSecuritySeed(context, initialDocument, replacementSeed);
const replacement = snapshotSecuritySeedDocument(replacementDocument);
await openNoteViaObsidian(context.cliBinary, sessionA.cliEnv, outboundPath);
await captureStage(context, sessionA, "security-seed-device-a-replacement-pending.png");
const cachedAfterRemoteReplacement = await cachedSecuritySeedFingerprint(context.cliBinary, sessionA.cliEnv);
assertEqual(
cachedAfterRemoteReplacement,
initial.fingerprint,
"Device A did not retain the deliberately stale Security Seed before replication."
);
assertEqual(
replacement.fingerprint,
fingerprintSecuritySeed(replacementSeed),
"The runner did not install the intended replacement Security Seed."
);
await pushLocalChanges(context.cliBinary, sessionA.cliEnv);
const cachedAfterReplication = await cachedSecuritySeedFingerprint(context.cliBinary, sessionA.cliEnv);
assertEqual(
cachedAfterReplication,
replacement.fingerprint,
"Device A did not refresh the Security Seed before replication."
);
const deviceAEncryptedPayload = await waitForEncryptedRemoteEntry(context, outboundEntry);
await captureStage(context, sessionA, "security-seed-device-a-refreshed-sync.png");
const deviceAHealthBeforeRestart = await inspectSessionHealth(context.cliBinary, sessionA.cliEnv);
await stopTrackedSession(context, sessionA);
const sessionB = await startConfiguredSession(context, vaultB, passphrase, "device-b");
await pushLocalChanges(context.cliBinary, sessionB.cliEnv);
await waitForPathContent(vaultB.path, outboundPath, outboundContent);
await openNoteViaObsidian(context.cliBinary, sessionB.cliEnv, outboundPath);
await captureStage(context, sessionB, "security-seed-device-b-received.png");
await pauseAutomaticReplication(context.cliBinary, sessionB.cliEnv);
const returnContent = `Encrypted from device B: ${randomUUID()}\n`;
await writeNoteViaObsidian(context.cliBinary, sessionB.cliEnv, returnPath, returnContent);
const returnEntry = await waitForLocalDatabaseEntry(context.cliBinary, sessionB.cliEnv, returnPath);
await pushLocalChanges(context.cliBinary, sessionB.cliEnv);
const deviceBEncryptedPayload = await waitForEncryptedRemoteEntry(context, returnEntry);
const deviceBHealth = await inspectSessionHealth(context.cliBinary, sessionB.cliEnv);
await stopTrackedSession(context, sessionB);
sessionA = await startConfiguredSession(context, vaultA, passphrase, "device-a-return");
await pushLocalChanges(context.cliBinary, sessionA.cliEnv);
await waitForPathContent(vaultA.path, returnPath, returnContent);
await openNoteViaObsidian(context.cliBinary, sessionA.cliEnv, returnPath);
await captureStage(context, sessionA, "security-seed-device-a-return-received.png");
const finalDocument = await fetchSecuritySeedDocument(context);
const final = snapshotSecuritySeedDocument(finalDocument);
assertEqual(
final.fingerprint,
replacement.fingerprint,
"A client rolled the remote Security Seed back after reconnecting."
);
const finalChangedFields = changedSynchronisationParameterFields(replacementDocument, finalDocument);
if (finalChangedFields.length > 0) {
throw new Error(
`A client rewrote unexpected synchronisation-parameter fields: ${finalChangedFields.join(", ")}`
);
}
const deviceAHealthAfterRestart = await inspectSessionHealth(context.cliBinary, sessionA.cliEnv);
const deviceAHealth = {
matchingErrorMessages: [
...new Set([
...deviceAHealthBeforeRestart.matchingErrorMessages,
...deviceAHealthAfterRestart.matchingErrorMessages,
]),
],
};
if (deviceAHealth.matchingErrorMessages.length > 0 || deviceBHealth.matchingErrorMessages.length > 0) {
throw new Error(
`HKDF or Security Seed errors were logged: ${JSON.stringify({
deviceA: deviceAHealth.matchingErrorMessages,
deviceB: deviceBHealth.matchingErrorMessages,
})}`
);
}
return {
source,
securitySeed: {
initial,
replacement,
final,
cachedBeforeReplacement,
cachedAfterRemoteReplacement,
cachedAfterReplication,
replacementChangedFields: changedSynchronisationParameterFields(initialDocument, replacementDocument),
finalChangedFields,
},
synchronisation: {
deviceAToDeviceB: true,
deviceBToDeviceA: true,
deviceAEncryptedPayload,
deviceBEncryptedPayload,
},
health: {
deviceA: deviceAHealth,
deviceB: deviceBHealth,
},
screenshots: [...context.screenshots],
};
}
async function cleanupResources(
context: RunnerContext,
vaults: TemporaryVault[],
databaseCreated: boolean
): Promise<TeardownEvidence> {
const errors: unknown[] = [];
try {
await stopTrackedSessions(context);
} catch (error) {
errors.push(error);
}
for (const vault of vaults) {
try {
await vault.dispose();
} catch (error) {
errors.push(error);
}
}
if (databaseCreated) {
try {
await deleteCouchDbDatabase(context.couchDb, context.dbName);
} catch (error) {
errors.push(error);
}
}
const sessionsStopped = context.allSessions.every(
(session) => session.app.process.exitCode !== null || session.app.process.signalCode !== null
);
const vaultRemoved = (await Promise.all(vaults.map(async (vault) => !(await pathExists(vault.path))))).every(
Boolean
);
const profileRemoved = (await Promise.all(vaults.map(async (vault) => !(await pathExists(vault.statePath))))).every(
Boolean
);
let databaseRemoved = !databaseCreated;
if (databaseCreated) {
try {
databaseRemoved = !(await couchDbDatabaseExists(context.couchDb, context.dbName));
} catch (error) {
errors.push(error);
}
}
const evidence = {
sessionsStopped,
vaultRemoved,
profileRemoved,
databaseRemoved,
remainingTrackedSessions: context.activeSessions.size,
};
if (!sessionsStopped || !vaultRemoved || !profileRemoved || !databaseRemoved || context.activeSessions.size > 0) {
errors.push(new Error(`Security Seed E2E teardown was incomplete: ${JSON.stringify(evidence)}`));
}
if (errors.length > 0) {
throw Object.assign(new MultipleErrors("Security Seed E2E teardown failed.", errors), {
evidence,
});
}
return evidence;
}
async function writeResult(result: unknown): Promise<string> {
const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
const resultPath = join(outputDirectory, "security-seed-reconnect-result.json");
await mkdir(outputDirectory, { recursive: true });
await writeFile(resultPath, `${JSON.stringify(result, null, 2)}\n`, "utf-8");
return resultPath;
}
async function main(): Promise<void> {
if (process.env.E2E_OBSIDIAN_KEEP_VAULT === "true" || process.env.E2E_OBSIDIAN_KEEP_COUCHDB === "true") {
throw new Error("The Security Seed reconnect scenario requires strict Vault, profile, and database cleanup.");
}
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
if (!cli.binary) {
throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
}
const artifactRoot = resolve(process.env.E2E_OBSIDIAN_ARTIFACT_ROOT ?? process.cwd());
const couchDb = await loadCouchDbConfig();
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "security-seed-reconnect");
const context: RunnerContext = {
binary,
cliBinary: cli.binary,
artifactRoot,
couchDb,
dbName,
activeSessions: new Set(),
allSessions: [],
screenshots: [],
};
const vaults: TemporaryVault[] = [];
let databaseCreated = false;
let evidence: ScenarioEvidence | undefined;
let scenarioError: unknown;
let teardown: TeardownEvidence | undefined;
let teardownError: unknown;
try {
await assertCouchDbReachable(couchDb);
await createCouchDbDatabase(couchDb, dbName);
databaseCreated = true;
vaults.push(await createTemporaryVault("obsidian-livesync-security-seed-a-"));
vaults.push(await createTemporaryVault("obsidian-livesync-security-seed-b-"));
evidence = await runScenario(context, vaults[0], vaults[1]);
} catch (error) {
scenarioError = error;
} finally {
try {
teardown = await cleanupResources(context, vaults, databaseCreated);
} catch (error) {
teardownError = error;
}
}
if (scenarioError !== undefined || teardownError !== undefined) {
const errors = [scenarioError, teardownError].filter((error) => error !== undefined);
if (errors.length === 1) {
throw errors[0];
}
throw new MultipleErrors("Security Seed reconnect scenario and teardown both failed.", errors);
}
if (!evidence || !teardown) {
throw new Error("Security Seed reconnect evidence was not produced.");
}
const result = {
scenario: "security-seed-reconnect",
...evidence,
teardown,
limitations: {
platformCommonRealObsidian: true,
iPadOsBackgroundReconnect: false,
androidDeviceLifecycle: false,
},
};
const resultPath = await writeResult(result);
console.log(`Security Seed E2E result: ${resultPath}`);
console.log(JSON.stringify(result, null, 2));
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exitCode = 1;
});
+294
View File
@@ -0,0 +1,294 @@
import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
import { assertMobileDialogueLayout, setObsidianMobileTestMode } from "../runner/mobileUi.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { captureObsidianDialogue, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETTINGS_TIMEOUT_MS ?? 10000);
const compatibilityReviewMessage = "Review the internal database compatibility change before synchronisation resumes.";
type ObsidianSettingsController = {
open(): void;
openTabById(tabId: string): void;
};
type LiveSyncTestPlugin = {
core: {
services: {
setting: {
currentSettings(): { versionUpFlash: string };
getSmallConfig(key: string): string | null;
};
};
};
};
type ObsidianTestApp = {
setting?: ObsidianSettingsController;
plugins?: { plugins: Record<string, LiveSyncTestPlugin | undefined> };
};
type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp };
async function verifyCompatibilityReview(): Promise<void> {
const port = obsidianRemoteDebuggingPort();
const summaryScreenshot = await captureObsidianDialogue(port, "compatibility-review-summary.png", async (page) => {
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Synchronisation paused for compatibility review",
}),
});
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal
.getByText("Your automatic synchronisation preferences have not been changed.", { exact: false })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal
.getByRole("button", { name: "Review compatibility details" })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal
.getByRole("button", {
name: "Resume synchronisation",
})
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal
.getByRole("button", { name: "Keep synchronisation paused" })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
});
await withObsidianPage(port, async (page) => {
const markerBeforeAcknowledgement = await page.evaluate(() => {
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
if (plugin === undefined) throw new Error("Self-hosted LiveSync is unavailable");
return plugin.core.services.setting.getSmallConfig("database-compatibility-version");
});
if (markerBeforeAcknowledgement !== null && markerBeforeAcknowledgement !== "") {
throw new Error(
`The database version was marked as acknowledged before review: ${markerBeforeAcknowledgement}`
);
}
});
await setObsidianMobileTestMode(port, true, uiTimeoutMs);
const mobileSummaryScreenshot = await captureObsidianDialogue(
port,
"compatibility-review-summary-mobile.png",
async (page) => {
const summary = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Synchronisation paused for compatibility review",
}),
});
await summary.waitFor({ state: "visible", timeout: uiTimeoutMs });
await assertMobileDialogueLayout(page, summary, "compatibility review summary");
const doctor = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Self-hosted LiveSync Config Doctor" }),
});
if (await doctor.isVisible()) {
throw new Error("Config Doctor must wait until the initial compatibility review has closed.");
}
}
);
await withObsidianPage(port, async (page) => {
const summary = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Synchronisation paused for compatibility review",
}),
});
await summary.getByRole("button", { name: "Review compatibility details" }).click();
});
const detailsScreenshot = await captureObsidianDialogue(
port,
"compatibility-review-details-mobile.png",
async (page) => {
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Compatibility review details" }),
});
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal.getByText("Why synchronisation is paused", { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await modal.getByText("Remote replication is blocked before work begins.", { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await modal
.getByRole("button", { name: "Back to compatibility review" })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
if ((await modal.getByRole("button", { name: "Keep synchronisation paused" }).count()) !== 0) {
throw new Error("The explanatory details dialogue must not make the pause decision.");
}
await assertMobileDialogueLayout(page, modal, "compatibility review details");
}
);
await withObsidianPage(port, async (page) => {
const details = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Compatibility review details" }),
});
await details.getByRole("button", { name: "Back to compatibility review" }).click();
const summary = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Synchronisation paused for compatibility review",
}),
});
await summary.waitFor({ state: "visible", timeout: uiTimeoutMs });
});
await setObsidianMobileTestMode(port, false, uiTimeoutMs);
await withObsidianPage(port, async (page) => {
const summary = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Synchronisation paused for compatibility review",
}),
});
await summary
.getByRole("button", {
name: "Resume synchronisation",
})
.click();
await summary.waitFor({ state: "hidden", timeout: uiTimeoutMs });
await page.waitForFunction(
(expectedVersion) => {
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
if (plugin === undefined) return false;
const setting = plugin.core.services.setting;
return (
setting.getSmallConfig("database-compatibility-version") === expectedVersion &&
setting.currentSettings().versionUpFlash === ""
);
},
`${VER}`,
{ timeout: uiTimeoutMs }
);
});
console.log(
`Compatibility review screenshots: ${summaryScreenshot}, ${mobileSummaryScreenshot}, ${detailsScreenshot}`
);
}
async function verifyConfigDoctorFollowsCompatibilityReview(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const doctor = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Self-hosted LiveSync Config Doctor" }),
});
await doctor.waitFor({ state: "visible", timeout: uiTimeoutMs });
await doctor.getByText("Per-file-saved customization sync", { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await doctor.getByText("Enhance chunk size", { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
if ((await doctor.getByText("Data Compression", { exact: true }).count()) !== 0) {
throw new Error("Config Doctor still treats supported Data Compression as a problem.");
}
await doctor.getByRole("button", { name: /No, and do not ask again/u }).click();
await doctor.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
}
async function verifyEffectiveSettings(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
await page.evaluate(() => {
const setting = (globalThis as ObsidianTestGlobal).app?.setting;
if (setting === undefined) throw new Error("Obsidian settings are unavailable");
setting.open();
setting.openTabById("obsidian-livesync");
});
const liveSyncSettings = page.locator(".sls-setting");
await liveSyncSettings.waitFor({ state: "visible", timeout: uiTimeoutMs });
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Change Log"]').click();
const removedAcknowledgements = liveSyncSettings.getByRole("button", {
name: /I got it and updated|OK, I have read everything/u,
});
if ((await removedAcknowledgements.count()) !== 0) {
throw new Error("The Change Log still contains a compatibility or release-note acknowledgement control.");
}
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Remote Configuration"]').click();
const connectionPanel = liveSyncSettings
.locator("h4.sls-setting-panel-title")
.filter({ hasText: "Connection settings" })
.locator("..");
await connectionPanel.waitFor({ state: "visible", timeout: uiTimeoutMs });
await connectionPanel.getByText("Saved connections", { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Sync Settings"]').click();
const deletionPanel = liveSyncSettings
.locator("h4.sls-setting-panel-title")
.filter({ hasText: "Deletion Propagation" })
.locator("..");
await deletionPanel
.getByText("Keep empty folder", { exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
// Retirement guard: the removed toggle must not reappear in the current settings pane.
const obsoleteToggleCount = await deletionPanel.getByText("Use the trash bin", { exact: true }).count();
if (obsoleteToggleCount !== 0) {
throw new Error(
`The obsolete LiveSync trash toggle is still present in the settings UI (${obsoleteToggleCount} found).`
);
}
});
}
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();
let session: ObsidianLiveSyncSession | undefined;
try {
session = await startObsidianLiveSyncSession({
binary,
cliBinary: cli.binary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: {
doctorProcessedVersion: "0.25.27",
isConfigured: true,
liveSync: false,
versionUpFlash: compatibilityReviewMessage,
notifyThresholdOfRemoteStorageSize: 0,
syncOnStart: false,
syncOnSave: false,
syncOnEditorSave: false,
syncOnFileOpen: false,
syncAfterMerge: false,
periodicReplication: false,
handleFilenameCaseSensitive: false,
useAdvancedMode: true,
useEdgeCaseMode: true,
},
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
await verifyCompatibilityReview();
await verifyConfigDoctorFollowsCompatibilityReview();
await verifyEffectiveSettings();
console.log("Compatibility review and settings expose only effective user controls.");
} 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);
});
@@ -0,0 +1,859 @@
import { execFile } from "node:child_process";
import { randomBytes } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { Locator, Page } from "playwright";
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,
type LocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import {
assertVerticalActionLayout,
generateSetupURIFromDevice,
resumeCompatibilityReviewIfShown,
} from "../runner/setupUri.ts";
import {
captureObsidianDialogue,
captureObsidianElement,
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 execFileAsync = promisify(execFile);
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_URI_TIMEOUT_MS ?? 30000);
const initialisationTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_INITIALISATION_TIMEOUT_MS ?? 120000);
const hiddenFileCliTimeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_CLI_TIMEOUT_MS ?? 90000);
const notePath = "E2E/setup-uri/provisioned-workflow.md";
const noteContent = "# Provisioned Setup URI\n\nThis note travelled through the generated CouchDB Setup URI.\n";
const returnNotePath = "E2E/setup-uri/from-second-device.md";
const returnNoteContent =
"# CouchDB from the second device\n\nThis note completed the return journey through CouchDB.\n";
const snippetPath = ".obsidian/snippets/setup-uri-workflow.css";
const snippetContent = [
"body {",
" --setup-uri-workflow-colour: #245a70;",
"}",
"",
".setup-uri-workflow {",
" color: var(--setup-uri-workflow-colour);",
"}",
"",
].join("\n");
type SetupArtifact = {
setupURI: string;
setupPassphrase: string;
};
type SetupState = {
configured: boolean;
databaseReady: boolean;
appReady: boolean;
suspended: boolean;
remoteType: string;
activeConfigurationId: string;
remoteConfigurationCount: number;
syncInternalFiles: boolean;
syncInternalFilesBeforeReplication: boolean;
};
type RunnerContext = {
binary: string;
cliBinary: string;
couchDb: CouchDbConfig;
dbName: string;
activeSessions: Set<ObsidianLiveSyncSession>;
};
function modalByTitle(page: Page, title: string): Locator {
return page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: title }),
});
}
function settingPanelByTitle(page: Page, title: string): Locator {
return page
.locator(".sls-setting")
.locator("h4.sls-setting-panel-title:visible")
.filter({ hasText: title })
.locator("..");
}
async function captureGuideDialogue(port: number, filename: string, title: string): Promise<string> {
return await captureObsidianElement(port, filename, (page) => modalByTitle(page, title).locator(".modal").first());
}
async function selectRadioOption(modal: Locator, title: string): Promise<void> {
const radio = modal.locator("label").filter({ hasText: title }).locator('input[type="radio"]').first();
await radio.check({ timeout: uiTimeoutMs });
}
async function selectCheckbox(modal: Locator, title: string): Promise<void> {
const checkbox = modal.locator("label").filter({ hasText: title }).locator('input[type="checkbox"]').first();
await checkbox.check({ timeout: uiTimeoutMs });
}
async function writeVaultFile(vaultPath: string, path: string, content: string): Promise<void> {
const fullPath = join(vaultPath, path);
await mkdir(dirname(fullPath), { recursive: true });
await writeFile(fullPath, content, "utf8");
}
async function readVaultFile(vaultPath: string, path: string): Promise<string> {
return await readFile(join(vaultPath, path), "utf8");
}
async function waitForPathContent(
vaultPath: string,
path: string,
expected: string,
timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 30000)
): Promise<string> {
const deadline = Date.now() + timeoutMs;
let lastContent = "";
while (Date.now() < deadline) {
try {
lastContent = await readVaultFile(vaultPath, path);
if (lastContent === expected) return lastContent;
} 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 runDeno(script: string, permissions: string[], environment: NodeJS.ProcessEnv): Promise<string> {
const { stdout } = await execFileAsync(
"deno",
[
"run",
"--minimum-dependency-age=0",
"--config=utils/flyio/deno.jsonc",
"--frozen",
"--lock=utils/flyio/deno.lock",
...permissions,
script,
],
{
cwd: process.cwd(),
env: environment,
maxBuffer: 4 * 1024 * 1024,
}
);
return stdout;
}
async function provisionAndGenerateSetupURI(couchDb: CouchDbConfig, dbName: string): Promise<SetupArtifact> {
const setupPassphrase = randomBytes(24).toString("base64url");
const environment = {
...process.env,
hostname: couchDb.uri,
username: couchDb.username,
password: couchDb.password,
database: dbName,
passphrase: randomBytes(24).toString("base64url"),
uri_passphrase: setupPassphrase,
remote_type: "couchdb",
retry_count: "3",
retry_delay_ms: "250",
};
await runDeno("utils/couchdb/provision.ts", ["--allow-env", "--allow-net"], environment);
const output = await runDeno("utils/setup/generate_setup_uri.ts", ["--allow-env"], environment);
const setupURI = output.split(/\r?\n/u).find((line) => line.startsWith("obsidian://setuplivesync?settings="));
if (!setupURI) throw new Error("The public Setup URI generator did not emit a Setup URI.");
return { setupURI, setupPassphrase };
}
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 enterSetupURI(port: number, mode: "new" | "existing", artifact: SetupArtifact): Promise<void> {
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 });
if (mode === "new") {
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 });
} else {
await selectRadioOption(intro, "I am adding a device to an existing synchronisation setup");
await intro
.getByRole("button", { name: "Yes, I want to add this device to my existing synchronisation" })
.click({ timeout: uiTimeoutMs });
}
const method = modalByTitle(page, mode === "new" ? "Connection Method" : "Device Setup Method");
await method.waitFor({ state: "visible", timeout: uiTimeoutMs });
await selectRadioOption(method, "Use a Setup URI (Recommended)");
await method.getByRole("button", { name: "Proceed with Setup URI" }).click({ timeout: uiTimeoutMs });
const setup = modalByTitle(page, "Enter Setup URI");
await setup.waitFor({ state: "visible", timeout: uiTimeoutMs });
await setup.locator('input[placeholder^="obsidian://setuplivesync"]').fill(artifact.setupURI);
await setup.locator('input[name="password"]').fill(artifact.setupPassphrase);
});
await captureGuideDialogue(
port,
`guide-quick-setup-${mode === "new" ? "first" : "second"}-setup-uri.png`,
"Enter Setup URI"
);
await withObsidianPage(port, async (page) => {
await modalByTitle(page, "Enter Setup URI")
.getByRole("button", { name: "Test Settings and Continue" })
.click({ timeout: uiTimeoutMs });
});
}
async function captureAndStartInitialisation(port: number, mode: "new" | "existing"): Promise<string> {
const title =
mode === "new"
? "Setup Complete: Preparing to Initialise Server"
: "Setup Complete: Preparing to Fetch Synchronisation Data";
const button = mode === "new" ? "Restart and Initialise Server" : "Restart and Fetch Data";
const screenshot = await captureObsidianDialogue(
port,
`setup-uri-${mode === "new" ? "first-initialise" : "second-fetch"}.png`,
async (page) => {
await modalByTitle(page, title).waitFor({ state: "visible", timeout: uiTimeoutMs });
}
);
await captureGuideDialogue(
port,
`guide-quick-setup-${mode === "new" ? "first-initialise" : "second-fetch"}.png`,
title
);
await withObsidianPage(port, async (page) => {
await modalByTitle(page, title).getByRole("button", { name: button }).click({ timeout: uiTimeoutMs });
});
return screenshot;
}
async function confirmRebuild(port: number): Promise<string> {
const title = "Final Confirmation: Overwrite Server Data with This Device's Files";
const screenshot = await captureObsidianDialogue(port, "setup-uri-first-rebuild-confirmation.png", async (page) => {
await modalByTitle(page, title).waitFor({ state: "visible", timeout: uiTimeoutMs });
});
await captureGuideDialogue(port, "guide-quick-setup-first-rebuild-confirmation.png", title);
await withObsidianPage(port, async (page) => {
const modal = modalByTitle(page, title);
await selectCheckbox(
modal,
"I understand that all changes made on other smartphones or computers possibly could be lost."
);
await selectCheckbox(
modal,
"I understand that other devices will no longer be able to synchronise, and will need to be reset the synchronisation information."
);
await selectCheckbox(modal, "I understand that this action is irreversible once performed.");
await selectRadioOption(modal, "I understand the risks and will proceed without a backup.");
await modal.getByRole("button", { name: "I Understand, Overwrite Server" }).click({ timeout: uiTimeoutMs });
});
return screenshot;
}
async function skipMissingRemoteConfiguration(port: number): Promise<string> {
const title = "Fetch Remote Configuration Failed";
const screenshot = await captureObsidianDialogue(
port,
"setup-uri-first-missing-remote-configuration.png",
async (page) => {
const modal = modalByTitle(page, title);
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal
.getByText("If you are new to the Self-hosted LiveSync, this might be expected.", {
exact: false,
})
.waitFor({ state: "visible", timeout: uiTimeoutMs });
}
);
await captureGuideDialogue(port, "guide-quick-setup-missing-remote-configuration.png", title);
await withObsidianPage(port, async (page) => {
await modalByTitle(page, title)
.getByRole("button", { name: "Skip and proceed" })
.click({ timeout: uiTimeoutMs });
});
return screenshot;
}
async function acknowledgeDisabledOptionalFeatures(port: number): Promise<string> {
const title = "All optional features are disabled";
const screenshot = await captureObsidianDialogue(
port,
"setup-uri-first-optional-features-disabled.png",
async (page) => {
const modal = modalByTitle(page, title);
await modal.waitFor({ state: "visible", timeout: initialisationTimeoutMs });
await modal
.getByText("Please enable them from the settings screen after setup is complete.", {
exact: false,
})
.waitFor({ state: "visible", timeout: uiTimeoutMs });
}
);
await captureGuideDialogue(port, "guide-quick-setup-optional-features-disabled.png", title);
await withObsidianPage(port, async (page) => {
await modalByTitle(page, title).getByRole("button", { name: "OK" }).click({ timeout: uiTimeoutMs });
});
return screenshot;
}
async function confirmFastFetch(port: number): Promise<string[]> {
const firstTitle = "Data retrieval scheduled";
await assertVerticalActionLayout(port, firstTitle);
const firstScreenshot = await captureObsidianDialogue(
port,
"setup-uri-second-retrieval-method.png",
async (page) => {
await modalByTitle(page, firstTitle).waitFor({ state: "visible", timeout: uiTimeoutMs });
}
);
await captureGuideDialogue(port, "guide-quick-setup-retrieval-method.png", firstTitle);
await withObsidianPage(port, async (page) => {
await modalByTitle(page, firstTitle)
.getByRole("button", { name: "Overwrite all with remote files" })
.click({ timeout: uiTimeoutMs });
});
const secondTitle = "How to handle extra existing local files?";
await assertVerticalActionLayout(port, secondTitle);
const secondScreenshot = await captureObsidianDialogue(
port,
"setup-uri-second-local-file-policy.png",
async (page) => {
await modalByTitle(page, secondTitle).waitFor({ state: "visible", timeout: uiTimeoutMs });
}
);
await captureGuideDialogue(port, "guide-quick-setup-local-file-policy.png", secondTitle);
await withObsidianPage(port, async (page) => {
await modalByTitle(page, secondTitle)
.getByRole("button", { name: "Keep local files even if not on remote" })
.click({ timeout: uiTimeoutMs });
});
return [firstScreenshot, secondScreenshot];
}
function isConfiguredSetupReady(state: SetupState): boolean {
return (
state.configured &&
state.databaseReady &&
state.appReady &&
!state.suspended &&
state.activeConfigurationId !== "" &&
state.remoteConfigurationCount === 1
);
}
async function finishInitialisation(
port: number,
filename: string,
cliBinary: string,
environment: NodeJS.ProcessEnv
): Promise<{ state: SetupState; screenshot?: string }> {
const message = "Do you want to resume file and database processing, and restart obsidian now?";
const deadline = Date.now() + initialisationTimeoutMs;
let lastState: SetupState | undefined;
let lastError: unknown;
while (Date.now() < deadline) {
const resumeVisible = await withObsidianPage(port, async (page) => {
return await modalByTitle(page, "Confirmation").filter({ hasText: message }).isVisible();
}).catch(() => false);
if (resumeVisible) {
const screenshot = await captureObsidianDialogue(port, filename, async (page) => {
await modalByTitle(page, "Confirmation")
.filter({ hasText: message })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
});
await withObsidianPage(port, async (page) => {
const modal = modalByTitle(page, "Confirmation").filter({ hasText: message });
await modal.getByText(message, { exact: true }).click({ timeout: uiTimeoutMs });
await modal.getByRole("button", { name: "Yes", exact: true }).click({ timeout: uiTimeoutMs });
});
return {
state: await waitForConfiguredSetup(cliBinary, environment, initialisationTimeoutMs),
screenshot,
};
}
try {
lastState = await readSetupState(cliBinary, environment);
if (isConfiguredSetupReady(lastState)) return { state: lastState };
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(
`Timed out waiting for Setup URI initialisation to finish: ${JSON.stringify(lastState)}${
lastError instanceof Error ? `; last error: ${lastError.message}` : ""
}`
);
}
async function readSetupState(cliBinary: string, environment: NodeJS.ProcessEnv): Promise<SetupState> {
return await evalObsidianJson<SetupState>(
cliBinary,
[
"(()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const settings=core.services.setting.currentSettings();",
"return JSON.stringify({",
"configured:settings.isConfigured===true,",
"databaseReady:core.services.database.isDatabaseReady(),",
"appReady:core.services.appLifecycle.isReady(),",
"suspended:core.services.appLifecycle.isSuspended(),",
"remoteType:settings.remoteType,",
"activeConfigurationId:settings.activeConfigurationId||'',",
"remoteConfigurationCount:Object.keys(settings.remoteConfigurations||{}).length,",
"syncInternalFiles:settings.syncInternalFiles===true,",
"syncInternalFilesBeforeReplication:settings.syncInternalFilesBeforeReplication===true,",
"});",
"})()",
].join(""),
environment
);
}
async function waitForConfiguredSetup(
cliBinary: string,
environment: NodeJS.ProcessEnv,
timeoutMs = initialisationTimeoutMs
): Promise<SetupState> {
const deadline = Date.now() + timeoutMs;
let lastState: SetupState | undefined;
let lastError: unknown;
while (Date.now() < deadline) {
try {
lastState = await readSetupState(cliBinary, environment);
if (isConfiguredSetupReady(lastState)) {
return lastState;
}
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(
`Timed out waiting for configured Setup URI state: ${JSON.stringify(lastState)}${
lastError instanceof Error ? `; last error: ${lastError.message}` : ""
}`
);
}
async function enableHiddenFileSync(cliBinary: string, environment: NodeJS.ProcessEnv): Promise<SetupState> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"await core.services.setting.applyPartial({",
"syncInternalFiles:true,",
"syncInternalFilesBeforeReplication:true,",
"},true);",
"await core.services.control.applySettings();",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
environment
);
const state = await waitForConfiguredSetup(cliBinary, environment);
if (!state.syncInternalFiles || !state.syncInternalFilesBeforeReplication) {
throw new Error(`Hidden File Sync was not enabled after setup: ${JSON.stringify(state)}`);
}
return state;
}
async function captureHiddenFileGuideSettings(
port: number,
cliBinary: string,
environment: NodeJS.ProcessEnv
): Promise<string[]> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"await core.services.setting.applyPartial({",
"useAdvancedMode:true,",
"syncInternalFilesTargetPatterns:'^\\\\.obsidian(?:$|/snippets(?:/|$))',",
"},true);",
"await core.services.control.applySettings();",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
environment
);
await withObsidianPage(port, async (page) => {
await page.evaluate(() => {
const obsidian = globalThis as typeof globalThis & {
app?: {
setting?: {
open(): void;
openTabById(tabId: string): void;
};
};
};
const setting = obsidian.app?.setting;
if (!setting) throw new Error("Obsidian settings are unavailable");
setting.open();
setting.openTabById("obsidian-livesync");
});
const settings = page.locator(".sls-setting");
await settings.waitFor({ state: "visible", timeout: uiTimeoutMs });
await settings.locator('.sls-setting-menu-btn[title="Setup"]').click({ timeout: uiTimeoutMs });
});
const screenshots = [
await captureObsidianElement(port, "guide-hidden-file-advanced-features.png", (page) =>
settingPanelByTitle(page, "Enable extra and advanced features")
),
];
await withObsidianPage(port, async (page) => {
await page
.locator(".sls-setting")
.locator('.sls-setting-menu-btn[title="Selector"]')
.click({ timeout: uiTimeoutMs });
});
screenshots.push(
await captureObsidianElement(port, "guide-hidden-file-selector.png", (page) =>
settingPanelByTitle(page, "Hidden Files")
)
);
await withObsidianPage(port, async (page) => {
await page
.locator(".sls-setting")
.locator('.sls-setting-menu-btn[title="Sync Settings"]')
.click({ timeout: uiTimeoutMs });
});
screenshots.push(
await captureObsidianElement(port, "guide-hidden-file-enable.png", (page) =>
settingPanelByTitle(page, "Hidden Files")
)
);
await withObsidianPage(port, async (page) => {
await page.keyboard.press("Escape");
});
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 scanHiddenStorage(cliBinary: string, environment: NodeJS.ProcessEnv): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const addOn=core.getAddOn('HiddenFileSync');",
"await addOn.scanAllStorageChanges(true);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
environment,
hiddenFileCliTimeoutMs
);
}
async function scanHiddenDatabase(cliBinary: string, environment: NodeJS.ProcessEnv): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const addOn=core.getAddOn('HiddenFileSync');",
"await addOn.scanAllDatabaseChanges(true);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
environment,
hiddenFileCliTimeoutMs
);
}
async function waitForRemoteEntry(context: RunnerContext, entry: LocalDatabaseEntry): 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 uploadWorkflowFiles(
context: RunnerContext,
session: ObsidianLiveSyncSession,
vault: TemporaryVault
): Promise<void> {
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, notePath, noteContent);
await writeVaultFile(vault.path, snippetPath, snippetContent);
await scanHiddenStorage(context.cliBinary, session.cliEnv);
const noteEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, notePath);
const snippetEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, snippetPath, {
hidden: true,
});
await pushLocalChanges(context.cliBinary, session.cliEnv);
await waitForRemoteEntry(context, noteEntry);
await waitForRemoteEntry(context, snippetEntry);
}
async function captureSynchronisedNote(port: number): Promise<string> {
await withObsidianPage(port, async (page) => {
await page.evaluate((path) => {
const obsidian = globalThis as typeof globalThis & {
app?: {
workspace?: { openLinkText(path: string, sourcePath: string, newLeaf: boolean): Promise<void> };
};
};
return obsidian.app?.workspace?.openLinkText(path, "", false);
}, notePath);
});
await captureObsidianPage(port, "setup-uri-synchronised-note.png", async (page) => {
await page.getByText("Provisioned Setup URI", { exact: false }).first().waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
});
return await captureObsidianElement(port, "guide-quick-setup-synchronised-note.png", (page) =>
page.locator(".workspace-leaf.mod-active").first()
);
}
async function captureFailure(session: ObsidianLiveSyncSession): Promise<void> {
await captureObsidianPage(
session.remoteDebuggingPort,
"setup-uri-workflow.failure.png",
async () => undefined
).catch(() => undefined);
}
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, "setup-uri-workflow");
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);
const artifact = await provisionAndGenerateSetupURI(couchDb, dbName);
const provisionedDocs = await waitForCouchDbDocs(couchDb, dbName, (docs) =>
docs.some((doc) => doc._id === "obsydian_livesync_version" && doc.version === VER)
);
if (!provisionedDocs.some((doc) => doc._id === "obsydian_livesync_version" && doc.version === VER)) {
throw new Error("The public provisioning tool did not initialise the Commonlib database version.");
}
console.log(`Using Obsidian executable: ${binary}`);
console.log(`Temporary vault A: ${vaultA.path}`);
console.log(`Temporary vault B: ${vaultB.path}`);
console.log(`Temporary provisioned CouchDB database: ${dbName}`);
let session = await startUnconfiguredSession(context, vaultA);
try {
await enterSetupURI(session.remoteDebuggingPort, "new", artifact);
screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "new"));
screenshots.push(await confirmRebuild(session.remoteDebuggingPort));
screenshots.push(await skipMissingRemoteConfiguration(session.remoteDebuggingPort));
screenshots.push(await acknowledgeDisabledOptionalFeatures(session.remoteDebuggingPort));
const firstCompletion = await finishInitialisation(
session.remoteDebuggingPort,
"setup-uri-first-initialisation-complete.png",
context.cliBinary,
session.cliEnv
);
if (firstCompletion.screenshot) screenshots.push(firstCompletion.screenshot);
const firstState = firstCompletion.state;
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
assertEqual(firstState.remoteType, "", "The first device did not activate the CouchDB remote profile.");
assertEqual(
firstState.syncInternalFiles,
false,
"Rebuild did not retain the documented optional-feature safety boundary."
);
screenshots.push(
...(await captureHiddenFileGuideSettings(
session.remoteDebuggingPort,
context.cliBinary,
session.cliEnv
))
);
await enableHiddenFileSync(context.cliBinary, session.cliEnv);
await uploadWorkflowFiles(context, session, vaultA);
const generated = await generateSetupURIFromDevice(
session.remoteDebuggingPort,
randomBytes(24).toString("base64url"),
{ scenario: "setup-uri-workflow", guide: "quick-setup" }
);
if (generated.artifact.setupURI === artifact.setupURI) {
throw new Error("The first device returned the bootstrap Setup URI instead of generating a new one.");
}
secondDeviceArtifact = generated.artifact;
screenshots.push(...generated.screenshots);
} catch (error) {
await captureFailure(session);
throw error;
} finally {
await stopTrackedSession(context, session);
}
session = await startUnconfiguredSession(context, vaultB);
try {
if (!secondDeviceArtifact)
throw new Error("The first device did not generate the second-device Setup URI.");
await enterSetupURI(session.remoteDebuggingPort, "existing", secondDeviceArtifact);
screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "existing"));
screenshots.push(...(await confirmFastFetch(session.remoteDebuggingPort)));
const secondCompletion = await finishInitialisation(
session.remoteDebuggingPort,
"setup-uri-second-initialisation-complete.png",
context.cliBinary,
session.cliEnv
);
if (secondCompletion.screenshot) screenshots.push(secondCompletion.screenshot);
const secondState = secondCompletion.state;
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
assertEqual(secondState.remoteType, "", "The second device did not activate the CouchDB remote profile.");
await enableHiddenFileSync(context.cliBinary, session.cliEnv);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await scanHiddenDatabase(context.cliBinary, session.cliEnv);
const receivedNote = await waitForPathContent(vaultB.path, notePath, noteContent);
const receivedSnippet = await waitForPathContent(vaultB.path, snippetPath, snippetContent);
assertEqual(receivedNote, noteContent, "The ordinary note did not reach the second Setup URI device.");
assertEqual(
receivedSnippet,
snippetContent,
"The hidden snippet did not reach the second Setup URI device."
);
screenshots.push(await captureSynchronisedNote(session.remoteDebuggingPort));
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);
throw error;
} finally {
await stopTrackedSession(context, session);
}
session = await startUnconfiguredSession(context, vaultA);
try {
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
await pushLocalChanges(context.cliBinary, session.cliEnv);
const receivedReturnNote = await waitForPathContent(vaultA.path, returnNotePath, returnNoteContent);
assertEqual(
receivedReturnNote,
returnNoteContent,
"The second device's ordinary note did not return to the first Setup URI device."
);
} catch (error) {
await captureFailure(session);
throw error;
} finally {
await stopTrackedSession(context, session);
}
console.log(
`The public provisioning and first-device-generated Setup URI workflow configured two fresh devices, completed an ordinary-note round-trip, and synchronised a hidden snippet. 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);
});
+9
View File
@@ -1,4 +1,8 @@
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertObsidianServiceContextContract,
inspectObsidianServiceContextContract,
} from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { createTemporaryVault } from "../runner/vault.ts";
@@ -25,6 +29,11 @@ async function main(): Promise<void> {
console.log(
`Obsidian plug-in ready: ${readiness.pluginId}@${readiness.pluginVersion} in ${readiness.vaultName}`
);
const contextContract = await inspectObsidianServiceContextContract(cli.binary, session.cliEnv);
assertObsidianServiceContextContract(contextContract);
console.log(
`Obsidian service Context contract passed: ${contextContract.contextType}, ${contextContract.serviceContextMismatches.length} mismatches.`
);
await new Promise((resolve) => setTimeout(resolve, Number(process.env.E2E_OBSIDIAN_SMOKE_TIMEOUT_MS ?? 1000)));
console.log("Obsidian stayed alive after the plug-in readiness check.");
} finally {
+24 -10
View File
@@ -1,3 +1,13 @@
/**
* Proves that a configured LiveSync Vault scans files created while Obsidian
* was stopped. The first launch receives a CouchDB profile using current
* settings and its acknowledged device-local compatibility marker before the
* plug-in loads.
*
* The second launch reuses the same Vault, profile, local database, and
* settings without rewriting plug-in data, so the assertion covers an
* ordinary configured restart rather than the separate onboarding flow.
*/
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import {
@@ -11,7 +21,8 @@ import {
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertEqual,
configureCouchDb,
createE2eCouchDbPluginData,
createE2eObsidianDeviceLocalState,
prepareRemote,
pushLocalChanges,
waitForLiveSyncCoreReady,
@@ -47,6 +58,12 @@ async function main(): Promise<void> {
const couchDb = await loadCouchDbConfig();
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "startup-scan");
const couchDbSettings = {
uri: couchDb.uri,
username: couchDb.username,
password: couchDb.password,
dbName,
};
const vault = await createTemporaryVault();
let session: ObsidianLiveSyncSession | undefined;
@@ -63,15 +80,11 @@ async function main(): Promise<void> {
cliBinary: cli.binary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: createE2eCouchDbPluginData(couchDbSettings),
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
const configured = await configureCouchDb(cli.binary, session.cliEnv, {
uri: couchDb.uri,
username: couchDb.username,
password: couchDb.password,
dbName,
});
assertEqual(configured.isConfigured, true, "Self-hosted LiveSync was not configured.");
const initialReadiness = await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
assertEqual(initialReadiness.configured, true, "Self-hosted LiveSync did not start configured.");
await prepareRemote(cli.binary, session.cliEnv);
await session.app.stop();
session = undefined;
@@ -84,7 +97,8 @@ async function main(): Promise<void> {
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
const restartedReadiness = await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
assertEqual(restartedReadiness.configured, true, "Self-hosted LiveSync lost its configuration on restart.");
const localEntry = await waitForLocalDatabaseEntry(cli.binary, session.cliEnv, notePath);
await pushLocalChanges(cli.binary, session.cliEnv);
+591 -74
View File
@@ -11,11 +11,16 @@ import {
type CouchDbConfig,
} from "../runner/couchdb.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { waitForExactCaseOnlyRename } from "../runner/pathAssertions.ts";
import {
assertEqual,
assertE2eCompatibilityMarker,
assertE2eCompatibilityReviewPending,
configureCouchDb,
createE2eCouchDbPluginData,
prepareRemote,
pushLocalChanges,
resumeCompatibilityReview,
waitForLiveSyncCoreReady,
waitForLocalDatabaseEntry,
type LocalDatabaseEntry,
@@ -31,7 +36,15 @@ const updatePath = "E2E/two-vault/update.md";
const deletePath = "E2E/two-vault/delete.md";
const renameFromPath = "E2E/two-vault/rename-source.md";
const renameToPath = "E2E/two-vault/renamed/rename-target.md";
const caseRenameFromPath = "E2E/two-vault/Case-Rename.md";
const caseRenameToPath = "E2E/two-vault/case-rename.md";
const conflictPath = "E2E/two-vault/conflict.md";
const conflictEditPath = "E2E/two-vault/conflict-operations/edit.md";
const conflictDeletePath = "E2E/two-vault/conflict-operations/delete.md";
const conflictCaseFromPath = "E2E/two-vault/conflict-operations/Case-Rename.md";
const conflictCaseToPath = "E2E/two-vault/conflict-operations/case-rename.md";
const conflictRenameFromPath = "E2E/two-vault/conflict-operations/rename-source.md";
const conflictRenameToPath = "E2E/two-vault/conflict-operations/renamed/rename-target.md";
const targetMismatchPath = "E2E/two-vault/target-mismatch.md";
const encryptedPath = "E2E/two-vault/encrypted.md";
@@ -40,6 +53,19 @@ type RunnerContext = {
cliBinary: string;
couchDb: CouchDbConfig;
dbName: string;
reviewedVaults: Set<string>;
activeSessions: Set<ObsidianLiveSyncSession>;
};
type FileConflictState = {
currentRev: string;
branches: {
rev: string;
parentRev?: string;
content: string;
deleted: boolean;
path: string;
}[];
};
async function writeVaultFile(vaultPath: string, path: string, content: string): Promise<void> {
@@ -68,6 +94,18 @@ async function pathExists(vaultPath: string, path: string): Promise<boolean> {
}
}
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 waitForPathContent(
vaultPath: string,
path: string,
@@ -161,27 +199,44 @@ async function startConfiguredSession(
vault: TemporaryVault,
overrides: Record<string, unknown> = {}
): Promise<ObsidianLiveSyncSession> {
const couchDbSettings = {
uri: context.couchDb.uri,
username: context.couchDb.username,
password: context.couchDb.password,
dbName: context.dbName,
};
const reviewAlreadyCompleted = context.reviewedVaults.has(vault.path);
const session = await startObsidianLiveSyncSession({
binary: context.binary,
cliBinary: context.cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: createE2eCouchDbPluginData(couchDbSettings, overrides),
});
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await configureCouchDb(
context.cliBinary,
session.cliEnv,
{
uri: context.couchDb.uri,
username: context.couchDb.username,
password: context.couchDb.password,
dbName: context.dbName,
},
overrides
);
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await prepareRemote(context.cliBinary, session.cliEnv);
return session;
context.activeSessions.add(session);
try {
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
if (!reviewAlreadyCompleted) {
await assertE2eCompatibilityReviewPending(context.cliBinary, session.cliEnv);
await resumeCompatibilityReview(session.remoteDebuggingPort);
}
await assertE2eCompatibilityMarker(context.cliBinary, session.cliEnv);
if (!reviewAlreadyCompleted) context.reviewedVaults.add(vault.path);
await configureCouchDb(context.cliBinary, session.cliEnv, couchDbSettings, overrides);
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await prepareRemote(context.cliBinary, session.cliEnv);
return session;
} catch (error) {
try {
await stopTrackedSession(context, session);
} catch (stopError) {
throw Object.assign(new Error("Could not stop Obsidian after session setup failed."), {
cause: error,
stopError,
});
}
throw error;
}
}
async function uploadNote(
@@ -241,25 +296,116 @@ async function storeFileRevision(
return result.rev;
}
async function createMarkdownConflict(
context: RunnerContext,
session: ObsidianLiveSyncSession,
vault: TemporaryVault,
path: string,
base: string,
left: string,
right: string
): Promise<void> {
const baseRev = await storeFileRevision(context.cliBinary, session.cliEnv, path, base);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path);
await storeFileRevision(context.cliBinary, session.cliEnv, path, left, baseRev);
await storeFileRevision(context.cliBinary, session.cliEnv, path, right, baseRev);
await writeVaultFile(vault.path, path, right);
async function readFileConflictState(
cliBinary: string,
env: NodeJS.ProcessEnv,
path: string
): Promise<FileConflictState> {
return await evalObsidianJson<FileConflictState>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const meta=await core.localDatabase.getDBEntryMeta(path,{conflicts:true},true);",
"if(!meta) throw new Error(`Could not find conflict metadata: ${path}`);",
"const revisions=[meta._rev,...(meta._conflicts??[])];",
"const branches=[];",
"for(const rev of revisions){",
" const branchMeta=await core.localDatabase.getDBEntryMeta(path,{rev,revs:true},true);",
" const entry=await core.localDatabase.getDBEntry(path,{rev},false,true,true);",
" if(!branchMeta||!entry) throw new Error(`Could not read conflict revision: ${path} ${rev}`);",
" const content=Array.isArray(entry.data)?entry.data.join(''):entry.data;",
" if(typeof content!=='string') throw new Error(`Conflict revision was not text: ${path} ${rev}`);",
" const ids=branchMeta._revisions?.ids??[];",
" const parentRev=ids[1]?`${branchMeta._revisions.start-1}-${ids[1]}`:undefined;",
" branches.push({rev,parentRev,content,deleted:Boolean(branchMeta.deleted||branchMeta._deleted),path:branchMeta.path});",
"}",
"return JSON.stringify({currentRev:meta._rev,branches});",
"})()",
].join(""),
env
);
}
async function autoMergeMarkdownConflict(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise<void> {
await evalObsidianJson<unknown>(
async function waitForFileConflict(
cliBinary: string,
env: NodeJS.ProcessEnv,
path: string
): Promise<FileConflictState> {
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000);
let state = await readFileConflictState(cliBinary, env, path);
while (state.branches.length < 2 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 250));
state = await readFileConflictState(cliBinary, env, path);
}
if (state.branches.length < 2) {
throw new Error(`Timed out waiting for a file conflict: ${path}`);
}
return state;
}
async function waitForConflictBranch(
cliBinary: string,
env: NodeJS.ProcessEnv,
path: string,
predicate: (branch: FileConflictState["branches"][number]) => boolean
): Promise<FileConflictState["branches"][number]> {
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000);
let state = await readFileConflictState(cliBinary, env, path);
while (Date.now() < deadline) {
const branch = state.branches.find(predicate);
if (branch) return branch;
await new Promise((resolve) => setTimeout(resolve, 250));
state = await readFileConflictState(cliBinary, env, path);
}
throw new Error(`Timed out waiting for the expected conflict branch: ${path}; ${JSON.stringify(state)}`);
}
async function readFileReflectionProvenance(
cliBinary: string,
env: NodeJS.ProcessEnv,
path: string
): Promise<{ revision: string; observedStorageMtime?: number } | null> {
return await evalObsidianJson<{ revision: string; observedStorageMtime?: number } | null>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const store=core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1');",
"return JSON.stringify((await store.get(path))??null);",
"})()",
].join(""),
env
);
}
async function readPathIdentity(
cliBinary: string,
env: NodeJS.ProcessEnv,
paths: readonly string[]
): Promise<{ caseSensitive: boolean; ids: Record<string, string> }> {
return await evalObsidianJson<{ caseSensitive: boolean; ids: Record<string, string> }>(
cliBinary,
[
"(async()=>{",
`const paths=${JSON.stringify(paths)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const ids={};",
"for(const path of paths) ids[path]=await core.services.path.path2id(path);",
"return JSON.stringify({",
" caseSensitive:Boolean(core.services.setting.currentSettings().handleFilenameCaseSensitive),",
" ids,",
"});",
"})()",
].join(""),
env
);
}
async function calculateMarkdownAutoMerge(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise<string> {
const result = await evalObsidianJson<{ content: string }>(
cliBinary,
[
"(async()=>{",
@@ -269,11 +415,29 @@ async function autoMergeMarkdownConflict(cliBinary: string, env: NodeJS.ProcessE
"if(!('result' in result)){",
" throw new Error(`Markdown conflict was not auto-mergeable: ${path}; ${JSON.stringify(result)}`);",
"}",
"if(!(await core.databaseFileAccess.storeContent(path,result.result))){",
" throw new Error(`Could not store merged Markdown content: ${path}`);",
"}",
"if(!(await core.fileHandler.deleteRevisionFromDB(path,result.conflictedRev))){",
" throw new Error(`Could not delete conflicted revision: ${path}`);",
"return JSON.stringify({content:result.result});",
"})()",
].join(""),
env
);
return result.content;
}
async function deleteRevisionAndReflect(
cliBinary: string,
env: NodeJS.ProcessEnv,
path: string,
revision: string
): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
`const revision=${JSON.stringify(revision)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"if(!(await core.fileHandler.deleteRevisionFromDB(path,revision))){",
" throw new Error(`Could not delete conflicted revision: ${path} ${revision}`);",
"}",
"if(!(await core.fileHandler.dbToStorage(path,path,true))){",
" throw new Error(`Could not reflect merged Markdown content: ${path}`);",
@@ -294,12 +458,12 @@ async function runCreateUpdateDelete(
let session = await startConfiguredSession(context, vaultA);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, createPath, createdContent);
await uploadNote(context, session, createPath);
await session.app.stop();
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB);
await syncAndApply(context, session);
const createdOnB = await waitForPathContent(vaultB.path, createPath, (content) => content === createdContent);
await session.app.stop();
await stopTrackedSession(context, session);
assertEqual(createdOnB, createdContent, "Created note did not round-trip to the second vault.");
const initialUpdateContent = "# Update target\n\nInitial content.\n";
@@ -309,34 +473,34 @@ async function runCreateUpdateDelete(
await uploadNote(context, session, updatePath);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, updatePath, updatedContent);
await uploadNote(context, session, updatePath);
await session.app.stop();
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB);
await syncAndApply(context, session);
const updatedOnB = await waitForPathContent(vaultB.path, updatePath, (content) => content === updatedContent);
await session.app.stop();
await stopTrackedSession(context, session);
assertEqual(updatedOnB, updatedContent, "Updated note content did not round-trip to the second vault.");
const deleteContent = "# Delete target\n\nThis note should be removed from B.\n";
session = await startConfiguredSession(context, vaultA);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, deletePath, deleteContent);
await uploadNote(context, session, deletePath);
await session.app.stop();
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB);
await syncAndApply(context, session);
await waitForPathContent(vaultB.path, deletePath, (content) => content === deleteContent);
await session.app.stop();
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultA);
await deleteNoteViaObsidian(context.cliBinary, session.cliEnv, deletePath);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await session.app.stop();
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB);
await syncAndApply(context, session);
await waitForPathDeleted(vaultB.path, deletePath);
await session.app.stop();
await stopTrackedSession(context, session);
console.log("Two-vault note creation, update, and deletion round-tripped.");
}
@@ -350,18 +514,51 @@ async function runRename(context: RunnerContext, vaultA: TemporaryVault, vaultB:
await renameNoteViaObsidian(context.cliBinary, session.cliEnv, renameFromPath, renameToPath);
await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, renameToPath);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await session.app.stop();
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB);
await syncAndApply(context, session);
const renamedOnB = await waitForPathContent(vaultB.path, renameToPath, (content) => content === renamedContent);
await waitForPathDeleted(vaultB.path, renameFromPath);
await session.app.stop();
await stopTrackedSession(context, session);
assertEqual(renamedOnB, renamedContent, "Renamed note content did not round-trip to the second vault.");
console.log("Two-vault note rename round-tripped.");
}
async function runCaseOnlyRename(
context: RunnerContext,
vaultA: TemporaryVault,
vaultB: TemporaryVault
): Promise<void> {
const fileContent = "# Case-only rename\n\nThe document ID should remain live.\n";
let session = await startConfiguredSession(context, vaultA);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, caseRenameFromPath, fileContent);
await uploadNote(context, session, caseRenameFromPath);
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB);
await syncAndApply(context, session);
await waitForPathContent(vaultB.path, caseRenameFromPath, (content) => content === fileContent);
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultA);
await renameNoteViaObsidian(context.cliBinary, session.cliEnv, caseRenameFromPath, caseRenameToPath);
await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, caseRenameToPath);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB);
await syncAndApply(context, session);
const renamedOnB = await waitForPathContent(vaultB.path, caseRenameToPath, (content) => content === fileContent);
await waitForExactCaseOnlyRename(vaultB.path, caseRenameFromPath, caseRenameToPath);
await stopTrackedSession(context, session);
assertEqual(renamedOnB, fileContent, "Case-only note rename did not round-trip to the second vault.");
console.log("Two-vault case-only note rename round-tripped without a tombstone.");
}
async function runEncryptedRoundTrip(
context: RunnerContext,
vaultA: TemporaryVault,
@@ -378,12 +575,12 @@ async function runEncryptedRoundTrip(
let session = await startConfiguredSession(context, vaultA, encryptedOverrides);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, encryptedPath, encryptedContent);
await uploadNote(context, session, encryptedPath);
await session.app.stop();
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB, encryptedOverrides);
await syncAndApply(context, session);
const received = await waitForPathContent(vaultB.path, encryptedPath, (content) => content === encryptedContent);
await session.app.stop();
await stopTrackedSession(context, session);
assertEqual(received, encryptedContent, "Encrypted note did not round-trip to the second vault.");
console.log("Two-vault encrypted note synchronisation round-tripped.");
@@ -397,31 +594,290 @@ async function runMarkdownAutoMerge(
const base = "# Conflict\n\nTop anchor\n\nMiddle anchor\n\nBottom anchor\n";
const left = "# Conflict\n\nTop anchor\n\nLeft line\n\nMiddle anchor\n\nBottom anchor\n";
const right = "# Conflict\n\nTop anchor\n\nMiddle anchor\n\nRight tail\n\nBottom anchor\n";
const conflictOverrides = {
disableMarkdownAutoMerge: true,
checkConflictOnlyOnOpen: true,
showMergeDialogOnlyOnActive: true,
};
let session = await startConfiguredSession(context, vaultB);
await createMarkdownConflict(context, session, vaultB, conflictPath, base, left, right);
await autoMergeMarkdownConflict(context.cliBinary, session.cliEnv, conflictPath);
let session = await startConfiguredSession(context, vaultA, conflictOverrides);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, conflictPath, base);
await uploadNote(context, session, conflictPath);
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB, conflictOverrides);
await syncAndApply(context, session);
const baseOnB = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, conflictPath);
await waitForPathContent(vaultB.path, conflictPath, (content) => content === base);
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultA, conflictOverrides);
const baseOnA = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, conflictPath);
await storeFileRevision(context.cliBinary, session.cliEnv, conflictPath, left, baseOnA.rev);
await writeVaultFile(vaultA.path, conflictPath, left);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB, conflictOverrides);
await storeFileRevision(context.cliBinary, session.cliEnv, conflictPath, right, baseOnB.rev);
await writeVaultFile(vaultB.path, conflictPath, right);
await pushLocalChanges(context.cliBinary, session.cliEnv);
const conflict = await waitForFileConflict(context.cliBinary, session.cliEnv, conflictPath);
const leftBranch = conflict.branches.find((branch) => branch.content === left);
const rightBranch = conflict.branches.find((branch) => branch.content === right);
if (!leftBranch || !rightBranch) {
throw new Error(`The two Vault edits did not form the expected conflict: ${JSON.stringify(conflict)}`);
}
const merged = await calculateMarkdownAutoMerge(context.cliBinary, session.cliEnv, conflictPath);
if (!merged.includes("Left line") || !merged.includes("Right tail")) {
throw new Error(`Markdown auto-merge discarded a non-overlapping edit: ${JSON.stringify({ merged })}`);
}
const mergedRev = await storeFileRevision(context.cliBinary, session.cliEnv, conflictPath, merged, rightBranch.rev);
await deleteRevisionAndReflect(context.cliBinary, session.cliEnv, conflictPath, leftBranch.rev);
await pushLocalChanges(context.cliBinary, session.cliEnv);
const mergedOnB = await waitForPathContent(
vaultB.path,
conflictPath,
(content) => content.includes("Left line") && content.includes("Right tail"),
(content) => content === merged,
Number(process.env.E2E_OBSIDIAN_MERGE_FILE_TIMEOUT_MS ?? 30000)
);
await session.app.stop();
session = await startConfiguredSession(context, vaultA);
const afterResolution = `${merged.trimEnd()}\n\nPost-resolution edit on B.\n`;
await storeFileRevision(context.cliBinary, session.cliEnv, conflictPath, afterResolution, mergedRev);
await writeVaultFile(vaultB.path, conflictPath, afterResolution);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultA, conflictOverrides);
await syncAndApply(context, session);
const mergedOnA = await waitForPathContent(
const resolvedOnA = await waitForPathContent(
vaultA.path,
conflictPath,
(content) => content.includes("Left line") && content.includes("Right tail"),
(content) => content === afterResolution,
Number(process.env.E2E_OBSIDIAN_MERGE_FILE_TIMEOUT_MS ?? 30000)
);
await session.app.stop();
const resolvedState = await readFileConflictState(context.cliBinary, session.cliEnv, conflictPath);
await stopTrackedSession(context, session);
assertEqual(mergedOnA, mergedOnB, "Merged Markdown content was not consistent across both vaults.");
console.log("Markdown conflict was automatically merged and propagated by the next synchronisation.");
assertEqual(mergedOnB, merged, "The resolving Vault did not reflect the merged Markdown content.");
assertEqual(
resolvedOnA,
afterResolution,
"The resolved Markdown content did not replace the known losing revision."
);
assertEqual(
resolvedState.branches.length,
1,
"The receiving Vault recreated a conflict from the known losing revision."
);
console.log(
"A two-Vault Markdown conflict was merged, edited again, and propagated to the Vault holding the resolved losing revision."
);
}
async function runConflictTimeStorageOperations(
context: RunnerContext,
vaultA: TemporaryVault,
vaultB: TemporaryVault
): Promise<void> {
const paths = [conflictEditPath, conflictDeletePath, conflictCaseFromPath, conflictRenameFromPath] as const;
const conflictOverrides = {
disableMarkdownAutoMerge: true,
checkConflictOnlyOnOpen: true,
showMergeDialogOnlyOnActive: true,
handleFilenameCaseSensitive: false,
};
const baseContent = Object.fromEntries(paths.map((path) => [path, `# Conflict operation\n\nBase for ${path}.\n`])) as Record<
(typeof paths)[number],
string
>;
const leftContent = Object.fromEntries(
paths.map((path) => [path, `${baseContent[path]}\nEdit made on Vault A.\n`])
) as Record<(typeof paths)[number], string>;
const rightContent = Object.fromEntries(
paths.map((path) => [path, `${baseContent[path]}\nDisplayed edit made on Vault B.\n`])
) as Record<(typeof paths)[number], string>;
let session = await startConfiguredSession(context, vaultA, conflictOverrides);
for (const path of paths) {
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, path, baseContent[path]);
await uploadNote(context, session, path);
}
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB, conflictOverrides);
await syncAndApply(context, session);
for (const path of paths) {
await waitForPathContent(vaultB.path, path, (content) => content === baseContent[path]);
}
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultA, conflictOverrides);
for (const path of paths) {
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, path, leftContent[path]);
await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path);
}
await pushLocalChanges(context.cliBinary, session.cliEnv);
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB, conflictOverrides);
for (const path of paths) {
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, path, rightContent[path]);
await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path);
}
await pushLocalChanges(context.cliBinary, session.cliEnv);
const displayedRevisions = new Map<string, string>();
const initialBranchRevisions = new Map<string, Set<string>>();
for (const path of paths) {
const state = await waitForFileConflict(context.cliBinary, session.cliEnv, path);
const displayedBranch = state.branches.find((branch) => branch.content === rightContent[path] && !branch.deleted);
if (!displayedBranch) {
throw new Error(`Could not identify the branch displayed by Vault B: ${path}; ${JSON.stringify(state)}`);
}
const provenance = await readFileReflectionProvenance(context.cliBinary, session.cliEnv, path);
assertEqual(
provenance?.revision,
displayedBranch.rev,
`Vault B did not retain the exact displayed revision for ${path}.`
);
displayedRevisions.set(path, displayedBranch.rev);
initialBranchRevisions.set(path, new Set(state.branches.map((branch) => branch.rev)));
}
const editedAgain = `${rightContent[conflictEditPath]}\nSecond edit while the conflict is active.\n`;
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, conflictEditPath, editedAgain);
const editedBranch = await waitForConflictBranch(
context.cliBinary,
session.cliEnv,
conflictEditPath,
(branch) => branch.content === editedAgain
);
assertEqual(
editedBranch.parentRev,
displayedRevisions.get(conflictEditPath),
"A conflict-time edit did not extend the displayed revision."
);
await deleteNoteViaObsidian(context.cliBinary, session.cliEnv, conflictDeletePath);
const deletedBranch = await waitForConflictBranch(
context.cliBinary,
session.cliEnv,
conflictDeletePath,
(branch) => branch.deleted
);
assertEqual(
deletedBranch.parentRev,
displayedRevisions.get(conflictDeletePath),
"A conflict-time deletion did not extend the displayed revision."
);
await renameNoteViaObsidian(
context.cliBinary,
session.cliEnv,
conflictCaseFromPath,
conflictCaseToPath
);
const caseRenamedBranch = await waitForConflictBranch(
context.cliBinary,
session.cliEnv,
conflictCaseToPath,
(branch) =>
!initialBranchRevisions.get(conflictCaseFromPath)?.has(branch.rev) &&
branch.path === conflictCaseToPath &&
branch.content === rightContent[conflictCaseFromPath] &&
!branch.deleted
);
const expectedCaseParent = displayedRevisions.get(conflictCaseFromPath);
if (caseRenamedBranch.parentRev !== expectedCaseParent) {
const [state, oldProvenance, newProvenance, identity] = await Promise.all([
readFileConflictState(context.cliBinary, session.cliEnv, conflictCaseToPath),
readFileReflectionProvenance(context.cliBinary, session.cliEnv, conflictCaseFromPath),
readFileReflectionProvenance(context.cliBinary, session.cliEnv, conflictCaseToPath),
readPathIdentity(context.cliBinary, session.cliEnv, [conflictCaseFromPath, conflictCaseToPath]),
]);
throw new Error(
`A conflict-time case-only rename did not extend the displayed revision: ${JSON.stringify({
expectedCaseParent,
caseRenamedBranch,
state,
oldProvenance,
newProvenance,
identity,
})}`
);
}
const [oldCaseProvenance, newCaseProvenance] = await Promise.all([
readFileReflectionProvenance(context.cliBinary, session.cliEnv, conflictCaseFromPath),
readFileReflectionProvenance(context.cliBinary, session.cliEnv, conflictCaseToPath),
]);
assertEqual(oldCaseProvenance, null, "A conflict-time case-only rename retained the old provenance path.");
assertEqual(
newCaseProvenance?.revision,
caseRenamedBranch.rev,
"A conflict-time case-only rename did not record the new displayed revision."
);
await renameNoteViaObsidian(
context.cliBinary,
session.cliEnv,
conflictRenameFromPath,
conflictRenameToPath
);
const renamedTarget = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, conflictRenameToPath);
const renamedSourceDeletion = await waitForConflictBranch(
context.cliBinary,
session.cliEnv,
conflictRenameFromPath,
(branch) => branch.deleted
);
assertEqual(
renamedSourceDeletion.parentRev,
displayedRevisions.get(conflictRenameFromPath),
"A conflict-time cross-path rename did not soft-delete the displayed source revision."
);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultA, conflictOverrides);
await syncAndApply(context, session);
const replicatedBranches = [
[conflictEditPath, editedBranch],
[conflictDeletePath, deletedBranch],
[conflictCaseToPath, caseRenamedBranch],
[conflictRenameFromPath, renamedSourceDeletion],
] as const;
for (const [path, expectedBranch] of replicatedBranches) {
const replicated = await waitForConflictBranch(
context.cliBinary,
session.cliEnv,
path,
(branch) => branch.rev === expectedBranch.rev
);
assertEqual(
replicated.parentRev,
expectedBranch.parentRev,
`The exact conflict-operation revision tree did not replicate for ${path}.`
);
}
await waitForPathContent(
vaultA.path,
conflictRenameToPath,
(content) => content === rightContent[conflictRenameFromPath]
);
const targetOnA = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, conflictRenameToPath);
assertEqual(targetOnA.id, renamedTarget.id, "The cross-path rename target did not replicate as the same document.");
assertEqual(
await readVaultFile(vaultA.path, conflictDeletePath),
leftContent[conflictDeletePath],
"A logical deletion from one conflict branch removed the other Vault's live branch."
);
await stopTrackedSession(context, session);
console.log(
"Conflict-time edit, logical deletion, case-only rename, and cross-path rename extended the displayed branches and replicated their revision trees."
);
}
async function runTargetMismatch(
@@ -435,23 +891,57 @@ async function runTargetMismatch(
let session = await startConfiguredSession(context, vaultA);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, targetMismatchPath, ignoredContent);
await uploadNote(context, session, targetMismatchPath);
await session.app.stop();
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB, {
syncOnlyRegEx: "^E2E/two-vault/allowed/.*",
});
await syncAndApply(context, session);
await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, targetMismatchPath);
assertEqual(
await pathExists(vaultB.path, targetMismatchPath),
false,
"A note was reflected on a device where it was not a target file."
);
await session.app.stop();
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB, {
syncOnlyRegEx: "^E2E/two-vault/allowed/.*",
});
assertEqual(
await pathExists(vaultB.path, targetMismatchPath),
false,
"A checkpointed non-target note was reflected before its target filter changed."
);
await configureCouchDb(
context.cliBinary,
session.cliEnv,
{
uri: context.couchDb.uri,
username: context.couchDb.username,
password: context.couchDb.password,
dbName: context.dbName,
},
{ syncOnlyRegEx: "" }
);
await syncAndApply(context, session);
const reflectedAfterEnabling = await waitForPathContent(
vaultB.path,
targetMismatchPath,
(content) => content === ignoredContent
);
await stopTrackedSession(context, session);
assertEqual(
reflectedAfterEnabling,
ignoredContent,
"Target file was not reflected after the device accepted the path."
);
session = await startConfiguredSession(context, vaultA);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, targetMismatchPath, acceptedContent);
await uploadNote(context, session, targetMismatchPath);
await session.app.stop();
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB, {
syncOnlyRegEx: "",
@@ -462,10 +952,12 @@ async function runTargetMismatch(
targetMismatchPath,
(content) => content === acceptedContent
);
await session.app.stop();
await stopTrackedSession(context, session);
assertEqual(received, acceptedContent, "Target file was not reflected after the device accepted the path.");
console.log("Two-vault target mismatch skipped a non-target note, then reflected it after enabling the target.");
assertEqual(received, acceptedContent, "Target file update was not reflected after the device accepted the path.");
console.log(
"Two-vault target mismatch skipped a non-target note, reflected it after enabling the target, and accepted a later update."
);
}
async function main(): Promise<void> {
@@ -482,8 +974,22 @@ async function main(): Promise<void> {
const vaultB = await createTemporaryVault();
const encryptedVaultA = await createTemporaryVault();
const encryptedVaultB = await createTemporaryVault();
const context: RunnerContext = { binary, cliBinary: cli.binary, couchDb, dbName };
const encryptedContext: RunnerContext = { binary, cliBinary: cli.binary, couchDb, dbName: encryptedDbName };
const context: RunnerContext = {
binary,
cliBinary: cli.binary,
couchDb,
dbName,
reviewedVaults: new Set(),
activeSessions: new Set(),
};
const encryptedContext: RunnerContext = {
binary,
cliBinary: cli.binary,
couchDb,
dbName: encryptedDbName,
reviewedVaults: new Set(),
activeSessions: new Set(),
};
try {
await assertCouchDbReachable(couchDb);
@@ -496,14 +1002,25 @@ async function main(): Promise<void> {
console.log(`Temporary CouchDB database: ${dbName}`);
console.log(`Temporary encrypted CouchDB database: ${encryptedDbName}`);
await runCreateUpdateDelete(context, vaultA, vaultB);
await runRename(context, vaultA, vaultB);
if (process.env.E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT === "true") {
await runMarkdownAutoMerge(context, vaultA, vaultB);
const onlyConflictOperations = process.env.E2E_OBSIDIAN_ONLY_CONFLICT_OPERATIONS === "true";
if (!onlyConflictOperations) {
await runCreateUpdateDelete(context, vaultA, vaultB);
await runRename(context, vaultA, vaultB);
await runCaseOnlyRename(context, vaultA, vaultB);
if (process.env.E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT === "true") {
await runMarkdownAutoMerge(context, vaultA, vaultB);
}
}
if (onlyConflictOperations || process.env.E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS === "true") {
await runConflictTimeStorageOperations(context, vaultA, vaultB);
}
if (!onlyConflictOperations) {
await runTargetMismatch(context, vaultA, vaultB);
await runEncryptedRoundTrip(encryptedContext, encryptedVaultA, encryptedVaultB);
}
await runTargetMismatch(context, vaultA, vaultB);
await runEncryptedRoundTrip(encryptedContext, encryptedVaultA, encryptedVaultB);
} finally {
await stopTrackedSessions(context);
await stopTrackedSessions(encryptedContext);
await vaultA.dispose();
await vaultB.dispose();
await encryptedVaultA.dispose();
@@ -0,0 +1,756 @@
import { spawn } from "node:child_process";
import { access, readFile, writeFile } from "node:fs/promises";
import { basename, resolve } from "node:path";
import {
assertCouchDbReachable,
createCouchDbDatabase,
deleteCouchDbDatabase,
fetchAllCouchDbDocs,
fetchCouchDbDatabaseInfo,
fetchCouchDbLocalDocs,
loadCouchDbConfig,
makeUniqueDatabaseName,
type CouchDbConfig,
type CouchDbDatabaseInfo,
type CouchDbDocument,
} from "../runner/couchdb.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
configureCouchDb,
configureObjectStorage,
createE2eCouchDbPluginData,
createE2eObjectStoragePluginData,
createE2eObsidianDeviceLocalState,
prepareRemote,
pushLocalChanges,
waitForLiveSyncCoreReady,
} from "../runner/liveSyncWorkflow.ts";
import {
deleteObjectStoragePrefix,
ensureObjectStorageBucket,
listObjectStorageObjects,
loadObjectStorageConfig,
makeUniqueBucketPrefix,
readObjectStorageJson,
type ObjectStorageConfig,
} from "../runner/objectStorage.ts";
import { ensurePinnedReleaseArtifact, UPGRADE_SOURCE_RELEASE } from "../runner/releaseArtifact.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import {
assertCouchDbCheckpointContinuity,
assertCouchDbDocumentsUnchanged,
assertJournalCheckpointAdvanced,
assertJournalCheckpointLoaded,
assertMilestoneContinuity,
assertNoJournalReplay,
assertSomeCouchDbCheckpointAdvanced,
type CouchDbCheckpointSnapshot,
type CouchDbDocumentRevision,
type MilestoneIdentity,
type RemoteObjectSnapshot,
} from "../runner/upgradeContinuity.ts";
import {
assertStableReleaseDefaults,
assertStableRemoteSelection,
assertUnconfiguredUpgradeReady,
assertUnconfiguredUpgradeRestarted,
assertUpgradeCompatibilityReady,
assertUpgradeRemainsReady,
configureStableRelease,
createPostUpgradeDelta,
createUpgradeScenarioPaths,
createVerifierReturnDelta,
dismissConfigDoctorIfShown,
prepareStableRemote,
readJournalCheckpoint,
readLocalCouchDbCheckpoints,
readRuntimeUpgradeState,
readRuntimeSettingsUpgradeState,
runCouchDbReplicationObserved,
runJournalReplicationObserved,
runStableFileHistory,
STABLE_RELEASE_VERSION,
verifyPostUpgradeHistory,
verifyPreUpgradeHistory,
verifyReturnDelta,
waitForPersistentNodeIdentity,
type CouchDbReplicationObservation,
type RuntimeUpgradeState,
type UpgradeTransportConfiguration,
} from "../runner/upgradeWorkflow.ts";
import { obsidianRemoteDebuggingPort } from "../runner/ui.ts";
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
type Transport = "couchdb" | "object-storage";
type RemoteMilestone = CouchDbDocument & {
created?: unknown;
locked?: unknown;
accepted_nodes?: unknown;
tweak_values?: unknown;
};
type CouchDbRemoteSnapshot = {
checkpoints: CouchDbCheckpointSnapshot[];
documents: CouchDbDocumentRevision[];
info: CouchDbDatabaseInfo;
milestone: MilestoneIdentity;
preferredTweaks: Record<string, unknown>;
};
type ObjectStorageRemoteSnapshot = {
journalObjects: RemoteObjectSnapshot[];
milestone: MilestoneIdentity;
preferredTweaks: Record<string, unknown>;
};
type RunnerContext = {
binary: string;
cliBinary: string;
sourceArtifactRoot: string;
targetArtifactRoot: string;
targetVersion: string;
activeSessions: Set<ObsidianLiveSyncSession>;
};
type ParsedArguments = {
transports: Transport[];
manageServices: boolean;
keepServices: boolean;
};
type StartSessionOptions = {
pluginData?: Record<string, unknown>;
localStorageEntries?: Readonly<Record<string, string>>;
waitForCoreReady?: boolean;
};
const MILESTONE_ID = "_local/obsydian_livesync_milestone";
const JOURNAL_MILESTONE_NAME = "_00000000-milestone.json";
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function assertEqual(actual: unknown, expected: unknown, message: string): void {
if (actual !== expected) {
throw new Error(`${message}\nExpected: ${String(expected)}\nActual: ${String(actual)}`);
}
}
function parseArguments(argv: readonly string[]): ParsedArguments {
let transportValue = "all";
for (let index = 0; index < argv.length; index++) {
const argument = argv[index];
if (argument === "--transport") {
transportValue = argv[index + 1] ?? "";
index++;
} else if (argument.startsWith("--transport=")) {
transportValue = argument.slice("--transport=".length);
}
}
const transports: Transport[] =
transportValue === "all"
? ["couchdb", "object-storage"]
: transportValue === "couchdb" || transportValue === "object-storage"
? [transportValue]
: (() => {
throw new Error(`Unsupported transport '${transportValue}'. Use couchdb, object-storage, or all.`);
})();
return {
transports,
manageServices: argv.includes("--manage-services"),
keepServices: argv.includes("--keep-services"),
};
}
function sessionEnvironment(port: number): NodeJS.ProcessEnv {
return { ...process.env, E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: String(port) };
}
function sessionPorts(): readonly [number, number] {
const first = obsidianRemoteDebuggingPort(process.env);
const second = Number(process.env.E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT ?? first + 1);
if (!Number.isInteger(second) || second < 1 || second > 65535 || second === first) {
throw new Error(`Invalid secondary Obsidian remote debugging port: ${second}`);
}
return [first, second];
}
function npmBinary(): string {
return process.platform === "win32" ? "npm.cmd" : "npm";
}
function runNpmScript(name: string, optional = false): Promise<void> {
return new Promise((resolvePromise, reject) => {
console.log(`\n# ${name}`);
const child = spawn(npmBinary(), ["run", name], {
cwd: process.cwd(),
env: process.env,
stdio: "inherit",
});
child.on("error", reject);
child.on("exit", (code, signal) => {
if (code === 0 || optional) {
if (code !== 0) {
console.warn(`${name} did not complete cleanly (${signal ? `signal ${signal}` : `exit ${code}`}).`);
}
resolvePromise();
return;
}
reject(new Error(`${name} failed (${signal ? `signal ${signal}` : `exit ${code}`}).`));
});
});
}
async function validateTargetArtifact(root: string): Promise<string> {
await Promise.all(
["main.js", "manifest.json", "styles.css"].map(async (name) => await access(resolve(root, name)))
);
const manifest = JSON.parse(await readFile(resolve(root, "manifest.json"), "utf8")) as {
id?: unknown;
version?: unknown;
};
assertEqual(manifest.id, UPGRADE_SOURCE_RELEASE.pluginId, "The target artefact has an unexpected plug-in id.");
assert(typeof manifest.version === "string" && manifest.version.length > 0, "The target manifest has no version.");
assert(
manifest.version !== STABLE_RELEASE_VERSION,
`The target artefact is still the source release ${STABLE_RELEASE_VERSION}.`
);
return manifest.version;
}
async function startSession(
context: RunnerContext,
vault: TemporaryVault,
port: number,
artifactRoot: string,
options: StartSessionOptions = {}
): Promise<ObsidianLiveSyncSession> {
const session = await startObsidianLiveSyncSession({
binary: context.binary,
cliBinary: context.cliBinary,
vault,
artifactRoot,
pluginData: options.pluginData,
localStorageEntries: options.localStorageEntries,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
env: sessionEnvironment(port),
});
context.activeSessions.add(session);
try {
if (options.waitForCoreReady !== false) {
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
}
return session;
} catch (error) {
await stopSession(context, session).catch(() => undefined);
throw error;
}
}
async function readStoredPluginData(vault: TemporaryVault): Promise<Record<string, unknown>> {
const path = resolve(vault.path, ".obsidian", "plugins", "obsidian-livesync", "data.json");
return JSON.parse(await readFile(path, "utf8")) as Record<string, unknown>;
}
async function writeStoredPluginData(vault: TemporaryVault, data: Record<string, unknown>): Promise<void> {
const path = resolve(vault.path, ".obsidian", "plugins", "obsidian-livesync", "data.json");
await writeFile(path, `${JSON.stringify(data, null, 2)}\n`);
}
async function runUnconfiguredSettingsUpgrade(context: RunnerContext, port: number): Promise<void> {
console.log(`\n# Upgrade from ${STABLE_RELEASE_VERSION}: unconfigured legacy settings`);
const vault = await createTemporaryVault("obsidian-livesync-upgrade-unconfigured-");
try {
let session = await startSession(context, vault, port, context.sourceArtifactRoot, {
pluginData: { liveSync: false },
waitForCoreReady: false,
});
const stableState = await readRuntimeSettingsUpgradeState(context.cliBinary, session.cliEnv);
assertStableReleaseDefaults(stableState, false);
await stopSession(context, session);
const stableData = await readStoredPluginData(vault);
if (stableData.isConfigured !== undefined) {
assertEqual(
stableData.isConfigured,
false,
"The stable release persisted a configured state for its default-equivalent settings."
);
}
// 0.25.83 infers the runtime boolean, but persistence depends on an
// unrelated settings-save event. Restore the pre-flag document
// explicitly so the target proves the direct legacy migration in
// either case rather than depending on that timing.
await writeStoredPluginData(vault, { liveSync: false });
session = await startSession(context, vault, port, context.targetArtifactRoot, {
waitForCoreReady: false,
});
const upgradedState = await readRuntimeSettingsUpgradeState(context.cliBinary, session.cliEnv);
assertUnconfiguredUpgradeReady(stableState, upgradedState, context.targetVersion);
await stopSession(context, session);
const migratedData = await readStoredPluginData(vault);
assertEqual(migratedData.isConfigured, false, "The inferred unconfigured state was not saved.");
assertEqual(
migratedData.handleFilenameCaseSensitive,
false,
"The inferred case-insensitive setting was not saved."
);
session = await startSession(context, vault, port, context.targetArtifactRoot, {
waitForCoreReady: false,
});
const restartedState = await readRuntimeSettingsUpgradeState(context.cliBinary, session.cliEnv);
assertUnconfiguredUpgradeRestarted(restartedState, context.targetVersion);
await stopSession(context, session);
console.log(
`PASS unconfigured settings: ${STABLE_RELEASE_VERSION} -> ${context.targetVersion}; legacy inference, persistence, and restart idempotence verified.`
);
} finally {
await stopSessions(context);
await vault.dispose();
}
}
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);
}
function milestoneIdentity(document: RemoteMilestone): MilestoneIdentity {
assert(document.created !== undefined && document.created !== null, "The remote milestone has no generation.");
assert(typeof document.locked === "boolean", "The remote milestone has no lock state.");
assert(Array.isArray(document.accepted_nodes), "The remote milestone has no accepted-device list.");
assert(
document.accepted_nodes.every((value) => typeof value === "string"),
"The remote milestone accepted-device list is malformed."
);
return {
created: document.created,
locked: document.locked,
acceptedNodes: document.accepted_nodes,
};
}
function preferredTweaks(document: RemoteMilestone): Record<string, unknown> {
const values = document.tweak_values;
assert(values !== null && typeof values === "object" && !Array.isArray(values), "The remote has no tweak map.");
const preferred = (values as Record<string, unknown>).PREFERRED;
assert(
preferred !== null && typeof preferred === "object" && !Array.isArray(preferred),
"The remote has no preferred tweak settings."
);
return { ...(preferred as Record<string, unknown>) };
}
async function readCouchDbRemoteSnapshot(config: CouchDbConfig, databaseName: string): Promise<CouchDbRemoteSnapshot> {
const [allDocs, localDocs, info] = await Promise.all([
fetchAllCouchDbDocs(config, databaseName),
fetchCouchDbLocalDocs(config, databaseName),
fetchCouchDbDatabaseInfo(config, databaseName),
]);
const milestone = localDocs.rows.find(({ id }) => id === MILESTONE_ID)?.doc as RemoteMilestone | undefined;
assert(milestone, "The CouchDB remote milestone is missing.");
const checkpoints = localDocs.rows.flatMap(({ id, doc }) =>
doc && Object.prototype.hasOwnProperty.call(doc, "last_seq") ? [{ id, lastSequence: doc.last_seq }] : []
);
const documents = allDocs.rows.map(({ id, value }) => ({
id,
revision: value.rev,
deleted: value.deleted === true,
}));
return {
checkpoints,
documents,
info,
milestone: milestoneIdentity(milestone),
preferredTweaks: preferredTweaks(milestone),
};
}
async function readObjectStorageRemoteSnapshot(
config: ObjectStorageConfig,
prefix: string
): Promise<ObjectStorageRemoteSnapshot> {
const [objects, milestone] = await Promise.all([
listObjectStorageObjects(config, prefix),
readObjectStorageJson<RemoteMilestone>(config, `${prefix}${JOURNAL_MILESTONE_NAME}`),
]);
const journalObjects = objects.flatMap((object) => {
if (!object.Key || basename(object.Key).startsWith("_")) return [];
return [
{
key: object.Key,
size: object.Size ?? 0,
etag: object.ETag ?? "",
},
];
});
return {
journalObjects,
milestone: milestoneIdentity(milestone),
preferredTweaks: preferredTweaks(milestone),
};
}
function assertNoOpCouchDbObservation(observation: CouchDbReplicationObservation): void {
assert(observation.succeeded, "The first post-upgrade CouchDB synchronisation failed.");
assertEqual(observation.sentDocuments, 0, "The no-op CouchDB synchronisation resent documents.");
assertEqual(observation.arrivedDocuments, 0, "The no-op CouchDB synchronisation refetched documents.");
}
function assertNoOpCouchDbDatabase(before: CouchDbRemoteSnapshot, after: CouchDbRemoteSnapshot): void {
assertCouchDbCheckpointContinuity(before.checkpoints, after.checkpoints);
assertCouchDbDocumentsUnchanged(before.documents, after.documents);
assertEqual(
after.info.update_seq,
before.info.update_seq,
"The no-op CouchDB synchronisation advanced update_seq."
);
assertEqual(after.info.doc_count, before.info.doc_count, "The no-op CouchDB synchronisation changed doc_count.");
assertMilestoneContinuity(before.milestone, after.milestone);
}
function assertRestartContinuity(before: RuntimeUpgradeState, after: RuntimeUpgradeState): void {
assertEqual(after.localDatabaseName, before.localDatabaseName, "Restart opened a different local database.");
assertEqual(after.nodeId, before.nodeId, "Restart changed the device node identity.");
assertEqual(
after.settings.activeConfigurationId,
before.settings.activeConfigurationId,
"Restart changed the active remote profile."
);
}
async function configureFreshCouchDbVerifier(
context: RunnerContext,
session: ObsidianLiveSyncSession,
config: CouchDbConfig,
databaseName: string,
tweaks: Record<string, unknown>
): Promise<void> {
await configureCouchDb(
context.cliBinary,
session.cliEnv,
{ uri: config.uri, username: config.username, password: config.password, dbName: databaseName },
tweaks
);
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await prepareRemote(context.cliBinary, session.cliEnv);
}
async function configureFreshObjectStorageVerifier(
context: RunnerContext,
session: ObsidianLiveSyncSession,
config: ObjectStorageConfig,
prefix: string,
tweaks: Record<string, unknown>
): Promise<void> {
await configureObjectStorage(context.cliBinary, session.cliEnv, { ...config, bucketPrefix: prefix }, tweaks);
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await prepareRemote(context.cliBinary, session.cliEnv);
}
async function runCouchDbUpgrade(context: RunnerContext, ports: readonly [number, number]): Promise<void> {
console.log(`\n# Upgrade from ${STABLE_RELEASE_VERSION}: CouchDB`);
const config = await loadCouchDbConfig();
const databaseName = makeUniqueDatabaseName(config.dbPrefix, "upgrade-from-stable");
const remote: UpgradeTransportConfiguration = { kind: "couchdb", config, databaseName };
const paths = createUpgradeScenarioPaths("couchdb");
const upgradeVault = await createTemporaryVault("obsidian-livesync-upgrade-couchdb-");
const verifierVault = await createTemporaryVault("obsidian-livesync-upgrade-couchdb-verifier-");
let upgradedSession: ObsidianLiveSyncSession | undefined;
try {
await assertCouchDbReachable(config);
await createCouchDbDatabase(config, databaseName);
let session = await startSession(context, upgradeVault, ports[0], context.sourceArtifactRoot);
assertStableReleaseDefaults(await readRuntimeUpgradeState(context.cliBinary, session.cliEnv), false);
await configureStableRelease(context.cliBinary, session.cliEnv, remote);
const configuredStable = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv);
assertStableReleaseDefaults(configuredStable, true);
assertStableRemoteSelection(configuredStable, remote);
await stopSession(context, session);
session = await startSession(context, upgradeVault, ports[0], context.sourceArtifactRoot);
const restartedStable = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv);
assertStableReleaseDefaults(restartedStable, true);
assertStableRemoteSelection(restartedStable, remote);
await waitForPersistentNodeIdentity(context.cliBinary, session.cliEnv);
await prepareStableRemote(context.cliBinary, session.cliEnv);
await runStableFileHistory(context.cliBinary, session.cliEnv, paths, async () => {
const result = await runCouchDbReplicationObserved(context.cliBinary, session.cliEnv);
assert(result.succeeded, "The stable CouchDB synchronisation failed.");
});
await verifyPreUpgradeHistory(upgradeVault, paths);
const stableState = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv);
const stableRemote = await readCouchDbRemoteSnapshot(config, databaseName);
const stableLocalCheckpoints = await readLocalCouchDbCheckpoints(
context.cliBinary,
session.cliEnv,
stableRemote.checkpoints.map(({ id }) => id)
);
assertCouchDbCheckpointContinuity(stableRemote.checkpoints, stableLocalCheckpoints);
await stopSession(context, session);
session = await startSession(context, upgradeVault, ports[0], context.targetArtifactRoot);
upgradedSession = session;
await dismissConfigDoctorIfShown(session.remoteDebuggingPort);
const upgradedState = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv);
assertUpgradeCompatibilityReady(stableState, upgradedState, context.targetVersion, remote);
await verifyPreUpgradeHistory(upgradeVault, paths);
const loadedLocalCheckpoints = await readLocalCouchDbCheckpoints(
context.cliBinary,
session.cliEnv,
stableRemote.checkpoints.map(({ id }) => id)
);
assertCouchDbCheckpointContinuity(stableLocalCheckpoints, loadedLocalCheckpoints);
const noOpObservation = await runCouchDbReplicationObserved(context.cliBinary, session.cliEnv);
assertNoOpCouchDbObservation(noOpObservation);
const noOpRemote = await readCouchDbRemoteSnapshot(config, databaseName);
assertNoOpCouchDbDatabase(stableRemote, noOpRemote);
await createPostUpgradeDelta(context.cliBinary, session.cliEnv, paths);
const deltaObservation = await runCouchDbReplicationObserved(context.cliBinary, session.cliEnv);
assert(deltaObservation.succeeded, "The post-upgrade CouchDB delta failed.");
assert(deltaObservation.sentDocuments > 0, "The post-upgrade CouchDB delta sent no documents.");
const deltaRemote = await readCouchDbRemoteSnapshot(config, databaseName);
assertSomeCouchDbCheckpointAdvanced(noOpRemote.checkpoints, deltaRemote.checkpoints);
assertMilestoneContinuity(noOpRemote.milestone, deltaRemote.milestone);
const verifierSettings = {
uri: config.uri,
username: config.username,
password: config.password,
dbName: databaseName,
};
const verifier = await startSession(context, verifierVault, ports[1], context.targetArtifactRoot, {
pluginData: createE2eCouchDbPluginData(verifierSettings, deltaRemote.preferredTweaks),
localStorageEntries: createE2eObsidianDeviceLocalState(verifierVault.name),
});
await configureFreshCouchDbVerifier(context, verifier, config, databaseName, deltaRemote.preferredTweaks);
await pushLocalChanges(context.cliBinary, verifier.cliEnv);
await verifyPostUpgradeHistory(verifierVault, paths);
await createVerifierReturnDelta(context.cliBinary, verifier.cliEnv, paths);
await pushLocalChanges(context.cliBinary, verifier.cliEnv);
const returnObservation = await runCouchDbReplicationObserved(context.cliBinary, session.cliEnv);
assert(returnObservation.succeeded, "The upgraded CouchDB device could not receive the verifier delta.");
assert(returnObservation.arrivedDocuments > 0, "The verifier CouchDB delta did not arrive.");
await verifyReturnDelta(upgradeVault, paths);
await stopSession(context, verifier);
await stopSession(context, session);
upgradedSession = undefined;
const restarted = await startSession(context, upgradeVault, ports[0], context.targetArtifactRoot);
const restartedState = await readRuntimeUpgradeState(context.cliBinary, restarted.cliEnv);
assertUpgradeRemainsReady(restartedState, context.targetVersion);
assertRestartContinuity(upgradedState, restartedState);
await verifyReturnDelta(upgradeVault, paths);
await stopSession(context, restarted);
console.log(
`PASS CouchDB: ${STABLE_RELEASE_VERSION} -> ${context.targetVersion}; checkpoint lineage, no-op sync, delta sync, fresh-device round-trip, and restart continuity verified.`
);
} finally {
if (upgradedSession) await stopSession(context, upgradedSession).catch(() => undefined);
await stopSessions(context);
await Promise.all([upgradeVault.dispose(), verifierVault.dispose()]);
if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") {
await deleteCouchDbDatabase(config, databaseName).catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
}
}
}
async function runObjectStorageUpgrade(context: RunnerContext, ports: readonly [number, number]): Promise<void> {
console.log(`\n# Upgrade from ${STABLE_RELEASE_VERSION}: Object Storage`);
const config = await loadObjectStorageConfig();
const prefix = makeUniqueBucketPrefix("upgrade-from-stable");
const remote: UpgradeTransportConfiguration = { kind: "object-storage", config, bucketPrefix: prefix };
const paths = createUpgradeScenarioPaths("object-storage");
const upgradeVault = await createTemporaryVault("obsidian-livesync-upgrade-object-storage-");
const verifierVault = await createTemporaryVault("obsidian-livesync-upgrade-object-storage-verifier-");
let upgradedSession: ObsidianLiveSyncSession | undefined;
try {
await ensureObjectStorageBucket(config);
let session = await startSession(context, upgradeVault, ports[0], context.sourceArtifactRoot);
assertStableReleaseDefaults(await readRuntimeUpgradeState(context.cliBinary, session.cliEnv), false);
await configureStableRelease(context.cliBinary, session.cliEnv, remote);
const configuredStable = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv);
assertStableReleaseDefaults(configuredStable, true);
assertStableRemoteSelection(configuredStable, remote);
await stopSession(context, session);
session = await startSession(context, upgradeVault, ports[0], context.sourceArtifactRoot);
const restartedStable = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv);
assertStableReleaseDefaults(restartedStable, true);
assertStableRemoteSelection(restartedStable, remote);
await waitForPersistentNodeIdentity(context.cliBinary, session.cliEnv);
await prepareStableRemote(context.cliBinary, session.cliEnv);
await runStableFileHistory(context.cliBinary, session.cliEnv, paths, async () => {
const result = await runJournalReplicationObserved(context.cliBinary, session.cliEnv);
assert(
result.succeeded,
`The stable Object Storage synchronisation failed.\nObservation: ${JSON.stringify(result)}`
);
});
await verifyPreUpgradeHistory(upgradeVault, paths);
const stableState = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv);
const stableCheckpoint = await readJournalCheckpoint(context.cliBinary, session.cliEnv);
const stableRemote = await readObjectStorageRemoteSnapshot(config, prefix);
await stopSession(context, session);
session = await startSession(context, upgradeVault, ports[0], context.targetArtifactRoot);
upgradedSession = session;
await dismissConfigDoctorIfShown(session.remoteDebuggingPort);
const upgradedState = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv);
assertUpgradeCompatibilityReady(stableState, upgradedState, context.targetVersion, remote);
await verifyPreUpgradeHistory(upgradeVault, paths);
const loadedCheckpoint = await readJournalCheckpoint(context.cliBinary, session.cliEnv);
assertJournalCheckpointLoaded(stableCheckpoint, loadedCheckpoint);
const noOpObservation = await runJournalReplicationObserved(context.cliBinary, session.cliEnv);
assert(noOpObservation.succeeded, "The first post-upgrade Object Storage synchronisation failed.");
const noOpCheckpoint = await readJournalCheckpoint(context.cliBinary, session.cliEnv);
const noOpRemote = await readObjectStorageRemoteSnapshot(config, prefix);
assertNoJournalReplay(
stableCheckpoint,
noOpCheckpoint,
stableRemote.journalObjects,
noOpRemote.journalObjects,
noOpObservation
);
assertMilestoneContinuity(stableRemote.milestone, noOpRemote.milestone);
await createPostUpgradeDelta(context.cliBinary, session.cliEnv, paths);
const deltaObservation = await runJournalReplicationObserved(context.cliBinary, session.cliEnv);
assert(deltaObservation.succeeded, "The post-upgrade Object Storage delta failed.");
const deltaCheckpoint = await readJournalCheckpoint(context.cliBinary, session.cliEnv);
assertJournalCheckpointAdvanced(noOpCheckpoint, deltaCheckpoint, deltaObservation);
const deltaRemote = await readObjectStorageRemoteSnapshot(config, prefix);
assertMilestoneContinuity(noOpRemote.milestone, deltaRemote.milestone);
const verifierSettings = { ...config, bucketPrefix: prefix };
const verifier = await startSession(context, verifierVault, ports[1], context.targetArtifactRoot, {
pluginData: createE2eObjectStoragePluginData(verifierSettings, deltaRemote.preferredTweaks),
localStorageEntries: createE2eObsidianDeviceLocalState(verifierVault.name),
});
await configureFreshObjectStorageVerifier(context, verifier, config, prefix, deltaRemote.preferredTweaks);
await pushLocalChanges(context.cliBinary, verifier.cliEnv);
await verifyPostUpgradeHistory(verifierVault, paths);
await createVerifierReturnDelta(context.cliBinary, verifier.cliEnv, paths);
await pushLocalChanges(context.cliBinary, verifier.cliEnv);
const returnObservation = await runJournalReplicationObserved(context.cliBinary, session.cliEnv);
assert(returnObservation.succeeded, "The upgraded Object Storage device could not receive the verifier delta.");
assert(returnObservation.downloadedJournalKeys.length > 0, "The verifier Object Storage delta did not arrive.");
await verifyReturnDelta(upgradeVault, paths);
await stopSession(context, verifier);
await stopSession(context, session);
upgradedSession = undefined;
const restarted = await startSession(context, upgradeVault, ports[0], context.targetArtifactRoot);
const restartedState = await readRuntimeUpgradeState(context.cliBinary, restarted.cliEnv);
assertUpgradeRemainsReady(restartedState, context.targetVersion);
assertRestartContinuity(upgradedState, restartedState);
await verifyReturnDelta(upgradeVault, paths);
await stopSession(context, restarted);
console.log(
`PASS Object Storage: ${STABLE_RELEASE_VERSION} -> ${context.targetVersion}; checkpoint lineage, no replay, delta sync, fresh-device round-trip, and restart continuity verified.`
);
} finally {
if (upgradedSession) await stopSession(context, upgradedSession).catch(() => undefined);
await stopSessions(context);
await Promise.all([upgradeVault.dispose(), verifierVault.dispose()]);
if (process.env.E2E_OBSIDIAN_KEEP_OBJECT_STORAGE !== "true") {
await deleteObjectStoragePrefix(config, prefix).catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
}
}
}
async function startManagedServices(transports: readonly Transport[]): Promise<void> {
if (transports.includes("couchdb")) {
await runNpmScript("test:docker-couchdb:stop", true);
await runNpmScript("test:docker-couchdb:start");
}
if (transports.includes("object-storage")) {
await runNpmScript("test:docker-s3:stop", true);
await runNpmScript("test:docker-s3:start");
}
}
async function stopManagedServices(transports: readonly Transport[]): Promise<void> {
if (transports.includes("object-storage")) await runNpmScript("test:docker-s3:stop", true);
if (transports.includes("couchdb")) await runNpmScript("test:docker-couchdb:stop", true);
}
async function main(): Promise<void> {
const arguments_ = parseArguments(process.argv.slice(2));
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
const targetArtifactRoot = resolve(process.env.E2E_LIVESYNC_TARGET_ARTIFACT_ROOT?.trim() || process.cwd());
const targetVersion = await validateTargetArtifact(targetArtifactRoot);
const sourceArtifactRoot = await ensurePinnedReleaseArtifact();
const context: RunnerContext = {
binary,
cliBinary: cli.binary,
sourceArtifactRoot,
targetArtifactRoot,
targetVersion,
activeSessions: new Set(),
};
const ports = sessionPorts();
let managedServicesStarted = false;
console.log(`Using exact source release: ${STABLE_RELEASE_VERSION}`);
console.log(`Using target release candidate: ${targetVersion}`);
console.log(`Source artefact cache: ${sourceArtifactRoot}`);
console.log(`Target artefact root: ${targetArtifactRoot}`);
try {
await runUnconfiguredSettingsUpgrade(context, ports[0]);
if (arguments_.manageServices) {
await startManagedServices(arguments_.transports);
managedServicesStarted = true;
}
for (const transport of arguments_.transports) {
if (transport === "couchdb") await runCouchDbUpgrade(context, ports);
else await runObjectStorageUpgrade(context, ports);
}
} finally {
await stopSessions(context);
if (managedServicesStarted && !arguments_.keepServices) {
await stopManagedServices(arguments_.transports);
}
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});