feat(alerting): Plan 03 — UI + backfills (SSRF guard, metrics caching, docker stack) #144
107
ui/src/test/e2e/alerting.spec.ts
Normal file
107
ui/src/test/e2e/alerting.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
39
ui/src/test/e2e/fixtures.ts
Normal file
39
ui/src/test/e2e/fixtures.ts
Normal file
@@ -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<Fixtures>({
|
||||
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 };
|
||||
Reference in New Issue
Block a user