diff --git a/ui/src/test/e2e/alerting.spec.ts b/ui/src/test/e2e/alerting.spec.ts new file mode 100644 index 00000000..832ea568 --- /dev/null +++ b/ui/src/test/e2e/alerting.spec.ts @@ -0,0 +1,107 @@ +import { test, expect } from './fixtures'; + +/** + * Plan 03 alerting smoke suite. + * + * Covers the CRUD + navigation paths that don't require event injection: + * - sidebar → inbox + * - create + delete a rule via the 5-step wizard + * - CMD-K opens, closes cleanly + * - silence create + end-early + * + * End-to-end fire→ack→clear is covered server-side by `AlertingFullLifecycleIT` + * (Plan 02). Exercising it from the UI would require injecting executions + * into ClickHouse, which is out of scope for this smoke. + * + * Note: the design-system `SectionHeader` renders a generic element (not role=heading), + * so page headings are asserted via `getByText`. + */ + +test.describe('alerting UI smoke', () => { + test('sidebar Alerts section navigates to inbox', async ({ page }) => { + // Click the Alerts sidebar section header. On navigation the accordion + // will already be expanded; the "Alerts" label is on the toggle button. + await page.getByRole('button', { name: /^(collapse|expand) alerts$/i }).first().click(); + await expect(page).toHaveURL(/\/alerts\/inbox/, { timeout: 10_000 }); + // Inbox page renders "Inbox" text + "Mark all read" button. + await expect(page.getByText(/^Inbox$/)).toBeVisible(); + await expect(page.getByRole('button', { name: /mark all read/i })).toBeVisible(); + }); + + test('create + delete a rule via the wizard', async ({ page }) => { + // Unique name per run so leftover rules from crashed prior runs don't + // trip the strict-mode "multiple matches" check. + const ruleName = `e2e smoke rule ${Date.now()}`; + + await page.goto('/alerts/rules'); + await expect(page.getByText(/^Alert rules$/)).toBeVisible(); + + await page.getByRole('link', { name: /new rule/i }).click(); + await expect(page).toHaveURL(/\/alerts\/rules\/new/); + + // Step 1 — Scope. DS FormField renders the label as a generic element + // (not `htmlFor` wired), so the textbox's accessible name is its placeholder. + await page.getByPlaceholder('Order API error rate').fill(ruleName); + await page.getByRole('button', { name: /^next$/i }).click(); + + // Step 2 — Condition (leave at ROUTE_METRIC default) + await page.getByRole('button', { name: /^next$/i }).click(); + + // Step 3 — Trigger (defaults) + await page.getByRole('button', { name: /^next$/i }).click(); + + // Step 4 — Notify: default title/message templates are pre-populated; + // targets/webhooks empty is OK for smoke. + await page.getByRole('button', { name: /^next$/i }).click(); + + // Step 5 — Review + save + await page.getByRole('button', { name: /^create rule$/i }).click(); + + // Land on rules list, rule appears in the table. + await expect(page).toHaveURL(/\/alerts\/rules$/, { timeout: 10_000 }); + const main = page.locator('main'); + await expect(main.getByRole('link', { name: ruleName })).toBeVisible({ timeout: 10_000 }); + + // Cleanup: delete. + page.once('dialog', (d) => d.accept()); + await page + .getByRole('row', { name: new RegExp(ruleName) }) + .getByRole('button', { name: /^delete$/i }) + .click(); + await expect(main.getByRole('link', { name: ruleName })).toHaveCount(0); + }); + + test('CMD-K palette opens + closes', async ({ page }) => { + await page.goto('/alerts/inbox'); + // The DS CommandPalette is toggled by the SearchTrigger button in the top bar + // (accessible name "Open search"). Ctrl/Cmd+K is wired inside the DS but + // clicking the button is the deterministic path. + await page.getByRole('button', { name: /open search/i }).click(); + const dialog = page.getByRole('dialog').first(); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + await page.keyboard.press('Escape'); + await expect(dialog).toBeHidden(); + }); + + test('silence create + end-early', async ({ page }) => { + await page.goto('/alerts/silences'); + await expect(page.getByText(/^Alert silences$/)).toBeVisible(); + + const unique = `smoke-app-${Date.now()}`; + // DS FormField labels aren't `htmlFor`-wired, so target via parent-of-label → textbox. + const form = page.locator('main'); + await form.getByText(/^App slug/).locator('..').getByRole('textbox').fill(unique); + await form.getByRole('spinbutton').fill('1'); + await form.getByPlaceholder('Maintenance window').fill('e2e smoke'); + await page.getByRole('button', { name: /create silence/i }).click(); + + await expect(page.getByText(unique).first()).toBeVisible({ timeout: 10_000 }); + + page.once('dialog', (d) => d.accept()); + await page + .getByRole('row', { name: new RegExp(unique) }) + .getByRole('button', { name: /^end$/i }) + .click(); + await expect(page.getByText(unique)).toHaveCount(0); + }); +}); diff --git a/ui/src/test/e2e/fixtures.ts b/ui/src/test/e2e/fixtures.ts new file mode 100644 index 00000000..2a02ead3 --- /dev/null +++ b/ui/src/test/e2e/fixtures.ts @@ -0,0 +1,39 @@ +import { test as base, expect } from '@playwright/test'; + +/** + * E2E fixtures for the alerting UI smoke suite. + * + * Auth happens once per test via an auto-applied fixture. Override creds via: + * E2E_ADMIN_USER=... E2E_ADMIN_PASS=... npm run test:e2e + * + * The fixture logs in to the local form (not OIDC). The backend in the + * Docker-compose stack defaults to `admin` / `admin` for the local login. + */ +export const ADMIN_USER = process.env.E2E_ADMIN_USER ?? 'admin'; +export const ADMIN_PASS = process.env.E2E_ADMIN_PASS ?? 'admin'; + +type Fixtures = { + loggedIn: void; +}; + +export const test = base.extend({ + loggedIn: [ + async ({ page }, use) => { + // `?local` keeps the login page's auto-OIDC-redirect from firing so the + // form-based login works even when an OIDC config happens to be present. + await page.goto('/login?local'); + await page.getByLabel(/username/i).fill(ADMIN_USER); + await page.getByLabel(/password/i).fill(ADMIN_PASS); + await page.getByRole('button', { name: /sign in/i }).click(); + // Default landing after login is /exchanges (via Navigate redirect). + await expect(page).toHaveURL(/\/(exchanges|alerts|dashboard)/, { timeout: 15_000 }); + // Env selection is required for every alerts query (useSelectedEnv gate). + // Pick the default env so hooks enable. + await page.getByRole('combobox').selectOption({ label: 'default' }); + await use(); + }, + { auto: true }, + ], +}); + +export { expect };