|
| 1 | +// @vitest-environment jsdom |
| 2 | +import type * as RemixReactModule from "@remix-run/react"; |
| 3 | +import { createElement, useState, type ComponentProps, type ReactNode } from "react"; |
| 4 | +import { act } from "react-dom/test-utils"; |
| 5 | +import { createRoot, type Root } from "react-dom/client"; |
| 6 | +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 7 | +import { OperatingSystemContextProvider } from "~/components/primitives/OperatingSystemProvider"; |
| 8 | +import { ShortcutsProvider } from "~/components/primitives/ShortcutsProvider"; |
| 9 | +import type { DraftPrefillParams } from "~/components/dashboard-agent/alert-creation/flow-state"; |
| 10 | +import { ManualAlertCreationDock } from "./ManualAlertCreation"; |
| 11 | + |
| 12 | +const CURRENT_ENV_CHANNELS = [{ id: "ch1", name: "Ops webhook", type: "WEBHOOK" }]; |
| 13 | +const PROJECT_CHANNELS = [ |
| 14 | + ...CURRENT_ENV_CHANNELS, |
| 15 | + { id: "ch_prod", name: "Prod pager", type: "WEBHOOK" }, |
| 16 | +]; |
| 17 | + |
| 18 | +vi.mock("@remix-run/react", async (importOriginal) => ({ |
| 19 | + ...(await importOriginal<typeof RemixReactModule>()), |
| 20 | + useInRouterContext: () => false, |
| 21 | + Link: ({ to, children }: { to: string; children: ReactNode }) => |
| 22 | + createElement("a", { href: to }, children), |
| 23 | + useNavigate: () => vi.fn(), |
| 24 | + useLocation: () => ({ pathname: "/", search: "" }), |
| 25 | + useFetcher: () => { |
| 26 | + const [url, setUrl] = useState<string>(); |
| 27 | + return { |
| 28 | + data: url?.startsWith("/resources/alerts/channels") |
| 29 | + ? { |
| 30 | + channels: url.includes("environmentId=") ? CURRENT_ENV_CHANNELS : PROJECT_CHANNELS, |
| 31 | + canWrite: true, |
| 32 | + } |
| 33 | + : undefined, |
| 34 | + load: setUrl, |
| 35 | + }; |
| 36 | + }, |
| 37 | +})); |
| 38 | +vi.mock("~/hooks/useOrganizations", () => ({ |
| 39 | + useOptionalOrganization: () => ({ slug: "org", id: "org1" }), |
| 40 | + useOrganization: () => ({ slug: "org", id: "org1" }), |
| 41 | +})); |
| 42 | +vi.mock("~/hooks/useProject", () => ({ |
| 43 | + useOptionalProject: () => ({ slug: "proj", id: "proj1" }), |
| 44 | + useProject: () => ({ slug: "proj", id: "proj1" }), |
| 45 | +})); |
| 46 | +vi.mock("~/hooks/useEnvironment", () => ({ |
| 47 | + useOptionalEnvironment: () => ({ slug: "prod", id: "env1", type: "PRODUCTION" }), |
| 48 | + useEnvironment: () => ({ slug: "prod", id: "env1", type: "PRODUCTION" }), |
| 49 | +})); |
| 50 | +vi.mock("~/hooks/useTypedMatchData", () => ({ useTypedMatchesData: () => undefined })); |
| 51 | +vi.mock("~/hooks/useFeatures", () => ({ useFeatures: () => ({ queueMetricsEnabled: true }) })); |
| 52 | + |
| 53 | +const fetchMock = vi.fn(async (url: string | URL | Request) => { |
| 54 | + if (String(url) === "/resources/alerts/create") { |
| 55 | + return new Response(JSON.stringify({ ok: true, alertId: "a1" })); |
| 56 | + } |
| 57 | + if (String(url).includes("/alerts/legacy?_data=")) { |
| 58 | + return new Response( |
| 59 | + JSON.stringify({ count: 1, message: "Created 1 alert", legacyRemaining: false }) |
| 60 | + ); |
| 61 | + } |
| 62 | + return new Response("{}"); |
| 63 | +}); |
| 64 | + |
| 65 | +Element.prototype.scrollIntoView ??= () => {}; |
| 66 | +globalThis.ResizeObserver ??= class { |
| 67 | + observe() {} |
| 68 | + unobserve() {} |
| 69 | + disconnect() {} |
| 70 | +} as unknown as typeof ResizeObserver; |
| 71 | + |
| 72 | +const fieldset = (step: string) => |
| 73 | + document.querySelector<HTMLFieldSetElement>(`fieldset[data-step="${step}"]`); |
| 74 | +const combobox = (step: string) => |
| 75 | + fieldset(step)?.querySelector<HTMLButtonElement>("button[role=combobox]") ?? null; |
| 76 | +const popover = (select: HTMLElement) => |
| 77 | + document.getElementById(select.getAttribute("aria-controls") ?? ""); |
| 78 | +const options = (select: HTMLElement) => |
| 79 | + Array.from(popover(select)?.querySelectorAll<HTMLElement>("[role=option]") ?? []); |
| 80 | +const click = (el: HTMLElement) => act(() => el.click()); |
| 81 | +const open = (select: HTMLElement) => { |
| 82 | + act(() => select.focus()); |
| 83 | + act(() => select.click()); |
| 84 | +}; |
| 85 | +function pick(step: string, label: string) { |
| 86 | + const select = combobox(step)!; |
| 87 | + open(select); |
| 88 | + const found = options(select).find((o) => o.textContent === label); |
| 89 | + if (!found) throw new Error(`no ${label}: ${options(select).map((o) => o.textContent)}`); |
| 90 | + click(found); |
| 91 | +} |
| 92 | +const checkbox = (step: string, label: string) => |
| 93 | + Array.from(fieldset(step)!.querySelectorAll<HTMLLabelElement>("[data-checkbox-list] label")) |
| 94 | + .find((l) => l.textContent === label)! |
| 95 | + .querySelector<HTMLInputElement>("input")!; |
| 96 | +const button = (label: string) => |
| 97 | + Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find( |
| 98 | + (b) => b.textContent === label |
| 99 | + ); |
| 100 | + |
| 101 | +let container: HTMLElement; |
| 102 | +let root: Root; |
| 103 | +const onCreated = vi.fn(); |
| 104 | +const onClose = vi.fn(); |
| 105 | +const onReplaced = vi.fn(); |
| 106 | + |
| 107 | +function render(prefill: DraftPrefillParams) { |
| 108 | + act(() => |
| 109 | + root.render( |
| 110 | + createElement( |
| 111 | + OperatingSystemContextProvider, |
| 112 | + { platform: "mac" } as ComponentProps<typeof OperatingSystemContextProvider>, |
| 113 | + createElement( |
| 114 | + ShortcutsProvider, |
| 115 | + null, |
| 116 | + createElement( |
| 117 | + ManualAlertCreationDock, |
| 118 | + { |
| 119 | + open: true, |
| 120 | + prefill, |
| 121 | + alertDetailPath: (id: string) => `/alerts/${id}`, |
| 122 | + onCreated, |
| 123 | + onReplaced, |
| 124 | + onClose, |
| 125 | + }, |
| 126 | + createElement("div", { "data-testid": "page" }, "page") |
| 127 | + ) |
| 128 | + ) |
| 129 | + ) |
| 130 | + ) |
| 131 | + ); |
| 132 | +} |
| 133 | + |
| 134 | +describe("ManualAlertCreation", () => { |
| 135 | + beforeEach(() => { |
| 136 | + fetchMock.mockClear(); |
| 137 | + vi.stubGlobal("fetch", fetchMock); |
| 138 | + container = document.createElement("div"); |
| 139 | + document.body.appendChild(container); |
| 140 | + root = createRoot(container); |
| 141 | + }); |
| 142 | + afterEach(() => { |
| 143 | + act(() => root.unmount()); |
| 144 | + container.remove(); |
| 145 | + vi.unstubAllGlobals(); |
| 146 | + }); |
| 147 | + |
| 148 | + it("walks the real form, posts the alert and reports the created id", async () => { |
| 149 | + vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-01-01T00:00:00.000Z") }); |
| 150 | + try { |
| 151 | + render({ area: "runs", capability: "runs.failed", scope: "task", scopeValue: "x" }); |
| 152 | + pick("condition", "50"); |
| 153 | + pick("expiry", "For 24 hours"); |
| 154 | + click(checkbox("notifiers", "Ops webhook")); |
| 155 | + click(button("Done")!); |
| 156 | + await act(async () => button("Create alert")!.click()); |
| 157 | + const calls = fetchMock.mock.calls.filter(([url]) => url === "/resources/alerts/create"); |
| 158 | + expect(calls).toHaveLength(1); |
| 159 | + const init = (calls[0] as unknown as [string, RequestInit])[1]; |
| 160 | + expect(init.method).toBe("POST"); |
| 161 | + expect(JSON.parse(init.body as string)).toEqual({ |
| 162 | + organizationId: "org1", |
| 163 | + projectId: "proj1", |
| 164 | + environmentId: "env1", |
| 165 | + name: "Failed runs above 50 on x", |
| 166 | + expiry: "24h", |
| 167 | + form: { |
| 168 | + capabilityKey: "runs.failed", |
| 169 | + scopeType: "task", |
| 170 | + scopeValue: "x", |
| 171 | + conditionKind: "threshold", |
| 172 | + operator: ">", |
| 173 | + value: 50, |
| 174 | + windowMinutes: 10, |
| 175 | + expiresAt: "2026-01-02T00:00:00.000Z", |
| 176 | + notifierChannelIds: ["ch1"], |
| 177 | + }, |
| 178 | + }); |
| 179 | + expect(onCreated).toHaveBeenCalledWith("a1"); |
| 180 | + } finally { |
| 181 | + vi.useRealTimers(); |
| 182 | + } |
| 183 | + }); |
| 184 | + |
| 185 | + it("starts a fresh draft when the prefill changes", () => { |
| 186 | + render({ area: "runs", capability: "runs.failed", scope: "task", scopeValue: "x" }); |
| 187 | + pick("condition", "50"); |
| 188 | + expect(combobox("condition")!.textContent).toBe("50"); |
| 189 | + render({ area: "runs", capability: "runs.failed", scope: "task", scopeValue: "y" }); |
| 190 | + expect(combobox("condition")!.textContent).toBe("Select…"); |
| 191 | + pick("condition", "50"); |
| 192 | + expect(fieldset("scope")!.textContent).toContain("y"); |
| 193 | + expect(fieldset("scope")!.textContent).not.toContain("x"); |
| 194 | + }); |
| 195 | + |
| 196 | + it("docks beside the page with the panel header and no chat controls", () => { |
| 197 | + render({ area: "runs", capability: "runs.failed", scope: "task", scopeValue: "x" }); |
| 198 | + expect(document.querySelector("[data-testid=page]")).not.toBeNull(); |
| 199 | + const panel = document.querySelector("[data-testid=manual-alert-panel]")!; |
| 200 | + const title = Array.from(panel.querySelectorAll("span")).find( |
| 201 | + (span) => span.textContent === "New alert" |
| 202 | + )!; |
| 203 | + expect(title.className).toContain("truncate text-sm font-medium text-text-bright"); |
| 204 | + expect(panel.querySelector("[aria-label^='Change layout']")).toBeNull(); |
| 205 | + expect(panel.querySelector("[aria-haspopup]:not([role=combobox])")).toBeNull(); |
| 206 | + expect(panel.querySelector("[data-testid=manual-mode-toggle]")).toBeNull(); |
| 207 | + expect(panel.querySelector("textarea")).toBeNull(); |
| 208 | + click(panel.querySelector<HTMLButtonElement>("[aria-label='Close (Esc)']")!); |
| 209 | + expect(onClose).toHaveBeenCalledTimes(1); |
| 210 | + }); |
| 211 | + |
| 212 | + it("replaces a legacy subscription through the legacy route", async () => { |
| 213 | + render({ |
| 214 | + capability: "runs.failed", |
| 215 | + scope: "all", |
| 216 | + replace: { |
| 217 | + channelId: "ch_prod", |
| 218 | + channelName: "Prod pager", |
| 219 | + alertType: "TASK_RUN", |
| 220 | + environments: [ |
| 221 | + { id: "env1", type: "PRODUCTION", slug: "prod", branchName: null }, |
| 222 | + { id: "env2", type: "DEVELOPMENT", slug: "dev", branchName: null }, |
| 223 | + ], |
| 224 | + }, |
| 225 | + }); |
| 226 | + pick("expiry", "No expiry"); |
| 227 | + expect(checkbox("notifiers", "Prod pager").checked).toBe(true); |
| 228 | + click(button("Done")!); |
| 229 | + await act(async () => button("Create alert")!.click()); |
| 230 | + const calls = fetchMock.mock.calls.filter(([url]) => String(url).includes("/alerts/legacy")); |
| 231 | + expect(calls).toHaveLength(1); |
| 232 | + const [url, init] = calls[0] as unknown as [string, RequestInit]; |
| 233 | + expect(url).toBe( |
| 234 | + `/orgs/org/projects/proj/env/prod/alerts/legacy?_data=${encodeURIComponent( |
| 235 | + "routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts_.legacy" |
| 236 | + )}` |
| 237 | + ); |
| 238 | + expect(init.method).toBe("POST"); |
| 239 | + expect(Object.fromEntries(init.body as FormData)).toEqual({ |
| 240 | + intent: "replace", |
| 241 | + channelId: "ch_prod", |
| 242 | + alertType: "TASK_RUN", |
| 243 | + environmentIds: "env1,env2", |
| 244 | + value: "0", |
| 245 | + windowMinutes: "10", |
| 246 | + scopeType: "all", |
| 247 | + notifierChannelIds: "ch_prod", |
| 248 | + }); |
| 249 | + expect(fetchMock.mock.calls.some(([u]) => u === "/resources/alerts/create")).toBe(false); |
| 250 | + expect(onReplaced).toHaveBeenCalledWith({ |
| 251 | + count: 1, |
| 252 | + message: "Created 1 alert", |
| 253 | + legacyRemaining: false, |
| 254 | + }); |
| 255 | + }); |
| 256 | +}); |
0 commit comments