import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ confirmAction: vi.fn(), pickOne: vi.fn(), promptPassword: vi.fn(), promptText: vi.fn(), legacyAskSelectString: vi.fn(), legacyAskString: vi.fn(), legacyAskYesNo: vi.fn(), legacyConfirm: vi.fn(), legacyWideConfirm: vi.fn(), })); vi.mock("@vrtmrz/obsidian-plugin-kit", () => ({ confirmAction: mocks.confirmAction, pickOne: mocks.pickOne, promptPassword: mocks.promptPassword, promptText: mocks.promptText, })); vi.mock("@/modules/coreObsidian/UILib/dialogs", () => ({ askSelectString: mocks.legacyAskSelectString, askString: mocks.legacyAskString, askYesNo: mocks.legacyAskYesNo, confirmWithMessage: mocks.legacyConfirm, confirmWithMessageWithWideButton: mocks.legacyWideConfirm, })); vi.mock("@/deps", () => ({ Notice: class {}, })); import { EVENT_PLUGIN_UNLOADED } from "@/common/events"; import { createLiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context"; import { ObsidianConfirm } from "./ObsidianConfirm"; import type { ObsidianServiceContext } from "./ObsidianServiceContext"; function createConfirm() { const app = { id: "app" }; const plugin = { app }; const events = createLiveSyncEventHub(); const notices = { show: vi.fn(), hide: vi.fn(), }; const context = { app, plugin, events, notices } as unknown as ObsidianServiceContext; return { confirm: new ObsidianConfirm(context), events, app, plugin, notices }; } beforeEach(() => { vi.clearAllMocks(); }); describe("ObsidianConfirm Fancy Kit adapter", () => { it("uses owner-bound Kit prompts and preserves cancellation and empty input", async () => { const { confirm, app } = createConfirm(); mocks.promptText.mockResolvedValueOnce(null); mocks.promptPassword.mockResolvedValueOnce(""); await expect(confirm.askString("Name", "Device name", "New Remote")).resolves.toBe(false); await expect(confirm.askString("Secret", "Passphrase", "Enter it", true)).resolves.toBe(""); expect(mocks.promptText).toHaveBeenCalledWith( app, { title: "Name", label: "Device name", placeholder: "New Remote", }, { signal: expect.any(AbortSignal) } ); expect(mocks.promptPassword).toHaveBeenCalledWith( app, { title: "Secret", label: "Passphrase", placeholder: "Enter it", }, { signal: expect.any(AbortSignal) } ); expect(mocks.legacyAskString).not.toHaveBeenCalled(); }); it("uses Kit for untimed yes/no and typed selection while preserving dismissed results", async () => { const { confirm, app } = createConfirm(); mocks.confirmAction.mockResolvedValueOnce("yes"); mocks.pickOne.mockResolvedValueOnce("Beta").mockResolvedValueOnce(null); await expect(confirm.askYesNo("Continue?")).resolves.toBe("yes"); await expect(confirm.askSelectString("Target", ["Alpha", "Beta"])).resolves.toBe("Beta"); await expect(confirm.askSelectString("Target", ["Alpha"])).resolves.toBe(""); expect(mocks.confirmAction).toHaveBeenCalledWith( app, expect.objectContaining({ message: "Continue?", actions: ["yes", "no"], actionLayout: "vertical", defaultAction: "no", }), { signal: expect.any(AbortSignal) } ); expect(mocks.pickOne).toHaveBeenCalledWith( app, expect.objectContaining({ items: ["Alpha", "Beta"], getText: expect.any(Function), }), { signal: expect.any(AbortSignal) } ); expect(mocks.legacyAskYesNo).not.toHaveBeenCalled(); expect(mocks.legacyAskSelectString).not.toHaveBeenCalled(); }); it("keeps untimed and countdown action dialogues vertically stacked", async () => { const { confirm, app, plugin } = createConfirm(); mocks.confirmAction.mockResolvedValueOnce("Apply").mockResolvedValueOnce("Yes"); mocks.legacyWideConfirm.mockResolvedValueOnce("Cancel").mockResolvedValueOnce("No"); await expect(confirm.confirmWithMessage("Review", "**Apply?**", ["Apply", "Cancel"], "Cancel")).resolves.toBe( "Apply" ); await expect(confirm.askYesNoDialog("Continue?", { title: "Question", defaultOption: "Yes" })).resolves.toBe( "yes" ); await expect(confirm.confirmWithMessage("Timed", "Wait", ["Apply", "Cancel"], "Cancel", 30)).resolves.toBe( "Cancel" ); await expect(confirm.askYesNoDialog("Timed?", { defaultOption: "No", timeout: 10 })).resolves.toBe("no"); expect(mocks.confirmAction).toHaveBeenNthCalledWith( 1, app, { title: "Review", message: "**Apply?**", actions: ["Apply", "Cancel"], actionLayout: "vertical", defaultAction: "Cancel", sourcePath: "/", }, { signal: expect.any(AbortSignal) } ); expect(mocks.confirmAction).toHaveBeenNthCalledWith( 2, app, expect.objectContaining({ title: "Question", message: "Continue?", actions: ["Yes", "No"], actionLayout: "vertical", defaultAction: "Yes", }), { signal: expect.any(AbortSignal) } ); expect(mocks.legacyWideConfirm).toHaveBeenNthCalledWith( 1, plugin, "Timed", "Wait", ["Apply", "Cancel"], "Cancel", 30 ); expect(mocks.legacyWideConfirm).toHaveBeenNthCalledWith( 2, plugin, expect.any(String), "Timed?", expect.any(Array), expect.any(String), 10 ); expect(mocks.legacyConfirm).not.toHaveBeenCalled(); }); it("uses Kit for untimed multi-action selection and keeps timed wide actions on the countdown dialogue", async () => { const { confirm, app, plugin } = createConfirm(); const actions = ["Apply now", "Review later"] as const; mocks.confirmAction.mockResolvedValueOnce("Review later"); mocks.legacyWideConfirm.mockResolvedValueOnce("Apply now"); await expect( confirm.askSelectStringDialogue("Choose the next step", actions, { title: "Next step", defaultAction: "Review later", }) ).resolves.toBe("Review later"); await expect( confirm.askSelectStringDialogue("Choose before the timer expires", actions, { title: "Timed step", defaultAction: "Apply now", timeout: 15, }) ).resolves.toBe("Apply now"); expect(mocks.confirmAction).toHaveBeenCalledWith( app, { title: "Next step", message: "Choose the next step", actions, actionLayout: "vertical", defaultAction: "Review later", sourcePath: "/", }, { signal: expect.any(AbortSignal) } ); expect(mocks.legacyWideConfirm).toHaveBeenCalledWith( plugin, "Timed step", "Choose before the timer expires", actions, "Apply now", 15 ); }); it("refers P2P connection approvals to the current P2P Status pane", async () => { const { confirm, plugin } = createConfirm(); const actions = ["Accept", "Ignore"] as const; mocks.legacyWideConfirm.mockResolvedValueOnce("Ignore"); await confirm.askSelectStringDialogue( "You can revoke your decision from the Peer-to-Peer Replicator Pane.", actions, { title: "P2P Connection Request", defaultAction: "Ignore", timeout: 30, } ); expect(mocks.legacyWideConfirm).toHaveBeenCalledWith( plugin, "P2P Connection Request", "You can revoke your decision from the P2P Status pane.", actions, "Ignore", 30 ); }); it("dismisses an open Kit dialogue when the plug-in unload event is emitted", async () => { const { confirm, events } = createConfirm(); let observedSignal: AbortSignal | undefined; mocks.confirmAction.mockImplementation( (_app, _options, lifecycle: { signal: AbortSignal }) => new Promise((resolve) => { observedSignal = lifecycle.signal; lifecycle.signal.addEventListener("abort", () => resolve(null), { once: true }); }) ); const result = confirm.confirmWithMessage("Review", "Message", ["OK"], "OK"); expect(observedSignal?.aborted).toBe(false); events.emitEvent(EVENT_PLUGIN_UNLOADED); await expect(result).resolves.toBe(false); expect(observedSignal?.aborted).toBe(true); }); it("routes popup display and expiry through the context-owned keyed Notice manager", async () => { vi.useFakeTimers(); const popupKey = "popup-remote-size-exceeded"; const fragment = {} as DocumentFragment; const anchor = { addEventListener: vi.fn() } as unknown as HTMLAnchorElement; const span = { appendText: vi.fn(), appendChild: vi.fn(), createEl: vi.fn((_tag, _options, callback: (element: HTMLAnchorElement) => void) => { callback(anchor); return anchor; }), }; vi.stubGlobal("createFragment", (callback: (document: unknown) => void) => { callback({ createSpan: (_options: unknown, build: (element: typeof span) => void) => build(span), }); return fragment; }); const { confirm, notices } = createConfirm(); try { confirm.askInPopup("remote-size-exceeded", "Review {HERE} details", vi.fn(), 20_000); await vi.advanceTimersByTimeAsync(1_000); expect(notices.show).toHaveBeenCalledWith(popupKey, fragment, { durationMs: 20_000 }); } finally { await vi.runAllTimersAsync(); vi.useRealTimers(); vi.unstubAllGlobals(); } }); it("closes an owned keyed Notice when the plug-in unload event is emitted", () => { const { confirm, events, notices } = createConfirm(); const popupKey = "popup-remote-size-exceeded"; (confirm as unknown as { popupKeys: Set }).popupKeys.add(popupKey); events.emitEvent(EVENT_PLUGIN_UNLOADED); expect(notices.hide).toHaveBeenCalledWith(popupKey); }); });