From f015e11957dd6ab41c578c862d1444aabaa60ba4 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Wed, 4 Nov 2026 02:57:41 +0000 Subject: [PATCH] Test conflict shutdown and Legacy settings saves --- ...interactiveConflictResolution.unit.spec.ts | 103 ++++++++++++++++++ test/apps/webapp/WebAppRuntime.unit.spec.ts | 64 +++++++++++ 2 files changed, 167 insertions(+) diff --git a/src/serviceFeatures/interactiveConflictResolution/interactiveConflictResolution.unit.spec.ts b/src/serviceFeatures/interactiveConflictResolution/interactiveConflictResolution.unit.spec.ts index 0935107f..c4f349e7 100644 --- a/src/serviceFeatures/interactiveConflictResolution/interactiveConflictResolution.unit.spec.ts +++ b/src/serviceFeatures/interactiveConflictResolution/interactiveConflictResolution.unit.spec.ts @@ -452,6 +452,109 @@ describe("interactive conflict resolution operations", () => { await expect(messages).resolves.toEqual([]); }); + it("does not inspect or publish conflict state when called after disposal", async () => { + const fixture = createOperations(); + const emit = vi.spyOn(fixture.context.events, "emitEvent"); + fixture.operations.dispose(); + emit.mockClear(); + + await expect(fixture.operations.getActiveConflictMessages()).resolves.toEqual([]); + await fixture.operations.refreshConflictState(path); + + expect(fixture.getConflictedRevs).not.toHaveBeenCalled(); + expect(fixture.getActiveFilePath).not.toHaveBeenCalled(); + expect(fixture.log).not.toHaveBeenCalled(); + expect(emit).not.toHaveBeenCalled(); + }); + + it("does not requeue a changed comparison when its conflict refresh finishes after disposal", async () => { + let finishInspection!: (revisions: string[]) => void; + const pendingInspection = new Promise((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.dialogueResult.value = "2-right"; + fixture.getDBEntry.mockResolvedValue({ _rev: "3-new-left", _conflicts: ["2-right"] }); + fixture.getConflictedRevs.mockReturnValue(pendingInspection); + const emit = vi.spyOn(fixture.context.events, "emitEvent"); + + const resolving = fixture.operations.resolveByUserInteraction(path, conflict); + await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce()); + const logsBeforeDisposal = sink.mock.calls.length; + fixture.operations.dispose(); + unregisterLog(); + emit.mockClear(); + finishInspection(["2-right"]); + + await expect(resolving).resolves.toBe(false); + expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal); + expect(emit).not.toHaveBeenCalled(); + expect(fixture.storeContent).not.toHaveBeenCalled(); + expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled(); + expect(fixture.queueCheckFor).not.toHaveBeenCalled(); + }); + + it.each([AUTO_MERGED, MISSING_OR_ERROR] as const)( + "does not continue concatenated resolution after deletion settles following disposal (%s)", + async (result) => { + let finishDeletion!: (value: typeof AUTO_MERGED | typeof MISSING_OR_ERROR) => void; + const pendingDeletion = new Promise((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 = LEAVE_TO_SUBSEQUENT; + 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()); + expect(fixture.storeContent).toHaveBeenCalledOnce(); + const logsBeforeDisposal = sink.mock.calls.length; + fixture.operations.dispose(); + unregisterLog(); + finishDeletion(result); + + await expect(resolving).resolves.toBe(false); + expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal); + expect(fixture.resolveByDeletingRevision).toHaveBeenCalledTimes(1); + expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled(); + expect(fixture.queueCheckFor).not.toHaveBeenCalled(); + } + ); + + it("does not requeue after in-flight replication completes following disposal", async () => { + let finishReplication!: (result: { status: "completed" }) => void; + const pendingReplication = new Promise<{ status: "completed" }>((resolve) => { + finishReplication = resolve; + }); + const fixture = createOperations(); + fixture.dialogueResult.value = "2-right"; + fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] }); + fixture.currentSettings.mockReturnValue({ syncAfterMerge: true }); + fixture.replicateUnattendedByEvent.mockReturnValue(pendingReplication); + + const resolving = fixture.operations.resolveByUserInteraction(path, conflict); + await vi.waitFor(() => expect(fixture.replicateUnattendedByEvent).toHaveBeenCalledOnce()); + fixture.operations.dispose(); + finishReplication({ status: "completed" }); + + await expect(resolving).resolves.toBe(false); + expect(fixture.resolveByDeletingRevision).toHaveBeenCalledOnce(); + expect(fixture.replicateUnattendedByEvent).toHaveBeenCalledTimes(1); + expect(fixture.queueCheckFor).not.toHaveBeenCalled(); + }); + it("replaces an active same-file dialogue with only the newest waiting request", async () => { const fixture = createDialogueConcurrencyHarness(); diff --git a/test/apps/webapp/WebAppRuntime.unit.spec.ts b/test/apps/webapp/WebAppRuntime.unit.spec.ts index 68204d2e..ff86da05 100644 --- a/test/apps/webapp/WebAppRuntime.unit.spec.ts +++ b/test/apps/webapp/WebAppRuntime.unit.spec.ts @@ -237,6 +237,70 @@ describe("WebAppRuntime lifecycle", () => { expect(getDirectoryHandle).not.toHaveBeenCalled(); }); + it.each([undefined, 0] as const)("saves supported Legacy settings without adopting NFC (%s)", async (version) => { + const write = vi.fn(async (_data: string) => undefined); + const close = vi.fn(async () => undefined); + const getFileHandle = vi.fn(async () => ({ createWritable: async () => ({ write, close }) })); + const getDirectoryHandle = vi.fn(async () => ({ getFileHandle })); + const runtime = new WebAppRuntime( + { name: "legacy-settings", getDirectoryHandle } as unknown as FileSystemDirectoryHandle, + { deviceValues: new MemoryValueStore() } + ); + const options = ( + runtime as unknown as { createServiceOptions(): LiveSyncBrowserServiceHubOptions } + ).createServiceOptions(); + if (!options.settings) throw new Error("The runtime must provide settings callbacks"); + const settings = { + ...unconfiguredSettings, + ...(version === undefined ? {} : { pathNormalizationVersion: version }), + } as ObsidianLiveSyncSettings; + + await options.settings.save(settings); + + expect(getDirectoryHandle).toHaveBeenCalledWith(".livesync", { create: true }); + expect(getFileHandle).toHaveBeenCalledWith("settings.json", { create: true }); + expect(write).toHaveBeenCalledOnce(); + expect(JSON.parse(write.mock.calls[0][0])).toEqual(settings); + expect(JSON.parse(write.mock.calls[0][0]).pathNormalizationVersion).toBe(version); + expect(close).toHaveBeenCalledOnce(); + }); + + it("reports a Legacy settings write failure without logging a successful save", async () => { + const denied = new DOMException("Settings write denied", "NotAllowedError"); + const write = vi.fn(async () => { + throw denied; + }); + const close = vi.fn(async () => undefined); + const runtime = new WebAppRuntime( + { + name: "denied-settings", + getDirectoryHandle: async () => ({ + getFileHandle: async () => ({ createWritable: async () => ({ write, close }) }), + }), + } as unknown as FileSystemDirectoryHandle, + { deviceValues: new MemoryValueStore() } + ); + const options = ( + runtime as unknown as { createServiceOptions(): LiveSyncBrowserServiceHubOptions } + ).createServiceOptions(); + if (!options.settings) throw new Error("The runtime must provide settings callbacks"); + const settings = { ...unconfiguredSettings, pathNormalizationVersion: 0 } as ObsidianLiveSyncSettings; + await runtime.start(); + runtimeMocks.addLog.mockClear(); + + await expect(options.settings.save(settings)).resolves.toBeUndefined(); + + expect(write).toHaveBeenCalledOnce(); + expect(close).not.toHaveBeenCalled(); + expect(runtimeMocks.addLog).toHaveBeenCalledWith( + expect.stringContaining("Failed to save settings: NotAllowedError: Settings write denied"), + expect.any(Number), + "settings" + ); + expect(runtimeMocks.addLog.mock.calls.some(([message]) => String(message).startsWith("Saved to "))).toBe(false); + expect(settings.pathNormalizationVersion).toBe(0); + }); + it("owns core start, directory scan, status reporting, and shutdown", async () => { const reportStatus = vi.fn(); const runtime = new WebAppRuntime(createRootHandle(), { reportStatus, deviceValues: new MemoryValueStore() });