mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-09 20:27:05 +00:00
Strengthen conflict resolution regression coverage
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+226
-11
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user