Compare commits

..

21 Commits

Author SHA1 Message Date
Vinícius Lourenço
0860aaeac6 fix(package): add rebuild and capture as build flag on tear up 2026-08-03 16:25:21 -03:00
Vinícius Lourenço
8cf71af11d Revert "perf(with-web-dockerfile): skip build with race"
This reverts commit 55e7f602e1.
2026-08-03 16:25:21 -03:00
Vinícius Lourenço
4f9de44d79 fix(alert-forms): broke the test after updating the UI to accept more options 2026-08-03 16:25:21 -03:00
Vinícius Lourenço
f7aeb45911 chore(fmt): fix format file 2026-08-03 16:25:21 -03:00
Vinícius Lourenço
33d7a3eac5 chore(alerts): add more tests 2026-08-03 16:25:21 -03:00
Vinícius Lourenço
f037bf96f3 chore(alerts): mark test as skip since they are valid bugs 2026-08-03 16:25:21 -03:00
Vinícius Lourenço
0caa9ae330 refactor(alerts): cleanup comments / reduce flakyness 2026-08-03 16:25:21 -03:00
Vinícius Lourenço
6aeb983f6f feat(alerts-create-edit): add initial unfiltered tests 2026-08-03 16:25:21 -03:00
Vinícius Lourenço
71aac6a4be feat(alerts-v1): add test ids 2026-08-03 16:25:21 -03:00
Vinícius Lourenço
aea817980e fix(alerts): continue more fixes to prevent flaky 2026-08-03 16:25:21 -03:00
Vinícius Lourenço
b259cc97dd fix(alerts): prevent more flaky tests 2026-08-03 16:25:21 -03:00
Vinícius Lourenço
40801cee45 fix(fmt): lint issue 2026-08-03 16:25:21 -03:00
Vinícius Lourenço
6983347d0e perf(with-web-dockerfile): skip build with race 2026-08-03 16:25:21 -03:00
Vinícius Lourenço
549434dc80 fix(timeline-pagination): improve flaky test 2026-08-03 16:25:21 -03:00
Vinícius Lourenço
0682b61730 chore(package): add few more scripts 2026-08-03 16:25:21 -03:00
Vinícius Lourenço
3370b1a6ed docs(e2e): fix path for alerts 2026-08-03 16:25:21 -03:00
Vinícius Lourenço
39f4d1c52a refactor(alerts): make it more resilient 2026-08-03 16:25:21 -03:00
Vinícius Lourenço
09fee3f55f refactor(auth): cleanups on auth due to mutating test locallly 2026-08-03 16:25:21 -03:00
Vinícius Lourenço
2ac367310a refactor(alerts): clean and re-organize the tests 2026-08-03 16:25:21 -03:00
Vinícius Lourenço
56e1a9a9dc feat(alerts): add initial e2e 2026-08-03 16:25:21 -03:00
Vinícius Lourenço
b0c478b155 chore(alert): add test ids 2026-08-03 16:25:21 -03:00
108 changed files with 8071 additions and 2784 deletions

View File

@@ -39,8 +39,6 @@ jobs:
matrix:
suite:
- alerts
- alertmanager
- alertmanagerrotation
- basepath
- callbackauthn
- cloudintegrations

View File

@@ -232,11 +232,14 @@ cd tests/e2e
# Single feature dir
npx playwright test tests/alerts/ --project=chromium
# Single sub-area
npx playwright test tests/alerts/history/ --project=chromium
# Single file
npx playwright test tests/alerts/alerts.spec.ts --project=chromium
npx playwright test tests/alerts/page.spec.ts --project=chromium
# Single test by title grep
npx playwright test --project=chromium -g "TC-01"
npx playwright test --project=chromium -g "AL-01"
```
### Iterative modes
@@ -270,7 +273,14 @@ yarn test:staging
| `SIGNOZ_E2E_PASSWORD` | Admin password. Bootstrap writes the integration-test default. |
| `SIGNOZ_E2E_SEEDER_URL` | Seeder HTTP base URL — hit by specs that need per-test telemetry. |
Loading order in `playwright.config.ts`: `.env` first (user-provided, staging), then `.env.local` with `override: true` (bootstrap-generated, local mode). Anything already set in `process.env` at yarn-test time wins because dotenv doesn't touch vars that are already present.
Precedence in `playwright.config.ts`, lowest to highest: `.env` (user-provided, staging) `.env.local` (bootstrap-generated, local mode) → whatever is already in `process.env`. The config parses both files itself and only fills in keys the environment does not already define, so exporting a variable always wins:
```bash
# runs against a locally served frontend, not whatever .env.local points at
SIGNOZ_E2E_BASE_URL=http://127.0.0.1:3301 pnpm test tests/alerts
```
This is deliberately not `dotenv.config({ override: true })`. That flag makes the *file* beat `process.env`, which silently discarded exported values — including the `SIGNOZ_E2E_BASE_URL` in `pnpm test:staging`, whenever a `.env.local` happened to exist.
### Playwright options

View File

@@ -29,6 +29,7 @@ function PopoverContent({
<Link
to={`${ROUTES.LOGS_EXPLORER}?${relatedLogsLink}`}
className="contributor-row-popover-buttons__button"
data-testid="alert-popover-view-logs"
>
<div className="icon">
<LogsIcon />
@@ -40,6 +41,7 @@ function PopoverContent({
<Link
to={`${ROUTES.TRACES_EXPLORER}?${relatedTracesLink}`}
className="contributor-row-popover-buttons__button"
data-testid="alert-popover-view-traces"
>
<div className="icon">
<DraftingCompass

View File

@@ -26,7 +26,10 @@ function ChangePercentage({
}: ChangePercentageProps): JSX.Element {
if (direction > 0) {
return (
<div className="change-percentage change-percentage--success">
<div
className="change-percentage change-percentage--success"
data-testid="stats-card-change"
>
<div className="change-percentage__icon">
<ArrowDownLeft size={14} color={Color.BG_FOREST_500} />
</div>
@@ -38,7 +41,10 @@ function ChangePercentage({
}
if (direction < 0) {
return (
<div className="change-percentage change-percentage--error">
<div
className="change-percentage change-percentage--error"
data-testid="stats-card-change"
>
<div className="change-percentage__icon">
<ArrowUpRight size={14} color={Color.BG_CHERRY_500} />
</div>
@@ -50,7 +56,10 @@ function ChangePercentage({
}
return (
<div className="change-percentage change-percentage--no-previous-data">
<div
className="change-percentage change-percentage--no-previous-data"
data-testid="stats-card-change"
>
<div className="change-percentage__label">no previous data</div>
</div>
);
@@ -103,7 +112,12 @@ function StatsCard({
const formattedEndTimeForTooltip = convertTimestampToLocaleDateString(endTime);
return (
<div className={`stats-card ${isEmpty ? 'stats-card--empty' : ''}`}>
<div
className={`stats-card ${isEmpty ? 'stats-card--empty' : ''}`}
data-testid="stats-card"
data-stats-title={title}
data-empty={isEmpty ? 'true' : 'false'}
>
<div className="stats-card__title-wrapper">
<div className="title">{title}</div>
<div className="duration-indicator">
@@ -123,7 +137,7 @@ function StatsCard({
</div>
<div className="stats-card__stats">
<div className="count-label">
<div className="count-label" data-testid="stats-card-value">
{isEmpty ? emptyMessage : displayValue || totalCurrentCount}
</div>

View File

@@ -81,7 +81,11 @@ function StatsGraph({ timeSeries, changeDirection }: Props): JSX.Element {
);
return (
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
<div
style={{ height: '100%', width: '100%' }}
ref={graphRef}
data-testid="stats-card-sparkline"
>
<Uplot data={[xData, yData]} options={options} />
</div>
);

View File

@@ -48,11 +48,16 @@ function TopContributorsCard({
return (
<>
<div className="top-contributors-card">
<div className="top-contributors-card" data-testid="top-contributors-card">
<div className="top-contributors-card__header">
<div className="title">top contributors</div>
{topContributorsData.length > 3 && (
<Button type="text" className="view-all" onClick={toggleViewAllDrawer}>
<Button
type="text"
className="view-all"
onClick={toggleViewAllDrawer}
data-testid="top-contributors-view-all"
>
<div className="label">View all</div>
<div className="icon">
<ArrowRight

View File

@@ -68,7 +68,10 @@ function TopContributorsRows({
relatedTracesLink={record.relatedTracesLink}
relatedLogsLink={record.relatedLogsLink}
>
<div className="total-contribution">
<div
className="total-contribution"
data-testid="top-contributors-row-count"
>
{count}/{totalCurrentTriggers}
</div>
</ConditionalAlertPopover>
@@ -78,7 +81,10 @@ function TopContributorsRows({
const handleRowClick = (
record: AlertRuleTopContributors,
): HTMLAttributes<AlertRuleTimelineTableResponse> => ({
): HTMLAttributes<AlertRuleTimelineTableResponse> & {
'data-testid': string;
} => ({
'data-testid': 'top-contributors-row',
onClick: (): void => {
logEvent('Alert history: Top contributors row: Clicked', {
labels: record.labels,

View File

@@ -31,7 +31,10 @@ function ViewAllDrawer({
}}
title="Viewing All Contributors"
>
<div className="top-contributors-card--view-all">
<div
className="top-contributors-card--view-all"
data-testid="top-contributors-drawer"
>
<div className="top-contributors-card__content">
<TopContributorsRows
topContributors={topContributorsData}

View File

@@ -32,8 +32,8 @@ function GraphWrapper({
}, [data?.data]);
return (
<div className="timeline-graph">
<div className="timeline-graph__title">
<div className="timeline-graph" data-testid="timeline-graph">
<div className="timeline-graph__title" data-testid="timeline-graph-title">
{totalCurrentTriggers} triggers in {relativeTime}
</div>
<div className="timeline-graph__chart">

View File

@@ -118,7 +118,10 @@ function TimelineTableContent(): JSX.Element {
const handleRowClick = (
record: AlertRuleTimelineTableResponse,
): HTMLAttributes<AlertRuleTimelineTableResponse> => ({
): HTMLAttributes<AlertRuleTimelineTableResponse> & {
'data-testid': string;
} => ({
'data-testid': 'timeline-row',
onClick: (): void => {
void logEvent('Alert history: Timeline table row: Clicked', {
ruleId: record.ruleID,
@@ -128,12 +131,15 @@ function TimelineTableContent(): JSX.Element {
});
return (
<div className="timeline-table">
<div className="timeline-table" data-testid="timeline-table">
{/* If we don't wait to have the keys, the QuerySearch will not render them at first usage */}
{!isLoadingKeys && hardcodedAttributeKeys ? (
<div className="timeline-table__filter">
<div className="timeline-table__filter-row">
<div className="timeline-table__filter-search">
<div
className="timeline-table__filter-search"
data-testid="timeline-filter-search"
>
<QuerySearch
onChange={querySearchOnChange}
queryData={queryData}
@@ -155,6 +161,7 @@ function TimelineTableContent(): JSX.Element {
<Skeleton.Input
className="timeline-table__filter--loading-skeleton"
active
data-testid="timeline-filter-skeleton"
/>
</div>
)}
@@ -172,14 +179,17 @@ function TimelineTableContent(): JSX.Element {
locale={{
emptyText:
isError && apiError ? (
<div className="timeline-table__error">
<div className="timeline-table__error" data-testid="timeline-error">
<ErrorContent error={apiError} />
</div>
) : undefined,
}}
footer={(): JSX.Element => (
<div className="timeline-table__pagination">
<div className="timeline-table__pagination-info">
<div
className="timeline-table__pagination-info"
data-testid="timeline-footer-range"
>
{paginationConfig.showTotal?.(totalItems, [
totalItems === 0
? 0

View File

@@ -21,7 +21,7 @@ export const timelineTableColumns = ({
sorter: true,
width: 140,
render: (value): JSX.Element => (
<div className="alert-rule-state">
<div className="alert-rule-state" data-testid="timeline-row-state">
<AlertState state={value} showLabel />
</div>
),
@@ -30,7 +30,7 @@ export const timelineTableColumns = ({
title: 'LABELS',
dataIndex: 'labels',
render: (labels): JSX.Element => (
<div className="alert-rule-labels">
<div className="alert-rule-labels" data-testid="timeline-row-labels">
<AlertLabels labels={labels} />
</div>
),
@@ -40,7 +40,10 @@ export const timelineTableColumns = ({
dataIndex: 'unixMilli',
width: 200,
render: (value): JSX.Element => (
<div className="alert-rule__created-at">
<div
className="alert-rule__created-at"
data-testid="timeline-row-created-at"
>
{formatTimezoneAdjustedTimestamp(value, DATE_TIME_FORMATS.DASH_DATETIME)}
</div>
),
@@ -53,7 +56,7 @@ export const timelineTableColumns = ({
if (!record.relatedTracesLink && !record.relatedLogsLink) {
return (
<Tooltip title="No links available for this item">
<Button type="text" ghost disabled>
<Button type="text" ghost disabled data-testid="timeline-row-actions">
<Ellipsis className="dropdown-icon" size="md" />
</Button>
</Tooltip>
@@ -65,7 +68,7 @@ export const timelineTableColumns = ({
relatedTracesLink={record.relatedTracesLink ?? ''}
relatedLogsLink={record.relatedLogsLink ?? ''}
>
<Button type="text" ghost>
<Button type="text" ghost data-testid="timeline-row-actions">
<Ellipsis className="dropdown-icon" size="md" />
</Button>
</ConditionalAlertPopover>

View File

@@ -23,6 +23,7 @@ function TimelineTabs(): JSX.Element {
{
value: TimelineTab.OVERALL_STATUS,
label: 'Overall Status',
testId: 'timeline-tab-overall-status',
},
{
value: TimelineTab.TOP_5_CONTRIBUTORS,
@@ -33,6 +34,7 @@ function TimelineTabs(): JSX.Element {
</div>
),
disabled: true,
testId: 'timeline-tab-top-contributors',
},
];
@@ -57,14 +59,17 @@ function TimelineFilters(): JSX.Element {
{
value: TimelineFilter.ALL,
label: 'All',
testId: 'timeline-filter-all',
},
{
value: TimelineFilter.FIRED,
label: 'Fired',
testId: 'timeline-filter-fired',
},
{
value: TimelineFilter.RESOLVED,
label: 'Resolved',
testId: 'timeline-filter-resolved',
},
];

View File

@@ -34,6 +34,7 @@ function AdvancedOptions(): JSX.Element {
})
}
value={advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit}
testId="send-notification-if-data-is-missing-input"
/>
<Typography.Text>Minutes</Typography.Text>
</div>
@@ -66,6 +67,7 @@ function AdvancedOptions(): JSX.Element {
})
}
value={advancedOptions.enforceMinimumDatapoints.minimumDatapoints}
testId="enforce-minimum-datapoints-input"
/>
<Typography.Text>Datapoints</Typography.Text>
</div>

View File

@@ -66,6 +66,7 @@ function EvaluationWindowPopover({
tabIndex={0}
data-value={option.value}
data-section-id={sectionId}
data-testid={`${sectionId}-option-${option.value}`}
onClick={(): void => onChange(option.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {

View File

@@ -186,6 +186,7 @@ function Footer(): JSX.Element {
color="primary"
onClick={handleSaveAlert}
disabled={disableButtons || Boolean(alertValidationMessage)}
testId="save-alert-rule-button"
>
{isCreatingAlertRule || isUpdatingAlertRule ? (
<Loader data-testid="save-alert-rule-loader-icon" size={14} />
@@ -218,6 +219,7 @@ function Footer(): JSX.Element {
color="secondary"
onClick={handleTestNotification}
disabled={disableButtons || Boolean(alertValidationMessage)}
testId="test-notification-button"
>
{isTestingAlertRule ? (
<Loader data-testid="test-notification-loader-icon" size={14} />
@@ -249,6 +251,7 @@ function Footer(): JSX.Element {
color="secondary"
onClick={handleDiscard}
disabled={disableButtons}
testId="discard-alert-rule-button"
>
<X size={14} /> Discard
</Button>

View File

@@ -119,6 +119,7 @@ function BasicInfo({
<SeveritySelect
getPopupContainer={popupContainer}
defaultValue="critical"
data-testid="alert-severity-select"
onChange={(value: unknown | string): void => {
const s = (value as string) || 'critical';
setAlertDef({
@@ -147,6 +148,7 @@ function BasicInfo({
]}
>
<InputSmall
data-testid="alert-name-input-v1"
onChange={(e): void => {
setAlertDef({
...alertDef,
@@ -161,6 +163,7 @@ function BasicInfo({
name={['annotations', 'description']}
>
<TextareaMedium
data-testid="alert-description-input"
onChange={(e): void => {
setAlertDef({
...alertDef,

View File

@@ -105,7 +105,7 @@ function QuerySection({
{
label: (
<Tooltip title="Query Builder">
<Button className="nav-btns">
<Button className="nav-btns" data-testid="query-builder-tab">
<Atom size={14} />
<Typography.Text>Query Builder</Typography.Text>
</Button>
@@ -122,7 +122,11 @@ function QuerySection({
: 'ClickHouse'
}
>
<Button className="nav-btns" disabled={isAnomalyDetection}>
<Button
className="nav-btns"
disabled={isAnomalyDetection}
data-testid="clickhouse-tab"
>
<Terminal size={14} />
<Typography.Text>ClickHouse Query</Typography.Text>
</Button>
@@ -162,7 +166,11 @@ function QuerySection({
: 'ClickHouse'
}
>
<Button className="nav-btns" disabled={isAnomalyDetection}>
<Button
className="nav-btns"
disabled={isAnomalyDetection}
data-testid="clickhouse-tab"
>
<Terminal size={14} />
<Typography.Text>ClickHouse Query</Typography.Text>
</Button>
@@ -180,7 +188,11 @@ function QuerySection({
: 'PromQL'
}
>
<Button className="nav-btns" disabled={isAnomalyDetection}>
<Button
className="nav-btns"
disabled={isAnomalyDetection}
data-testid="promql-tab"
>
<PromQLIcon
fillColor={isDarkMode ? Color.BG_VANILLA_200 : Color.BG_INK_300}
/>

View File

@@ -80,6 +80,7 @@ function RuleOptions({
defaultValue={defaultCompareOp}
value={alertDef.condition?.op}
style={{ minWidth: '120px' }}
data-testid="alert-threshold-op-select"
onChange={(value: string | unknown): void => {
const newOp = (value as string) || '';
@@ -116,6 +117,7 @@ function RuleOptions({
defaultValue={defaultMatchType}
style={{ minWidth: '130px' }}
value={alertDef.condition?.matchType}
data-testid="alert-threshold-match-type-select-v1"
onChange={(value: string | unknown): void => handleMatchOptChange(value)}
>
<Select.Option value="1">{t('option_atleastonce')}</Select.Option>
@@ -177,6 +179,7 @@ function RuleOptions({
style={{ minWidth: '120px' }}
value={alertDef.evalWindow}
onChange={onChangeEvalWindow}
data-testid="alert-eval-window-select"
>
<Select.Option value="5m0s">{t('option_5min')}</Select.Option>
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
@@ -194,6 +197,7 @@ function RuleOptions({
style={{ minWidth: '120px' }}
value={alertDef.evalWindow}
onChange={onChangeEvalWindow}
data-testid="alert-eval-window-select"
>
<Select.Option value="5m0s">{t('option_5min')}</Select.Option>
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
@@ -395,6 +399,7 @@ function RuleOptions({
value={alertDef?.condition?.target}
onChange={onChange}
type="number"
data-testid="alert-threshold-target-input"
onWheel={(e): void => e.currentTarget.blur()}
/>
</Form.Item>

View File

@@ -844,8 +844,6 @@ function FormAlertRules({
return (
<>
{Element}
<div
id="top"
className={`form-alert-rules-container ${
@@ -968,6 +966,7 @@ function FormAlertRules({
!isChannelConfigurationValid ||
queryStatus === 'error'
}
data-testid="alert-save-button"
>
{isNewRule ? t('button_createrule') : t('button_savechanges')}
</ActionButton>
@@ -981,6 +980,7 @@ function FormAlertRules({
}
type="default"
onClick={onTestRuleHandler}
data-testid="alert-test-button"
>
{' '}
{t('button_testrule')}
@@ -989,6 +989,7 @@ function FormAlertRules({
disabled={loading || false}
type="default"
onClick={onCancelHandler}
data-testid="alert-cancel-button"
>
{isNewRule && t('button_cancelchanges')}
{ruleId && !isEmpty(ruleId) && t('button_discard')}
@@ -998,6 +999,7 @@ function FormAlertRules({
</div>
<ConfirmDialog
testId="alert-save-confirm-dialog"
open={isConfirmSaveOpen}
onOpenChange={setIsConfirmSaveOpen}
title={t('confirm_save_title')}

View File

@@ -174,6 +174,7 @@ function LabelSelect({
<div style={{ display: 'flex', width: '100%' }}>
<Input
data-testid="alert-labels-input-v1"
placeholder={renderPlaceholder()}
onChange={handleLabelChange}
onKeyUp={(e): void => {

View File

@@ -94,6 +94,8 @@ function AlertDetails(): JSX.Element {
>
<div
className={classNames('alert-details', { 'alert-details-v2': isV2Alert })}
data-testid="alert-details-root"
data-schema-version={isV2Alert ? NEW_ALERT_SCHEMA_VERSION : 'v1'}
>
<AlertBreadcrumb
className="alert-details__breadcrumb"

View File

@@ -117,7 +117,11 @@ function AlertActionButtons({
<div className="alert-action-buttons">
<Tooltip title={isAlertRuleDisabled ? 'Enable alert' : 'Disable alert'}>
{isAlertRuleDisabled !== undefined && (
<Switch onChange={toggleAlertRule} value={!isAlertRuleDisabled} />
<Switch
onChange={toggleAlertRule}
value={!isAlertRuleDisabled}
testId="alert-actions-toggle"
/>
)}
</Tooltip>
<CopyToClipboard textToCopy={window.location.href} />
@@ -129,6 +133,7 @@ function AlertActionButtons({
<Tooltip title="More options">
<Button
type="text"
data-testid="alert-actions-menu"
icon={
<Ellipsis
size={16}

View File

@@ -47,21 +47,29 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
<div className="alert-info__info-wrapper">
<div className="top-section">
<div className="alert-title-wrapper">
<AlertState state={alertRuleState ?? state ?? ''} />
<div className="alert-title">
<div data-testid="alert-header-state">
<AlertState state={alertRuleState ?? state ?? ''} />
</div>
<div className="alert-title" data-testid="alert-header-title">
<LineClampedText text={displayName || ''} />
</div>
</div>
</div>
<div className="bottom-section">
{labels?.severity && <AlertSeverity severity={labels.severity} />}
{labels?.severity && (
<div data-testid="alert-header-severity">
<AlertSeverity severity={labels.severity} />
</div>
)}
{/* // TODO(shaheer): Get actual data when we are able to get alert firing from state from API */}
{/* <AlertStatus
status="firing"
timestamp={dayjs().subtract(1, 'd').valueOf()}
/> */}
<AlertLabels labels={labelsWithoutSeverity} />
<div data-testid="alert-header-labels">
<AlertLabels labels={labelsWithoutSeverity} />
</div>
</div>
</div>
);

View File

@@ -127,7 +127,7 @@ export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
{
Component: EditRules,
name: (
<div className="tab-item">
<div className="tab-item" data-testid="alert-details-tab-overview">
<Table size={14} />
Overview
</div>
@@ -138,7 +138,7 @@ export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
{
Component: AlertHistory,
name: (
<div className="tab-item">
<div className="tab-item" data-testid="alert-details-tab-history">
<History size={14} />
History
<BetaTag />

View File

@@ -114,23 +114,6 @@ describe('getCaretContext — stage detection', () => {
expect(ctx.partial).toBe('');
});
it('never replaces past the caret when it sits before the operator', () => {
const ctx = getCaretContext("env = 'prod'", 4);
expect(ctx.stage).toBe('operator');
expect(ctx.partial).toBe('');
expect(ctx.replaceStart).toBe(4);
expect(ctx.replaceEnd).toBe(4);
});
it('never replaces past the caret when it sits before the value', () => {
const ctx = getCaretContext("env = 'prod'", 6);
expect(ctx.stage).toBe('value');
expect(ctx.operator).toBe('=');
expect(ctx.partial).toBe('');
expect(ctx.replaceStart).toBe(6);
expect(ctx.replaceEnd).toBe(6);
});
it('detects the stage of the term under a mid-string caret', () => {
const q = "env = AND team = 'core'";
// caret right after the first `env ` (index 4) is the operator stage
@@ -159,13 +142,6 @@ describe('spliceAtCaret', () => {
expect(next).toBe("env = 'prod'");
});
it('inserts (without duplicating text) at a caret parked before a token', () => {
const q = "env = 'prod'";
const ctx = getCaretContext(q, 4);
const { next } = spliceAtCaret(q, ctx, '!= ');
expect(next).toBe("env != = 'prod'");
});
it('preserves text after the caret', () => {
const q = "env AND team = 'core'";
const ctx = getCaretContext(q, 4); // operator gap after `env`

View File

@@ -328,7 +328,7 @@ export const getCaretContext = (query: string, caret: number): CaretContext => {
fieldKey: scan.key ? scan.key.text : '',
operator: slot.operator,
partial: slot.partial,
replaceStart: Math.min(term.start + slot.replaceStartRel, pos),
replaceStart: term.start + slot.replaceStartRel,
replaceEnd: pos,
};
};

View File

@@ -13,6 +13,8 @@ interface Tab {
disabled?: boolean;
icon?: string | JSX.Element;
isBeta?: boolean;
/** Optional `data-testid` for the tab button. */
testId?: string;
}
interface TimelineTabsProps {
@@ -63,6 +65,7 @@ function Tabs2({
disabled={tab.disabled}
icon={tab.icon}
style={{ minWidth: buttonMinWidth }}
data-testid={tab.testId}
>
{tab.label}

View File

@@ -241,12 +241,9 @@ func (server *Server) PutAlerts(ctx context.Context, postableAlerts alertmanager
}
func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertmanagertypes.Config) error {
resolved, err := alertmanagerConfig.Resolved()
if err != nil {
return err
}
config := resolved.AlertmanagerConfig()
config := alertmanagerConfig.AlertmanagerConfig()
var err error
// Load SigNoz's alertmanager notification templates from the configured
// globs. The upstream default templates (default.tmpl, email.tmpl) are
// always loaded from the embedded alertmanager assets inside FromGlobs, so
@@ -278,7 +275,7 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
server.logger.InfoContext(ctx, "skipping creation of receiver not referenced by any route", slog.String("receiver", rcv.Name))
continue
}
extendedRcv, err := resolved.GetReceiver(rcv.Name)
extendedRcv, err := alertmanagerConfig.GetReceiver(rcv.Name)
if err != nil {
return err
}
@@ -353,7 +350,7 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
go server.dispatcher.Run()
go server.inhibitor.Run()
server.alertmanagerConfig = resolved
server.alertmanagerConfig = alertmanagerConfig
return nil
}

View File

@@ -1,6 +1,7 @@
package alertmanager
import (
"net/url"
"strings"
"time"
@@ -25,6 +26,11 @@ type Signoz struct {
alertmanagerserver.Config `mapstructure:",squash" yaml:",squash"`
}
type Legacy struct {
// ApiURL is the URL of the legacy signoz alertmanager.
ApiURL *url.URL `mapstructure:"api_url"`
}
func NewConfigFactory() factory.ConfigFactory {
return factory.NewConfigFactory(factory.MustNewName("alertmanager"), newConfig)
}

View File

@@ -167,10 +167,6 @@ func (provider *provider) UpdateChannelByReceiverAndID(ctx context.Context, orgI
return err
}
if err := config.SetGlobalConfig(provider.config.Signoz.Global); err != nil {
return err
}
if err := config.UpdateReceiver(receiver); err != nil {
return err
}
@@ -221,10 +217,6 @@ func (provider *provider) CreateChannel(ctx context.Context, orgID string, recei
return nil, err
}
if err := config.SetGlobalConfig(provider.config.Signoz.Global); err != nil {
return nil, err
}
if err := config.CreateReceiver(receiver); err != nil {
return nil, err
}

View File

@@ -1,40 +0,0 @@
package implcloudintegration
import (
"context"
"testing"
citypes "github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestServiceDefinitionsAreValid(t *testing.T) {
store := NewServiceDefinitionStore()
for _, provider := range []citypes.CloudProviderType{
citypes.CloudProviderTypeAWS,
citypes.CloudProviderTypeAzure,
citypes.CloudProviderTypeGCP,
} {
t.Run(provider.StringValue(), func(t *testing.T) {
defs, err := store.List(context.Background(), provider)
require.NoError(t, err, "all embedded definitions must load and validate")
require.NotEmpty(t, defs, "provider should ship at least one service definition")
for _, def := range defs {
assert.NotEmpty(t, def.ID, "service definition must have an id")
assert.NotEmpty(t, def.Title, "service %q must have a title", def.ID)
// Get() must agree with List() for every service it advertises.
serviceID, err := citypes.NewServiceID(provider, def.ID)
if !assert.NoError(t, err, "service id %q must be registered in serviceid.go", def.ID) {
continue
}
got, err := store.Get(context.Background(), provider, serviceID)
require.NoError(t, err, "service %q listed but not gettable", def.ID)
assert.Equal(t, def.ID, got.ID)
}
})
}
}

View File

@@ -621,7 +621,7 @@
{
"metricName": "cloudsql.googleapis.com/database/postgresql/deadlock_count",
"temporality": "",
"timeAggregation": "rate",
"timeAggregation": "max",
"spaceAggregation": "sum",
"reduceTo": "avg"
}
@@ -882,7 +882,7 @@
{
"metricName": "cloudsql.googleapis.com/database/postgresql/transaction_count",
"temporality": "",
"timeAggregation": "rate",
"timeAggregation": "max",
"spaceAggregation": "sum",
"reduceTo": "avg"
}

View File

@@ -54,13 +54,13 @@
{
"name": "cloudsql.googleapis.com/database/postgresql/transaction_count",
"unit": "Count",
"type": "Sum",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/postgresql/deadlock_count",
"unit": "Count",
"type": "Sum",
"type": "Gauge",
"description": ""
},
{

View File

@@ -925,7 +925,7 @@
"metricName": "compute.googleapis.com/instance/disk/average_io_latency",
"temporality": "",
"timeAggregation": "avg",
"spaceAggregation": "max",
"spaceAggregation": "sum",
"reduceTo": "avg"
}
],

View File

@@ -1207,8 +1207,8 @@
{
"metricName": "kubernetes.io/container/restart_count",
"temporality": "",
"timeAggregation": "increase",
"spaceAggregation": "sum",
"timeAggregation": "max",
"spaceAggregation": "max",
"reduceTo": "avg"
}
],

View File

@@ -601,8 +601,8 @@
{
"metricName": "redis.googleapis.com/stats/cache_hit_ratio",
"temporality": "",
"timeAggregation": "min",
"spaceAggregation": "min",
"timeAggregation": "max",
"spaceAggregation": "sum",
"reduceTo": "avg"
}
],
@@ -788,7 +788,7 @@
"metricName": "redis.googleapis.com/commands/usec_per_call",
"temporality": "",
"timeAggregation": "max",
"spaceAggregation": "max",
"spaceAggregation": "sum",
"reduceTo": "avg"
}
],
@@ -945,7 +945,7 @@
{
"metricName": "redis.googleapis.com/commands/calls",
"temporality": "",
"timeAggregation": "rate",
"timeAggregation": "max",
"spaceAggregation": "sum",
"reduceTo": "avg"
}
@@ -1044,8 +1044,8 @@
{
"metricName": "redis.googleapis.com/stats/reject_connections_count",
"temporality": "",
"timeAggregation": "rate",
"spaceAggregation": "sum",
"timeAggregation": "avg",
"spaceAggregation": "avg",
"reduceTo": "avg"
}
],
@@ -1213,4 +1213,4 @@
"refreshInterval": "",
"links": []
}
}
}

View File

@@ -233,7 +233,6 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewFillDashboardMeterSourceFactory(sqlstore, dashboardStore),
sqlmigration.NewUpdateRoleTransactionGroupsFactory(),
sqlmigration.NewFillDashboardSpecCollectionsFactory(sqlstore, dashboardStore),
sqlmigration.NewScrubEmailChannelTransportFactory(sqlstore),
)
}

View File

@@ -1,274 +0,0 @@
package sqlmigration
import (
"context"
"crypto/md5"
"encoding/json"
"fmt"
"log/slog"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type scrubEmailChannelTransport struct {
sqlstore sqlstore.SQLStore
logger *slog.Logger
}
type alertmanagerConfigScrubRow struct {
bun.BaseModel `bun:"table:alertmanager_config"`
ID string `bun:"id"`
Config string `bun:"config"`
}
type notificationChannelScrubRow struct {
bun.BaseModel `bun:"table:notification_channel"`
ID string `bun:"id"`
Data string `bun:"data"`
}
var emailTransportKeys = []string{
"from",
"hello",
"smarthost",
"auth_username",
"auth_password",
"auth_password_file",
"auth_secret",
"auth_secret_file",
"auth_identity",
"require_tls",
"tls_config",
"force_implicit_tls",
}
var globalSMTPKeys = []string{
"smtp_from",
"smtp_hello",
"smtp_smarthost",
"smtp_auth_username",
"smtp_auth_password",
"smtp_auth_password_file",
"smtp_auth_secret",
"smtp_auth_secret_file",
"smtp_auth_identity",
"smtp_require_tls",
"smtp_tls_config",
"smtp_force_implicit_tls",
}
func NewScrubEmailChannelTransportFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("scrub_email_channel_transport"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &scrubEmailChannelTransport{sqlstore: sqlstore, logger: ps.Logger}, nil
},
)
}
func (migration *scrubEmailChannelTransport) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
func (migration *scrubEmailChannelTransport) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
if err := migration.scrubConfigs(ctx, tx); err != nil {
return err
}
if err := migration.scrubChannels(ctx, tx); err != nil {
return err
}
return tx.Commit()
}
func (migration *scrubEmailChannelTransport) scrubConfigs(ctx context.Context, tx bun.Tx) error {
rows := make([]*alertmanagerConfigScrubRow, 0)
if err := tx.NewSelect().Model(&rows).Scan(ctx); err != nil {
return err
}
for _, row := range rows {
cfg := make(map[string]json.RawMessage)
if err := json.Unmarshal([]byte(row.Config), &cfg); err != nil {
migration.logger.WarnContext(ctx, "skipping alertmanager config with unreadable config", slog.String("config_id", row.ID), errors.Attr(err))
continue
}
changed := false
if globalRaw, ok := cfg["global"]; ok && string(globalRaw) != "null" {
global := make(map[string]json.RawMessage)
if err := json.Unmarshal(globalRaw, &global); err != nil {
migration.logger.WarnContext(ctx, "skipping alertmanager config with unreadable global", slog.String("config_id", row.ID), errors.Attr(err))
continue
}
if deleteKeys(global, globalSMTPKeys) {
newGlobal, err := json.Marshal(global)
if err != nil {
migration.logger.WarnContext(ctx, "skipping alertmanager config, cannot marshal global", slog.String("config_id", row.ID), errors.Attr(err))
continue
}
cfg["global"] = newGlobal
changed = true
}
}
if receiversRaw, ok := cfg["receivers"]; ok && string(receiversRaw) != "null" {
receivers := make([]map[string]json.RawMessage, 0)
if err := json.Unmarshal(receiversRaw, &receivers); err != nil {
migration.logger.WarnContext(ctx, "skipping alertmanager config with unreadable receivers", slog.String("config_id", row.ID), errors.Attr(err))
continue
}
receiversChanged := false
unreadable := false
for _, receiver := range receivers {
scrubbed, err := scrubEmailConfigs(receiver)
if err != nil {
migration.logger.WarnContext(ctx, "skipping alertmanager config with unreadable email configs", slog.String("config_id", row.ID), errors.Attr(err))
unreadable = true
break
}
receiversChanged = receiversChanged || scrubbed
}
if unreadable {
continue
}
if receiversChanged {
newReceivers, err := json.Marshal(receivers)
if err != nil {
migration.logger.WarnContext(ctx, "skipping alertmanager config, cannot marshal receivers", slog.String("config_id", row.ID), errors.Attr(err))
continue
}
cfg["receivers"] = newReceivers
changed = true
}
}
if !changed {
continue
}
newConfig, err := json.Marshal(cfg)
if err != nil {
migration.logger.WarnContext(ctx, "skipping alertmanager config, cannot marshal config", slog.String("config_id", row.ID), errors.Attr(err))
continue
}
if _, err := tx.NewUpdate().
Model((*alertmanagerConfigScrubRow)(nil)).
Set("config = ?", string(newConfig)).
Set("hash = ?", fmt.Sprintf("%x", md5.Sum(newConfig))).
Where("id = ?", row.ID).
Exec(ctx); err != nil {
return err
}
}
return nil
}
func (migration *scrubEmailChannelTransport) scrubChannels(ctx context.Context, tx bun.Tx) error {
rows := make([]*notificationChannelScrubRow, 0)
if err := tx.NewSelect().Model(&rows).Where("type = ?", "email").Scan(ctx); err != nil {
return err
}
for _, row := range rows {
receiver := make(map[string]json.RawMessage)
if err := json.Unmarshal([]byte(row.Data), &receiver); err != nil {
migration.logger.WarnContext(ctx, "skipping notification channel with unreadable data", slog.String("channel_id", row.ID), errors.Attr(err))
continue
}
scrubbed, err := scrubEmailConfigs(receiver)
if err != nil {
migration.logger.WarnContext(ctx, "skipping notification channel with unreadable email configs", slog.String("channel_id", row.ID), errors.Attr(err))
continue
}
if !scrubbed {
continue
}
newData, err := json.Marshal(receiver)
if err != nil {
migration.logger.WarnContext(ctx, "skipping notification channel, cannot marshal data", slog.String("channel_id", row.ID), errors.Attr(err))
continue
}
if _, err := tx.NewUpdate().
Model((*notificationChannelScrubRow)(nil)).
Set("data = ?", string(newData)).
Where("id = ?", row.ID).
Exec(ctx); err != nil {
return err
}
}
return nil
}
func scrubEmailConfigs(receiver map[string]json.RawMessage) (bool, error) {
emailConfigsRaw, ok := receiver["email_configs"]
if !ok || string(emailConfigsRaw) == "null" {
return false, nil
}
emailConfigs := make([]map[string]json.RawMessage, 0)
if err := json.Unmarshal(emailConfigsRaw, &emailConfigs); err != nil {
return false, err
}
changed := false
for _, emailConfig := range emailConfigs {
changed = deleteKeys(emailConfig, emailTransportKeys) || changed
}
if !changed {
return false, nil
}
newEmailConfigs, err := json.Marshal(emailConfigs)
if err != nil {
return false, err
}
receiver["email_configs"] = newEmailConfigs
return true, nil
}
func deleteKeys(m map[string]json.RawMessage, keys []string) bool {
deleted := false
for _, key := range keys {
if _, ok := m[key]; ok {
delete(m, key)
deleted = true
}
}
return deleted
}
func (migration *scrubEmailChannelTransport) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -35,8 +35,12 @@ func TestNewConfigFromChannels(t *testing.T) {
"email_configs": []any{map[string]any{
"send_resolved": false,
"to": "test@example.com",
"smarthost": "",
"from": "alerts@example.com",
"hello": "localhost",
"smarthost": "smtp.example.com:587",
"require_tls": true,
"html": "{{ template \"email.default.html\" . }}",
"tls_config": map[string]any{"insecure_skip_verify": false},
"threading": map[string]any{},
}},
},
@@ -59,6 +63,7 @@ func TestNewConfigFromChannels(t *testing.T) {
"slack_configs": []any{map[string]any{
"send_resolved": true,
"api_url": "https://slack.com/api/test",
"app_url": "https://slack.com/api/chat.postMessage",
"channel": "#alerts",
"callback_id": "{{ template \"slack.default.callbackid\" . }}",
"color": "{{ if eq .Status \"firing\" }}danger{{ else }}good{{ end }}",
@@ -72,6 +77,12 @@ func TestNewConfigFromChannels(t *testing.T) {
"title": "{{ template \"slack.default.title\" . }}",
"title_link": "{{ template \"slack.default.titlelink\" . }}",
"username": "{{ template \"slack.default.username\" . }}",
"http_config": map[string]any{
"tls_config": map[string]any{"insecure_skip_verify": false},
"follow_redirects": true,
"enable_http2": true,
"proxy_url": nil,
},
}},
},
},
@@ -93,6 +104,7 @@ func TestNewConfigFromChannels(t *testing.T) {
"pagerduty_configs": []any{map[string]any{
"send_resolved": false,
"service_key": "test",
"url": "https://events.pagerduty.com/v2/enqueue",
"client": "{{ template \"pagerduty.default.client\" . }}",
"client_url": "{{ template \"pagerduty.default.clientURL\" . }}",
"description": "{{ template \"pagerduty.default.description\" .}}",
@@ -104,6 +116,12 @@ func TestNewConfigFromChannels(t *testing.T) {
"num_resolved": "{{ .Alerts.Resolved | len }}",
"resolved": "{{ .Alerts.Resolved | toJson }}",
},
"http_config": map[string]any{
"tls_config": map[string]any{"insecure_skip_verify": false},
"follow_redirects": true,
"enable_http2": true,
"proxy_url": nil,
},
}},
},
},
@@ -130,6 +148,7 @@ func TestNewConfigFromChannels(t *testing.T) {
"pagerduty_configs": []any{map[string]any{
"send_resolved": false,
"service_key": "test",
"url": "https://events.pagerduty.com/v2/enqueue",
"client": "{{ template \"pagerduty.default.client\" . }}",
"client_url": "{{ template \"pagerduty.default.clientURL\" . }}",
"description": "{{ template \"pagerduty.default.description\" .}}",
@@ -141,6 +160,12 @@ func TestNewConfigFromChannels(t *testing.T) {
"num_resolved": "{{ .Alerts.Resolved | len }}",
"resolved": "{{ .Alerts.Resolved | toJson }}",
},
"http_config": map[string]any{
"tls_config": map[string]any{"insecure_skip_verify": false},
"follow_redirects": true,
"enable_http2": true,
"proxy_url": nil,
},
}},
},
{
@@ -148,6 +173,7 @@ func TestNewConfigFromChannels(t *testing.T) {
"slack_configs": []any{map[string]any{
"send_resolved": true,
"api_url": "https://slack.com/api/test",
"app_url": "https://slack.com/api/chat.postMessage",
"channel": "#alerts",
"callback_id": "{{ template \"slack.default.callbackid\" . }}",
"color": "{{ if eq .Status \"firing\" }}danger{{ else }}good{{ end }}",
@@ -161,6 +187,12 @@ func TestNewConfigFromChannels(t *testing.T) {
"title": "{{ template \"slack.default.title\" . }}",
"title_link": "{{ template \"slack.default.titlelink\" . }}",
"username": "{{ template \"slack.default.username\" . }}",
"http_config": map[string]any{
"tls_config": map[string]any{"insecure_skip_verify": false},
"follow_redirects": true,
"enable_http2": true,
"proxy_url": nil,
},
}},
},
},

View File

@@ -117,11 +117,6 @@ func NewConfigFromStoreableConfig(sc *StoreableConfig) (*Config, error) {
return nil, err
}
// It must be replaced with an empty, non-nil global, upstream swaps nil for
// DefaultGlobalConfig, which would let a path that skips SetGlobalConfig pass
// validation and fail silently at delivery instead of failing fast here.
alertmanagerConfig.Global = &config.GlobalConfig{}
return &Config{
alertmanagerConfig: alertmanagerConfig,
customConfigs: customConfigs,
@@ -179,7 +174,7 @@ func newConfigFromString(s string) (*config.Config, map[string]customReceiverCon
return amConfig, customConfigs, nil
}
func extendedReceivers(c *config.Config, customConfigs map[string]customReceiverConfigs) []*Receiver {
func newRawFromConfig(c *config.Config, customConfigs map[string]customReceiverConfigs) []byte {
receivers := make([]*Receiver, len(c.Receivers))
for i := range c.Receivers {
base := c.Receivers[i]
@@ -190,14 +185,7 @@ func extendedReceivers(c *config.Config, customConfigs map[string]customReceiver
}
}
return receivers
}
func newRawFromConfig(c *config.Config, customConfigs map[string]customReceiverConfigs) []byte {
persistable := *c
persistable.Global = nil
b, err := json.Marshal(storedConfig{Config: &persistable, Receivers: extendedReceivers(c, customConfigs)})
b, err := json.Marshal(storedConfig{Config: c, Receivers: receivers})
if err != nil {
// Taking inspiration from the upstream. This is never expected to happen.
return []byte(fmt.Sprintf("<error creating config string: %s>", err))
@@ -218,37 +206,6 @@ func (c *Config) flush() {
c.storeableConfig.UpdatedAt = time.Now()
}
func (c *Config) Resolved() (*Config, error) {
raw, err := json.Marshal(storedConfig{Config: c.alertmanagerConfig, Receivers: extendedReceivers(c.alertmanagerConfig, c.customConfigs)})
if err != nil {
return nil, err
}
alertmanagerConfig, customConfigs, err := newConfigFromString(string(raw))
if err != nil {
return nil, err
}
if err := alertmanagerConfig.UnmarshalYAML(func(i interface{}) error { return nil }); err != nil {
return nil, err
}
storeableConfig := *c.storeableConfig
resolved := &Config{
alertmanagerConfig: alertmanagerConfig,
customConfigs: customConfigs,
storeableConfig: &storeableConfig,
}
resolved.applyNativeDefaults()
return resolved, nil
}
func (c *Config) validate() error {
_, err := c.Resolved()
return err
}
func (c *Config) CopyWithReset() (*Config, error) {
newConfig, err := NewDefaultConfig(
*c.alertmanagerConfig.Global,
@@ -314,15 +271,6 @@ func (c *Config) StoreableConfig() *StoreableConfig {
return c.storeableConfig
}
func cloneReceiver(receiver *Receiver) (*Receiver, error) {
raw, err := json.Marshal(receiver)
if err != nil {
return nil, err
}
return NewReceiver(string(raw))
}
func (c *Config) CreateReceiver(receiver *Receiver) error {
// check that receiver name is not already used
for _, existingReceiver := range c.alertmanagerConfig.Receivers {
@@ -331,21 +279,16 @@ func (c *Config) CreateReceiver(receiver *Receiver) error {
}
}
owned, err := cloneReceiver(receiver)
if err != nil {
return err
}
route, err := NewRouteFromReceiver(owned)
route, err := NewRouteFromReceiver(receiver)
if err != nil {
return err
}
c.alertmanagerConfig.Route.Routes = append(c.alertmanagerConfig.Route.Routes, route)
c.alertmanagerConfig.Receivers = append(c.alertmanagerConfig.Receivers, *owned.Receiver)
c.setCustomConfigs(owned)
c.alertmanagerConfig.Receivers = append(c.alertmanagerConfig.Receivers, *receiver.Receiver)
c.setCustomConfigs(receiver)
if err := c.validate(); err != nil {
if err := c.alertmanagerConfig.UnmarshalYAML(func(i interface{}) error { return nil }); err != nil {
return err
}
c.applyNativeDefaults()
@@ -370,21 +313,16 @@ func (c *Config) GetReceiver(name string) (*Receiver, error) {
}
func (c *Config) UpdateReceiver(receiver *Receiver) error {
owned, err := cloneReceiver(receiver)
if err != nil {
return err
}
// find and update receiver
for i, existingReceiver := range c.alertmanagerConfig.Receivers {
if existingReceiver.Name == owned.Name {
c.alertmanagerConfig.Receivers[i] = *owned.Receiver
c.setCustomConfigs(owned)
if existingReceiver.Name == receiver.Name {
c.alertmanagerConfig.Receivers[i] = *receiver.Receiver
c.setCustomConfigs(receiver)
break
}
}
if err := c.validate(); err != nil {
if err := c.alertmanagerConfig.UnmarshalYAML(func(i interface{}) error { return nil }); err != nil {
return err
}
c.applyNativeDefaults()

View File

@@ -330,150 +330,6 @@ func TestSetGlobalConfigPreservesSMTPRequireTLS(t *testing.T) {
}
}
func newSMTPGlobalConfig() GlobalConfig {
return GlobalConfig{
SMTPFrom: "alerts@example.com",
SMTPHello: "example.com",
SMTPSmarthost: config.HostPort{Host: "smtp.sendgrid.net", Port: "587"},
SMTPAuthUsername: "apikey",
SMTPAuthPassword: "operator-secret",
SMTPRequireTLS: true,
}
}
func newEmailTestConfig(t *testing.T) *Config {
t.Helper()
cfg, err := NewDefaultConfig(
newSMTPGlobalConfig(),
RouteConfig{GroupInterval: time.Minute, GroupWait: time.Minute, RepeatInterval: time.Minute},
"1",
)
require.NoError(t, err)
receiver, err := NewReceiver(`{"name":"email-receiver","email_configs":[{"to":"team@example.com"}]}`)
require.NoError(t, err)
require.NoError(t, cfg.CreateReceiver(receiver))
return cfg
}
func TestStoreableConfigCarriesNoSMTPSettings(t *testing.T) {
cfg := newEmailTestConfig(t)
raw := cfg.StoreableConfig().Config
assert.NotContains(t, raw, "operator-secret")
assert.NotContains(t, raw, "smtp.sendgrid.net")
assert.NotContains(t, raw, "apikey")
assert.NotContains(t, raw, "alerts@example.com")
assert.Equal(t, "operator-secret", string(cfg.alertmanagerConfig.Global.SMTPAuthPassword))
}
func TestStoreableConfigCarriesNoGlobal(t *testing.T) {
cfg := newEmailTestConfig(t)
stored := map[string]json.RawMessage{}
require.NoError(t, json.Unmarshal([]byte(cfg.StoreableConfig().Config), &stored))
assert.NotContains(t, stored, "global")
}
func TestSetGlobalConfigDoesNotChangeStoreableHash(t *testing.T) {
cfg := newEmailTestConfig(t)
hash := cfg.StoreableConfig().Hash
require.NoError(t, cfg.SetGlobalConfig(GlobalConfig{SMTPSmarthost: config.HostPort{Host: "smtp.other.net", Port: "2525"}, SMTPAuthPassword: "rotated-secret"}))
assert.Equal(t, hash, cfg.StoreableConfig().Hash)
assert.NotContains(t, cfg.StoreableConfig().Config, "rotated-secret")
}
func TestNewConfigFromStoreableConfigDiscardsStoredGlobal(t *testing.T) {
stored := &StoreableConfig{
Config: `{"global":{"resolve_timeout":"5m","smtp_smarthost":"email-smtp.us-east-1.amazonaws.com:587","smtp_auth_password":"old-secret","slack_api_url":"https://hooks.slack.com/services/T/B/X"},"route":{"receiver":"default-receiver"},"receivers":[{"name":"default-receiver"}]}`,
OrgID: "1",
}
cfg, err := NewConfigFromStoreableConfig(stored)
require.NoError(t, err)
assert.Equal(t, &config.GlobalConfig{}, cfg.alertmanagerConfig.Global)
}
func TestResolvedFillsEmailTransportFromGlobal(t *testing.T) {
cfg := newEmailTestConfig(t)
resolved, err := cfg.Resolved()
require.NoError(t, err)
receiver, err := resolved.GetReceiver("email-receiver")
require.NoError(t, err)
require.Len(t, receiver.EmailConfigs, 1)
got := receiver.EmailConfigs[0]
assert.Equal(t, "team@example.com", got.To)
assert.Equal(t, "smtp.sendgrid.net:587", got.Smarthost.String())
assert.Equal(t, "alerts@example.com", got.From)
assert.Equal(t, "apikey", got.AuthUsername)
assert.Equal(t, "operator-secret", string(got.AuthPassword))
require.NotNil(t, got.RequireTLS)
assert.True(t, *got.RequireTLS)
stored, err := cfg.GetReceiver("email-receiver")
require.NoError(t, err)
assert.Empty(t, stored.EmailConfigs[0].Smarthost.String())
assert.NotContains(t, cfg.StoreableConfig().Config, "operator-secret")
}
func TestStaleStoredSMTPSettingsAreReplacedOnLoad(t *testing.T) {
stored := &StoreableConfig{
Config: `{"global":{"resolve_timeout":"5m","smtp_from":"old@example.com","smtp_hello":"localhost","smtp_smarthost":"email-smtp.us-east-1.amazonaws.com:587","smtp_auth_username":"old-user","smtp_auth_password":"old-secret","smtp_require_tls":true},"route":{"receiver":"default-receiver","group_by":["ruleId"],"routes":[{"receiver":"email-receiver","continue":true,"matchers":["ruleId=~\"-1\""]}],"group_wait":"30s","group_interval":"5m","repeat_interval":"4h"},"receivers":[{"name":"default-receiver"},{"name":"email-receiver","email_configs":[{"send_resolved":false,"to":"team@example.com","from":"old@example.com","hello":"localhost","smarthost":"email-smtp.us-east-1.amazonaws.com:587","auth_username":"old-user","auth_password":"old-secret","require_tls":true}]}]}`,
OrgID: "1",
}
cfg, err := NewConfigFromStoreableConfig(stored)
require.NoError(t, err)
loaded, err := cfg.GetReceiver("email-receiver")
require.NoError(t, err)
require.Len(t, loaded.EmailConfigs, 1)
assert.Empty(t, loaded.EmailConfigs[0].Smarthost.String())
assert.Empty(t, string(loaded.EmailConfigs[0].AuthPassword))
require.NoError(t, cfg.SetGlobalConfig(newSMTPGlobalConfig()))
resolved, err := cfg.Resolved()
require.NoError(t, err)
receiver, err := resolved.GetReceiver("email-receiver")
require.NoError(t, err)
got := receiver.EmailConfigs[0]
assert.Equal(t, "smtp.sendgrid.net:587", got.Smarthost.String())
assert.Equal(t, "operator-secret", string(got.AuthPassword))
assert.Equal(t, "alerts@example.com", got.From)
assert.NotContains(t, cfg.StoreableConfig().Config, "old-secret")
assert.NotContains(t, cfg.StoreableConfig().Config, "amazonaws.com")
assert.NotContains(t, cfg.StoreableConfig().Config, "operator-secret")
}
func TestCreateReceiverDoesNotMutateCaller(t *testing.T) {
cfg := newEmailTestConfig(t)
resolved, err := cfg.Resolved()
require.NoError(t, err)
receiver, err := resolved.GetReceiver("email-receiver")
require.NoError(t, err)
require.Equal(t, "smtp.sendgrid.net:587", receiver.EmailConfigs[0].Smarthost.String())
throwaway, err := cfg.CopyWithReset()
require.NoError(t, err)
require.NoError(t, throwaway.CreateReceiver(receiver))
assert.Equal(t, "smtp.sendgrid.net:587", receiver.EmailConfigs[0].Smarthost.String())
assert.Equal(t, "operator-secret", string(receiver.EmailConfigs[0].AuthPassword))
}
// Round-trip: create → serialize → reload → GetReceiver still has the configs.
func TestConfigPreservesGoogleChatConfigs(t *testing.T) {
webhookURL, err := url.Parse("https://chat.googleapis.com/v1/spaces/test/messages")

View File

@@ -37,7 +37,6 @@ func NewReceiver(input string) (*Receiver, error) {
if err != nil {
return nil, err
}
stripEmailTransport(withDefaults)
receiver.Receiver = withDefaults
// Extend this block when adding another native notifier type.
@@ -54,23 +53,6 @@ func NewReceiver(input string) (*Receiver, error) {
return receiver, nil
}
func stripEmailTransport(base *config.Receiver) {
for _, ec := range base.EmailConfigs {
ec.From = ""
ec.Hello = ""
ec.Smarthost = config.HostPort{}
ec.AuthUsername = ""
ec.AuthPassword = ""
ec.AuthPasswordFile = ""
ec.AuthSecret = ""
ec.AuthSecretFile = ""
ec.AuthIdentity = ""
ec.RequireTLS = nil
ec.TLSConfig = nil
ec.ForceImplicitTLS = nil
}
}
func defaultedBaseReceiver(base *config.Receiver) (*config.Receiver, error) {
bytes, err := yaml.Marshal(base)
if err != nil {
@@ -120,12 +102,7 @@ func TestReceiver(ctx context.Context, receiver *Receiver, receiverIntegrationsF
return err
}
resolvedConfig, err := testConfig.Resolved()
if err != nil {
return err
}
defaultedReceiver, err := resolvedConfig.GetReceiver(receiver.Name)
defaultedReceiver, err := testConfig.GetReceiver(receiver.Name)
if err != nil {
return err
}

View File

@@ -46,31 +46,6 @@ func TestNewReceiver(t *testing.T) {
}
}
func TestNewReceiverStripsEmailTransport(t *testing.T) {
receiver, err := NewReceiver(`{"name":"email","email_configs":[{"to":"team@example.com","from":"attacker@example.com","hello":"example.com","smarthost":"smtp.example.com:587","auth_username":"user","auth_password":"supersecret","auth_secret":"alsosecret","auth_identity":"id","require_tls":false,"headers":{"Subject":"custom"}}]}`)
require.NoError(t, err)
require.Len(t, receiver.EmailConfigs, 1)
got := receiver.EmailConfigs[0]
assert.Equal(t, "team@example.com", got.To)
assert.Equal(t, map[string]string{"Subject": "custom"}, got.Headers)
assert.Empty(t, got.From)
assert.Empty(t, got.Hello)
assert.Empty(t, got.Smarthost.String())
assert.Empty(t, got.AuthUsername)
assert.Empty(t, string(got.AuthPassword))
assert.Empty(t, string(got.AuthSecret))
assert.Empty(t, got.AuthIdentity)
assert.Nil(t, got.RequireTLS)
assert.Nil(t, got.TLSConfig)
bytes, err := json.Marshal(receiver)
require.NoError(t, err)
assert.NotContains(t, string(bytes), "supersecret")
assert.NotContains(t, string(bytes), "smtp.example.com")
}
// Omitted fields fall back to DefaultGoogleChatReceiverConfig.
func TestNewReceiverGoogleChatAppliesDefaults(t *testing.T) {
receiver, err := NewReceiver(`{"name":"googlechat","googlechat_configs":[{"webhook_url":"https://chat.googleapis.com/v1/spaces/test/messages"}]}`)

View File

@@ -24,7 +24,6 @@ pytest_plugins = [
"fixtures.keycloak",
"fixtures.idp",
"fixtures.notification_channel",
"fixtures.maildev",
"fixtures.alerts",
"fixtures.cloudintegrations",
"fixtures.jsontypes",

View File

@@ -0,0 +1,451 @@
import type { Browser } from '@playwright/test';
import {
createEmailChannelViaApi,
createLogsAlertViaApi,
createMetricAlertViaApi,
createNoDataAlertViaApi,
createTracesAlertViaApi,
deleteAlertViaApi,
deleteChannelViaApi,
readTimelineTotal,
seedAlertHistoryLogs,
seedAlertHistoryMetrics,
seedAlertHistoryTraces,
setRuleDisabledViaApi,
waitForTimelineEntries,
waitForTimelineStates,
} from '../helpers/alerts';
import { expect, test as base, withAdminPage } from './alert-rules';
// Worker-scoped alert-history fixtures. Extends `alert-rules`, so a spec that
// imports `test` from here also gets `alertChannel` / `alertList` / `ownedRules`
// — the details specs need a history seed *and* their own throwaway rules.
//
// Every history row has to come from the ruler actually evaluating a rule (there
// is no seeder endpoint for `rule_state_history_v0`), so each fixture pays a
// real ruler wait: ~20-35s for the logs fixtures, ~10s for metrics, ~105s for
// the firing→resolved wave. Worker scope means one wait per worker instead of
// one per test, and Playwright creates each fixture lazily — a spec that never
// asks for `resolvedHistory` never pays its 105s.
//
// See `tests/e2e/specs/alerts/alerts-e2e-coverage.md` §3 for the recipes and the
// empirically-measured timings each budget here is derived from.
/** Distinct `service.name` values SEED-A seeds ⇒ its timeline row count. */
const SEED_A_SERVICES = 25;
/** SEED-F seeds fewer services and a 1m window so it resolves inside ~105s. */
const SEED_F_SERVICES = 3;
/** SEED-E's group-by values ⇒ a 2-row history that fits on one page. */
const SEED_E_HOSTS = ['host-0', 'host-1'];
/** SEED-H seeds just enough services to prove the traces link; keeps the wait short. */
const SEED_H_SERVICES = 3;
/** Non-severity label on SEED-C, so the v1 header's labels row is non-empty. */
export const SEED_C_TEAM_LABEL = 'e2e-platform';
export interface AlertHistorySeed {
/** v2 (`schemaVersion: v2alpha1`) rule — the default history subject. */
ruleId: string;
/** Legacy v1 rule over the same logs. Its `threshold.name` is `warning`. */
ruleIdV1: string;
channelName: string;
/** The `body CONTAINS` marker both rules match. */
marker: string;
/** The seeded `service.name` values, in creation order. */
services: string[];
/** Baseline `total` for {@link ruleId}, read after the rule was frozen. */
total: number;
/** Baseline `total` for {@link ruleIdV1}. */
totalV1: number;
}
export interface MetricsHistorySeed {
ruleId: string;
channelName: string;
metricName: string;
hosts: string[];
total: number;
}
export interface TracesHistorySeed {
ruleId: string;
channelName: string;
/** The span `name` the rule matches (`name = '<marker>'`). */
marker: string;
services: string[];
total: number;
}
export interface ResolvedHistorySeed {
ruleId: string;
channelName: string;
marker: string;
services: string[];
/** Rows in the `firing` state — equals `stats.totalCurrentTriggers`. */
firingCount: number;
/** Rows in the `inactive` state, i.e. what the `Resolved` filter shows. */
resolvedCount: number;
}
export interface NoDataHistorySeed {
ruleId: string;
channelName: string;
}
export interface EmptyHistorySeed {
ruleId: string;
channelName: string;
}
async function cleanup(
browser: Browser,
{ ruleIds, channelId }: { ruleIds: string[]; channelId?: string },
): Promise<void> {
await withAdminPage(browser, async (page) => {
for (const id of ruleIds) {
// eslint-disable-next-line no-await-in-loop
await deleteAlertViaApi(page, id);
}
if (channelId) {
await deleteChannelViaApi(page, channelId);
}
});
}
export const test = base.extend<
// eslint-disable-next-line @typescript-eslint/ban-types
{},
{
alertHistory: AlertHistorySeed;
metricsHistory: MetricsHistorySeed;
tracesHistory: TracesHistorySeed;
resolvedHistory: ResolvedHistorySeed;
noDataHistory: NoDataHistorySeed;
emptyHistory: EmptyHistorySeed;
}
>({
/**
* SEED-A (25-row firing history, v2) **plus** SEED-C (the same logs seen
* through a legacy v1 rule). Both rules share one seeded log batch, so the
* two ruler waves overlap and the fixture costs roughly one wait, not two.
*/
alertHistory: [
async ({ browser }, use) => {
const stamp = Date.now();
const marker = `e2e alert history ${stamp}`;
let channelId = '';
let ruleId = '';
let ruleIdV1 = '';
const seed = await withAdminPage(browser, async (page) => {
const channel = await createEmailChannelViaApi(page, `e2e-ah-ch-${stamp}`);
channelId = channel.id;
// Seed and create in the same breath: the rules only fire while the
// records are inside the 5m eval window.
const services = await seedAlertHistoryLogs(page, {
marker,
services: SEED_A_SERVICES,
servicePrefix: `e2e-ah-svc`,
});
ruleId = await createLogsAlertViaApi(page, {
name: `e2e-ah-rule-v2-${stamp}`,
marker,
channels: [channel.name],
schema: 'v2',
});
ruleIdV1 = await createLogsAlertViaApi(page, {
name: `e2e-ah-rule-v1-${stamp}`,
marker,
channels: [channel.name],
schema: 'v1',
// The v1 header renders `labels` minus `severity`, so without a
// second label its labels row is present but empty (AD-02).
extraLabels: { team: SEED_C_TEAM_LABEL },
});
await waitForTimelineEntries(page, ruleId, { min: SEED_A_SERVICES });
await waitForTimelineEntries(page, ruleIdV1, { min: SEED_A_SERVICES });
// Freeze both before the eval window rolls past the seeded records —
// otherwise the resolve wave doubles `total` mid-suite.
await setRuleDisabledViaApi(page, ruleId, true);
await setRuleDisabledViaApi(page, ruleIdV1, true);
return {
ruleId,
ruleIdV1,
channelName: channel.name,
marker,
services,
total: await readTimelineTotal(page, ruleId),
totalV1: await readTimelineTotal(page, ruleIdV1),
};
});
if (seed.total !== SEED_A_SERVICES) {
// A different total means the fixture is not what the scenarios were
// written against — most likely the resolve wave landed before the
// PATCH froze the rule. Fail loudly here rather than let every
// downstream count assertion fail with a confusing off-by-N.
throw new Error(
`SEED-A expected ${SEED_A_SERVICES} timeline rows, got ${seed.total}`,
);
}
await use(seed);
await cleanup(browser, { ruleIds: [ruleId, ruleIdV1], channelId });
},
{ scope: 'worker', timeout: 240_000 },
],
/**
* SEED-E — a metrics rule over two hosts. Two things SEED-A can't give:
* history rows with **no** related links (links are derived from the rule's
* signal), and a 2-row history that fits on a single page.
*/
metricsHistory: [
async ({ browser }, use) => {
const stamp = Date.now();
const metricName = `e2e_ah_probe_metric_${stamp}`;
let channelId = '';
let ruleId = '';
const seed = await withAdminPage(browser, async (page) => {
const channel = await createEmailChannelViaApi(
page,
`e2e-ah-metrics-ch-${stamp}`,
);
channelId = channel.id;
await seedAlertHistoryMetrics(page, {
metricName,
hosts: SEED_E_HOSTS,
});
ruleId = await createMetricAlertViaApi(page, {
name: `e2e-ah-metrics-rule-${stamp}`,
metricName,
channels: [channel.name],
});
await waitForTimelineEntries(page, ruleId, {
min: SEED_E_HOSTS.length,
timeoutMs: 120_000,
});
await setRuleDisabledViaApi(page, ruleId, true);
return {
ruleId,
channelName: channel.name,
metricName,
hosts: SEED_E_HOSTS,
total: await readTimelineTotal(page, ruleId),
};
});
await use(seed);
await cleanup(browser, { ruleIds: [ruleId], channelId });
},
{ scope: 'worker', timeout: 240_000 },
],
/**
* SEED-H — a traces rule over seeded spans. The only fixture whose history
* rows carry `relatedTracesLink`: the backend derives the link from the
* rule's signal and returns either a logs link or a traces link, never both,
* so the "View Traces" popover entry is unreachable from SEED-A.
*/
tracesHistory: [
async ({ browser }, use) => {
const stamp = Date.now();
const marker = `e2e-aht-span-${stamp}`;
let channelId = '';
let ruleId = '';
const seed = await withAdminPage(browser, async (page) => {
const channel = await createEmailChannelViaApi(
page,
`e2e-ah-traces-ch-${stamp}`,
);
channelId = channel.id;
const services = await seedAlertHistoryTraces(page, {
marker,
services: SEED_H_SERVICES,
servicePrefix: 'e2e-aht-svc',
});
ruleId = await createTracesAlertViaApi(page, {
name: `e2e-ah-traces-rule-${stamp}`,
marker,
channels: [channel.name],
});
await waitForTimelineEntries(page, ruleId, { min: SEED_H_SERVICES });
// Same reason as SEED-A: freeze before the eval window rolls past the
// seeded spans and the resolve wave doubles `total`.
await setRuleDisabledViaApi(page, ruleId, true);
return {
ruleId,
channelName: channel.name,
marker,
services,
total: await readTimelineTotal(page, ruleId),
};
});
await use(seed);
await cleanup(browser, { ruleIds: [ruleId], channelId });
},
{ scope: 'worker', timeout: 240_000 },
],
/**
* SEED-F — firing **and** resolved, without touching the seeder: a 1m eval
* window means the seeded records fall out of it fast, so the rule resolves
* on its own in ~105s. This is the only fixture that produces a non-zero
* average resolution time and a 3-segment overall-status graph.
*/
resolvedHistory: [
async ({ browser }, use) => {
const stamp = Date.now();
const marker = `e2e alert resolved ${stamp}`;
let channelId = '';
let ruleId = '';
const seed = await withAdminPage(browser, async (page) => {
const channel = await createEmailChannelViaApi(
page,
`e2e-ah-resolved-ch-${stamp}`,
);
channelId = channel.id;
const services = await seedAlertHistoryLogs(page, {
marker,
services: SEED_F_SERVICES,
ageSeconds: 40,
minAgeSeconds: 28,
servicePrefix: 'e2e-ahr-svc',
});
ruleId = await createLogsAlertViaApi(page, {
name: `e2e-ah-resolved-rule-${stamp}`,
marker,
channels: [channel.name],
evalWindow: '1m0s',
});
const timeline = await waitForTimelineStates(page, ruleId, {
states: {
firing: SEED_F_SERVICES,
inactive: SEED_F_SERVICES,
},
});
await setRuleDisabledViaApi(page, ruleId, true);
return {
ruleId,
channelName: channel.name,
marker,
services,
firingCount: timeline.items.filter((i) => i.state === 'firing').length,
resolvedCount: timeline.items.filter((i) => i.state === 'inactive').length,
};
});
await use(seed);
await cleanup(browser, { ruleIds: [ruleId], channelId });
},
{ scope: 'worker', timeout: 300_000 },
],
/**
* SEED-G — a `nodata` row, reached the same way
* `integration/testdata/alerts/test_scenarios/no_data_rule_test` does:
* `alertOnAbsent` on a query that matches nothing.
*/
noDataHistory: [
async ({ browser }, use) => {
const stamp = Date.now();
let channelId = '';
let ruleId = '';
const seed = await withAdminPage(browser, async (page) => {
const channel = await createEmailChannelViaApi(
page,
`e2e-ah-nodata-ch-${stamp}`,
);
channelId = channel.id;
ruleId = await createNoDataAlertViaApi(page, {
name: `e2e-ah-nodata-rule-${stamp}`,
// Deliberately unseeded — the query must match nothing.
marker: `e2e alert nodata ${stamp}`,
channels: [channel.name],
});
await waitForTimelineEntries(page, ruleId, {
min: 1,
state: 'nodata',
timeoutMs: 180_000,
});
await setRuleDisabledViaApi(page, ruleId, true);
return { ruleId, channelName: channel.name };
});
await use(seed);
await cleanup(browser, { ruleIds: [ruleId], channelId });
},
{ scope: 'worker', timeout: 300_000 },
],
/**
* A rule that will never have history: its query matches nothing and it is
* disabled immediately. Covers "no history yet" (empty table, zero stats) and
* "no key suggestions" without waiting on the ruler at all.
*/
emptyHistory: [
async ({ browser }, use) => {
const stamp = Date.now();
let channelId = '';
let ruleId = '';
const seed = await withAdminPage(browser, async (page) => {
const channel = await createEmailChannelViaApi(
page,
`e2e-ah-empty-ch-${stamp}`,
);
channelId = channel.id;
ruleId = await createLogsAlertViaApi(page, {
name: `e2e-ah-empty-rule-${stamp}`,
marker: `e2e alert never seeded ${stamp}`,
channels: [channel.name],
});
await setRuleDisabledViaApi(page, ruleId, true);
return { ruleId, channelName: channel.name };
});
await use(seed);
await cleanup(browser, { ruleIds: [ruleId], channelId });
},
{ scope: 'worker', timeout: 120_000 },
],
});
export { expect };

View File

@@ -0,0 +1,214 @@
import type { Browser, Page } from '@playwright/test';
import {
type AlertSchema,
createEmailChannelViaApi,
createLogsAlertViaApi,
createThresholdAlertViaApi,
deleteAlertViaApi,
deleteChannelViaApi,
type LogsAlertSeed,
seedAlertRules,
type ThresholdAlertSeed,
} from '../helpers/alerts';
import { newAdminContext } from '../helpers/auth';
import { expect, test as base } from './auth';
// Alert *rule* fixtures — the API-only half of the alerts suite. Nothing here
// waits on the ruler: a rule is created and that's it. History rows need real
// evaluations, so those fixtures live in `fixtures/alert-history.ts`, which
// extends this module — a spec importing from there gets both sets.
//
// Scopes, and why:
// `alertChannel` — worker. Every rule payload has to reference a channel by
// name, and one channel serves the whole worker.
// `alertList` — worker. SEED-B, the read-only rule list the `tests/alerts/
// list` specs page, search and sort through. Names and label values are
// stamped per worker so parallel batches never count each other's rules.
// `ownedRules` — test. Scenarios that rename/toggle/clone/delete a rule seed
// their own and have it removed when they finish; mutating a shared seed
// would break every scenario scheduled after it.
export interface AlertChannel {
id: string;
name: string;
}
export interface AlertListSeed {
channelName: string;
/** Rules are named `<namePrefix>-NN` — unique to this worker's batch. */
namePrefix: string;
/** Rules seeded ⇒ the `of N` total once the list is scoped to the prefix. */
count: number;
/** `team` label on the odd-indexed half of the batch, i.e. `count / 2` rules. */
paymentsLabel: string;
ruleIds: string[];
}
export interface OwnedRules {
/** Seed a metric threshold rule this test owns. */
threshold(
name: string,
overrides?: Partial<Omit<ThresholdAlertSeed, 'name'>>,
): Promise<string>;
/**
* Seed a logs rule this test owns. No telemetry is seeded for its marker, so
* it never fires — enough for anything about the details shell.
*
* `schema: 'v1'` posts the legacy payload and is SEED-RV1; the condition
* overrides exist so a v1 *prefill* assertion can be made against values the
* create form would not have produced by itself.
*/
logs(
options: {
name: string;
schema?: AlertSchema;
marker?: string;
} & Partial<
Pick<
LogsAlertSeed,
'severity' | 'extraLabels' | 'evalWindow' | 'target' | 'op' | 'matchType'
>
>,
): Promise<string>;
/**
* Track a rule the *app* created (Clone / Duplicate) so teardown removes it
* too. Lives here because the id may legitimately be missing and a
* conditional inside a test body is a lint error.
*/
register(response: { json: () => Promise<unknown> }): Promise<void>;
}
/** SEED-B size. 12 over a pinned page size of 10 ⇒ a short second page. */
const LIST_SEED_COUNT = 12;
/**
* Run `body` on a throwaway admin page. Worker hooks can't use the test-scoped
* `authedPage`, and every API helper needs a page whose context carries the
* admin storage state.
*/
export async function withAdminPage<T>(
browser: Browser,
body: (page: Page) => Promise<T>,
): Promise<T> {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
return await body(page);
} finally {
await ctx.close();
}
}
async function deleteRules(browser: Browser, ids: string[]): Promise<void> {
if (ids.length === 0) {
return;
}
await withAdminPage(browser, async (page) => {
for (const id of ids) {
// eslint-disable-next-line no-await-in-loop
await deleteAlertViaApi(page, id);
}
});
}
export const test = base.extend<
{ ownedRules: OwnedRules },
{ alertChannel: AlertChannel; alertList: AlertListSeed }
>({
alertChannel: [
async ({ browser }, use, workerInfo) => {
const channel = await withAdminPage(browser, (page) =>
createEmailChannelViaApi(
page,
`e2e-alerts-ch-w${workerInfo.workerIndex}-${Date.now()}`,
),
);
await use(channel);
await withAdminPage(browser, (page) =>
deleteChannelViaApi(page, channel.id),
);
},
{ scope: 'worker' },
],
alertList: [
async ({ browser, alertChannel }, use, workerInfo) => {
const stamp = `w${workerInfo.workerIndex}-${Date.now()}`;
const namePrefix = `e2e-alert-list-${stamp}`;
const teamSuffix = `-${stamp}`;
const ruleIds = await withAdminPage(browser, (page) =>
seedAlertRules(page, {
count: LIST_SEED_COUNT,
channelName: alertChannel.name,
namePrefix,
teamSuffix,
}),
);
await use({
channelName: alertChannel.name,
namePrefix,
count: LIST_SEED_COUNT,
paymentsLabel: `payments${teamSuffix}`,
ruleIds,
});
await deleteRules(browser, ruleIds);
},
{ scope: 'worker', timeout: 120_000 },
],
ownedRules: async ({ browser, alertChannel }, use) => {
const ids = new Set<string>();
const seed = async (
create: (page: Page) => Promise<string>,
): Promise<string> => {
const id = await withAdminPage(browser, create);
ids.add(id);
return id;
};
await use({
threshold: (name, overrides = {}) =>
seed((page) =>
createThresholdAlertViaApi(page, {
name,
target: 42,
channels: [alertChannel.name],
labels: { severity: 'critical' },
...overrides,
}),
),
logs: ({ name, schema = 'v2', marker, ...overrides }) =>
seed((page) =>
createLogsAlertViaApi(page, {
name,
marker: marker ?? `e2e alert never seeded ${name}`,
channels: [alertChannel.name],
schema,
...overrides,
}),
),
register: async (response) => {
const body = (await response.json()) as { data?: { id?: string } };
const id = body.data?.id;
if (id) {
ids.add(String(id));
}
},
});
// Best-effort: `deleteAlertViaApi` tolerates a rule a scenario already
// deleted through the UI.
await deleteRules(browser, [...ids]);
},
});
export { expect };

View File

@@ -1,81 +1,11 @@
import {
test as base,
expect,
type Browser,
type BrowserContext,
type Page,
} from '@playwright/test';
import { test as base, expect, type Page } from '@playwright/test';
export type User = { email: string; password: string };
import { ADMIN, storageStateFor, type User } from '../helpers/auth';
// Default user — admin from the pytest bootstrap (.env.local) or staging .env.
export const ADMIN: User = {
email: process.env.SIGNOZ_E2E_USERNAME!,
password: process.env.SIGNOZ_E2E_PASSWORD!,
};
// Per-worker storageState cache. One login per unique user per worker.
// Promise-valued so concurrent requests share the same in-flight work.
// Held in memory only — no .auth/ dir, no JSON on disk.
type StorageState = Awaited<ReturnType<BrowserContext['storageState']>>;
const storageByUser = new Map<string, Promise<StorageState>>();
async function storageFor(browser: Browser, user: User): Promise<StorageState> {
const cached = storageByUser.get(user.email);
if (cached) {
return cached;
}
const task = (async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await login(page, user);
await pinSidenav(page);
const state = await ctx.storageState();
await ctx.close();
return state;
})();
storageByUser.set(user.email, task);
return task;
}
async function login(page: Page, user: User): Promise<void> {
if (!user.email || !user.password) {
throw new Error(
'User credentials missing. Set SIGNOZ_E2E_USERNAME / SIGNOZ_E2E_PASSWORD ' +
'(pytest bootstrap writes them to .env.local), or pass a User via test.use({ user: ... }).',
);
}
await page.goto('/login?password=Y');
await page.getByTestId('email').fill(user.email);
await page.getByTestId('initiate_login').click();
await page.getByTestId('password').fill(user.password);
await page.getByRole('button', { name: 'Sign in with Password' }).click();
// Post-login lands somewhere different depending on whether the org is
// licensed (onboarding flow on ENTERPRISE) or not (legacy "Hello there"
// welcome). Wait for URL to move off /login — whichever page follows
// is fine, each spec navigates to the feature under test anyway.
await page.waitForURL((url) => !url.pathname.startsWith('/login'));
}
// Pin the nav suite-wide: unpinned it flies out on hover and overlays content.
// Server-side pref, so set once per user at login.
async function pinSidenav(page: Page): Promise<void> {
const token = await page.evaluate(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
);
const res = await page.request.put('/api/v1/user/preferences/sidenav_pinned', {
data: { value: true },
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
throw new Error(
`PUT /api/v1/user/preferences/sidenav_pinned ${res.status()}: ${await res.text()}`,
);
}
}
// The login flow and the per-worker session cache live in `helpers/auth.ts` so
// worker-scoped fixtures and suite hooks share one login with this fixture.
export { ADMIN };
export type { User };
export const test = base.extend<{
/**
@@ -95,7 +25,7 @@ export const test = base.extend<{
user: [ADMIN, { option: true }],
authedPage: async ({ browser, user }, use) => {
const storageState = await storageFor(browser, user);
const storageState = await storageStateFor(browser, user);
const ctx = await browser.newContext({ storageState });
const page = await ctx.newPage();
// Opt-in CPU throttling to reproduce GitHub-Linux-runner conditions on

View File

@@ -0,0 +1,738 @@
import { expect, type Locator, type Page } from '@playwright/test';
// Helpers for the alert *create* and *edit* forms — two different form
// implementations behind one feature. Rule *seeding* lives in
// `helpers/alerts.ts`; this module is about driving the UI, so nothing here talks
// to the rules API except through the browser.
//
// The single most important thing to know before using any of this: v1 and v2
// are not two skins over one form, they are two components with different
// selectors, different save gates and different success feedback. Every helper
// below is therefore named for the form it drives (`v1…` / `v2…`) unless it is
// genuinely shared, and the shared set is small.
// ─── Routes ────────────────────────────────────────────────────────────────
export const ALERTS_NEW_PATH = '/alerts/new';
/**
* The standalone edit route. Distinct from `/alerts/overview`, which renders the
* *same* editor inside the details shell. The two are not interchangeable for v2
* rules — see `edit/v2.spec.ts` EV2-12.
*/
export const ALERT_EDIT_PATH = '/alerts/edit';
// ─── Enums mirrored from the frontend ──────────────────────────────────────
/**
* URL values of `AlertTypes` (`frontend/src/types/api/alerts/alertTypes.ts`).
* Note `METRICS` maps to the *singular* `METRIC_BASED_ALERT` — the enum key and
* its value disagree in the source, and the URL carries the value.
*/
export const AlertType = {
METRICS: 'METRIC_BASED_ALERT',
LOGS: 'LOGS_BASED_ALERT',
TRACES: 'TRACES_BASED_ALERT',
EXCEPTIONS: 'EXCEPTIONS_BASED_ALERT',
ANOMALY: 'ANOMALY_BASED_ALERT',
} as const;
export type AlertTypeValue = (typeof AlertType)[keyof typeof AlertType];
/** `AlertDetectionTypes` (`frontend/src/container/FormAlertRules/index.tsx:78-81`). */
export const RuleType = {
THRESHOLD: 'threshold_rule',
ANOMALY: 'anomaly_rule',
} as const;
/**
* `AlertThresholdOperator` (`CreateAlertV2/context/types.ts:97-105`) and its
* dropdown labels (`context/constants.ts:123-137`).
*
* Threshold-alert operators only. Anomaly alerts render a different, shorter
* list (`ANOMALY_THRESHOLD_OPERATOR_OPTIONS`) with relabelled entries.
*/
export const ThresholdOperator = {
ABOVE: { value: 'above', label: 'ABOVE' },
BELOW: { value: 'below', label: 'BELOW' },
EQUAL_TO: { value: 'equal', label: 'EQUAL TO' },
NOT_EQUAL_TO: { value: 'not_equal', label: 'NOT EQUAL TO' },
ABOVE_OR_EQUAL_TO: { value: 'above_or_equal', label: 'ABOVE OR EQUAL TO' },
BELOW_OR_EQUAL_TO: { value: 'below_or_equal', label: 'BELOW OR EQUAL TO' },
} as const;
/**
* `AlertThresholdMatchType` (`CreateAlertV2/context/types.ts:105-111`) and its
* dropdown labels (`context/constants.ts:136-142`).
*
* Watch the plural: the enum *key* is `ALL_THE_TIME` but the wire value is
* `all_the_times`, and the API rejects the singular outright — the same
* key/value mismatch as `METRICS_BASED_ALERT` → `METRIC_BASED_ALERT`.
*/
export const ThresholdMatchType = {
AT_LEAST_ONCE: { value: 'at_least_once', label: 'AT LEAST ONCE' },
ALL_THE_TIME: { value: 'all_the_times', label: 'ALL THE TIME' },
ON_AVERAGE: { value: 'on_average', label: 'ON AVERAGE' },
IN_TOTAL: { value: 'in_total', label: 'IN TOTAL' },
LAST: { value: 'last', label: 'LAST' },
} as const;
/**
* `AlertListTabs` (`frontend/src/pages/AlertList/types.ts:7-9`). The values are
* space-less — the tab *labels* read "Triggered Alerts" but the `tab` URL param
* is `TriggeredAlerts`, and asserting the label form silently fails.
*/
export const AlertListTab = {
TRIGGERED_ALERTS: 'TriggeredAlerts',
ALERT_RULES: 'AlertRules',
CONFIGURATION: 'Configuration',
} as const;
/**
* The four cards a stock stack shows, in render order
* (`CreateAlertRule/SelectAlertType/config.ts:10-31`). Anomaly is `unshift`ed to
* the **front** of this list when the `ANOMALY_DETECTION` feature flag is active,
* so both the count and the order change when it is enabled.
*/
export const STOCK_ALERT_TYPE_CARDS: AlertTypeValue[] = [
AlertType.METRICS,
AlertType.LOGS,
AlertType.TRACES,
AlertType.EXCEPTIONS,
];
// ─── Navigation ────────────────────────────────────────────────────────────
/**
* Open the bare type-selection page. `isTypeSelectionMode` is
* `!alertType && !ruleType && !compositeQuery`
* (`container/CreateAlertRule/index.tsx:39-41`), so *any* of those three params
* skips this page — including a stale `compositeQuery` left in the URL.
*/
export async function gotoAlertTypeSelection(page: Page): Promise<void> {
await page.goto(ALERTS_NEW_PATH);
await expect(alertTypeCard(page, AlertType.METRICS)).toBeVisible();
}
export function alertTypeCard(page: Page, type: AlertTypeValue): Locator {
return page.getByTestId(`alert-type-card-${type}`);
}
export function alertTypeCards(page: Page): Locator {
return page.locator('[data-testid^="alert-type-card-"]');
}
/**
* Whether the anomaly card is on the page, i.e. whether `ANOMALY_DETECTION` is
* active for this stack. It **is** active on the pytest-bootstrapped integration
* stack, so every card-count assertion has to branch on it rather than hard-code
* 4.
*/
export async function hasAnomalyAlertTypeCard(page: Page): Promise<boolean> {
return (await alertTypeCard(page, AlertType.ANOMALY).count()) > 0;
}
/**
* Assert the type-selection page shows exactly the expected set of cards: the
* four stock ones, plus anomaly *first* when the flag is on (`getOptionList`
* `unshift`s it, `SelectAlertType/config.ts:33-40`).
*
* Written as an exact set rather than "at least four" so that adding a fifth
* signal still fails this assertion — the flag branch is the only slack.
*/
export async function expectAlertTypeCardSet(page: Page): Promise<void> {
const anomaly = await hasAnomalyAlertTypeCard(page);
const expected = anomaly
? [AlertType.ANOMALY, ...STOCK_ALERT_TYPE_CARDS]
: STOCK_ALERT_TYPE_CARDS;
const cards = alertTypeCards(page);
await expect(cards).toHaveCount(expected.length);
// Read the testids positionally so order is asserted too — anomaly being
// unshifted rather than appended is the behaviour worth pinning.
const rendered: (string | null)[] = [];
for (let i = 0; i < expected.length; i += 1) {
// eslint-disable-next-line no-await-in-loop
rendered.push(await cards.nth(i).getAttribute('data-testid'));
}
expect(rendered).toEqual(expected.map((type) => `alert-type-card-${type}`));
}
export interface CreateAlertUrlOptions {
alertType?: AlertTypeValue;
ruleType?: string;
/** Sets `showClassicCreateAlertsPage=true` ⇒ the v1 classic form. */
classic?: boolean;
/** Merged in last, so it can override anything above. */
params?: Record<string, string>;
}
export function createAlertUrl({
alertType = AlertType.LOGS,
ruleType = RuleType.THRESHOLD,
classic = false,
params = {},
}: CreateAlertUrlOptions = {}): string {
const search = new URLSearchParams({ alertType, ruleType });
if (classic) {
search.set('showClassicCreateAlertsPage', 'true');
}
for (const [key, value] of Object.entries(params)) {
search.set(key, value);
}
return `${ALERTS_NEW_PATH}?${search.toString()}`;
}
/**
* Open the **v2** builder and wait until it has settled. The wait is two-part on
* purpose: the header proves the builder mounted, and the `compositeQuery` in the
* URL proves `useShareBuilderUrl` has finished serialising the default query —
* without the second half, an assertion on the URL races the builder's own
* rewrite (the same trap `gotoAlertOverview` documents).
*/
export async function gotoCreateAlertV2(
page: Page,
options: Omit<CreateAlertUrlOptions, 'classic'> = {},
): Promise<void> {
await page.goto(createAlertUrl({ ...options, classic: false }));
await expect(page.getByTestId('alert-name-input')).toBeVisible();
await page.waitForURL(/compositeQuery=/, { timeout: 15_000 });
}
/** Open the **v1** classic create form and wait for its primary action. */
export async function gotoCreateAlertV1(
page: Page,
options: Omit<CreateAlertUrlOptions, 'classic'> = {},
): Promise<void> {
await page.goto(createAlertUrl({ ...options, classic: true }));
await expect(v1SaveButton(page)).toBeVisible();
}
// ─── v2 builder ────────────────────────────────────────────────────────────
/**
* Footer buttons. The disabled Save/Test buttons are wrapped in a `<span>` inside
* an antd `Tooltip` (`CreateAlertV2/Footer/Footer.tsx:198-204`) — the wrapper is
* why {@link v2SaveTooltip} exists instead of reading a `title` attribute, and
* why these are testids rather than accessible names: the name lookup also
* matched the wrapper in some states.
*/
export function v2SaveButton(page: Page): Locator {
return page.getByTestId('save-alert-rule-button');
}
export function v2TestButton(page: Page): Locator {
return page.getByTestId('test-notification-button');
}
export function v2DiscardButton(page: Page): Locator {
return page.getByTestId('discard-alert-rule-button');
}
/**
* Click the v2 Discard button — via `dispatchEvent`, because a real click cannot
* reach it.
*
* The footer is `position: fixed; left: 63px` (the *collapsed* nav rail width) and
* Discard is its left-most control, so the button occupies roughly x 75-170 at the
* bottom of the viewport. The side navigation occupies x 0-240 whenever it is
* 240px wide, which is: always when pinned — the default — and transiently when
* not pinned, because a mouse travelling toward the button crosses the rail and
* triggers `:not(.pinned).is-hovered`. Either way `document.elementFromPoint` at
* the button's centre returns the nav's `.nav-item-data`, so the nav swallows the
* click.
*
* `{ force: true }` does **not** help: it skips Playwright's actionability wait
* but still delivers a real mouse event at those coordinates, which the nav
* receives. `dispatchEvent('click')` bypasses hit-testing entirely and React's
* delegated handler fires normally — verified: the page navigates to `/alerts`.
*
* This is a workaround for a **product** bug, not for a flaky test.
* `create/edge.spec.ts` CE-09 is the skipped scenario that asserts the fixed
* behaviour; unskipping it and reverting this helper to `.click()` belong in the
* same commit as the fix.
*/
export async function v2ClickDiscard(page: Page): Promise<void> {
await v2DiscardButton(page).dispatchEvent('click');
}
/**
* Whether the side navigation currently overlaps a point — the mechanism behind
* {@link v2ClickDiscard}. Used by CE-09, which asserts the *absence* of that
* overlap and is skipped until the footer is fixed.
*/
export async function elementAtPointClassName(
page: Page,
x: number,
y: number,
): Promise<string> {
return page.evaluate(
([px, py]) => {
const el = document.elementFromPoint(px as number, py as number);
return el ? String(el.className) : '';
},
[x, y],
);
}
/**
* Hover the (disabled) Save button and return the antd tooltip's text — this is
* the only way to read `validateCreateAlertState`'s message, since the button
* cannot be clicked while a message exists.
*/
export async function v2SaveTooltip(page: Page): Promise<string> {
// The tooltip anchors to the wrapper span, not the disabled button: a disabled
// button emits no pointer events, so hovering it directly never opens.
await v2SaveButton(page).locator('xpath=..').hover();
const tooltip = page.locator('.ant-tooltip-inner').first();
await expect(tooltip).toBeVisible();
return (await tooltip.innerText()).trim();
}
/**
* Threshold rows. There is **no** `threshold-item-<id>` testid — the row is a bare
* `className="threshold-item"` (`AlertCondition/ThresholdItem.tsx`), so rows are
* addressed positionally.
*/
export function thresholdRows(page: Page): Locator {
return page.locator('.threshold-item');
}
export function thresholdRow(page: Page, index: number): Locator {
return thresholdRows(page).nth(index);
}
/**
* Pick a notification channel by exact name in one of the two channel selects —
* v2's per-threshold one and v1's single `alert-channel-select`. Both are
* `mode="multiple"` antd selects over the *same* global channel list, so both need
* exactly this sequence; the shared body is why this is one function rather than
* two near-copies.
*
* The list must be **searched**, not scrolled. Channels are global while the
* `alertChannel` fixture is worker-scoped, so a shared stack accumulates one
* channel per worker (plus anything a killed run leaked) and antd virtualises the
* dropdown: measured on this stack, 31 channels render **10** options into the DOM,
* and the wanted one is simply not there. Clicking by name without filtering first
* is therefore not a slow path, it is a missing element — and it was the single
* biggest source of flake in this suite. It fails as a plain click timeout
* ("waiting for locator … .ant-select-item-option …"), which reads like a renamed
* testid rather than a virtualised list.
*/
async function pickChannelByName(
page: Page,
select: Locator,
channelName: string,
): Promise<void> {
const tagsBefore = await selectedTags(select).count();
await select.click();
await expect(select).toHaveClass(/ant-select-open/);
// `fill` on the combobox input rather than `keyboard.type`: the query is a ~30
// character channel name and every keystroke re-runs antd's filter, so typing it
// costs ~2.5 s per pick — CV2-09 makes four of them, which was a quarter of that
// test's 30 s budget. `fill` sets the value in one input event, which is all
// rc-select's search needs.
await select.locator('input[role="combobox"]').fill(channelName);
const dropdown = await ownDropdown(page, select);
await dropdown
.locator('.ant-select-item-option')
.filter({ hasText: channelName })
.first()
.click();
// A multi-select stays open after a pick and its dropdown overlays the controls
// below, which the next interaction would otherwise hit instead.
await page.keyboard.press('Escape');
// Wait for *this* select to report itself closed before returning. antd removes
// `.ant-select-dropdown-hidden` only after the close transition, so a caller that
// immediately opens the next row's select races a still-visible stale list: the
// option lookup then resolves inside the previous row's dropdown and the click
// fails with "element is not stable" followed by "element is not visible".
await expect(select).not.toHaveClass(/ant-select-open/);
// Fail here rather than three assertions later: a silently-missed pick shows up
// as "Save is still disabled", which points at the validator instead of at this.
//
// Counted, not name-matched: v2's select sets `maxTagTextLength={10}`
// (`ThresholdItem.tsx:140`) so its tag reads `e2e-alerts…`, and v1's passes
// `optionLabelProp="label"` to options that carry no `label` prop, so its tag
// renders empty. Neither can ever contain the full channel name. The name itself
// is verified where it actually matters — in the request body (CV2-20, CV1-08).
await expect(selectedTags(select)).toHaveCount(tagsBefore + 1);
}
/** Assign a notification channel to the Nth v2 threshold. */
export async function selectThresholdChannel(
page: Page,
index: number,
channelName: string,
): Promise<void> {
await pickChannelByName(
page,
page.getByTestId('threshold-notification-channel-select').nth(index),
channelName,
);
}
/**
* The currently-open antd dropdown. Scoping option lookups to it matters because
* antd keeps previously-opened dropdowns in the DOM with
* `.ant-select-dropdown-hidden`, so an unscoped `.ant-select-item-option` can
* resolve into a stale list.
*
* Adequate when only one select is ever open on the page. When several selects of
* the *same kind* exist — the per-threshold channel selects — use
* {@link ownDropdown} instead: `-hidden` is applied only after the close
* transition, so "the open dropdown" is briefly ambiguous.
*/
export function openDropdown(page: Page): Locator {
return page.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)');
}
/**
* The dropdown belonging to one specific antd select, resolved through the
* combobox's `aria-controls` → the listbox id it owns.
*
* This is the only unambiguous way to address one of several sibling selects'
* option lists. Filtering on "the visible dropdown" is not enough: with four
* threshold rows, row N's list is still mid-close while row N+1's opens, so the
* option lookup lands in the wrong list and the click fails with "element is not
* stable" and then "element is not visible".
*/
export async function ownDropdown(
page: Page,
select: Locator,
): Promise<Locator> {
const listId = await select
.locator('input[role="combobox"]')
.getAttribute('aria-controls');
if (!listId) {
throw new Error(
'select has no aria-controls — not an antd combobox, or not yet opened',
);
}
return page
.locator('.ant-select-dropdown')
.filter({ has: page.locator(`[id="${listId}"]`) });
}
/**
* An option in the open dropdown, matched on its **exact** label. Substring
* matching is wrong here: `hasText: 'EQUAL TO'` also matches `NOT EQUAL TO`.
*/
export function dropdownOption(page: Page, label: string): Locator {
return openDropdown(page)
.locator('.ant-select-item-option')
.filter({ has: page.getByText(label, { exact: true }) });
}
/**
* Read an antd multi-select's chosen values, for asserting what a threshold row
* ended up pointing at.
*/
export function selectedTags(scope: Locator): Locator {
return scope.locator('.ant-select-selection-item-content');
}
/**
* Add a label through the v2 header editor. The input is a single field with two
* phases — key, then value, each committed with Enter
* (`CreateAlertHeader/LabelsInput.tsx:25-93`) — and a `key:value` string in the
* first phase is accepted as a shortcut. This helper drives the two-phase path
* because that is what a user does.
*/
export async function addAlertLabel(
page: Page,
key: string,
value: string,
): Promise<void> {
await page.getByTestId('alert-add-label-button').click();
const input = page.getByTestId('alert-add-label-input');
await input.fill(key);
await input.press('Enter');
await input.fill(value);
await input.press('Enter');
// Committing a label does *not* close the editor — `isAdding` stays true so a
// user can type several in a row, which means `alert-add-label-button` is still
// unmounted. Escape (with both fields empty) is what closes it, and without this
// a second call to this helper waits forever for the add button.
await input.press('Escape');
await expect(page.getByTestId('alert-add-label-button')).toBeVisible();
}
/**
* The toggle inside an `AdvancedOptionItem` (repeat notifications, send-if-missing,
* enforce-minimum-datapoints). The `Switch` there carries no testid of its own, so
* it is reached through the container's — hence the container testid being the
* documented handle rather than the switch.
*/
export function advancedOptionToggle(
page: Page,
containerTestId: string,
): Locator {
return page.getByTestId(containerTestId).locator('[role="switch"]');
}
// ─── Evaluation window + cadence ───────────────────────────────────────────
/**
* Rolling-window presets (`EvaluationSettings/constants.ts:9-18`) paired with the
* button label each one produces. A value *outside* this set collapses to `custom`
* on load (`utils.tsx:86-96`), which is what makes it a prefill assertion worth
* having: `10m0s` proves the seed was read, `7m0s` proves the fallback fired.
*/
export const EVALUATION_WINDOW_PRESETS = {
'5m0s': 'Last 5 minutes',
'10m0s': 'Last 10 minutes',
'15m0s': 'Last 15 minutes',
'30m0s': 'Last 30 minutes',
'1h0m0s': 'Last 1 hour',
'2h0m0s': 'Last 2 hours',
'4h0m0s': 'Last 4 hours',
} as const;
export function evaluationSettingsButton(page: Page): Locator {
return page.getByTestId('evaluation-settings-button');
}
/**
* Open the evaluation-window popover. It is an antd `Popover`, so its content is
* only in the DOM while open — every option lookup has to come after this.
*/
export async function openEvaluationSettings(page: Page): Promise<void> {
await evaluationSettingsButton(page).click();
await expect(page.locator('.evaluation-window-popover')).toBeVisible();
}
/**
* A popover option. The popover renders two lists from one component, keyed by
* `data-section-id` — `window-type` (Rolling / Cumulative) and `timeframe` — and
* the testid carries both, so `timeframe-option-10m0s` cannot collide with a
* window-type value.
*/
export function evaluationWindowOption(
page: Page,
section: 'window-type' | 'timeframe',
value: string,
): Locator {
return page.getByTestId(`${section}-option-${value}`);
}
/**
* Pick a rolling timeframe and wait for the trigger button to reflect it. The wait
* matters: the popover closes on its own animation, and a spec that immediately
* clicks Save can otherwise post the previous window.
*/
export async function selectEvaluationTimeframe(
page: Page,
value: keyof typeof EVALUATION_WINDOW_PRESETS,
): Promise<void> {
await openEvaluationSettings(page);
await evaluationWindowOption(page, 'timeframe', value).click();
await expect(evaluationSettingsButton(page)).toContainText(
EVALUATION_WINDOW_PRESETS[value],
);
await page.keyboard.press('Escape');
}
/**
* Expand the ADVANCED OPTIONS panel inside the alert-condition section.
*
* antd's `Collapse` renders its panel children lazily, so `evaluation-cadence-*`
* and the two `AdvancedOptionItem` containers do not exist in the DOM at all until
* this runs — an assertion on them without it fails as "not found" rather than as
* "not visible", which reads like a missing testid.
*/
export async function expandAdvancedOptions(page: Page): Promise<void> {
const header = page.getByRole('button', { name: /ADVANCED OPTIONS/i });
if ((await header.getAttribute('aria-expanded')) !== 'true') {
await header.click();
}
await expect(page.getByTestId('evaluation-cadence-input-group')).toBeVisible();
}
/** The cadence duration field — `evaluation.spec.frequency`'s UI half. */
export function evaluationCadenceInput(page: Page): Locator {
return page.getByTestId('evaluation-cadence-duration-input');
}
export function evaluationCadenceUnitSelect(page: Page): Locator {
return page.getByTestId('evaluation-cadence-unit-select');
}
export function labelPill(page: Page, key: string, value: string): Locator {
return page.getByTestId(`label-pill-${key}-${value}`);
}
// ─── v1 classic form ───────────────────────────────────────────────────────
// Every locator below addresses a testid added to the classic form for this suite.
// Before those, each of these was an antd label or role lookup. If one stops
// resolving, the testid was dropped from the component, not renamed here.
/**
* The v1 primary action. Its *label* is mode-dependent — *Create Rule* when
* `isNewRule`, *Save Rule* when editing (`FormAlertRules/index.tsx:970`) — so
* scenarios that care about the mode assert the text; the locator itself does not.
*/
export function v1SaveButton(page: Page): Locator {
return page.getByTestId('alert-save-button');
}
export function v1TestButton(page: Page): Locator {
return page.getByTestId('alert-test-button');
}
/** *Cancel* on create, *Discard* on edit (`FormAlertRules/index.tsx:991-992`). */
export function v1CancelButton(page: Page): Locator {
return page.getByTestId('alert-cancel-button');
}
export function v1NameInput(page: Page): Locator {
return page.getByTestId('alert-name-input-v1');
}
export function v1DescriptionInput(page: Page): Locator {
return page.getByTestId('alert-description-input');
}
export function v1SeveritySelect(page: Page): Locator {
return page.getByTestId('alert-severity-select');
}
/** The four `RuleOptions` controls, in the order the condition sentence reads. */
export function v1OperatorSelect(page: Page): Locator {
return page.getByTestId('alert-threshold-op-select');
}
export function v1MatchTypeSelect(page: Page): Locator {
return page.getByTestId('alert-threshold-match-type-select-v1');
}
export function v1EvalWindowSelect(page: Page): Locator {
return page.getByTestId('alert-eval-window-select');
}
/**
* The threshold value. antd's `InputNumber` spreads unknown props straight onto
* its inner `<input>` (rc-input-number), *not* onto the `.ant-input-number`
* wrapper — so the testid is already the field and looking for an `input`
* underneath it finds nothing.
*/
export function v1ThresholdInput(page: Page): Locator {
return page.getByTestId('alert-threshold-target-input');
}
export function v1BroadcastSwitch(page: Page): Locator {
return page.getByTestId('alert-broadcast-to-all-channels');
}
export function v1ChannelSelect(page: Page): Locator {
return page.getByTestId('alert-channel-select');
}
/**
* Pick a channel in the classic form. Deliberately **not** {@link v1SelectOption}:
* that helper scrolls to nothing and clicks the option by label, which cannot work
* on a virtualised list — see {@link pickChannelByName} for the measurement. The
* other v1 selects (operator, match type, evaluation window, severity) have a
* handful of options each and no search box, so they keep using `v1SelectOption`.
*/
export async function v1SelectChannel(
page: Page,
channelName: string,
): Promise<void> {
await pickChannelByName(page, v1ChannelSelect(page), channelName);
}
/**
* v1 gates every save behind a confirm dialog: the Save button only opens it
* (`FormAlertRules/index.tsx:653-655`), and both the field validation and the
* request live in `saveRule`, which the dialog's OK invokes (`:1007-1010`).
* A spec that clicks Save and waits for a POST without this step will time out —
* and one that expects a *validation error* without it will too.
*/
export function v1ConfirmDialog(page: Page): Locator {
return page.getByTestId('alert-save-confirm-dialog');
}
export async function v1ConfirmSave(page: Page): Promise<void> {
await expect(v1ConfirmDialog(page)).toBeVisible();
await v1ConfirmDialog(page).getByRole('button', { name: 'OK' }).click();
}
/** Dismiss the confirm dialog without saving — the CV1-07 half that must not POST. */
export async function v1CancelSave(page: Page): Promise<void> {
await expect(v1ConfirmDialog(page)).toBeVisible();
await v1ConfirmDialog(page).getByRole('button', { name: 'Cancel' }).click();
await expect(v1ConfirmDialog(page)).toBeHidden();
}
/**
* Pick an option in one of v1's antd selects. Scoped through {@link ownDropdown}
* because the condition sentence puts four selects side by side, and matched on
* the exact label because several share option text (*Above* / *Below* appear in
* both the operator and the match-type lists in the anomaly variant).
*/
export async function v1SelectOption(
page: Page,
select: Locator,
label: string,
): Promise<void> {
await select.click();
const dropdown = await ownDropdown(page, select);
await dropdown
.locator('.ant-select-item-option')
.filter({ has: page.getByText(label, { exact: true }) })
.first()
.click();
// The channel select is `mode="multiple"` (`ChannelSelect/index.tsx:91`), so it
// stays open after a pick and its list overlays the controls below — which the
// next interaction would hit instead of its target. Escape closes it; on the
// single selects it is a no-op.
await page.keyboard.press('Escape');
await expect(select).not.toHaveClass(/ant-select-open/);
}
/** Switch the v1 query section to another query mode (`QuerySection.tsx`). */
export async function v1SelectQueryMode(
page: Page,
mode: 'query-builder' | 'promql' | 'clickhouse',
): Promise<void> {
await page.getByTestId(`${mode}-tab`).click();
}
// ─── SEED-CH1: a stack with no notification channels ───────────────────────
/**
* Route-stub `GET /api/v1/channels` to an empty list for this page only.
*
* This is the **one** place the alerts suite mocks the network, and it is a
* deliberate exception to the standing no-stubbing rule. The justification: zero
* channels is a real product state — every fresh install has it — and it is the
* only state that reaches the `disabled` broadcast switch and
* the empty-channel dropdown content. It cannot be produced server-side, because
* `alertChannel` is worker-scoped and parallel workers share one stack, so
* deleting the channel would break every other scenario running at that moment.
*
* Both forms read the same endpoint through `api/channels/getAll`, so one stub
* covers v1 and v2.
*/
export async function stubNoChannels(page: Page): Promise<void> {
await page.route('**/api/v1/channels', async (route) => {
if (route.request().method() !== 'GET') {
await route.fallback();
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ status: 'success', data: [] }),
});
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,34 +1,123 @@
import type { Browser, BrowserContext } from '@playwright/test';
import type { Browser, BrowserContext, Page } from '@playwright/test';
export type User = { email: string; password: string };
/** Default user — admin from the pytest bootstrap (.env.local) or staging .env. */
export const ADMIN: User = {
email: process.env.SIGNOZ_E2E_USERNAME!,
password: process.env.SIGNOZ_E2E_PASSWORD!,
};
/**
* Build a fresh authenticated `BrowserContext` via UI login. Used by suite
* hooks (`test.beforeAll` / `test.afterAll`), where the test-scoped
* `authedPage` fixture from `fixtures/auth.ts` is not reachable.
* `browser.newContext()` only inherits `use.baseURL` while a *test* is in
* scope. Worker-scoped fixtures (and their teardown) run outside that, where a
* relative `page.goto('/login')` fails with "Cannot navigate to invalid URL" —
* so pass it explicitly whenever we know it. Left empty when the var is unset
* so the config's staging default still applies inside a test.
*/
const contextDefaults: { baseURL?: string } = process.env.SIGNOZ_E2E_BASE_URL
? { baseURL: process.env.SIGNOZ_E2E_BASE_URL }
: {};
// Per-worker storageState cache. One UI login per unique user per worker
// process, shared by everything in that worker: the `authedPage` fixture, the
// worker-scoped seed fixtures, and their teardown. Promise-valued so concurrent
// callers await the same in-flight login rather than racing several of their
// own. Held in memory only — no .auth/ dir, no JSON on disk.
//
// This cache is why `newAdminContext` is cheap. It used to log in through the
// UI on every call, and the alerts fixtures call it a dozen-plus times per
// worker (channel, rule list, five history seeds, one per owned rule, plus a
// teardown for each) — a couple of seconds each, paid over and over for a
// session that never changes.
type StorageState = Awaited<ReturnType<BrowserContext['storageState']>>;
const storageByUser = new Map<string, Promise<StorageState>>();
async function login(page: Page, user: User): Promise<void> {
if (!user.email || !user.password) {
throw new Error(
'User credentials missing. Set SIGNOZ_E2E_USERNAME / SIGNOZ_E2E_PASSWORD ' +
'(pytest bootstrap writes them to .env.local), or pass a User via test.use({ user: ... }).',
);
}
await page.goto('/login?password=Y');
await page.getByTestId('email').fill(user.email);
await page.getByTestId('initiate_login').click();
await page.getByTestId('password').fill(user.password);
await page.getByRole('button', { name: 'Sign in with Password' }).click();
// Post-login lands somewhere different depending on whether the org is
// licensed (onboarding flow on ENTERPRISE) or not (legacy "Hello there"
// welcome). Wait for URL to move off /login — whichever page follows
// is fine, each spec navigates to the feature under test anyway.
await page.waitForURL((url) => !url.pathname.startsWith('/login'));
}
// Pin the nav suite-wide: unpinned it flies out on hover and overlays content.
// Server-side pref, so set once per user at login.
async function pinSidenav(page: Page): Promise<void> {
const token = await page.evaluate(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
);
const res = await page.request.put('/api/v1/user/preferences/sidenav_pinned', {
data: { value: true },
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
const text = await res.text();
// Two workers logging in at the same moment both insert the preference and
// the loser gets a 500 on `uq_user_preference_name_user_id`. The write it
// lost to set the same value, so the preference *is* pinned — treat the
// duplicate as success rather than failing an unrelated test.
if (text.includes('uq_user_preference_name_user_id')) {
return;
}
throw new Error(
`PUT /api/v1/user/preferences/sidenav_pinned ${res.status()}: ${text}`,
);
}
}
/**
* Authenticated storage state for `user`, logging in once per worker. Callers
* hand the result to `browser.newContext({ storageState })`.
*/
export function storageStateFor(
browser: Browser,
user: User = ADMIN,
): Promise<StorageState> {
const cached = storageByUser.get(user.email);
if (cached) {
return cached;
}
const task = (async () => {
const ctx = await browser.newContext(contextDefaults);
const page = await ctx.newPage();
await login(page, user);
await pinSidenav(page);
const state = await ctx.storageState();
await ctx.close();
return state;
})();
storageByUser.set(user.email, task);
return task;
}
/**
* Build an authenticated admin `BrowserContext`. Used by suite hooks
* (`test.beforeAll` / `test.afterAll`) and worker-scoped fixtures, where the
* test-scoped `authedPage` fixture from `fixtures/auth.ts` is not reachable.
*
* Each call performs one fresh login (~1s). The per-worker storageState
* cache in `fixtures/auth.ts` is intentionally not shared here — keeping
* this helper standalone avoids coupling suite hooks to the fixture's
* private cache.
* Reuses this worker's cached session, so only the first call in a worker pays
* for a login. The caller owns the context and must close it.
*/
export async function newAdminContext(
browser: Browser,
): Promise<BrowserContext> {
const email = process.env.SIGNOZ_E2E_USERNAME;
const password = process.env.SIGNOZ_E2E_PASSWORD;
if (!email || !password) {
throw new Error(
'SIGNOZ_E2E_USERNAME / SIGNOZ_E2E_PASSWORD must be set ' +
'(pytest bootstrap writes them to .env.local).',
);
}
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto('/login?password=Y');
await page.getByTestId('email').fill(email);
await page.getByTestId('initiate_login').click();
await page.getByTestId('password').fill(password);
await page.getByRole('button', { name: 'Sign in with Password' }).click();
await page.waitForURL((url) => !url.pathname.startsWith('/login'));
await page.close();
return ctx;
return browser.newContext({
...contextDefaults,
storageState: await storageStateFor(browser, ADMIN),
});
}

View File

@@ -1,4 +1,4 @@
import type { Page } from '@playwright/test';
import type { Page, Request } from '@playwright/test';
// Shared helpers used across feature-specific helper modules (dashboards,
// trace-details, …). Keep this to genuinely cross-feature utilities.
@@ -18,6 +18,108 @@ export function seederUrl(): string {
return url;
}
// ─── Console / network noise ──────────────────────────────────────────────
// Requests the bootstrap stack always fails, on every page, for reasons that
// have nothing to do with the feature under test. Keep this list tiny and give
// every entry a reason — it is a deny-list of *environment* noise, never of real
// application errors.
const HARNESS_FAILING_REQUESTS = [
// Zeus is a WireMock stub with no /api/v2/zeus/hosts mapping, so the app
// shell's workspace-URL lookup 404s on every page load. It reaches the console
// three ways: the resource-load error, the AxiosError, and the literal `any`
// that `api/ErrorResponseHandler.ts`'s fallback branch logs.
'/api/v2/zeus/hosts',
// The app shell polls GitHub for the latest release. Unauthenticated calls
// from CI/dev machines get rate-limited (403), which has nothing to do with
// the page under test.
'api.github.com',
];
// The console side of {@link HARNESS_FAILING_REQUESTS}. Browsers log a
// resource-load error without the URL, so these have to be matched on text —
// which is why the URL list above is the precise half of the check.
const HARNESS_CONSOLE_NOISE = [
'Failed to load resource: the server responded with a status of 404 (Not Found)',
'Failed to load resource: the server responded with a status of 403',
'Request failed with status code 404',
'client never received a response, or request never left',
'any',
];
export interface ConsoleWatch {
/** Console `error` entries and uncaught page errors, harness noise removed. */
errors: string[];
/** `"<status> <method> <url>"` for every 4xx/5xx, harness noise removed. */
failedResponses: string[];
}
/**
* Watch a page for console errors and failed requests. Call **before** the first
* navigation; the returned object fills in as the page runs, so assert on it at
* the end of the scenario.
*
* Console text alone is a weak signal (the harness's Zeus 404 produces three
* generic-looking entries), so the failed-response list is the precise half:
* text matching is deliberately loose while the URL check stays strict.
*/
export function watchConsole(
page: Page,
/**
* Extra substrings to ignore. Use this — with a comment naming the defect —
* for a *known application* bug that is out of the spec's scope, so the rest
* of the console assertion keeps its value instead of being deleted.
*/
options: { ignore?: string[] } = {},
): ConsoleWatch {
const watch: ConsoleWatch = { errors: [], failedResponses: [] };
const noise = [...HARNESS_CONSOLE_NOISE, ...(options.ignore ?? [])];
const isNoise = (text: string): boolean =>
noise.some((entry) => text.includes(entry));
page.on('console', (msg) => {
if (msg.type() === 'error' && !isNoise(msg.text())) {
watch.errors.push(msg.text());
}
});
page.on('pageerror', (err) => {
if (!isNoise(String(err))) {
watch.errors.push(String(err));
}
});
page.on('response', (res) => {
if (res.status() < 400) {
return;
}
const url = res.url();
if (HARNESS_FAILING_REQUESTS.some((entry) => url.includes(entry))) {
return;
}
watch.failedResponses.push(
`${res.status()} ${res.request().method()} ${url}`,
);
});
return watch;
}
// ─── Network capture ──────────────────────────────────────────────────────
/**
* Every request the page issues from now on. Call **before** the first
* navigation — the returned array fills in as the page runs, so filter it at the
* end of the scenario ("endpoint called exactly once", "no legacy route used").
*/
export function collectRequests(page: Page): Request[] {
const requests: Request[] = [];
page.on('request', (request) => requests.push(request));
return requests;
}
/** A request's URL, parsed — the readable way to reach `searchParams`. */
export function requestUrl(request: Request): URL {
return new URL(request.url());
}
// ─── Auth ────────────────────────────────────────────────────────────────
// Read the app JWT from the context's stored auth state. No navigation needed:

View File

@@ -5,7 +5,12 @@
"main": "index.js",
"scripts": {
"preinstall": "npx only-allow pnpm",
"env:start": "cd .. && uv run pytest --basetemp=./tmp/ -vv --reuse --rebuild --capture=no --with-web e2e/bootstrap/setup.py::test_setup",
"env:stop": "cd .. && uv run pytest --basetemp=./tmp/ -vv --teardown --capture=no e2e/bootstrap/setup.py::test_teardown",
"env:clean": "rm -rf ../tmp ../.pytest_cache .env.local artifacts && echo 'Cleaned. Run docker container prune if needed.'",
"test": "playwright test",
"test:local": "pnpm env:start && pnpm test",
"test:all": "playwright test",
"test:staging": "SIGNOZ_E2E_BASE_URL=https://app.us.staging.signoz.cloud playwright test",
"test:ui": "playwright test --ui",
"test:headed": "playwright test --headed",

View File

@@ -1,15 +1,37 @@
import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
import fs from 'fs';
import os from 'os';
import path from 'path';
// .env holds user-provided defaults (staging creds).
// .env.local is written by tests/e2e/bootstrap/setup.py when the pytest
// lifecycle brings the backend up locally; override=true so local-backend
// coordinates win over any stale .env values. Subprocess-injected env
// (e.g. when pytest shells out to `pnpm test`) still takes priority —
// dotenv doesn't touch vars that are already set in process.env.
dotenv.config({ path: path.resolve(__dirname, '.env') });
dotenv.config({ path: path.resolve(__dirname, '.env.local'), override: true });
// Precedence, lowest to highest:
// .env — user-provided defaults (staging creds)
// .env.local — written by tests/e2e/bootstrap/setup.py when the pytest
// lifecycle brings the backend up locally, so it must win over
// any stale .env value
// the real environment — anything the caller exported on purpose, e.g.
// `SIGNOZ_E2E_BASE_URL=http://127.0.0.1:3301 pnpm test` to run
// against a locally served frontend, or the vars pytest injects
// when it shells out to `pnpm test`.
//
// This is deliberately *not* `dotenv.config({ override: true })`: that flag
// makes the file beat process.env, so an exported SIGNOZ_E2E_BASE_URL was
// silently discarded and every run went to whatever .env.local pointed at.
// Parsing by hand is the only way to get ".env.local beats .env" without also
// getting ".env.local beats the caller".
const exported = new Set(Object.keys(process.env));
for (const file of ['.env', '.env.local']) {
const filePath = path.resolve(__dirname, file);
if (!fs.existsSync(filePath)) {
continue;
}
const parsed = dotenv.parse(fs.readFileSync(filePath));
for (const [key, value] of Object.entries(parsed)) {
if (!exported.has(key)) {
process.env[key] = value;
}
}
}
export default defineConfig({
testDir: './tests',
@@ -33,8 +55,17 @@ export default defineConfig({
// Retry on CI only
retries: process.env.CI ? 2 : 0,
// Workers
workers: process.env.CI ? 2 : undefined,
// Workers. Playwright's local default is `cpus / 2`, which on a 32-core box is
// 16 — and 16 is strictly worse than 6 here, because every worker's browser
// shares one SigNoz container: measured on `tests/alerts/{create,edit}` at
// `--repeat-each=3` (224 tests), 16 workers took 128 s with 3 failures while 6
// took 119 s with none. Past ~6 the extra workers only add queueing, which shows
// up as 4-6 s app mounts and save requests that outlive the test timeout — i.e.
// as flakes that look like product bugs. Capped rather than fixed at 6 so a
// 4-core laptop still gets `cpus / 2`.
workers: process.env.CI
? 2
: Math.max(1, Math.min(6, Math.floor(os.cpus().length / 2))),
// The SPA hydrates slowly on CI, so the 5s expect default fires mid-load.
expect: { timeout: 15_000 },

View File

@@ -1,67 +0,0 @@
import { expect, test } from '../../fixtures/auth';
import {
createEmailChannelViaApi,
createThresholdAlertViaApi,
deleteAlertViaApi,
deleteChannelViaApi,
gotoAlertOverview,
} from '../../helpers/alerts';
import { newAdminContext } from '../../helpers/auth';
test('TC-01 alerts page — tabs render', async ({ authedPage: page }) => {
await page.goto('/alerts');
await expect(page.getByRole('tab', { name: /alert rules/i })).toBeVisible();
await expect(page.getByRole('tab', { name: /configuration/i })).toBeVisible();
});
test.describe('alerts — threshold persists on edit-page load', () => {
const TARGET = 245;
let ruleId: string;
let channelId: string;
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const stamp = Date.now();
const channel = await createEmailChannelViaApi(
page,
`e2e-threshold-persistence-ch-${stamp}`,
);
channelId = channel.id;
ruleId = await createThresholdAlertViaApi(page, {
name: `e2e-threshold-persistence-${stamp}`,
target: TARGET,
channels: [channel.name],
});
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
if (ruleId) {
await deleteAlertViaApi(page, ruleId);
}
if (channelId) {
await deleteChannelViaApi(page, channelId);
}
} finally {
await ctx.close();
}
});
test('TC-02 edit page shows the saved threshold value', async ({
authedPage: page,
}) => {
await gotoAlertOverview(page, ruleId);
// The condition editor should show the persisted target once loaded.
await expect(page.getByTestId('threshold-value-input')).toHaveValue(
String(TARGET),
);
});
});

View File

@@ -0,0 +1,45 @@
import { expect, test } from '../../../fixtures/auth';
import {
createEmailChannelViaApi,
deleteChannelViaApi,
} from '../../../helpers/alerts';
test.describe('Notification channels — edit', () => {
// Regression guard for engineering-pod#5509: after channels moved from
// /settings/channels to /alerts/channels, the edit container still parsed the
// channel id out of the old pathname, so every save PUT went to an empty id
// and no edit ever persisted. Nothing in the suite navigated into the edit
// page, so the whole class of "edits silently do nothing" was invisible.
test('NC-01 an edited recipient persists after reload', async ({
authedPage: page,
}) => {
// The channel *name* is read-only on the edit page, so the editable field
// this exercises is the email recipient.
const name = `e2e-nc-${Date.now()}`;
const updatedTo = 'e2e-updated@signoz.test';
const { id } = await createEmailChannelViaApi(page, name);
try {
await page.goto(`/alerts/channels/edit/${id}`);
const toBox = page.getByRole('textbox', { name: 'To' });
await expect(toBox).toHaveValue('e2e@signoz.test');
await toBox.fill(updatedTo);
await Promise.all([
page.waitForResponse(
(r) =>
r.url().includes('/api/v1/channels') && r.request().method() === 'PUT',
),
page.getByTestId('save-channel-button').click(),
]);
await page.goto(`/alerts/channels/edit/${id}`);
await expect(page.getByRole('textbox', { name: 'To' })).toHaveValue(
updatedTo,
);
} finally {
await deleteChannelViaApi(page, id);
}
});
});

View File

@@ -0,0 +1,137 @@
import { expect, test } from '../../../fixtures/alert-rules';
import {
AlertType,
elementAtPointClassName,
gotoCreateAlertV1,
gotoCreateAlertV2,
selectThresholdChannel,
v1SaveButton,
v2DiscardButton,
v2SaveButton,
} from '../../../helpers/alert-forms';
import {
createEmailChannelViaApi,
deleteChannelViaApi,
gotoAlertDetails,
gotoAlertOverview,
} from '../../../helpers/alerts';
import { watchConsole } from '../../../helpers/common';
// CE-* — errors and edges that are not specific to one form. CE-03 lives in
// `edit/edge.spec.ts`; CE-05/CE-06 are v1-only validation and live with the v1
// specs.
test.describe('Alert create — errors and edges', () => {
test('CE-04 a server-side rejection opens the error modal and keeps the draft', async ({
authedPage: page,
}) => {
// A duplicate rule name is *not* rejected — the API happily creates two rules
// with the same `alert`. A missing channel is, with
// `400 invalid_input: channels: the following channels do not exist`.
//
// So the 4xx comes from a real race rather than a stub: the form is filled with
// a channel that exists, and the channel is deleted behind its back before the
// save. Nothing about the response is faked.
const channel = await createEmailChannelViaApi(
page,
`e2e-ce04-ch-${Date.now()}`,
);
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
const name = `e2e-ce04-${Date.now()}`;
await page.getByTestId('alert-name-input').fill(name);
await selectThresholdChannel(page, 0, channel.name);
await deleteChannelViaApi(page, channel.id);
const [response] = await Promise.all([
page.waitForResponse(
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
),
v2SaveButton(page).click(),
]);
expect(response.status()).toBe(400);
// Both forms funnel every save error into the shared error modal, which is
// antd's wrapped in `.error-modal__wrap`.
await expect(page.locator('.error-modal__wrap')).toBeVisible();
await expect(page.getByText(/do not exist/)).toBeVisible();
// A rejected save must not navigate, and must not lose what the user typed.
expect(new URL(page.url()).pathname).toBe('/alerts/new');
await page.getByTestId('close-button').click();
await expect(page.locator('.error-modal__wrap')).toBeHidden();
await expect(page.getByTestId('alert-name-input')).toHaveValue(name);
});
test('CE-07 none of the four builder mounts logs a console error', async ({
authedPage: page,
ownedRules,
}) => {
const watch = watchConsole(page);
// v2 create.
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
// v1 create. Metrics-based on purpose: it is the only alert type whose classic
// form renders the detection-method step and the PromQL tab, i.e. the most code.
await gotoCreateAlertV1(page, { alertType: AlertType.METRICS });
// v2 edit.
const v2Rule = await ownedRules.threshold(`e2e-ce07-v2-${Date.now()}`);
await gotoAlertOverview(page, v2Rule);
// v1 edit. `gotoAlertOverview` is wrong here — it waits for
// `threshold-value-input`, which only the v2 builder renders — so the shell-level
// wait is used and the classic form is asserted directly.
const v1Rule = await ownedRules.logs({
name: `e2e-ce07-v1-${Date.now()}`,
schema: 'v1',
});
await gotoAlertDetails(page, v1Rule);
await expect(v1SaveButton(page)).toBeVisible();
expect(watch.errors).toEqual([]);
});
// TODO: enable once the covered-Discard bug is fixed, and revert
// `v2ClickDiscard` (`helpers/alert-forms.ts`) to a plain `.click()` in the same
// commit — CV2-22 and EV2-11 both go through it.
//
// 🐞 **A user cannot discard an alert draft.** The footer is
// `position: fixed; left: 63px` — the *collapsed* nav rail width — while the side
// navigation is 240px wide whenever expanded, which is the default (pinned for a
// fresh admin) and also happens transiently on hover when unpinned. Discard is the
// footer's left-most control, so the nav sits on top of it and wins the stacking
// contest despite the footer's `z-index: 1000`.
//
// Observed live: `document.elementFromPoint` at the button's centre returns the
// nav's `.nav-item-data`, and `page.click()` fails with
// *"div.nav-item-data … intercepts pointer events"*. `{ force: true }` does not
// help — it skips the actionability wait but still delivers a real mouse event at
// those coordinates. Only `dispatchEvent('click')` gets through, which proves the
// handler is fine and the defect is purely pointer delivery.
//
// Fix is one of: make the footer's `left` follow the nav's actual width, move
// Discard to the right-hand group, or lift the footer out of the nav's stacking
// context.
test.skip('CE-09 the v2 Discard button is clickable', async ({
authedPage: page,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
// Nothing from the side navigation may sit over the button's centre.
const box = await v2DiscardButton(page).boundingBox();
expect(box).not.toBeNull();
const covering = await elementAtPointClassName(
page,
box!.x + box!.width / 2,
box!.y + box!.height / 2,
);
expect(covering).not.toMatch(/nav-item/);
// And the consequence that matters: a real click lands and leaves the form.
await v2DiscardButton(page).click({ timeout: 3_000 });
await page.waitForURL(/\/alerts(\?|$)/);
expect(new URL(page.url()).pathname).toBe('/alerts');
});
});

View File

@@ -0,0 +1,264 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/alert-rules';
import {
ALERTS_NEW_PATH,
AlertType,
type AlertTypeValue,
evaluationSettingsButton,
gotoCreateAlertV2,
RuleType,
thresholdRows,
ThresholdMatchType,
ThresholdOperator,
} from '../../../helpers/alert-forms';
import { gotoAlertOverview } from '../../../helpers/alerts';
// CD-* — deep-link prefill.
//
// The contract is producer-agnostic (`context/resolveUrlAlertPrefill.ts`), but the
// three producers do **not** write the same params: dashboards
// (`buildAlertUrl`) and the explorer only ever emit query/panel params, while
// metering (`MultiIngestionSettings`) is the sole producer of `ruleName`,
// `yAxisUnit` and `evaluationWindowPreset`. CD-04 and CD-05 therefore drive the
// *metering* URL shape — aiming them at a dashboard URL would test a link nobody
// generates.
/**
* A `compositeQuery` param harvested from the app itself.
*
* Hand-writing the v5 envelope would be a second, drifting copy of the query
* builder's serialiser — the thing these scenarios are *reading*, not testing. So
* the builder is opened once, allowed to serialise its own default query into the
* URL, and that exact value is reused as the deep link.
*/
async function harvestCompositeQuery(
page: Page,
alertType: AlertTypeValue,
): Promise<string> {
await gotoCreateAlertV2(page, { alertType });
const value = new URL(page.url()).searchParams.get('compositeQuery');
if (!value) {
throw new Error(
'the builder did not serialise a compositeQuery into the URL',
);
}
return value;
}
/** `Threshold` as `context/types.ts` declares it — the shape the URL param carries. */
function urlThreshold(
overrides: Record<string, unknown>,
): Record<string, unknown> {
return {
id: 'e2e-url-threshold',
label: 'from-url',
thresholdValue: 0,
recoveryThresholdValue: null,
unit: '',
channels: [],
color: '#e5484d',
...overrides,
};
}
function prefillUrl(params: Record<string, string>): string {
return `${ALERTS_NEW_PATH}?${new URLSearchParams(params).toString()}`;
}
test.describe('Alert create — deep-link prefill', () => {
test('CD-01 a compositeQuery alone selects the alert type', async ({
authedPage: page,
}) => {
const compositeQuery = await harvestCompositeQuery(page, AlertType.LOGS);
// No `alertType` and no `ruleType` in this URL: both come from the query's data
// source through `ALERT_TYPE_VS_SOURCE_MAPPING`. The presence of
// `compositeQuery` is also what skips the type-selection page, so this one
// param decides two things at once.
await page.goto(prefillUrl({ compositeQuery }));
await expect(page.getByTestId('alert-name-input')).toBeVisible();
// Asserted on the *rendered* signal tab, not on the URL: the mapping only feeds
// the memo that picks the form — it does **not** write `alertType` back into the
// query string. So a spec waiting for `alertType=LOGS_BASED_ALERT` in the URL
// waits forever.
await expect(
page.locator('.list-view-tab.active-tab', {
has: page.getByTestId('logs-view'),
}),
).toHaveCount(1);
expect(new URL(page.url()).searchParams.get('alertType')).toBeNull();
// A stale `compositeQuery` silently bypasses card selection, so the cards must
// not be on screen.
await expect(page.locator('[data-testid^="alert-type-card-"]')).toHaveCount(
0,
);
});
test('CD-02 thresholds prefill from JSON, and a malformed value falls back', async ({
authedPage: page,
}) => {
const base = {
alertType: AlertType.LOGS,
ruleType: RuleType.THRESHOLD,
};
await page.goto(
prefillUrl({
...base,
thresholds: JSON.stringify([
urlThreshold({ label: 'page-me', thresholdValue: 42 }),
urlThreshold({
id: 'e2e-url-threshold-2',
label: 'warn-me',
thresholdValue: 7,
}),
]),
}),
);
await expect(thresholdRows(page)).toHaveCount(2);
const names = page.getByTestId('threshold-name-input');
await expect(names.nth(0)).toHaveValue('page-me');
await expect(names.nth(1)).toHaveValue('warn-me');
await expect(page.getByTestId('threshold-value-input').nth(0)).toHaveValue(
'42',
);
// A malformed value is swallowed by `parseThresholds` and the form falls back to
// its own single `critical` row. That path also writes
// `console.error('Error parsing thresholds from URL:', …)`, which is why this
// scenario must never be paired with CE-07's clean-console assertion.
await page.goto(prefillUrl({ ...base, thresholds: 'not-json-at-all' }));
await expect(thresholdRows(page)).toHaveCount(1);
await expect(page.getByTestId('threshold-name-input')).toHaveValue(
'critical',
);
});
test('CD-03 matchType and compareOp aliases normalise to the enum', async ({
authedPage: page,
}) => {
// `avg` and `<` are aliases the *backend* accepts (`normalizeMatchType` /
// `normalizeOperator` mirror `pkg/types/ruletypes/{match,compare}.go`), not values
// the UI ever writes — so a producer or a hand-edited link can carry them.
await page.goto(
prefillUrl({
alertType: AlertType.LOGS,
ruleType: RuleType.THRESHOLD,
matchType: 'avg',
compareOp: '<',
}),
);
await expect(
page.getByTestId('alert-threshold-match-type-select'),
).toContainText(ThresholdMatchType.ON_AVERAGE.label);
await expect(
page.getByTestId('alert-threshold-operator-select'),
).toContainText(ThresholdOperator.BELOW.label);
});
test('CD-04 ruleName and yAxisUnit apply once and never stomp an edit', async ({
authedPage: page,
}) => {
const compositeQuery = await harvestCompositeQuery(page, AlertType.METRICS);
const ruleName =
'[ingestion][logs] e2e key has exceeded daily ingestion limit';
// The metering URL shape, verbatim from `MultiIngestionSettings.tsx`.
await page.goto(
prefillUrl({
compositeQuery,
thresholds: JSON.stringify([
urlThreshold({ label: 'critical', thresholdValue: 100, unit: 'bytes' }),
]),
ruleName,
yAxisUnit: 'bytes',
matchType: ThresholdMatchType.IN_TOTAL.value,
evaluationWindowPreset: 'meter',
}),
);
await expect(page.getByTestId('alert-name-input')).toHaveValue(ruleName);
// `yAxisUnit` is what makes the per-threshold unit select usable at all — with no
// unit the control is permanently disabled (CV2-12).
await expect(
page.getByTestId('threshold-unit-select').first(),
).not.toHaveClass(/ant-select-disabled/);
// Now the half the `ruleNameAppliedRef` / `yAxisUnitAppliedRef` guards exist for.
// The prefill effect re-runs on *every* change to location.search, and the query
// builder rewrites it constantly — without the refs, a hand-edited name would be
// silently reverted to the URL's the next time that happened.
const edited = 'e2e-cd-04-renamed-by-hand';
await page.getByTestId('alert-name-input').fill(edited);
// Switching the signal tab is a real user action that rewrites the URL *and*
// changes `alertType`, which is also in the effect's dependency list.
await page.getByTestId('logs-view').click();
await page.waitForURL(/alertType=LOGS_BASED_ALERT/);
await expect(page.getByTestId('alert-name-input')).toHaveValue(edited);
});
test('CD-05 evaluationWindowPreset=meter switches to the cumulative daily window', async ({
authedPage: page,
}) => {
const compositeQuery = await harvestCompositeQuery(page, AlertType.METRICS);
await page.goto(
prefillUrl({
compositeQuery,
matchType: ThresholdMatchType.IN_TOTAL.value,
evaluationWindowPreset: 'meter',
}),
);
// `SET_INITIAL_STATE_FOR_METER` is a *cumulative* window starting at midnight
// UTC — not one of the rolling presets — so the trigger button's whole text
// changes shape, type included.
await expect(evaluationSettingsButton(page)).toContainText('Cumulative');
await expect(evaluationSettingsButton(page)).toContainText(
'Current day, starting from 00:00:00 (UTC)',
);
});
test('CD-06 URL prefill is ignored in edit mode', async ({
authedPage: page,
ownedRules,
}) => {
const ruleId = await ownedRules.threshold(`e2e-cd-06-${Date.now()}`, {
target: 42,
});
await gotoAlertOverview(page, ruleId);
// Append a prefill param to the *edit* URL, which is what a stale link or a copied
// query string produces in practice.
await page.goto(
`${new URL(page.url()).pathname}?${new URLSearchParams({
ruleId,
thresholds: JSON.stringify([
urlThreshold({ label: 'from-url', thresholdValue: 999 }),
]),
}).toString()}`,
);
await expect(page.getByTestId('threshold-value-input').first()).toBeVisible();
// The effect early-returns in edit mode. Without that return the `RESET` at the
// top of the block would wipe the loaded rule's thresholds every time the query
// builder rewrote location.search.
await expect(thresholdRows(page)).toHaveCount(1);
await expect(page.getByTestId('threshold-name-input')).toHaveValue(
'critical',
);
await expect(page.getByTestId('threshold-value-input')).toHaveValue('42');
await expect(page.getByTestId('alert-name-input')).not.toHaveValue(
'from-url',
);
});
});

View File

@@ -0,0 +1,168 @@
import { expect, test } from '../../../fixtures/alert-rules';
import {
AlertListTab,
AlertType,
alertTypeCard,
expectAlertTypeCardSet,
createAlertUrl,
gotoAlertTypeSelection,
gotoCreateAlertV1,
gotoCreateAlertV2,
hasAnomalyAlertTypeCard,
RuleType,
STOCK_ALERT_TYPE_CARDS,
v1SaveButton,
} from '../../../helpers/alert-forms';
// CS-01 … CS-08 — the create *shell*: type selection, how a card click writes the
// URL, the breadcrumb, the surrounding alerts tab bar, and the two ways to reach
// the classic form. Nothing here saves a rule, so no scenario needs a channel.
test.describe('Alert create — shell & type selection', () => {
test('CS-01 bare /alerts/new lists exactly the expected alert-type cards', async ({
authedPage: page,
}) => {
await gotoAlertTypeSelection(page);
await expect(page.getByText('Choose a type for the alert')).toBeVisible();
// The four stock signals are unconditional; anomaly is added only when
// ANOMALY_DETECTION is active. `expectAlertTypeCardSet` pins the exact set *and
// order* for whichever branch applies, so adding a sixth signal still fails.
for (const type of STOCK_ALERT_TYPE_CARDS) {
await expect(alertTypeCard(page, type)).toBeVisible();
}
await expectAlertTypeCardSet(page);
});
test('CS-02 picking a card writes both params and mounts the v2 builder', async ({
authedPage: page,
}) => {
await gotoAlertTypeSelection(page);
await alertTypeCard(page, AlertType.METRICS).click();
await expect(page.getByTestId('alert-name-input')).toBeVisible();
const params = new URL(page.url()).searchParams;
expect(params.get('ruleType')).toBe(RuleType.THRESHOLD);
expect(params.get('alertType')).toBe(AlertType.METRICS);
});
test('CS-03 the anomaly card rewrites the rule type, not the alert type', async ({
authedPage: page,
}) => {
await gotoAlertTypeSelection(page);
test.skip(
!(await hasAnomalyAlertTypeCard(page)),
'ANOMALY_DETECTION feature flag is inactive on this stack (see CS-01)',
);
await alertTypeCard(page, AlertType.ANOMALY).click();
const params = new URL(page.url()).searchParams;
expect(params.get('ruleType')).toBe(RuleType.ANOMALY);
// The card's own value is deliberately *not* written: `handleSelectType`
// forces the metrics alert type for anomaly rules, and the rendered form
// resolves back to anomaly from `ruleType` alone.
expect(params.get('alertType')).toBe(AlertType.METRICS);
});
test('CS-04 modifier-clicking a card opens the builder in a new tab', async ({
authedPage: page,
}) => {
await gotoAlertTypeSelection(page);
const [newTab] = await Promise.all([
page.context().waitForEvent('page'),
alertTypeCard(page, AlertType.METRICS).click({
modifiers: ['ControlOrMeta'],
}),
]);
await newTab.waitForLoadState();
const params = new URL(newTab.url()).searchParams;
expect(params.get('ruleType')).toBe(RuleType.THRESHOLD);
expect(params.get('alertType')).toBe(AlertType.METRICS);
// A modifier click that *also* navigates in place is the regression this half
// guards.
await expect(alertTypeCard(page, AlertType.METRICS)).toBeVisible();
await newTab.close();
});
test('CS-05 breadcrumb gains a third crumb after a type is picked', async ({
authedPage: page,
}) => {
await gotoAlertTypeSelection(page);
const breadcrumb = page.locator('.ant-breadcrumb');
await expect(breadcrumb.getByText('Alert Rules')).toBeVisible();
await expect(breadcrumb.getByText('Select Alert Type')).toBeVisible();
await alertTypeCard(page, AlertType.METRICS).click();
await expect(page.getByTestId('alert-name-input')).toBeVisible();
await expect(breadcrumb.getByText('Metric-Based Alert')).toBeVisible();
// The middle crumb is now navigable and goes back to bare /alerts/new.
await breadcrumb.getByRole('button', { name: 'Select Alert Type' }).click();
await expect(alertTypeCard(page, AlertType.METRICS)).toBeVisible();
expect(new URL(page.url()).searchParams.get('alertType')).toBeNull();
});
test('CS-06 create renders inside the Alert Rules tab and leaving drops subTab/search', async ({
authedPage: page,
}) => {
// `subTab` and `search` are seeded here precisely so their removal is
// observable — `handleTabChange` deletes them while keeping everything else.
await page.goto(
createAlertUrl({
alertType: AlertType.LOGS,
params: { subTab: 'Alert Rules', search: 'stale' },
}),
);
await expect(page.getByTestId('alert-name-input')).toBeVisible();
await expect(page.getByRole('tab', { name: /Alert Rules/ })).toBeVisible();
await page.getByRole('tab', { name: /Triggered Alerts/ }).click();
await page.waitForURL(/\/alerts\?/);
const params = new URL(page.url()).searchParams;
// The param carries the space-less enum value, not the tab's visible label.
expect(params.get('tab')).toBe(AlertListTab.TRIGGERED_ALERTS);
expect(params.get('subTab')).toBeNull();
expect(params.get('search')).toBeNull();
});
test('CS-07 showClassicCreateAlertsPage=true renders the v1 form instead', async ({
authedPage: page,
}) => {
await gotoCreateAlertV1(page, { alertType: AlertType.METRICS });
await expect(v1SaveButton(page)).toBeVisible();
// The clearest v1/v2 discriminator: the v2 header input simply is not there.
await expect(page.getByTestId('alert-name-input')).toBeHidden();
});
test('CS-08 Switch to Classic Experience replaces history, so Back does not return to v2', async ({
authedPage: page,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.METRICS });
await page
.getByRole('button', { name: 'Switch to Classic Experience' })
.click();
await expect(v1SaveButton(page)).toBeVisible();
expect(
new URL(page.url()).searchParams.get('showClassicCreateAlertsPage'),
).toBe('true');
// `safeNavigate(url, { replace: true })` — going back must skip the v2 entry
// entirely rather than bouncing between the two experiences.
await page.goBack();
await expect(page.getByTestId('alert-name-input')).toBeHidden();
});
});

View File

@@ -0,0 +1,537 @@
import { expect, test } from '../../../fixtures/alert-rules';
import {
AlertType,
alertTypeCard,
gotoAlertTypeSelection,
gotoCreateAlertV1,
stubNoChannels,
v1BroadcastSwitch,
v1CancelButton,
v1CancelSave,
v1ChannelSelect,
v1ConfirmDialog,
v1ConfirmSave,
v1DescriptionInput,
v1EvalWindowSelect,
v1MatchTypeSelect,
v1NameInput,
v1OperatorSelect,
v1SelectChannel,
v1SelectOption,
v1SelectQueryMode,
v1SeveritySelect,
v1TestButton,
v1ThresholdInput,
v1SaveButton,
} from '../../../helpers/alert-forms';
// CV1-* — the v1 classic create form, reached with
// `showClassicCreateAlertsPage=true`. CE-05 and CE-06 live here too: both are
// PromQL/ClickHouse validation, which only the classic form has.
//
// Three things about this form drive almost every row below, and all three differ
// from v2:
// 1. Save only *opens* a confirm dialog. Both the field validation and the
// request run behind its OK, so a validation message is never visible until
// the dialog has been confirmed.
// 2. The Save/Test buttons are disabled by `isAlertNameMissing ||
// !isChannelConfigurationValid || queryStatus === 'error'`, so a row that
// wants to reach the dialog has to satisfy the name *and* the channels first.
// 3. Validation failures surface as antd **notifications**, not inline errors.
const VALIDATION = {
targetMissing: 'Please enter a threshold to proceed',
promql: 'promql expression is required when query format is set to PromQL',
clickhouse: 'query is required when query format is set to ClickHouse',
} as const;
/**
* Alert type for every row that actually saves.
*
* A metrics-based rule is rejected by the **server** with
* `400 invalid query 'A': metric name is required for aggregation #1`, because the
* metrics default query has no metric selected and picking one is query-builder
* territory. A logs-based alert's default query is valid with no seeded data, which
* is the same reason every `CV2-*` row uses one. Rows that assert on *rendering*
* stay metrics-based, and so does CE-05 — PromQL is offered for metrics only.
*/
const SAVEABLE = AlertType.LOGS;
/** Every v1 save and test goes to the same endpoint the v2 builder uses. */
function isRuleCreate(url: string, method: string): boolean {
return method === 'POST' && new URL(url).pathname === '/api/v2/rules';
}
/**
* Fill the minimum a v1 rule needs before Save stops being disabled: a name, a
* channel and a threshold.
*/
async function fillMinimalV1Rule(
page: import('@playwright/test').Page,
{ name, channelName }: { name: string; channelName: string },
): Promise<void> {
await v1NameInput(page).fill(name);
await v1ThresholdInput(page).fill('5');
await v1SelectChannel(page, channelName);
await expect(v1SaveButton(page)).toBeEnabled();
}
test.describe('Alert create — v1 classic form', () => {
test('CV1-01 the classic form renders its steps and the create-mode labels', async ({
authedPage: page,
}) => {
// Whether the detection-method step renders depends on `ANOMALY_DETECTION`, the
// same flag CS-01 reads off the type-selection page. Read it there rather than
// assuming, so this stays one unconditional assertion whichever way it falls.
await gotoAlertTypeSelection(page);
const anomalyEnabled =
(await alertTypeCard(page, AlertType.ANOMALY).count()) > 0;
await gotoCreateAlertV1(page, { alertType: AlertType.METRICS });
await expect(page.getByText('Metrics Based Alert')).toBeVisible();
await expect(
page.getByRole('button', { name: 'Alert Setup Guide' }),
).toBeVisible();
// `isNewRule` decides both action labels.
await expect(v1SaveButton(page)).toHaveText(/Create Rule/);
await expect(v1CancelButton(page)).toHaveText(/Cancel/);
await expect(page.getByText('Define the metric')).toBeVisible();
await expect(page.getByText('Define Alert Conditions')).toBeVisible();
await expect(page.getByText('Alert Configuration')).toBeVisible();
await expect(page.locator('.detection-method-container')).toHaveCount(
anomalyEnabled ? 1 : 0,
);
});
test('CV1-02 the rendered severity is the default from the rule, not the select', async ({
authedPage: page,
}) => {
await gotoCreateAlertV1(page, { alertType: AlertType.METRICS });
// Two defaults disagree: `alertDefaults.labels.severity` is `warning` while the
// select's own `defaultValue` prop says `critical`. The antd Form's
// `initialValues` wins, so what a user sees — and what the payload carries — is
// *warning*.
await expect(v1SeveritySelect(page)).toContainText('Warning');
});
test('CV1-03 one keystroke in the name field is enough to enable Save', async ({
authedPage: page,
alertChannel,
}) => {
await gotoCreateAlertV1(page, { alertType: SAVEABLE });
// Satisfy the other two gates first so the name is the only one left.
await v1ThresholdInput(page).fill('5');
await v1SelectChannel(page, alertChannel.name);
await expect(v1SaveButton(page)).toBeDisabled();
await expect(v1TestButton(page)).toBeDisabled();
// `isAlertNameMissing` is `!formInstance.getFieldValue('alert')` read during
// render — not a subscription. It only ever looks fresh because `setAlertDef`
// re-renders on every keystroke, so the realistic failure is the *first*
// character. One character, no second keystroke, no blur.
await v1NameInput(page).pressSequentially('a');
await expect(v1SaveButton(page)).toBeEnabled();
await expect(v1TestButton(page)).toBeEnabled();
});
test('CV1-04 Save stays disabled until the channel configuration resolves', async ({
authedPage: page,
alertChannel,
}) => {
await gotoCreateAlertV1(page, { alertType: SAVEABLE });
await v1NameInput(page).fill(`e2e-cv1-04-${Date.now()}`);
await v1ThresholdInput(page).fill('5');
// A new rule starts with the broadcast switch **off** and no preferred channels,
// so `isChannelConfigurationValid` is false and there is no message anywhere —
// just a dead button. That silence is what this row pins.
await expect(v1BroadcastSwitch(page)).toHaveAttribute(
'aria-checked',
'false',
);
await expect(v1SaveButton(page)).toBeDisabled();
await v1SelectChannel(page, alertChannel.name);
await expect(v1SaveButton(page)).toBeEnabled();
});
// TODO: enable once the `broadcastToAll` bug is fixed.
//
// 🐞 **"Alert all the configured channels" is broken end to end**, so this row
// asserts the *intended* behaviour and fails today:
//
// 1. `BasicInfo`'s switch sets `alertDef.broadcastToAll` and unmounts the
// channel select, so no channel can be picked while it is on.
// 2. `preparePostData` blanks `preferredChannels` when `broadcastToAll` is set
// (`FormAlertRules/index.tsx`).
// 3. `toPostableRuleDTOFromAlertDef` (`types/api/alerts/convert.ts`) **never
// copies `broadcastToAll`** into the DTO.
//
// The request therefore says "no channels, no broadcast" and the server answers
// `400 at least one channel is required`, which surfaces as the generic error
// modal. Observed live: `body.broadcastToAll === undefined`, status `400`,
// `.error-modal__wrap` visible, still on `/alerts/new`.
//
// The fix is to send the field (or to delete the switch). When it lands, unskip.
test.skip('CV1-05 broadcast-to-all saves the rule with the broadcast flag', async ({
authedPage: page,
alertChannel,
ownedRules,
}) => {
expect(alertChannel.name).toBeTruthy();
await gotoCreateAlertV1(page, { alertType: SAVEABLE });
await v1NameInput(page).fill(`e2e-cv1-05-${Date.now()}`);
await v1ThresholdInput(page).fill('5');
await v1BroadcastSwitch(page).click();
// The select is unmounted, not disabled, so "pick a channel" stops being
// possible rather than becoming optional — the switch has to carry the intent
// on its own.
await expect(v1ChannelSelect(page)).toHaveCount(0);
await expect(v1SaveButton(page)).toBeEnabled();
await v1SaveButton(page).click();
const [response] = await Promise.all([
page.waitForResponse((r) => isRuleCreate(r.url(), r.request().method())),
v1ConfirmSave(page),
]);
await ownedRules.register(response);
// The broadcast intent must survive the DTO conversion: an empty channel list
// is only valid when the flag that replaces it is present.
const body = response.request().postDataJSON();
expect(body.broadcastToAll).toBe(true);
expect(body.preferredChannels).toEqual([]);
expect(response.status(), await response.text()).toBe(201);
await expect(page.locator('.error-modal__wrap')).toBeHidden();
await expect(page.getByText('Rule created successfully')).toBeVisible();
await page.waitForURL(/\/alerts(\?|$)/);
expect(new URL(page.url()).pathname).toBe('/alerts');
});
test('CV1-06 a cleared threshold is coerced to 0, so the required-threshold branch is dead', async ({
authedPage: page,
alertChannel,
ownedRules,
}) => {
await gotoCreateAlertV1(page, { alertType: SAVEABLE });
await v1NameInput(page).fill(`e2e-cv1-06-${Date.now()}`);
await v1SelectChannel(page, alertChannel.name);
// `Please enter a threshold to proceed` cannot be reached from the UI at all:
//
// 1. The field renders `0`, not empty — on metrics, logs and traces alike.
// 2. `RuleOptions`'s `onChange` writes `Number(value) || 0`, so clearing the
// input stores 0 rather than nothing.
// 3. `validateQBParams` guards `target !== 0 && !target`, i.e. it
// *deliberately* treats 0 as a valid threshold.
//
// What follows asserts the reachable behaviour and pins each link in that chain.
await expect(v1ThresholdInput(page)).toHaveValue('0');
await v1ThresholdInput(page).fill('');
await expect(v1SaveButton(page)).toBeEnabled();
await v1SaveButton(page).click();
await expect(v1ConfirmDialog(page)).toBeVisible();
const [response] = await Promise.all([
page.waitForResponse((r) => isRuleCreate(r.url(), r.request().method())),
v1ConfirmSave(page),
]);
await ownedRules.register(response);
// A rule saved with a threshold of 0 rather than a validation error.
expect(response.status(), await response.text()).toBe(201);
expect(response.request().postDataJSON().condition.target).toBe(0);
await expect(page.getByText(VALIDATION.targetMissing)).toBeHidden();
});
test('CV1-07 cancelling the confirm dialog does not save', async ({
authedPage: page,
alertChannel,
}) => {
await gotoCreateAlertV1(page, { alertType: SAVEABLE });
await fillMinimalV1Rule(page, {
name: `e2e-cv1-07-${Date.now()}`,
channelName: alertChannel.name,
});
let sawPost = false;
page.on('request', (request) => {
if (isRuleCreate(request.url(), request.method())) {
sawPost = true;
}
});
await v1SaveButton(page).click();
await expect(v1ConfirmDialog(page)).toContainText('Your alert built with');
await v1CancelSave(page);
expect(sawPost).toBe(false);
// Still on the form, nothing lost.
expect(new URL(page.url()).pathname).toBe('/alerts/new');
await expect(v1SaveButton(page)).toBeEnabled();
});
test('CV1-08 the happy path posts the v1 body shape to the shared endpoint', async ({
authedPage: page,
alertChannel,
ownedRules,
}) => {
await gotoCreateAlertV1(page, { alertType: SAVEABLE });
const name = `e2e-cv1-08-${Date.now()}`;
await fillMinimalV1Rule(page, { name, channelName: alertChannel.name });
await v1SaveButton(page).click();
const [response] = await Promise.all([
page.waitForResponse((r) => isRuleCreate(r.url(), r.request().method())),
v1ConfirmSave(page),
]);
await ownedRules.register(response);
// The server's message is folded into the assertion: a v1 payload rejection is
// only diagnosable from its text.
expect(response.status(), await response.text()).toBe(201);
// The endpoint is shared with v2 — there is no `/api/v1/rules` client in the
// frontend — so the *body* is the only thing that distinguishes the two forms.
const body = response.request().postDataJSON();
expect(body.condition.target).toBe(5);
expect(body.condition.op).toBe('1');
// `4` is *in total*, not the `defaultMatchType` of `1` (*at least once*): the
// per-signal defaults disagree with the shared one. `logAlertDefaults`,
// `traceAlertDefaults` and `exceptionAlertDefaults` all hardcode `matchType: '4'`
// while `alertDefaults` — metrics — uses `defaultMatchType`.
expect(body.condition.matchType).toBe('4');
expect(body.evalWindow).toBe('5m0s');
expect(body.preferredChannels).toEqual([alertChannel.name]);
expect(body.labels.severity).toBe('warning');
// No v2 threshold envelope and no v2 schema marker anywhere in it.
expect(body.schemaVersion).toBeUndefined();
expect(body.condition.thresholds).toBeUndefined();
// antd notification, not a sonner toast — the other half of the v1/v2 split.
await expect(page.getByText('Rule created successfully')).toBeVisible();
await page.waitForURL(/\/alerts(\?|$)/);
expect(new URL(page.url()).pathname).toBe('/alerts');
});
test('CV1-09 CV1-10 description, labels and severity all land in the payload', async ({
authedPage: page,
alertChannel,
ownedRules,
}) => {
await gotoCreateAlertV1(page, { alertType: SAVEABLE });
await fillMinimalV1Rule(page, {
name: `e2e-cv1-09-${Date.now()}`,
channelName: alertChannel.name,
});
await v1DescriptionInput(page).fill('raised by the e2e suite');
// The label editor is a two-phase input — key, ENTER, value, ENTER — sharing one
// field, and it writes the whole label map back.
const labels = page.getByTestId('alert-labels-input-v1');
await labels.fill('team');
await labels.press('Enter');
await labels.fill('payments');
await labels.press('Enter');
// Severity is just another label in the payload, which is why the two halves of
// this row belong together: the editor above must not have dropped it.
await v1SelectOption(page, v1SeveritySelect(page), 'Critical');
await v1SaveButton(page).click();
const [response] = await Promise.all([
page.waitForResponse((r) => isRuleCreate(r.url(), r.request().method())),
v1ConfirmSave(page),
]);
await ownedRules.register(response);
const body = response.request().postDataJSON();
expect(body.annotations.description).toBe('raised by the e2e suite');
expect(body.labels).toMatchObject({
team: 'payments',
severity: 'critical',
});
});
test('CV1-11 test notification skips the dialog and reports no matching data', async ({
authedPage: page,
alertChannel,
}) => {
await gotoCreateAlertV1(page, { alertType: SAVEABLE });
await fillMinimalV1Rule(page, {
name: `e2e-cv1-11-${Date.now()}`,
channelName: alertChannel.name,
});
// `onTestRuleHandler` validates inline — the confirm dialog is the *save* path
// only, so a spec that waits for it here hangs.
const [response] = await Promise.all([
page.waitForResponse(
(r) =>
r.url().includes('/api/v2/rules/test') && r.request().method() === 'POST',
),
v1TestButton(page).click(),
]);
expect(response.ok()).toBe(true);
await expect(v1ConfirmDialog(page)).toHaveCount(0);
// A threshold of 5 on a rule nobody is feeding evaluates fine and matches nothing,
// which is an *error* notification rather than a success one. Asserted
// permissively so a stack that happens to have matching data does not flip it.
await expect(
page.getByText(/No alerts found during the evaluation|Success/),
).toBeVisible();
});
test('CV1-12 with no channels the form is a dead end', async ({
authedPage: page,
}) => {
// v2 at least offers routing policies; v1 has no equivalent escape.
await stubNoChannels(page);
await gotoCreateAlertV1(page, { alertType: AlertType.METRICS });
await v1NameInput(page).fill(`e2e-cv1-12-${Date.now()}`);
await v1ThresholdInput(page).fill('5');
// `noChannels` disables the switch, so the one control that could satisfy
// `isChannelConfigurationValid` without picking a channel is gone.
await expect(v1BroadcastSwitch(page)).toBeDisabled();
// And the select that is still on screen offers nothing but a way out of the page.
await v1ChannelSelect(page).click();
await expect(
page.getByText('Create a new channel', { exact: false }),
).toBeVisible();
await page.keyboard.press('Escape');
await expect(
page.getByRole('button', { name: 'Create a notification channel' }),
).toBeVisible();
// ⇒ no reachable state saves this rule.
await expect(v1SaveButton(page)).toBeDisabled();
});
test('CV1-13 Cancel leaves the form without saving', async ({
authedPage: page,
alertChannel,
}) => {
await gotoCreateAlertV1(page, { alertType: SAVEABLE });
await fillMinimalV1Rule(page, {
name: `e2e-cv1-13-${Date.now()}`,
channelName: alertChannel.name,
});
let sawPost = false;
page.on('request', (request) => {
if (isRuleCreate(request.url(), request.method())) {
sawPost = true;
}
});
// A plain click, unlike v2's Discard: v1 has no fixed footer for the side
// navigation to cover (CE-09).
await v1CancelButton(page).click();
await page.waitForURL(/\/alerts(\?|$)/);
expect(sawPost).toBe(false);
});
test('CE-05 an empty PromQL expression is rejected behind the dialog', async ({
authedPage: page,
alertChannel,
}) => {
await gotoCreateAlertV1(page, { alertType: AlertType.METRICS });
await fillMinimalV1Rule(page, {
name: `e2e-ce05-${Date.now()}`,
channelName: alertChannel.name,
});
// PromQL is offered for metrics-based alerts only; the logs/traces/exceptions
// tab set has ClickHouse but no PromQL.
await v1SelectQueryMode(page, 'promql');
let sawPost = false;
page.on('request', (request) => {
if (isRuleCreate(request.url(), request.method())) {
sawPost = true;
}
});
await v1SaveButton(page).click();
await v1ConfirmSave(page);
await expect(page.getByText(VALIDATION.promql)).toBeVisible();
expect(sawPost).toBe(false);
});
test('CE-06 an empty ClickHouse query is rejected behind the dialog', async ({
authedPage: page,
alertChannel,
}) => {
// Metrics-based, and not interchangeable with the logs form: `logAlertDefaults`
// ships a **prefilled** ClickHouse query, so on a logs alert the expression is
// never empty and `chquery_required` cannot fire. Only `alertDefaults` (metrics)
// starts with `query: ''`.
await gotoCreateAlertV1(page, { alertType: AlertType.METRICS });
await fillMinimalV1Rule(page, {
name: `e2e-ce06-${Date.now()}`,
channelName: alertChannel.name,
});
await v1SelectQueryMode(page, 'clickhouse');
let sawPost = false;
page.on('request', (request) => {
if (isRuleCreate(request.url(), request.method())) {
sawPost = true;
}
});
await v1SaveButton(page).click();
await v1ConfirmSave(page);
await expect(page.getByText(VALIDATION.clickhouse)).toBeVisible();
expect(sawPost).toBe(false);
});
test('CV1-14 the condition sentence keeps its selections', async ({
authedPage: page,
alertChannel,
ownedRules,
}) => {
// The write side of EV1-02's prefill assertions — without it a broken
// `RuleOptions` select would only be caught on the *edit* path.
await gotoCreateAlertV1(page, { alertType: SAVEABLE });
await fillMinimalV1Rule(page, {
name: `e2e-cv1-14-${Date.now()}`,
channelName: alertChannel.name,
});
await v1SelectOption(page, v1OperatorSelect(page), 'below');
await v1SelectOption(page, v1MatchTypeSelect(page), 'all the times');
await v1SelectOption(page, v1EvalWindowSelect(page), '10 mins');
await v1SaveButton(page).click();
const [response] = await Promise.all([
page.waitForResponse((r) => isRuleCreate(r.url(), r.request().method())),
v1ConfirmSave(page),
]);
await ownedRules.register(response);
// v1 stores these as the numeric enum strings the legacy validator wants — `2`
// is *below* and `2` is *all the times*, on two different scales.
const body = response.request().postDataJSON();
expect(body.condition.op).toBe('2');
expect(body.condition.matchType).toBe('2');
expect(body.evalWindow).toBe('10m0s');
});
});

View File

@@ -0,0 +1,564 @@
import { expect, test } from '../../../fixtures/alert-rules';
import {
addAlertLabel,
advancedOptionToggle,
AlertType,
evaluationCadenceInput,
expandAdvancedOptions,
gotoCreateAlertV2,
labelPill,
dropdownOption,
openDropdown,
ownDropdown,
selectEvaluationTimeframe,
selectThresholdChannel,
stubNoChannels,
thresholdRows,
ThresholdMatchType,
ThresholdOperator,
v2ClickDiscard,
v2DiscardButton,
v2SaveButton,
v2SaveTooltip,
v2TestButton,
} from '../../../helpers/alert-forms';
// CV2-* — the v2 create builder.
//
// Every scenario uses a **logs**-based alert unless it says otherwise: its default
// query is valid with no seeded metrics. CV2-12 (unit select) and CV2-16 (group-by
// select) are about what that choice costs — both are gated on query state a
// default logs query does not provide, and both assert the gate.
const VALIDATION = {
name: 'Please enter an alert name',
thresholdLabel: 'Please enter a label for each threshold',
channels:
'Please select at least one channel for each threshold or enable routing policies',
} as const;
test.describe('Alert create — v2 builder', () => {
test('CV2-01 initial state: one critical threshold, both actions gated', async ({
authedPage: page,
alertChannel,
}) => {
expect(alertChannel.name).toBeTruthy();
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await expect(page.getByTestId('alert-name-input')).toHaveValue('');
await expect(page.getByTestId('alert-name-input')).toHaveAttribute(
'placeholder',
'Enter alert rule name',
);
// `INITIAL_CRITICAL_THRESHOLD` — label `critical`, value 0, `channels: []`.
// The empty channel list is what makes the save gate reachable at all.
await expect(thresholdRows(page)).toHaveCount(1);
await expect(page.getByTestId('threshold-name-input')).toHaveValue(
'critical',
);
await expect(page.getByTestId('threshold-value-input')).toHaveValue('0');
await expect(v2SaveButton(page)).toBeDisabled();
await expect(v2TestButton(page)).toBeDisabled();
});
test('CV2-02 the save tooltip walks from the name gate to the channel gate', async ({
authedPage: page,
alertChannel,
}) => {
expect(alertChannel.name).toBeTruthy();
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
// `validateCreateAlertState` returns the *first* failure, so the message order
// encodes the validation order: name, then per-threshold label, then channels.
expect(await v2SaveTooltip(page)).toBe(VALIDATION.name);
await page.getByTestId('alert-name-input').fill(`e2e-cv2-02-${Date.now()}`);
expect(await v2SaveTooltip(page)).toBe(VALIDATION.channels);
});
test('CV2-03 clearing a threshold label re-gates the save', async ({
authedPage: page,
alertChannel,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-name-input').fill(`e2e-cv2-03-${Date.now()}`);
await selectThresholdChannel(page, 0, alertChannel.name);
// With a name and a channel the only remaining gate is the label.
await expect(v2SaveButton(page)).toBeEnabled();
await page.getByTestId('threshold-name-input').fill('');
expect(await v2SaveTooltip(page)).toBe(VALIDATION.thresholdLabel);
});
test('CV2-04 a label added in the header survives the save round-trip', async ({
authedPage: page,
alertChannel,
ownedRules,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
const name = `e2e-cv2-04-${Date.now()}`;
await page.getByTestId('alert-name-input').fill(name);
await selectThresholdChannel(page, 0, alertChannel.name);
await addAlertLabel(page, 'team', 'payments');
await expect(labelPill(page, 'team', 'payments')).toBeVisible();
const [response] = await Promise.all([
page.waitForResponse(
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
),
v2SaveButton(page).click(),
]);
await ownedRules.register(response);
// Asserted on the request body rather than on the pill: the pill only proves
// local state, and the defect worth guarding is a label that renders but is
// never posted.
const body = response.request().postDataJSON();
expect(body.labels).toMatchObject({ team: 'payments' });
});
test('CV2-05 a rejected label key surfaces as a notification, not an inline message', async ({
authedPage: page,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
// Both rejection branches in `LabelsInput` — duplicate key and group-by key —
// raise an antd **notification**, so a spec looking for inline text fails. Only
// the duplicate branch is reachable here: the group-by branch needs a query that
// already groups by something, which the default logs query does not.
await addAlertLabel(page, 'team', 'payments');
await expect(labelPill(page, 'team', 'payments')).toBeVisible();
await page.getByTestId('alert-add-label-button').click();
const input = page.getByTestId('alert-add-label-input');
await input.fill('team');
await input.press('Enter');
await expect(
page.getByText('Label with this key already exists'),
).toBeVisible();
// Rejected, so no second pill appeared.
await expect(page.locator('[data-testid^="label-pill-team-"]')).toHaveCount(
1,
);
});
test('CV2-06 CV2-07 the operator and match-type selects offer the documented options', async ({
authedPage: page,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-threshold-operator-select').click();
// Exact-text matching, because `hasText: 'EQUAL TO'` also matches the
// `NOT EQUAL TO` option and the count assertion then reads 2.
for (const operator of Object.values(ThresholdOperator)) {
await expect(dropdownOption(page, operator.label)).toHaveCount(1);
}
await expect(
openDropdown(page).locator('.ant-select-item-option'),
).toHaveCount(Object.keys(ThresholdOperator).length);
await page.keyboard.press('Escape');
await page.getByTestId('alert-threshold-match-type-select').click();
for (const matchType of Object.values(ThresholdMatchType)) {
await expect(dropdownOption(page, matchType.label)).toHaveCount(1);
}
await expect(
openDropdown(page).locator('.ant-select-item-option'),
).toHaveCount(Object.keys(ThresholdMatchType).length);
});
test('CV2-08 the operator is rule-wide: one change reaches every threshold', async ({
authedPage: page,
alertChannel,
ownedRules,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-name-input').fill(`e2e-cv2-08-${Date.now()}`);
await page.getByTestId('add-threshold-button').click();
await expect(thresholdRows(page)).toHaveCount(2);
await selectThresholdChannel(page, 0, alertChannel.name);
await selectThresholdChannel(page, 1, alertChannel.name);
await page.getByTestId('alert-threshold-operator-select').click();
await dropdownOption(page, ThresholdOperator.BELOW.label).click();
const [response] = await Promise.all([
page.waitForResponse(
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
),
v2SaveButton(page).click(),
]);
await ownedRules.register(response);
// The UI models one operator per rule while the schema stores one per
// threshold, so a single change is fanned out across `spec[]`.
const spec = response.request().postDataJSON().condition.thresholds.spec;
expect(spec).toHaveLength(2);
expect(spec.map((entry: { op: string }) => entry.op)).toEqual([
ThresholdOperator.BELOW.value,
ThresholdOperator.BELOW.value,
]);
});
test('CV2-09 CV2-10 added thresholds take preset tiers, and the first cannot be removed', async ({
authedPage: page,
alertChannel,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
// `addThreshold` branches on the current count: 2nd ⇒ warning, 3rd ⇒ info,
// 4th and beyond ⇒ an unnamed row with a random colour.
await page.getByTestId('add-threshold-button').click();
await page.getByTestId('add-threshold-button').click();
await page.getByTestId('add-threshold-button').click();
await expect(thresholdRows(page)).toHaveCount(4);
const names = page.getByTestId('threshold-name-input');
await expect(names.nth(0)).toHaveValue('critical');
await expect(names.nth(1)).toHaveValue('warning');
await expect(names.nth(2)).toHaveValue('info');
await expect(names.nth(3)).toHaveValue('');
// `showRemoveButton` is `index !== 0 && length > 1`, so there are three remove
// buttons for four rows and the first row can never be removed.
await expect(page.getByTestId('remove-threshold-button')).toHaveCount(3);
// To see the unnamed row's own gate, the earlier gates have to be satisfied
// first: `validateCreateAlertState` loops thresholds and returns on the first
// failure, checking label *then* channels **per threshold** — so with row 0
// lacking a channel the channel message wins before row 3 is ever examined.
await page.getByTestId('alert-name-input').fill(`e2e-cv2-09-${Date.now()}`);
for (const index of [0, 1, 2, 3]) {
// eslint-disable-next-line no-await-in-loop
await selectThresholdChannel(page, index, alertChannel.name);
}
expect(await v2SaveTooltip(page)).toBe(VALIDATION.thresholdLabel);
await names.nth(3).fill('page-me');
await expect(v2SaveButton(page)).toBeEnabled();
});
test('CV2-11 a channel on one threshold is not enough — the validator loops all of them', async ({
authedPage: page,
alertChannel,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-name-input').fill(`e2e-cv2-11-${Date.now()}`);
await page.getByTestId('add-threshold-button').click();
await selectThresholdChannel(page, 0, alertChannel.name);
expect(await v2SaveTooltip(page)).toBe(VALIDATION.channels);
await selectThresholdChannel(page, 1, alertChannel.name);
await expect(v2SaveButton(page)).toBeEnabled();
});
test('CV2-12 the unit select is disabled while the query has no y-axis unit', async ({
authedPage: page,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
// `disabled={units.length === 0}`, and `units` is derived from
// `alertState.yAxisUnit`. A logs alert carries no unit, so the control is dead on
// this path — CD-04 covers a URL that does supply one.
const unitSelect = page.getByTestId('threshold-unit-select').first();
await expect(unitSelect).toHaveClass(/ant-select-disabled/);
});
test('CV2-13 the recovery threshold control is never rendered', async ({
authedPage: page,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
// `showRecoveryThreshold` starts false and the only setter is commented out, so
// neither the input nor its remove button can appear.
await expect(page.getByTestId('recovery-threshold-value-input')).toHaveCount(
0,
);
await expect(
page.getByTestId('remove-recovery-threshold-button'),
).toHaveCount(0);
});
test('CV2-14 CV2-15 the evaluation window and cadence reach the payload', async ({
authedPage: page,
alertChannel,
ownedRules,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-name-input').fill(`e2e-cv2-14-${Date.now()}`);
await selectThresholdChannel(page, 0, alertChannel.name);
// Written as one test because the two settings share a payload branch:
// `getEvaluationProps` emits `evalWindow` and `frequency` together, and a
// scenario that changed only one would still pass with the other hardcoded.
await selectEvaluationTimeframe(page, '30m0s');
await expandAdvancedOptions(page);
await evaluationCadenceInput(page).fill('5');
const [response] = await Promise.all([
page.waitForResponse(
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
),
v2SaveButton(page).click(),
]);
await ownedRules.register(response);
const { evaluation } = response.request().postDataJSON();
expect(evaluation.kind).toBe('rolling');
expect(evaluation.spec.evalWindow).toBe('30m0s');
// `getFormattedTimeValue` maps value + unit onto a Go duration; the unit is
// left at its default Minutes, which is what makes `5m` the expected string.
expect(evaluation.spec.frequency).toBe('5m');
});
test('CV2-18 with no channels the dropdown offers only a way to create one', async ({
authedPage: page,
}) => {
// The single deliberate network stub in the alerts suite — its justification is
// in `stubNoChannels`, and the state it produces is the one every fresh install
// starts in.
await stubNoChannels(page);
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-name-input').fill(`e2e-cv2-18-${Date.now()}`);
const select = page
.getByTestId('threshold-notification-channel-select')
.first();
await select.click();
const dropdown = await ownDropdown(page, select);
// `NotificationChannelsNotFoundContent` branches on the user's role. The harness
// user is an admin, so this is the "create one here" half — asserting the
// non-admin string instead would fail for the wrong reason.
await expect(dropdown.getByText('No channels yet.')).toBeVisible();
await expect(dropdown.getByRole('button', { name: 'here.' })).toBeVisible();
await expect(dropdown.getByRole('button', { name: 'Refresh' })).toBeVisible();
await expect(
dropdown.getByText('Please ask your admin to create one.'),
).toBeHidden();
await page.keyboard.press('Escape');
// With a name filled and no channel selectable, the channel gate is the only
// thing left — and there is no way to satisfy it from this page.
expect(await v2SaveTooltip(page)).toBe(VALIDATION.channels);
await expect(v2SaveButton(page)).toBeDisabled();
});
test('CV2-19 routing policies unlock the save with zero channels', async ({
authedPage: page,
}) => {
await stubNoChannels(page);
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-name-input').fill(`e2e-cv2-19-${Date.now()}`);
await expect(v2SaveButton(page)).toBeDisabled();
await page.getByTestId('routing-policies-switch').click();
// The validator skips the channel check when `routingPolicies` is on, and the
// threshold row *removes* its channel select rather than disabling it — so this
// is the one route to a saveable rule on a stack with no channels at all.
await expect(
page.getByTestId('threshold-notification-channel-select'),
).toHaveCount(0);
await expect(v2SaveButton(page)).toBeEnabled();
await page.getByTestId('view-routing-policies-button').click();
await page.waitForURL(/subTab=routing-policies/);
const url = new URL(page.url());
expect(url.pathname).toBe('/alerts');
expect(url.searchParams.get('tab')).toBe('Configuration');
});
test('CV2-16 the group-by select is disabled until the query groups by something', async ({
authedPage: page,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
// `isMultipleNotificationsEnabled` is `spaceAggregationOptions.length > 0`, and
// the options come from the query's `groupBy` keys. The default logs query has
// none, so notification grouping is unreachable without editing the query first.
const groupBy = page.getByTestId('multiple-notifications-select');
await expect(groupBy).toHaveAttribute('aria-disabled', 'true');
await expect(page.getByText('No grouping fields available')).toBeVisible();
});
test('CV2-17 repeat notifications enable their inputs and reach the payload', async ({
authedPage: page,
alertChannel,
ownedRules,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-name-input').fill(`e2e-cv2-17-${Date.now()}`);
await selectThresholdChannel(page, 0, alertChannel.name);
const interval = page.getByTestId('repeat-notifications-time-input');
await expect(interval).toBeDisabled();
await advancedOptionToggle(page, 'repeat-notifications-container').click();
await expect(interval).toBeEnabled();
await interval.fill('45');
const [response] = await Promise.all([
page.waitForResponse(
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
),
v2SaveButton(page).click(),
]);
await ownedRules.register(response);
// `getFormattedTimeValue` turns value+unit into a Go duration.
const renotify = response.request().postDataJSON()
.notificationSettings.renotify;
expect(renotify.enabled).toBe(true);
expect(renotify.interval).toBe('45m');
});
test('CV2-20 happy-path save posts the v2 shape and lands on the list', async ({
authedPage: page,
alertChannel,
ownedRules,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
const name = `e2e-cv2-20-${Date.now()}`;
await page.getByTestId('alert-name-input').fill(name);
await selectThresholdChannel(page, 0, alertChannel.name);
// One click — v2 has no confirm dialog, unlike v1.
const [response] = await Promise.all([
page.waitForResponse(
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
),
v2SaveButton(page).click(),
]);
await ownedRules.register(response);
expect(response.status()).toBe(201);
expect(new URL(response.url()).pathname).toBe('/api/v2/rules');
const body = response.request().postDataJSON();
expect(body.schemaVersion).toBe('v2alpha1');
expect(body.version).toBe('v5');
expect(body.alert).toBe(name);
expect(body.condition.thresholds.kind).toBe('basic');
expect(body.condition.thresholds.spec[0]).toMatchObject({
name: 'critical',
target: 0,
matchType: ThresholdMatchType.AT_LEAST_ONCE.value,
op: ThresholdOperator.ABOVE.value,
channels: [alertChannel.name],
targetUnit: '',
});
// The payload carries no recovery field at all — see CV2-13.
expect(body.condition.thresholds.spec[0]).not.toHaveProperty(
'recoveryTarget',
);
await expect(page.getByText('Alert rule created successfully')).toBeVisible();
// `safeNavigate('/alerts')`; the list page then appends its own defaults, so
// only the pathname is asserted.
await page.waitForURL(/\/alerts(\?|$)/);
expect(new URL(page.url()).pathname).toBe('/alerts');
});
test('CV2-21 test notification reports that a non-firing rule matched nothing', async ({
authedPage: page,
alertChannel,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-name-input').fill(`e2e-cv2-21-${Date.now()}`);
await selectThresholdChannel(page, 0, alertChannel.name);
const [response] = await Promise.all([
page.waitForResponse(
(r) =>
r.url().includes('/api/v2/rules/test') && r.request().method() === 'POST',
),
v2TestButton(page).click(),
]);
expect(response.ok()).toBe(true);
// `alertCount === 0` is an *error* toast, not a success one — the rule evaluated
// fine, it just did not fire. Asserted permissively so a stack that happens to
// have matching data does not flip it.
await expect(
page.getByText(/No alerts found during the evaluation|sent successfully/),
).toBeVisible();
});
test('CV2-22 discard leaves without posting and resets the form', async ({
authedPage: page,
alertChannel,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-name-input').fill(`e2e-cv2-22-${Date.now()}`);
await selectThresholdChannel(page, 0, alertChannel.name);
let sawPost = false;
page.on('request', (request) => {
if (request.method() === 'POST' && request.url().includes('/api/v2/rules')) {
sawPost = true;
}
});
// dispatchEvent, not click — the side navigation covers the button (CE-09).
await v2ClickDiscard(page);
await page.waitForURL(/\/alerts(\?|$)/);
expect(sawPost).toBe(false);
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await expect(page.getByTestId('alert-name-input')).toHaveValue('');
await expect(thresholdRows(page)).toHaveCount(1);
});
test('CV2-23 every footer button is disabled while the save is in flight', async ({
authedPage: page,
alertChannel,
ownedRules,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-name-input').fill(`e2e-cv2-23-${Date.now()}`);
await selectThresholdChannel(page, 0, alertChannel.name);
// The in-flight window is a few milliseconds against a local stack, so it is
// widened by *delaying* the request — `route.continue()` still sends it to the
// real backend and the real 201 comes back, so nothing about the response is
// faked.
await page.route('**/api/v2/rules', async (route) => {
if (route.request().method() !== 'POST') {
await route.fallback();
return;
}
await new Promise((resolve) => {
setTimeout(resolve, 2_000);
});
await route.continue();
});
const responsePromise = page.waitForResponse(
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
);
await v2SaveButton(page).click();
// `disableButtons` is one flag shared by all three, so Discard going disabled is
// what proves a user cannot abandon a half-created rule mid-request.
await expect(page.getByTestId('save-alert-rule-loader-icon')).toBeVisible();
await expect(page.getByTestId('save-alert-rule-check-icon')).toHaveCount(0);
await expect(v2SaveButton(page)).toBeDisabled();
await expect(v2TestButton(page)).toBeDisabled();
await expect(v2DiscardButton(page)).toBeDisabled();
const response = await responsePromise;
await ownedRules.register(response);
expect(response.status()).toBe(201);
await expect(page.getByText('Alert rule created successfully')).toBeVisible();
});
});

View File

@@ -0,0 +1,96 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
ALERT_OVERVIEW_PATH,
ALERTS_LIST_PATH,
gotoAlertDetails,
} from '../../../helpers/alerts';
test.describe('Alert details — actions', () => {
test('AD-06 enable/disable toggle changes the rule state', async ({
authedPage: page,
ownedRules,
}) => {
const ruleId = await ownedRules.logs({
name: `e2e-ad-toggle-${Date.now()}`,
schema: 'v2',
});
await gotoAlertDetails(page, ruleId);
const toggle = page.getByTestId('alert-actions-toggle');
await expect(toggle).toBeVisible();
await Promise.all([
page.waitForResponse(
(res) =>
res.url().includes(`/api/v2/rules/${ruleId}`) &&
res.request().method() === 'PATCH',
),
toggle.click(),
]);
await expect(page.getByText('Alert has been disabled.')).toBeVisible();
await Promise.all([
page.waitForResponse(
(res) =>
res.url().includes(`/api/v2/rules/${ruleId}`) &&
res.request().method() === 'PATCH',
),
toggle.click(),
]);
await expect(page.getByText('Alert has been enabled.')).toBeVisible();
});
test('AD-07 Duplicate creates a copy and navigates to overview', async ({
authedPage: page,
ownedRules,
}) => {
const name = `e2e-ad-duplicate-${Date.now()}`;
const ruleId = await ownedRules.logs({ name, schema: 'v2' });
await gotoAlertDetails(page, ruleId);
await page.getByTestId('alert-actions-menu').click();
const [createResponse] = await Promise.all([
page.waitForResponse(
(res) =>
/\/api\/v\d\/rules$/.test(new URL(res.url()).pathname) &&
res.request().method() === 'POST',
),
page.getByRole('menuitem', { name: 'Duplicate' }).click(),
]);
await ownedRules.register(createResponse);
await expect(page).toHaveURL(new RegExp(ALERT_OVERVIEW_PATH));
await expect(page).toHaveURL(/[?&]ruleId=/);
await page.goto(`${ALERTS_LIST_PATH}?search=${name}`);
await expect(page.getByText(`${name} - Copy`)).toBeVisible();
});
test('AD-08 Delete removes the rule and returns to the list', async ({
authedPage: page,
ownedRules,
}) => {
const name = `e2e-ad-delete-${Date.now()}`;
const ruleId = await ownedRules.logs({ name, schema: 'v2' });
await gotoAlertDetails(page, ruleId);
await page.getByTestId('alert-actions-menu').click();
await Promise.all([
page.waitForResponse(
(res) =>
res.url().includes(`/api/v2/rules/${ruleId}`) &&
res.request().method() === 'DELETE',
),
page.getByRole('menuitem', { name: 'Delete' }).click(),
]);
await expect(page).toHaveURL(new RegExp(`${ALERTS_LIST_PATH}$`));
await page.goto(`${ALERTS_LIST_PATH}?search=${name}`);
await expect(page.getByText(name, { exact: true })).toHaveCount(0);
});
});

View File

@@ -0,0 +1,51 @@
import { expect, test } from '../../../fixtures/alert-history';
import { ALERTS_LIST_PATH, gotoAlertHistory } from '../../../helpers/alerts';
test.describe('Alert details — page chrome', () => {
test('AD-09 copy-link button copies the current URL to clipboard', async ({
authedPage: page,
alertHistory,
browserName,
}) => {
test.skip(
browserName !== 'chromium',
'clipboard-read permission is Chromium-only in Playwright',
);
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await gotoAlertHistory(page, alertHistory.ruleId);
const expected = page.url();
await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Copied')).toBeVisible();
const copied = await page.evaluate(() => navigator.clipboard.readText());
expect(copied).toBe(expected);
});
test('AD-10 breadcrumb navigates back to the alert list', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const breadcrumb = page.locator('.ant-breadcrumb');
await expect(breadcrumb).toContainText('Alert Rules');
await expect(breadcrumb).toContainText(alertHistory.ruleId);
await breadcrumb.getByText('Alert Rules').click();
await expect(page).toHaveURL(new RegExp(`${ALERTS_LIST_PATH}$`));
});
test('AD-13 document title updates to show the rule name', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await expect
.poll(() => page.title(), { timeout: 15_000 })
.toContain('e2e-ah-rule-v2');
});
});

View File

@@ -0,0 +1,55 @@
import {
expect,
SEED_C_TEAM_LABEL,
test,
} from '../../../fixtures/alert-history';
import { gotoAlertDetails } from '../../../helpers/alerts';
test.describe('Alert details — header', () => {
test('AD-01 v2 header shows editable name input without Rename menu item', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertDetails(page, alertHistory.ruleId);
await expect(page.getByTestId('alert-details-root')).toHaveAttribute(
'data-schema-version',
'v2alpha1',
);
const nameInput = page.getByTestId('alert-name-input');
await expect(nameInput).toBeVisible();
await expect(nameInput).not.toHaveValue('');
await expect(nameInput).toBeEditable();
await page.getByTestId('alert-actions-menu').click();
await expect(page.getByRole('menuitem', { name: 'Duplicate' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Rename' })).toHaveCount(0);
});
test('AD-02 v1 header shows static title with state, severity and labels', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertDetails(page, alertHistory.ruleIdV1);
await expect(page.getByTestId('alert-details-root')).toHaveAttribute(
'data-schema-version',
'v1',
);
await expect(page.getByTestId('alert-header-title')).toBeVisible();
await expect(page.getByTestId('alert-header-state')).toBeVisible();
await expect(page.getByTestId('alert-header-severity')).toContainText(
'Warning',
);
await expect(page.getByTestId('alert-header-labels')).toContainText(
SEED_C_TEAM_LABEL,
);
await expect(page.getByTestId('alert-name-input')).toHaveCount(0);
await page.getByTestId('alert-actions-menu').click();
await expect(page.getByRole('menuitem', { name: 'Rename' })).toBeVisible();
});
});

View File

@@ -0,0 +1,33 @@
import { expect, test } from '../../../fixtures/alert-rules';
import {
ALERT_HISTORY_PATH,
ALERT_OVERVIEW_PATH,
} from '../../../helpers/alerts';
test.describe('Alert details — not found', () => {
test('AD-11 invalid ruleId shows AlertNotFound page', async ({
authedPage: page,
}) => {
await page.goto(`${ALERT_HISTORY_PATH}?ruleId=not-a-real-rule-id`);
await expect(
page.getByText("Uh-oh! We couldn't find the given alert rule."),
).toBeVisible();
await page.goto(
`${ALERT_HISTORY_PATH}?ruleId=01920000-0000-7000-8000-000000000000`,
);
await expect(
page.getByText("Uh-oh! We couldn't find the given alert rule."),
).toBeVisible();
});
test('AD-12 missing ruleId on overview shows AlertNotFound page', async ({
authedPage: page,
}) => {
await page.goto(ALERT_OVERVIEW_PATH);
await expect(
page.getByText("Uh-oh! We couldn't find the given alert rule."),
).toBeVisible();
});
});

View File

@@ -0,0 +1,74 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
ALERTS_LIST_PATH,
gotoAlertDetails,
gotoAlertHistory,
} from '../../../helpers/alerts';
test.describe('Alert details — rename', () => {
test('AD-03 v1 rename via modal updates the rule name', async ({
authedPage: page,
ownedRules,
}) => {
const stamp = Date.now();
const original = `e2e-ad-rename-v1-${stamp}`;
const renamed = `${original}-renamed`;
const ruleId = await ownedRules.logs({ name: original, schema: 'v1' });
await gotoAlertDetails(page, ruleId);
await expect(page.getByTestId('alert-header-title')).toContainText(original);
await page.getByTestId('alert-actions-menu').click();
await page.getByRole('menuitem', { name: 'Rename' }).click();
const modalInput = page.getByTestId('alert-name');
await expect(modalInput).toBeVisible();
await modalInput.fill(renamed);
await page.getByRole('button', { name: 'Rename Alert' }).click();
await expect(page.getByText('Alert renamed successfully')).toBeVisible();
await expect(page.getByTestId('alert-header-title')).toContainText(renamed);
await page.goto(`${ALERTS_LIST_PATH}?search=${renamed}`);
await expect(page.getByText(renamed)).toBeVisible();
});
test('AD-04 v2 inline rename saves via Overview footer button', async ({
authedPage: page,
ownedRules,
}) => {
const stamp = Date.now();
const original = `e2e-ad-rename-v2-${stamp}`;
const renamed = `${original}-renamed`;
const ruleId = await ownedRules.logs({ name: original, schema: 'v2' });
await gotoAlertDetails(page, ruleId);
const nameInput = page.getByTestId('alert-name-input');
await expect(nameInput).toHaveValue(original);
await nameInput.fill(renamed);
await Promise.all([
page.waitForResponse(
(res) =>
res.url().includes(`/api/v2/rules/${ruleId}`) &&
['PUT', 'POST', 'PATCH'].includes(res.request().method()),
),
page.getByRole('button', { name: 'Save Alert Rule' }).click(),
]);
await page.goto(`${ALERTS_LIST_PATH}?search=${renamed}`);
await expect(page.getByText(renamed)).toBeVisible();
await gotoAlertHistory(page, ruleId);
const historyInput = page.getByTestId('alert-name-input');
await historyInput.fill(`${renamed}-unsaved`);
await expect(
page.getByRole('button', { name: 'Save Alert Rule' }),
).toHaveCount(0);
await page.goto(`${ALERTS_LIST_PATH}?search=${renamed}`);
await expect(page.getByText(renamed)).toBeVisible();
await expect(page.getByText(`${renamed}-unsaved`)).toHaveCount(0);
});
});

View File

@@ -0,0 +1,55 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
ALERT_HISTORY_PATH,
ALERT_OVERVIEW_PATH,
DEFAULT_RELATIVE_TIME,
gotoAlertDetails,
gotoAlertHistory,
} from '../../../helpers/alerts';
test.describe('Alert details — tabs', () => {
test('AD-05 Overview/History tabs preserve ruleId and relativeTime', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertDetails(page, alertHistory.ruleId);
await page.getByTestId('alert-details-tab-history').click();
await expect(page).toHaveURL(new RegExp(ALERT_HISTORY_PATH));
await expect(page).toHaveURL(new RegExp(`ruleId=${alertHistory.ruleId}`));
await expect(page).toHaveURL(
new RegExp(`relativeTime=${DEFAULT_RELATIVE_TIME}`),
);
await expect(
page.getByTestId('alert-details-tab-history').getByText('Beta'),
).toBeVisible();
await page.getByTestId('alert-details-tab-overview').click();
await expect(page).toHaveURL(new RegExp(ALERT_OVERVIEW_PATH));
await expect(page).toHaveURL(new RegExp(`ruleId=${alertHistory.ruleId}`));
});
test('AD-05b switching to History tab discards other history params', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, {
page: '2',
order: 'desc',
timelineFilter: 'FIRED',
});
await page.getByTestId('alert-details-tab-overview').click();
await expect(page).toHaveURL(new RegExp(ALERT_OVERVIEW_PATH));
await expect(page).toHaveURL(/[?&]timelineFilter=FIRED/);
await page.getByTestId('alert-details-tab-history').click();
await expect(page).toHaveURL(new RegExp(ALERT_HISTORY_PATH));
const search = new URL(page.url()).searchParams;
expect([...search.keys()].sort()).toEqual(['relativeTime', 'ruleId']);
expect(search.get('ruleId')).toBe(alertHistory.ruleId);
});
});

View File

@@ -0,0 +1,22 @@
import { expect, test } from '../../../fixtures/alert-rules';
import { gotoAlertOverview } from '../../../helpers/alerts';
const TARGET = 245;
test.describe('Alert overview — threshold persistence', () => {
test('TC-02 edit page displays the saved threshold value', async ({
authedPage: page,
ownedRules,
}) => {
const ruleId = await ownedRules.threshold(
`e2e-threshold-persistence-${Date.now()}`,
{ target: TARGET },
);
await gotoAlertOverview(page, ruleId);
await expect(page.getByTestId('threshold-value-input')).toHaveValue(
String(TARGET),
);
});
});

View File

@@ -0,0 +1,54 @@
import { expect, test } from '../../../fixtures/alert-rules';
import { ALERT_EDIT_PATH } from '../../../helpers/alert-forms';
import { ALERT_OVERVIEW_PATH } from '../../../helpers/alerts';
// CE-03 — what the edit routes do with an unknown ruleId.
//
// `pages/EditRules`'s own error branches are unreachable: `/alerts/edit` is a
// legacy alias redirected to `/alerts/overview` before any route matches
// (`AppRoutes/Private.tsx`), and the details shell validates the id before
// rendering the Overview tab. So neither the "Rule Id is required" notification nor
// the `edit-rules-container--error` card can ever render — this file asserts the
// behaviour that replaces them.
const UNKNOWN_RULE_ID = '999999999';
test.describe('Alert edit — routing edges', () => {
test('CE-03 an unknown ruleId shows AlertNotFound on both entry URLs', async ({
authedPage: page,
}) => {
for (const entry of [
`${ALERT_OVERVIEW_PATH}?ruleId=${UNKNOWN_RULE_ID}`,
`${ALERT_EDIT_PATH}?ruleId=${UNKNOWN_RULE_ID}`,
]) {
// eslint-disable-next-line no-await-in-loop
await page.goto(entry);
// `AlertDetails.tsx` returns AlertNotFound before the provider or the editor
// mount, so neither the details root nor the standalone route's error card may
// appear.
// eslint-disable-next-line no-await-in-loop
await expect(page.locator('.alert-not-found')).toBeVisible();
// eslint-disable-next-line no-await-in-loop
await expect(
page.getByText("Uh-oh! We couldn't find the given alert rule."),
).toBeVisible();
// eslint-disable-next-line no-await-in-loop
await expect(page.getByTestId('alert-details-root')).toBeHidden();
// eslint-disable-next-line no-await-in-loop
await expect(page.locator('.edit-rules-container--error')).toBeHidden();
}
});
test('CE-03b /alerts/edit with no ruleId also lands on AlertNotFound', async ({
authedPage: page,
}) => {
await page.goto(ALERT_EDIT_PATH);
// The alias redirect fires first, so `pages/EditRules`'s missing-id branch — an
// error notification plus a navigation to `/alerts` — never runs.
await page.waitForURL(/\/alerts\/overview/);
await expect(page.locator('.alert-not-found')).toBeVisible();
await expect(page.getByText('Rule Id is required')).toBeHidden();
});
});

View File

@@ -0,0 +1,291 @@
import { expect, test, type OwnedRules } from '../../../fixtures/alert-rules';
import {
ALERT_EDIT_PATH,
v1CancelButton,
v1ChannelSelect,
v1ConfirmSave,
v1DescriptionInput,
v1EvalWindowSelect,
v1MatchTypeSelect,
v1NameInput,
v1OperatorSelect,
v1SaveButton,
v1SeveritySelect,
v1ThresholdInput,
v1BroadcastSwitch,
selectedTags,
} from '../../../helpers/alert-forms';
import { authToken, watchConsole } from '../../../helpers/common';
import { gotoAlertDetails } from '../../../helpers/alerts';
// EV1-* — editing a rule whose `schemaVersion` is *not* `v2alpha1`.
//
// The classic form is what renders, inside the same details shell the v2 builder
// uses: `container/EditRules/index.tsx` picks the form from the rule, so there is no
// way to open a v1 rule in the v2 builder (and EV1-08 asserts the reverse — editing
// never migrates the schema).
//
// `gotoAlertOverview` cannot be used here: it waits for `threshold-value-input`,
// which only the v2 builder renders.
/**
* SEED-RV1 — every asserted field deliberately differs from `alertDefaults`, so a
* passing prefill assertion cannot be satisfied by the create form's own defaults.
*/
const SEED_RV1 = {
target: 73,
/** `2` is *below*, against the create default of `1` (*above*). */
op: '2',
/** `2` is *all the times*, against the create default of `1` (*at least once*). */
matchType: '2',
evalWindow: '15m0s',
severity: 'error',
} as const;
/** The description every seeded rule carries (`ANNOTATIONS` in `helpers/alerts.ts`). */
const SEEDED_DESCRIPTION =
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})';
type LogsSeedOverrides = Omit<Parameters<OwnedRules['logs']>[0], 'name'>;
function seedRv1(
ownedRules: OwnedRules,
name: string,
extra: Partial<LogsSeedOverrides> = {},
): Promise<string> {
return ownedRules.logs({
schema: 'v1',
target: SEED_RV1.target,
op: SEED_RV1.op,
matchType: SEED_RV1.matchType,
evalWindow: SEED_RV1.evalWindow,
severity: SEED_RV1.severity,
...extra,
name,
});
}
/** Read a rule straight from the API — EV1-08 needs the stored schema version. */
async function readRule(
page: import('@playwright/test').Page,
ruleId: string,
): Promise<Record<string, unknown>> {
const token = await authToken(page);
const res = await page.request.get(`/api/v2/rules/${ruleId}`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.ok()).toBe(true);
const json = (await res.json()) as { data: Record<string, unknown> };
return json.data;
}
test.describe('Alert edit — v1 rule', () => {
test('EV1-01 the classic form renders in edit mode inside the details shell', async ({
authedPage: page,
ownedRules,
}) => {
const ruleId = await seedRv1(ownedRules, `e2e-ev1-shell-${Date.now()}`);
await gotoAlertDetails(page, ruleId);
const root = page.getByTestId('alert-details-root');
// The shell marks the schema on the root node, and only v2 rules get the
// `alert-details-v2` class.
await expect(root).toHaveAttribute('data-schema-version', 'v1');
await expect(root).not.toHaveClass(/alert-details-v2/);
// The classic form, not the builder: v1's own name field exists and the v2
// header's does not.
await expect(v1NameInput(page)).toBeVisible();
await expect(page.getByTestId('alert-name-input')).toHaveCount(0);
await expect(
page.locator('.form-alert-rules-container.edit-mode'),
).toBeVisible();
await expect(v1SaveButton(page)).toHaveText(/Save Rule/);
await expect(v1CancelButton(page)).toHaveText(/Discard/);
});
test('EV1-02 every seeded field prefills the form', async ({
authedPage: page,
ownedRules,
}) => {
const name = `e2e-ev1-prefill-${Date.now()}`;
const ruleId = await seedRv1(ownedRules, name, {
extraLabels: { team: 'payments' },
});
await gotoAlertDetails(page, ruleId);
await expect(v1NameInput(page)).toHaveValue(name);
await expect(v1DescriptionInput(page)).toHaveValue(SEEDED_DESCRIPTION);
await expect(v1SeveritySelect(page)).toContainText('Error');
// The four `RuleOptions` controls — the values CV1-14 writes, read back from the
// other side.
await expect(v1ThresholdInput(page)).toHaveValue(String(SEED_RV1.target));
await expect(v1OperatorSelect(page)).toContainText('below');
await expect(v1MatchTypeSelect(page)).toContainText('all the times');
await expect(v1EvalWindowSelect(page)).toContainText('15 mins');
await expect(page.getByText('team: payments')).toBeVisible();
});
test('EV1-03 preferredChannels decide which channel control is prefilled', async ({
authedPage: page,
ownedRules,
alertChannel,
}) => {
const ruleId = await seedRv1(ownedRules, `e2e-ev1-channels-${Date.now()}`);
await gotoAlertDetails(page, ruleId);
// `BasicInfo.tsx` reads `preferredChannels` *once*, on mount: a rule that names
// channels gets the switch **off** and the select filled. The tag is counted
// rather than name-matched, since the select truncates at 10 characters.
await expect(v1BroadcastSwitch(page)).toHaveAttribute(
'aria-checked',
'false',
);
await expect(v1ChannelSelect(page)).toBeVisible();
await expect(selectedTags(v1ChannelSelect(page))).toHaveCount(1);
expect(alertChannel.name).toBeTruthy();
});
test('EV1-04 the happy-path update PUTs the v1 body and keeps unrelated params', async ({
authedPage: page,
ownedRules,
}) => {
const name = `e2e-ev1-update-${Date.now()}`;
const ruleId = await seedRv1(ownedRules, name);
await gotoAlertDetails(page, ruleId);
await v1ThresholdInput(page).fill('81');
await v1SaveButton(page).click();
const [response] = await Promise.all([
page.waitForResponse(
(r) =>
r.request().method() === 'PUT' &&
r.url().includes(`/api/v2/rules/${ruleId}`),
),
v1ConfirmSave(page),
]);
// **`PUT /api/v2/rules/{id}`**, not `/api/v1/rules/{id}`: there is no v1 rules
// client in the frontend at all, so both forms share the endpoint and differ only
// in the body.
expect(response.ok()).toBe(true);
const body = response.request().postDataJSON();
expect(body.condition.target).toBe(81);
expect(body.condition.op).toBe(SEED_RV1.op);
expect(body.evalWindow).toBe(SEED_RV1.evalWindow);
expect(body.schemaVersion).toBeUndefined();
await expect(page.getByText('Rule edited successfully')).toBeVisible();
await page.waitForURL(/\/alerts(\?|$)/);
expect(new URL(page.url()).pathname).toBe('/alerts');
// `saveRule` strips exactly four params on the way out.
const params = new URL(page.url()).searchParams;
expect(params.get('ruleId')).toBeNull();
expect(params.get('compositeQuery')).toBeNull();
await gotoAlertDetails(page, ruleId);
await expect(v1ThresholdInput(page)).toHaveValue('81');
});
test('EV1-05 Discard leaves without a PUT and without changing the rule', async ({
authedPage: page,
ownedRules,
}) => {
const ruleId = await seedRv1(ownedRules, `e2e-ev1-discard-${Date.now()}`);
await gotoAlertDetails(page, ruleId);
let sawPut = false;
page.on('request', (request) => {
if (request.method() === 'PUT' && request.url().includes('/rules/')) {
sawPut = true;
}
});
await v1ThresholdInput(page).fill('999');
await v1CancelButton(page).click();
await page.waitForURL(/\/alerts(\?|$)/);
expect(sawPut).toBe(false);
await gotoAlertDetails(page, ruleId);
await expect(v1ThresholdInput(page)).toHaveValue(String(SEED_RV1.target));
});
test('EV1-06 the header title and the form name field agree', async ({
authedPage: page,
ownedRules,
}) => {
const name = `e2e-ev1-header-${Date.now()}`;
const ruleId = await seedRv1(ownedRules, name);
await gotoAlertDetails(page, ruleId);
// v1's `AlertHeader` renders the name as static text while the form renders it as
// an input; two sources for one value, so they are asserted together. Renaming
// through the header's modal is AD-03's scenario — this row pins the precondition
// that makes it meaningful.
await expect(page.getByTestId('alert-details-root')).toContainText(name);
await expect(v1NameInput(page)).toHaveValue(name);
});
test('EV1-07 /alerts/edit redirects for a v1 rule exactly as it does for v2', async ({
authedPage: page,
ownedRules,
}) => {
const ruleId = await seedRv1(ownedRules, `e2e-ev1-alias-${Date.now()}`);
// The alias is resolved by `AppRoutes/Private.tsx` before route matching, so
// `pages/EditRules` never renders standalone and there is no v1/v2 asymmetry.
const watch = watchConsole(page);
await page.goto(`${ALERT_EDIT_PATH}?ruleId=${ruleId}`);
await page.waitForURL(/\/alerts\/overview/);
expect(new URL(page.url()).searchParams.get('ruleId')).toBe(ruleId);
await expect(page.getByTestId('alert-details-root')).toHaveAttribute(
'data-schema-version',
'v1',
);
await expect(v1SaveButton(page)).toBeVisible();
expect(watch.errors).toEqual([]);
});
test('EV1-08 editing a v1 rule never migrates it to the v2 schema', async ({
authedPage: page,
ownedRules,
}) => {
const ruleId = await seedRv1(ownedRules, `e2e-ev1-noupgrade-${Date.now()}`);
await gotoAlertDetails(page, ruleId);
await v1ThresholdInput(page).fill('91');
await v1SaveButton(page).click();
await Promise.all([
page.waitForResponse(
(r) =>
r.request().method() === 'PUT' &&
r.url().includes(`/api/v2/rules/${ruleId}`),
),
v1ConfirmSave(page),
]);
await page.waitForURL(/\/alerts(\?|$)/);
// The classic form posts a v1 body and `pages/EditRules` picks the editor from
// the *stored* schema, so a saved v1 rule stays v1. Asserted from the API as well
// as the DOM — the UI could pick the right form off a cached response.
const rule = await readRule(page, ruleId);
expect(rule.schemaVersion).not.toBe('v2alpha1');
await gotoAlertDetails(page, ruleId);
await expect(page.getByTestId('alert-details-root')).toHaveAttribute(
'data-schema-version',
'v1',
);
await expect(page.getByTestId('alert-name-input')).toHaveCount(0);
});
});

View File

@@ -0,0 +1,420 @@
import { expect, test } from '../../../fixtures/alert-rules';
import {
ALERT_EDIT_PATH,
EVALUATION_WINDOW_PRESETS,
evaluationCadenceInput,
evaluationCadenceUnitSelect,
evaluationSettingsButton,
evaluationWindowOption,
expandAdvancedOptions,
openEvaluationSettings,
thresholdRows,
ThresholdMatchType,
ThresholdOperator,
v2ClickDiscard,
v2SaveButton,
} from '../../../helpers/alert-forms';
import { gotoAlertOverview } from '../../../helpers/alerts';
import { watchConsole } from '../../../helpers/common';
// EV2-* — editing a rule whose `schemaVersion` is `v2alpha1`.
//
// Unless a scenario says otherwise these run through `/alerts/overview?ruleId=`,
// which is the route the rules list's Edit action actually uses. EV2-12 is the
// exception and the reason the distinction matters: the same editor reached
// through `/alerts/edit` has no `CreateAlertProvider` above it.
/**
* SEED-RV2 — a v2 rule whose every asserted field differs from the create-form
* default, so a passing prefill assertion cannot be satisfied by the defaults.
*/
const SEED_RV2 = {
target: 42,
warningTarget: 21,
evalWindow: '10m0s',
/** Deliberately *not* one of the rolling presets — see EV2-05. */
customEvalWindow: '7m0s',
frequency: '5m',
renotifyInterval: '2h',
absentFor: 7,
} as const;
/** PUT for one specific rule. v1 and v2 share this endpoint. */
function isRuleUpdate(url: string, method: string, ruleId: string): boolean {
return method === 'PUT' && url.includes(`/api/v2/rules/${ruleId}`);
}
test.describe('Alert edit — v2 rule', () => {
test('EV2-01 the v2 editor renders inside the details shell', async ({
authedPage: page,
ownedRules,
}) => {
const ruleId = await ownedRules.threshold(`e2e-ev2-shell-${Date.now()}`, {
target: SEED_RV2.target,
});
await gotoAlertOverview(page, ruleId);
const root = page.getByTestId('alert-details-root');
await expect(root).toHaveClass(/alert-details-v2/);
await expect(root).toHaveAttribute('data-schema-version', 'v2alpha1');
// `CreateAlertHeader` hides the whole tab bar in edit mode, which removes both
// the "New Alert Rule" chip and the classic-experience escape hatch. The escape
// hatch matters: switching experiences mid-edit would silently drop the loaded
// rule.
await expect(page.getByTestId('alert-name-input')).toBeVisible();
await expect(page.getByText('New Alert Rule')).toBeHidden();
await expect(
page.getByRole('button', { name: 'Switch to Classic Experience' }),
).toBeHidden();
});
test('EV2-02 name and labels prefill from the rule', async ({
authedPage: page,
ownedRules,
}) => {
const name = `e2e-ev2-prefill-${Date.now()}`;
const ruleId = await ownedRules.threshold(name, {
target: SEED_RV2.target,
labels: { severity: 'critical', team: 'payments' },
});
await gotoAlertOverview(page, ruleId);
await expect(page.getByTestId('alert-name-input')).toHaveValue(name);
await expect(page.getByTestId('label-pill-severity-critical')).toBeVisible();
await expect(page.getByTestId('label-pill-team-payments')).toBeVisible();
});
test('EV2-03 both thresholds prefill, and the sentence reads spec[0]', async ({
authedPage: page,
ownedRules,
alertChannel,
}) => {
const ruleId = await ownedRules.threshold(
`e2e-ev2-thresholds-${Date.now()}`,
{
thresholds: [
{
name: 'critical',
target: SEED_RV2.target,
op: ThresholdOperator.BELOW.value,
matchType: ThresholdMatchType.ALL_THE_TIME.value,
channels: [alertChannel.name],
},
{
name: 'warning',
target: SEED_RV2.warningTarget,
op: ThresholdOperator.BELOW.value,
matchType: ThresholdMatchType.ALL_THE_TIME.value,
channels: [alertChannel.name],
},
],
},
);
await gotoAlertOverview(page, ruleId);
await expect(thresholdRows(page)).toHaveCount(2);
await expect(page.getByTestId('threshold-name-input').nth(0)).toHaveValue(
'critical',
);
await expect(page.getByTestId('threshold-name-input').nth(1)).toHaveValue(
'warning',
);
await expect(page.getByTestId('threshold-value-input').nth(0)).toHaveValue(
String(SEED_RV2.target),
);
await expect(page.getByTestId('threshold-value-input').nth(1)).toHaveValue(
String(SEED_RV2.warningTarget),
);
// The condition sentence is rule-wide in the UI but per-threshold in the schema,
// and the mapper reads it back from `spec[0]` only. Both seeded thresholds share
// op/matchType so this row stays about prefill.
await expect(
page.getByTestId('alert-threshold-operator-select'),
).toContainText(ThresholdOperator.BELOW.label);
await expect(
page.getByTestId('alert-threshold-match-type-select'),
).toContainText(ThresholdMatchType.ALL_THE_TIME.label);
});
test('EV2-04 the recovery threshold control never renders', async ({
authedPage: page,
ownedRules,
}) => {
const ruleId = await ownedRules.threshold(`e2e-ev2-recovery-${Date.now()}`, {
target: SEED_RV2.target,
recoveryTarget: 10,
});
await gotoAlertOverview(page, ruleId);
// `showRecoveryThreshold` starts false and its only setter is commented out, so a
// seeded `recoveryTarget` has nowhere to land. If either locator ever appears the
// feature was finished, and CV2-13/EV2-04 need rewriting rather than deleting.
await expect(page.getByTestId('recovery-threshold-value-input')).toHaveCount(
0,
);
await expect(
page.getByTestId('remove-recovery-threshold-button'),
).toHaveCount(0);
});
test('EV2-05 the evaluation window prefills, and a non-preset value collapses to custom', async ({
authedPage: page,
ownedRules,
}) => {
const presetRule = await ownedRules.threshold(
`e2e-ev2-window-${Date.now()}`,
{ target: SEED_RV2.target, evalWindow: SEED_RV2.evalWindow },
);
await gotoAlertOverview(page, presetRule);
// The trigger button carries both halves of the window: the timeframe label and
// the window *type*, which comes from the seeded `evaluation.kind`.
await expect(evaluationSettingsButton(page)).toContainText(
EVALUATION_WINDOW_PRESETS[SEED_RV2.evalWindow],
);
await expect(evaluationSettingsButton(page)).toContainText('Rolling');
const customRule = await ownedRules.threshold(
`e2e-ev2-window-custom-${Date.now()}`,
{ target: SEED_RV2.target, evalWindow: SEED_RV2.customEvalWindow },
);
await gotoAlertOverview(page, customRule);
// `getRollingWindowTimeframe` only recognises the seven presets; anything else
// becomes `custom`, and the label is then built from the parsed number + unit. So
// a rule created outside the UI with an odd window keeps its value — it just
// renders through the custom branch.
await expect(evaluationSettingsButton(page)).toContainText('Last 7 Minutes');
await openEvaluationSettings(page);
await expect(evaluationWindowOption(page, 'timeframe', 'custom')).toHaveClass(
/active/,
);
});
test('EV2-06 repeat notifications prefill from the seeded renotify block', async ({
authedPage: page,
ownedRules,
}) => {
const ruleId = await ownedRules.threshold(`e2e-ev2-renotify-${Date.now()}`, {
target: SEED_RV2.target,
renotify: {
enabled: true,
interval: SEED_RV2.renotifyInterval,
alertStates: ['firing'],
},
});
await gotoAlertOverview(page, ruleId);
// Every control in the block is `disabled={!reNotification.enabled}`, so "is it
// enabled" is a stronger read of the toggle than the Switch's own state.
const interval = page.getByTestId('repeat-notifications-time-input');
await expect(interval).toBeEnabled();
// `parseGoTime` splits the Go duration into value + unit.
await expect(interval).toHaveValue('2');
await expect(
page.getByTestId('repeat-notifications-unit-select'),
).toContainText('Hours');
await expect(
page.getByTestId('repeat-notifications-conditions-select'),
).toContainText('Firing');
});
test('EV2-07 alertOnAbsent prefills the advanced options', async ({
authedPage: page,
ownedRules,
}) => {
const ruleId = await ownedRules.threshold(`e2e-ev2-absent-${Date.now()}`, {
target: SEED_RV2.target,
alertOnAbsent: { absentFor: SEED_RV2.absentFor },
});
await gotoAlertOverview(page, ruleId);
await expandAdvancedOptions(page);
const tolerance = page.getByTestId(
'send-notification-if-data-is-missing-input',
);
await expect(tolerance).toBeVisible();
await expect(tolerance).toHaveValue(String(SEED_RV2.absentFor));
// The sibling option was not seeded, so its input stays behind the
// `display: none` its container applies when the toggle is off — asserted so a
// prefill that turned *every* advanced option on would still fail this row.
await expect(
page.getByTestId('enforce-minimum-datapoints-input'),
).toBeHidden();
});
test('EV2-08 the evaluation cadence always reads back in default mode', async ({
authedPage: page,
ownedRules,
}) => {
const ruleId = await ownedRules.threshold(`e2e-ev2-cadence-${Date.now()}`, {
target: SEED_RV2.target,
frequency: SEED_RV2.frequency,
});
await gotoAlertOverview(page, ruleId);
await expandAdvancedOptions(page);
await expect(evaluationCadenceInput(page)).toHaveValue('5');
await expect(evaluationCadenceUnitSelect(page)).toContainText('Minutes');
// `getAdvancedOptionsStateFromAlertDef` hardcodes `mode: 'default'`, and
// `EditCustomSchedule` mounts only when the mode is *not* default — so a schedule
// saved by any other client reopens as a plain interval and the next save
// persists the flattened value. The custom and rrule modes are unreachable from
// the UI (the "Add custom schedule" button is commented out) and unrepresentable
// in the payload, so the flattening itself is what this row asserts; the day the
// custom editor can mount, it fails.
await expect(page.locator('.edit-custom-schedule')).toHaveCount(0);
});
test('EV2-09 changing a threshold PUTs the rule and the change survives a reload', async ({
authedPage: page,
ownedRules,
}) => {
const name = `e2e-ev2-update-${Date.now()}`;
const ruleId = await ownedRules.threshold(name, {
target: SEED_RV2.target,
});
await gotoAlertOverview(page, ruleId);
await page.getByTestId('threshold-value-input').first().fill('99');
const [response] = await Promise.all([
page.waitForResponse((r) =>
isRuleUpdate(r.url(), r.request().method(), ruleId),
),
v2SaveButton(page).click(),
]);
expect(response.ok()).toBe(true);
const body = response.request().postDataJSON();
expect(body.condition.thresholds.spec[0].target).toBe(99);
// The mapper hardcodes the schema version on every save, so an edit of a v2 rule
// stays v2.
expect(body.schemaVersion).toBe('v2alpha1');
expect(body.alert).toBe(name);
await expect(page.getByText('Alert rule updated successfully')).toBeVisible();
await page.waitForURL(/\/alerts(\?|$)/);
expect(new URL(page.url()).pathname).toBe('/alerts');
// Re-read rather than trust the toast: the footer invalidates the rule and list
// caches, and a stale cache would show the old value here.
await gotoAlertOverview(page, ruleId);
await expect(page.getByTestId('threshold-value-input').first()).toHaveValue(
'99',
);
});
test('EV2-10 the footer save is what persists a rename made on the Overview tab', async ({
authedPage: page,
ownedRules,
}) => {
const name = `e2e-ev2-rename-${Date.now()}`;
const renamed = `${name}-renamed`;
const ruleId = await ownedRules.threshold(name, {
target: SEED_RV2.target,
});
await gotoAlertOverview(page, ruleId);
await page.getByTestId('alert-name-input').fill(renamed);
// AD-03/AD-04 cover renaming through the details header's RenameModal, which
// PATCHes on its own. This row is the other path: the editable header field is
// local state until the *Footer* saves it, so the assertion is on the PUT body.
const [response] = await Promise.all([
page.waitForResponse((r) =>
isRuleUpdate(r.url(), r.request().method(), ruleId),
),
v2SaveButton(page).click(),
]);
expect(response.request().postDataJSON().alert).toBe(renamed);
await gotoAlertOverview(page, ruleId);
await expect(page.getByTestId('alert-name-input')).toHaveValue(renamed);
});
test('EV2-11 Discard leaves without a PUT and without touching the rule', async ({
authedPage: page,
ownedRules,
}) => {
const name = `e2e-ev2-discard-${Date.now()}`;
const ruleId = await ownedRules.threshold(name, {
target: SEED_RV2.target,
});
await gotoAlertOverview(page, ruleId);
let sawPut = false;
page.on('request', (request) => {
if (request.method() === 'PUT' && request.url().includes('/rules/')) {
sawPut = true;
}
});
await page.getByTestId('threshold-value-input').first().fill('999');
// dispatchEvent, not click — the side navigation covers the button (CE-09).
await v2ClickDiscard(page);
await page.waitForURL(/\/alerts(\?|$)/);
expect(sawPut).toBe(false);
// `discardAlertRule` also forces the *context's* alertType back to metrics, which
// is harmless on the way out only if the stored rule is untouched.
await gotoAlertOverview(page, ruleId);
await expect(page.getByTestId('threshold-value-input').first()).toHaveValue(
String(SEED_RV2.target),
);
await expect(page.getByTestId('alert-name-input')).toHaveValue(name);
});
test('EV2-12 /alerts/edit is a legacy alias that redirects into the details shell', async ({
authedPage: page,
ownedRules,
}) => {
const ruleId = await ownedRules.threshold(
`e2e-ev2-standalone-${Date.now()}`,
{ target: SEED_RV2.target },
);
// Reading the components suggests this route should crash: `EditAlertV2` renders
// Footer/AlertCondition/NotificationSettings, all of which call
// `useCreateAlertState()`, and the only provider lives in `AlertDetails.tsx`. It
// does not crash, because `/alerts/edit` never renders `pages/EditRules` at all —
// `AppRoutes/Private.tsx` redirects the legacy alias to `/alerts/overview`,
// merging the search params, before route matching.
//
// Worth keeping despite being a redirect: the alias is linked from the AI
// assistant and Metrics Explorer, so a regression in `oldNewRoutesMapping` would
// break real product links.
const watch = watchConsole(page);
await page.goto(`${ALERT_EDIT_PATH}?ruleId=${ruleId}`);
await page.waitForURL(/\/alerts\/overview/);
expect(new URL(page.url()).pathname).toBe('/alerts/overview');
expect(new URL(page.url()).searchParams.get('ruleId')).toBe(ruleId);
// The editor mounted under the shell's provider, so the missing-provider error
// never fires. Asserted explicitly: if someone removes the redirect entry, this
// is the message that would appear.
await expect(thresholdRows(page)).toHaveCount(1);
expect(
watch.errors.filter((message) =>
message.includes('useCreateAlertState must be used within'),
),
).toEqual([]);
});
});

View File

@@ -0,0 +1,223 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
ALERT_HISTORY_PATH,
DEFAULT_RELATIVE_TIME,
encodeTimelineCursor,
gotoAlertHistory,
HISTORY_ENDPOINTS,
isHistoryRequest,
sortTimelineDescending,
statsCard,
TIMELINE_PAGE_SIZE,
timelineFooterRange,
timelineRows,
} from '../../../helpers/alerts';
import {
collectRequests,
requestUrl,
watchConsole,
} from '../../../helpers/common';
test.describe('Alert history — cross-cutting', () => {
test('AX-01 full deep-link with all params is honoured in one load', async ({
authedPage: page,
alertHistory,
}) => {
const service = alertHistory.services[8];
await page.goto(
`${ALERT_HISTORY_PATH}?ruleId=${alertHistory.ruleId}` +
`&relativeTime=${DEFAULT_RELATIVE_TIME}` +
`&timelineFilter=FIRED&page=1&order=desc` +
`&alertHistoryExpression=${encodeURIComponent(`service.name = '${service}'`)}` +
`&viewAllTopContributors=true`,
);
await expect(page.getByTestId('timeline-table')).toBeVisible();
await expect(page.getByTestId('top-contributors-drawer')).toBeVisible();
await expect(page.getByTestId('timeline-filter-fired')).toHaveClass(
/selected/,
);
await expect(timelineRows(page)).toHaveCount(1);
});
test('AX-02 page reload preserves all history params', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, {
timelineFilter: 'FIRED',
page: '2',
order: 'desc',
});
const before = new URL(page.url()).searchParams;
await page.reload();
await expect(page.getByTestId('timeline-table')).toBeVisible();
const after = new URL(page.url()).searchParams;
for (const [key, value] of before) {
expect(after.get(key), `param ${key} survived the reload`).toBe(value);
}
});
test('AX-03 browser back/forward restores correct table state', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
await page.getByTestId('timeline-filter-fired').click();
await expect(page).toHaveURL(/[?&]timelineFilter=FIRED/);
await page.getByTestId('timeline-next-page').click();
await expect(page).toHaveURL(/[?&]page=2/);
await expect(timelineRows(page)).toHaveCount(
alertHistory.total - TIMELINE_PAGE_SIZE,
);
await page.goBack();
await expect(page).not.toHaveURL(/[?&]page=2/);
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
await page.goForward();
await expect(page).toHaveURL(/[?&]page=2/);
await expect(timelineRows(page)).toHaveCount(
alertHistory.total - TIMELINE_PAGE_SIZE,
);
});
test('AX-04 no unhandled console errors across full history session', async ({
authedPage: page,
alertHistory,
}) => {
const watch = watchConsole(page);
await gotoAlertHistory(page, alertHistory.ruleId);
await page.getByTestId('timeline-filter-fired').click();
await page.getByTestId('timeline-next-page').click();
await expect(page).toHaveURL(/[?&]page=2/);
await sortTimelineDescending(page);
await expect(page).toHaveURL(/[?&]order=desc/);
await page.getByTestId('top-contributors-view-all').click();
await expect(page.getByTestId('top-contributors-drawer')).toBeVisible();
expect(watch.errors).toEqual([]);
expect(watch.failedResponses).toEqual([]);
});
test('AX-05 no request storm on mount (exactly one call per endpoint)', async ({
authedPage: page,
alertHistory,
}) => {
const requests = collectRequests(page);
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
await expect(
statsCard(page, 'Total Triggered').getByTestId('stats-card-value'),
).toBeVisible();
for (const endpoint of HISTORY_ENDPOINTS) {
const calls = requests.filter((req) => isHistoryRequest(req, endpoint));
expect(calls, `${endpoint} called exactly once on mount`).toHaveLength(1);
}
});
test('AX-06 v1 and v2 schema rules both render history correctly', async ({
authedPage: page,
alertHistory,
}) => {
for (const [variant, ruleId, total] of [
['v2', alertHistory.ruleId, alertHistory.total],
['v1', alertHistory.ruleIdV1, alertHistory.totalV1],
] as const) {
await gotoAlertHistory(page, ruleId);
await expect(
page.getByTestId('alert-details-root'),
`${variant} rule renders the details shell`,
).toHaveAttribute(
'data-schema-version',
variant === 'v2' ? 'v2alpha1' : 'v1',
);
await expect(timelineFooterRange(page)).toContainText(`of ${total}`);
}
});
test('AX-07 no legacy v1 history API calls during full session', async ({
authedPage: page,
alertHistory,
}) => {
const requests = collectRequests(page);
await gotoAlertHistory(page, alertHistory.ruleId);
await page.getByTestId('timeline-filter-fired').click();
await page.getByTestId('timeline-next-page').click();
await expect(page).toHaveURL(/[?&]page=2/);
await sortTimelineDescending(page);
await expect(page).toHaveURL(/[?&]order=desc/);
await gotoAlertHistory(page, alertHistory.ruleId, { relativeTime: '6h' });
const legacy = requests.filter((req) =>
/\/api\/v1\/rules\/[^/]+\/history\//.test(req.url()),
);
expect(
legacy.map((req) => req.url()),
'no legacy POST /api/v1/rules/*/history/* calls',
).toEqual([]);
for (const endpoint of HISTORY_ENDPOINTS) {
expect(
requests.filter((req) => isHistoryRequest(req, endpoint)).length,
`v2 ${endpoint} endpoint was used`,
).toBeGreaterThan(0);
}
});
test('AX-08 history API endpoints carry expected params', async ({
authedPage: page,
alertHistory,
}) => {
const requests = collectRequests(page);
await gotoAlertHistory(page, alertHistory.ruleId, {
timelineFilter: 'FIRED',
page: '2',
});
await expect(page.getByTestId('timeline-table')).toBeVisible();
const timeline = requests
.filter((req) => isHistoryRequest(req, 'timeline'))
.pop();
expect(timeline).toBeDefined();
const timelineParams = requestUrl(timeline!).searchParams;
for (const key of ['start', 'end', 'limit', 'order', 'cursor', 'state']) {
expect(timelineParams.get(key), `timeline carries ${key}`).toBeTruthy();
}
expect(timelineParams.get('limit')).toBe(String(TIMELINE_PAGE_SIZE));
expect(timelineParams.get('cursor')).toBe(encodeTimelineCursor(2));
for (const endpoint of [
'stats',
'overall_status',
'top_contributors',
] as const) {
const request = requests
.filter((req) => isHistoryRequest(req, endpoint))
.pop();
expect(request, `${endpoint} was requested`).toBeDefined();
const params = requestUrl(request!).searchParams;
expect(params.get('start'), `${endpoint} carries start`).toBeTruthy();
expect(params.get('end'), `${endpoint} carries end`).toBeTruthy();
}
const keys = requests
.filter((req) => /\/history\/filter_keys/.test(req.url()))
.pop();
expect(keys, 'filter_keys was requested').toBeDefined();
const keyParams = requestUrl(keys!).searchParams;
expect(keyParams.get('startUnixMilli')).toBeTruthy();
expect(keyParams.get('endUnixMilli')).toBeTruthy();
expect(keyParams.get('start')).toBeNull();
});
});

View File

@@ -0,0 +1,180 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
ALERT_HISTORY_PATH,
createLogsAlertViaApi,
deleteAlertViaApi,
expectFirstPage,
gotoAlertHistory,
runFilterExpression,
setRuleDisabledViaApi,
statsCard,
TIMELINE_PAGE_SIZE,
timelineFooterRange,
timelineRows,
waitForHistoryResponse,
} from '../../../helpers/alerts';
import { collectRequests } from '../../../helpers/common';
import { typeExpression } from '../../../helpers/query-builder';
test.describe('Alert history — error and empty states', () => {
test('AE-01 invalid filter expression shows syntax error and recovers on fix', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const responsePromise = waitForHistoryResponse(page, 'timeline', {
filterExpression: 'service.name =',
});
await runFilterExpression(page, 'service.name =');
const response = await responsePromise;
expect(response.status()).toBe(400);
const error = page.getByTestId('timeline-error');
await expect(error).toBeVisible();
await expect(error).toContainText(/syntax error/i);
const fixed = `service.name = '${alertHistory.services[7]}'`;
const fixResponsePromise = waitForHistoryResponse(page, 'timeline', {
status: 200,
filterExpression: fixed,
});
await runFilterExpression(page, fixed);
await fixResponsePromise;
await expect(page.getByTestId('timeline-error')).toHaveCount(0);
await expect(timelineRows(page)).toHaveCount(1);
});
test('AE-02 empty filter_keys response still mounts editor (no suggestions)', async ({
authedPage: page,
emptyHistory,
}) => {
const keysPromise = page.waitForResponse((res) =>
/\/history\/filter_keys/.test(res.url()),
);
await gotoAlertHistory(page, emptyHistory.ruleId);
const keysResponse = await keysPromise;
const body = (await keysResponse.json()) as {
data: { keys?: Record<string, unknown> } | null;
};
expect(Object.keys(body.data?.keys ?? {})).toHaveLength(0);
await expect(page.getByTestId('timeline-filter-skeleton')).toHaveCount(0);
await expect(page.getByTestId('timeline-filter-search')).toBeVisible();
await typeExpression(page, 'anything.at.all');
await expect(
page.locator('.query-where-clause-editor .cm-content'),
).toContainText('anything.at.all');
});
test('AE-02b bogus ruleId never reaches history APIs (shows AlertNotFound)', async ({
authedPage: page,
}) => {
const requests = collectRequests(page);
await page.goto(`${ALERT_HISTORY_PATH}?ruleId=not-a-real-rule-id`);
await expect(
page.getByText("Uh-oh! We couldn't find the given alert rule."),
).toBeVisible();
expect(requests.filter((req) => /\/history\//.test(req.url()))).toHaveLength(
0,
);
});
test('AE-03 rule with no history renders empty state (not error)', async ({
authedPage: page,
emptyHistory,
}) => {
await gotoAlertHistory(page, emptyHistory.ruleId);
await expect(timelineRows(page)).toHaveCount(0);
await expect(
statsCard(page, 'Total Triggered').getByTestId('stats-card-value'),
).toHaveText('None Triggered.');
await expect(
statsCard(page, 'Avg. Resolution Time').getByTestId('stats-card-value'),
).toHaveText('No Resolutions.');
await expect(page.getByTestId('top-contributors-row')).toHaveCount(0);
await expect(page.getByTestId('timeline-error')).toHaveCount(0);
});
test('AE-04 time range with no data renders empty state', async ({
authedPage: page,
alertHistory,
}) => {
const end = Date.now() - 24 * 60 * 60 * 1000;
const start = end - 30 * 60 * 1000;
await gotoAlertHistory(page, alertHistory.ruleId, {
startTime: String(start),
endTime: String(end),
});
await expect(timelineRows(page)).toHaveCount(0);
await expect(
statsCard(page, 'Total Triggered').getByTestId('stats-card-value'),
).toHaveText('None Triggered.');
await expect(page.getByTestId('timeline-error')).toHaveCount(0);
});
test('AE-05 time-range change resets pagination to first page', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, { page: '2' });
await expect(page).toHaveURL(/[?&]page=2/);
await page.locator('.filters input').first().click();
await page.getByText('Last 6 hours', { exact: true }).click();
await expectFirstPage(page);
});
test('AE-06 absurd time range (90d) still renders', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, { relativeTime: '90d' });
await expect(page.getByTestId('timeline-table')).toBeVisible();
await expect(timelineRows(page).first()).toBeVisible();
await expect(page.getByTestId('timeline-graph-title')).toContainText(
`${alertHistory.total} triggers in`,
);
});
test('AE-07 disabled rule history is still readable', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
await expect(timelineFooterRange(page)).toContainText(
`of ${alertHistory.total}`,
);
});
test('AE-08 deleted rule shows AlertNotFound on revisit', async ({
authedPage: page,
alertHistory,
}) => {
const doomedId = await createLogsAlertViaApi(page, {
name: `e2e-ah-doomed-${Date.now()}`,
marker: alertHistory.marker,
channels: [alertHistory.channelName],
});
await setRuleDisabledViaApi(page, doomedId, true);
await gotoAlertHistory(page, doomedId);
await expect(page.getByTestId('timeline-table')).toBeVisible();
await deleteAlertViaApi(page, doomedId);
await page.goto(`${ALERT_HISTORY_PATH}?ruleId=${doomedId}`);
await expect(
page.getByText("Uh-oh! We couldn't find the given alert rule."),
).toBeVisible();
});
});

View File

@@ -0,0 +1,286 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
expectFirstPage,
gotoAlertHistory,
isHistoryRequest,
runFilterExpression,
TIMELINE_PAGE_SIZE,
timelineFooterRange,
timelineRows,
waitForHistoryResponse,
} from '../../../helpers/alerts';
import { requestUrl } from '../../../helpers/common';
import { typeExpression } from '../../../helpers/query-builder';
test.describe('Alert history — expression filter', () => {
test('AF-06 key suggestions load on page load', async ({
authedPage: page,
alertHistory,
}) => {
const keysPromise = page.waitForResponse((res) =>
/\/history\/filter_keys/.test(res.url()),
);
await gotoAlertHistory(page, alertHistory.ruleId);
const keysResponse = await keysPromise;
const body = (await keysResponse.json()) as {
data: { keys: Record<string, { name: string }[]> } | null;
};
expect(Object.keys(body.data?.keys ?? {}).sort()).toEqual([
'service.name',
'severity',
'threshold.name',
]);
const params = new URL(keysResponse.url()).searchParams;
expect(params.get('startUnixMilli')).toBeTruthy();
expect(params.get('endUnixMilli')).toBeTruthy();
await expect(page.getByTestId('timeline-filter-search')).toBeVisible();
await expect(page.getByTestId('timeline-filter-skeleton')).toHaveCount(0);
});
test('AF-07 value suggestions fetch from filter_values endpoint', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const valuesPromise = page.waitForResponse((res) =>
/\/history\/filter_values/.test(res.url()),
);
await typeExpression(page, "service.name = '");
const valuesResponse = await valuesPromise;
const params = new URL(valuesResponse.url()).searchParams;
expect(params.get('name')).toBe('service.name');
const body = (await valuesResponse.json()) as {
data: { values?: { stringValues?: string[] }; complete?: boolean } | null;
};
expect(body.data?.values?.stringValues).toEqual(
[...alertHistory.services].sort(),
);
expect(body.data?.complete).toBe(true);
});
test('AF-08 value suggestions filter client-side as user types', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await typeExpression(page, "service.name = 'svc-1");
const options = page.locator('.cm-tooltip-autocomplete li');
await expect(options.first()).toBeVisible();
const texts = await options.allInnerTexts();
expect(texts.length).toBeGreaterThan(0);
expect(texts.length).toBeLessThan(alertHistory.services.length);
for (const text of texts) {
expect(text).toContain('svc-1');
}
});
test('AF-09 running equality expression filters the table', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const service = alertHistory.services[3];
const requestPromise = page.waitForRequest((req) =>
isHistoryRequest(req, 'timeline'),
);
await runFilterExpression(page, `service.name = '${service}'`);
const request = await requestPromise;
expect(requestUrl(request).searchParams.get('filterExpression')).toBe(
`service.name = '${service}'`,
);
await expect(timelineRows(page)).toHaveCount(1);
await expect(page).toHaveURL(/[?&]alertHistoryExpression=/);
});
test('AF-10 running expression resets pagination to first page', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, { page: '2' });
const responsePromise = waitForHistoryResponse(page, 'timeline');
await runFilterExpression(
page,
`service.name = '${alertHistory.services[0]}'`,
);
await responsePromise;
await expect(page).not.toHaveURL(/[?&]page=2/);
await expectFirstPage(page);
});
test('AF-11 Run button re-fetches unchanged expression', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const expression = `service.name = '${alertHistory.services[1]}'`;
const firstResponsePromise = waitForHistoryResponse(page, 'timeline');
await runFilterExpression(page, expression);
await firstResponsePromise;
await expect(timelineRows(page)).toHaveCount(1);
const requestPromise = page.waitForRequest((req) =>
isHistoryRequest(req, 'timeline'),
);
await page.getByRole('button', { name: /run query/i }).click();
await requestPromise;
await expect(timelineRows(page)).toHaveCount(1);
});
test('AF-12 in-flight query can be cancelled', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await page.route(
(url) => /\/history\/timeline/.test(url.pathname),
async (route) => {
await new Promise((resolve) => {
setTimeout(resolve, 3_000);
});
await route.continue();
},
);
await typeExpression(page, `service.name = '${alertHistory.services[2]}'`);
await page.getByRole('button', { name: /run query/i }).click();
const cancel = page.getByRole('button', { name: 'Cancel' });
await expect(cancel).toBeVisible();
await cancel.click();
await page.unrouteAll({ behavior: 'ignoreErrors' });
await expect(page.getByRole('button', { name: 'Cancel' })).toHaveCount(0);
await expect(page.getByRole('button', { name: /run query/i })).toBeVisible();
});
test('AF-13 threshold.name and severity keys filter correctly', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
let responsePromise = waitForHistoryResponse(page, 'timeline');
await runFilterExpression(page, `threshold.name = 'critical'`);
await responsePromise;
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
await expect(timelineFooterRange(page)).toContainText(
`of ${alertHistory.total}`,
);
responsePromise = waitForHistoryResponse(page, 'timeline');
await runFilterExpression(page, `severity = 'critical'`);
await responsePromise;
await expect(timelineFooterRange(page)).toContainText(
`of ${alertHistory.total}`,
);
await gotoAlertHistory(page, alertHistory.ruleIdV1);
responsePromise = waitForHistoryResponse(page, 'timeline');
await runFilterExpression(page, `threshold.name = 'warning'`);
await responsePromise;
await expect(timelineFooterRange(page)).toContainText(
`of ${alertHistory.totalV1}`,
);
responsePromise = waitForHistoryResponse(page, 'timeline');
await runFilterExpression(page, `threshold.name = 'critical'`);
await responsePromise;
await expect(timelineRows(page)).toHaveCount(0);
});
test('AF-14 unknown key returns 200 with zero rows (not 500)', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const responsePromise = waitForHistoryResponse(page, 'timeline');
await runFilterExpression(page, `nonexistent.key = 'x'`);
const response = await responsePromise;
expect(response.status()).toBe(200);
await expect(timelineRows(page)).toHaveCount(0);
await expect(page.getByTestId('timeline-error')).toHaveCount(0);
});
test('AF-15 expression is lost on Overview→History round-trip (known bug)', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const expression = `service.name = '${alertHistory.services[4]}'`;
const responsePromise = waitForHistoryResponse(page, 'timeline');
await runFilterExpression(page, expression);
await responsePromise;
await expect(timelineRows(page)).toHaveCount(1);
await expect(page).toHaveURL(/[?&]alertHistoryExpression=/);
await page.getByTestId('alert-details-tab-overview').click();
await page.getByTestId('alert-details-tab-history').click();
await expect(page.getByTestId('timeline-table')).toBeVisible();
expect(new URL(page.url()).searchParams.has('alertHistoryExpression')).toBe(
false,
);
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
});
test('AF-16 expression and state filter compose in request', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, {
timelineFilter: 'FIRED',
});
const requestPromise = page.waitForRequest((req) =>
isHistoryRequest(req, 'timeline'),
);
const service = alertHistory.services[5];
await runFilterExpression(page, `service.name = '${service}'`);
const request = await requestPromise;
const params = requestUrl(request).searchParams;
expect(params.get('state')).toBe('firing');
expect(params.get('filterExpression')).toBe(`service.name = '${service}'`);
await expect(timelineRows(page)).toHaveCount(1);
});
test('AF-17 clearing expression restores full unfiltered list', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const firstResponsePromise = waitForHistoryResponse(page, 'timeline');
await runFilterExpression(
page,
`service.name = '${alertHistory.services[6]}'`,
);
await firstResponsePromise;
await expect(timelineRows(page)).toHaveCount(1);
const requestPromise = page.waitForRequest((req) =>
isHistoryRequest(req, 'timeline'),
);
await runFilterExpression(page, '');
const request = await requestPromise;
expect(requestUrl(request).searchParams.get('filterExpression')).toBeNull();
await expect(timelineFooterRange(page)).toContainText(
`of ${alertHistory.total}`,
);
});
});

View File

@@ -0,0 +1,112 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
expectFirstPage,
gotoAlertHistory,
isHistoryRequest,
timelineRows,
waitForHistoryResponse,
} from '../../../helpers/alerts';
import { requestUrl } from '../../../helpers/common';
test.describe('Alert history — state filter', () => {
test('AF-01 All filter sends no state param in request', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, {
timelineFilter: 'FIRED',
});
const requestPromise = page.waitForRequest((req) =>
isHistoryRequest(req, 'timeline'),
);
await page.getByTestId('timeline-filter-all').click();
const request = await requestPromise;
await expect(page).toHaveURL(/[?&]timelineFilter=ALL/);
expect(requestUrl(request).searchParams.get('state')).toBeNull();
});
test('AF-02 Fired filter sends state=firing in request', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const requestPromise = page.waitForRequest((req) =>
isHistoryRequest(req, 'timeline'),
);
await page.getByTestId('timeline-filter-fired').click();
const request = await requestPromise;
await expect(page).toHaveURL(/[?&]timelineFilter=FIRED/);
expect(requestUrl(request).searchParams.get('state')).toBe('firing');
await expect(timelineRows(page).first()).toBeVisible();
});
test('AF-03 Resolved filter shows empty for rule with no resolutions', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const requestPromise = page.waitForRequest((req) =>
isHistoryRequest(req, 'timeline'),
);
await page.getByTestId('timeline-filter-resolved').click();
const request = await requestPromise;
await expect(page).toHaveURL(/[?&]timelineFilter=RESOLVED/);
expect(requestUrl(request).searchParams.get('state')).toBe('inactive');
await expect(timelineRows(page)).toHaveCount(0);
});
test('AF-03b Resolved filter shows rows for rule with resolutions', async ({
authedPage: page,
resolvedHistory,
}) => {
await gotoAlertHistory(page, resolvedHistory.ruleId, {
timelineFilter: 'RESOLVED',
});
await expect(timelineRows(page)).toHaveCount(resolvedHistory.resolvedCount);
for (const text of await timelineRows(page)
.getByTestId('timeline-row-state')
.allInnerTexts()) {
expect(text).toBe('Resolved');
}
const responsePromise = waitForHistoryResponse(page, 'timeline');
await page.getByTestId('timeline-filter-fired').click();
await responsePromise;
await expect(timelineRows(page)).toHaveCount(resolvedHistory.firingCount);
});
test('AF-04 deep-link ?timelineFilter=FIRED starts on Fired tab', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, {
timelineFilter: 'FIRED',
});
await expect(page.getByTestId('timeline-filter-fired')).toHaveClass(
/selected/,
);
await expect(timelineRows(page).first()).toBeVisible();
});
test('AF-05 changing state filter resets pagination to first page', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, { page: '2' });
const responsePromise = waitForHistoryResponse(page, 'timeline');
await page.getByTestId('timeline-filter-fired').click();
await responsePromise;
await expect(page).not.toHaveURL(/[?&]page=2/);
await expectFirstPage(page);
});
});

View File

@@ -0,0 +1,125 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
gotoAlertHistory,
statsCard,
timelineRows,
} from '../../../helpers/alerts';
test.describe('Alert history — statistics', () => {
test('AS-01 Total Triggered card shows the firing count', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const card = statsCard(page, 'Total Triggered');
await expect(card).toBeVisible();
await expect(card).toHaveAttribute('data-empty', 'false');
await expect(card.getByTestId('stats-card-value')).toHaveText(
String(alertHistory.total),
);
});
test('AS-02 Avg. Resolution Time card shows "No Resolutions." when none exist', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const card = statsCard(page, 'Avg. Resolution Time');
await expect(card).toBeVisible();
await expect(card).toHaveAttribute('data-empty', 'true');
const value = card.getByTestId('stats-card-value');
await expect(value).toHaveText('No Resolutions.');
await expect(value).not.toHaveText(/NaN/);
await expect(card.getByTestId('stats-card-sparkline')).toHaveCount(0);
});
test('AS-03 empty stats card never renders a sparkline', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const empty = statsCard(page, 'Avg. Resolution Time');
await expect(empty).toHaveAttribute('data-empty', 'true');
await expect(empty.getByTestId('stats-card-sparkline')).toHaveCount(0);
});
// eslint-disable-next-line playwright/expect-expect -- documented coverage gap
test('AS-03b sparkline present with a multi-point series', async () => {
test.skip(
true,
'Not deterministic on SEED-A: whether `currentTriggersSeries` lands in ' +
'one stats bucket or two depends on where the ~2-minute seed falls ' +
'relative to the bucket boundary, so the `timeSeries.length > 1` gate ' +
'flips between runs (observed both ways). Needs a fixture that ' +
'guarantees ≥2 points — see coverage doc §3.5.',
);
});
test('AS-04 change-vs-past indicator shows "no previous data" when unavailable', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const card = statsCard(page, 'Total Triggered');
await expect(card.getByTestId('stats-card-change')).toHaveText(
'no previous data',
);
});
test('AS-09 stats update when time range changes', async ({
authedPage: page,
alertHistory,
}) => {
const end = Date.now() - 24 * 60 * 60 * 1000;
const start = end - 30 * 60 * 1000;
await gotoAlertHistory(page, alertHistory.ruleId, {
startTime: String(start),
endTime: String(end),
});
await expect(
statsCard(page, 'Total Triggered').getByTestId('stats-card-value'),
).toHaveText('None Triggered.');
await expect(
statsCard(page, 'Avg. Resolution Time').getByTestId('stats-card-value'),
).toHaveText('No Resolutions.');
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(
statsCard(page, 'Total Triggered').getByTestId('stats-card-value'),
).toHaveText(String(alertHistory.total));
});
test('AS-11 Avg. Resolution Time shows formatted duration when resolutions exist', async ({
authedPage: page,
resolvedHistory,
}) => {
await gotoAlertHistory(page, resolvedHistory.ruleId);
const card = statsCard(page, 'Avg. Resolution Time');
await expect(card).toHaveAttribute('data-empty', 'false');
await expect(card.getByTestId('stats-card-value')).not.toHaveText(
'No Resolutions.',
);
await expect(card.getByTestId('stats-card-value')).not.toHaveText('');
await expect(card.getByTestId('stats-card-sparkline')).toHaveCount(0);
});
test('AS-12 Total Triggered counts only firing rows (not resolved)', async ({
authedPage: page,
resolvedHistory,
}) => {
await gotoAlertHistory(page, resolvedHistory.ruleId);
await expect(
statsCard(page, 'Total Triggered').getByTestId('stats-card-value'),
).toHaveText(String(resolvedHistory.firingCount));
await expect(timelineRows(page)).toHaveCount(
resolvedHistory.firingCount + resolvedHistory.resolvedCount,
);
});
});

View File

@@ -0,0 +1,99 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
DEFAULT_RELATIVE_TIME,
gotoAlertHistory,
isHistoryRequest,
} from '../../../helpers/alerts';
import { watchConsole } from '../../../helpers/common';
test.describe('Alert history — timeline graph', () => {
test('AT-03 renders canvas with two segments (inactive→firing)', async ({
authedPage: page,
alertHistory,
}) => {
const watch = watchConsole(page);
const statusPromise = page.waitForResponse((res) =>
isHistoryRequest(res.request(), 'overall_status'),
);
await gotoAlertHistory(page, alertHistory.ruleId);
const status = await statusPromise;
const body = (await status.json()) as {
data: { state: string }[] | null;
};
const states = (body.data ?? []).map((segment) => segment.state);
expect(states).toEqual(['inactive', 'firing']);
await expect(page.getByTestId('timeline-graph')).toBeVisible();
await expect(
page.getByTestId('timeline-graph').locator('canvas'),
).toBeVisible();
await expect(page.getByTestId('timeline-graph-title')).toHaveText(
`${alertHistory.total} triggers in ${DEFAULT_RELATIVE_TIME}`,
);
expect(watch.errors).toEqual([]);
expect(watch.failedResponses).toEqual([]);
});
test('AT-03b renders canvas with three segments (inactive→firing→inactive)', async ({
authedPage: page,
resolvedHistory,
}) => {
const watch = watchConsole(page);
const statusPromise = page.waitForResponse((res) =>
isHistoryRequest(res.request(), 'overall_status'),
);
await gotoAlertHistory(page, resolvedHistory.ruleId);
const status = await statusPromise;
const body = (await status.json()) as { data: { state: string }[] | null };
expect((body.data ?? []).map((s) => s.state)).toEqual([
'inactive',
'firing',
'inactive',
]);
await expect(
page.getByTestId('timeline-graph').locator('canvas'),
).toBeVisible();
expect(watch.errors).toEqual([]);
expect(watch.failedResponses).toEqual([]);
});
test('AT-19 handles nodata state without console errors', async ({
authedPage: page,
noDataHistory,
}) => {
const watch = watchConsole(page);
const statusPromise = page.waitForResponse((res) =>
isHistoryRequest(res.request(), 'overall_status'),
);
await gotoAlertHistory(page, noDataHistory.ruleId);
const status = await statusPromise;
const body = (await status.json()) as { data: { state: string }[] | null };
const KNOWN_STATES = [
'firing',
'inactive',
'pending',
'nodata',
'recovering',
'disabled',
];
const states = (body.data ?? []).map((segment) => segment.state);
expect(states.length).toBeGreaterThan(0);
for (const state of states) {
expect(KNOWN_STATES).toContain(state);
}
await expect(
page.getByTestId('timeline-graph').locator('canvas'),
).toBeVisible();
expect(watch.errors).toEqual([]);
expect(watch.failedResponses).toEqual([]);
});
});

View File

@@ -0,0 +1,172 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
encodeTimelineCursor,
expectFirstPage,
gotoAlertHistory,
isHistoryRequest,
sortTimelineDescending,
TIMELINE_PAGE_SIZE,
timelineFooterRange,
timelineRowLabels,
timelineRows,
} from '../../../helpers/alerts';
import { requestUrl } from '../../../helpers/common';
test.describe('Alert history — timeline pagination', () => {
test('AT-06 next page sends cursor and shows different rows', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(timelineRows(page).first()).toBeVisible();
const firstPageLabels = await timelineRowLabels(page);
const requestPromise = page.waitForRequest((req) =>
isHistoryRequest(req, 'timeline'),
);
await page.getByTestId('timeline-next-page').click();
const request = await requestPromise;
expect(requestUrl(request).searchParams.get('cursor')).toBe(
encodeTimelineCursor(2),
);
await expect(page).toHaveURL(/[?&]page=2/);
await expect(timelineRows(page)).toHaveCount(
alertHistory.total - TIMELINE_PAGE_SIZE,
);
await expect(timelineFooterRange(page)).toHaveText(
`${TIMELINE_PAGE_SIZE + 1}${alertHistory.total} of ${alertHistory.total}`,
);
await expect(page.getByTestId('timeline-prev-page')).toBeEnabled();
expect(await timelineRowLabels(page)).not.toEqual(firstPageLabels);
});
test('AT-07 prev page drops the cursor from request', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, { page: '2' });
const requestPromise = page.waitForRequest((req) =>
isHistoryRequest(req, 'timeline'),
);
await page.getByTestId('timeline-prev-page').click();
const request = await requestPromise;
expect(requestUrl(request).searchParams.get('cursor')).toBeNull();
await expect(timelineFooterRange(page)).toHaveText(
`1 — ${TIMELINE_PAGE_SIZE} of ${alertHistory.total}`,
);
});
test('AT-08 pagination buttons disable at first and last page', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(page.getByTestId('timeline-prev-page')).toBeDisabled();
await expect(page.getByTestId('timeline-next-page')).toBeEnabled();
await page.getByTestId('timeline-next-page').click();
await expect(page.getByTestId('timeline-next-page')).toBeDisabled();
await expect(page.getByTestId('timeline-prev-page')).toBeEnabled();
});
test('AT-09 browser back after paging returns to previous page', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await page.getByTestId('timeline-next-page').click();
await expect(page).toHaveURL(/[?&]page=2/);
await page.goBack();
await expect(page).not.toHaveURL(/[?&]page=2/);
await expect(timelineFooterRange(page)).toHaveText(
`1 — ${TIMELINE_PAGE_SIZE} of ${alertHistory.total}`,
);
});
test('AT-10 deep-link ?page=2 loads second page directly', async ({
authedPage: page,
alertHistory,
}) => {
const requestPromise = page.waitForRequest(
(req) =>
isHistoryRequest(req, 'timeline') &&
requestUrl(req).searchParams.get('cursor') === encodeTimelineCursor(2),
);
await gotoAlertHistory(page, alertHistory.ruleId, { page: '2' });
await requestPromise;
await expect(timelineRows(page)).toHaveCount(
alertHistory.total - TIMELINE_PAGE_SIZE,
);
});
test('AT-11 default sort order is ascending', async ({
authedPage: page,
alertHistory,
}) => {
const requestPromise = page.waitForRequest((req) =>
isHistoryRequest(req, 'timeline'),
);
await gotoAlertHistory(page, alertHistory.ruleId);
const request = await requestPromise;
expect(new URL(page.url()).searchParams.get('order')).toBeNull();
expect(requestUrl(request).searchParams.get('order')).toBe('asc');
});
test('AT-12 sorting toggles order and resets to first page', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, { page: '2' });
await sortTimelineDescending(page);
await expect(page).toHaveURL(/[?&]order=desc/);
await expectFirstPage(page);
});
test('AT-13 single page disables both pagination buttons', async ({
authedPage: page,
metricsHistory,
}) => {
await gotoAlertHistory(page, metricsHistory.ruleId);
await expect(timelineRows(page)).toHaveCount(metricsHistory.total);
await expect(page.getByTestId('timeline-prev-page')).toBeDisabled();
await expect(page.getByTestId('timeline-next-page')).toBeDisabled();
await expect(timelineFooterRange(page)).toHaveText(
`1 — ${metricsHistory.total} of ${metricsHistory.total}`,
);
});
test('AT-21 all pages together cover the complete row set', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(timelineRows(page).first()).toBeVisible();
const pageOne = await timelineRowLabels(page);
await page.getByTestId('timeline-next-page').click();
await expect(timelineRows(page)).toHaveCount(
alertHistory.total - TIMELINE_PAGE_SIZE,
);
await expect(timelineFooterRange(page)).toHaveText(
`${TIMELINE_PAGE_SIZE + 1}${alertHistory.total} of ${alertHistory.total}`,
);
const pageTwo = await timelineRowLabels(page);
const union = new Set([...pageOne, ...pageTwo]);
expect(pageOne.length + pageTwo.length).toBe(alertHistory.total);
expect(union.size).toBe(alertHistory.total);
});
});

View File

@@ -0,0 +1,198 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
firstTimelineRowCreatedAt,
gotoAlertHistory,
TIMELINE_PAGE_SIZE,
timelineFooterRange,
timelineRows,
} from '../../../helpers/alerts';
test.describe('Alert history — timeline table', () => {
test('AT-01 timeline section renders all chrome elements', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(page.locator('.timeline__title')).toHaveText('Timeline');
await expect(page.getByTestId('timeline-tab-overall-status')).toBeVisible();
await expect(page.getByTestId('timeline-filter-all')).toBeVisible();
await expect(page.getByTestId('timeline-filter-fired')).toBeVisible();
await expect(page.getByTestId('timeline-filter-resolved')).toBeVisible();
await expect(page.getByTestId('timeline-graph')).toBeVisible();
await expect(page.getByTestId('timeline-table')).toBeVisible();
});
test('AT-02 Top 5 Contributors tab is disabled with Coming Soon indicator', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const tab = page.getByTestId('timeline-tab-top-contributors');
await expect(tab).toBeDisabled();
await expect(tab).toContainText('Coming Soon');
});
test('AT-04 table rows display state, labels and formatted timestamp', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
const first = timelineRows(page).first();
await expect(first.getByTestId('timeline-row-state')).toHaveText('Firing');
await expect(first.getByTestId('timeline-row-labels')).toContainText(
'service.name',
);
await expect(first.getByTestId('timeline-row-created-at')).toHaveText(
/^[A-Z][a-z]{2} \d{1,2}, \d{4} ⎯ \d{2}:\d{2}:\d{2}$/,
);
});
test('AT-05 footer shows correct row range', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(timelineFooterRange(page)).toHaveText(
`1 — ${TIMELINE_PAGE_SIZE} of ${alertHistory.total}`,
);
});
test('AT-14 row click does not navigate away', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const before = page.url();
await timelineRows(page).first().click();
expect(page.url()).toBe(before);
await expect(page.getByTestId('timeline-table')).toBeVisible();
});
test('AT-15 row actions link navigates to logs explorer', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await timelineRows(page).first().getByTestId('timeline-row-actions').click();
const viewLogs = page.getByTestId('alert-popover-view-logs');
await expect(viewLogs).toBeVisible();
await viewLogs.click();
await expect(page).toHaveURL(/\/logs\/logs-explorer/);
const search = new URL(page.url()).searchParams;
expect(search.get('startTime')).toBeTruthy();
expect(search.get('endTime')).toBeTruthy();
expect(page.url()).toContain('compositeQuery');
});
test('AT-15b row actions link navigates to traces explorer', async ({
authedPage: page,
tracesHistory,
}) => {
await gotoAlertHistory(page, tracesHistory.ruleId);
await timelineRows(page).first().getByTestId('timeline-row-actions').click();
// A traces rule yields a traces link only — the backend returns one or the
// other, never both, so "View Logs" must be absent.
const viewTraces = page.getByTestId('alert-popover-view-traces');
await expect(viewTraces).toBeVisible();
await expect(page.getByTestId('alert-popover-view-logs')).toHaveCount(0);
await viewTraces.click();
await expect(page).toHaveURL(/\/traces-explorer/);
const search = new URL(page.url()).searchParams;
expect(search.get('startTime')).toBeTruthy();
expect(search.get('endTime')).toBeTruthy();
expect(page.url()).toContain('compositeQuery');
});
test('AT-16 metrics rule rows show disabled action (no related links)', async ({
authedPage: page,
metricsHistory,
}) => {
await gotoAlertHistory(page, metricsHistory.ruleId);
const action = timelineRows(page).first().getByTestId('timeline-row-actions');
await expect(action).toBeDisabled();
await expect(page.getByTestId('alert-popover-view-logs')).toHaveCount(0);
await expect(page.getByTestId('alert-popover-view-traces')).toHaveCount(0);
});
test('AT-17 CREATED AT column respects app timezone setting', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(timelineRows(page).first()).toBeVisible();
const utcCell = await firstTimelineRowCreatedAt(page);
await page.addInitScript(() =>
localStorage.setItem('PREFERRED_TIMEZONE', 'Asia/Kolkata'),
);
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(timelineRows(page).first()).toBeVisible();
expect(await firstTimelineRowCreatedAt(page)).not.toBe(utcCell);
});
test('AT-18 state cell renders Firing, Resolved, and No Data correctly', async ({
authedPage: page,
resolvedHistory,
noDataHistory,
}) => {
await gotoAlertHistory(page, resolvedHistory.ruleId);
await expect(timelineRows(page).first()).toBeVisible();
const stateCells = timelineRows(page).getByTestId('timeline-row-state');
const labels = [...new Set(await stateCells.allInnerTexts())];
expect(labels).toContain('Firing');
expect(labels).toContain('Resolved');
await gotoAlertHistory(page, noDataHistory.ruleId);
await expect(
timelineRows(page).getByTestId('timeline-row-state').first(),
).toHaveText('No Data');
});
// eslint-disable-next-line playwright/expect-expect -- documented coverage gap
test('AT-18b pending/recovering states render blank (coverage gap)', async () => {
test.skip(
true,
'Needs SEED-D (coverage doc §3.5): `pending` and `recovering` are ' +
'transient states no cheap fixture produces. AlertState.tsx has no ' +
'`case` for either, so both hit `default` and the STATE cell renders ' +
'empty — write this as a bug-catch once the seeder endpoint exists.',
);
});
// eslint-disable-next-line playwright/expect-expect -- documented coverage gap
test('AT-18c disabled state renders as "Muted" (coverage gap)', async () => {
test.skip(
true,
'Needs SEED-D (coverage doc §3.5): a `disabled` history row is ' +
'policy-driven and the ruler never writes one for a rule we disable ' +
'(verified: disabling appends no row).',
);
});
// eslint-disable-next-line playwright/expect-expect -- documented coverage gap
test('AT-20 time-range boundaries inclusive/exclusive (coverage gap)', async () => {
test.skip(
true,
'Needs SEED-D (coverage doc §3.5): asserting a row exactly at `start` ' +
'and one at `start-1ms` requires controlling the row timestamps, and ' +
'evaluation times are whatever the ruler chose.',
);
});
});

View File

@@ -0,0 +1,118 @@
import { expect, test } from '../../../fixtures/alert-history';
import { gotoAlertHistory } from '../../../helpers/alerts';
test.describe('Alert history — top contributors', () => {
test('AS-05 card displays max 3 rows with count ratios', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const card = page.getByTestId('top-contributors-card');
await expect(card).toBeVisible();
await expect(card).toContainText('top contributors');
const rows = card.getByTestId('top-contributors-row');
await expect(rows).toHaveCount(3);
for (let i = 0; i < 3; i += 1) {
await expect(
rows.nth(i).getByTestId('top-contributors-row-count'),
).toHaveText(`1/${alertHistory.total}`);
await expect(rows.nth(i)).toContainText('service.name');
}
});
// AS-05 reads the `count/total` text, which a *different* column renders than
// the bar does. Dropping the `/ total * 100` scaling from the bar leaves that
// text untouched, so the width needs its own assertion. Radix surfaces the
// clamped percent as `aria-valuenow`.
test('AS-13 contributor bar width is the count as a percentage of the total', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const rows = page
.getByTestId('top-contributors-card')
.getByTestId('top-contributors-row');
await expect(rows).toHaveCount(3);
// Every SEED-A contributor fired exactly once out of `total`.
const percent = String((1 / alertHistory.total) * 100);
for (let i = 0; i < 3; i += 1) {
await expect(rows.nth(i).getByRole('progressbar')).toHaveAttribute(
'aria-valuenow',
percent,
);
}
});
test('AS-06 "View all" button only appears when more than 3 contributors', async ({
authedPage: page,
alertHistory,
metricsHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(page.getByTestId('top-contributors-view-all')).toBeVisible();
await gotoAlertHistory(page, metricsHistory.ruleId);
await expect(page.getByTestId('top-contributors-card')).toBeVisible();
await expect(page.getByTestId('top-contributors-view-all')).toHaveCount(0);
});
test('AS-07 View-all drawer shows paginated list of all contributors', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await page.getByTestId('top-contributors-view-all').click();
const drawer = page.getByTestId('top-contributors-drawer');
await expect(drawer).toBeVisible();
await expect(page.getByText('Viewing All Contributors')).toBeVisible();
await expect(drawer.getByTestId('top-contributors-row')).toHaveCount(10);
await expect(drawer.locator('.total')).toHaveText(
` of ${alertHistory.total}`,
);
});
test('AS-07b drawer opens from deep link with ?viewAllTopContributors=true', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, {
viewAllTopContributors: 'true',
});
await expect(page.getByTestId('top-contributors-drawer')).toBeVisible();
});
test('AS-08 View-all click adds ?viewAllTopContributors=true to URL', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await page.getByTestId('top-contributors-view-all').click();
await expect(page).toHaveURL(/[?&]viewAllTopContributors=true/);
await expect(page.getByTestId('top-contributors-drawer')).toBeVisible();
});
test('AS-10 contributor rows show related-logs link for logs-based rules', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await page
.getByTestId('top-contributors-card')
.getByTestId('top-contributors-row')
.first()
.getByTestId('top-contributors-row-count')
.click();
await expect(page.getByTestId('alert-popover-view-logs')).toBeVisible();
await expect(page.getByTestId('alert-popover-view-traces')).toHaveCount(0);
});
});

View File

@@ -0,0 +1,74 @@
import { expect, test } from '../../../fixtures/alert-rules';
import {
ALERT_LIST_PAGE_SIZE,
alertRuleRows,
gotoAlertList,
} from '../../../helpers/alerts';
test.describe('Alert rules list — columns', () => {
test('LR-01 renders all default columns (Status, Alert Name, Severity, Labels, Actions)', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page, { search: alertList.namePrefix });
for (const header of ['Status', 'Alert Name', 'Severity', 'Labels']) {
await expect(page.getByRole('columnheader', { name: header })).toBeVisible();
}
await expect(
page.getByRole('columnheader', { name: 'Actions' }),
).toBeVisible();
for (const hidden of [
'Created At',
'Created By',
'Updated At',
'Updated By',
]) {
await expect(page.getByRole('columnheader', { name: hidden })).toHaveCount(
0,
);
}
await expect(alertRuleRows(page)).toHaveCount(ALERT_LIST_PAGE_SIZE);
await expect(page.getByTestId('alert-columns-button')).toBeVisible();
});
// eslint-disable-next-line playwright/expect-expect -- documented coverage gap
test('LR-02 shows empty state when no rules exist', async () => {
test.skip(
true,
'AlertsEmptyState needs a zero-rule workspace; unreachable without ' +
'tearing down SEED-B mid-suite. Covered by the component test.',
);
});
test('LR-10 column selector hides and shows a column', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page, { search: alertList.namePrefix });
await expect(
page.getByRole('columnheader', { name: 'Severity' }),
).toBeVisible();
await page.getByTestId('alert-columns-button').click();
await page.getByText('Toggle Columns').waitFor();
const severityToggle = page.locator('label', { hasText: 'Severity' });
await severityToggle.click();
await expect(
page.getByRole('columnheader', { name: 'Severity' }),
).toHaveCount(0);
await page.reload();
await expect(
page.getByRole('columnheader', { name: 'Severity' }),
).toHaveCount(0);
await page.getByTestId('alert-columns-button').click();
await page.locator('label', { hasText: 'Severity' }).click();
await expect(
page.getByRole('columnheader', { name: 'Severity' }),
).toBeVisible();
});
});

View File

@@ -0,0 +1,88 @@
import { expect, test } from '../../../fixtures/alert-rules';
import { alertRuleRows, gotoAlertList } from '../../../helpers/alerts';
test.describe('Alert rules list — navigation', () => {
test('LR-11 row click opens the overview page', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page, { search: alertList.namePrefix });
await alertRuleRows(page).first().click();
await expect(page).toHaveURL(/\/alerts\/overview\?/);
await expect(page).toHaveURL(/[?&]ruleId=/);
await expect(page).toHaveURL(/[?&]compositeQuery=/);
await expect(page).toHaveURL(/[?&]panelTypes=/);
});
test('LR-12 ctrl/cmd-click opens the overview in a new tab', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page, { search: alertList.namePrefix });
// Bounded on purpose. This scenario is intermittently red (~1 in 5 at
// --workers=1, worse under parallel load): the ctrl+click lands, one or
// more popups open, and the `page` event never arrives. Unbounded, the
// wait burns the whole test timeout — 120s in a mutation run — which made
// it the single largest item on the critical path. The flake is *not*
// fixed by this; it just costs seconds instead of minutes when it trips.
const [newPage] = await Promise.all([
page.context().waitForEvent('page', { timeout: 15_000 }),
alertRuleRows(page)
.first()
.click({ modifiers: ['ControlOrMeta'] }),
]);
await newPage.waitForLoadState();
expect(newPage.url()).toContain('/alerts/overview');
expect(newPage.url()).toContain('ruleId=');
await newPage.close();
});
test('LR-13 actions menu Edit and Edit in New Tab navigate correctly', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page, { search: alertList.namePrefix });
await alertRuleRows(page).first().getByTestId('alert-actions').click();
await page.getByRole('menuitem', { name: 'Edit in New Tab' }).waitFor();
const [newPage] = await Promise.all([
page.context().waitForEvent('page'),
page.getByRole('menuitem', { name: 'Edit in New Tab' }).click(),
]);
await newPage.waitForLoadState();
expect(newPage.url()).toContain('/alerts/overview');
await newPage.close();
await alertRuleRows(page).first().getByTestId('alert-actions').click();
await page.getByRole('menuitem', { name: 'Edit', exact: true }).click();
await expect(page).toHaveURL(/\/alerts\/overview\?/);
await expect(page).toHaveURL(/[?&]ruleId=/);
});
test('LR-17 New Alert button navigates to alert creation', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page, { search: alertList.namePrefix });
await page.getByTestId('list-alerts-new-alert-button').click();
await expect(page).toHaveURL(/\/alerts\/new/);
});
// eslint-disable-next-line playwright/expect-expect -- documented coverage gap
test('LR-18 shows ErrorEmptyState when list fails to load', async () => {
test.skip(
true,
'Not covered: the suite never stubs network, and there is no server-side ' +
'way to make GET /api/v1/rules fail on demand. Left explicitly ' +
'untested rather than mocked.',
);
});
});

View File

@@ -0,0 +1,58 @@
import { expect, test } from '../../../fixtures/alert-rules';
import {
ALERT_LIST_PAGE_SIZE,
alertRuleRows,
gotoAlertList,
} from '../../../helpers/alerts';
test.describe('Alert rules list — pagination and sorting', () => {
test('LR-07 navigates between pages', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page, { search: alertList.namePrefix });
await expect(page.getByTestId('pagination-total-count')).toContainText(
`of ${alertList.count}`,
);
await expect(alertRuleRows(page)).toHaveCount(ALERT_LIST_PAGE_SIZE);
await page.getByLabel('Go to next page').click();
await expect(page).toHaveURL(/[?&]page=2/);
await expect(alertRuleRows(page)).toHaveCount(
alertList.count - ALERT_LIST_PAGE_SIZE,
);
await expect(page.getByTestId('pagination-total-count')).toContainText(
`${ALERT_LIST_PAGE_SIZE + 1} - ${alertList.count}`,
);
});
test('LR-08 changes page size', async ({ authedPage: page, alertList }) => {
await gotoAlertList(page, { search: alertList.namePrefix });
await expect(alertRuleRows(page)).toHaveCount(ALERT_LIST_PAGE_SIZE);
await page.getByTestId('pagination-page-size').click();
await page.getByRole('option', { name: '20', exact: true }).click();
await expect(page).toHaveURL(/[?&]limit=20/);
await expect(alertRuleRows(page)).toHaveCount(alertList.count);
});
test('LR-09 sorts by column header click', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page, { search: alertList.namePrefix });
const firstCell = alertRuleRows(page).first();
const ascFirst = await firstCell.textContent();
await page.getByRole('button', { name: 'Alert Name' }).click();
await expect(page).toHaveURL(/[?&]orderBy=/);
await page.getByRole('button', { name: 'Alert Name' }).click();
await expect(page).toHaveURL(/[?&]orderBy=/);
await expect(alertRuleRows(page).first()).not.toHaveText(ascFirst ?? '');
});
});

View File

@@ -0,0 +1,92 @@
import { expect, test } from '../../../fixtures/alert-rules';
import { alertRuleRows, gotoAlertList } from '../../../helpers/alerts';
test.describe('Alert rules list — row actions', () => {
test('LR-14 Disable then Enable toggles the rule state', async ({
authedPage: page,
ownedRules,
}) => {
const name = `e2e-alert-row-toggle-${Date.now()}`;
const ruleId = await ownedRules.threshold(name);
await gotoAlertList(page, { search: name });
const row = alertRuleRows(page).first();
await expect(row.getByTestId(`alert-row-${ruleId}-state`)).toHaveText('OK');
await row.getByTestId('alert-actions').click();
await Promise.all([
page.waitForResponse(
(res) =>
res.url().includes(`/api/v2/rules/${ruleId}`) &&
res.request().method() === 'PATCH',
),
page.getByRole('menuitem', { name: 'Disable' }).click(),
]);
await expect(row.getByTestId(`alert-row-${ruleId}-state`)).toHaveText(
'Disabled',
);
await row.getByTestId('alert-actions').click();
await Promise.all([
page.waitForResponse(
(res) =>
res.url().includes(`/api/v2/rules/${ruleId}`) &&
res.request().method() === 'PATCH',
),
page.getByRole('menuitem', { name: 'Enable' }).click(),
]);
await expect(row.getByTestId(`alert-row-${ruleId}-state`)).toHaveText('OK');
});
test('LR-15 Clone creates a copy and shows success toast', async ({
authedPage: page,
ownedRules,
}) => {
const name = `e2e-alert-row-clone-${Date.now()}`;
await ownedRules.threshold(name);
await gotoAlertList(page, { search: name });
await alertRuleRows(page).first().getByTestId('alert-actions').click();
const [createResponse] = await Promise.all([
page.waitForResponse(
(res) =>
/\/api\/v\d\/rules$/.test(new URL(res.url()).pathname) &&
res.request().method() === 'POST',
),
page.getByRole('menuitem', { name: 'Clone' }).click(),
]);
await ownedRules.register(createResponse);
await expect(page.getByText('Alert cloned successfully')).toBeVisible();
await gotoAlertList(page, { search: name });
await expect(page.getByText(`${name} - Copy`)).toBeVisible();
});
test('LR-16 Delete removes the rule and shows success toast', async ({
authedPage: page,
ownedRules,
}) => {
const name = `e2e-alert-row-delete-${Date.now()}`;
const ruleId = await ownedRules.threshold(name);
await gotoAlertList(page, { search: name });
await alertRuleRows(page).first().getByTestId('alert-actions').click();
await Promise.all([
page.waitForResponse(
(res) =>
res.url().includes(`/api/v2/rules/${ruleId}`) &&
res.request().method() === 'DELETE',
),
page.getByRole('menuitem', { name: 'Delete' }).click(),
]);
await expect(page.getByText('Alert deleted successfully')).toBeVisible();
await expect(page.getByText(name, { exact: true })).toHaveCount(0);
});
});

View File

@@ -0,0 +1,113 @@
import { expect, test } from '../../../fixtures/alert-rules';
import {
ALERT_LIST_PAGE_SIZE,
alertRuleRows,
expectFirstPage,
gotoAlertList,
SEED_B_SEVERITIES,
} from '../../../helpers/alerts';
test.describe('Alert rules list — search', () => {
test('LR-03 filters by name', async ({ authedPage: page, alertList }) => {
await gotoAlertList(page);
await page
.getByTestId('list-alerts-search-input')
.fill(`${alertList.namePrefix}-03`);
await expect(page).toHaveURL(new RegExp(`search=${alertList.namePrefix}-03`));
await expect(alertRuleRows(page)).toHaveCount(1);
await expect(page.getByText(`${alertList.namePrefix}-03`)).toBeVisible();
});
test('LR-04 filters by severity and by label', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page);
const search = page.getByTestId('list-alerts-search-input');
const perSeverity = Math.floor(alertList.count / SEED_B_SEVERITIES.length);
await search.fill('warning');
await expect(page).toHaveURL(/search=warning/);
await expect(alertRuleRows(page)).not.toHaveCount(0);
for (const row of await alertRuleRows(page).all()) {
await expect(row).toContainText('warning');
}
expect(await alertRuleRows(page).count()).toBeGreaterThanOrEqual(perSeverity);
await search.fill(alertList.paymentsLabel);
await expect(page).toHaveURL(new RegExp(`search=${alertList.paymentsLabel}`));
await expect(alertRuleRows(page)).toHaveCount(alertList.count / 2);
});
test('LR-05 shows no-results state with clear button', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page, { search: alertList.namePrefix });
await page
.getByTestId('list-alerts-search-input')
.fill('no-such-alert-anywhere');
await expect(page.getByText('No matching alert rules')).toBeVisible();
await page.getByRole('button', { name: 'Clear Search' }).click();
await expect(page).not.toHaveURL(/search=/);
await expect(alertRuleRows(page)).toHaveCount(ALERT_LIST_PAGE_SIZE);
});
test('LR-06 resets pagination when searching', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page, { page: '2' });
await expect(page).toHaveURL(/[?&]page=2/);
await page.getByTestId('list-alerts-search-input').fill(alertList.namePrefix);
await expectFirstPage(page);
});
test('LR-19 state and severity filters intersect, they do not union', async ({
authedPage: page,
alertList,
}) => {
const perSeverity = alertList.count / SEED_B_SEVERITIES.length;
const scoped = { search: alertList.namePrefix };
// One filter kind: a third of the batch.
await gotoAlertList(page, {
...scoped,
alertRulesFilters: JSON.stringify(['severity:warning']),
});
await expect(alertRuleRows(page)).toHaveCount(perSeverity);
for (const row of await alertRuleRows(page).all()) {
await expect(row).toContainText('warning');
}
// Both kinds, both satisfied — seeded rules never fire, so they sit in
// `inactive`. The intersection is unchanged.
await gotoAlertList(page, {
...scoped,
alertRulesFilters: JSON.stringify(['severity:warning', 'state:inactive']),
});
await expect(alertRuleRows(page)).toHaveCount(perSeverity);
// Both kinds, only one satisfied. Under AND this is empty; under OR the
// severity match alone would still surface all `perSeverity` rules.
await gotoAlertList(
page,
{
...scoped,
alertRulesFilters: JSON.stringify(['severity:warning', 'state:firing']),
},
{ expectRows: false },
);
await expect(alertRuleRows(page)).toHaveCount(0);
await expect(page.getByText('No matching alert rules')).toBeVisible();
});
});

View File

@@ -0,0 +1,130 @@
import { expect, test } from '../../fixtures/alert-rules';
import { ALERTS_LIST_PATH } from '../../helpers/alerts';
import { watchConsole } from '../../helpers/common';
// AL-* — the Alerts page shell: the four top-level tabs, how they map to
// `?tab=`, and a navigation smoke check per tab. Tab *internals* (channel CRUD,
// planned downtime, routing policies) are deliberately out of scope — each
// deserves its own spec file.
const TAB_NAMES = {
triggered: /triggered alerts/i,
rules: /alert rules/i,
channels: /notification channels/i,
configuration: /configuration/i,
};
test.describe('Alerts page shell', () => {
test('AL-01 all four top-level tabs render', async ({ authedPage: page }) => {
await page.goto(ALERTS_LIST_PATH);
for (const name of Object.values(TAB_NAMES)) {
await expect(page.getByRole('tab', { name })).toBeVisible();
}
});
test('AL-02 default tab is Alert Rules', async ({ authedPage: page }) => {
await page.goto(ALERTS_LIST_PATH);
// No `tab` param at all — `getActiveKey()` falls back to AlertRules.
await expect(page).toHaveURL(ALERTS_LIST_PATH);
await expect(
page.getByRole('tab', { name: TAB_NAMES.rules }),
).toHaveAttribute('aria-selected', 'true');
// This spec seeds no rules, so the pane may render either the table (with
// its search input) or `AlertsEmptyState` depending on what neighbouring
// specs have in flight. Assert the pane itself, not one of its variants —
// `ListAlertRules` hides the search input entirely in the empty state.
await expect(
page
.getByRole('heading', { name: 'Alert Rules' })
.or(page.getByTestId('list-alerts-search-input')),
).toBeVisible();
});
test('AL-03 tab switch writes ?tab= and clears subTab', async ({
authedPage: page,
}) => {
await page.goto(ALERTS_LIST_PATH);
await page.getByRole('tab', { name: TAB_NAMES.triggered }).click();
await expect(page).toHaveURL(/[?&]tab=TriggeredAlerts/);
await page.getByRole('tab', { name: TAB_NAMES.channels }).click();
await expect(page).toHaveURL(/[?&]tab=Channels/);
// Entering Configuration adds the default subTab...
await page.getByRole('tab', { name: TAB_NAMES.configuration }).click();
await expect(page).toHaveURL(/[?&]tab=Configuration/);
await expect(page).toHaveURL(/[?&]subTab=planned-downtime/);
// ...and leaving it drops subTab entirely (onChange rebuilds the search
// from scratch rather than mutating the existing params).
await page.getByRole('tab', { name: TAB_NAMES.rules }).click();
await expect(page).toHaveURL(/[?&]tab=AlertRules/);
await expect(page).not.toHaveURL(/subTab=/);
});
test('AL-04 Configuration deep-link', async ({ authedPage: page }) => {
// Deep-linking without subTab: the inner Tabs falls back to
// planned-downtime for its activeKey without writing it to the URL, so
// assert the rendered tab, not the param.
await page.goto(`${ALERTS_LIST_PATH}?tab=Configuration`);
await expect(
page.getByRole('tab', { name: /planned downtime/i }),
).toHaveAttribute('aria-selected', 'true');
await page.goto(
`${ALERTS_LIST_PATH}?tab=Configuration&subTab=routing-policies`,
);
await expect(
page.getByRole('tab', { name: /routing policies/i }),
).toHaveAttribute('aria-selected', 'true');
});
test('AL-05 Triggered Alerts tab smoke', async ({ authedPage: page }) => {
// Known application defect, out of scope here: an icon on this tab renders
// with a NaN dimension ("<svg> attribute viewBox: Expected number, \"0 0 32
// NaN\""). Ignore that one string so the rest of the console guard still
// has teeth.
const watch = watchConsole(page, {
ignore: ['attribute viewBox: Expected number'],
});
await page.goto(`${ALERTS_LIST_PATH}?tab=TriggeredAlerts`);
// A fresh stack has no firing instances, so either the table or the empty
// state is correct — what matters is that the tab mounts and its controls
// render.
await expect(page.getByTestId('triggered-alerts-search-input')).toBeVisible();
expect(watch.errors).toEqual([]);
expect(watch.failedResponses).toEqual([]);
});
test('AL-06 Notification Channels tab smoke', async ({
authedPage: page,
alertChannel,
}) => {
// The worker's channel gives this tab a row to render.
await page.goto(`${ALERTS_LIST_PATH}?tab=Channels`);
await expect(page.getByText(alertChannel.name)).toBeVisible();
await expect(
page.getByRole('button', { name: /new alert channel/i }),
).toBeVisible();
});
test('AL-07 tab state survives reload', async ({ authedPage: page }) => {
await page.goto(`${ALERTS_LIST_PATH}?tab=Channels`);
await expect(
page.getByRole('tab', { name: TAB_NAMES.channels }),
).toHaveAttribute('aria-selected', 'true');
await page.reload();
await expect(page).toHaveURL(/[?&]tab=Channels/);
await expect(
page.getByRole('tab', { name: TAB_NAMES.channels }),
).toHaveAttribute('aria-selected', 'true');
});
});

View File

@@ -1,13 +1,11 @@
import base64
import json
import re
import time
import urllib.parse
import uuid
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from urllib.parse import urlparse
import pytest
import requests
@@ -17,7 +15,6 @@ from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.fs import get_testdata_file_path
from fixtures.logger import setup_logger
from fixtures.logs import Logs
from fixtures.maildev import get_all_mails, verify_email_received
from fixtures.metrics import Metrics
from fixtures.traces import Traces
@@ -314,145 +311,3 @@ def update_rule_channel_name(rule_data: dict, channel_name: str):
# loop over all the sepcs and update the channels
for spec in thresholds["spec"]:
spec["channels"] = [channel_name]
def _is_json_subset(subset, superset) -> bool:
"""Check if subset is contained within superset recursively.
- For dicts: all keys in subset must exist in superset with matching values
- For lists: all items in subset must be present in superset
- For scalars: exact equality
"""
if isinstance(subset, dict):
if not isinstance(superset, dict):
return False
return all(key in superset and _is_json_subset(value, superset[key]) for key, value in subset.items())
if isinstance(subset, list):
if not isinstance(superset, list):
return False
return all(any(_is_json_subset(sub_item, sup_item) for sup_item in superset) for sub_item in subset)
if isinstance(subset, re.Pattern):
return isinstance(superset, str) and subset.search(superset) is not None
return subset == superset
def verify_webhook_notification_expectation(
notification_channel: types.TestContainerDocker,
validation_data: dict,
) -> bool:
"""Check if wiremock received a request at the given path
whose JSON body is a superset of the expected json_body."""
path = validation_data["path"]
json_body = validation_data["json_body"]
url = notification_channel.host_configs["8080"].get("__admin/requests/find")
try:
res = requests.post(url, json={"method": "POST", "url": path}, timeout=10)
except requests.exceptions.RequestException:
return False
if res.status_code != HTTPStatus.OK:
return False
for req in res.json()["requests"]:
body = json.loads(base64.b64decode(req["bodyAsBase64"]).decode("utf-8"))
if _is_json_subset(json_body, body):
return True
return False
def _check_notification_validation(
validation: types.NotificationValidation,
notification_channel: types.TestContainerDocker,
maildev: types.TestContainerDocker,
) -> bool:
"""Dispatch a single validation check to the appropriate verifier."""
if validation.destination_type == "webhook":
return verify_webhook_notification_expectation(notification_channel, validation.validation_data)
if validation.destination_type == "email":
return verify_email_received(maildev, validation.validation_data)
raise ValueError(f"Invalid destination type: {validation.destination_type}")
def verify_notification_expectation(
notification_channel: types.TestContainerDocker,
maildev: types.TestContainerDocker,
expected_notification: types.AMNotificationExpectation,
) -> bool:
"""Poll for expected notifications across webhook and email channels."""
time_to_wait = datetime.now() + timedelta(seconds=expected_notification.wait_time_seconds)
while datetime.now() < time_to_wait:
all_found = all(_check_notification_validation(v, notification_channel, maildev) for v in expected_notification.notification_validations)
if expected_notification.should_notify and all_found:
logger.info("All expected notifications found")
return True
time.sleep(1)
# Timeout reached
if not expected_notification.should_notify:
# Verify no notifications were received
for validation in expected_notification.notification_validations:
found = _check_notification_validation(validation, notification_channel, maildev)
assert not found, f"Expected no notification but found one for {validation.destination_type} with data {validation.validation_data}"
logger.info("No notifications found, as expected")
return True
missing = [v for v in expected_notification.notification_validations if not _check_notification_validation(v, notification_channel, maildev)]
assert len(missing) == 0, f"Expected all notifications to be found but missing: {missing}, received: {_received_notifications(notification_channel, maildev, missing)}"
return True
def _received_notifications(
notification_channel: types.TestContainerDocker,
maildev: types.TestContainerDocker,
missing: list[types.NotificationValidation],
) -> dict:
received = {}
if any(v.destination_type == "webhook" for v in missing):
webhook_bodies = []
for validation in missing:
if validation.destination_type != "webhook":
continue
url = notification_channel.host_configs["8080"].get("__admin/requests/find")
try:
res = requests.post(url, json={"method": "POST", "url": validation.validation_data["path"]}, timeout=10)
webhook_bodies.extend(json.loads(base64.b64decode(req["bodyAsBase64"]).decode("utf-8")) for req in res.json()["requests"])
except requests.exceptions.RequestException as exc:
webhook_bodies.append(f"<failed to fetch wiremock journal: {exc}>")
received["webhook"] = webhook_bodies
if any(v.destination_type == "email" for v in missing):
received["email"] = get_all_mails(maildev)
return received
def update_raw_channel_config(
channel_config: dict,
channel_name: str,
notification_channel: types.TestContainerDocker,
) -> dict:
"""
Updates the channel config to point to the given wiremock
notification_channel container to receive notifications.
"""
config = channel_config.copy()
config["name"] = channel_name
url_field_map = {
"slack_configs": "api_url",
"msteamsv2_configs": "webhook_url",
"webhook_configs": "url",
"pagerduty_configs": "url",
"opsgenie_configs": "api_url",
}
for config_key, url_field in url_field_map.items():
if config_key in config:
for entry in config[config_key]:
if url_field in entry:
original_url = entry[url_field]
path = urlparse(original_url).path
entry[url_field] = notification_channel.container_configs["8080"].get(path)
return config

View File

@@ -77,7 +77,7 @@ def register_admin(
timeout=5,
)
assert response.status_code == HTTPStatus.OK, f"failed to register admin: {response.status_code} {response.text}"
assert response.status_code == HTTPStatus.OK
return types.Operation(name="create_user_admin")

View File

@@ -125,19 +125,13 @@ def gateway(
@pytest.fixture(name="make_http_mocks", scope="function")
def make_http_mocks() -> Callable[[types.TestContainerDocker, list[Mapping]], None]:
mocked_containers = []
def _make_http_mocks(container: types.TestContainerDocker, mappings: list[Mapping]) -> None:
Config.base_url = container.host_configs["8080"].get("/__admin")
for mapping in mappings:
Mappings.create_mapping(mapping=mapping)
mocked_containers.append(container)
yield _make_http_mocks
for container in mocked_containers:
Config.base_url = container.host_configs["8080"].get("/__admin")
Mappings.delete_all_mappings()
Requests.reset_request_journal()
Mappings.delete_all_mappings()
Requests.reset_request_journal()

14
tests/fixtures/idp.py vendored
View File

@@ -7,7 +7,6 @@ import pytest
import requests
from keycloak import KeycloakAdmin
from selenium import webdriver
from selenium.common.exceptions import WebDriverException
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.wait import WebDriverWait
@@ -371,20 +370,11 @@ def idp_login(driver: webdriver.Chrome) -> Callable[[str, str], None]:
password_field.send_keys(password)
# Click the login button
idp_host = urlparse(driver.current_url).netloc
login_button = wait.until(EC.element_to_be_clickable((By.ID, "kc-login")))
login_button.click()
# Wait till the browser has left the idp host — not just the login page: keycloak's SAML flow inserts an
# auto-submitting interstitial on the idp whose POST is what creates the user in signoz. The button is
# re-queried per poll; a mid-navigation WebDriverException (detached node) just retries the poll.
def _left_idp(drv: webdriver.Chrome) -> bool:
try:
return urlparse(drv.current_url).netloc != idp_host and not drv.find_elements(By.ID, "kc-login")
except WebDriverException:
return False
wait.until(_left_idp)
# Wait till kc-login element has vanished from the page, which means that a redirection is taking place.
wait.until(EC.invisibility_of_element((By.ID, "kc-login")))
return _idp_login

View File

@@ -1,143 +0,0 @@
import re
from http import HTTPStatus
import docker
import docker.errors
import pytest
import requests
from testcontainers.core.container import DockerContainer, Network
from fixtures import reuse, types
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
MAILDEV_INCOMING_USER = "apikey"
MAILDEV_INCOMING_PASS = "integration-smtp-secret"
SMTP_TEST_FROM = "alertmanager@integration.test"
OLD_PROVIDER_SMTP_PASS = "old-provider-smtp-secret"
NEW_PROVIDER_SMTP_PASS = "new-provider-smtp-secret"
def signoz_smtp_env(maildev: "types.TestContainerDocker", password: str = MAILDEV_INCOMING_PASS) -> dict:
return {
"SIGNOZ_ALERTMANAGER_SIGNOZ_GLOBAL_SMTP__SMARTHOST": f"{maildev.container_configs['1025'].address}:{maildev.container_configs['1025'].port}",
"SIGNOZ_ALERTMANAGER_SIGNOZ_GLOBAL_SMTP__FROM": SMTP_TEST_FROM,
"SIGNOZ_ALERTMANAGER_SIGNOZ_GLOBAL_SMTP__AUTH__USERNAME": MAILDEV_INCOMING_USER,
"SIGNOZ_ALERTMANAGER_SIGNOZ_GLOBAL_SMTP__AUTH__PASSWORD": password,
"SIGNOZ_ALERTMANAGER_SIGNOZ_GLOBAL_SMTP__REQUIRE__TLS": "false",
}
def create_maildev(
network: Network,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
cache_key: str = "maildev",
incoming_user: str = MAILDEV_INCOMING_USER,
incoming_pass: str = MAILDEV_INCOMING_PASS,
) -> types.TestContainerDocker:
def create() -> types.TestContainerDocker:
container = DockerContainer(image="maildev/maildev:2.2.1")
container.with_env("MAILDEV_INCOMING_USER", incoming_user)
container.with_env("MAILDEV_INCOMING_PASS", incoming_pass)
container.with_exposed_ports(1025, 1080)
container.with_network(network=network)
container.start()
return types.TestContainerDocker(
id=container.get_wrapped_container().id,
host_configs={
"1025": types.TestContainerUrlConfig(
scheme="smtp",
address=container.get_container_host_ip(),
port=container.get_exposed_port(1025),
),
"1080": types.TestContainerUrlConfig(
scheme="http",
address=container.get_container_host_ip(),
port=container.get_exposed_port(1080),
),
},
container_configs={
"1025": types.TestContainerUrlConfig(
scheme="smtp",
address=container.get_wrapped_container().name,
port=1025,
),
"1080": types.TestContainerUrlConfig(
scheme="http",
address=container.get_wrapped_container().name,
port=1080,
),
},
)
def delete(container: types.TestContainerDocker):
client = docker.from_env()
try:
client.containers.get(container_id=container.id).stop()
client.containers.get(container_id=container.id).remove(v=True)
except docker.errors.NotFound:
logger.info(
"Skipping removal of MailDev, MailDev(%s) not found. Maybe it was manually removed?",
{"id": container.id},
)
def restore(cache: dict) -> types.TestContainerDocker:
return types.TestContainerDocker.from_cache(cache)
return reuse.wrap(
request,
pytestconfig,
cache_key,
lambda: types.TestContainerDocker(id="", host_configs={}, container_configs={}),
create,
delete,
restore,
)
@pytest.fixture(name="maildev", scope="package")
def maildev(network: Network, request: pytest.FixtureRequest, pytestconfig: pytest.Config) -> types.TestContainerDocker:
return create_maildev(network, request, pytestconfig)
def get_all_mails(_maildev: types.TestContainerDocker) -> list[dict]:
url = _maildev.host_configs["1080"].get("/email")
response = requests.get(url, timeout=5)
assert response.status_code == HTTPStatus.OK, f"Failed to fetch emails from MailDev, status code: {response.status_code}, response: {response.text}"
def addresses(entries: list[dict]) -> str:
return ",".join(sorted(entry.get("address", "") for entry in entries))
return [
{
"subject": email.get("subject", ""),
"html": email.get("html", ""),
"text": email.get("text", ""),
"from": addresses(email.get("from", [])),
"to": addresses(email.get("to", [])),
}
for email in response.json()
]
def verify_email_received(_maildev: types.TestContainerDocker, filters: dict) -> bool:
def matches(expected, actual: str) -> bool:
if isinstance(expected, re.Pattern):
return expected.search(actual) is not None
return expected == actual
for email in get_all_mails(_maildev):
if all(key in email and matches(filter_value, email[key]) for key, filter_value in filters.items()):
return True
return False
def delete_all_mails(_maildev: types.TestContainerDocker) -> None:
url = _maildev.host_configs["1080"].get("/email/all")
response = requests.delete(url, timeout=5)
assert response.status_code == HTTPStatus.OK, f"Failed to delete emails from MailDev, status code: {response.status_code}, response: {response.text}"

View File

@@ -1,6 +1,3 @@
# pylint: disable=line-too-long
import json
import time
from collections.abc import Callable
from http import HTTPStatus
@@ -14,116 +11,10 @@ from wiremock.testing.testcontainer import WireMockContainer
from fixtures import reuse, types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logger import setup_logger
from fixtures.maildev import MAILDEV_INCOMING_PASS, SMTP_TEST_FROM
logger = setup_logger(__name__)
EMAIL_TRANSPORT_KEYS = [
"from",
"hello",
"smarthost",
"auth_username",
"auth_password",
"auth_password_file",
"auth_secret",
"auth_secret_file",
"auth_identity",
"require_tls",
"tls_config",
"force_implicit_tls",
]
def assert_email_channel_payload_clean(payload: str) -> None:
receiver = json.loads(payload)
for email_config in receiver["email_configs"]:
transport_keys = set(email_config.keys()) & set(EMAIL_TRANSPORT_KEYS)
transport_keys -= {"smarthost"} if email_config.get("smarthost", "") == "" else set()
assert not transport_keys, f"email channel payload carries transport keys {transport_keys}: {payload}"
assert MAILDEV_INCOMING_PASS not in payload
assert SMTP_TEST_FROM not in payload
"""
Default notification channel configs shared across alertmanager tests.
"""
slack_default_config = {
# channel name configured on runtime
"slack_configs": [
{
"api_url": "services/TEAM_ID/BOT_ID/TOKEN_ID", # base_url configured on runtime
"title": '[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}\n {{- if gt (len .CommonLabels) (len .GroupLabels) -}}\n {{" "}}(\n {{- with .CommonLabels.Remove .GroupLabels.Names }}\n {{- range $index, $label := .SortedPairs -}}\n {{ if $index }}, {{ end }}\n {{- $label.Name }}="{{ $label.Value -}}"\n {{- end }}\n {{- end -}}\n )\n {{- end }}',
"text": '{{ range .Alerts -}}\r\n *Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}\r\n\r\n *Summary:* {{ .Annotations.summary }}\r\n *Description:* {{ .Annotations.description }}\r\n *RelatedLogs:* {{ if gt (len .Annotations.related_logs) 0 -}} View in <{{ .Annotations.related_logs }}|logs explorer> {{- end}}\r\n *RelatedTraces:* {{ if gt (len .Annotations.related_traces) 0 -}} View in <{{ .Annotations.related_traces }}|traces explorer> {{- end}}\r\n\r\n *Details:*\r\n {{ range .Labels.SortedPairs -}}\r\n {{- if ne .Name "ruleId" -}}\r\n \u2022 *{{ .Name }}:* {{ .Value }}\r\n {{ end -}}\r\n {{ end -}}\r\n{{ end }}',
}
],
}
msteams_default_config = {
"msteamsv2_configs": [
{
"webhook_url": "msteams/webhook_url", # base_url configured on runtime
"title": '[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}\n {{- if gt (len .CommonLabels) (len .GroupLabels) -}}\n {{" "}}(\n {{- with .CommonLabels.Remove .GroupLabels.Names }}\n {{- range $index, $label := .SortedPairs -}}\n {{ if $index }}, {{ end }}\n {{- $label.Name }}="{{ $label.Value -}}"\n {{- end }}\n {{- end -}}\n )\n {{- end }}',
"text": '{{ range .Alerts -}}\r\n *Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}\r\n\r\n *Summary:* {{ .Annotations.summary }}\r\n *Description:* {{ .Annotations.description }}\r\n *RelatedLogs:* {{ if gt (len .Annotations.related_logs) 0 -}} View in <{{ .Annotations.related_logs }}|logs explorer> {{- end}}\r\n *RelatedTraces:* {{ if gt (len .Annotations.related_traces) 0 -}} View in <{{ .Annotations.related_traces }}|traces explorer> {{- end}}\r\n\r\n *Details:*\r\n {{ range .Labels.SortedPairs -}}\r\n {{- if ne .Name "ruleId" -}}\r\n \u2022 *{{ .Name }}:* {{ .Value }}\r\n {{ end -}}\r\n {{ end -}}\r\n{{ end }}',
}
],
}
pagerduty_default_config = {
"pagerduty_configs": [
{
"routing_key": "PagerDutyRoutingKey",
"url": "v2/enqueue", # base_url configured on runtime
"client": "SigNoz Alert Manager",
"client_url": "https://enter-signoz-host-n-port-here/alerts",
"description": '[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}\n\t{{- if gt (len .CommonLabels) (len .GroupLabels) -}}\n\t {{" "}}(\n\t {{- with .CommonLabels.Remove .GroupLabels.Names }}\n\t\t{{- range $index, $label := .SortedPairs -}}\n\t\t {{ if $index }}, {{ end }}\n\t\t {{- $label.Name }}="{{ $label.Value -}}"\n\t\t{{- end }}\n\t {{- end -}}\n\t )\n\t{{- end }}',
"details": {
"firing": '{{ template "pagerduty.default.instances" .Alerts.Firing }}',
"num_firing": "{{ .Alerts.Firing | len }}",
"num_resolved": "{{ .Alerts.Resolved | len }}",
"resolved": '{{ template "pagerduty.default.instances" .Alerts.Resolved }}',
},
"source": "SigNoz Alert Manager",
"severity": "{{ (index .Alerts 0).Labels.severity }}",
}
],
}
opsgenie_default_config = {
"opsgenie_configs": [
{
"api_key": "OpsGenieAPIKey",
"api_url": "/", # base_url configured on runtime
"description": '{{ if gt (len .Alerts.Firing) 0 -}}\r\n\tAlerts Firing:\r\n\t{{ range .Alerts.Firing }}\r\n\t - Message: {{ .Annotations.description }}\r\n\tLabels:\r\n\t{{ range .Labels.SortedPairs -}}\r\n\t\t{{- if ne .Name "ruleId" }} - {{ .Name }} = {{ .Value }}\r\n\t{{ end -}}\r\n\t{{- end }} Annotations:\r\n\t{{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }}\r\n\t{{ end }} Source: {{ .GeneratorURL }}\r\n\t{{ end }}\r\n{{- end }}\r\n{{ if gt (len .Alerts.Resolved) 0 -}}\r\n\tAlerts Resolved:\r\n\t{{ range .Alerts.Resolved }}\r\n\t - Message: {{ .Annotations.description }}\r\n\tLabels:\r\n\t{{ range .Labels.SortedPairs -}}\r\n\t\t{{- if ne .Name "ruleId" }} - {{ .Name }} = {{ .Value }}\r\n\t{{ end -}}\r\n\t{{- end }} Annotations:\r\n\t{{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }}\r\n\t{{ end }} Source: {{ .GeneratorURL }}\r\n\t{{ end }}\r\n{{- end }}',
"priority": '{{ if eq (index .Alerts 0).Labels.severity "critical" }}P1{{ else if eq (index .Alerts 0).Labels.severity "warning" }}P2{{ else if eq (index .Alerts 0).Labels.severity "info" }}P3{{ else }}P4{{ end }}',
"message": "{{ .CommonLabels.alertname }}",
"details": {},
}
],
}
webhook_default_config = {
"webhook_configs": [
{
"url": "webhook/webhook_url", # base_url configured on runtime
}
],
}
email_default_config = {
"email_configs": [
{
"to": "test@example.com",
"html": '<html><body>{{ range .Alerts -}}\r\n *Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}\r\n\r\n *Summary:* {{ .Annotations.summary }}\r\n *Description:* {{ .Annotations.description }}\r\n *RelatedLogs:* {{ if gt (len .Annotations.related_logs) 0 -}} View in <{{ .Annotations.related_logs }}|logs explorer> {{- end}}\r\n *RelatedTraces:* {{ if gt (len .Annotations.related_traces) 0 -}} View in <{{ .Annotations.related_traces }}|traces explorer> {{- end}}\r\n\r\n *Details:*\r\n {{ range .Labels.SortedPairs -}}\r\n {{- if ne .Name "ruleId" -}}\r\n \u2022 *{{ .Name }}:* {{ .Value }}\r\n {{ end -}}\r\n {{ end -}}\r\n{{ end }}</body></html>',
"headers": {
"Subject": '[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}\n {{- if gt (len .CommonLabels) (len .GroupLabels) -}}\n {{" "}}(\n {{- with .CommonLabels.Remove .GroupLabels.Names }}\n {{- range $index, $label := .SortedPairs -}}\n {{ if $index }}, {{ end }}\n {{- $label.Name }}="{{ $label.Value -}}"\n {{- end }}\n {{- end -}}\n )\n {{- end }}'
},
}
],
}
@pytest.fixture(name="notification_channel", scope="package")
def notification_channel(
network: Network,
@@ -176,40 +67,6 @@ def notification_channel(
)
@pytest.fixture(name="create_notification_channel", scope="function")
def create_notification_channel(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> Callable[[dict], str]:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
channel_ids = []
def _create_notification_channel(channel_config: dict) -> str:
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/channels"),
json=channel_config,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, f"Failed to create channel, Response: {response.text} Response status: {response.status_code}"
channel_id = response.json()["data"]["id"]
channel_ids.append(channel_id)
return channel_id
yield _create_notification_channel
for channel_id in channel_ids:
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/channels/{channel_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
if response.status_code != HTTPStatus.NO_CONTENT:
logger.error("Failed to delete channel: %s", {"channel_id": channel_id, "status": response.status_code, "response": response.text})
@pytest.fixture(name="create_webhook_notification_channel", scope="function")
def create_webhook_notification_channel(
signoz: types.SigNoz,
@@ -246,19 +103,3 @@ def create_webhook_notification_channel(
return channel_id
return _create_webhook_notification_channel
def send_test_notification(signoz: types.SigNoz, token: str, receiver: dict, wait_seconds: int = 90) -> None:
deadline = time.time() + wait_seconds
last = None
while time.time() < deadline:
last = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/channels/test"),
json=receiver,
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
if last.status_code == HTTPStatus.NO_CONTENT:
return
time.sleep(2)
raise AssertionError(f"test notification did not succeed within {wait_seconds}s, last response: {last.status_code} {last.text}")

View File

@@ -197,40 +197,3 @@ class AlertTestCase:
alert_data: list[AlertData]
# list of alert expectations for the test case
alert_expectation: AlertExpectation
@dataclass(frozen=True)
class NotificationValidation:
# destination type of the notification, either webhook or email
# slack, msteams, pagerduty, opsgenie, webhook channels send notifications through webhook
# email channels send notifications through email
destination_type: Literal["webhook", "email"]
# validation data for validating the received notification payload
validation_data: dict[str, any]
@dataclass(frozen=True)
class AMNotificationExpectation:
# whether we expect any notifications to be fired or not, false when testing downtime scenarios
# or don't expect any notifications to be fired in given time period
should_notify: bool
# seconds to wait for the notifications to be fired, if no
# notifications are fired in the expected time, the test will fail
wait_time_seconds: int
# list of notifications to expect, as a single rule can trigger multiple notifications
# spanning across different notifiers
notification_validations: list[NotificationValidation]
@dataclass(frozen=True)
class AlertManagerNotificationTestCase:
# name of the test case
name: str
# path to the rule file in testdata directory
rule_path: str
# list of alert data that will be inserted into the database for the rule to be triggered
alert_data: list[AlertData]
# configuration for the notification channel
channel_config: dict[str, any]
# notification expectations for the test case
notification_expectation: AMNotificationExpectation

View File

@@ -39,7 +39,5 @@ def test_teardown(
idp: types.TestContainerIDP, # pylint: disable=unused-argument
create_user_admin: types.Operation, # pylint: disable=unused-argument
migrator: types.Operation, # pylint: disable=unused-argument
maildev: types.TestContainerDocker, # pylint: disable=unused-argument
notification_channel: types.TestContainerDocker, # pylint: disable=unused-argument
) -> None:
pass

View File

@@ -1,20 +0,0 @@
{ "timestamp": "2026-01-29T10:00:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "User login successful", "severity_text": "INFO" }
{ "timestamp": "2026-01-29T10:00:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: gateway timeout", "severity_text": "ERROR" }
{ "timestamp": "2026-01-29T10:01:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: card declined", "severity_text": "ERROR" }
{ "timestamp": "2026-01-29T10:01:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "Database connection established", "severity_text": "INFO" }
{ "timestamp": "2026-01-29T10:02:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: insufficient funds", "severity_text": "ERROR" }
{ "timestamp": "2026-01-29T10:02:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: invalid token", "severity_text": "ERROR" }
{ "timestamp": "2026-01-29T10:03:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "API request received", "severity_text": "INFO" }
{ "timestamp": "2026-01-29T10:03:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: gateway timeout", "severity_text": "ERROR" }
{ "timestamp": "2026-01-29T10:04:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: card declined", "severity_text": "ERROR" }
{ "timestamp": "2026-01-29T10:04:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: invalid token", "severity_text": "ERROR" }
{ "timestamp": "2026-01-29T10:05:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: gateway timeout", "severity_text": "ERROR" }
{ "timestamp": "2026-01-29T10:05:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: card declined", "severity_text": "ERROR" }
{ "timestamp": "2026-01-29T10:06:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: gateway timeout", "severity_text": "ERROR" }
{ "timestamp": "2026-01-29T10:06:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: insufficient funds", "severity_text": "ERROR" }
{ "timestamp": "2026-01-29T10:07:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: card declined", "severity_text": "ERROR" }
{ "timestamp": "2026-01-29T10:07:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: gateway timeout", "severity_text": "ERROR" }
{ "timestamp": "2026-01-29T10:08:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "Response sent to client", "severity_text": "INFO" }
{ "timestamp": "2026-01-29T10:08:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: invalid token", "severity_text": "ERROR" }
{ "timestamp": "2026-01-29T10:09:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: card declined", "severity_text": "ERROR" }
{ "timestamp": "2026-01-29T10:10:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: gateway timeout", "severity_text": "ERROR" }

View File

@@ -1,69 +0,0 @@
{
"alert": "content_templating_logs",
"ruleType": "threshold_rule",
"alertType": "LOGS_BASED_ALERT",
"condition": {
"thresholds": {
"kind": "basic",
"spec": [
{
"name": "critical",
"target": 0,
"matchType": "1",
"op": "1",
"channels": [
"test channel"
]
}
]
},
"compositeQuery": {
"queryType": "builder",
"panelType": "graph",
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"filter": {
"expression": "body CONTAINS 'payment failure'"
},
"aggregations": [
{
"expression": "count()"
}
],
"groupBy": [
{"name": "service.name", "fieldContext": "resource"}
]
}
}
]
},
"selectedQueryName": "A"
},
"evaluation": {
"kind": "rolling",
"spec": {
"evalWindow": "5m0s",
"frequency": "15s"
}
},
"labels": {},
"annotations": {
"description": "Payment failure spike detected on $service_name",
"summary": "Payment failures elevated on $service_name"
},
"notificationSettings": {
"groupBy": [],
"usePolicy": false,
"renotify": {
"enabled": false,
"interval": "30m",
"alertStates": []
}
},
"version": "v5",
"schemaVersion": "v2alpha1"
}

View File

@@ -1,12 +0,0 @@
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:01:00+00:00","value":80,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:02:00+00:00","value":95,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:03:00+00:00","value":110,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:04:00+00:00","value":120,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:05:00+00:00","value":125,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:06:00+00:00","value":130,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:07:00+00:00","value":135,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:08:00+00:00","value":140,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:09:00+00:00","value":145,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:10:00+00:00","value":150,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:11:00+00:00","value":155,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:12:00+00:00","value":160,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}

View File

@@ -1,72 +0,0 @@
{
"alert": "content_templating_metrics",
"ruleType": "threshold_rule",
"alertType": "METRIC_BASED_ALERT",
"condition": {
"thresholds": {
"kind": "basic",
"spec": [
{
"name": "critical",
"target": 100,
"matchType": "1",
"op": "1",
"channels": [
"test channel"
]
}
]
},
"compositeQuery": {
"queryType": "builder",
"panelType": "graph",
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [
{
"metricName": "container_memory_bytes_content_templating",
"timeAggregation": "avg",
"spaceAggregation": "max"
}
],
"groupBy": [
{"name": "namespace", "fieldContext": "attribute", "fieldDataType": "string"},
{"name": "pod", "fieldContext": "attribute", "fieldDataType": "string"},
{"name": "container", "fieldContext": "attribute", "fieldDataType": "string"},
{"name": "node", "fieldContext": "attribute", "fieldDataType": "string"},
{"name": "severity", "fieldContext": "attribute", "fieldDataType": "string"}
]
}
}
]
},
"selectedQueryName": "A"
},
"evaluation": {
"kind": "rolling",
"spec": {
"evalWindow": "5m0s",
"frequency": "15s"
}
},
"labels": {},
"annotations": {
"description": "Container $container in pod $pod ($namespace) exceeded memory threshold",
"summary": "High container memory in $namespace/$pod"
},
"notificationSettings": {
"groupBy": [],
"usePolicy": false,
"renotify": {
"enabled": false,
"interval": "30m",
"alertStates": []
}
},
"version": "v5",
"schemaVersion": "v2alpha1"
}

View File

@@ -1,20 +0,0 @@
{ "timestamp": "2026-01-29T10:00:00.000000Z", "duration": "PT1.2S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6a1", "span_id": "c1b2c3d4e5f6a7b8", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:00:30.000000Z", "duration": "PT1.4S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6a2", "span_id": "c2b3c4d5e6f7a8b9", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:01:00.000000Z", "duration": "PT1.6S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6a3", "span_id": "c3b4c5d6e7f8a9b0", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:01:30.000000Z", "duration": "PT1.8S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6a4", "span_id": "c4b5c6d7e8f9a0b1", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:02:00.000000Z", "duration": "PT2.1S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6a5", "span_id": "c5b6c7d8e9f0a1b2", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:02:30.000000Z", "duration": "PT2.3S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6a6", "span_id": "c6b7c8d9e0f1a2b3", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:03:00.000000Z", "duration": "PT2.5S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6a7", "span_id": "c7b8c9d0e1f2a3b4", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:03:30.000000Z", "duration": "PT2.7S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6a8", "span_id": "c8b9c0d1e2f3a4b5", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:04:00.000000Z", "duration": "PT2.9S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6a9", "span_id": "c9b0c1d2e3f4a5b6", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:04:30.000000Z", "duration": "PT3.1S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6b1", "span_id": "d1c2d3e4f5a6b7c8", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:05:00.000000Z", "duration": "PT3.3S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6b2", "span_id": "d2c3d4e5f6a7b8c9", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:05:30.000000Z", "duration": "PT3.5S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6b3", "span_id": "d3c4d5e6f7a8b9c0", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:06:00.000000Z", "duration": "PT3.7S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6b4", "span_id": "d4c5d6e7f8a9b0c1", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:06:30.000000Z", "duration": "PT3.9S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6b5", "span_id": "d5c6d7e8f9a0b1c2", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:07:00.000000Z", "duration": "PT4.1S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6b6", "span_id": "d6c7d8e9f0a1b2c3", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:07:30.000000Z", "duration": "PT4.3S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6b7", "span_id": "d7c8d9e0f1a2b3c4", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:08:00.000000Z", "duration": "PT4.5S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6b8", "span_id": "d8c9d0e1f2a3b4c5", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:08:30.000000Z", "duration": "PT4.7S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6b9", "span_id": "d9c0d1e2f3a4b5c6", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:09:00.000000Z", "duration": "PT4.9S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6c1", "span_id": "e1d2e3f4a5b6c7d8", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:10:00.000000Z", "duration": "PT5.1S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6c2", "span_id": "e2d3e4f5a6b7c8d9", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }

View File

@@ -1,71 +0,0 @@
{
"alert": "content_templating_traces",
"ruleType": "threshold_rule",
"alertType": "TRACES_BASED_ALERT",
"condition": {
"thresholds": {
"kind": "basic",
"spec": [
{
"name": "critical",
"target": 1,
"matchType": "1",
"op": "1",
"channels": [
"test channel"
],
"targetUnit": "s"
}
]
},
"compositeQuery": {
"queryType": "builder",
"unit": "ns",
"panelType": "graph",
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"filter": {
"expression": "http.request.path = '/checkout'"
},
"aggregations": [
{
"expression": "p90(duration_nano)"
}
],
"groupBy": [
{"name": "service.name", "fieldContext": "resource"}
]
}
}
]
},
"selectedQueryName": "A"
},
"evaluation": {
"kind": "rolling",
"spec": {
"evalWindow": "5m0s",
"frequency": "15s"
}
},
"labels": {},
"annotations": {
"description": "p90 latency high on $service_name",
"summary": "p90 latency exceeded threshold on $service_name"
},
"notificationSettings": {
"groupBy": [],
"usePolicy": false,
"renotify": {
"enabled": false,
"interval": "30m",
"alertStates": []
}
},
"version": "v5",
"schemaVersion": "v2alpha1"
}

View File

@@ -1,268 +0,0 @@
import time
import uuid
from collections.abc import Callable
from http import HTTPStatus
import pytest
import requests
from sqlalchemy import text
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.maildev import (
MAILDEV_INCOMING_PASS,
SMTP_TEST_FROM,
delete_all_mails,
verify_email_received,
)
from fixtures.notification_channel import assert_email_channel_payload_clean, send_test_notification
TIMEOUT = 10
CHANNEL_TYPE_CASES = [
(
"webhook",
lambda sink: {"webhook_configs": [{"url": sink.container_configs["8080"].get("/webhook/crud-original"), "send_resolved": True}]},
lambda sink: {"webhook_configs": [{"url": sink.container_configs["8080"].get("/webhook/crud-updated"), "send_resolved": True}]},
"crud-original",
"crud-updated",
),
(
"slack",
lambda sink: {"slack_configs": [{"api_url": sink.container_configs["8080"].get("/services/T/B/X"), "channel": "#crud-original"}]},
lambda sink: {"slack_configs": [{"api_url": sink.container_configs["8080"].get("/services/T/B/X"), "channel": "#crud-updated"}]},
"#crud-original",
"#crud-updated",
),
(
"pagerduty",
lambda sink: {"pagerduty_configs": [{"routing_key": "crud-original-routing-key"}]},
lambda sink: {"pagerduty_configs": [{"routing_key": "crud-updated-routing-key"}]},
"crud-original-routing-key",
"crud-updated-routing-key",
),
(
"opsgenie",
lambda sink: {"opsgenie_configs": [{"api_key": "crud-original-api-key", "message": "{{ .CommonLabels.alertname }}"}]},
lambda sink: {"opsgenie_configs": [{"api_key": "crud-updated-api-key", "message": "{{ .CommonLabels.alertname }}"}]},
"crud-original-api-key",
"crud-updated-api-key",
),
(
"msteamsv2",
lambda sink: {"msteamsv2_configs": [{"webhook_url": sink.container_configs["8080"].get("/msteams/crud-original")}]},
lambda sink: {"msteamsv2_configs": [{"webhook_url": sink.container_configs["8080"].get("/msteams/crud-updated")}]},
"crud-original",
"crud-updated",
),
(
"email",
lambda sink: {"email_configs": [{"to": "crud-original@integration.test"}]},
lambda sink: {"email_configs": [{"to": "crud-updated@integration.test"}]},
"crud-original@integration.test",
"crud-updated@integration.test",
),
]
@pytest.mark.parametrize(
"channel_type,make_config,make_updated_config,created_marker,updated_marker",
CHANNEL_TYPE_CASES,
ids=[case[0] for case in CHANNEL_TYPE_CASES],
)
def test_channel_crud( # pylint: disable=too-many-arguments,too-many-positional-arguments
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
notification_channel: types.TestContainerDocker,
channel_type: str,
make_config: Callable[[types.TestContainerDocker], dict],
make_updated_config: Callable[[types.TestContainerDocker], dict],
created_marker: str,
updated_marker: str,
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
name = f"crud-{channel_type}-{uuid.uuid4()}"
config = {"name": name, **make_config(notification_channel)}
response = requests.post(signoz.self.host_configs["8080"].get("/api/v1/channels"), json=config, headers={"Authorization": f"Bearer {token}"}, timeout=TIMEOUT)
assert response.status_code == HTTPStatus.CREATED, response.text
created = response.json()["data"]
channel_id = created["id"]
assert created["name"] == name
assert created["type"] == channel_type
response = requests.get(signoz.self.host_configs["8080"].get("/api/v1/channels"), headers={"Authorization": f"Bearer {token}"}, timeout=TIMEOUT)
assert response.status_code == HTTPStatus.OK, response.text
listed = {channel["name"]: channel for channel in response.json()["data"]}
assert name in listed
assert listed[name]["type"] == channel_type
response = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/channels/{channel_id}"), headers={"Authorization": f"Bearer {token}"}, timeout=TIMEOUT)
assert response.status_code == HTTPStatus.OK, response.text
assert created_marker in response.json()["data"]["data"]
updated_config = {"name": name, **make_updated_config(notification_channel)}
response = requests.put(signoz.self.host_configs["8080"].get(f"/api/v1/channels/{channel_id}"), json=updated_config, headers={"Authorization": f"Bearer {token}"}, timeout=TIMEOUT)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/channels/{channel_id}"), headers={"Authorization": f"Bearer {token}"}, timeout=TIMEOUT)
assert response.status_code == HTTPStatus.OK, response.text
data = response.json()["data"]["data"]
assert updated_marker in data
assert created_marker not in data
response = requests.delete(signoz.self.host_configs["8080"].get(f"/api/v1/channels/{channel_id}"), headers={"Authorization": f"Bearer {token}"}, timeout=TIMEOUT)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/channels/{channel_id}"), headers={"Authorization": f"Bearer {token}"}, timeout=TIMEOUT)
assert response.status_code == HTTPStatus.NOT_FOUND, response.text
def test_create_rejects_duplicate_name(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_notification_channel: Callable[[dict], str],
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
name = f"duplicate-{uuid.uuid4()}"
create_notification_channel({"name": name, "email_configs": [{"to": "first@integration.test"}]})
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/channels"),
json={"name": name, "email_configs": [{"to": "second@integration.test"}]},
headers={"Authorization": f"Bearer {token}"},
timeout=TIMEOUT,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert "unique" in response.text
def test_create_rejects_channel_without_configs(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/channels"),
json={"name": f"empty-{uuid.uuid4()}"},
headers={"Authorization": f"Bearer {token}"},
timeout=TIMEOUT,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert "notification configuration" in response.text
def test_update_rejects_name_change(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_notification_channel: Callable[[dict], str],
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
name = f"rename-{uuid.uuid4()}"
channel_id = create_notification_channel({"name": name, "email_configs": [{"to": "rename@integration.test"}]})
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/channels/{channel_id}"),
json={"name": f"{name}-renamed", "email_configs": [{"to": "rename@integration.test"}]},
headers={"Authorization": f"Bearer {token}"},
timeout=TIMEOUT,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert "cannot update channel name" in response.text
def test_channels_require_authentication(signoz: types.SigNoz) -> None:
response = requests.get(signoz.self.host_configs["8080"].get("/api/v1/channels"), timeout=TIMEOUT)
assert response.status_code == HTTPStatus.UNAUTHORIZED, response.text
def test_email_channel_never_stores_or_serves_smtp_settings(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
hostile_name = f"hostile-email-{uuid.uuid4()}"
hostile_config = {
"name": hostile_name,
"email_configs": [
{
"to": "hostile@integration.test",
"from": "spoofed@integration.test",
"hello": "attacker.test",
"smarthost": "smtp.attacker.test:2525",
"auth_username": "attacker",
"auth_password": "tenant-posted-secret",
"require_tls": False,
"headers": {"Subject": "hostile subject"},
}
],
}
response = requests.post(signoz.self.host_configs["8080"].get("/api/v1/channels"), json=hostile_config, headers={"Authorization": f"Bearer {token}"}, timeout=TIMEOUT)
assert response.status_code == HTTPStatus.CREATED, response.text
created = response.json()["data"]
assert_email_channel_payload_clean(created["data"])
response = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/channels/{created['id']}"), headers={"Authorization": f"Bearer {token}"}, timeout=TIMEOUT)
assert response.status_code == HTTPStatus.OK, response.text
served = response.json()["data"]["data"]
assert_email_channel_payload_clean(served)
assert "hostile@integration.test" in served
assert "hostile subject" in served
assert "smtp.attacker.test" not in served
assert "tenant-posted-secret" not in served
response = requests.get(signoz.self.host_configs["8080"].get("/api/v1/channels"), headers={"Authorization": f"Bearer {token}"}, timeout=TIMEOUT)
assert response.status_code == HTTPStatus.OK, response.text
assert "tenant-posted-secret" not in response.text
assert MAILDEV_INCOMING_PASS not in response.text
with signoz.sqlstore.conn.connect() as conn:
stored = conn.execute(
text("SELECT data FROM notification_channel WHERE name = :name"),
{"name": hostile_name},
).fetchone()
assert stored is not None
assert_email_channel_payload_clean(stored[0])
assert "tenant-posted-secret" not in stored[0]
configs = conn.execute(text("SELECT config FROM alertmanager_config")).fetchall()
assert len(configs) > 0
for (config_raw,) in configs:
assert MAILDEV_INCOMING_PASS not in config_raw
assert "tenant-posted-secret" not in config_raw
assert '"smtp_auth_password"' not in config_raw
assert '"auth_password"' not in config_raw
def test_email_test_channel_delivers_via_env_transport(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
maildev: types.TestContainerDocker,
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
delete_all_mails(maildev)
recipient = f"delivery-{uuid.uuid4()}@integration.test"
send_test_notification(
signoz,
token,
{"name": f"delivery-{uuid.uuid4()}", "email_configs": [{"to": recipient}]},
)
deadline = time.time() + 30
while time.time() < deadline:
if verify_email_received(maildev, {"to": recipient, "from": SMTP_TEST_FROM}):
return
time.sleep(1)
raise AssertionError(f"no email delivered to {recipient} from {SMTP_TEST_FROM}")

Some files were not shown because too many files have changed in this diff Show More