import { cleanup, fireEvent, render, screen, waitFor, act } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { WorkflowSyncController, WorkflowSyncFormState, } from "@/hooks/domains/settings/use-workflow-sync"; import type { WorkflowSyncConfig } from "@/lib/types/workflow-sync"; import { WorkflowSyncDialog } from "./workflow-sync-dialog"; afterEach(() => { cleanup(); Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 }); }); const WORKFLOWS_DIR = ".kandev/workflows"; const REMOVE_TEST_ID = "workflow-sync-remove"; const REMOVE_CONFIRMATION_TEST_ID = "workflow-sync-remove-confirmation"; const REMOVE_CONFIRM_TEST_ID = "workflow-sync-remove-confirm"; const EMPTY_FORM: WorkflowSyncFormState = { provider: "github", repo_owner: "", repo_name: "", project_path: "", branch: "main", path: WORKFLOWS_DIR, interval_seconds: 300, poll_enabled: true, }; function config(overrides: Partial = {}): WorkflowSyncConfig { return { workspace_id: "workspace-1", provider: "github", repo_owner: "acme", repo_name: "flows", project_path: "", branch: "main", path: WORKFLOWS_DIR, interval_seconds: 300, poll_enabled: true, last_ok: true, created_at: "2026-08-01T00:00:00Z", updated_at: "2026-08-01T00:00:00Z", ...overrides, }; } function formForConfig(currentConfig: WorkflowSyncConfig | null): WorkflowSyncFormState { if (!currentConfig) return { ...EMPTY_FORM }; return { provider: currentConfig.provider, repo_owner: currentConfig.repo_owner, repo_name: currentConfig.repo_name, project_path: currentConfig.project_path, branch: currentConfig.branch, path: currentConfig.path, interval_seconds: currentConfig.interval_seconds, poll_enabled: currentConfig.poll_enabled, }; } function controller(overrides: Partial = {}): WorkflowSyncController { const currentConfig = overrides.config === undefined ? config() : overrides.config; return { config: currentConfig, form: formForConfig(currentConfig), url: currentConfig ? "https://github.com/acme/flows" : "", urlInvalid: false, loading: false, saving: false, syncing: false, update: vi.fn(), setUrlInput: vi.fn(), setProvider: vi.fn(), handleSave: vi.fn().mockResolvedValue(true), handleDelete: vi.fn().mockResolvedValue(true), handleSyncNow: vi.fn().mockResolvedValue(undefined), ...overrides, }; } describe("WorkflowSyncDialog phone removal confirmation", () => { it("does not dismiss a replacement dialog when an unmounted removal completes", async () => { Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 }); let finish!: (result: boolean) => void; const sync = controller({ handleDelete: vi.fn( () => new Promise((resolve) => { finish = resolve; }), ), }); const onOpenChange = vi.fn(); const { rerender } = render( , ); fireEvent.click(screen.getByTestId(REMOVE_TEST_ID)); fireEvent.click(screen.getByTestId(REMOVE_CONFIRM_TEST_ID)); await waitFor(() => expect(sync.handleDelete).toHaveBeenCalledTimes(1)); rerender( , ); await act(async () => finish(true)); expect(onOpenChange).not.toHaveBeenCalled(); expect(screen.getByTestId(REMOVE_TEST_ID)).toBeTruthy(); }); it("uses one phone dialog, preserves its draft, and retries a failed removal", async () => { Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 }); let finish!: (result: boolean) => void; const sync = controller({ handleDelete: vi .fn() .mockImplementationOnce( () => new Promise((resolve) => { finish = resolve; }), ) .mockResolvedValue(true), }); const onOpenChange = vi.fn(); render(); const dialog = screen.getByTestId("workflow-sync-dialog"); const originalId = dialog.id; const input = screen.getByTestId("workflow-sync-branch-input"); const trigger = screen.getByTestId(REMOVE_TEST_ID); fireEvent.click(trigger); expect(screen.getByRole("button", { name: "Back" })).toBeTruthy(); expect(screen.getAllByRole("dialog")).toHaveLength(1); expect(dialog.id).toBe(originalId); expect(input.closest("[inert]")).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "Back" })); await waitFor(() => expect(document.activeElement).toBe(trigger)); expect(screen.getByTestId("workflow-sync-branch-input")).toBe(input); expect(sync.handleDelete).not.toHaveBeenCalled(); fireEvent.click(trigger); const confirm = screen.getByTestId(REMOVE_CONFIRM_TEST_ID); fireEvent.click(confirm); fireEvent.click(confirm); await waitFor(() => expect(sync.handleDelete).toHaveBeenCalledTimes(1)); expect((confirm as HTMLButtonElement).disabled).toBe(true); await act(async () => finish(false)); await waitFor(() => expect((screen.getByTestId(REMOVE_CONFIRM_TEST_ID) as HTMLButtonElement).disabled).toBe( false, ), ); expect(onOpenChange).not.toHaveBeenCalled(); fireEvent.click(screen.getByTestId(REMOVE_CONFIRM_TEST_ID)); await waitFor(() => expect(sync.handleDelete).toHaveBeenCalledTimes(2)); expect(onOpenChange).toHaveBeenCalledWith(false); }); }); describe("WorkflowSyncDialog wider removal confirmation", () => { it("keeps removal inline in the existing dialog without a second overlay", () => { const sync = controller(); render(); fireEvent.click(screen.getByTestId(REMOVE_TEST_ID)); expect(screen.getByTestId(REMOVE_CONFIRMATION_TEST_ID)).toBeTruthy(); expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy(); expect(screen.getByTestId(REMOVE_CONFIRM_TEST_ID)).toBeTruthy(); expect(screen.getAllByRole("dialog")).toHaveLength(1); expect(sync.handleDelete).not.toHaveBeenCalled(); }); it("cancels without mutating sync state", () => { const sync = controller(); const onOpenChange = vi.fn(); render(); fireEvent.click(screen.getByTestId(REMOVE_TEST_ID)); fireEvent.click(screen.getByRole("button", { name: "Cancel" })); expect(sync.handleDelete).not.toHaveBeenCalled(); expect(onOpenChange).not.toHaveBeenCalled(); expect(screen.queryByTestId(REMOVE_CONFIRMATION_TEST_ID)).toBeNull(); expect(screen.getByTestId(REMOVE_TEST_ID)).toBeTruthy(); }); it("prevents saves while removal confirmation is active", () => { const sync = controller(); render(); fireEvent.click(screen.getByTestId(REMOVE_TEST_ID)); const save = screen.getByTestId("workflow-sync-save"); expect(save.disabled).toBe(true); fireEvent.click(save); expect(sync.handleSave).not.toHaveBeenCalled(); }); it("confirms removal once and closes after successful mutation", async () => { const sync = controller(); const onOpenChange = vi.fn(); render(); fireEvent.click(screen.getByTestId(REMOVE_TEST_ID)); fireEvent.click(screen.getByTestId(REMOVE_CONFIRM_TEST_ID)); await waitFor(() => expect(sync.handleDelete).toHaveBeenCalledTimes(1)); expect(onOpenChange).toHaveBeenCalledWith(false); }); it("keeps inline actions available when removal fails", async () => { const sync = controller({ handleDelete: vi.fn().mockResolvedValue(false) }); const onOpenChange = vi.fn(); render(); fireEvent.click(screen.getByTestId(REMOVE_TEST_ID)); fireEvent.click(screen.getByTestId(REMOVE_CONFIRM_TEST_ID)); await waitFor(() => expect(sync.handleDelete).toHaveBeenCalledTimes(1)); expect(onOpenChange).not.toHaveBeenCalled(); expect(screen.getByTestId(REMOVE_CONFIRMATION_TEST_ID)).toBeTruthy(); }); it("clears inline state when the dialog closes or target changes", async () => { const sync = controller(); const { rerender } = render(); fireEvent.click(screen.getByTestId(REMOVE_TEST_ID)); expect(screen.getByTestId(REMOVE_CONFIRMATION_TEST_ID)).toBeTruthy(); rerender(); rerender(); expect(screen.queryByTestId(REMOVE_CONFIRMATION_TEST_ID)).toBeNull(); fireEvent.click(screen.getByTestId(REMOVE_TEST_ID)); const nextSync = controller({ config: config({ repo_name: "new-flows" }) }); rerender(); await waitFor(() => expect(screen.queryByTestId(REMOVE_CONFIRMATION_TEST_ID)).toBeNull()); }); it("clears inline state when the user edits the repository URL", async () => { const sync = controller(); const { rerender } = render(); fireEvent.click(screen.getByTestId(REMOVE_TEST_ID)); fireEvent.change(screen.getByTestId("workflow-sync-url-input"), { target: { value: "https://github.com/acme/other-flows" }, }); expect(sync.setUrlInput).toHaveBeenCalledWith("https://github.com/acme/other-flows"); const editedSync = controller({ form: { ...sync.form, repo_name: "other-flows" }, url: "https://github.com/acme/other-flows", }); rerender(); await waitFor(() => expect(screen.queryByTestId(REMOVE_CONFIRMATION_TEST_ID)).toBeNull()); expect(screen.getByTestId(REMOVE_TEST_ID)).toBeTruthy(); }); });