Files
obsidian-livesync/src/serviceFeatures/conflictResolution/conflictResolution.unit.spec.ts
T

884 lines
37 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
import {
AUTO_MERGED,
DEFAULT_SETTINGS,
LOG_LEVEL_INFO,
LOG_LEVEL_NOTICE,
MISSING_OR_ERROR,
NOT_CONFLICTED,
type FilePath,
type FilePathWithPrefix,
type MetaEntry,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { EVENT_CONFLICT_CANCELLED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
import type { ConflictResolutionHost } from "./index";
import { createConflictResolutionOperations, useConflictResolutionFeature } from "./index";
import type { ConflictResolutionOperationsDependencies } from "./operations";
type ConflictLeaf = {
rev: string;
data: string;
ctime: number;
mtime: number;
deleted?: boolean;
};
type HarnessOptions = {
files?: FilePathWithPrefix[];
settings?: Partial<ObsidianLiveSyncSettings>;
activeFile?: FilePathWithPrefix;
compose?: boolean;
};
function createHarness(options: HarnessOptions = {}) {
const context = createServiceContext();
const conflict = new InjectableConflictService(context);
const settings = { ...DEFAULT_SETTINGS, ...options.settings };
const tryAutoMerge = vi.fn();
const databaseFileAccess = {
fetchEntryMeta: vi.fn(),
getConflictedRevs: vi.fn(async () => [] as string[]),
storeContent: vi.fn(async () => true),
};
const fileHandler = {
dbToStorage: vi.fn(async () => true),
deleteRevisionFromDB: vi.fn(async () => true),
};
const addLog = vi.fn();
const storageAccess = {
getFileNames: vi.fn(async () => options.files ?? []),
};
const activeFile = vi.fn(() => options.activeFile);
const services = {
API: { addLog },
appLifecycle: { isSuspended: vi.fn(() => false) },
conflict,
context,
database: { localDatabase: { tryAutoMerge } },
replication: { replicateUnattendedByEvent: vi.fn(async () => ({ status: "completed" as const })) },
setting: { currentSettings: vi.fn(() => settings) },
vault: { getActiveFilePath: activeFile },
};
const serviceModules = { databaseFileAccess, fileHandler, storageAccess };
if (options.compose !== false) {
useConflictResolutionFeature({ services, serviceModules } as unknown as ConflictResolutionHost);
}
return {
addLog,
conflict,
context,
databaseFileAccess,
fileHandler,
services,
serviceModules,
storageAccess,
tryAutoMerge,
};
}
function leaf(rev: string, data: string, mtime: number): ConflictLeaf {
return { rev, data, mtime, ctime: mtime, deleted: false };
}
function metadata(path: FilePathWithPrefix, rev: string, mtime: number): MetaEntry {
return {
_id: "doc-id",
_rev: rev,
path,
ctime: mtime,
mtime,
size: 0,
children: [],
type: "plain",
eden: {},
} as unknown as MetaEntry;
}
function createOperationsHarness() {
const events = { emitEvent: vi.fn() };
const tryAutoMerge = vi.fn();
const databaseFileAccess = {
fetchEntryMeta: vi.fn(),
getConflictedRevs: vi.fn(async () => [] as string[]),
storeContent: vi.fn(async () => true),
};
const fileHandler = {
dbToStorage: vi.fn(async () => true),
deleteRevisionFromDB: vi.fn(async () => true),
};
const resolveByDeletingRevision = vi.fn(async () => AUTO_MERGED);
const queueCheckFor = vi.fn(async () => undefined);
const resolveByUserInteraction = vi.fn(async () => false);
const replicateUnattendedByEvent = vi.fn(async () => ({ status: "completed" as const }));
const dependencies = {
events,
databaseFileAccess,
fileHandler,
localDatabase: () => ({ tryAutoMerge }),
conflict: { queueCheckFor, resolveByDeletingRevision, resolveByUserInteraction },
replication: { replicateUnattendedByEvent },
appLifecycle: { isSuspended: vi.fn(() => false) },
vault: { getActiveFilePath: vi.fn(() => undefined) },
storageAccess: { getFileNames: vi.fn(async () => [] as FilePathWithPrefix[]) },
currentSettings: vi.fn(() => ({
disableMarkdownAutoMerge: false,
resolveConflictsByNewerFile: false,
syncAfterMerge: true,
showMergeDialogOnlyOnActive: false,
})),
log: vi.fn(),
} as unknown as ConflictResolutionOperationsDependencies;
return {
...dependencies,
dependencies,
operations: createConflictResolutionOperations(dependencies),
events,
tryAutoMerge,
databaseFileAccess,
fileHandler,
resolveByDeletingRevision,
resolveByUserInteraction,
queueCheckFor,
replicateUnattendedByEvent,
};
}
describe("conflict resolution serviceFeature", () => {
it("keeps resolver operations on narrow collaborators and extension seams", async () => {
const harness = createOperationsHarness();
const path = "same.md" as FilePathWithPrefix;
const leftLeaf = leaf("1-left", "Same content\n", 1000);
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-left", "same");
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;
const firstTryAutoMerge = vi.fn(async () => ({ ok: NOT_CONFLICTED as typeof NOT_CONFLICTED }));
const replacementTryAutoMerge = vi.fn(async () => ({ ok: NOT_CONFLICTED as typeof NOT_CONFLICTED }));
let activeDatabase = { tryAutoMerge: firstTryAutoMerge };
const dependencies = {
...harness.dependencies,
localDatabase: () => activeDatabase,
};
const operations = createConflictResolutionOperations(dependencies);
await operations.checkConflictAndPerformAutoMerge(path);
activeDatabase = { tryAutoMerge: replacementTryAutoMerge };
await operations.checkConflictAndPerformAutoMerge(path);
expect(firstTryAutoMerge).toHaveBeenCalledOnce();
expect(replacementTryAutoMerge).toHaveBeenCalledOnce();
});
it("returns a manual diff for independently created files with different content", async () => {
const harness = createOperationsHarness();
const path = "independently-created.md" as FilePathWithPrefix;
const leftLeaf = leaf("1-left", "Left content\n", 1000);
const rightLeaf = leaf("1-right", "Right content\n", 2000);
harness.tryAutoMerge.mockResolvedValue({
leftRev: leftLeaf.rev,
rightRev: rightLeaf.rev,
leftLeaf,
rightLeaf,
});
const result = await harness.operations.checkConflictAndPerformAutoMerge(path);
expect(result).toMatchObject({ left: leftLeaf, right: rightLeaf });
expect(result).toHaveProperty("diff");
expect(harness.resolveByDeletingRevision).not.toHaveBeenCalled();
});
it("stores a sensible merge before resolving its conflict leaf", async () => {
const harness = createOperationsHarness();
const path = "sensible.md" as FilePathWithPrefix;
harness.tryAutoMerge.mockResolvedValue({
result: "Title\nLeft changed\nRight changed\n",
conflictedRev: "2-right",
});
const result = await harness.operations.checkConflictAndPerformAutoMerge(path);
expect(result).toBe(AUTO_MERGED);
expect(harness.databaseFileAccess.storeContent).toHaveBeenCalledWith(
path,
"Title\nLeft changed\nRight changed\n"
);
expect(harness.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "Sensible");
});
it("keeps the conflict leaf when sensible merged content cannot be stored", async () => {
const harness = createOperationsHarness();
const path = "failed-sensible.md" as FilePathWithPrefix;
harness.tryAutoMerge.mockResolvedValue({
result: "Merged content\n",
conflictedRev: "2-right",
});
harness.databaseFileAccess.storeContent.mockResolvedValue(false);
const result = await harness.operations.checkConflictAndPerformAutoMerge(path);
expect(result).toBe(MISSING_OR_ERROR);
expect(harness.resolveByDeletingRevision).not.toHaveBeenCalled();
});
it("stops before emitting or reflecting when conflict revision deletion fails", async () => {
const harness = createOperationsHarness();
const path = "failed-delete.md" as FilePathWithPrefix;
harness.fileHandler.deleteRevisionFromDB.mockResolvedValue(false);
const result = await harness.operations.resolveByDeletingRevision(path, "2-right", "UI Selected");
expect(result).toBe(MISSING_OR_ERROR);
expect(harness.events.emitEvent).not.toHaveBeenCalled();
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;
const remainingManualPair = {
leftRev: "3-merged",
rightRev: "2-third",
leftLeaf: leaf("3-merged", "Merged\n", 3),
rightLeaf: leaf("2-third", "Overlapping\n", 2),
};
harness.tryAutoMerge
.mockResolvedValueOnce({
result: "Merged\n",
conflictedRev: "2-second",
})
.mockResolvedValueOnce(remainingManualPair);
await harness.operations.resolve(path);
expect(harness.databaseFileAccess.storeContent).toHaveBeenCalledWith(path, "Merged\n");
expect(harness.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-second", "Sensible");
expect(harness.queueCheckFor).toHaveBeenCalledWith(path);
expect(harness.resolveByUserInteraction).not.toHaveBeenCalled();
await harness.operations.resolve(path);
expect(harness.tryAutoMerge).toHaveBeenCalledTimes(2);
expect(harness.resolveByUserInteraction).toHaveBeenCalledWith(
path,
expect.objectContaining({
left: remainingManualPair.leftLeaf,
right: remainingManualPair.rightLeaf,
})
);
});
it("requeues and replicates through collaborators after an automatic merge", async () => {
const harness = createOperationsHarness();
const path = "merged.md" as FilePathWithPrefix;
harness.tryAutoMerge.mockResolvedValue({ ok: AUTO_MERGED });
await harness.operations.resolve(path);
expect(harness.replicateUnattendedByEvent).toHaveBeenCalledWith({
trigger: "merge",
interaction: NO_INTERACTION,
});
expect(harness.queueCheckFor).toHaveBeenCalledWith(path);
});
it("postpones a manual merge until its file is active when configured", async () => {
const harness = createOperationsHarness();
const path = "inactive.md" as FilePathWithPrefix;
harness.tryAutoMerge.mockResolvedValue({
leftRev: "2-left",
rightRev: "2-right",
leftLeaf: leaf("2-left", "Left\n", 1),
rightLeaf: leaf("2-right", "Right\n", 2),
});
const dependencies = {
...harness.dependencies,
currentSettings: () => ({
disableMarkdownAutoMerge: false,
resolveConflictsByNewerFile: false,
syncAfterMerge: false,
showMergeDialogOnlyOnActive: true,
}),
vault: { getActiveFilePath: () => "other.md" as FilePath },
};
const operations = createConflictResolutionOperations(dependencies);
await operations.resolve(path);
expect(harness.resolveByUserInteraction).not.toHaveBeenCalled();
expect(dependencies.log).toHaveBeenCalledWith(
expect.stringContaining("Merging process has been postponed"),
LOG_LEVEL_NOTICE
);
});
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;
harness.tryAutoMerge.mockResolvedValue({
leftRev: "2-left",
rightRev: "2-right",
leftLeaf: leaf("2-left", "Left\n", 1),
rightLeaf: leaf("2-right", "Right\n", 2),
});
let finishDialogue: ((result: boolean) => void) | undefined;
harness.resolveByUserInteraction.mockImplementation(
async () => await new Promise<boolean>((resolve) => (finishDialogue = resolve))
);
harness.events.emitEvent.mockImplementation((event, filename) => {
if (event === EVENT_CONFLICT_CANCELLED && filename === path && finishDialogue) {
const finish = finishDialogue;
finishDialogue = undefined;
finish(false);
}
});
const first = harness.operations.resolve(path);
await vi.waitFor(() => expect(harness.resolveByUserInteraction).toHaveBeenCalledOnce());
const replacement = harness.operations.resolve(path);
await vi.waitFor(() => expect(harness.resolveByUserInteraction).toHaveBeenCalledTimes(2));
expect(harness.events.emitEvent).toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
finishDialogue?.(false);
await Promise.all([first, replacement]);
});
it("passes only the newest waiting same-file resolution to the interactive resolver", async () => {
const harness = createOperationsHarness();
const path = "repeated-three-times.md" as FilePathWithPrefix;
harness.tryAutoMerge.mockResolvedValue({
leftRev: "2-left",
rightRev: "2-right",
leftLeaf: leaf("2-left", "Left\n", 1),
rightLeaf: leaf("2-right", "Right\n", 2),
});
let finishFirstDialogue: ((result: boolean) => void) | undefined;
harness.resolveByUserInteraction
.mockImplementationOnce(
async () => await new Promise<boolean>((resolve) => (finishFirstDialogue = resolve))
)
.mockResolvedValue(false);
harness.events.emitEvent.mockImplementation((event, filename) => {
if (event === EVENT_CONFLICT_CANCELLED && filename === path && finishFirstDialogue) {
const finish = finishFirstDialogue;
finishFirstDialogue = undefined;
finish(false);
}
});
const first = harness.operations.resolve(path);
await vi.waitFor(() => expect(harness.resolveByUserInteraction).toHaveBeenCalledOnce());
const superseded = harness.operations.resolve(path);
const replacement = harness.operations.resolve(path);
await Promise.all([first, superseded, replacement]);
expect(harness.resolveByUserInteraction).toHaveBeenCalledTimes(2);
});
it("does not open a superseded dialogue after conflict inspection completes", async () => {
const harness = createOperationsHarness();
const path = "superseded-during-inspection.md" as FilePathWithPrefix;
const manualConflict = {
leftRev: "2-left",
rightRev: "2-right",
leftLeaf: leaf("2-left", "Left\n", 1),
rightLeaf: leaf("2-right", "Right\n", 2),
};
let finishFirstInspection!: (result: typeof manualConflict) => void;
harness.tryAutoMerge
.mockImplementationOnce(
async () => await new Promise<typeof manualConflict>((resolve) => (finishFirstInspection = resolve))
)
.mockResolvedValue(manualConflict);
harness.resolveByUserInteraction.mockResolvedValue(false);
const superseded = harness.operations.resolve(path);
await vi.waitFor(() => expect(harness.tryAutoMerge).toHaveBeenCalledOnce());
const replacement = harness.operations.resolve(path);
finishFirstInspection(manualConflict);
await Promise.all([superseded, replacement]);
expect(harness.resolveByUserInteraction).toHaveBeenCalledOnce();
});
it("registers all conflict service operations during composition", () => {
const harness = createHarness({ compose: false });
const registrations = [
vi.spyOn(harness.conflict.queueCheckForIfOpen, "setHandler"),
vi.spyOn(harness.conflict.queueCheckFor, "setHandler"),
vi.spyOn(harness.conflict.ensureAllProcessed, "setHandler"),
vi.spyOn(harness.conflict.resolveByDeletingRevision, "setHandler"),
vi.spyOn(harness.conflict.resolve, "setHandler"),
vi.spyOn(harness.conflict.resolveByNewest, "setHandler"),
vi.spyOn(harness.conflict.resolveAllConflictedFilesByNewerOnes, "setHandler"),
];
useConflictResolutionFeature({
services: harness.services,
serviceModules: harness.serviceModules,
} as unknown as ConflictResolutionHost);
for (const registration of registrations) {
expect(registration).toHaveBeenCalledOnce();
expect(registration).toHaveBeenCalledWith(expect.any(Function));
}
});
it("applies the active-file gate and deduplicates pending checks for one path", async () => {
const path = "postponed.md" as FilePathWithPrefix;
const harness = createHarness({
activeFile: "other.md" as FilePathWithPrefix,
settings: { checkConflictOnlyOnOpen: true },
});
harness.tryAutoMerge.mockResolvedValue({ ok: NOT_CONFLICTED });
await harness.conflict.queueCheckForIfOpen(path);
expect(harness.tryAutoMerge).not.toHaveBeenCalled();
harness.services.vault.getActiveFilePath = vi.fn(() => path);
await Promise.all([harness.conflict.queueCheckFor(path), harness.conflict.queueCheckFor(path)]);
await harness.conflict.ensureAllProcessed();
expect(harness.tryAutoMerge).toHaveBeenCalledOnce();
expect(harness.tryAutoMerge).toHaveBeenCalledWith(path, true);
expect(harness.addLog).toHaveBeenCalledWith(
`${path} is conflicted, merging process has been postponed.`,
LOG_LEVEL_NOTICE,
""
);
});
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();
const finishByPath = new Map<FilePathWithPrefix, () => void>();
harness.tryAutoMerge.mockImplementation(
async (path: FilePathWithPrefix) =>
await new Promise<{ ok: typeof NOT_CONFLICTED }>((resolve) => {
finishByPath.set(path, () => resolve({ ok: NOT_CONFLICTED }));
})
);
await Promise.all(paths.map(async (path) => await harness.conflict.queueCheckFor(path)));
await vi.waitFor(() => expect(harness.tryAutoMerge).toHaveBeenCalledTimes(10));
expect(harness.conflict.conflictProcessQueueCount.value).toBe(11);
let allProcessed = false;
const completion = harness.conflict.ensureAllProcessed().then((result) => {
allProcessed = true;
return result;
});
await Promise.resolve();
expect(allProcessed).toBe(false);
const finishFirst = finishByPath.get(paths[0]);
finishByPath.delete(paths[0]);
finishFirst?.();
await vi.waitFor(() => expect(harness.tryAutoMerge).toHaveBeenCalledTimes(11));
for (const finish of finishByPath.values()) finish();
await expect(completion).resolves.toBe(true);
expect(harness.conflict.conflictProcessQueueCount.value).toBe(0);
});
it("replaces an older same-path check while every resolver slot is occupied", async () => {
const occupiedPaths = Array.from({ length: 10 }, (_, index) => `occupied-${index}.md` as FilePathWithPrefix);
const repeatedPath = "waiting-replacement.md" as FilePathWithPrefix;
const harness = createHarness();
const finishers: Array<{ path: FilePathWithPrefix; finish: () => void }> = [];
harness.tryAutoMerge.mockImplementation(
async (path: FilePathWithPrefix) =>
await new Promise<{ ok: typeof NOT_CONFLICTED }>((resolve) => {
finishers.push({ path, finish: () => resolve({ ok: NOT_CONFLICTED }) });
})
);
await Promise.all(occupiedPaths.map(async (path) => await harness.conflict.queueCheckFor(path)));
await vi.waitFor(() => expect(harness.tryAutoMerge).toHaveBeenCalledTimes(10));
await harness.conflict.queueCheckFor(repeatedPath);
await harness.conflict.queueCheckFor(repeatedPath);
for (const { finish } of finishers.filter(({ path }) => path !== repeatedPath)) finish();
await vi.waitFor(() => expect(harness.conflict.conflictProcessQueueCount.value).toBe(1));
expect(harness.tryAutoMerge.mock.calls.filter(([path]) => path === repeatedPath)).toHaveLength(1);
finishers.find(({ path }) => path === repeatedPath)?.finish();
await expect(harness.conflict.ensureAllProcessed()).resolves.toBe(true);
});
it("waits for a conflict check requeued by an automatic merge", async () => {
const path = "requeued-automatic-merge.md" as FilePathWithPrefix;
const harness = createHarness({ settings: { syncAfterMerge: false } });
let finishFirst!: (result: { ok: typeof AUTO_MERGED }) => void;
harness.tryAutoMerge
.mockImplementationOnce(
async () =>
await new Promise<{ ok: typeof AUTO_MERGED }>((resolve) => {
finishFirst = resolve;
})
)
.mockResolvedValueOnce({ ok: NOT_CONFLICTED });
await harness.conflict.queueCheckFor(path);
await vi.waitFor(() => expect(harness.tryAutoMerge).toHaveBeenCalledOnce());
const completion = harness.conflict.ensureAllProcessed();
finishFirst({ ok: AUTO_MERGED });
await expect(completion).resolves.toBe(true);
expect(harness.tryAutoMerge).toHaveBeenCalledTimes(2);
expect(harness.conflict.conflictProcessQueueCount.value).toBe(0);
});
it("drains repeated manual resolutions for a file with more than two conflicting versions", async () => {
const path = "requeued-manual-merge.md" as FilePathWithPrefix;
const harness = createHarness({ settings: { syncAfterMerge: false } });
const firstPair = {
leftRev: "3-current",
rightRev: "2-second",
leftLeaf: leaf("3-current", "Current\n", 3),
rightLeaf: leaf("2-second", "Second\n", 2),
};
const remainingPair = {
leftRev: "4-merged",
rightRev: "2-third",
leftLeaf: leaf("4-merged", "Merged\n", 4),
rightLeaf: leaf("2-third", "Third\n", 2),
};
harness.tryAutoMerge
.mockResolvedValueOnce(firstPair)
.mockResolvedValueOnce(remainingPair)
.mockResolvedValueOnce({ ok: NOT_CONFLICTED });
const resolvePair = vi.fn(async (filename: FilePathWithPrefix) => {
await harness.conflict.queueCheckFor(filename);
return false;
});
const unregister = harness.conflict.resolveByUserInteraction.addHandler(resolvePair);
try {
await harness.conflict.queueCheckFor(path);
await expect(harness.conflict.ensureAllProcessed()).resolves.toBe(true);
expect(harness.tryAutoMerge).toHaveBeenCalledTimes(3);
expect(resolvePair).toHaveBeenCalledTimes(2);
expect(harness.conflict.conflictProcessQueueCount.value).toBe(0);
} finally {
unregister();
}
});
it("honours optional conflict handlers before entering the check queue", async () => {
const path = "optional.md" as FilePathWithPrefix;
const harness = createHarness();
harness.tryAutoMerge.mockResolvedValue({ ok: NOT_CONFLICTED });
const unregisterResolved = harness.conflict.getOptionalConflictCheckMethod.addHandler(async () => true);
await harness.conflict.queueCheckFor(path);
await harness.conflict.ensureAllProcessed();
expect(harness.tryAutoMerge).not.toHaveBeenCalled();
unregisterResolved();
const unregisterNewer = harness.conflict.getOptionalConflictCheckMethod.addHandler(async () => "newer");
harness.databaseFileAccess.fetchEntryMeta.mockResolvedValue(false);
await harness.conflict.queueCheckFor(path);
expect(harness.databaseFileAccess.fetchEntryMeta).toHaveBeenCalledWith(path, undefined, true);
unregisterNewer();
});
it("keeps unreadable conflict revisions available for explicit repair", async () => {
const path = "missing-conflict-body.md" as FilePathWithPrefix;
const harness = createHarness();
harness.tryAutoMerge.mockResolvedValue({
leftRev: "3-current",
rightRev: "2-unreadable",
leftLeaf: leaf("3-current", "Readable current body\n", 3),
rightLeaf: false,
});
await harness.conflict.resolve(path);
expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled();
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
expect(harness.addLog).toHaveBeenCalledWith(
`could not read conflicted revision 2-unreadable:${path}`,
LOG_LEVEL_NOTICE,
""
);
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 } });
const cancelled: FilePathWithPrefix[] = [];
harness.context.events.onEvent(EVENT_CONFLICT_CANCELLED, (filename) => cancelled.push(filename));
const leftLeaf = leaf("1-left", "Same content\n", 1000);
const rightLeaf = leaf("1-right", "Same content\n", 2000);
harness.tryAutoMerge
.mockResolvedValueOnce({
leftRev: leftLeaf.rev,
rightRev: rightLeaf.rev,
leftLeaf,
rightLeaf,
})
.mockResolvedValueOnce({ ok: NOT_CONFLICTED });
await harness.conflict.resolve(path);
await harness.conflict.ensureAllProcessed();
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledWith(path, "1-left");
expect(harness.fileHandler.dbToStorage).toHaveBeenCalledWith(path, path, true);
expect(cancelled).toEqual([path, path]);
expect(harness.tryAutoMerge).toHaveBeenCalledTimes(2);
expect(AUTO_MERGED).toBeDefined();
});
it("uses deterministic revision ordering and suppresses notices during bulk resolution", async () => {
const files = Array.from({ length: 11 }, (_, index) => `note-${index}.md` as FilePathWithPrefix);
const harness = createHarness({ files });
harness.databaseFileAccess.fetchEntryMeta.mockImplementation(async (path: FilePathWithPrefix, rev?: string) =>
metadata(path, rev ?? "2-current", rev ? 1 : 2)
);
let conflictInspection = 0;
harness.databaseFileAccess.getConflictedRevs.mockImplementation(async () =>
conflictInspection++ % 2 === 0 ? ["1-old"] : []
);
await harness.conflict.resolveAllConflictedFilesByNewerOnes();
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledTimes(11);
expect(harness.addLog).toHaveBeenCalledWith(
"Check and Processing 10 / 11",
LOG_LEVEL_NOTICE,
"resolveAllConflictedFilesByNewerOnes"
);
expect(harness.addLog).toHaveBeenCalledWith(
expect.stringContaining("has been merged automatically"),
LOG_LEVEL_INFO,
""
);
});
it("uses revision identifiers to break newest-resolution timestamp ties", async () => {
const harness = createOperationsHarness();
const path = "same-time.md" as FilePathWithPrefix;
harness.databaseFileAccess.fetchEntryMeta.mockImplementation(
async (filename: FilePathWithPrefix, revision?: string) => metadata(filename, revision ?? "2-3", 1000)
);
harness.databaseFileAccess.getConflictedRevs
.mockResolvedValueOnce(["2-10", "2-2"])
.mockResolvedValueOnce(["2-10"])
.mockResolvedValueOnce([]);
await harness.operations.resolveByNewest(path);
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenNthCalledWith(1, path, "2-3");
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenNthCalledWith(2, path, "2-10");
expect(harness.dependencies.log).toHaveBeenLastCalledWith(
`${path} has been merged automatically`,
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);
}
});
});