test(ui/alerts): Playwright E2E smoke (sidebar, rule CRUD, CMD-K, silence CRUD)
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m10s
CI / cleanup-branch (pull_request) Has been skipped
CI / build (pull_request) Successful in 2m34s
CI / docker (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / deploy-feature (pull_request) Has been skipped
CI / docker (push) Successful in 5m11s
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Successful in 40s

fixtures.ts: auto-applied login fixture — visits /login?local to skip OIDC
auto-redirect, fills username/password via label-matcher, clicks 'Sign in',
then selects the 'default' env so alerting hooks enable (useSelectedEnv gate).
Override via E2E_ADMIN_USER + E2E_ADMIN_PASS.

alerting.spec.ts: 4 tests against the full docker-compose stack:
 - sidebar Alerts accordion → /alerts/inbox
 - 5-step wizard: defaults-only create + row delete (unique timestamp name
   avoids strict-mode collisions with leftover rules)
 - CMD-K palette via SearchTrigger click (deterministic; Ctrl+K via keyboard
   is flaky when the canvas doesn't have focus)
 - silence matcher-based create + end-early

DS FormField renders labels as generics (not htmlFor-wired), so inputs are
targeted by placeholder or label-proximity locators instead of getByLabel.

Does not exercise fire→ack→clear; that's covered backend-side by
AlertingFullLifecycleIT (Plan 02). UI E2E for that path would need event
injection into ClickHouse, out of scope for this smoke.
This commit is contained in:
hsiegeln
2026-04-20 16:18:17 +02:00
parent d88bede097
commit 1ebc2fa71e
2 changed files with 146 additions and 0 deletions

View 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);
});
});

View 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 };