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.
This commit is contained in:
vorotamoroz committed 2026-11-03 08:44:31 +00:00
1 parent d557298ceb
commit 6f8df8f731
4 files changed
+511 -50

No files matched your search

@@ -1,5 +1,7 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context"; 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 { import {
AUTO_MERGED, AUTO_MERGED,
CANCELLED, CANCELLED,
@@ -30,7 +32,10 @@ async function* documents(items: unknown[]) {
} }
} }
function createOperations(conflictedRevisions: string[] = ["2-right"]) { function createOperations(
conflictedRevisions: string[] = ["2-right"],
overrides: Partial<InteractiveConflictResolutionOperationsDependencies> = {}
) {
const context = createServiceContext(); const context = createServiceContext();
let dialogueResult: unknown = POSTPONED; let dialogueResult: unknown = POSTPONED;
const constructed = { value: 0 }; const constructed = { value: 0 };
@@ -75,6 +80,7 @@ function createOperations(conflictedRevisions: string[] = ["2-right"]) {
}; };
}), }),
log, log,
...overrides,
} as unknown as InteractiveConflictResolutionOperationsDependencies); } as unknown as InteractiveConflictResolutionOperationsDependencies);
return { return {
askSelectString, askSelectString,
@@ -181,6 +187,271 @@ function createDialogueConcurrencyHarness() {
} }
describe("interactive conflict resolution operations", () => { 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<MergeDialogResult>((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<false | { _rev: string; _conflicts?: string[] }>((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<boolean>((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<boolean>((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<typeof AUTO_MERGED | typeof MISSING_OR_ERROR>((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<typeof AUTO_MERGED | typeof MISSING_OR_ERROR>((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<string[]>((_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<string[]>((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<string[]>((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 () => { it("replaces an active same-file dialogue with only the newest waiting request", async () => {
const fixture = createDialogueConcurrencyHarness(); const fixture = createDialogueConcurrencyHarness();
@@ -100,10 +100,13 @@ export function createInteractiveConflictResolutionOperations(
}; };
const getConflictVersionCount = async (filename: FilePathWithPrefix): Promise<number | undefined> => { const getConflictVersionCount = async (filename: FilePathWithPrefix): Promise<number | undefined> => {
if (disposed) return undefined;
try { try {
const conflictCount = (await dependencies.databaseFileAccess.getConflictedRevs(filename)).length; const conflictCount = (await dependencies.databaseFileAccess.getConflictedRevs(filename)).length;
if (disposed) return undefined;
return conflictCount === 0 ? 0 : conflictCount + 1; return conflictCount === 0 ? 0 : conflictCount + 1;
} catch (error) { } catch (error) {
if (disposed) return undefined;
dependencies.log(`Could not inspect the conflict state of ${filename}`, LOG_LEVEL_VERBOSE); dependencies.log(`Could not inspect the conflict state of ${filename}`, LOG_LEVEL_VERBOSE);
dependencies.log(error, LOG_LEVEL_VERBOSE); dependencies.log(error, LOG_LEVEL_VERBOSE);
return undefined; return undefined;
@@ -111,9 +114,11 @@ export function createInteractiveConflictResolutionOperations(
}; };
const getActiveConflictMessages = async (): Promise<string[]> => { const getActiveConflictMessages = async (): Promise<string[]> => {
if (disposed) return [];
const filename = dependencies.vault.getActiveFilePath(); const filename = dependencies.vault.getActiveFilePath();
if (!filename) return []; if (!filename) return [];
const versionCount = await getConflictVersionCount(filename); const versionCount = await getConflictVersionCount(filename);
if (disposed) return [];
if (versionCount === 0) { if (versionCount === 0) {
postponedConflictEpisodes.delete(filename); postponedConflictEpisodes.delete(filename);
return []; return [];
@@ -132,7 +137,10 @@ export function createInteractiveConflictResolutionOperations(
}; };
const refreshConflictState = async (filename: FilePathWithPrefix): Promise<void> => { const refreshConflictState = async (filename: FilePathWithPrefix): Promise<void> => {
if ((await getConflictVersionCount(filename)) === 0) { if (disposed) return;
const versionCount = await getConflictVersionCount(filename);
if (disposed) return;
if (versionCount === 0) {
postponedConflictEpisodes.delete(filename); postponedConflictEpisodes.delete(filename);
} }
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR); dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
@@ -182,6 +190,7 @@ export function createInteractiveConflictResolutionOperations(
activeDialogue = undefined; activeDialogue = undefined;
} }
} }
if (disposed) return false;
if (selected === POSTPONED) { if (selected === POSTPONED) {
postponedConflictEpisodes.add(filename); postponedConflictEpisodes.add(filename);
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR); dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
@@ -196,6 +205,7 @@ export function createInteractiveConflictResolutionOperations(
const testDoc = await dependencies const testDoc = await dependencies
.localDatabase() .localDatabase()
.getDBEntry(filename, { conflicts: true }, false, true, true); .getDBEntry(filename, { conflicts: true }, false, true, true);
if (disposed) return false;
if (testDoc === false) { if (testDoc === false) {
dependencies.log(`Merge: Could not read ${filename} from the local database`, LOG_LEVEL_VERBOSE); dependencies.log(`Merge: Could not read ${filename} from the local database`, LOG_LEVEL_VERBOSE);
return false; return false;
@@ -203,6 +213,7 @@ export function createInteractiveConflictResolutionOperations(
if (!testDoc._conflicts || testDoc._conflicts.length === 0) { if (!testDoc._conflicts || testDoc._conflicts.length === 0) {
dependencies.log(`Merge: Nothing to do ${filename}`, LOG_LEVEL_VERBOSE); dependencies.log(`Merge: Nothing to do ${filename}`, LOG_LEVEL_VERBOSE);
await refreshConflictState(filename); await refreshConflictState(filename);
if (disposed) return false;
return false; return false;
} }
if ( if (
@@ -214,6 +225,7 @@ export function createInteractiveConflictResolutionOperations(
LOG_LEVEL_INFO LOG_LEVEL_INFO
); );
await refreshConflictState(filename); await refreshConflictState(filename);
if (disposed) return false;
await dependencies.conflict.queueCheckFor(filename); await dependencies.conflict.queueCheckFor(filename);
return false; return false;
} }
@@ -224,15 +236,20 @@ export function createInteractiveConflictResolutionOperations(
// Create a new file by concatenating both conflicted revisions. // Create a new file by concatenating both conflicted revisions.
const p = conflictCheckResult.diff.map((e) => e[1]).join(""); const p = conflictCheckResult.diff.map((e) => e[1]).join("");
const delRev = conflictCheckResult.right.rev; 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); dependencies.log(`Concatenated content cannot be stored:${filename}`, LOG_LEVEL_NOTICE);
return false; return false;
} }
// 2. As usual, delete the conflicted revision and if there are no conflicts, write the resolved content to the storage. // 2. As usual, delete the conflicted revision and if there are no conflicts, write the resolved content to the storage.
if ( const deletionResult = await dependencies.conflict.resolveByDeletingRevision(
(await dependencies.conflict.resolveByDeletingRevision(filename, delRev, "UI Concatenated")) == filename,
MISSING_OR_ERROR delRev,
) { "UI Concatenated"
);
if (disposed) return false;
if (deletionResult == MISSING_OR_ERROR) {
dependencies.log( dependencies.log(
`Concatenated saved, but cannot delete conflicted revisions: ${filename}, (${displayRev(delRev)})`, `Concatenated saved, but cannot delete conflicted revisions: ${filename}, (${displayRev(delRev)})`,
LOG_LEVEL_NOTICE LOG_LEVEL_NOTICE
@@ -244,10 +261,13 @@ export function createInteractiveConflictResolutionOperations(
(toDelete === conflictCheckResult.left.rev || toDelete === conflictCheckResult.right.rev) (toDelete === conflictCheckResult.left.rev || toDelete === conflictCheckResult.right.rev)
) { ) {
// Select one of the conflicted revision to delete. // Select one of the conflicted revision to delete.
if ( const deletionResult = await dependencies.conflict.resolveByDeletingRevision(
(await dependencies.conflict.resolveByDeletingRevision(filename, toDelete, "UI Selected")) == filename,
MISSING_OR_ERROR toDelete,
) { "UI Selected"
);
if (disposed) return false;
if (deletionResult == MISSING_OR_ERROR) {
dependencies.log(`Merge: Something went wrong: ${filename}, (${toDelete})`, LOG_LEVEL_NOTICE); dependencies.log(`Merge: Something went wrong: ${filename}, (${toDelete})`, LOG_LEVEL_NOTICE);
return false; return false;
} }
@@ -266,6 +286,7 @@ export function createInteractiveConflictResolutionOperations(
trigger: "merge", trigger: "merge",
interaction: NO_INTERACTION, interaction: NO_INTERACTION,
}); });
if (disposed) return false;
} }
// And, check it again. // And, check it again.
await dependencies.conflict.queueCheckFor(filename); await dependencies.conflict.queueCheckFor(filename);
@@ -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 { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { evalObsidianJson } from "../runner/cli.ts"; import { evalObsidianJson } from "../runner/cli.ts";
import { import {
@@ -28,6 +31,11 @@ type ConflictFixture = {
conflicts: string[]; conflicts: string[];
}; };
type ObservedConflictService = {
ensureAllProcessed(): Promise<boolean>;
conflictProcessQueueCount: { value: number };
};
type ObsidianTestApp = { type ObsidianTestApp = {
commands?: { executeCommandById(commandId: string): boolean }; commands?: { executeCommandById(commandId: string): boolean };
plugins?: { plugins?: {
@@ -38,10 +46,7 @@ type ObsidianTestApp = {
| { | {
core?: { core?: {
services?: { services?: {
conflict?: { conflict?: ObservedConflictService;
ensureAllProcessed(): Promise<boolean>;
conflictProcessQueueCount: { value: number };
};
}; };
}; };
} }
@@ -54,8 +59,43 @@ type ObsidianTestGlobal = typeof globalThis & {
app?: ObsidianTestApp; app?: ObsidianTestApp;
__livesyncConflictChecksCompleted?: boolean; __livesyncConflictChecksCompleted?: boolean;
__livesyncWaitingConflictCompleted?: 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> { async function createAndOpenBaseFile(cliBinary: string, env: NodeJS.ProcessEnv, targetPath = path): Promise<void> {
await evalObsidianJson<unknown>( await evalObsidianJson<unknown>(
cliBinary, cliBinary,
@@ -249,7 +289,7 @@ async function createTwoVersionConflict(
if (fixture.conflicts.length !== 1) { if (fixture.conflicts.length !== 1) {
throw new Error(`Expected exactly two live leaves for ${targetPath}: ${JSON.stringify(fixture)}`); throw new Error(`Expected exactly two live leaves for ${targetPath}: ${JSON.stringify(fixture)}`);
} }
return fixture; return { ...fixture, currentParentRev: base.rev };
} }
async function setShowMergeDialogOnlyOnActive( async function setShowMergeDialogOnlyOnActive(
@@ -308,13 +348,19 @@ async function requestInteractiveConflictResolution(
`const conflictRev=${JSON.stringify(fixture.conflicts[0])};`, `const conflictRev=${JSON.stringify(fixture.conflicts[0])};`,
`const trackCompletion=${JSON.stringify(trackCompletion)};`, `const trackCompletion=${JSON.stringify(trackCompletion)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;", "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,{", "const pending=core.services.conflict.resolveByUserInteraction(path,{",
"left:{rev:currentRev,data:'Current branch',ctime:1,mtime:2},", "left:{rev:currentRev,data:'Current branch',ctime:1,mtime:2},",
"right:{rev:conflictRev,data:'Conflict branch',ctime:1,mtime:3},", "right:{rev:conflictRev,data:'Conflict branch',ctime:1,mtime:3},",
"diff:[[0,'Current and conflict branches']],", "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});", "return JSON.stringify({ok:true});",
"})()", "})()",
].join(""), ].join(""),
@@ -328,10 +374,18 @@ async function disableLiveSyncAndWaitForConflictChecks(port: number): Promise<vo
const host = globalThis as ObsidianTestGlobal; const host = globalThis as ObsidianTestGlobal;
const conflict = host.app?.plugins?.plugins?.["obsidian-livesync"]?.core?.services?.conflict; const conflict = host.app?.plugins?.plugins?.["obsidian-livesync"]?.core?.services?.conflict;
if (conflict === undefined) throw new Error("LiveSync conflict service is unavailable before unload"); if (conflict === undefined) throw new Error("LiveSync conflict service is unavailable before unload");
host.__livesyncObservedConflict = conflict;
host.__livesyncConflictChecksCompleted = false; host.__livesyncConflictChecksCompleted = false;
void conflict.ensureAllProcessed().then(() => { host.__livesyncConflictChecksError = undefined;
host.__livesyncConflictChecksCompleted = true; void conflict.ensureAllProcessed().then(
}); () => {
host.__livesyncConflictChecksCompleted = true;
},
(error) => {
host.__livesyncConflictChecksError =
error instanceof Error ? (error.stack ?? String(error)) : String(error);
}
);
}); });
await page.evaluate(async () => { await page.evaluate(async () => {
const plugins = (globalThis as ObsidianTestGlobal).app?.plugins; const plugins = (globalThis as ObsidianTestGlobal).app?.plugins;
@@ -362,6 +416,76 @@ async function enableLiveSync(port: number): Promise<void> {
}); });
} }
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> { async function main(): Promise<void> {
const binary = requireObsidianBinary(); const binary = requireObsidianBinary();
const cli = discoverObsidianCli(); const cli = discoverObsidianCli();
@@ -372,14 +496,17 @@ async function main(): Promise<void> {
const vault = await createTemporaryVault("obsidian-livesync-conflict-dialog-"); const vault = await createTemporaryVault("obsidian-livesync-conflict-dialog-");
let session: ObsidianLiveSyncSession | undefined; let session: ObsidianLiveSyncSession | undefined;
let unloadFixtures: UnloadConflictFixture[] = [];
let unloadPreservationBefore: PreservedConflictState[] | undefined;
try { try {
session = await startObsidianLiveSyncSession({ session = await startObsidianLiveSyncSession({
binary, binary,
cliBinary, cliBinary,
vault, vault,
artifactRoot: process.env.E2E_OBSIDIAN_ARTIFACT_ROOT,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: { pluginData: {
doctorProcessedVersion: "1.0.0", doctorProcessedVersion: DoctorRegulation.version,
isConfigured: true, isConfigured: true,
liveSync: false, liveSync: false,
remoteType: "", remoteType: "",
@@ -433,34 +560,31 @@ async function main(): Promise<void> {
(page) => conflictDialogue(page).locator(".modal").first() (page) => conflictDialogue(page).locator(".modal").first()
); );
await withObsidianPage(session.remoteDebuggingPort, async (page) => { await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const modal = conflictDialogue(page); await page.waitForFunction(
const actionButtons = modal.locator(".conflict-action-button"); (targetPath) => {
await actionButtons.nth(3).waitFor({ state: "visible", timeout: uiTimeoutMs }); const dialogues = Array.from(document.querySelectorAll(".modal-container")).filter(
const actionButtonBounds = await actionButtons.evaluateAll((buttons) => (element) =>
buttons.map((button) => { element.getClientRects().length > 0 &&
const bounds = button.getBoundingClientRect(); element.querySelector(".modal-title")?.textContent?.includes("Conflicting changes") &&
return { top: bounds.top, bottom: bounds.bottom }; 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) => { await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const modal = conflictDialogue(page); const modal = conflictDialogue(page);
@@ -506,6 +630,7 @@ async function main(): Promise<void> {
binary, binary,
cliBinary, cliBinary,
vault, vault,
artifactRoot: process.env.E2E_OBSIDIAN_ARTIFACT_ROOT,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
}); });
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
@@ -542,6 +667,7 @@ async function main(): Promise<void> {
binary, binary,
cliBinary, cliBinary,
vault, vault,
artifactRoot: process.env.E2E_OBSIDIAN_ARTIFACT_ROOT,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
}); });
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
@@ -734,8 +860,12 @@ async function main(): Promise<void> {
}); });
await waitForConflictChecks(cliBinary, session.cliEnv); 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); 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 requestConflictCheck(cliBinary, session.cliEnv, false, unloadActivePath);
await withObsidianPage(session.remoteDebuggingPort, async (page) => { await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await conflictDialogue(page, unloadActivePath).waitFor({ state: "visible", timeout: uiTimeoutMs }); await conflictDialogue(page, unloadActivePath).waitFor({ state: "visible", timeout: uiTimeoutMs });
@@ -752,7 +882,14 @@ async function main(): Promise<void> {
"conflict-dialog-before-unload.png", "conflict-dialog-before-unload.png",
(page) => conflictDialogue(page, unloadActivePath).locator(".modal").first() (page) => conflictDialogue(page, unloadActivePath).locator(".modal").first()
); );
unloadPreservationBefore = await recordUnloadPreservation(
cliBinary,
session.cliEnv,
"before-unload",
unloadFixtures
);
await disableLiveSyncAndWaitForConflictChecks(session.remoteDebuggingPort); await disableLiveSyncAndWaitForConflictChecks(session.remoteDebuggingPort);
await recordConflictDiagnostics(session.remoteDebuggingPort, "unloaded");
const afterUnloadScreenshot = await captureObsidianPage( const afterUnloadScreenshot = await captureObsidianPage(
session.remoteDebuggingPort, session.remoteDebuggingPort,
"conflict-dialog-after-unload.png", "conflict-dialog-after-unload.png",
@@ -770,6 +907,13 @@ async function main(): Promise<void> {
await enableLiveSync(session.remoteDebuggingPort); await enableLiveSync(session.remoteDebuggingPort);
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
await recordUnloadPreservation(
cliBinary,
session.cliEnv,
"after-reload",
unloadFixtures,
unloadPreservationBefore
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => { await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.waitForTimeout(500); await page.waitForTimeout(500);
if (await conflictDialogue(page).isVisible()) { if (await conflictDialogue(page).isVisible()) {
@@ -803,6 +947,27 @@ async function main(): Promise<void> {
console.log(`Before unload screenshot: ${beforeUnloadScreenshot}`); console.log(`Before unload screenshot: ${beforeUnloadScreenshot}`);
console.log(`After unload screenshot: ${afterUnloadScreenshot}`); console.log(`After unload screenshot: ${afterUnloadScreenshot}`);
console.log(`After reload screenshot: ${afterReloadScreenshot}`); 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 { } finally {
if (session) { if (session) {
await session.app.stop(); await session.app.stop();
+4
View File
@@ -18,6 +18,10 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
### Fixed ### 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 #### 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! - 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!