Strengthen conflict resolution regression coverage

This commit is contained in:
vorotamoroz
2026-09-04 08:16:14 +00:00
parent 56a1a19d2c
commit f06f33cbf4
2 changed files with 402 additions and 11 deletions
@@ -170,6 +170,66 @@ describe("conflict resolution serviceFeature", () => {
expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled();
});
it("deletes the older conflict leaf when matching current content is newer", async () => {
const harness = createOperationsHarness();
const path = "same-newer-current.md" as FilePathWithPrefix;
const leftLeaf = leaf("2-left", "Same content\n", 3000);
const rightLeaf = leaf("1-right", "Same content\n", 2000);
harness.tryAutoMerge.mockResolvedValue({
leftRev: leftLeaf.rev,
rightRev: rightLeaf.rev,
leftLeaf,
rightLeaf,
});
const result = await harness.operations.checkConflictAndPerformAutoMerge(path);
expect(result).toBe(AUTO_MERGED);
expect(harness.resolveByDeletingRevision).toHaveBeenCalledWith(path, "1-right", "same");
});
it.each([
{
description: "binary content",
path: "different.png" as FilePathWithPrefix,
resolveConflictsByNewerFile: false,
expectedSubtitle: "binary",
},
{
description: "the newer-file policy",
path: "different.md" as FilePathWithPrefix,
resolveConflictsByNewerFile: true,
expectedSubtitle: "alwaysNewer",
},
])(
"retains automatic resolution for $description",
async ({ path, resolveConflictsByNewerFile, expectedSubtitle }) => {
const harness = createOperationsHarness();
const leftLeaf = leaf("1-left", "Left content\n", 1000);
const rightLeaf = leaf("2-right", "Right content\n", 2000);
harness.tryAutoMerge.mockResolvedValue({
leftRev: leftLeaf.rev,
rightRev: rightLeaf.rev,
leftLeaf,
rightLeaf,
});
const operations = createConflictResolutionOperations({
...harness.dependencies,
currentSettings: () => ({
disableMarkdownAutoMerge: false,
resolveConflictsByNewerFile,
syncAfterMerge: false,
showMergeDialogOnlyOnActive: false,
}),
});
const result = await operations.checkConflictAndPerformAutoMerge(path);
expect(result).toBe(AUTO_MERGED);
expect(harness.resolveByDeletingRevision).toHaveBeenCalledWith(path, "1-left", expectedSubtitle);
}
);
it("acquires the active local database for each resolution attempt", async () => {
const harness = createOperationsHarness();
const path = "database-reset.md" as FilePathWithPrefix;
@@ -254,6 +314,34 @@ describe("conflict resolution serviceFeature", () => {
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
});
it("returns a safe error and logs when reflecting the resolved revision fails", async () => {
const harness = createOperationsHarness();
const path = "failed-reflection.md" as FilePathWithPrefix;
harness.fileHandler.dbToStorage.mockResolvedValue(false);
const result = await harness.operations.resolveByDeletingRevision(path, "2-right", "UI Selected");
expect(result).toBe(MISSING_OR_ERROR);
expect(harness.fileHandler.dbToStorage).toHaveBeenCalledWith(path, path, true);
expect(harness.dependencies.log).toHaveBeenCalledWith(
`Could not write the resolved content to the storage: ${path}`,
LOG_LEVEL_NOTICE
);
});
it.each([
["plugin metadata", "ps:plugin-metadata" as FilePathWithPrefix],
["customisation metadata", "ix:customisation-metadata" as FilePathWithPrefix],
])("does not reflect %s into the Vault after deleting its revision", async (_description, path) => {
const harness = createOperationsHarness();
const result = await harness.operations.resolveByDeletingRevision(path, "2-right", "UI Selected");
expect(result).toBe(AUTO_MERGED);
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledWith(path, "2-right");
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
});
it("rechecks a remaining manual pair after committing a sensible merge", async () => {
const harness = createOperationsHarness();
const path = "three-versions.md" as FilePathWithPrefix;
@@ -333,6 +421,35 @@ describe("conflict resolution serviceFeature", () => {
);
});
it("opens a manual merge when the active-only setting matches the current file", async () => {
const harness = createOperationsHarness();
const path = "active.md" as FilePathWithPrefix;
const activePath = "active.md" as FilePath;
harness.tryAutoMerge.mockResolvedValue({
leftRev: "2-left",
rightRev: "2-right",
leftLeaf: leaf("2-left", "Left\n", 1),
rightLeaf: leaf("2-right", "Right\n", 2),
});
const operations = createConflictResolutionOperations({
...harness.dependencies,
currentSettings: () => ({
disableMarkdownAutoMerge: false,
resolveConflictsByNewerFile: false,
syncAfterMerge: false,
showMergeDialogOnlyOnActive: true,
}),
vault: { getActiveFilePath: () => activePath },
});
await operations.resolve(path);
expect(harness.resolveByUserInteraction).toHaveBeenCalledWith(
path,
expect.objectContaining({ left: expect.anything(), right: expect.anything() })
);
});
it("cancels an active same-file dialogue before serialising a repeated resolution", async () => {
const harness = createOperationsHarness();
const path = "repeated.md" as FilePathWithPrefix;
@@ -472,6 +589,21 @@ describe("conflict resolution serviceFeature", () => {
);
});
it.each([
["the active-file gate is disabled", { checkConflictOnlyOnOpen: false }, "other.md" as FilePathWithPrefix],
["the requested path is active", { checkConflictOnlyOnOpen: true }, "active.md" as FilePathWithPrefix],
])("reaches the resolver when %s", async (_description, settings, activeFile) => {
const path = "active.md" as FilePathWithPrefix;
const harness = createHarness({ settings, activeFile });
harness.tryAutoMerge.mockResolvedValue({ ok: NOT_CONFLICTED });
await harness.conflict.queueCheckForIfOpen(path);
await harness.conflict.ensureAllProcessed();
expect(harness.tryAutoMerge).toHaveBeenCalledOnce();
expect(harness.tryAutoMerge).toHaveBeenCalledWith(path, true);
});
it("limits concurrent conflict checks and reports queued and active work", async () => {
const paths = Array.from({ length: 11 }, (_, index) => `concurrent-${index}.md` as FilePathWithPrefix);
const harness = createHarness();
@@ -632,6 +764,27 @@ describe("conflict resolution serviceFeature", () => {
expect(MISSING_OR_ERROR).toBeDefined();
});
it("preserves revisions and returns a safe error when the current leaf is unreadable", async () => {
const harness = createOperationsHarness();
const path = "unreadable-current.md" as FilePathWithPrefix;
harness.tryAutoMerge.mockResolvedValue({
leftRev: "3-current",
rightRev: "2-conflict",
leftLeaf: false,
rightLeaf: leaf("2-conflict", "Conflict body\n", 2),
});
const result = await harness.operations.checkConflictAndPerformAutoMerge(path);
expect(result).toBe(MISSING_OR_ERROR);
expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled();
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
expect(harness.dependencies.log).toHaveBeenCalledWith(
`could not get current revisions:${path}`,
LOG_LEVEL_NOTICE
);
});
it("resolves an identical pair, emits cancellation, and rechecks after merging", async () => {
const path = "independently-created.md" as FilePathWithPrefix;
const harness = createHarness({ settings: { syncAfterMerge: false } });
@@ -704,4 +857,27 @@ describe("conflict resolution serviceFeature", () => {
LOG_LEVEL_NOTICE
);
});
it.each([
{ description: "no conflicts remain", conflicts: [] as string[], expectedDeletes: [] as string[] },
{
description: "conflict metadata is unreadable",
conflicts: ["2-unreadable"],
expectedDeletes: ["2-unreadable"],
},
])("handles the case where $description while resolving by newest", async ({ conflicts, expectedDeletes }) => {
const harness = createOperationsHarness();
const path = "newest-policy.md" as FilePathWithPrefix;
harness.databaseFileAccess.fetchEntryMeta.mockImplementation(
async (_filename: FilePathWithPrefix, revision?: string) =>
revision === undefined ? metadata(path, "3-current", 1000) : false
);
harness.databaseFileAccess.getConflictedRevs.mockResolvedValueOnce(conflicts).mockResolvedValueOnce([]);
await expect(harness.operations.resolveByNewest(path)).resolves.toBe(true);
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledTimes(expectedDeletes.length);
for (const [index, revision] of expectedDeletes.entries()) {
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenNthCalledWith(index + 1, path, revision);
}
});
});
@@ -9,7 +9,7 @@ import {
type FilePathWithPrefix,
type diff_result,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { EVENT_CONFLICT_CANCELLED, EVENT_PLUGIN_UNLOADED } from "@/common/events";
import { EVENT_CONFLICT_CANCELLED, EVENT_ON_UNRESOLVED_ERROR, EVENT_PLUGIN_UNLOADED } from "@/common/events";
import {
createInteractiveConflictResolutionOperations,
type InteractiveConflictResolutionOperationsDependencies,
@@ -37,12 +37,14 @@ function createOperations(conflictedRevisions: string[] = ["2-right"]) {
const getDBEntry = vi.fn(async (): Promise<false | { _rev: string; _conflicts?: string[] }> => false);
const findAllDocs = vi.fn(() => documents([]));
const askSelectString = vi.fn(async (): Promise<string> => "");
const askInPopup = vi.fn();
const queueCheckFor = vi.fn(async () => undefined);
const ensureAllProcessed = vi.fn(async () => true);
const resolveByDeletingRevision = vi.fn(async () => AUTO_MERGED);
const getConflictedRevs = vi.fn(async () => conflictedRevisions);
const storeContent = vi.fn(async () => true);
const isSuspended = vi.fn(() => false);
const getActiveFilePath = vi.fn((): FilePathWithPrefix | undefined => path);
const replicateUnattendedByEvent = vi.fn(async () => ({ status: "completed" as const }));
const currentSettings = vi.fn(() => ({ syncAfterMerge: false }));
const log = vi.fn();
@@ -55,10 +57,10 @@ function createOperations(conflictedRevisions: string[] = ["2-right"]) {
localDatabase: () => ({ getDBEntry, findAllDocs }),
confirm: {
askSelectString,
askInPopup: vi.fn(),
askInPopup,
},
path: { getPath: vi.fn((entry: { path: FilePathWithPrefix }) => entry.path) },
vault: { getActiveFilePath: vi.fn(() => path) },
vault: { getActiveFilePath },
appLifecycle: { isSuspended },
conflict: { queueCheckFor, ensureAllProcessed, resolveByDeletingRevision },
replication: {
@@ -76,6 +78,7 @@ function createOperations(conflictedRevisions: string[] = ["2-right"]) {
} as unknown as InteractiveConflictResolutionOperationsDependencies);
return {
askSelectString,
askInPopup,
constructed,
context,
dialogueResult: {
@@ -88,6 +91,7 @@ function createOperations(conflictedRevisions: string[] = ["2-right"]) {
},
findAllDocs,
getDBEntry,
getActiveFilePath,
getConflictedRevs,
ensureAllProcessed,
currentSettings,
@@ -194,6 +198,22 @@ describe("interactive conflict resolution operations", () => {
await Promise.all([first, superseded, replacement]);
});
it("keeps a same-file replacement request when its cancellation refresh sees the active dialogue", async () => {
const dialogues: ControlledDialogue[] = [];
const fixture = createFeatureHarness((context) => createControlledDialogueFactory(context, dialogues));
const resolveByUserInteraction = fixture.handlers.resolveByUserInteraction!;
const first = resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(dialogues).toHaveLength(1));
const replacement = resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(dialogues).toHaveLength(2));
expect(dialogues[1]?.filename).toBe(path);
dialogues[1]?.finish(CANCELLED);
await Promise.all([first, replacement]);
});
it("keeps a different file waiting until the active dialogue finishes", async () => {
const fixture = createDialogueConcurrencyHarness();
const otherPath = "other.md" as FilePathWithPrefix;
@@ -259,12 +279,55 @@ describe("interactive conflict resolution operations", () => {
expect(constructed.value).toBe(2);
});
it.each([
{ label: "cannot be read", result: false as const, refreshes: false },
{ label: "has no conflicts", result: { _rev: "2-left", _conflicts: [] as string[] }, refreshes: true },
])("exits safely when the live database $label after a user selection", async ({ result, refreshes }) => {
const fixture = createOperations();
fixture.dialogueResult.value = "2-right";
fixture.getDBEntry.mockResolvedValue(result);
const emitEvent = vi.spyOn(fixture.context.events, "emitEvent");
await expect(fixture.operations.resolveByUserInteraction(path, conflict)).resolves.toBe(false);
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
if (refreshes) {
expect(fixture.getConflictedRevs).toHaveBeenCalledWith(path);
expect(emitEvent).toHaveBeenCalledWith(EVENT_ON_UNRESOLVED_ERROR);
} else {
expect(fixture.getConflictedRevs).not.toHaveBeenCalled();
expect(emitEvent).not.toHaveBeenCalled();
}
});
it("contributes the active conflict to the existing unresolved-message display", async () => {
const { operations } = createOperations();
await expect(operations.getActiveConflictMessages()).resolves.toEqual(["This file has unresolved conflicts."]);
});
it("returns no unresolved message when there is no active file", async () => {
const { getActiveFilePath, getConflictedRevs, operations } = createOperations();
getActiveFilePath.mockReturnValue(undefined);
await expect(operations.getActiveConflictMessages()).resolves.toEqual([]);
expect(getConflictedRevs).not.toHaveBeenCalled();
});
it("logs a failed conflict inspection and returns no unresolved messages", async () => {
const fixture = createOperations();
const error = new Error("database unavailable");
fixture.getConflictedRevs.mockRejectedValue(error);
await expect(fixture.operations.getActiveConflictMessages()).resolves.toEqual([]);
expect(fixture.log).toHaveBeenCalledWith("Could not inspect the conflict state of note.md", expect.anything());
expect(fixture.log).toHaveBeenCalledWith(error, expect.anything());
});
it("removes the active warning once the conflict has resolved", async () => {
const conflictedRevisions = ["2-right"];
const { operations } = createOperations(conflictedRevisions);
@@ -312,19 +375,47 @@ describe("interactive conflict resolution operations", () => {
expect(restartedSession.constructed.value).toBe(1);
});
it("deletes the compared right leaf when concatenating a deterministically selected pair", async () => {
it("stores the exact concatenated diff before deleting a deterministically selected pair", async () => {
const fixture = createOperations(["2-unrelated", "2-right"]);
fixture.dialogueResult.value = LEAVE_TO_SUBSEQUENT;
fixture.getDBEntry.mockResolvedValue({
_rev: "2-left",
_conflicts: ["2-unrelated", "2-right"],
});
await fixture.operations.resolveByUserInteraction(path, conflict);
await fixture.operations.resolveByUserInteraction(path, {
...conflict,
diff: [
[0, "left"],
[1, "\n"],
[1, "right"],
],
});
expect(fixture.storeContent).toHaveBeenCalledWith(path, "");
expect(fixture.storeContent).toHaveBeenCalledWith(path, "left\nright");
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Concatenated");
});
it("does not replicate or requeue when concatenated revision deletion fails", async () => {
const fixture = createOperations();
fixture.dialogueResult.value = LEAVE_TO_SUBSEQUENT;
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
fixture.currentSettings.mockReturnValue({ syncAfterMerge: true });
fixture.resolveByDeletingRevision.mockResolvedValue(MISSING_OR_ERROR);
await fixture.operations.resolveByUserInteraction(path, {
...conflict,
diff: [
[0, "left"],
[1, "\nright"],
],
});
expect(fixture.storeContent).toHaveBeenCalledWith(path, "left\nright");
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Concatenated");
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
});
it("rechecks the live leaves instead of applying a stale dialogue selection", async () => {
const fixture = createOperations(["2-other"]);
fixture.dialogueResult.value = "2-right";
@@ -364,6 +455,22 @@ describe("interactive conflict resolution operations", () => {
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
});
it("rejects an unexpected dialogue result without changing revisions", async () => {
const fixture = createOperations();
fixture.dialogueResult.value = "unexpected-result";
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
await fixture.operations.resolveByUserInteraction(path, conflict);
expect(fixture.log).toHaveBeenCalledWith(
`Merge: Something went wrong: ${path}, (unexpected-result)`,
expect.anything()
);
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
});
it("replicates and requeues after a selected revision is resolved", async () => {
const fixture = createOperations();
fixture.dialogueResult.value = "2-right";
@@ -424,21 +531,82 @@ describe("interactive conflict resolution operations", () => {
expect(fixture.askSelectString).not.toHaveBeenCalled();
expect(fixture.log).toHaveBeenCalledWith("There are no conflicted documents", expect.anything());
});
it("sorts multiple picker candidates newest first and returns safely on cancellation", async () => {
const fixture = createOperations();
fixture.findAllDocs.mockReturnValue(
documents([
{ _id: "ordinary-id", path: "ordinary.md", mtime: 40 },
{ _id: "old-id", path: "old.md", _conflicts: ["2-old"], mtime: 10 },
{ _id: "new-id", path: "new.md", _conflicts: ["2-new"], mtime: 30 },
{ _id: "middle-id", path: "middle.md", _conflicts: ["2-middle"], mtime: 20 },
])
);
fixture.askSelectString.mockResolvedValue("");
await expect(fixture.operations.pickFileForResolve()).resolves.toBe(false);
expect(fixture.askSelectString).toHaveBeenCalledWith("File to resolve conflict", [
"new.md",
"middle.md",
"old.md",
]);
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
expect(fixture.ensureAllProcessed).not.toHaveBeenCalled();
});
it("reports conflicted documents and wires the startup popup action", async () => {
const fixture = createOperations();
fixture.findAllDocs.mockImplementation(() =>
documents([
{ _id: "ordinary-id", path: "ordinary.md", mtime: 30 },
{ _id: "first-id", path: "first.md", _conflicts: ["2-first"], mtime: 10 },
{ _id: "second-id", path: "second.md", _conflicts: ["2-second"], mtime: 20 },
])
);
await expect(fixture.operations.scanStartupIssues()).resolves.toBe(true);
expect(fixture.askInPopup).toHaveBeenCalledWith(
"conflicting-detected-on-safety",
expect.stringContaining("Some files have been left conflicted!"),
expect.any(Function)
);
expect(fixture.log).toHaveBeenCalledWith("Conflicted: first.md");
expect(fixture.log).toHaveBeenCalledWith("Conflicted: second.md");
expect(fixture.log).not.toHaveBeenCalledWith("Conflicted: ordinary.md");
const popupAction = fixture.askInPopup.mock.calls[0]?.[2] as (anchor: HTMLAnchorElement) => void;
const addEventListener = vi.fn();
const anchor = { text: "", addEventListener } as unknown as HTMLAnchorElement;
popupAction(anchor);
expect(anchor.text).toBe("HERE");
expect(addEventListener).toHaveBeenCalledWith("click", expect.any(Function));
const clickHandler = addEventListener.mock.calls[0]?.[1] as () => void;
clickHandler();
await vi.waitFor(() =>
expect(fixture.askSelectString).toHaveBeenCalledWith("File to resolve conflict", ["second.md", "first.md"])
);
});
});
function createFeatureHarness(
createDialogueForContext?: (
context: ReturnType<typeof createServiceContext>
) => ConflictResolveDialogueFactory
createDialogueForContext?: (context: ReturnType<typeof createServiceContext>) => ConflictResolveDialogueFactory
) {
const context = createServiceContext();
type FeatureLocalDatabase = {
getDBEntry: (...args: unknown[]) => Promise<unknown>;
findAllDocs: (...args: unknown[]) => AsyncGenerator<unknown>;
};
const initialLocalDatabase = {
getDBEntry: vi.fn(async () => false),
findAllDocs: vi.fn(() => documents([])),
};
let activeLocalDatabase = initialLocalDatabase;
let activeLocalDatabase: FeatureLocalDatabase = initialLocalDatabase;
const getLocalDatabase = vi.fn(() => activeLocalDatabase);
const replaceLocalDatabase = (replacement: typeof initialLocalDatabase) => {
const replaceLocalDatabase = (replacement: FeatureLocalDatabase) => {
activeLocalDatabase = replacement;
};
const database = {} as { readonly localDatabase: ReturnType<typeof getLocalDatabase> };
@@ -605,6 +773,18 @@ describe("interactive conflict resolution feature composition", () => {
await fixture.handlers.onUnload?.();
});
it("rejects a resolution request after unload without opening a dialogue", async () => {
const dialogues: ControlledDialogue[] = [];
const fixture = createFeatureHarness((context) => createControlledDialogueFactory(context, dialogues));
const resolveByUserInteraction = fixture.handlers.resolveByUserInteraction!;
await fixture.handlers.onUnload?.();
await expect(resolveByUserInteraction(path, conflict)).resolves.toBe(false);
expect(dialogues).toHaveLength(0);
expect(fixture.createDialogue).not.toHaveBeenCalled();
});
it("drops a waiting dialogue when its conflict is resolved elsewhere", async () => {
const dialogues: ControlledDialogue[] = [];
const fixture = createFeatureHarness((context) => createControlledDialogueFactory(context, dialogues));
@@ -667,6 +847,41 @@ describe("interactive conflict resolution feature composition", () => {
expect(fixture.getLocalDatabase).toHaveBeenCalledTimes(2);
});
it("reads the host's current sync setting after a composed resolution", async () => {
const fixture = createFeatureHarness(() => () => ({
open: vi.fn(),
waitForResult: vi.fn(async (): Promise<MergeDialogResult> => "2-right"),
}));
fixture.replaceLocalDatabase({
getDBEntry: vi.fn(async () => ({ _rev: "2-left", _conflicts: ["2-right"] })),
findAllDocs: vi.fn(() => documents([])),
});
fixture.services.setting.currentSettings.mockReturnValue({
...DEFAULT_SETTINGS,
syncAfterMerge: true,
});
await fixture.handlers.resolveByUserInteraction?.(path, conflict);
expect(fixture.services.setting.currentSettings).toHaveBeenCalledOnce();
expect(fixture.services.replication.replicateUnattendedByEvent).toHaveBeenCalledOnce();
});
it("does nothing when the editor command has no active file", async () => {
const fixture = createFeatureHarness();
await fixture.handlers.initialise?.();
const command = fixture.services.API.addCommand.mock.calls[0]?.[0] as {
editorCallback: (editor: unknown, view: { file: null }) => void;
};
command.editorCallback({}, { file: null });
await Promise.resolve();
expect(fixture.services.conflict.queueCheckFor).not.toHaveBeenCalled();
expect(fixture.services.conflict.ensureAllProcessed).not.toHaveBeenCalled();
expect(fixture.createDialogue).not.toHaveBeenCalled();
});
it("reports a failed startup scan without escaping the lifecycle handler", async () => {
const fixture = createFeatureHarness();
async function* failedDocuments() {