diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx index e03cf38ea..6d5d3f15b 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx @@ -2171,9 +2171,8 @@ describe("SettingsModal", () => { }); render(); - await waitForSettingsModalReady(); - await userEvent.click(screen.getByRole("button", { name: "Authentication" })); - const copilotCard = screen.getByTestId("auth-provider-icon-github-copilot").closest(".auth-provider-card") as HTMLElement; + await userEvent.click(await screen.findByRole("button", { name: "Authentication" })); + const copilotCard = (await screen.findByTestId("auth-provider-icon-github-copilot")).closest(".auth-provider-card") as HTMLElement; await userEvent.click(within(copilotCard).getByRole("button", { name: "Login" })); await within(copilotCard).findByText("ABCD-1234"); @@ -2202,9 +2201,8 @@ describe("SettingsModal", () => { }); render(); - await waitForSettingsModalReady(); - await userEvent.click(screen.getByRole("button", { name: "Authentication" })); - const copilotCard = screen.getByTestId("auth-provider-icon-github-copilot").closest(".auth-provider-card") as HTMLElement; + await userEvent.click(await screen.findByRole("button", { name: "Authentication" })); + const copilotCard = (await screen.findByTestId("auth-provider-icon-github-copilot")).closest(".auth-provider-card") as HTMLElement; await userEvent.click(within(copilotCard).getByRole("button", { name: "Login" })); await within(copilotCard).findByText("ABCD-1234"); @@ -3743,6 +3741,12 @@ describe("SettingsModal", () => { }); describe("Remote section", () => { + let user: ReturnType; + + beforeEach(() => { + user = userEvent.setup({ delay: null }); + }); + beforeEach(() => { mockFetchSettings.mockResolvedValue({ ...defaultSettings, @@ -3752,15 +3756,133 @@ describe("SettingsModal", () => { const openRemoteSection = async () => { const [remoteSectionButton] = await screen.findAllByRole("button", { name: /Remote Access/i }); - await userEvent.click(remoteSectionButton); + await user.click(remoteSectionButton); await screen.findByRole("heading", { name: "Remote Access" }); }; const openAdvancedSettings = async () => { const summary = screen.getByText("Advanced Settings"); - await userEvent.click(summary); + await user.click(summary); }; + describe("with default Remote render", () => { + beforeEach(async () => { + renderModal(); + await waitForSettingsModalReady(); + await openRemoteSection(); + }); + + it("shows provider-specific settings when provider selected and auto-saves on Start Tunnel", async () => { + await user.click(screen.getByLabelText("Tailscale")); + expect(screen.queryByLabelText("Hostname label")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Target port")).not.toBeInTheDocument(); + expect(screen.getByLabelText("Accept routes")).toBeInTheDocument(); + expect(screen.queryByLabelText("Tunnel name")).not.toBeInTheDocument(); + + await user.click(screen.getByLabelText("Cloudflare")); + expect(screen.queryByLabelText("Accept routes")).not.toBeInTheDocument(); + + if (!screen.queryByLabelText("Tunnel name")) { + const advancedDetails = screen.getByText(/Advanced \(Named Tunnel\)/i, { selector: "summary" }).closest("details") as HTMLDetailsElement; + advancedDetails.open = true; + fireEvent(advancedDetails, new Event("toggle")); + expect(await screen.findByLabelText("Tunnel name")).toBeInTheDocument(); + } + fireEvent.change(screen.getByLabelText("Tunnel name"), { target: { value: "cf-team" } }); + fireEvent.change(screen.getByLabelText("Tunnel token"), { target: { value: "cf_token" } }); + fireEvent.change(screen.getByLabelText("Ingress URL"), { target: { value: "https://remote.example.com" } }); + + await user.click(screen.getByRole("button", { name: "Start Tunnel" })); + + await waitFor(() => { + expect(mockUpdateRemoteSettings).toHaveBeenCalledWith( + expect.objectContaining({ + remoteActiveProvider: "cloudflare", + remoteCloudflareEnabled: true, + remoteCloudflareQuickTunnel: false, + remoteCloudflareTunnelName: "cf-team", + remoteCloudflareTunnelToken: "cf_token", + remoteCloudflareIngressUrl: "https://remote.example.com", + }), + undefined, + ); + }); + }); + + it("toggles Cloudflare named tunnel advanced section and persists quick tunnel state", async () => { + await user.click(screen.getByLabelText("Cloudflare")); + const namedTunnelSummary = screen.getByText(/Advanced \(Named Tunnel\)/i, { selector: "summary" }); + + if (!screen.queryByLabelText("Tunnel name")) { + await user.click(namedTunnelSummary); + } + expect(await screen.findByLabelText("Tunnel name")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Start Tunnel" })); + await waitFor(() => { + expect(mockUpdateRemoteSettings).toHaveBeenCalledWith(expect.objectContaining({ remoteCloudflareQuickTunnel: false }), undefined); + }); + + // Closing
is not consistently simulated in jsdom; verify default quick-tunnel state via a fresh render below. + }); + + it("renders branded Cloudflare icon in Remote provider selector", () => { + const cloudflareSlot = screen.getByTestId("remote-provider-icon-cloudflare"); + expect(within(cloudflareSlot).getByTestId("remote-cloudflare-option-icon")).toBeInTheDocument(); + }); + + it("Start Tunnel button is disabled when no provider is selected", async () => { + const startButton = screen.getByRole("button", { name: "Start Tunnel" }); + expect(startButton).toBeDisabled(); + await user.click(screen.getByLabelText("Tailscale")); + expect(startButton).not.toBeDisabled(); + }); + + it("no separate Activate Provider or Save Remote Settings buttons exist", () => { + expect(screen.queryByRole("button", { name: /Activate Provider/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Save Remote Settings/i })).not.toBeInTheDocument(); + }); + + it("only the selected provider's settings are rendered", async () => { + await user.click(screen.getByLabelText("Tailscale")); + expect(screen.getByLabelText("Accept routes")).toBeInTheDocument(); + expect(screen.queryByText(/Advanced \(Named Tunnel\)/i)).not.toBeInTheDocument(); + + await user.click(screen.getByLabelText("Cloudflare")); + expect(screen.getByText(/Advanced \(Named Tunnel\)/i)).toBeInTheDocument(); + expect(screen.queryByLabelText("Accept routes")).not.toBeInTheDocument(); + }); + + it("sets quick tunnel false when opening Cloudflare advanced details", async () => { + await user.click(screen.getByLabelText("Cloudflare")); + + const namedTunnelSummary = screen.getByText(/Advanced \(Named Tunnel\)/i, { selector: "summary" }); + if (!screen.queryByLabelText("Tunnel name")) { + await user.click(namedTunnelSummary); + } + await user.click(screen.getByRole("button", { name: "Start Tunnel" })); + await waitFor(() => { + expect(mockUpdateRemoteSettings).toHaveBeenCalledWith(expect.objectContaining({ remoteCloudflareQuickTunnel: false }), undefined); + }); + }); + + it("Start Tunnel auto-saves with enabled=true on selected provider before starting", async () => { + await user.click(screen.getByLabelText("Tailscale")); + await user.click(screen.getByRole("button", { name: "Start Tunnel" })); + + await waitFor(() => { + expect(mockUpdateRemoteSettings).toHaveBeenCalledWith( + expect.objectContaining({ + remoteActiveProvider: "tailscale", + remoteTailscaleEnabled: true, + }), + undefined, + ); + }); + expect(mockStartRemoteTunnel).toHaveBeenCalled(); + }); + }); + it("renders remote-status-bar with stopped state and omits share block when not running", async () => { mockFetchRemoteStatus.mockResolvedValue({ provider: null, state: "stopped", url: null, lastError: null }); const { container } = renderModal(); @@ -3785,71 +3907,6 @@ describe("SettingsModal", () => { expect(container.querySelector(".remote-share-block")).toBeInTheDocument(); }); - it("shows provider-specific settings when provider selected and auto-saves on Start Tunnel", async () => { - renderModal(); - await waitForSettingsModalReady(); - await openRemoteSection(); - - await userEvent.click(screen.getByLabelText("Tailscale")); - expect(screen.queryByLabelText("Hostname label")).not.toBeInTheDocument(); - expect(screen.queryByLabelText("Target port")).not.toBeInTheDocument(); - expect(screen.getByLabelText("Accept routes")).toBeInTheDocument(); - expect(screen.queryByLabelText("Tunnel name")).not.toBeInTheDocument(); - - await userEvent.click(screen.getByLabelText("Cloudflare")); - expect(screen.queryByLabelText("Accept routes")).not.toBeInTheDocument(); - - if (!screen.queryByLabelText("Tunnel name")) { - const advancedDetails = screen.getByText(/Advanced \(Named Tunnel\)/i, { selector: "summary" }).closest("details") as HTMLDetailsElement; - advancedDetails.open = true; - fireEvent(advancedDetails, new Event("toggle")); - expect(await screen.findByLabelText("Tunnel name")).toBeInTheDocument(); - } - await userEvent.clear(screen.getByLabelText("Tunnel name")); - await userEvent.type(screen.getByLabelText("Tunnel name"), "cf-team"); - await userEvent.clear(screen.getByLabelText("Tunnel token")); - await userEvent.type(screen.getByLabelText("Tunnel token"), "cf_token"); - await userEvent.clear(screen.getByLabelText("Ingress URL")); - await userEvent.type(screen.getByLabelText("Ingress URL"), "https://remote.example.com"); - - await userEvent.click(screen.getByRole("button", { name: "Start Tunnel" })); - - await waitFor(() => { - expect(mockUpdateRemoteSettings).toHaveBeenCalledWith( - expect.objectContaining({ - remoteActiveProvider: "cloudflare", - remoteCloudflareEnabled: true, - remoteCloudflareQuickTunnel: false, - remoteCloudflareTunnelName: "cf-team", - remoteCloudflareTunnelToken: "cf_token", - remoteCloudflareIngressUrl: "https://remote.example.com", - }), - undefined, - ); - }); - }); - - it("toggles Cloudflare named tunnel advanced section and persists quick tunnel state", async () => { - renderModal(); - await waitForSettingsModalReady(); - await openRemoteSection(); - - await userEvent.click(screen.getByLabelText("Cloudflare")); - const namedTunnelSummary = screen.getByText(/Advanced \(Named Tunnel\)/i, { selector: "summary" }); - - if (!screen.queryByLabelText("Tunnel name")) { - await userEvent.click(namedTunnelSummary); - } - expect(await screen.findByLabelText("Tunnel name")).toBeInTheDocument(); - - await userEvent.click(screen.getByRole("button", { name: "Start Tunnel" })); - await waitFor(() => { - expect(mockUpdateRemoteSettings).toHaveBeenCalledWith(expect.objectContaining({ remoteCloudflareQuickTunnel: false }), undefined); - }); - - // Closing
is not consistently simulated in jsdom; verify default quick-tunnel state via a fresh render below. - }); - it("updates provider selection via radio and shows provider status", async () => { mockFetchRemoteStatus .mockResolvedValueOnce({ provider: null, state: "stopped", url: null, lastError: null }) @@ -3859,10 +3916,10 @@ describe("SettingsModal", () => { await waitForSettingsModalReady(); await openRemoteSection(); - await userEvent.click(screen.getByLabelText("Tailscale")); + await user.click(screen.getByLabelText("Tailscale")); expect(screen.getByLabelText("Tailscale")).toBeChecked(); - await userEvent.click(screen.getByRole("button", { name: "Start Tunnel" })); + await user.click(screen.getByRole("button", { name: "Start Tunnel" })); expect(await screen.findByText("https://tail.example", { selector: ".remote-status-url" })).toBeInTheDocument(); }); @@ -3872,28 +3929,19 @@ describe("SettingsModal", () => { renderModal(); await waitForSettingsModalReady(); await openRemoteSection(); - await userEvent.click(screen.getByLabelText("Cloudflare")); + await user.click(screen.getByLabelText("Cloudflare")); expect(await screen.findByText("cloudflared is installed")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Install cloudflared" })).not.toBeInTheDocument(); }); - it("renders branded Cloudflare icon in Remote provider selector", async () => { - renderModal(); - await waitForSettingsModalReady(); - await openRemoteSection(); - - const cloudflareSlot = screen.getByTestId("remote-provider-icon-cloudflare"); - expect(within(cloudflareSlot).getByTestId("remote-cloudflare-option-icon")).toBeInTheDocument(); - }); - it("shows install button when Cloudflare is selected and cloudflared is not available", async () => { mockFetchRemoteStatus.mockResolvedValue({ provider: "cloudflare", state: "stopped", url: null, lastError: null, cloudflaredAvailable: false }); renderModal(); await waitForSettingsModalReady(); await openRemoteSection(); - await userEvent.click(screen.getByLabelText("Cloudflare")); + await user.click(screen.getByLabelText("Cloudflare")); expect(await screen.findByText("cloudflared is not installed")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Install cloudflared" })).toBeInTheDocument(); @@ -3910,10 +3958,10 @@ describe("SettingsModal", () => { renderModal({ addToast }); await waitForSettingsModalReady(); await openRemoteSection(); - await userEvent.click(screen.getByLabelText("Cloudflare")); + await user.click(screen.getByLabelText("Cloudflare")); const installButton = await screen.findByRole("button", { name: "Install cloudflared" }); - await userEvent.click(installButton); + await user.click(installButton); await waitFor(() => { expect(mockInstallCloudflared).toHaveBeenCalledWith(undefined); @@ -3931,9 +3979,9 @@ describe("SettingsModal", () => { renderModal(); await waitForSettingsModalReady(); await openRemoteSection(); - await userEvent.click(screen.getByLabelText("Cloudflare")); + await user.click(screen.getByLabelText("Cloudflare")); - await userEvent.click(await screen.findByRole("button", { name: "Install cloudflared" })); + await user.click(await screen.findByRole("button", { name: "Install cloudflared" })); expect(await screen.findByText("Command failed")).toBeInTheDocument(); }); @@ -3949,10 +3997,10 @@ describe("SettingsModal", () => { await waitForSettingsModalReady(); await openRemoteSection(); - await userEvent.click(screen.getByLabelText("Tailscale")); + await user.click(screen.getByLabelText("Tailscale")); expect(screen.getByRole("button", { name: "Start Tunnel" })).toBeInTheDocument(); - await userEvent.click(screen.getByRole("button", { name: "Start Tunnel" })); + await user.click(screen.getByRole("button", { name: "Start Tunnel" })); await waitFor(() => { expect(mockUpdateRemoteSettings).toHaveBeenCalled(); expect(mockStartRemoteTunnel).toHaveBeenCalledTimes(1); @@ -3961,11 +4009,11 @@ describe("SettingsModal", () => { expect(await screen.findByRole("button", { name: "Stop Tunnel" })).toBeInTheDocument(); - await userEvent.click(screen.getByRole("button", { name: "Stop Tunnel" })); + await user.click(screen.getByRole("button", { name: "Stop Tunnel" })); await waitFor(() => { expect(mockStopRemoteTunnel).toHaveBeenCalledTimes(1); }); - await userEvent.click(screen.getByRole("button", { name: "Stop Tunnel" })); + await user.click(screen.getByRole("button", { name: "Stop Tunnel" })); await waitFor(() => { expect(mockStopRemoteTunnel).toHaveBeenCalledTimes(2); }); @@ -4002,9 +4050,9 @@ describe("SettingsModal", () => { renderModal(); await waitForSettingsModalReady(); await openRemoteSection(); - await userEvent.click(screen.getByLabelText("Tailscale")); + await user.click(screen.getByLabelText("Tailscale")); - await userEvent.click(await screen.findByRole("button", { name: "Start Fresh" })); + await user.click(await screen.findByRole("button", { name: "Start Fresh" })); await waitFor(() => { expect(mockKillExternalTunnel).toHaveBeenCalledWith(undefined); @@ -4017,10 +4065,10 @@ describe("SettingsModal", () => { renderModal({ addToast }); await waitForSettingsModalReady(); await openRemoteSection(); - await userEvent.click(screen.getByLabelText("Tailscale")); + await user.click(screen.getByLabelText("Tailscale")); await openAdvancedSettings(); - await userEvent.click(screen.getByRole("button", { name: "Regenerate persistent token" })); + await user.click(screen.getByRole("button", { name: "Regenerate persistent token" })); await waitFor(() => { expect(mockRegenerateRemotePersistentToken).toHaveBeenCalledWith(undefined); @@ -4045,20 +4093,20 @@ describe("SettingsModal", () => { renderModal(); await waitForSettingsModalReady(); await openRemoteSection(); - await userEvent.click(screen.getByLabelText("Tailscale")); + await user.click(screen.getByLabelText("Tailscale")); await openAdvancedSettings(); - await userEvent.selectOptions(screen.getByLabelText("Auth link token type"), "short-lived"); + await user.selectOptions(screen.getByLabelText("Auth link token type"), "short-lived"); const ttlInput = screen.getByLabelText("Short-lived TTL (ms)") as HTMLInputElement; fireEvent.change(ttlInput, { target: { value: "120000" } }); - await userEvent.click(screen.getByRole("button", { name: "Generate short-lived token" })); + await user.click(screen.getByRole("button", { name: "Generate short-lived token" })); await waitFor(() => { expect(mockGenerateShortLivedRemoteToken).toHaveBeenCalledWith(120000, undefined); }); fireEvent.change(ttlInput, { target: { value: "120000" } }); - await userEvent.click(screen.getByRole("button", { name: "Show URL" })); + await user.click(screen.getByRole("button", { name: "Show URL" })); await waitFor(() => { expect(mockFetchRemoteUrl).toHaveBeenLastCalledWith({ projectId: undefined, @@ -4088,134 +4136,100 @@ describe("SettingsModal", () => { renderModal(); await waitForSettingsModalReady(); await openRemoteSection(); - await userEvent.click(screen.getByLabelText("Tailscale")); + await user.click(screen.getByLabelText("Tailscale")); await openAdvancedSettings(); - await userEvent.click(screen.getByRole("button", { name: "Generate QR" })); + await user.click(screen.getByRole("button", { name: "Generate QR" })); await waitFor(() => { expect(mockFetchRemoteQr).toHaveBeenNthCalledWith(1, "image/svg", expect.objectContaining({ tokenType: "persistent" })); }); expect(await screen.findByRole("img", { name: "Remote access QR code" })).toBeInTheDocument(); - await userEvent.click(screen.getByRole("button", { name: "Generate QR" })); + await user.click(screen.getByRole("button", { name: "Generate QR" })); await waitFor(() => { expect(screen.queryByRole("img", { name: "Remote access QR code" })).not.toBeInTheDocument(); }); expect(screen.getByText("https://remote.example.com/qr-text")).toBeInTheDocument(); }); - it("Start Tunnel button is disabled when no provider is selected", async () => { - renderModal(); - await waitForSettingsModalReady(); - await openRemoteSection(); - - const startButton = screen.getByRole("button", { name: "Start Tunnel" }); - expect(startButton).toBeDisabled(); - await userEvent.click(screen.getByLabelText("Tailscale")); - expect(startButton).not.toBeDisabled(); - }); - - it("no separate Activate Provider or Save Remote Settings buttons exist", async () => { - renderModal(); - await waitForSettingsModalReady(); - await openRemoteSection(); - - expect(screen.queryByRole("button", { name: /Activate Provider/i })).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: /Save Remote Settings/i })).not.toBeInTheDocument(); - }); - - it("only the selected provider's settings are rendered", async () => { - renderModal(); - await waitForSettingsModalReady(); - await openRemoteSection(); - - await userEvent.click(screen.getByLabelText("Tailscale")); - expect(screen.getByLabelText("Accept routes")).toBeInTheDocument(); - expect(screen.queryByText(/Advanced \(Named Tunnel\)/i)).not.toBeInTheDocument(); - - await userEvent.click(screen.getByLabelText("Cloudflare")); - expect(screen.getByText(/Advanced \(Named Tunnel\)/i)).toBeInTheDocument(); - expect(screen.queryByLabelText("Accept routes")).not.toBeInTheDocument(); - }); - - it("sets quick tunnel false when opening Cloudflare advanced details", async () => { - renderModal(); - await waitForSettingsModalReady(); - await openRemoteSection(); - await userEvent.click(screen.getByLabelText("Cloudflare")); - - const namedTunnelSummary = screen.getByText(/Advanced \(Named Tunnel\)/i, { selector: "summary" }); - if (!screen.queryByLabelText("Tunnel name")) { - await userEvent.click(namedTunnelSummary); - } - await userEvent.click(screen.getByRole("button", { name: "Start Tunnel" })); - await waitFor(() => { - expect(mockUpdateRemoteSettings).toHaveBeenCalledWith(expect.objectContaining({ remoteCloudflareQuickTunnel: false }), undefined); - }); - - }); - - it("Start Tunnel auto-saves with enabled=true on selected provider before starting", async () => { - renderModal(); - await waitForSettingsModalReady(); - await openRemoteSection(); - - await userEvent.click(screen.getByLabelText("Tailscale")); - await userEvent.click(screen.getByRole("button", { name: "Start Tunnel" })); - - await waitFor(() => { - expect(mockUpdateRemoteSettings).toHaveBeenCalledWith( - expect.objectContaining({ - remoteActiveProvider: "tailscale", - remoteTailscaleEnabled: true, - }), - undefined, - ); - }); - expect(mockStartRemoteTunnel).toHaveBeenCalled(); - }); }); describe("Notifications provider cards", () => { - const openNotificationsSection = async () => { - await userEvent.click(await screen.findByRole("button", { name: /Notifications/ })); - }; + let user: ReturnType; - it("shows ntfy and webhook provider cards in notifications section", async () => { - renderModal(); - await waitForSettingsModalReady(); - await openNotificationsSection(); - - expect(screen.getByText("ntfy")).toBeInTheDocument(); - expect(screen.getByText("Webhook")).toBeInTheDocument(); + beforeEach(() => { + user = userEvent.setup({ delay: null }); }); - it("renders failure mode controls and persists updated values", async () => { - renderModal(); - await waitForSettingsModalReady(); - await openNotificationsSection(); + const openNotificationsSection = async () => { + await user.click(await screen.findByRole("button", { name: /Notifications/ })); + }; - const modeSelect = screen.getByLabelText("Failure notification mode") as HTMLSelectElement; - const delayInput = screen.getByLabelText("Failure notification delay (ms)") as HTMLInputElement; + describe("with default Notifications render", () => { + beforeEach(async () => { + renderModal({ initialSection: "notifications" }); + await screen.findByRole("heading", { name: "Notifications" }); + }); - expect(modeSelect.value).toBe("sticky-only"); - expect(delayInput.value).toBe("30000"); + it("shows ntfy and webhook provider cards in notifications section", () => { + expect(screen.getByText("ntfy")).toBeInTheDocument(); + expect(screen.getByText("Webhook")).toBeInTheDocument(); + }); - await userEvent.selectOptions(modeSelect, "all"); - expect(delayInput).toBeDisabled(); - await userEvent.selectOptions(modeSelect, "sticky-only"); - await userEvent.clear(delayInput); - await userEvent.type(delayInput, "45000"); - await userEvent.click(screen.getByRole("button", { name: "Save" })); + it("renders failure mode controls and persists updated values", async () => { + const modeSelect = screen.getByLabelText("Failure notification mode") as HTMLSelectElement; + const delayInput = screen.getByLabelText("Failure notification delay (ms)") as HTMLInputElement; - await waitFor(() => { - expect(mockUpdateGlobalSettings).toHaveBeenCalledWith( - expect.objectContaining({ - failureNotificationMode: "sticky-only", - failureNotificationDelayMs: 45000, - }), - ); + expect(modeSelect.value).toBe("sticky-only"); + expect(delayInput.value).toBe("30000"); + + await user.selectOptions(modeSelect, "all"); + expect(delayInput).toBeDisabled(); + await user.selectOptions(modeSelect, "sticky-only"); + fireEvent.change(delayInput, { target: { value: "45000" } }); + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(mockUpdateGlobalSettings).toHaveBeenCalledWith( + expect.objectContaining({ + failureNotificationMode: "sticky-only", + failureNotificationDelayMs: 45000, + }), + ); + }); + }); + + it("persists terminal-only selection on save", async () => { + const modeSelect = screen.getByLabelText("Failure notification mode") as HTMLSelectElement; + await user.selectOptions(modeSelect, "terminal-only"); + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(mockUpdateGlobalSettings).toHaveBeenCalledWith( + expect.objectContaining({ + failureNotificationMode: "terminal-only", + }), + ); + }); + }); + + it("calls testNotification with webhook provider ID when webhook test button clicked", async () => { + await user.click(screen.getByLabelText("Webhook notifications")); + fireEvent.change(screen.getByLabelText("Webhook URL"), { target: { value: "https://hooks.example.com/test" } }); + + const webhookCard = screen.getByText("Webhook").closest(".notification-provider-card") as HTMLElement; + await user.click(within(webhookCard).getByRole("button", { name: /Test notification/ })); + + await waitFor(() => { + expect(mockTestNotification).toHaveBeenCalledWith( + "webhook", + expect.objectContaining({ webhookUrl: "https://hooks.example.com/test" }), + undefined, + ); + }); + expect(within(webhookCard).getByText("Test notification sent — check your webhook endpoint!")).toBeInTheDocument(); + expect(within(webhookCard).getByText("Test notification sent — check your webhook endpoint!").closest(".notification-test-feedback")).toHaveAttribute("aria-live", "polite"); }); }); @@ -4235,24 +4249,6 @@ describe("SettingsModal", () => { expect(delayInput).not.toBeDisabled(); }); - it("persists terminal-only selection on save", async () => { - renderModal(); - await waitForSettingsModalReady(); - await openNotificationsSection(); - - const modeSelect = screen.getByLabelText("Failure notification mode") as HTMLSelectElement; - await userEvent.selectOptions(modeSelect, "terminal-only"); - await userEvent.click(screen.getByRole("button", { name: "Save" })); - - await waitFor(() => { - expect(mockUpdateGlobalSettings).toHaveBeenCalledWith( - expect.objectContaining({ - failureNotificationMode: "terminal-only", - }), - ); - }); - }); - it.each([ { provider: "ntfy", @@ -4265,7 +4261,7 @@ describe("SettingsModal", () => { provider: "webhook", initial: defaultSettings, enable: async () => { - await userEvent.click(screen.getByLabelText("Webhook notifications")); + await user.click(screen.getByLabelText("Webhook notifications")); }, visibleLabel: "Webhook URL", hiddenLabel: "ntfy Topic", @@ -4319,7 +4315,7 @@ describe("SettingsModal", () => { expect(agentToAgentNtfy.checked).toBe(true); expect(roomMessageNtfy.checked).toBe(true); - await userEvent.click(screen.getByLabelText("Webhook notifications")); + await user.click(screen.getByLabelText("Webhook notifications")); expect(screen.getAllByLabelText("Fallback model used (recovered)").length).toBeGreaterThan(0); expect(screen.getAllByLabelText("Agent created a task").length).toBeGreaterThan(0); expect(screen.getAllByLabelText("DREAMS.md entry added").length).toBeGreaterThan(0); @@ -4338,10 +4334,10 @@ describe("SettingsModal", () => { renderModal(); await waitForSettingsModalReady(); await openNotificationsSection(); - await userEvent.click(screen.getByText("Advanced")); - await userEvent.type(screen.getByLabelText("Access token (optional)"), "secret-token"); + await user.click(screen.getByText("Advanced")); + fireEvent.change(screen.getByLabelText("Access token (optional)"), { target: { value: "secret-token" } }); - await userEvent.click(screen.getByRole("button", { name: /Test notification/ })); + await user.click(screen.getByRole("button", { name: /Test notification/ })); await waitFor(() => { expect(mockTestNotification).toHaveBeenCalledWith( @@ -4366,10 +4362,10 @@ describe("SettingsModal", () => { renderModal(); await waitForSettingsModalReady(); await openNotificationsSection(); - await userEvent.click(screen.getByText("Advanced")); + await user.click(screen.getByText("Advanced")); const tokenInput = screen.getByLabelText("Access token (optional)"); - await userEvent.clear(tokenInput); - await userEvent.click(screen.getByRole("button", { name: "Save" })); + await user.clear(tokenInput); + await user.click(screen.getByRole("button", { name: "Save" })); await waitFor(() => { expect(mockUpdateGlobalSettings).toHaveBeenCalledWith( @@ -4385,7 +4381,7 @@ describe("SettingsModal", () => { await waitForSettingsModalReady(); await openNotificationsSection(); - await userEvent.click(screen.getByRole("button", { name: /Test message inbox/ })); + await user.click(screen.getByRole("button", { name: /Test message inbox/ })); await waitFor(() => { expect(mockTestNotification).toHaveBeenCalledWith( @@ -4409,7 +4405,7 @@ describe("SettingsModal", () => { await waitForSettingsModalReady(); await openNotificationsSection(); - await userEvent.click(screen.getByRole("button", { name: /Test room reply/ })); + await user.click(screen.getByRole("button", { name: /Test room reply/ })); await waitFor(() => { expect(mockTestNotification).toHaveBeenCalledWith( @@ -4433,7 +4429,7 @@ describe("SettingsModal", () => { await waitForSettingsModalReady(); await openNotificationsSection(); - await userEvent.click(screen.getByRole("button", { name: /Test room reply/ })); + await user.click(screen.getByRole("button", { name: /Test room reply/ })); await waitFor(() => { expect(addToast).toHaveBeenCalledWith("Failed to send room reply test", "error"); @@ -4441,27 +4437,6 @@ describe("SettingsModal", () => { expect(screen.getByText("Room reply: Failed to send room reply test")).toBeInTheDocument(); }); - it("calls testNotification with webhook provider ID when webhook test button clicked", async () => { - renderModal(); - await waitForSettingsModalReady(); - await openNotificationsSection(); - await userEvent.click(screen.getByLabelText("Webhook notifications")); - await userEvent.type(screen.getByLabelText("Webhook URL"), "https://hooks.example.com/test"); - - const webhookCard = screen.getByText("Webhook").closest(".notification-provider-card") as HTMLElement; - await userEvent.click(within(webhookCard).getByRole("button", { name: /Test notification/ })); - - await waitFor(() => { - expect(mockTestNotification).toHaveBeenCalledWith( - "webhook", - expect.objectContaining({ webhookUrl: "https://hooks.example.com/test" }), - undefined, - ); - }); - expect(within(webhookCard).getByText("Test notification sent — check your webhook endpoint!")).toBeInTheDocument(); - expect(within(webhookCard).getByText("Test notification sent — check your webhook endpoint!").closest(".notification-test-feedback")).toHaveAttribute("aria-live", "polite"); - }); - it("preserves existing ntfy settings in backward compat", async () => { mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings,