import { describe, expect, it, vi } from "vitest"; import { UICommandRegistry } from "@vrtmrz/livesync-commonlib/ui-commands"; import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings"; import { REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types"; import type { JoinedDevicesSnapshot } from "@vrtmrz/livesync-commonlib/replication"; import type { JoinedDevicesDialogOptions } from "./joinedDevicesDialogTypes"; vi.mock("@/modules/services/LiveSyncUI/dialogues/JoinedDevicesDialog.svelte", () => ({ default: {} })); vi.mock("@/common/translation", () => ({ $msg: (message: string) => message })); import { useJoinedDevicesFeature } from "./useJoinedDevices"; import { OPEN_JOINED_DEVICES } from "./joinedDevicesCommand"; function fixture() { const settings = { ...createNewVaultSettings(), remoteType: REMOTE_COUCHDB, couchDB_URI: "https://example.test", couchDB_DBNAME: "vault", }; const initialise: Array<() => Promise> = []; const unload: Array<() => Promise> = []; const before: Array<() => Promise> = []; const stopBefore = vi.fn(); const commands = new UICommandRegistry(); const read = vi.fn<() => Promise>(async () => ({ milestonePresent: true, locked: false, cleaned: false, devices: [{ id: "other", accepted: true, pluginVersion: "2.0.0" }], })); const forget = vi.fn(async (_id: string) => "forgotten" as const); const dispose = vi.fn(async () => {}); const createResource = vi.fn(async () => ({ read, forget, dispose })); let close!: () => void; const dialog = vi.fn( (_component: unknown, _options: JoinedDevicesDialogOptions) => new Promise((resolve) => { close = resolve; }) ); const notices = { setItem: vi.fn(), finish: vi.fn(), hide: vi.fn() }; const palette: Array<{ id: string; checkCallback: (checking: boolean) => boolean }> = []; const database = { localDatabase: { get: vi.fn(async () => ({ nodeid: "self" })) } }; const host = { services: { API: { getPluginVersion: () => "1.0.0", getAppVersion: () => "1.9.0", getPlatform: () => "desktop", addCommand: (command: (typeof palette)[number]) => palette.push(command), }, UI: { commands, dialogManager: { open: dialog } }, setting: { currentSettings: () => settings }, database: { isDatabaseReady: () => true, localDatabase: database }, replicator: { createRemoteResource: createResource }, replication: { onBeforeReplicate: { addHandler: (handler: (typeof before)[number]) => { before.push(handler); return stopBefore; }, }, }, appLifecycle: { onInitialise: { addHandler: (handler: (typeof initialise)[number]) => initialise.push(handler) }, onUnload: { addHandler: (handler: (typeof unload)[number]) => unload.push(handler) }, }, }, serviceModules: {}, } as unknown as Parameters[0]; useJoinedDevicesFeature(host, notices as unknown as Parameters[1]); return { settings, initialise, unload, before, stopBefore, commands, read, forget, dispose, createResource, dialog, notices, palette, database, close: () => close(), }; } async function open(data: ReturnType) { const pending = data.commands.invoke(OPEN_JOINED_DEVICES, undefined); const options = data.dialog.mock.calls[0][1]; return { options, pending }; } describe("joined devices service feature", () => { it("opens one dialogue through the command and disposes each completed read", async () => { const data = fixture(); const { options, pending } = await open(data); expect(await data.commands.invoke(OPEN_JOINED_DEVICES, undefined)).toEqual({ status: "handled", value: "already-open", }); expect((await options.refresh()).devices[0]).toMatchObject({ id: "self", self: true }); expect(data.dispose).toHaveBeenCalledTimes(1); data.close(); expect(await pending).toEqual({ status: "handled", value: "closed" }); await expect(options.refresh()).rejects.toThrow(); }); it("prevents self removal and fences an old dialogue after a remote switch", async () => { const data = fixture(); const { options, pending } = await open(data); await expect(options.forget("self")).rejects.toThrow(); data.settings.couchDB_DBNAME = "new-vault"; await expect(options.forget("other")).rejects.toThrow(); expect(data.forget).not.toHaveBeenCalled(); expect(data.createResource).not.toHaveBeenCalled(); data.close(); await pending; }); it("discards a stale read, releases the resource, and emits no stale Notice", async () => { const data = fixture(); let resolve!: (value: JoinedDevicesSnapshot) => void; data.read.mockReturnValueOnce( new Promise((done) => { resolve = done; }) ); const { options, pending } = await open(data); const reading = options.refresh(); await vi.waitFor(() => expect(data.read).toHaveBeenCalled()); data.settings.couchDB_DBNAME = "new-vault"; resolve({ milestonePresent: true, locked: false, cleaned: false, devices: [] }); await expect(reading).rejects.toThrow(); expect(data.dispose).toHaveBeenCalledOnce(); expect(data.notices.setItem).not.toHaveBeenCalled(); data.close(); await pending; }); it.each(["dialogue cancellation", "remote switch", "unload"] as const)( "does not send a pending forget operation after %s", async (invalidation) => { const data = fixture(); const resource = { read: data.read, forget: data.forget, dispose: data.dispose }; let resolve!: (value: typeof resource) => void; data.createResource.mockReturnValueOnce( new Promise((done) => { resolve = done; }) ); const { options, pending } = await open(data); const forgetting = options.forget("other"); const rejected = expect(forgetting).rejects.toThrow(); await vi.waitFor(() => expect(data.createResource).toHaveBeenCalledOnce()); if (invalidation === "dialogue cancellation") options.cancel(); if (invalidation === "remote switch") data.settings.couchDB_DBNAME = "new-vault"; if (invalidation === "unload") for (const unload of data.unload) await unload(); resolve(resource); await rejected; expect(data.forget).not.toHaveBeenCalled(); expect(data.dispose).toHaveBeenCalledOnce(); data.close(); await pending; } ); it("discards a completed forget result after the dialogue closes and releases its resource", async () => { const data = fixture(); let resolve!: (value: "forgotten") => void; data.forget.mockReturnValueOnce( new Promise((done) => { resolve = done; }) ); const { options, pending } = await open(data); const forgetting = options.forget("other"); const rejected = expect(forgetting).rejects.toThrow(); await vi.waitFor(() => expect(data.forget).toHaveBeenCalledWith("other")); data.close(); await pending; resolve("forgotten"); await rejected; expect(data.dispose).toHaveBeenCalledOnce(); }); it("propagates a failed forget operation after releasing its resource", async () => { const data = fixture(); const failure = new Error("conditional write failed"); data.forget.mockRejectedValueOnce(failure); const { options, pending } = await open(data); await expect(options.forget("other")).rejects.toBe(failure); expect(data.dispose).toHaveBeenCalledOnce(); data.close(); await pending; }); it("deduplicates advisory Notices and fences their actions after configuration changes", async () => { const data = fixture(); const { options, pending } = await open(data); await options.refresh(); await options.refresh(); expect(data.notices.setItem).toHaveBeenCalledTimes(1); const action = data.notices.setItem.mock.calls[0][2].action; data.close(); await pending; data.settings.couchDB_DBNAME = "new-vault"; action.onSelect(); expect(data.dialog).toHaveBeenCalledTimes(1); }); it("keeps inspection failures out of the replication result and unregisters on unload", async () => { const data = fixture(); data.read.mockRejectedValue(new Error("unavailable")); expect(await data.before[0]()).toBe(true); await vi.waitFor(() => expect(data.dispose).toHaveBeenCalledOnce()); expect(await data.before[0]()).toBe(true); expect(data.read).toHaveBeenCalledTimes(1); for (const unload of data.unload) await unload(); expect(await data.commands.invoke(OPEN_JOINED_DEVICES, undefined)).toEqual({ status: "unavailable" }); expect(data.stopBefore).toHaveBeenCalledOnce(); expect(data.notices.hide).toHaveBeenCalled(); }); it("uses the same UI command from the command palette", async () => { const data = fixture(); for (const initialise of data.initialise) await initialise(); expect(data.palette[0].checkCallback(true)).toBe(true); expect(data.dialog).not.toHaveBeenCalled(); data.palette[0].checkCallback(false); expect(data.dialog).toHaveBeenCalledOnce(); data.close(); }); });