Files
obsidian-livesync/test/e2e-obsidian/scripts/conflict-dialog-policy.ts
T

786 lines
35 KiB
TypeScript

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 ObsidianTestApp = {
commands?: { executeCommandById(commandId: string): boolean };
plugins?: {
disablePlugin(pluginId: string): Promise<void>;
enablePlugin(pluginId: string): Promise<void>;
plugins?: Record<
string,
| {
core?: {
services?: {
conflict?: { ensureAllProcessed(): Promise<boolean> };
};
};
}
| undefined
>;
};
};
type ObsidianTestGlobal = typeof globalThis & {
app?: ObsidianTestApp;
__livesyncConflictChecksCompleted?: boolean;
__livesyncWaitingConflictCompleted?: boolean;
};
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;
}
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;",
"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;});",
"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.__livesyncConflictChecksCompleted = false;
void conflict.ensureAllProcessed().then(() => {
host.__livesyncConflictChecksCompleted = true;
});
});
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");
});
});
}
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 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);
const actionButtons = modal.locator(".conflict-action-button");
await actionButtons.nth(3).waitFor({ state: "visible", timeout: uiTimeoutMs });
const actionButtonBounds = await actionButtons.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
)
) {
const buttonDetails = await modal.locator("button").evaluateAll((buttons) =>
buttons.map((button) => ({
text: button.textContent,
className: button.className,
}))
);
throw new Error(
`Conflict action buttons are not stacked vertically: ${JSON.stringify({
actionButtonBounds,
buttonDetails,
})}`
);
}
});
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 });
});
// 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 });
await page.waitForTimeout(250);
const visibleCount = await conflictDialogue(page, repeatedPath).evaluateAll(
(elements) => elements.filter((element) => element.getClientRects().length > 0).length
);
if (visibleCount !== 1) {
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);
await createTwoVersionConflict(cliBinary, session.cliEnv, unloadActivePath);
const unloadWaitingFixture = await createTwoVersionConflict(cliBinary, session.cliEnv, unloadWaitingPath);
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()
);
await disableLiveSyncAndWaitForConflictChecks(session.remoteDebuggingPort);
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 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}`);
} 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);
});