mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-05 07:48:18 +00:00
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.
983 lines
45 KiB
TypeScript
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);
|
|
});
|