Files
obsidian-livesync/test/e2e-obsidian/scripts/conflict-dialog-policy.ts
T
vorotamoroz 6f8df8f731 Stop conflict work after disposal and verify reload preservation
Stop pending dialogue and selected-resolution continuations after disposal,
before they log or start further resolution work. Verify released log binders
and retain real-Obsidian checks for content and revision history across reload.
2026-11-03 08:44:31 +00:00

983 lines
45 KiB
TypeScript

import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { DoctorRegulation } from "@vrtmrz/livesync-commonlib/compat/common/configForDoc";
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, captureObsidianPage, 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 repeatedPath = "conflict-dialog-repeated.md";
const activePath = "conflict-dialog-active.md";
const waitingPath = "conflict-dialog-waiting.md";
const externallyResolvedWaitingPath = "conflict-dialog-resolved-while-waiting.md";
const unloadActivePath = "conflict-dialog-unload-active.md";
const unloadWaitingPath = "conflict-dialog-unload-waiting.md";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_CONFLICT_DIALOG_TIMEOUT_MS ?? 10000);
type ConflictFixture = {
currentRev: string;
currentParentRev?: string;
conflicts: string[];
};
type ObservedConflictService = {
ensureAllProcessed(): Promise<boolean>;
conflictProcessQueueCount: { value: number };
};
type ObsidianTestApp = {
commands?: { executeCommandById(commandId: string): boolean };
plugins?: {
disablePlugin(pluginId: string): Promise<void>;
enablePlugin(pluginId: string): Promise<void>;
plugins?: Record<
string,
| {
core?: {
services?: {
conflict?: ObservedConflictService;
};
};
}
| undefined
>;
};
};
type ObsidianTestGlobal = typeof globalThis & {
app?: ObsidianTestApp;
__livesyncConflictChecksCompleted?: boolean;
__livesyncWaitingConflictCompleted?: boolean;
__livesyncConflictChecksError?: string;
__livesyncWaitingConflictError?: string;
__livesyncObservedConflict?: ObservedConflictService;
};
async function recordConflictDiagnostics(port: number, label: string) {
const state = await withObsidianPage(
port,
async (page) =>
await page.evaluate(() => {
const host = globalThis as ObsidianTestGlobal;
const plugin = host.app?.plugins?.plugins?.["obsidian-livesync"];
const conflict = host.__livesyncObservedConflict ?? plugin?.core?.services?.conflict;
return {
pluginLoaded: plugin !== undefined,
queueCount: conflict?.conflictProcessQueueCount.value,
checksCompleted: host.__livesyncConflictChecksCompleted,
waitingCompleted: host.__livesyncWaitingConflictCompleted,
checksError: host.__livesyncConflictChecksError,
waitingError: host.__livesyncWaitingConflictError,
visibility: document.visibilityState,
dialogues: Array.from(document.querySelectorAll(".modal-container")).map((element) => ({
visible: element.getClientRects().length > 0,
title: element.querySelector(".modal-title")?.textContent,
text: element.textContent?.slice(0, 300),
actions: element.querySelectorAll(".conflict-action-button").length,
})),
};
})
);
const directory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
await mkdir(directory, { recursive: true });
await writeFile(join(directory, `conflict-${label}.json`), JSON.stringify(state, null, 2) + "\n");
console.log(`Conflict ${label}: ${JSON.stringify(state)}`);
return state;
}
async function createAndOpenBaseFile(cliBinary: string, env: NodeJS.ProcessEnv, targetPath = path): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(targetPath)};`,
`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[],
targetPath = path
): Promise<ConflictFixture> {
return await evalObsidianJson<ConflictFixture>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(targetPath)};`,
`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,
targetPath = path
): Promise<ConflictFixture> {
return await evalObsidianJson<ConflictFixture>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(targetPath)};`,
"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,
targetPath = path
): Promise<ConflictFixture> {
const deadline = Date.now() + uiTimeoutMs;
let fixture = await readConflictFixture(cliBinary, env, targetPath);
while (fixture.conflicts.length !== expectedConflictCount && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 100));
fixture = await readConflictFixture(cliBinary, env, targetPath);
}
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,
targetPath = path
) {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(targetPath)};`,
`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,
targetPath = path
): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(targetPath)};`,
`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], targetPath?: string) {
const dialogues = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Conflicting changes" }),
});
return targetPath === undefined ? dialogues : dialogues.filter({ hasText: targetPath });
}
async function createTwoVersionConflict(
cliBinary: string,
env: NodeJS.ProcessEnv,
targetPath: string
): Promise<ConflictFixture> {
await createAndOpenBaseFile(cliBinary, env, targetPath);
const base = await waitForLocalDatabaseEntry(cliBinary, env, targetPath);
const fixture = await createManualConflict(cliBinary, env, base.rev, [leftContent, rightContent], targetPath);
if (fixture.conflicts.length !== 1) {
throw new Error(`Expected exactly two live leaves for ${targetPath}: ${JSON.stringify(fixture)}`);
}
return { ...fixture, currentParentRev: base.rev };
}
async function setShowMergeDialogOnlyOnActive(
cliBinary: string,
env: NodeJS.ProcessEnv,
enabled: boolean
): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(()=>{",
`const enabled=${JSON.stringify(enabled)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"core.settings.showMergeDialogOnlyOnActive=enabled;",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env
);
}
async function requestRepeatedConflictChecks(
cliBinary: string,
env: NodeJS.ProcessEnv,
targetPath: string,
count: number
): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(targetPath)};`,
`const count=${JSON.stringify(count)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"await Promise.all(Array.from({length:count},()=>core.services.conflict.queueCheckFor(path)));",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env
);
}
async function requestInteractiveConflictResolution(
cliBinary: string,
env: NodeJS.ProcessEnv,
targetPath: string,
fixture: ConflictFixture,
trackCompletion = false
): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(()=>{",
`const path=${JSON.stringify(targetPath)};`,
`const currentRev=${JSON.stringify(fixture.currentRev)};`,
`const conflictRev=${JSON.stringify(fixture.conflicts[0])};`,
`const trackCompletion=${JSON.stringify(trackCompletion)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"if(trackCompletion){",
" globalThis.__livesyncWaitingConflictCompleted=false;",
" globalThis.__livesyncWaitingConflictError=undefined;",
"}",
"const pending=core.services.conflict.resolveByUserInteraction(path,{",
"left:{rev:currentRev,data:'Current branch',ctime:1,mtime:2},",
"right:{rev:conflictRev,data:'Conflict branch',ctime:1,mtime:3},",
"diff:[[0,'Current and conflict branches']],",
"});",
"if(trackCompletion) void pending.then(",
" ()=>{globalThis.__livesyncWaitingConflictCompleted=true;},",
" (error)=>{globalThis.__livesyncWaitingConflictError=error?.stack??String(error);}",
");",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env
);
}
async function disableLiveSyncAndWaitForConflictChecks(port: number): Promise<void> {
await withObsidianPage(port, async (page) => {
await page.evaluate(() => {
const host = globalThis as ObsidianTestGlobal;
const conflict = host.app?.plugins?.plugins?.["obsidian-livesync"]?.core?.services?.conflict;
if (conflict === undefined) throw new Error("LiveSync conflict service is unavailable before unload");
host.__livesyncObservedConflict = conflict;
host.__livesyncConflictChecksCompleted = false;
host.__livesyncConflictChecksError = undefined;
void conflict.ensureAllProcessed().then(
() => {
host.__livesyncConflictChecksCompleted = true;
},
(error) => {
host.__livesyncConflictChecksError =
error instanceof Error ? (error.stack ?? String(error)) : String(error);
}
);
});
await page.evaluate(async () => {
const plugins = (globalThis as ObsidianTestGlobal).app?.plugins;
if (plugins === undefined) throw new Error("Obsidian plug-in manager is unavailable");
await plugins.disablePlugin("obsidian-livesync");
});
await conflictDialogue(page).waitFor({ state: "hidden", timeout: uiTimeoutMs });
await page.waitForFunction(
() => {
const host = globalThis as ObsidianTestGlobal;
return (
host.__livesyncConflictChecksCompleted === true && host.__livesyncWaitingConflictCompleted === true
);
},
undefined,
{ timeout: uiTimeoutMs }
);
});
}
async function enableLiveSync(port: number): Promise<void> {
await withObsidianPage(port, async (page) => {
await page.evaluate(async () => {
const plugins = (globalThis as ObsidianTestGlobal).app?.plugins;
if (plugins === undefined) throw new Error("Obsidian plug-in manager is unavailable");
await plugins.enablePlugin("obsidian-livesync");
});
});
}
type UnloadConflictFixture = { path: string; fixture: ConflictFixture };
type PreservedConflictState = {
path: string;
currentRev: string;
baseContent: string | false;
vaultContent: string;
leaves: { rev: string; parentRev: string; content: string | false; deleted: boolean }[];
};
async function recordUnloadPreservation(
cliBinary: string,
env: NodeJS.ProcessEnv,
label: string,
fixtures: UnloadConflictFixture[],
before?: PreservedConflictState[]
): Promise<PreservedConflictState[]> {
const states = await evalObsidianJson<PreservedConflictState[]>(
cliBinary,
[
"(async()=>{",
`const fixtures=${JSON.stringify(fixtures)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const contentOf=(entry)=>entry===false?false:Array.isArray(entry.data)?entry.data.join(''):entry.data;",
"const states=[];",
"for(const {path,fixture} of fixtures){",
" const id=await core.services.path.path2id(path);",
" const meta=await core.localDatabase.getRaw(id,{conflicts:true});",
" const leaves=[];",
" for(const rev of [meta._rev,...(meta._conflicts??[])].sort()){",
" const raw=await core.localDatabase.getRaw(id,{rev,revs:true});",
" const history=raw._revisions;",
" const parentRev=history?.ids?.length>1?`${history.start-1}-${history.ids[1]}`:undefined;",
" const entry=await core.localDatabase.getDBEntry(path,{rev},false,true,true);",
" leaves.push({rev,parentRev,content:contentOf(entry),deleted:!!(raw.deleted||raw._deleted)});",
" }",
" const base=await core.localDatabase.getDBEntry(path,{rev:fixture.currentParentRev},false,true,true);",
" const vaultContent=await app.vault.adapter.read(path);",
" states.push({path,currentRev:meta._rev,baseContent:contentOf(base),vaultContent,leaves});",
"}",
"return JSON.stringify(states);",
"})()",
].join(""),
env
);
const directory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
await mkdir(directory, { recursive: true });
await writeFile(join(directory, `conflict-${label}-preservation.json`), JSON.stringify(states, null, 2) + "\n");
for (const { path: targetPath, fixture } of fixtures) {
const state = states.find((entry) => entry.path === targetPath);
const expectedRevs = [fixture.currentRev, ...fixture.conflicts].sort();
if (
!state ||
state.currentRev !== fixture.currentRev ||
state.baseContent !== baseContent ||
JSON.stringify(state.leaves.map((leaf) => leaf.rev)) !== JSON.stringify(expectedRevs) ||
JSON.stringify(state.leaves.map((leaf) => leaf.content).sort()) !==
JSON.stringify([leftContent, rightContent].sort()) ||
state.leaves.some((leaf) => leaf.deleted || leaf.parentRev !== fixture.currentParentRev)
) {
throw new Error(`Conflict contents or ancestry changed across ${label}: ${JSON.stringify(state)}`);
}
const previous = before?.find((entry) => entry.path === targetPath);
if (previous && state.vaultContent !== previous.vaultContent) {
throw new Error(`Vault content changed across ${label}: ${targetPath}`);
}
}
console.log(`Verified conflict contents, leaf revisions, and ancestry: ${label}`);
return states;
}
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;
let unloadFixtures: UnloadConflictFixture[] = [];
let unloadPreservationBefore: PreservedConflictState[] | undefined;
try {
session = await startObsidianLiveSyncSession({
binary,
cliBinary,
vault,
artifactRoot: process.env.E2E_OBSIDIAN_ARTIFACT_ROOT,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: {
doctorProcessedVersion: DoctorRegulation.version,
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 firstDialogueScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"conflict-dialog-three-versions.png",
(page) => conflictDialogue(page).locator(".modal").first()
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.waitForFunction(
(targetPath) => {
const dialogues = Array.from(document.querySelectorAll(".modal-container")).filter(
(element) =>
element.getClientRects().length > 0 &&
element.querySelector(".modal-title")?.textContent?.includes("Conflicting changes") &&
element.textContent?.includes(targetPath)
);
if (dialogues.length !== 1) return false;
const bounds = Array.from(dialogues[0].querySelectorAll(".conflict-action-button")).map((button) =>
button.getBoundingClientRect()
);
return (
bounds.length === 4 &&
bounds.every(
(button, index) =>
button.width > 0 &&
button.height > 0 &&
(index === 0 || button.top >= bounds[index - 1].bottom)
)
);
},
path,
{ timeout: uiTimeoutMs }
);
});
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,
artifactRoot: process.env.E2E_OBSIDIAN_ARTIFACT_ROOT,
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,
artifactRoot: process.env.E2E_OBSIDIAN_ARTIFACT_ROOT,
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 });
});
// Keep concurrent manual comparisons independent from the active-file
// gate so that the dialogue serialisation policy is exercised directly.
await setShowMergeDialogOnlyOnActive(cliBinary, session.cliEnv, false);
await createTwoVersionConflict(cliBinary, session.cliEnv, repeatedPath);
await requestConflictCheck(cliBinary, session.cliEnv, false, repeatedPath);
const repeatedSession = session;
await withObsidianPage(repeatedSession.remoteDebuggingPort, async (page) => {
const modal = conflictDialogue(page, repeatedPath);
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
const original = await modal.elementHandle();
if (original === null) throw new Error("Could not retain the original same-file conflict dialogue");
await requestRepeatedConflictChecks(cliBinary, repeatedSession.cliEnv, repeatedPath, 3);
await page.waitForFunction((element) => !element.isConnected, original, { timeout: uiTimeoutMs });
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
// All enqueue calls have settled. Only the final resolver may remain
// while its dialogue waits for a response; earlier checks also count.
await page.waitForFunction(
(targetPath) => {
const conflict = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins?.["obsidian-livesync"]
?.core?.services?.conflict;
const visible = Array.from(document.querySelectorAll(".modal-container")).filter(
(element) =>
element.getClientRects().length > 0 &&
element.querySelector(".modal-title")?.textContent?.includes("Conflicting changes") &&
element.textContent?.includes(targetPath)
);
return conflict?.conflictProcessQueueCount.value === 1 && visible.length === 1;
},
repeatedPath,
{ timeout: uiTimeoutMs }
);
const visibleCount = await conflictDialogue(page, repeatedPath).evaluateAll(
(elements) => elements.filter((element) => element.getClientRects().length > 0).length
);
if (visibleCount !== 1) {
const dialogues = await page.locator(".modal-container").evaluateAll((elements) =>
elements.map((element) => ({
visible: element.getClientRects().length > 0,
text: element.textContent?.slice(0, 1200),
}))
);
const fixture = await readConflictFixture(cliBinary, repeatedSession.cliEnv, repeatedPath);
console.error(JSON.stringify({ sameFileReplacementFailure: { visibleCount, dialogues, fixture } }));
await captureObsidianPage(
repeatedSession.remoteDebuggingPort,
"conflict-dialog-same-file-replacement.failure.png",
async () => {}
);
throw new Error(`Expected one newest same-file dialogue, but found ${visibleCount}`);
}
});
const repeatedDialogueScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"conflict-dialog-same-file-replacement.png",
(page) => conflictDialogue(page, repeatedPath).locator(".modal").first()
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const modal = conflictDialogue(page, repeatedPath);
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 withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.waitForTimeout(500);
if (await conflictDialogue(page, repeatedPath).isVisible()) {
throw new Error("A superseded same-file conflict dialogue opened after the newest request completed");
}
});
await createTwoVersionConflict(cliBinary, session.cliEnv, activePath);
await createTwoVersionConflict(cliBinary, session.cliEnv, waitingPath);
const externallyResolvedWaitingFixture = await createTwoVersionConflict(
cliBinary,
session.cliEnv,
externallyResolvedWaitingPath
);
await requestConflictCheck(cliBinary, session.cliEnv, false, activePath);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await conflictDialogue(page, activePath).waitFor({ state: "visible", timeout: uiTimeoutMs });
});
await requestConflictCheck(cliBinary, session.cliEnv, false, waitingPath);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.waitForTimeout(500);
if (!(await conflictDialogue(page, activePath).isVisible())) {
throw new Error("A different-file request closed the active conflict dialogue");
}
if (await conflictDialogue(page, waitingPath).isVisible()) {
throw new Error("A different-file conflict dialogue opened before the active dialogue completed");
}
});
const differentFileWaitingScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"conflict-dialog-different-file-waiting.png",
(page) => conflictDialogue(page, activePath).locator(".modal").first()
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const activeModal = conflictDialogue(page, activePath);
await activeModal.getByRole("button", { name: "Not now", exact: true }).click({ timeout: uiTimeoutMs });
await activeModal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
await conflictDialogue(page, waitingPath).waitFor({ state: "visible", timeout: uiTimeoutMs });
});
await requestInteractiveConflictResolution(
cliBinary,
session.cliEnv,
externallyResolvedWaitingPath,
externallyResolvedWaitingFixture,
true
);
await applyReplicatedConflictResolution(
cliBinary,
session.cliEnv,
externallyResolvedWaitingFixture.conflicts[0],
0,
externallyResolvedWaitingPath
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
if (!(await conflictDialogue(page, waitingPath).isVisible())) {
throw new Error("Resolving a waiting file elsewhere closed the active different-file dialogue");
}
if (await conflictDialogue(page, externallyResolvedWaitingPath).isVisible()) {
throw new Error("A conflict dialogue opened for a waiting file which was already resolved elsewhere");
}
const waitingModal = conflictDialogue(page, waitingPath);
await waitingModal.getByRole("button", { name: "Not now", exact: true }).click({ timeout: uiTimeoutMs });
await waitingModal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
await page.waitForFunction(
() => (globalThis as ObsidianTestGlobal).__livesyncWaitingConflictCompleted === true,
undefined,
{ timeout: uiTimeoutMs }
);
await page.waitForTimeout(250);
if (await conflictDialogue(page, externallyResolvedWaitingPath).isVisible()) {
throw new Error("A resolved waiting-file dialogue opened after the active dialogue completed");
}
});
await waitForConflictChecks(cliBinary, session.cliEnv);
const unloadActiveFixture = await createTwoVersionConflict(cliBinary, session.cliEnv, unloadActivePath);
const unloadWaitingFixture = await createTwoVersionConflict(cliBinary, session.cliEnv, unloadWaitingPath);
unloadFixtures = [
{ path: unloadActivePath, fixture: unloadActiveFixture },
{ path: unloadWaitingPath, fixture: unloadWaitingFixture },
];
await requestConflictCheck(cliBinary, session.cliEnv, false, unloadActivePath);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await conflictDialogue(page, unloadActivePath).waitFor({ state: "visible", timeout: uiTimeoutMs });
});
await requestInteractiveConflictResolution(
cliBinary,
session.cliEnv,
unloadWaitingPath,
unloadWaitingFixture,
true
);
const beforeUnloadScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"conflict-dialog-before-unload.png",
(page) => conflictDialogue(page, unloadActivePath).locator(".modal").first()
);
unloadPreservationBefore = await recordUnloadPreservation(
cliBinary,
session.cliEnv,
"before-unload",
unloadFixtures
);
await disableLiveSyncAndWaitForConflictChecks(session.remoteDebuggingPort);
await recordConflictDiagnostics(session.remoteDebuggingPort, "unloaded");
const afterUnloadScreenshot = await captureObsidianPage(
session.remoteDebuggingPort,
"conflict-dialog-after-unload.png",
async (page) => {
await page.waitForTimeout(250);
if (await conflictDialogue(page).isVisible()) {
throw new Error("A conflict dialogue remained visible after LiveSync was unloaded");
}
const pluginStillLoaded = await page.evaluate(
() => (globalThis as ObsidianTestGlobal).app?.plugins?.plugins?.["obsidian-livesync"] !== undefined
);
if (pluginStillLoaded) throw new Error("LiveSync remained loaded after disablePlugin completed");
}
);
await enableLiveSync(session.remoteDebuggingPort);
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
await recordUnloadPreservation(
cliBinary,
session.cliEnv,
"after-reload",
unloadFixtures,
unloadPreservationBefore
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.waitForTimeout(500);
if (await conflictDialogue(page).isVisible()) {
throw new Error("A stale conflict dialogue reopened after LiveSync was enabled again");
}
});
await createAndOpenBaseFile(cliBinary, session.cliEnv, unloadActivePath);
await requestConflictCheck(cliBinary, session.cliEnv, false, unloadActivePath);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await conflictDialogue(page, unloadActivePath).waitFor({ state: "visible", timeout: uiTimeoutMs });
});
const afterReloadScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"conflict-dialog-after-reload.png",
(page) => conflictDialogue(page, unloadActivePath).locator(".modal").first()
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const modal = conflictDialogue(page, unloadActivePath);
await modal.getByRole("button", { name: "Not now", exact: true }).click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
await waitForConflictChecks(cliBinary, session.cliEnv);
console.log(
"Real Obsidian preserved pairwise conflict resolution and dialogue presentation; replaced only stale same-file dialogues; serialised different files; discarded externally resolved waiting requests; and drained active and waiting requests across unload and reload."
);
console.log(`Dialogue screenshot: ${firstDialogueScreenshot}`);
console.log(`Postponed warning screenshot: ${warningScreenshot}`);
console.log(`Same-file replacement screenshot: ${repeatedDialogueScreenshot}`);
console.log(`Different-file waiting screenshot: ${differentFileWaitingScreenshot}`);
console.log(`Before unload screenshot: ${beforeUnloadScreenshot}`);
console.log(`After unload screenshot: ${afterUnloadScreenshot}`);
console.log(`After reload screenshot: ${afterReloadScreenshot}`);
} catch (error) {
if (session) {
try {
const state = await recordConflictDiagnostics(session.remoteDebuggingPort, "failure");
await captureObsidianPage(session.remoteDebuggingPort, "conflict-failure.png", async () => {});
if (!state.pluginLoaded && unloadPreservationBefore) {
await enableLiveSync(session.remoteDebuggingPort);
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
await recordUnloadPreservation(
cliBinary,
session.cliEnv,
"after-failed-unload-reload",
unloadFixtures,
unloadPreservationBefore
);
}
} catch (diagnosticError) {
console.error("Could not capture conflict diagnostics:", diagnosticError);
}
}
throw error;
} 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);
});