Test conflict shutdown and Legacy settings saves

This commit is contained in:
vorotamoroz committed 2026-11-04 02:57:41 +00:00
1 parent 9920da8f94
commit f015e11957
2 files changed
+167

No files matched your search

@@ -452,6 +452,109 @@ describe("interactive conflict resolution operations", () => {
await expect(messages).resolves.toEqual([]); 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<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.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<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 = 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 () => { it("replaces an active same-file dialogue with only the newest waiting request", async () => {
const fixture = createDialogueConcurrencyHarness(); const fixture = createDialogueConcurrencyHarness();
@@ -237,6 +237,70 @@ describe("WebAppRuntime lifecycle", () => {
expect(getDirectoryHandle).not.toHaveBeenCalled(); 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<never> }
).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<never> }
).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 () => { it("owns core start, directory scan, status reporting, and shutdown", async () => {
const reportStatus = vi.fn(); const reportStatus = vi.fn();
const runtime = new WebAppRuntime(createRootHandle(), { reportStatus, deviceValues: new MemoryValueStore() }); const runtime = new WebAppRuntime(createRootHandle(), { reportStatus, deviceValues: new MemoryValueStore() });