mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-05 15:58:19 +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
+511
-50
No files matched your search
+272
-1
@@ -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();
|
||||||
|
|||||||
@@ -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!
|
||||||
|
|||||||
Reference in new issue
Block a user