From 6f8df8f7316b503ac9b97e44658ea9c232a21736 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Tue, 3 Nov 2026 04:27:11 +0000 Subject: [PATCH] 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. --- ...interactiveConflictResolution.unit.spec.ts | 273 +++++++++++++++++- .../operations.ts | 41 ++- .../scripts/conflict-dialog-policy.ts | 243 +++++++++++++--- updates.md | 4 + 4 files changed, 511 insertions(+), 50 deletions(-) diff --git a/src/serviceFeatures/interactiveConflictResolution/interactiveConflictResolution.unit.spec.ts b/src/serviceFeatures/interactiveConflictResolution/interactiveConflictResolution.unit.spec.ts index 69c10048..0935107f 100644 --- a/src/serviceFeatures/interactiveConflictResolution/interactiveConflictResolution.unit.spec.ts +++ b/src/serviceFeatures/interactiveConflictResolution/interactiveConflictResolution.unit.spec.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { createServiceContext } from "@vrtmrz/livesync-commonlib/context"; +import { bindableFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils"; +import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils"; import { AUTO_MERGED, CANCELLED, @@ -30,7 +32,10 @@ async function* documents(items: unknown[]) { } } -function createOperations(conflictedRevisions: string[] = ["2-right"]) { +function createOperations( + conflictedRevisions: string[] = ["2-right"], + overrides: Partial = {} +) { const context = createServiceContext(); let dialogueResult: unknown = POSTPONED; const constructed = { value: 0 }; @@ -75,6 +80,7 @@ function createOperations(conflictedRevisions: string[] = ["2-right"]) { }; }), log, + ...overrides, } as unknown as InteractiveConflictResolutionOperationsDependencies); return { askSelectString, @@ -181,6 +187,271 @@ function createDialogueConcurrencyHarness() { } describe("interactive conflict resolution operations", () => { + it.each([ + { label: "cancelled", result: CANCELLED }, + { label: "postponed", result: POSTPONED }, + { label: "selected", result: "2-right" }, + ] as const)("ignores a $label dialogue result which settles after disposal", async ({ result }) => { + let finish!: (result: MergeDialogResult) => void; + const response = new Promise((resolve) => { + finish = resolve; + }); + const sink = vi.fn(); + const addLog = bindableFunction<(message: unknown) => void>("addLog"); + const unregisterLog = addLog.setHandler(sink); + const createDialogue = vi.fn(() => ({ open: vi.fn(), waitForResult: () => response })); + const fixture = createOperations(undefined, { + createDialogue, + log: createInstanceLogFunction("Conflict shutdown", { addLog } as never), + }); + const active = fixture.operations.resolveByUserInteraction(path, conflict); + await vi.waitFor(() => expect(createDialogue).toHaveBeenCalledOnce()); + const waiting = fixture.operations.resolveByUserInteraction("waiting.md" as FilePathWithPrefix, conflict); + const settled = Promise.allSettled([active, waiting]); + const logsBeforeDisposal = sink.mock.calls.length; + + fixture.operations.dispose(); + unregisterLog(); + finish(result); + + expect(await settled).toEqual([ + { status: "fulfilled", value: false }, + { status: "fulfilled", value: false }, + ]); + expect(createDialogue).toHaveBeenCalledOnce(); + expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal); + expect(fixture.getDBEntry).not.toHaveBeenCalled(); + expect(fixture.storeContent).not.toHaveBeenCalled(); + expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled(); + expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled(); + expect(fixture.queueCheckFor).not.toHaveBeenCalled(); + }); + + it("does not continue a selected conflict after the database read settles following disposal", async () => { + let finishRead!: (value: false | { _rev: string; _conflicts?: string[] }) => void; + const pendingRead = new Promise((resolve) => { + finishRead = resolve; + }); + const sink = vi.fn(); + const addLog = bindableFunction<(message: unknown) => void>("addLog"); + const unregisterLog = addLog.setHandler(sink); + const fixture = createOperations(undefined, { + createDialogue: vi.fn(() => ({ + open: vi.fn(), + waitForResult: vi.fn(async () => "2-right"), + })), + log: createInstanceLogFunction("Conflict shutdown", { addLog } as never), + }); + fixture.getDBEntry.mockReturnValue(pendingRead); + + const resolving = fixture.operations.resolveByUserInteraction(path, conflict); + await vi.waitFor(() => expect(fixture.getDBEntry).toHaveBeenCalledOnce()); + const logsBeforeDisposal = sink.mock.calls.length; + + fixture.operations.dispose(); + unregisterLog(); + finishRead(false); + + await expect(resolving).resolves.toBe(false); + expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal); + expect(fixture.storeContent).not.toHaveBeenCalled(); + expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled(); + expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled(); + expect(fixture.queueCheckFor).not.toHaveBeenCalled(); + }); + + it("does not log a failed content store after disposal", async () => { + let finishStore!: (stored: boolean) => void; + const pendingStore = new Promise((resolve) => { + finishStore = resolve; + }); + const sink = vi.fn(); + const addLog = bindableFunction<(message: unknown) => void>("addLog"); + const unregisterLog = addLog.setHandler(sink); + const fixture = createOperations(undefined, { + log: createInstanceLogFunction("Conflict shutdown", { addLog } as never), + }); + fixture.dialogueResult.value = LEAVE_TO_SUBSEQUENT; + fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] }); + fixture.storeContent.mockReturnValue(pendingStore); + + const resolving = fixture.operations.resolveByUserInteraction(path, conflict); + await vi.waitFor(() => expect(fixture.storeContent).toHaveBeenCalledOnce()); + const logsBeforeDisposal = sink.mock.calls.length; + + fixture.operations.dispose(); + unregisterLog(); + finishStore(false); + + await expect(resolving).resolves.toBe(false); + expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal); + expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled(); + expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled(); + expect(fixture.queueCheckFor).not.toHaveBeenCalled(); + }); + + it("does not delete or requeue after content storage succeeds following disposal", async () => { + let finishStore!: (stored: boolean) => void; + const pendingStore = new Promise((resolve) => { + finishStore = resolve; + }); + const sink = vi.fn(); + const addLog = bindableFunction<(message: unknown) => void>("addLog"); + const unregisterLog = addLog.setHandler(sink); + const fixture = createOperations(undefined, { + log: createInstanceLogFunction("Conflict shutdown", { addLog } as never), + }); + fixture.dialogueResult.value = LEAVE_TO_SUBSEQUENT; + fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] }); + fixture.storeContent.mockReturnValue(pendingStore); + fixture.currentSettings.mockReturnValue({ syncAfterMerge: true }); + + const resolving = fixture.operations.resolveByUserInteraction(path, conflict); + await vi.waitFor(() => expect(fixture.storeContent).toHaveBeenCalledOnce()); + const logsBeforeDisposal = sink.mock.calls.length; + + fixture.operations.dispose(); + unregisterLog(); + finishStore(true); + + await expect(resolving).resolves.toBe(false); + expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal); + expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled(); + expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled(); + expect(fixture.queueCheckFor).not.toHaveBeenCalled(); + }); + + it("does not log a failed revision deletion after disposal", async () => { + let finishDeletion!: (result: typeof AUTO_MERGED | typeof MISSING_OR_ERROR) => void; + const pendingDeletion = new Promise((resolve) => { + finishDeletion = resolve; + }); + const sink = vi.fn(); + const addLog = bindableFunction<(message: unknown) => void>("addLog"); + const unregisterLog = addLog.setHandler(sink); + const fixture = createOperations(undefined, { + log: createInstanceLogFunction("Conflict shutdown", { addLog } as never), + }); + fixture.dialogueResult.value = "2-right"; + fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] }); + fixture.resolveByDeletingRevision.mockReturnValue(pendingDeletion); + + const resolving = fixture.operations.resolveByUserInteraction(path, conflict); + await vi.waitFor(() => expect(fixture.resolveByDeletingRevision).toHaveBeenCalledOnce()); + const logsBeforeDisposal = sink.mock.calls.length; + + fixture.operations.dispose(); + unregisterLog(); + finishDeletion(MISSING_OR_ERROR); + + await expect(resolving).resolves.toBe(false); + expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal); + expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled(); + expect(fixture.queueCheckFor).not.toHaveBeenCalled(); + }); + + it("does not replicate or requeue after revision deletion succeeds following disposal", async () => { + let finishDeletion!: (result: typeof AUTO_MERGED | typeof MISSING_OR_ERROR) => void; + const pendingDeletion = new Promise((resolve) => { + finishDeletion = resolve; + }); + const sink = vi.fn(); + const addLog = bindableFunction<(message: unknown) => void>("addLog"); + const unregisterLog = addLog.setHandler(sink); + const fixture = createOperations(undefined, { + log: createInstanceLogFunction("Conflict shutdown", { addLog } as never), + }); + fixture.dialogueResult.value = "2-right"; + fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] }); + fixture.resolveByDeletingRevision.mockReturnValue(pendingDeletion); + fixture.currentSettings.mockReturnValue({ syncAfterMerge: true }); + + const resolving = fixture.operations.resolveByUserInteraction(path, conflict); + await vi.waitFor(() => expect(fixture.resolveByDeletingRevision).toHaveBeenCalledOnce()); + const logsBeforeDisposal = sink.mock.calls.length; + + fixture.operations.dispose(); + unregisterLog(); + finishDeletion(AUTO_MERGED); + + await expect(resolving).resolves.toBe(false); + expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal); + expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled(); + expect(fixture.queueCheckFor).not.toHaveBeenCalled(); + }); + + it("does not log or emit a conflict refresh after a pending inspection rejects following disposal", async () => { + let rejectInspection!: (reason: unknown) => void; + const pendingInspection = new Promise((_resolve, reject) => { + rejectInspection = reject; + }); + const sink = vi.fn(); + const addLog = bindableFunction<(message: unknown) => void>("addLog"); + const unregisterLog = addLog.setHandler(sink); + const fixture = createOperations(undefined, { + log: createInstanceLogFunction("Conflict shutdown", { addLog } as never), + }); + fixture.getConflictedRevs.mockReturnValue(pendingInspection); + const emitEvent = vi.spyOn(fixture.context.events, "emitEvent"); + + const refreshing = fixture.operations.refreshConflictState(path); + await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce()); + const logsBeforeDisposal = sink.mock.calls.length; + + fixture.operations.dispose(); + unregisterLog(); + rejectInspection(new Error("database unavailable")); + + await expect(refreshing).resolves.toBeUndefined(); + expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal); + expect(emitEvent).not.toHaveBeenCalledWith(EVENT_ON_UNRESOLVED_ERROR); + }); + + it("does not emit a successful conflict refresh after disposal", async () => { + let finishInspection!: (revisions: string[]) => void; + const pendingInspection = new Promise((resolve) => { + finishInspection = resolve; + }); + const sink = vi.fn(); + const addLog = bindableFunction<(message: unknown) => void>("addLog"); + const unregisterLog = addLog.setHandler(sink); + const fixture = createOperations(undefined, { + log: createInstanceLogFunction("Conflict shutdown", { addLog } as never), + }); + fixture.getConflictedRevs.mockReturnValue(pendingInspection); + const emitEvent = vi.spyOn(fixture.context.events, "emitEvent"); + + const refreshing = fixture.operations.refreshConflictState(path); + await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce()); + const logsBeforeDisposal = sink.mock.calls.length; + + fixture.operations.dispose(); + unregisterLog(); + finishInspection(["2-right"]); + + await expect(refreshing).resolves.toBeUndefined(); + expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal); + expect(emitEvent).not.toHaveBeenCalledWith(EVENT_ON_UNRESOLVED_ERROR); + }); + + it("returns no active conflict messages when inspection settles after disposal", async () => { + let finishInspection!: (revisions: string[]) => void; + const pendingInspection = new Promise((resolve) => { + finishInspection = resolve; + }); + const fixture = createOperations(undefined, { + log: vi.fn(), + }); + fixture.getConflictedRevs.mockReturnValue(pendingInspection); + + const messages = fixture.operations.getActiveConflictMessages(); + await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce()); + fixture.operations.dispose(); + finishInspection(["2-right"]); + + await expect(messages).resolves.toEqual([]); + }); + it("replaces an active same-file dialogue with only the newest waiting request", async () => { const fixture = createDialogueConcurrencyHarness(); diff --git a/src/serviceFeatures/interactiveConflictResolution/operations.ts b/src/serviceFeatures/interactiveConflictResolution/operations.ts index 3196b126..858b5245 100644 --- a/src/serviceFeatures/interactiveConflictResolution/operations.ts +++ b/src/serviceFeatures/interactiveConflictResolution/operations.ts @@ -100,10 +100,13 @@ export function createInteractiveConflictResolutionOperations( }; const getConflictVersionCount = async (filename: FilePathWithPrefix): Promise => { + if (disposed) return undefined; try { const conflictCount = (await dependencies.databaseFileAccess.getConflictedRevs(filename)).length; + if (disposed) return undefined; return conflictCount === 0 ? 0 : conflictCount + 1; } catch (error) { + if (disposed) return undefined; dependencies.log(`Could not inspect the conflict state of ${filename}`, LOG_LEVEL_VERBOSE); dependencies.log(error, LOG_LEVEL_VERBOSE); return undefined; @@ -111,9 +114,11 @@ export function createInteractiveConflictResolutionOperations( }; const getActiveConflictMessages = async (): Promise => { + if (disposed) return []; const filename = dependencies.vault.getActiveFilePath(); if (!filename) return []; const versionCount = await getConflictVersionCount(filename); + if (disposed) return []; if (versionCount === 0) { postponedConflictEpisodes.delete(filename); return []; @@ -132,7 +137,10 @@ export function createInteractiveConflictResolutionOperations( }; const refreshConflictState = async (filename: FilePathWithPrefix): Promise => { - if ((await getConflictVersionCount(filename)) === 0) { + if (disposed) return; + const versionCount = await getConflictVersionCount(filename); + if (disposed) return; + if (versionCount === 0) { postponedConflictEpisodes.delete(filename); } dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR); @@ -182,6 +190,7 @@ export function createInteractiveConflictResolutionOperations( activeDialogue = undefined; } } + if (disposed) return false; if (selected === POSTPONED) { postponedConflictEpisodes.add(filename); dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR); @@ -196,6 +205,7 @@ export function createInteractiveConflictResolutionOperations( const testDoc = await dependencies .localDatabase() .getDBEntry(filename, { conflicts: true }, false, true, true); + if (disposed) return false; if (testDoc === false) { dependencies.log(`Merge: Could not read ${filename} from the local database`, LOG_LEVEL_VERBOSE); return false; @@ -203,6 +213,7 @@ export function createInteractiveConflictResolutionOperations( if (!testDoc._conflicts || testDoc._conflicts.length === 0) { dependencies.log(`Merge: Nothing to do ${filename}`, LOG_LEVEL_VERBOSE); await refreshConflictState(filename); + if (disposed) return false; return false; } if ( @@ -214,6 +225,7 @@ export function createInteractiveConflictResolutionOperations( LOG_LEVEL_INFO ); await refreshConflictState(filename); + if (disposed) return false; await dependencies.conflict.queueCheckFor(filename); return false; } @@ -224,15 +236,20 @@ export function createInteractiveConflictResolutionOperations( // Create a new file by concatenating both conflicted revisions. const p = conflictCheckResult.diff.map((e) => e[1]).join(""); const delRev = conflictCheckResult.right.rev; - if (!(await dependencies.databaseFileAccess.storeContent(filename, p))) { + const contentStored = await dependencies.databaseFileAccess.storeContent(filename, p); + if (disposed) return false; + if (!contentStored) { dependencies.log(`Concatenated content cannot be stored:${filename}`, LOG_LEVEL_NOTICE); return false; } // 2. As usual, delete the conflicted revision and if there are no conflicts, write the resolved content to the storage. - if ( - (await dependencies.conflict.resolveByDeletingRevision(filename, delRev, "UI Concatenated")) == - MISSING_OR_ERROR - ) { + const deletionResult = await dependencies.conflict.resolveByDeletingRevision( + filename, + delRev, + "UI Concatenated" + ); + if (disposed) return false; + if (deletionResult == MISSING_OR_ERROR) { dependencies.log( `Concatenated saved, but cannot delete conflicted revisions: ${filename}, (${displayRev(delRev)})`, LOG_LEVEL_NOTICE @@ -244,10 +261,13 @@ export function createInteractiveConflictResolutionOperations( (toDelete === conflictCheckResult.left.rev || toDelete === conflictCheckResult.right.rev) ) { // Select one of the conflicted revision to delete. - if ( - (await dependencies.conflict.resolveByDeletingRevision(filename, toDelete, "UI Selected")) == - MISSING_OR_ERROR - ) { + const deletionResult = await dependencies.conflict.resolveByDeletingRevision( + filename, + toDelete, + "UI Selected" + ); + if (disposed) return false; + if (deletionResult == MISSING_OR_ERROR) { dependencies.log(`Merge: Something went wrong: ${filename}, (${toDelete})`, LOG_LEVEL_NOTICE); return false; } @@ -266,6 +286,7 @@ export function createInteractiveConflictResolutionOperations( trigger: "merge", interaction: NO_INTERACTION, }); + if (disposed) return false; } // And, check it again. await dependencies.conflict.queueCheckFor(filename); diff --git a/test/e2e-obsidian/scripts/conflict-dialog-policy.ts b/test/e2e-obsidian/scripts/conflict-dialog-policy.ts index 62638685..85538acb 100644 --- a/test/e2e-obsidian/scripts/conflict-dialog-policy.ts +++ b/test/e2e-obsidian/scripts/conflict-dialog-policy.ts @@ -1,3 +1,6 @@ +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 { @@ -28,6 +31,11 @@ type ConflictFixture = { conflicts: string[]; }; +type ObservedConflictService = { + ensureAllProcessed(): Promise; + conflictProcessQueueCount: { value: number }; +}; + type ObsidianTestApp = { commands?: { executeCommandById(commandId: string): boolean }; plugins?: { @@ -38,10 +46,7 @@ type ObsidianTestApp = { | { core?: { services?: { - conflict?: { - ensureAllProcessed(): Promise; - conflictProcessQueueCount: { value: number }; - }; + conflict?: ObservedConflictService; }; }; } @@ -54,8 +59,43 @@ 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 { await evalObsidianJson( cliBinary, @@ -249,7 +289,7 @@ async function createTwoVersionConflict( if (fixture.conflicts.length !== 1) { throw new Error(`Expected exactly two live leaves for ${targetPath}: ${JSON.stringify(fixture)}`); } - return fixture; + return { ...fixture, currentParentRev: base.rev }; } async function setShowMergeDialogOnlyOnActive( @@ -308,13 +348,19 @@ async function requestInteractiveConflictResolution( `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;", + "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;});", + "if(trackCompletion) void pending.then(", + " ()=>{globalThis.__livesyncWaitingConflictCompleted=true;},", + " (error)=>{globalThis.__livesyncWaitingConflictError=error?.stack??String(error);}", + ");", "return JSON.stringify({ok:true});", "})()", ].join(""), @@ -328,10 +374,18 @@ async function disableLiveSyncAndWaitForConflictChecks(port: number): Promise { - host.__livesyncConflictChecksCompleted = true; - }); + 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; @@ -362,6 +416,76 @@ async function enableLiveSync(port: number): Promise { }); } +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 { + const states = await evalObsidianJson( + 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 { const binary = requireObsidianBinary(); const cli = discoverObsidianCli(); @@ -372,14 +496,17 @@ async function main(): Promise { 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: "1.0.0", + doctorProcessedVersion: DoctorRegulation.version, isConfigured: true, liveSync: false, remoteType: "", @@ -433,34 +560,31 @@ async function main(): Promise { (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 }; - }) + 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 } ); - 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); @@ -506,6 +630,7 @@ async function main(): Promise { 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); @@ -542,6 +667,7 @@ async function main(): Promise { 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); @@ -734,8 +860,12 @@ async function main(): Promise { }); await waitForConflictChecks(cliBinary, session.cliEnv); - await createTwoVersionConflict(cliBinary, session.cliEnv, unloadActivePath); + 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 }); @@ -752,7 +882,14 @@ async function main(): Promise { "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", @@ -770,6 +907,13 @@ async function main(): Promise { 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()) { @@ -803,6 +947,27 @@ async function main(): Promise { 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(); diff --git a/updates.md b/updates.md index 784e6591..8141b0f2 100644 --- a/updates.md +++ b/updates.md @@ -18,6 +18,10 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi ### Fixed +#### Conflict resolution + +- Pending conflict dialogues and their resolution steps stop using the released logger after the plug-in is disabled. Waiting continuations check for shutdown before logging or starting further resolution work. + #### Translations - The notice shown when a translation is available now displays the **Display Language** setting label correctly in English, French, Hebrew, Russian, and Simplified Chinese. (#1298) Thank you to @cwatanab for the contribution!