mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-05 07:48:18 +00:00
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:
1 parent
d557298ceb
commit
6f8df8f731
4 files changed
+507
-46
No files matched your search
+272
-1
@@ -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<InteractiveConflictResolutionOperationsDependencies> = {}
|
||||
) {
|
||||
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<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 () => {
|
||||
const fixture = createDialogueConcurrencyHarness();
|
||||
|
||||
|
||||
@@ -100,10 +100,13 @@ export function createInteractiveConflictResolutionOperations(
|
||||
};
|
||||
|
||||
const getConflictVersionCount = async (filename: FilePathWithPrefix): Promise<number | undefined> => {
|
||||
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<string[]> => {
|
||||
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<void> => {
|
||||
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);
|
||||
|
||||
@@ -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<boolean>;
|
||||
conflictProcessQueueCount: { value: number };
|
||||
};
|
||||
|
||||
type ObsidianTestApp = {
|
||||
commands?: { executeCommandById(commandId: string): boolean };
|
||||
plugins?: {
|
||||
@@ -38,10 +46,7 @@ type ObsidianTestApp = {
|
||||
| {
|
||||
core?: {
|
||||
services?: {
|
||||
conflict?: {
|
||||
ensureAllProcessed(): Promise<boolean>;
|
||||
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<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
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<vo
|
||||
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;
|
||||
void conflict.ensureAllProcessed().then(() => {
|
||||
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<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> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
@@ -372,14 +496,17 @@ async function main(): Promise<void> {
|
||||
|
||||
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<void> {
|
||||
(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 (
|
||||
actionButtonBounds.length !== 4 ||
|
||||
actionButtonBounds.some(
|
||||
(bounds, index) => index > 0 && bounds.top < actionButtonBounds[index - 1].bottom
|
||||
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)
|
||||
)
|
||||
) {
|
||||
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,
|
||||
})}`
|
||||
},
|
||||
path,
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
}
|
||||
});
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const modal = conflictDialogue(page);
|
||||
@@ -506,6 +630,7 @@ async function main(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
});
|
||||
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<void> {
|
||||
"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<void> {
|
||||
|
||||
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<void> {
|
||||
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();
|
||||
|
||||
@@ -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!
|
||||
|
||||
Reference in new issue
Block a user