Compare commits

..

10 Commits

Author SHA1 Message Date
Vinícius Lourenço
2f257fa04f fix(fmt): lint issue 2026-07-30 17:59:02 -03:00
Vinícius Lourenço
c08eb2f70a perf(with-web-dockerfile): skip build with race 2026-07-30 17:03:05 -03:00
Vinícius Lourenço
a185a504f8 fix(timeline-pagination): improve flaky test 2026-07-30 17:01:08 -03:00
Vinícius Lourenço
7e62d1edf7 chore(package): add few more scripts 2026-07-30 16:09:05 -03:00
Vinícius Lourenço
221063c91d docs(e2e): fix path for alerts 2026-07-30 16:09:04 -03:00
Vinícius Lourenço
99f1c8333e refactor(alerts): make it more resilient 2026-07-30 16:09:04 -03:00
Vinícius Lourenço
dd502444d0 refactor(auth): cleanups on auth due to mutating test locallly 2026-07-30 16:09:04 -03:00
Vinícius Lourenço
f5e1c25b23 refactor(alerts): clean and re-organize the tests 2026-07-30 16:09:04 -03:00
Vinícius Lourenço
eea7b3eba5 feat(alerts): add initial e2e 2026-07-30 16:09:04 -03:00
Vinícius Lourenço
cbbd96ab50 chore(alert): add test ids 2026-07-30 16:09:04 -03:00
82 changed files with 4421 additions and 2270 deletions

View File

@@ -38,7 +38,8 @@ COPY ./pkg/ ./pkg/
COPY ./templates /root/templates
COPY Makefile Makefile
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise
RUN mv /root/linux-${TARGETARCH}/signoz /root/signoz
COPY --from=build /opt/build ./web/

View File

@@ -227,11 +227,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
@@ -265,7 +268,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

@@ -1,66 +0,0 @@
import { sortByMeanDesc } from '../sortByMeanDesc';
interface Item {
name: string;
values: (number | null)[];
}
const item = (name: string, values: (number | null)[]): Item => ({
name,
values,
});
const sorted = (items: Item[]): string[] =>
sortByMeanDesc(items, {
getValues: (entry) => entry.values,
getKey: (entry) => entry.name,
}).map((entry) => entry.name);
describe('sortByMeanDesc', () => {
it('orders items by descending mean', () => {
expect(
sorted([item('a', [1, 1]), item('b', [10, 20]), item('c', [5, 5])]),
).toStrictEqual(['b', 'c', 'a']);
});
it('sinks items with no finite values to the bottom', () => {
expect(
sorted([item('a', []), item('b', [NaN, Infinity]), item('c', [1])]),
).toStrictEqual(['c', 'a', 'b']);
});
it('ignores non-finite and null values when averaging', () => {
// Mean over the finite values only is 10, so NaN must not poison it to last place.
expect(
sorted([item('a', [2, 2]), item('b', [10, NaN]), item('c', [4, null])]),
).toStrictEqual(['b', 'c', 'a']);
});
it('produces the same order whichever order the items arrive in', () => {
const a = item('a', [5]);
const b = item('b', [5]);
const c = item('c', [9]);
expect(sorted([a, b, c])).toStrictEqual(sorted([c, b, a]));
expect(sorted([b, a, c])).toStrictEqual(['c', 'a', 'b']);
});
it('tiebreaks equal means on the key', () => {
expect(sorted([item('b', [1]), item('a', [1])])).toStrictEqual(['a', 'b']);
});
it('tiebreaks on the key for items that have no finite values either', () => {
expect(sorted([item('b', []), item('a', [NaN])])).toStrictEqual(['a', 'b']);
});
it('does not mutate the input array', () => {
const items = [item('a', [1]), item('b', [9])];
expect(sorted(items)).toStrictEqual(['b', 'a']);
expect(items.map((entry) => entry.name)).toStrictEqual(['a', 'b']);
});
it('returns an empty array for no items', () => {
expect(sorted([])).toStrictEqual([]);
});
});

View File

@@ -1,45 +0,0 @@
type MeanInput = number | null | undefined;
function finiteMean(values: Iterable<MeanInput>): number | null {
let sum = 0;
let count = 0;
for (const value of values) {
if (typeof value === 'number' && Number.isFinite(value)) {
sum += value;
count += 1;
}
}
return count > 0 ? sum / count : null;
}
export interface SortByMeanDescOptions<T> {
/** Numeric values of an item; non-finite entries are ignored. */
getValues: (item: T) => Iterable<MeanInput>;
/** Stable identity of an item, used to break ties on equal means. */
getKey: (item: T) => string;
}
/**
* Orders series by descending mean, on a copy (V1 parity: `utils/getSortedSeriesData`). The wire
* order can't stand in for it: the backend returns series in Go map-iteration order.
*
* Call this before building the uPlot config and aligned data — those two and click attribution
* all index the list positionally, and uPlot draws a stacked bar's segments in series-index order.
*/
export function sortByMeanDesc<T>(
items: T[],
{ getValues, getKey }: SortByMeanDescOptions<T>,
): T[] {
return items
.map((item) => ({ item, mean: finiteMean(getValues(item)) }))
.sort((a, b) => {
if (a.mean !== null && b.mean !== null && a.mean !== b.mean) {
return b.mean - a.mean;
}
if ((a.mean === null) !== (b.mean === null)) {
return a.mean === null ? 1 : -1;
}
return getKey(a.item).localeCompare(getKey(b.item));
})
.map(({ item }) => item);
}

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

@@ -1,97 +0,0 @@
import {
emptyVariableFormModel,
type VariableFormModel,
} from '../variableFormModel';
import { dtoToFormModel, formModelToDto } from '../variableAdapters';
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
return { ...emptyVariableFormModel(), name: 'v', ...overrides };
}
/** The list spec a saved variable carries, whatever its plugin. */
function listSpec(m: VariableFormModel): {
allowMultiple?: boolean;
allowAllValue?: boolean;
} {
return formModelToDto(m).spec as {
allowMultiple?: boolean;
allowAllValue?: boolean;
};
}
describe('formModelToDto — the ALL flag needs multi-select', () => {
it('keeps ALL for a multi-select dynamic variable', () => {
expect(
listSpec(
model({
type: 'DYNAMIC',
dynamicAttribute: 'host.name',
multiSelect: true,
}),
),
).toMatchObject({ allowMultiple: true, allowAllValue: true });
});
it('drops ALL for a single-select dynamic variable', () => {
// The API rejects allowAllValue without allowMultiple, and this used to be forced
// true for every dynamic variable — which blocked saving the whole dashboard.
expect(
listSpec(
model({
type: 'DYNAMIC',
dynamicAttribute: 'host.name',
multiSelect: false,
}),
),
).toMatchObject({ allowMultiple: false, allowAllValue: false });
});
it('drops ALL for a single-select query variable that still carries the flag', () => {
expect(
listSpec(
model({
type: 'QUERY',
queryValue: 'SELECT 1',
multiSelect: false,
showAllOption: true,
}),
),
).toMatchObject({ allowMultiple: false, allowAllValue: false });
});
it('respects the toggle on a multi-select query variable', () => {
expect(
listSpec(
model({
type: 'QUERY',
queryValue: 'SELECT 1',
multiSelect: true,
showAllOption: false,
}),
),
).toMatchObject({ allowMultiple: true, allowAllValue: false });
});
it('round-trips a single-select dynamic variable unchanged', () => {
// What the dashboard in the report holds: saving it must not flip the flag.
const dto = {
kind: 'ListVariable',
spec: {
name: 'host.name',
display: { name: 'host.name', description: '' },
allowMultiple: false,
allowAllValue: false,
sort: 'alphabetical-asc',
plugin: {
kind: 'signoz/DynamicVariable',
spec: { name: 'host.name', signal: 'all' },
},
},
} as never;
expect(listSpec(dtoToFormModel(dto))).toMatchObject({
allowMultiple: false,
allowAllValue: false,
});
});
});

View File

@@ -133,14 +133,9 @@ export function formModelToDto(
name: model.name,
display,
allowMultiple: model.multiSelect,
// Dynamic variables always expose the aggregate "ALL" entry (matches V1, which
// forced showALLOption true on save); other types respect the toggle. Either
// way it needs multi-select — ALL is a set of values, and the API rejects the
// flag without it, which used to make a single-select dynamic variable
// unsaveable and blocked every other edit to the dashboard with it.
allowAllValue:
model.multiSelect &&
(model.type === 'DYNAMIC' ? true : model.showAllOption),
// Dynamic variables always expose the aggregate "ALL" entry (matches V1,
// which forced showALLOption true on save); other types respect the toggle.
allowAllValue: model.type === 'DYNAMIC' ? true : model.showAllOption,
// model.sort is already a Perses sort token (`none` / `alphabetical-*`).
sort: model.sort,
defaultValue: model.defaultValue,

View File

@@ -1,164 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider as ReduxProvider } from 'react-redux';
import { MemoryRouter, Route, useHistory, useParams } from 'react-router-dom';
import { CompatRouter } from 'react-router-dom-v5-compat';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import {
type DashboardtypesPanelDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import configureStore from 'redux-mock-store';
import appStore from 'store';
import { useOpenPanelEditor } from '../../hooks/useOpenPanelEditor';
import { usePanelEditorQuerySync } from '../hooks/usePanelEditorQuerySync';
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder/PanelEditorQueryBuilder';
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
jest.mock('hooks/useSafeNavigate', () => {
const { useHistory: useRouterHistory } =
jest.requireActual('react-router-dom');
return {
useSafeNavigate: (): unknown => {
const history = useRouterHistory();
return {
safeNavigate: (to: string): void => history.push(to),
};
},
};
});
jest.mock('../../store/useDashboardStore', () => ({
useDashboardStore: (selector: (s: unknown) => unknown): unknown =>
selector({ dashboardId: 'dash-1' }),
}));
function makePromPanel(name: string, promql: string): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [
{
kind: 'time_series',
spec: {
plugin: {
kind: 'signoz/PromQLQuery',
spec: { name: 'A', query: promql, legend: '', disabled: false },
},
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
}
const PANELS: Record<string, DashboardtypesPanelDTO> = {
A: makePromPanel('Panel A', 'up{job="alpha"}'),
B: makePromPanel('Panel B', 'up{job="bravo"}'),
};
const noop = (): void => {};
/** Stands in for the editor route: the same draft + builder sync `PanelEditorContainer` runs. */
function EditorRoute(): JSX.Element {
const { panelId } = useParams<{ panelId: string }>();
const [panel] = useState(PANELS[panelId]);
usePanelEditorQuerySync({
draft: panel,
panelType: PANEL_TYPES.TIME_SERIES,
setSpec: noop,
refetch: noop,
signal: TelemetrytypesSignalDTO.metrics,
savedQueries: panel.spec.queries,
});
return (
<PanelEditorQueryBuilder
panelKind="signoz/TimeSeriesPanel"
signal={TelemetrytypesSignalDTO.metrics}
isLoadingQueries={false}
onStageRunQuery={noop}
onCancelQuery={noop}
/>
);
}
function Harness(): JSX.Element {
const openPanelEditor = useOpenPanelEditor();
const history = useHistory();
return (
<>
<button
type="button"
data-testid="edit-a"
onClick={(): void => openPanelEditor('A', { panel: PANELS.A })}
>
edit A
</button>
<button
type="button"
data-testid="edit-b"
onClick={(): void => openPanelEditor('B', { panel: PANELS.B })}
>
edit B
</button>
<button
type="button"
data-testid="back"
onClick={(): void => history.push('/dashboard/dash-1')}
>
back
</button>
<Route
path="/dashboard/:dashboardId/panel/:panelId"
component={EditorRoute}
/>
</>
);
}
const renderHarness = (): void => {
render(
<MemoryRouter initialEntries={['/dashboard/dash-1']}>
<CompatRouter>
<QueryClientProvider client={new QueryClient()}>
<ReduxProvider store={configureStore([])(appStore.getState())}>
<TooltipProvider>
<QueryBuilderProvider>
<Harness />
</QueryBuilderProvider>
</TooltipProvider>
</ReduxProvider>
</QueryClientProvider>
</CompatRouter>
</MemoryRouter>,
);
};
describe('Panel editor route, PromQL panels', () => {
it('opens on the edited panel query, not the previously edited one', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderHarness();
await user.click(screen.getByTestId('edit-a'));
expect(screen.getByTestId('promql-query-input')).toHaveValue(
'up{job="alpha"}',
);
await user.click(screen.getByTestId('back'));
await user.click(screen.getByTestId('edit-b'));
expect(screen.getByTestId('promql-query-input')).toHaveValue(
'up{job="bravo"}',
);
});
});

View File

@@ -27,7 +27,6 @@ import { stepClickTimeRange } from '../../utils/drilldown/chartClickTimeRange';
import { enrichChartClick } from '../../utils/drilldown/enrichChartClick';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
import { sortSeriesByMeanDesc } from '../../utils/sortSeriesByMean';
import { buildBarChartConfig } from './utils/buildConfig';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
@@ -71,12 +70,7 @@ function BarPanelRenderer({
const flatSeries = useMemo(
() =>
sortSeriesByMeanDesc(
flattenTimeSeries(
getTimeSeriesResults(data.response),
data.legendMap ?? {},
),
),
flattenTimeSeries(getTimeSeriesResults(data.response), data.legendMap ?? {}),
[data.response, data.legendMap],
);

View File

@@ -27,7 +27,6 @@ import { stepClickTimeRange } from '../../utils/drilldown/chartClickTimeRange';
import { enrichChartClick } from '../../utils/drilldown/enrichChartClick';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
import { sortSeriesByMeanDesc } from '../../utils/sortSeriesByMean';
import { buildTimeSeriesConfig } from './utils/buildConfig';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
@@ -72,12 +71,7 @@ function TimeSeriesPanelRenderer({
const flatSeries = useMemo(
() =>
sortSeriesByMeanDesc(
flattenTimeSeries(
getTimeSeriesResults(data.response),
data.legendMap ?? {},
),
),
flattenTimeSeries(getTimeSeriesResults(data.response), data.legendMap ?? {}),
[data.response, data.legendMap],
);

View File

@@ -1,95 +0,0 @@
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { sortSeriesByMeanDesc } from '../sortSeriesByMean';
function makeSeries(
queryName: string,
values: number[],
overrides: Partial<PanelSeries> = {},
): PanelSeries {
return {
queryName,
legend: '',
labels: {},
kind: 'series',
values: values.map((value, index) => ({
timestamp: (index + 1) * 1_000,
value,
})),
aggregation: { index: 0, alias: '' },
...overrides,
};
}
const names = (series: PanelSeries[]): string[] =>
series.map((item) => item.queryName);
describe('sortSeriesByMeanDesc', () => {
it('orders series by descending mean', () => {
const sorted = sortSeriesByMeanDesc([
makeSeries('A', [1, 1]),
makeSeries('B', [10, 20]),
makeSeries('C', [5, 5]),
]);
expect(names(sorted)).toStrictEqual(['B', 'C', 'A']);
});
it('sinks series with no finite points to the bottom', () => {
const sorted = sortSeriesByMeanDesc([
makeSeries('A', []),
makeSeries('B', [NaN, Infinity]),
makeSeries('C', [1]),
]);
expect(names(sorted)).toStrictEqual(['C', 'A', 'B']);
});
it('ignores non-finite points when averaging', () => {
const sorted = sortSeriesByMeanDesc([
makeSeries('A', [2, 2]),
// Mean over finite points only is 10, so NaN must not poison it to last place.
makeSeries('B', [10, NaN]),
]);
expect(names(sorted)).toStrictEqual(['B', 'A']);
});
it('produces the same order regardless of input order', () => {
const a = makeSeries('A', [5]);
const b = makeSeries('B', [5]);
const c = makeSeries('C', [9]);
expect(names(sortSeriesByMeanDesc([a, b, c]))).toStrictEqual(
names(sortSeriesByMeanDesc([c, b, a])),
);
expect(names(sortSeriesByMeanDesc([b, a, c]))).toStrictEqual(['C', 'A', 'B']);
});
it('tiebreaks equal means on labels when the query name matches', () => {
const forward = sortSeriesByMeanDesc([
makeSeries('A', [1], { labels: { host: 'b' } }),
makeSeries('A', [1], { labels: { host: 'a' } }),
]);
const reversed = sortSeriesByMeanDesc([
makeSeries('A', [1], { labels: { host: 'a' } }),
makeSeries('A', [1], { labels: { host: 'b' } }),
]);
expect(forward.map((item) => item.labels.host)).toStrictEqual(['a', 'b']);
expect(reversed.map((item) => item.labels.host)).toStrictEqual(['a', 'b']);
});
it('does not mutate the input array', () => {
const input = [makeSeries('A', [1]), makeSeries('B', [9])];
const sorted = sortSeriesByMeanDesc(input);
expect(names(input)).toStrictEqual(['A', 'B']);
expect(names(sorted)).toStrictEqual(['B', 'A']);
});
it('returns an empty array for no series', () => {
expect(sortSeriesByMeanDesc([])).toStrictEqual([]);
});
});

View File

@@ -1,23 +0,0 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap } from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getPanelDefinition } from '../registry';
import { PANEL_KIND_TO_PANEL_TYPE } from '../types/panelKind';
import { fromPerses } from '../../queryV5/persesQueryAdapters';
/**
* The panel's saved query as a builder `Query` — what the editor route and the View
* modal put in `compositeQuery` when they open. Matches the seed
* `usePanelEditorQuerySync` computes from the panel.
*/
export function getPanelBuilderQuery(panel: DashboardtypesPanelDTO): Query {
const kind = panel.spec.plugin.kind;
const [defaultSignal] = getPanelDefinition(kind).supportedSignals;
// A query-less panel seeds from the kind's first supported signal — `fromPerses`'s
// metrics default isn't authorable in every kind (e.g. List).
if (panel.spec.queries.length === 0 && defaultSignal) {
return initialQueriesMap[defaultSignal];
}
return fromPerses(panel.spec.queries, PANEL_KIND_TO_PANEL_TYPE[kind]);
}

View File

@@ -1,18 +0,0 @@
import { sortByMeanDesc } from 'container/DashboardContainer/visualization/charts/utils/sortByMeanDesc';
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
function seriesKey(series: PanelSeries): string {
const labels = Object.keys(series.labels)
.sort()
.map((name) => `${name}=${series.labels[name]}`)
.join(',');
return `${series.queryName}|${series.aggregation.index}|${series.kind}|${labels}`;
}
/** `sortByMeanDesc` over flattened V5 series; call it before building the config and chart data. */
export function sortSeriesByMeanDesc(series: PanelSeries[]): PanelSeries[] {
return sortByMeanDesc(series, {
getValues: (item) => item.values.map((point) => point.value),
getKey: seriesKey,
});
}

View File

@@ -240,10 +240,7 @@ describe('usePanelActionItems', () => {
(i) => 'key' in i && i.key === 'edit-panel',
);
(edit as { onClick: () => void }).onClick();
// The panel rides along so its saved query lands in the editor URL.
expect(mockOpenEditor).toHaveBeenCalledWith('panel-1', {
panel: mockPanel,
});
expect(mockOpenEditor).toHaveBeenCalledWith('panel-1');
});
it('"Move to section" offers a single "Dashboard (root)" target', () => {
@@ -397,9 +394,7 @@ describe('usePanelActionItems', () => {
(i) => 'key' in i && i.key === 'view-panel',
);
(view as { onClick: () => void }).onClick();
// The panel goes along so the opener can seed the shared query builder before
// the modal mounts (otherwise it renders the previously-viewed panel's query).
expect(mockOpenView).toHaveBeenCalledWith('panel-1', baseArgs.panel);
expect(mockOpenView).toHaveBeenCalledWith('panel-1');
});
it('create-alert seeds an alert from this panel', () => {

View File

@@ -130,7 +130,7 @@ export function usePanelActionItems({
key: 'view-panel',
label: 'View',
icon: <Fullscreen size={14} />,
onClick: (): void => openView(panelId, panel),
onClick: (): void => openView(panelId),
});
}
if (canEdit && canEditWidget && panelCapabilities.edit) {
@@ -139,7 +139,7 @@ export function usePanelActionItems({
label: label('Edit panel'),
icon: <PenLine size={14} />,
disabled: isLocked,
onClick: (): void => openPanelEditor(panelId, { panel }),
onClick: (): void => openPanelEditor(panelId),
});
}
if (canEdit && canEditWidget && panelCapabilities.clone) {

View File

@@ -1,253 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactElement } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider as ReduxProvider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { CompatRouter } from 'react-router-dom-v5-compat';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import configureStore from 'redux-mock-store';
import appStore from 'store';
import ViewPanelModal from '../ViewPanelModal/ViewPanelModal';
import { useViewPanel } from '../hooks/useViewPanel';
// CodeMirror (the where-clause editor) needs real DOM measurement APIs.
beforeAll(() => {
const rect = {
width: 100,
height: 20,
top: 0,
left: 0,
right: 100,
bottom: 20,
x: 0,
y: 0,
toJSON: (): unknown => rect,
} as DOMRect;
const rects = { length: 1, item: (): DOMRect => rect, 0: rect };
document.createRange = (): Range =>
({
getClientRects: (): unknown => rects,
getBoundingClientRect: (): DOMRect => rect,
setStart: (): void => {},
setEnd: (): void => {},
startContainer: document.body,
endContainer: document.body,
startOffset: 0,
endOffset: 0,
collapsed: true,
commonAncestorContainer: document.body,
}) as unknown as Range;
Element.prototype.getBoundingClientRect = (): DOMRect => rect;
});
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
jest.mock('hooks/useSafeNavigate', () => {
const { useHistory } = jest.requireActual('react-router-dom');
return {
useSafeNavigate: (): unknown => {
const history = useHistory();
return {
safeNavigate: (to: string, opts?: { replace?: boolean }): void => {
if (opts?.replace) {
history.replace(to);
} else {
history.push(to);
}
},
};
},
};
});
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
getKeySuggestions: jest
.fn()
.mockResolvedValue({ data: { data: { keys: {} } } }),
}));
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
getValueSuggestions: jest.fn().mockResolvedValue({
data: { data: { values: { stringValues: [], numberValues: [] } } },
}),
}));
jest.mock(
'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery',
() => ({
usePanelQuery: (): unknown => ({
data: undefined,
isFetching: false,
isPreviousData: false,
error: null,
refetch: jest.fn(),
cancelQuery: jest.fn(),
pagination: undefined,
}),
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore',
() => ({
useDashboardStore: (selector: (s: unknown) => unknown): unknown =>
selector({ dashboardId: 'dash-1' }),
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/PanelEditor/PreviewPane/PreviewPane',
() =>
function MockPreviewPane(): ReactElement {
return <div data-testid="preview-pane" />;
},
);
jest.mock('../hooks/useDrilldown', () => ({
useDrilldown: (): unknown => ({
enableDrillDown: false,
onPanelClick: jest.fn(),
contextMenuProps: {
coordinates: null,
popoverPosition: null,
items: null,
onClose: jest.fn(),
},
}),
}));
jest.mock('../hooks/usePanelInteractions', () => ({
usePanelInteractions: (): unknown => ({
onDragSelect: jest.fn(),
dashboardPreference: { syncMode: 0 },
}),
}));
jest.mock(
'../ViewPanelModal/ViewPanelModalHeader',
() =>
function MockViewPanelModalHeader(): ReactElement {
return <div data-testid="view-panel-header" />;
},
);
function makePanel(
name: string,
extras: Record<string, unknown>,
): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [
{
kind: 'time_series',
spec: {
plugin: {
kind: 'signoz/BuilderQuery',
spec: {
name: 'A',
signal: 'logs',
disabled: false,
filter: { expression: "service = 'x'" },
aggregations: [{ expression: 'count()' }],
...extras,
},
},
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
}
const PANELS: Record<string, DashboardtypesPanelDTO> = {
plain: makePanel('Plain panel', {}),
grouped: makePanel('Grouped panel', {
groupBy: [{ name: 'host.name', fieldDataType: 'string' }],
having: { expression: 'count() > 5' },
}),
};
function Harness(): JSX.Element {
const { expandedPanelId, openView, closeView } = useViewPanel();
return (
<>
<button
type="button"
data-testid="open-plain"
onClick={(): void => openView('plain', PANELS.plain)}
>
open plain
</button>
<button
type="button"
data-testid="open-grouped"
onClick={(): void => openView('grouped', PANELS.grouped)}
>
open grouped
</button>
<button type="button" data-testid="close" onClick={closeView}>
close
</button>
<ViewPanelModal
open={!!expandedPanelId}
panel={expandedPanelId ? PANELS[expandedPanelId] : undefined}
panelId={expandedPanelId ?? undefined}
onClose={closeView}
/>
</>
);
}
const renderHarness = (): void => {
render(
<MemoryRouter initialEntries={['/dashboard/dash-1']}>
<CompatRouter>
<QueryClientProvider client={new QueryClient()}>
<ReduxProvider store={configureStore([])(appStore.getState())}>
<TooltipProvider>
<QueryBuilderProvider>
<Harness />
</QueryBuilderProvider>
</TooltipProvider>
</ReduxProvider>
</QueryClientProvider>
</CompatRouter>
</MemoryRouter>,
);
};
// QueryAddOns picks which rows are visible once on mount and never re-seeds, so unlike
// the field values these never recover from a context swap that lands after mount.
describe('View modal, builder add-on rows', () => {
it('shows the opened panel add-ons, not the previous panel ones', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0, delay: null });
renderHarness();
await user.click(screen.getByTestId('open-grouped'));
expect(screen.getByTestId('group-by-content')).toBeInTheDocument();
expect(screen.getByTestId('having-content')).toBeInTheDocument();
await user.click(screen.getByTestId('close'));
await user.click(screen.getByTestId('open-plain'));
expect(screen.queryByTestId('group-by-content')).not.toBeInTheDocument();
expect(screen.queryByTestId('having-content')).not.toBeInTheDocument();
});
it('shows add-ons the opened panel has after a panel without them', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0, delay: null });
renderHarness();
await user.click(screen.getByTestId('open-plain'));
await user.click(screen.getByTestId('close'));
await user.click(screen.getByTestId('open-grouped'));
expect(screen.getByTestId('group-by-content')).toBeInTheDocument();
expect(screen.getByTestId('having-content')).toBeInTheDocument();
});
});

View File

@@ -1,187 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactElement } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider as ReduxProvider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { CompatRouter } from 'react-router-dom-v5-compat';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import configureStore from 'redux-mock-store';
import appStore from 'store';
import ViewPanelModal from '../ViewPanelModal/ViewPanelModal';
import { useViewPanel } from '../hooks/useViewPanel';
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
jest.mock('hooks/useSafeNavigate', () => {
const { useHistory } = jest.requireActual('react-router-dom');
return {
useSafeNavigate: (): unknown => {
const history = useHistory();
return {
safeNavigate: (to: string, opts?: { replace?: boolean }): void => {
if (opts?.replace) {
history.replace(to);
} else {
history.push(to);
}
},
};
},
};
});
jest.mock(
'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery',
() => ({
usePanelQuery: (): unknown => ({
data: undefined,
isFetching: false,
isPreviousData: false,
error: null,
refetch: jest.fn(),
cancelQuery: jest.fn(),
pagination: undefined,
}),
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore',
() => ({
useDashboardStore: (selector: (s: unknown) => unknown): unknown =>
selector({ dashboardId: 'dash-1' }),
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/PanelEditor/PreviewPane/PreviewPane',
() =>
function MockPreviewPane(): ReactElement {
return <div data-testid="preview-pane" />;
},
);
jest.mock('../hooks/useDrilldown', () => ({
useDrilldown: (): unknown => ({
enableDrillDown: false,
onPanelClick: jest.fn(),
contextMenuProps: {
coordinates: null,
popoverPosition: null,
items: null,
onClose: jest.fn(),
},
}),
}));
jest.mock('../hooks/usePanelInteractions', () => ({
usePanelInteractions: (): unknown => ({
onDragSelect: jest.fn(),
dashboardPreference: { syncMode: 0 },
}),
}));
jest.mock(
'../ViewPanelModal/ViewPanelModalHeader',
() =>
function MockViewPanelModalHeader(): ReactElement {
return <div data-testid="view-panel-header" />;
},
);
function makePromPanel(name: string, promql: string): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [
{
kind: 'time_series',
spec: {
plugin: {
kind: 'signoz/PromQLQuery',
spec: { name: 'A', query: promql, legend: '', disabled: false },
},
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
}
const PANELS: Record<string, DashboardtypesPanelDTO> = {
A: makePromPanel('Panel A', 'up{job="alpha"}'),
B: makePromPanel('Panel B', 'up{job="bravo"}'),
};
function Harness(): JSX.Element {
const { expandedPanelId, openView, closeView } = useViewPanel();
return (
<>
<button
type="button"
data-testid="open-a"
onClick={(): void => openView('A', PANELS.A)}
>
open A
</button>
<button
type="button"
data-testid="open-b"
onClick={(): void => openView('B', PANELS.B)}
>
open B
</button>
<button type="button" data-testid="close" onClick={closeView}>
close
</button>
<ViewPanelModal
open={!!expandedPanelId}
panel={expandedPanelId ? PANELS[expandedPanelId] : undefined}
panelId={expandedPanelId ?? undefined}
onClose={closeView}
/>
</>
);
}
const renderHarness = (): void => {
render(
<MemoryRouter initialEntries={['/dashboard/dash-1']}>
<CompatRouter>
<QueryClientProvider client={new QueryClient()}>
<ReduxProvider store={configureStore([])(appStore.getState())}>
<TooltipProvider>
<QueryBuilderProvider>
<Harness />
</QueryBuilderProvider>
</TooltipProvider>
</ReduxProvider>
</QueryClientProvider>
</CompatRouter>
</MemoryRouter>,
);
};
describe('View modal, PromQL panels', () => {
it('shows the opened panel query, not the previously viewed one', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0, delay: null });
renderHarness();
await user.click(screen.getByTestId('open-a'));
expect(screen.getByTestId('promql-query-input')).toHaveValue(
'up{job="alpha"}',
);
await user.click(screen.getByTestId('close'));
await user.click(screen.getByTestId('open-b'));
expect(screen.getByTestId('promql-query-input')).toHaveValue(
'up{job="bravo"}',
);
});
});

View File

@@ -1,285 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { CompatRouter } from 'react-router-dom-v5-compat';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { useViewPanelMode } from '../ViewPanelModal/useViewPanelMode';
import { useViewPanel } from '../hooks/useViewPanel';
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
jest.mock('hooks/useSafeNavigate', () => {
const { useHistory } = jest.requireActual('react-router-dom');
return {
useSafeNavigate: (): unknown => {
const history = useHistory();
return {
safeNavigate: (to: string, opts?: { replace?: boolean }): void => {
if (opts?.replace) {
history.replace(to);
} else {
history.push(to);
}
},
};
},
};
});
jest.mock(
'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery',
() => ({
usePanelQuery: (): unknown => ({
data: undefined,
isFetching: false,
isPreviousData: false,
error: null,
refetch: jest.fn(),
cancelQuery: jest.fn(),
pagination: undefined,
}),
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore',
() => ({
useDashboardStore: (selector: (s: unknown) => unknown): unknown =>
selector({ dashboardId: 'dash-1' }),
}),
);
function makePanel(name: string, expression: string): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [
{
kind: 'time_series',
spec: {
plugin: {
kind: 'signoz/BuilderQuery',
spec: {
name: 'A',
signal: 'logs',
disabled: false,
filter: { expression },
aggregations: [{ expression: 'count()' }],
},
},
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
}
const PANELS: Record<string, DashboardtypesPanelDTO> = {
A: makePanel('Panel A', "service = 'alpha'"),
B: makePanel('Panel B', "service = 'bravo'"),
};
const panelOf = (json: string): string => {
if (json.includes('alpha')) {
return 'A';
}
if (json.includes('bravo')) {
return 'B';
}
return 'none';
};
/** Every render of the open modal, so a single stale frame can't hide. */
const renders: { current: string; draft: string; filterItems: number }[] = [];
const stagedIds: (string | undefined)[] = [];
function ModalBody({ panelId }: { panelId: string }): JSX.Element {
const { currentQuery } = useQueryBuilder();
const { draft } = useViewPanelMode({
panel: PANELS[panelId],
panelId,
time: { startMs: 0, endMs: 1000 },
});
renders.push({
current: panelOf(JSON.stringify(currentQuery)),
draft: panelOf(JSON.stringify(draft.spec.queries)),
filterItems: currentQuery.builder.queryData[0]?.filters?.items.length ?? 0,
});
return <div data-testid="modal-body" />;
}
function SearchProbe(): JSX.Element {
return <div data-testid="search">{useLocation().search}</div>;
}
function StagedQueryProbe(): null {
const { stagedQuery } = useQueryBuilder();
if (stagedIds.at(-1) !== stagedQuery?.id) {
stagedIds.push(stagedQuery?.id);
}
return null;
}
const drilldownQuery = (base: Query): Query => ({
...base,
builder: {
...base.builder,
queryData: base.builder.queryData.map((q) => ({
...q,
filter: { expression: "service = 'bravo' AND host = 'h1'" },
})),
},
});
function Harness(): JSX.Element {
const { expandedPanelId, openView, openViewWithQuery, closeView } =
useViewPanel();
return (
<>
<button
type="button"
data-testid="open-a"
onClick={(): void => openView('A', PANELS.A)}
>
open A
</button>
<button
type="button"
data-testid="open-b"
onClick={(): void => openView('B', PANELS.B)}
>
open B
</button>
<button
type="button"
data-testid="drilldown-b"
onClick={(): void =>
openViewWithQuery(
'B',
drilldownQuery(
fromPerses(PANELS.B.spec.queries, PANEL_TYPES.TIME_SERIES),
),
PANEL_TYPES.TIME_SERIES,
)
}
>
drilldown B
</button>
<button type="button" data-testid="close" onClick={closeView}>
close
</button>
<StagedQueryProbe />
<SearchProbe />
{expandedPanelId && <ModalBody panelId={expandedPanelId} />}
</>
);
}
const renderHarness = (): void => {
render(
<MemoryRouter initialEntries={['/dashboard/dash-1']}>
<CompatRouter>
<QueryBuilderProvider>
<Harness />
</QueryBuilderProvider>
</CompatRouter>
</MemoryRouter>,
);
};
describe('useViewPanel', () => {
beforeEach(() => {
renders.length = 0;
stagedIds.length = 0;
});
// The builder context is global and outlives the modal; its fields seed themselves
// on mount, so a late swap leaves them on the previously-viewed panel.
it('seeds the builder with the opened panel before the modal renders', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderHarness();
await user.click(screen.getByTestId('open-a'));
await user.click(screen.getByTestId('close'));
renders.length = 0;
await user.click(screen.getByTestId('open-b'));
expect(renders.length).toBeGreaterThan(0);
expect(renders.every((r) => r.current === 'B')).toBe(true);
});
it("carries the opened panel's query in the URL", async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderHarness();
await user.click(screen.getByTestId('open-b'));
const search = new URLSearchParams(
screen.getByTestId('search').textContent ?? '',
);
expect(search.get(QueryParams.expandedWidgetId)).toBe('B');
const carried = search.get(QueryParams.compositeQuery);
expect(panelOf(decodeURIComponent(carried as string))).toBe('B');
expect(search.get(QueryParams.graphType)).toBeNull();
});
// The edit session commits any staged query on mount, so a staged query left over
// from the previous panel would make this panel's preview fetch the old query.
it('never lets the previous panel query reach the new panel draft', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderHarness();
await user.click(screen.getByTestId('open-a'));
await user.click(screen.getByTestId('close'));
renders.length = 0;
await user.click(screen.getByTestId('open-b'));
expect(renders.every((r) => r.draft === 'B')).toBe(true);
});
// The drilldown URL carries the query's own id, so a staged query with that id makes
// the provider skip hydration — losing the normalisation that fills `filters.items`.
it('still lets the provider hydrate a drilldown query from the URL', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderHarness();
await user.click(screen.getByTestId('open-a'));
await user.click(screen.getByTestId('close'));
renders.length = 0;
await user.click(screen.getByTestId('drilldown-b'));
expect(renders.every((r) => r.current === 'B')).toBe(true);
expect(renders.at(-1)?.filterItems).toBeGreaterThan(0);
});
// `useSyncTimeOnStagedQueryChange` (dashboard toolbar) re-anchors global time when one
// non-null staged id replaces another, refetching every panel behind the modal.
it.each([['open-b'], ['drilldown-b']])(
'never swaps one staged query for another (%s)',
async (trigger) => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderHarness();
await user.click(screen.getByTestId('open-a'));
await user.click(screen.getByTestId('close'));
await user.click(screen.getByTestId(trigger));
const swapsWithoutNull = stagedIds.some(
(id, i) => i > 0 && id !== undefined && stagedIds[i - 1] !== undefined,
);
expect(swapsWithoutNull).toBe(false);
},
);
});

View File

@@ -1,14 +1,11 @@
import { useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import logEvent from 'api/common/logEvent';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
import { getPanelBuilderQuery } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getPanelBuilderQuery';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { clearViewPanelHandoff } from '../ViewPanelModal/viewPanelHandoffStore';
@@ -16,8 +13,8 @@ import { clearViewPanelHandoff } from '../ViewPanelModal/viewPanelHandoffStore';
export interface UseViewPanelApi {
/** Panel id currently expanded in the View modal; null when none is open. */
expandedPanelId: string | null;
/** Open the View modal on the saved panel, its query carried in `compositeQuery`. */
openView: (panelId: string, panel: DashboardtypesPanelDTO) => void;
/** Open the View modal on the saved panel (clears any leftover in-modal query/kind). */
openView: (panelId: string) => void;
/**
* Open the View modal pre-seeded with a drilldown query + kind, persisted in the URL so it
* survives refresh (V1 parity); the modal hydrates its draft from these on mount.
@@ -33,38 +30,30 @@ export interface UseViewPanelApi {
/**
* Drives the panel View modal off the URL (V1 parity): `expandedWidgetId` holds the open
* panel and `compositeQuery` the query it opens on, which a drilldown retargets along with
* `graphType`. URL-backed state is shareable, survives refresh, and browser Back closes it.
* panel, and a drilldown additionally seeds `compositeQuery` + `graphType`. URL-backed state
* is shareable, survives refresh, and the browser back-button closes it.
*/
export function useViewPanel(): UseViewPanelApi {
const { safeNavigate } = useSafeNavigate();
const { pathname } = useLocation();
const urlQuery = useUrlQuery();
const { resetQuery } = useQueryBuilder();
const expandedPanelId = urlQuery.get(QueryParams.expandedWidgetId);
const openView = useCallback(
(panelId: string, panel: DashboardtypesPanelDTO): void => {
(panelId: string): void => {
// Copy before mutating: useUrlQuery returns a memoized instance.
const next = new URLSearchParams(urlQuery);
next.set(QueryParams.expandedWidgetId, panelId);
// Only a drilldown retargets the panel type.
// Drop leftover in-modal query/kind + the editor's handoff so a plain View opens
// on the saved panel, not stale state the modal would otherwise hydrate from.
next.delete(QueryParams.compositeQuery);
next.delete(QueryParams.graphType);
clearViewPanelHandoff();
const query = getPanelBuilderQuery(panel);
next.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
// The provider applies the URL in an effect, a tick after the builder's fields have
// mounted and read the query they keep. `resetQuery` — not `initQueryBuilderData`:
// swapping one staged id for another re-anchors global time and refetches the grid.
resetQuery(query);
void logEvent(DashboardDetailEvents.PanelViewed, { panelId });
safeNavigate(`${pathname}?${next.toString()}`);
},
[pathname, safeNavigate, urlQuery, resetQuery],
[pathname, safeNavigate, urlQuery],
);
const openViewWithQuery = useCallback(
@@ -74,10 +63,6 @@ export function useViewPanel(): UseViewPanelApi {
next.set(QueryParams.graphType, panelType);
// A grid drilldown opens on the saved panel, never a stale editor handoff.
clearViewPanelHandoff();
// As in `openView`. Clearing the staged query matters twice over here: the URL
// below carries this query's own id, and a staged query with a matching id
// makes the provider skip the hydration that normalises legacy filter fields.
resetQuery(query);
// Same encoding the query builder uses (see `useGetCompositeQueryParam`): the URL
// value is `encodeURIComponent(JSON.stringify(query))`, decoded once on read.
next.set(
@@ -86,7 +71,7 @@ export function useViewPanel(): UseViewPanelApi {
);
safeNavigate(`${pathname}?${next.toString()}`);
},
[pathname, safeNavigate, urlQuery, resetQuery],
[pathname, safeNavigate, urlQuery],
);
const closeView = useCallback((): void => {

View File

@@ -13,7 +13,6 @@ import { TOOLTIP_SCROLL_CONTENT_CLASS } from 'components/TooltipScrollArea/Toolt
import HiddenVariablesTooltip from './components/HiddenVariablesTooltip/HiddenVariablesTooltip';
import { useVariableSelection } from './hooks/useVariableSelection';
import { resolveDefaultSelection } from './utils/resolveVariableSelection';
import VariableSelector from './components/VariableSelector/VariableSelector';
import styles from './VariablesBar.module.scss';
@@ -97,10 +96,12 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
variable={variable}
variables={variables}
selections={selection}
// Until the seed commits a selection, stand in the same default it will
// commit, through the one resolver — an empty stand-in reads as "nothing
// selected" to a control that snapshots it on mount.
selection={selection[variable.name] ?? resolveDefaultSelection(variable)}
selection={
selection[variable.name] ?? {
value: variable.multiSelect ? [] : '',
allSelected: false,
}
}
onChange={(next): void => setSelection(variable.name, next)}
onAutoSelect={(next): void => autoSelect(variable.name, next)}
/>

View File

@@ -1,92 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { VariableSelection } from '../selectionTypes';
import TextSelector from '../components/selectors/TextSelector';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
function renderSelector(
selection: VariableSelection,
onChange = jest.fn(),
): { rerender: (selection: VariableSelection) => void; onChange: jest.Mock } {
const { rerender } = render(
<TextSelector
selection={selection}
defaultValue="flower"
onChange={onChange}
testId="variable-input-service"
/>,
);
return {
onChange,
rerender: (next): void =>
rerender(
<TextSelector
selection={next}
defaultValue="flower"
onChange={onChange}
testId="variable-input-service"
/>,
),
};
}
function input(): HTMLInputElement {
return screen.getByTestId('variable-input-service') as HTMLInputElement;
}
describe('TextSelector', () => {
it('shows the value the seed commits after mount', () => {
// The bar mounts before the seed resolves the definition's default, so the first
// render sees nothing selected. The box must follow the value once it lands.
const { rerender } = renderSelector({ value: '', allSelected: false });
rerender({ value: 'flower', allSelected: false });
expect(input().value).toBe('flower');
});
it('follows a selection replaced from outside (share link, reset)', () => {
const { rerender } = renderSelector({ value: 'flower', allSelected: false });
rerender({ value: 'rose', allSelected: false });
expect(input().value).toBe('rose');
});
it('keeps what the user types until they commit it', async () => {
const user = userEvent.setup();
const { onChange } = renderSelector({ value: 'flower', allSelected: false });
await user.clear(input());
await user.type(input(), 'tulip');
// Still local — a keystroke must not cascade to dependent panels.
expect(input().value).toBe('tulip');
expect(onChange).not.toHaveBeenCalled();
await user.tab();
expect(onChange).toHaveBeenCalledWith({
value: 'tulip',
allSelected: false,
});
});
it('restores the default when emptied', async () => {
const user = userEvent.setup();
const { onChange } = renderSelector({ value: 'tulip', allSelected: false });
await user.clear(input());
await user.tab();
expect(onChange).toHaveBeenCalledWith({
value: 'flower',
allSelected: false,
});
expect(input().value).toBe('flower');
});
});

View File

@@ -87,99 +87,4 @@ describe('ValueSelector', () => {
expect(screen.getByText('ALL')).toBeInTheDocument();
});
describe('an ALL selection whose options are still loading', () => {
it('reads ALL, not the "Select value" placeholder', () => {
// Options arrive after the selection is known (first fetch, or a dynamic ALL,
// which carries no values at all) — the control must not read as unselected.
renderSelector({ value: null, allSelected: true }, [], true);
expect(
document.querySelector('.ant-select-selection-placeholder'),
).toHaveTextContent('ALL');
});
it('still shows concrete values while options load', () => {
renderSelector(
{ value: ['checkout-service-prod'], allSelected: false },
[],
true,
);
expect(
document.querySelector('.ant-select-selection-placeholder'),
).toBeNull();
});
});
describe('clearing', () => {
function clearIcon(): Element | null {
return document.querySelector('.ant-select-clear');
}
async function openDropdown(): Promise<void> {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
const control = screen.getByTestId('variable-select-env');
await user.click(control.querySelector('input') as HTMLInputElement);
}
it('offers no clear icon while the list is closed', () => {
renderSelector({ value: VALUES, allSelected: false }, OPTIONS);
expect(clearIcon()).toBeNull();
});
it('offers it once the list is open', async () => {
renderSelector({ value: VALUES, allSelected: false }, OPTIONS);
await openDropdown();
expect(clearIcon()).not.toBeNull();
});
it('offers no clear icon while every option is selected', async () => {
// ALL is every option, so there is nothing to clear — and the shared control
// refuses to empty an ALL selection, which would leave the icon inert.
renderSelector({ value: OPTIONS, allSelected: true }, OPTIONS);
await openDropdown();
expect(clearIcon()).toBeNull();
});
it('empties the list and commits nothing until it closes', async () => {
const onChange = jest.fn();
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
render(
<TooltipProvider>
<ValueSelector
options={OPTIONS}
variableType="query"
multiSelect
showAllOption
selection={{ value: VALUES, allSelected: false }}
onChange={onChange}
emptyFallback={{ value: [OPTIONS[0]], allSelected: false }}
testId="variable-select-env"
/>
</TooltipProvider>,
);
await openDropdown();
await user.click(clearIcon() as Element);
expect(document.querySelectorAll('.ant-select-selection-item')).toHaveLength(
0,
);
expect(onChange).not.toHaveBeenCalled();
// Closing fills in whatever the variable should hold.
await user.keyboard('{Escape}');
expect(onChange).toHaveBeenCalledWith({
value: [OPTIONS[0]],
allSelected: false,
});
});
});
});

View File

@@ -61,47 +61,6 @@ describe('useAutoSelect', () => {
expect(next).toBeUndefined();
});
it('selects ALL for an ALL-enabled multi-select with nothing selected', () => {
const next = run(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
['a', 'b'],
{ value: [], allSelected: false },
);
expect(next).toStrictEqual({ value: ['a', 'b'], allSelected: true });
});
it('falls back to ALL, not the first option, when every selected value is gone', () => {
const next = run(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
['x', 'y'],
{ value: ['a', 'b'], allSelected: false },
);
expect(next).toStrictEqual({ value: ['x', 'y'], allSelected: true });
});
it('selects the ALL sentinel for an empty ALL-enabled dynamic multi-select', () => {
const next = run(
model({ type: 'DYNAMIC', multiSelect: true, showAllOption: true }),
['a', 'b'],
{ value: [], allSelected: false },
);
expect(next).toStrictEqual({ value: null, allSelected: true });
});
it('still honours a configured default over ALL', () => {
const next = run(
model({
type: 'QUERY',
multiSelect: true,
showAllOption: true,
defaultValue: 'b',
}),
['a', 'b'],
{ value: [], allSelected: false },
);
expect(next).toStrictEqual({ value: ['b'], allSelected: false });
});
it('keeps the still-valid subset of a multi-select when options re-scope', () => {
const next = run(
model({ type: 'QUERY', multiSelect: true }),

View File

@@ -97,125 +97,6 @@ describe('useSeedVariableSelection', () => {
});
});
it('prefers the configured default over a stored empty text value', () => {
// Persisted from a session where the box was never filled — the default the
// definition carries must win, or the variable reads as unset forever.
useDashboardStore
.getState()
.setVariableValues('d1', { env: { value: '', allSelected: false } });
const dash = dashboard('d1', [
model({ name: 'env', type: 'TEXT', textValue: 'prod' }),
]);
renderHook(() => useSeedVariableSelection(dash));
expect(seededValue('d1', 'env')).toStrictEqual({
value: 'prod',
allSelected: false,
});
});
it('defaults an ALL-enabled multi-select to ALL over a stored empty array', () => {
useDashboardStore
.getState()
.setVariableValues('d1', { env: { value: [], allSelected: false } });
const dash = dashboard('d1', [
model({
name: 'env',
type: 'CUSTOM',
customValue: 'a,b',
multiSelect: true,
showAllOption: true,
}),
]);
renderHook(() => useSeedVariableSelection(dash));
// Custom options need no request, so ALL is materialized here rather than left as
// a flag for the post-fetch reconcile to expand.
expect(seededValue('d1', 'env')).toStrictEqual({
value: ['a', 'b'],
allSelected: true,
});
});
it('materializes ALL for a custom variable so nothing has to reconcile it later', () => {
const dash = dashboard('d1', [
model({
name: 'env',
type: 'CUSTOM',
customValue: 'a,b,c',
multiSelect: true,
showAllOption: true,
}),
]);
renderHook(() => useSeedVariableSelection(dash));
expect(seededValue('d1', 'env')).toStrictEqual({
value: ['a', 'b', 'c'],
allSelected: true,
});
});
it('drops values a custom variable no longer offers, at seed time', () => {
useDashboardStore.getState().setVariableValues('d1', {
env: { value: ['a', 'gone'], allSelected: false },
});
const dash = dashboard('d1', [
model({
name: 'env',
type: 'CUSTOM',
customValue: 'a,b',
multiSelect: true,
}),
]);
renderHook(() => useSeedVariableSelection(dash));
expect(seededValue('d1', 'env')).toStrictEqual({
value: ['a'],
allSelected: false,
});
});
it('keeps a stored ALL selection that has not materialized yet', () => {
useDashboardStore
.getState()
.setVariableValues('d1', { env: { value: null, allSelected: true } });
const dash = dashboard('d1', [
model({
name: 'env',
type: 'QUERY',
multiSelect: true,
showAllOption: true,
defaultValue: 'b',
}),
]);
renderHook(() => useSeedVariableSelection(dash));
expect(seededValue('d1', 'env')).toStrictEqual({
value: null,
allSelected: true,
});
});
it('does not rewrite the store when the effect re-runs with the same values', () => {
// A dashboard refetch or any spec edit hands back an equal-but-new variables array,
// re-running the seed. Writing an identical map would re-render every subscriber.
const variable = model({ name: 'env', type: 'TEXT', textValue: 'prod' });
const { rerender } = renderHook(
({ dash }) => useSeedVariableSelection(dash),
{ initialProps: { dash: dashboard('d1', [variable]) } },
);
const afterSeed = useDashboardStore.getState().variableValues;
rerender({ dash: dashboard('d1', [{ ...variable }]) });
expect(useDashboardStore.getState().variableValues).toBe(afterSeed);
});
it('initializes the fetch context with idle states for every variable', () => {
const dash = dashboard('d1', [
model({ name: 'env', type: 'TEXT' }),

View File

@@ -1,152 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import {
emptyVariableFormModel,
type VariableFormModel,
} from '../../DashboardSettings/Variables/variableFormModel';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useVariableSelection } from '../hooks/useVariableSelection';
jest.mock('nuqs', () => ({
parseAsJson: (): unknown => ({ withOptions: (): unknown => ({}) }),
useQueryState: (): unknown => [null, jest.fn()],
}));
jest.mock('react-redux', () => ({
useSelector: (selector: (state: unknown) => unknown): unknown =>
selector({
globalTime: { minTime: 1, maxTime: 2, selectedTime: '5m' },
}),
}));
jest.mock('../../DashboardSettings/Variables/variableAdapters', () => ({
dtoToFormModel: (dto: unknown): unknown => dto,
}));
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
return { ...emptyVariableFormModel(), ...overrides };
}
// env → svc, so a cascade off `env` is observable as a cycle bump on `svc`.
const env = model({
name: 'env',
type: 'QUERY',
multiSelect: true,
queryValue: 'SELECT env',
});
const svc = model({
name: 'svc',
type: 'QUERY',
queryValue: 'SELECT svc WHERE env = $env',
});
const dashboard = {
id: 'd1',
spec: { variables: [env, svc] },
} as unknown as DashboardtypesGettableDashboardV2DTO;
function svcCycleId(): number {
return useDashboardStore.getState().variableCycleIds.svc ?? 0;
}
describe('useVariableSelection — setSelection', () => {
beforeEach(() => {
useDashboardStore.setState({
variableValues: {},
variableFetchStates: {},
variableLastUpdated: {},
variableCycleIds: {},
variableResolvedEmpty: {},
variableFetchContext: null,
lastFetchAllKey: null,
});
});
it('does not re-cascade when the picked value is the one already held', () => {
const { result } = renderHook(() => useVariableSelection(dashboard));
act(() => {
result.current.setSelection('env', {
value: ['a', 'b'],
allSelected: false,
});
});
const afterFirstPick = svcCycleId();
// Same values, then the same set in a different order: neither is a change, so
// the dependent must not be re-fetched.
act(() => {
result.current.setSelection('env', {
value: ['a', 'b'],
allSelected: false,
});
});
act(() => {
result.current.setSelection('env', {
value: ['b', 'a'],
allSelected: false,
});
});
expect(svcCycleId()).toBe(afterFirstPick);
});
it('ignores an auto-fill that the store already satisfies', async () => {
const { result } = renderHook(() => useVariableSelection(dashboard));
act(() => {
result.current.setSelection('env', { value: ['a'], allSelected: false });
});
const before = svcCycleId();
// What a selector's first-render reconcile produces before the seed commits: a
// value the store then resolves to on its own.
await act(async () => {
result.current.autoSelect('env', { value: ['a'], allSelected: false });
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
});
expect(svcCycleId()).toBe(before);
});
it('still applies an auto-fill that changes the value', async () => {
const { result } = renderHook(() => useVariableSelection(dashboard));
act(() => {
result.current.setSelection('env', { value: ['a'], allSelected: false });
});
const before = svcCycleId();
await act(async () => {
result.current.autoSelect('env', { value: ['a', 'b'], allSelected: false });
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
});
expect(useDashboardStore.getState().variableValues.d1?.env).toStrictEqual({
value: ['a', 'b'],
allSelected: false,
});
expect(svcCycleId()).toBe(before + 1);
});
it('still cascades a genuine change', () => {
const { result } = renderHook(() => useVariableSelection(dashboard));
act(() => {
result.current.setSelection('env', { value: ['a'], allSelected: false });
});
const before = svcCycleId();
act(() => {
result.current.setSelection('env', {
value: ['a', 'c'],
allSelected: false,
});
});
expect(useDashboardStore.getState().variableValues.d1?.env).toStrictEqual({
value: ['a', 'c'],
allSelected: false,
});
expect(svcCycleId()).toBe(before + 1);
});
});

View File

@@ -9,7 +9,6 @@ import type {
VariableSelection,
VariableSelectionMap,
} from '../../selectionTypes';
import { textDefault } from '../../utils/resolveVariableSelection';
import { computeVariableDependencies } from '../../utils/variableDependencies';
import TextSelector from '../selectors/TextSelector';
import VariableValueControl from '../selectors/VariableValueControl';
@@ -66,7 +65,7 @@ function VariableSelector({
variable.type === 'TEXT' ? (
<TextSelector
selection={selection}
defaultValue={textDefault(variable)}
defaultValue={variable.textValue}
onChange={onChange}
testId={`variable-input-${variable.name}`}
/>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import type { InputRef } from 'antd';
// eslint-disable-next-line signoz/no-antd-components -- match V1 textbox behaviour (commit on blur/Enter, borderless)
import { Input } from 'antd';
@@ -31,17 +31,6 @@ function TextSelector({
typeof selection.value === 'string' ? selection.value : (defaultValue ?? ''),
);
// The committed value can land after this input mounts — the seed resolves the
// definition's default one tick later, and a share link or a reset can replace it
// at any point. Without following it the box would keep showing its first render
// (empty, since nothing is seeded yet) until the user focused and blurred it.
// Typing never touches the selection, so an edit in progress cannot be interrupted.
useEffect(() => {
setValue(
typeof selection.value === 'string' ? selection.value : (defaultValue ?? ''),
);
}, [selection.value, defaultValue]);
const commit = useCallback(
(next: string): void => {
void logEvent(

View File

@@ -54,26 +54,11 @@ function ValueSelector({
[selection, options],
);
// That "all" path needs the options, so an ALL selection whose options have not
// arrived yet has nothing to render and the control would read "Select value"
// while it spins — as if nothing were selected. Say ALL in that slot instead: the
// selection is known, only its options are pending. Display only, so it can never
// be committed as a value.
const isAllPendingOptions = selection.allSelected && options.length === 0;
// Buffer edits while the dropdown is open; the committed selection is shown
// when closed. This defers the dependent cascade to a single commit-on-close.
const [isOpen, setIsOpen] = useState(false);
const [draft, setDraft] = useState<string[]>(committedValues);
// ALL is every option, so there is nothing to clear — and the shared control refuses
// to empty an ALL selection anyway, which would leave the icon inert. Unchecking ALL
// in the list is the way out of it.
const draftIsAll =
showAllOption &&
options.length > 0 &&
options.every((option) => draft.includes(option));
const commit = (values: string[]): void => {
// CustomMultiSelect emits the full value set when ALL is picked.
const isAll =
@@ -109,11 +94,8 @@ function ValueSelector({
errorMessage={errorMessage}
onRetry={onRetry}
showSearch
// Clearing belongs to the open list: on the closed control the icon would
// appear on hover, in a row of variable pills, for an action whose result is
// not visible.
allowClear={isOpen && !draftIsAll}
placeholder={isAllPendingOptions ? 'ALL' : 'Select value'}
allowClear
placeholder="Select value"
maxTagCount={1}
maxTagTextLength={10}
maxTagPlaceholder={(omitted): JSX.Element => (
@@ -147,10 +129,12 @@ function ValueSelector({
void logEvent(DashboardDetailEvents.VariableMultiSelectCleared, {
variableType,
});
// Empties the list, committing nothing. Closing resolves an empty draft
// to whatever the variable should hold — its configured default, else ALL
// where it offers one, else the first option.
setDraft([]);
// A clear on the closed control falls back to the default immediately;
// while open it just empties the draft (committed on close).
if (!isOpen) {
onChange(emptyFallback);
}
}}
/>
);

View File

@@ -6,13 +6,7 @@ import { dtoToFormModel } from '../../DashboardSettings/Variables/variableAdapte
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import { selectVariableValues } from '../../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../../store/useDashboardStore';
import { knownVariableOptions } from '../utils/knownVariableOptions';
import {
areSelectionsEqual,
reconcileWithOptions,
resolveDefaultSelection,
} from '../utils/resolveVariableSelection';
import { hasUsableValue } from '../utils/selectionUtils';
import { resolveDefaultSelection } from '../utils/resolveVariableSelection';
import type {
SelectedVariableValue,
VariableSelection,
@@ -24,44 +18,6 @@ import {
} from '../utils/variableDependencies';
import { ALL_SELECTED, variablesUrlParser } from '../utils/variablesUrlState';
function isStoredSelectionSet(
stored: VariableSelection,
model: VariableFormModel,
): boolean {
return !!stored.allSelected || hasUsableValue(stored, model.type);
}
/** Whether the seeded map matches, entry for entry, what the store already holds. */
function isSameSelection(
seeded: VariableSelectionMap,
current: VariableSelectionMap,
): boolean {
const names = Object.keys(seeded);
return (
names.length === Object.keys(current).length &&
names.every(
(name) => !!current[name] && areSelectionsEqual(seeded[name], current[name]),
)
);
}
/**
* A seeded value taken as far as it can go: for a variable whose options need no
* request, resolve against them now — ALL becomes the concrete array, values the list
* no longer offers are dropped. Left alone otherwise; the post-fetch reconcile does it
* once the options arrive.
*/
function resolveAgainstKnownOptions(
value: VariableSelection,
model: VariableFormModel,
): VariableSelection {
const options = knownVariableOptions(model);
if (options.length === 0) {
return value;
}
return reconcileWithOptions(model, value, options) ?? value;
}
// The `__ALL__` sentinel only means "ALL" for variables that support it — a
// legitimate value of "__ALL__" (e.g. a text var) is taken literally.
function fromUrlValue(
@@ -114,31 +70,22 @@ export function useSeedVariableSelection(
variables.forEach((variable) => {
const urlValue = urlValues?.[variable.name];
const stored = selection[variable.name];
const seed = (value: VariableSelection): void => {
seeded[variable.name] = resolveAgainstKnownOptions(value, variable);
};
if (urlValue !== undefined) {
const fromUrl = fromUrlValue(urlValue, variable);
// When the URL carries only the ALL sentinel but the store already holds
// the materialized full-option array, reuse it — avoids the re-fetch +
// re-materialize round-trip (and its dependent-refetch cascade) on load.
seed(
seeded[variable.name] =
fromUrl.allSelected && stored?.allSelected && Array.isArray(stored.value)
? stored
: fromUrl,
);
} else if (stored && isStoredSelectionSet(stored, variable)) {
seed(stored);
: fromUrl;
} else if (stored) {
seeded[variable.name] = stored;
} else {
seed(resolveDefaultSelection(variable));
seeded[variable.name] = resolveDefaultSelection(variable);
}
});
// This runs again whenever the spec's variable array changes identity — a refetch
// or any spec edit — and writing an identical map would re-render every selection
// subscriber for nothing.
if (!isSameSelection(seeded, selection)) {
setVariableValues(dashboardId, seeded);
}
setVariableValues(dashboardId, seeded);
// Read-once: a share link's `?variables=` seeds the store, then the param is
// dropped so the store is the sole source of truth. Selection changes never

View File

@@ -1,11 +1,14 @@
import { useEffect, useMemo } from 'react';
import logEvent from 'api/common/logEvent';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
import { VARIABLE_TYPE_EVENT_LABEL } from '../../DashboardSettings/Variables/variableFormModel';
import {
sortValuesByOrder,
VARIABLE_TYPE_EVENT_LABEL,
} from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableSelectionMap } from '../selectionTypes';
import { knownVariableOptions } from '../utils/knownVariableOptions';
import {
useFetchedVariableOptions,
type VariableOptions,
@@ -27,12 +30,14 @@ export function useVariableOptions(
): VariableOptions {
const fetched = useFetchedVariableOptions(variable, variables, selections);
// Keyed on the fields the parse actually reads, not the model identity: a dashboard
// refetch hands back an equal-but-new model, and a new options array would re-fire
// the post-fetch reconcile for nothing.
const customOptions = useMemo(
() => knownVariableOptions(variable),
// eslint-disable-next-line react-hooks/exhaustive-deps
() =>
variable.type === 'CUSTOM'
? sortValuesByOrder(
commaValuesParser(variable.customValue),
variable.sort,
).map(String)
: ([] as string[]),
[variable.type, variable.customValue, variable.sort],
);

View File

@@ -12,7 +12,6 @@ import type {
VariableSelection,
VariableSelectionMap,
} from '../selectionTypes';
import { areSelectionsEqual } from '../utils/resolveVariableSelection';
import { useSeedVariableSelection } from './useSeedVariableSelection';
/**
@@ -104,10 +103,6 @@ export function useVariableSelection(
const setSelection = useCallback(
(name: string, next: VariableSelection): void => {
const current = selectionRef.current[name];
if (current && areSelectionsEqual(next, current)) {
return;
}
setVariableValue(dashboardId, name, next);
enqueueDescendants(name);
},
@@ -129,19 +124,8 @@ export function useVariableSelection(
if (names.length === 0 || !dashboardId) {
return;
}
// A fill can arrive already satisfied: a selector reconciles against the options
// on its first render, before the seed has committed, and the seed then resolves
// to the same value. Refresh only what actually moved, so a settled load ends
// without a write or a dependent refetch.
const current = selectionRef.current;
const changed = names.filter(
(name) => !current[name] || !areSelectionsEqual(fills[name], current[name]),
);
if (changed.length === 0) {
return;
}
setVariableValues(dashboardId, { ...current, ...fills });
enqueueDescendantsBatch(changed);
setVariableValues(dashboardId, { ...selectionRef.current, ...fills });
enqueueDescendantsBatch(names);
}, [dashboardId, setVariableValues, enqueueDescendantsBatch]);
const autoSelect = useCallback(

View File

@@ -1,23 +0,0 @@
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
/**
* The options of a variable whose list needs no request — a CUSTOM variable's comma
* list. Empty for QUERY and DYNAMIC, whose options only exist once fetched, and for
* TEXT, which has none.
*
* Knowing them up front lets the seed resolve such a variable's value completely
* (materializing ALL, dropping values the list no longer offers) instead of leaving
* that to the post-fetch reconcile, which would cost a second store write and a
* refetch of everything downstream.
*/
export function knownVariableOptions(model: VariableFormModel): string[] {
if (model.type !== 'CUSTOM') {
return [];
}
return sortValuesByOrder(commaValuesParser(model.customValue), model.sort).map(
String,
);
}

View File

@@ -37,7 +37,7 @@ function firstConfiguredDefault(model: VariableFormModel): string | undefined {
}
/** A TEXT variable's default: its configured default, else its textValue (always a string). */
export function textDefault(model: VariableFormModel): string {
function textDefault(model: VariableFormModel): string {
return firstConfiguredDefault(model) ?? model.textValue;
}
@@ -53,31 +53,15 @@ function isAllDefault(
);
}
/**
* The default selection for a variable with nothing usable selected: its configured
* default, else ALL for an ALL-enabled multi-select, else the first option.
*/
/** The configured default (or first option) as a fresh selection. */
function fillDefault(
model: VariableFormModel,
options: string[],
): VariableSelection {
const fallback = firstConfiguredDefault(model);
if (fallback && options.includes(fallback)) {
return {
value: model.multiSelect ? [fallback] : fallback,
allSelected: false,
};
}
// No usable configured default: an ALL-enabled multi-select defaults to ALL, not
// to an arbitrary first option — the same default the seed applies (keep in sync
// with resolveDefaultSelection).
if (model.multiSelect && model.showAllOption) {
return materializeAll(model, options, null) ?? ALL_SELECTION;
}
const initial = fallback && options.includes(fallback) ? fallback : options[0];
return {
value: model.multiSelect ? [options[0]] : options[0],
value: model.multiSelect ? [initial] : initial,
allSelected: false,
};
}

View File

@@ -1,7 +1,5 @@
import { renderHook } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
import { EQueryType } from 'types/common/dashboard';
import { useOpenPanelEditor } from '../useOpenPanelEditor';
@@ -83,37 +81,6 @@ describe('useOpenPanelEditor', () => {
);
});
it("carries the panel's saved query into the editor URL", () => {
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
const panel = {
kind: 'Panel',
spec: {
display: { name: 'Panel A' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [
{
kind: 'time_series',
spec: {
plugin: {
kind: 'signoz/PromQLQuery',
spec: { name: 'A', query: 'up{job="alpha"}', disabled: false },
},
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
const { result } = renderHook(() => useOpenPanelEditor());
result.current('panel-9', { panel });
const [url] = mockSafeNavigate.mock.calls[0];
const carried = new URLSearchParams(url.split('?')[1]).get('compositeQuery');
const parsed = JSON.parse(decodeURIComponent(carried as string));
expect(parsed.queryType).toBe(EQueryType.PROM);
expect(parsed.promql[0].query).toBe('up{job="alpha"}');
});
it('merges search with the time window (leading ? tolerated)', () => {
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
const { result } = renderHook(() => useOpenPanelEditor());

View File

@@ -1,13 +1,9 @@
import { useCallback } from 'react';
import { generatePath } from 'react-router-dom';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import type { PanelEditorHandoffState } from '../PanelEditor/panelEditorHandoff';
import { getPanelBuilderQuery } from '../Panels/utils/getPanelBuilderQuery';
import { useDashboardStore } from '../store/useDashboardStore';
import { useTimeSearchParams } from './useTimeSearchParams';
import logEvent from '@/api/common/logEvent';
@@ -17,8 +13,6 @@ interface OpenPanelEditorOptions {
handoffState?: PanelEditorHandoffState;
/** Extra query merged into the editor URL (leading `?` optional). */
search?: string;
/** The panel being edited — its query rides in the URL as `compositeQuery`. */
panel?: DashboardtypesPanelDTO;
}
/** Opens the V2 panel editor, carrying the active time window in the URL. */
@@ -29,7 +23,6 @@ export function useOpenPanelEditor(): (
const { safeNavigate } = useSafeNavigate();
const timeSearch = useTimeSearchParams();
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { resetQuery } = useQueryBuilder();
return useCallback(
(panelId: string, options?: OpenPanelEditorOptions): void => {
@@ -46,24 +39,12 @@ export function useOpenPanelEditor(): (
new URLSearchParams(timeSearch).forEach((value, key) => {
params.set(key, value);
});
if (options?.panel) {
const query = getPanelBuilderQuery(options.panel);
// Single-encoded: `useGetCompositeQueryParam` decodes once on top of the decode
// `URLSearchParams` already does.
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
// The provider applies the URL in an effect, a tick after the builder's fields
// have mounted and read the query they keep (PromQL inputs, add-on rows).
resetQuery(query);
}
const search = params.toString();
safeNavigate(
search ? `${path}?${search}` : path,
options?.handoffState ? { state: options.handoffState } : undefined,
);
},
[safeNavigate, dashboardId, timeSearch, resetQuery],
[safeNavigate, dashboardId, timeSearch],
);
}

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

@@ -70,24 +70,24 @@ require (
github.com/x448/float16 v0.8.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.opentelemetry.io/otel v1.42.0 // indirect
go.opentelemetry.io/otel/metric v1.42.0 // indirect
go.opentelemetry.io/otel/trace v1.42.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/goleak v1.3.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/term v0.42.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.41.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/api v0.272.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/grpc v1.82.1 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

View File

@@ -74,8 +74,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
@@ -355,16 +355,16 @@ go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.67.0/go.mod h1:gO9smoZe9KnZcJCqcB0lMmQ4Z5VEifYmjMTpnwtTSuQ=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -377,40 +377,40 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA=
google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA=
google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d h1:vsOm753cOAMkt76efriTCDKjpCbK18XGHMJHo0JUKhc=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -0,0 +1,383 @@
import type { Browser } from '@playwright/test';
import {
createEmailChannelViaApi,
createLogsAlertViaApi,
createMetricAlertViaApi,
createNoDataAlertViaApi,
deleteAlertViaApi,
deleteChannelViaApi,
readTimelineTotal,
seedAlertHistoryLogs,
seedAlertHistoryMetrics,
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'];
/** 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 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;
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-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,201 @@
import type { Browser, Page } from '@playwright/test';
import {
type AlertSchema,
createEmailChannelViaApi,
createLogsAlertViaApi,
createThresholdAlertViaApi,
deleteAlertViaApi,
deleteChannelViaApi,
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.
*/
logs(options: {
name: string;
schema?: AlertSchema;
marker?: string;
}): 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 }) =>
seed((page) =>
createLogsAlertViaApi(page, {
name,
marker: marker ?? `e2e alert never seeded ${name}`,
channels: [alertChannel.name],
schema,
}),
),
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

@@ -1,11 +1,34 @@
import { expect, type Page } from '@playwright/test';
import {
expect,
type Locator,
type Page,
type Request,
} from '@playwright/test';
import { authToken } from './common';
import { authToken, requestUrl, seederUrl } from './common';
import { typeExpression } from './query-builder';
// ─── Constants ───────────────────────────────────────────────────────────
export const ALERTS_LIST_PATH = '/alerts';
export const ALERT_OVERVIEW_PATH = '/alerts/overview';
export const ALERT_HISTORY_PATH = '/alerts/history';
/**
* Mirrors `TIMELINE_TABLE_PAGE_SIZE` in
* `frontend/src/container/AlertHistory/constants.ts`. This 20 is what makes the
* page-2 cursor `base64url({"offset":20,"limit":20})`, so the two must not drift.
*/
export const TIMELINE_PAGE_SIZE = 20;
/** The `relativeTime` the history page falls back to (`DEFAULT_TIME_RANGE`). */
export const DEFAULT_RELATIVE_TIME = '30m';
/**
* Page size the list specs pin in the URL, so the number of rendered rows never
* depends on the viewport height.
*/
export const ALERT_LIST_PAGE_SIZE = 10;
// ─── Types ─────────────────────────────────────────────────────────────────
@@ -19,6 +42,11 @@ export interface ThresholdAlertSeed {
* required by the API — seed one with {@link createEmailChannelViaApi}.
*/
channels: string[];
/**
* Rule labels. `severity` drives the list's Severity column and is one of
* the things its search box matches on, so list specs set it explicitly.
*/
labels?: Record<string, string>;
}
// ─── Payload ─────────────────────────────────────────────────────────────
@@ -30,6 +58,7 @@ function buildThresholdRulePayload({
name,
target,
channels,
labels,
}: ThresholdAlertSeed): Record<string, unknown> {
return {
alert: name,
@@ -39,6 +68,7 @@ function buildThresholdRulePayload({
version: 'v5',
disabled: false,
source: '',
...(labels ? { labels } : {}),
annotations: {
description:
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
@@ -186,3 +216,926 @@ export async function gotoAlertOverview(
// eslint-disable-next-line playwright/no-wait-for-timeout -- no DOM signal for the async settle
await page.waitForTimeout(500);
}
/**
* Open the alert details shell (Overview tab) for `ruleId` and wait until it has
* mounted. Unlike {@link gotoAlertOverview} this does **not** wait for the
* condition editor or the serialised query — use it for scenarios about the
* shell itself (header, tabs, actions menu) rather than the rule's contents.
*/
export async function gotoAlertDetails(
page: Page,
ruleId: string,
): Promise<void> {
await page.goto(
`${ALERT_OVERVIEW_PATH}?ruleId=${ruleId}&relativeTime=${DEFAULT_RELATIVE_TIME}`,
);
await expect(page.getByTestId('alert-details-root')).toBeVisible();
}
/** Rows currently rendered in the alert-rules table body. */
export function alertRuleRows(page: Page): Locator {
return page.locator('tbody tr');
}
/**
* Open the alert-rules list and wait until it has rows. `params` is merged into
* the query string (`search`, `page`, `orderBy`, …); `limit` defaults to
* {@link ALERT_LIST_PAGE_SIZE} so row counts are viewport-independent.
*
* Pass `expectRows: false` for scenarios whose filters are *meant* to match
* nothing — the row wait would otherwise fail before the assertion runs.
*/
export async function gotoAlertList(
page: Page,
params: Record<string, string> = {},
{ expectRows = true }: { expectRows?: boolean } = {},
): Promise<void> {
const query = new URLSearchParams({
limit: String(ALERT_LIST_PAGE_SIZE),
...params,
});
await page.goto(`${ALERTS_LIST_PATH}?${query.toString()}`);
await expect(page.getByTestId('list-alerts-search-input')).toBeVisible();
if (expectRows) {
await expect(alertRuleRows(page).first()).toBeVisible();
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Alert history fixtures
//
// There is no seeder endpoint that writes `rule_state_history_v0`, so every
// history row here comes from the ruler actually evaluating a rule: seed
// telemetry, create a rule whose query matches it, then poll the timeline until
// the firing wave lands. See `tests/e2e/specs/alerts/alerts-e2e-coverage.md` §3.
// ═══════════════════════════════════════════════════════════════════════════
// ─── Types ─────────────────────────────────────────────────────────────────
/** Rule schema flavour. `v1` is the legacy payload posted to `/api/v1/rules`. */
export type AlertSchema = 'v1' | 'v2';
export interface LogsAlertSeed {
name: string;
/** Substring the rule matches on (`body CONTAINS '<marker>'`). */
marker: string;
/** Channel *names* (not ids) — the API validates the reference. */
channels: string[];
schema?: AlertSchema;
/** Go duration, e.g. `5m0s`. Shrink it to make the rule resolve fast. */
evalWindow?: string;
frequency?: string;
/** Becomes the history `threshold.name` for v1 rules (`processRuleDefaults`). */
severity?: string;
/**
* Extra rule labels merged alongside `severity`. They show up in the details
* header's labels row (which renders `labels` minus `severity`) *and* as extra
* history `filter_keys`, so add them only where a scenario needs them.
*/
extraLabels?: Record<string, string>;
/** `condition.alertOnAbsent` — the only route to a `nodata` row. */
alertOnAbsent?: boolean;
/** `condition.absentFor`, in minutes. */
absentFor?: number;
}
export interface MetricAlertSeed {
name: string;
metricName: string;
channels: string[];
/** Attribute the history rows group by. Defaults to `host`. */
groupByKey?: string;
evalWindow?: string;
frequency?: string;
}
export interface LogsSeedOptions {
marker: string;
/** Number of distinct `service.name` values ⇒ number of timeline rows. */
services: number;
recordsPerService?: number;
/** Oldest record age in seconds; records spread from here up to `minAgeSeconds`. */
ageSeconds?: number;
minAgeSeconds?: number;
/** Prefix for the generated `service.name` values. */
servicePrefix?: string;
}
export interface MetricsSeedOptions {
metricName: string;
/** Distinct attribute values ⇒ number of timeline rows. */
hosts: string[];
pointsPerHost?: number;
groupByKey?: string;
}
/** One row of `GET /api/v2/rules/{id}/history/timeline`. */
export interface TimelineItem {
state: string;
unixMilli: number;
fingerprint: string;
value: number;
labels: {
key?: { name?: string };
value?: string | number | boolean | null;
}[];
relatedLogsLink?: string;
relatedTracesLink?: string;
}
export interface TimelineResponse {
items: TimelineItem[];
total: number;
nextCursor?: string;
}
// ─── Payload builders ────────────────────────────────────────────────────
const ANNOTATIONS = {
description:
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
summary:
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
};
// The v5 `queries[]` envelope is identical for both schema versions
// (`AlertCompositeQuery` in pkg/types/ruletypes/alerting.go) — only the
// threshold / evaluation / channel envelopes differ. That keeps one builder
// per signal and a thin branch over the wrapper.
function logsCompositeQuery(marker: string): Record<string, unknown> {
return {
panelType: 'graph',
queryType: 'builder',
queries: [
{
type: 'builder_query',
spec: {
name: 'A',
signal: 'logs',
source: '',
disabled: false,
filter: { expression: `body CONTAINS '${marker}'` },
groupBy: [
{
name: 'service.name',
fieldContext: 'resource',
fieldDataType: 'string',
},
],
aggregations: [{ expression: 'count()' }],
having: { expression: '' },
legend: '',
},
},
],
};
}
function metricsCompositeQuery(
metricName: string,
groupByKey: string,
): Record<string, unknown> {
return {
panelType: 'graph',
queryType: 'builder',
queries: [
{
type: 'builder_query',
spec: {
name: 'A',
signal: 'metrics',
source: '',
disabled: false,
filter: { expression: '' },
groupBy: [
{ name: groupByKey, fieldContext: 'attribute', fieldDataType: 'string' },
],
aggregations: [
{
metricName,
temporality: '',
timeAggregation: 'avg',
spaceAggregation: 'max',
},
],
having: { expression: '' },
legend: '',
},
},
],
};
}
// `target 0 / op above / matchType at_least_once` fires on the first evaluation
// that sees any matching record, which is what keeps the ruler wait to ~20-35s.
function v2RulePayload({
name,
alertType,
compositeQuery,
channels,
severity,
extraLabels,
evalWindow,
frequency,
extraCondition,
}: {
name: string;
alertType: string;
compositeQuery: Record<string, unknown>;
channels: string[];
severity: string;
extraLabels?: Record<string, string>;
evalWindow: string;
frequency: string;
extraCondition?: Record<string, unknown>;
}): Record<string, unknown> {
return {
alert: name,
alertType,
ruleType: 'threshold_rule',
schemaVersion: 'v2alpha1',
version: 'v5',
disabled: false,
source: '',
labels: { severity, ...extraLabels },
annotations: ANNOTATIONS,
evaluation: { kind: 'rolling', spec: { evalWindow, frequency } },
notificationSettings: {
groupBy: [],
renotify: { enabled: false, interval: '30m', alertStates: [] },
usePolicy: false,
},
condition: {
selectedQueryName: 'A',
compositeQuery,
thresholds: {
kind: 'basic',
spec: [
{
name: severity,
target: 0,
targetUnit: '',
recoveryTarget: null,
matchType: 'at_least_once',
op: 'above',
channels,
},
],
},
...extraCondition,
},
};
}
// Legacy schema: `evalWindow`/`frequency` sit at the top level, channels are
// `preferredChannels`, and `condition.{op,target,matchType}` are the numeric
// enum forms the v1 validator requires. `labels.severity` becomes the history
// `threshold.name`.
function v1RulePayload({
name,
alertType,
compositeQuery,
channels,
severity,
extraLabels,
evalWindow,
frequency,
extraCondition,
}: {
name: string;
alertType: string;
compositeQuery: Record<string, unknown>;
channels: string[];
severity: string;
extraLabels?: Record<string, string>;
evalWindow: string;
frequency: string;
extraCondition?: Record<string, unknown>;
}): Record<string, unknown> {
return {
alert: name,
alertType,
ruleType: 'threshold_rule',
disabled: false,
source: '',
evalWindow,
frequency,
preferredChannels: channels,
labels: { severity, ...extraLabels },
annotations: ANNOTATIONS,
condition: {
selectedQueryName: 'A',
op: '1',
target: 0,
matchType: '1',
compositeQuery,
...extraCondition,
},
};
}
// ─── Seeding telemetry ───────────────────────────────────────────────────
async function postToSeeder(
page: Page,
path: string,
data: unknown,
): Promise<void> {
const url = `${seederUrl()}${path}`;
// The seeder shares one ClickHouse client, so concurrent POSTs from parallel
// workers collide with a transient 500 "concurrent queries within the same
// session". Retry those; anything else is real.
const maxAttempts = 6;
let lastStatus = 0;
let lastText = '';
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
// eslint-disable-next-line no-await-in-loop
const res = await page.request.post(url, {
data,
headers: { 'Content-Type': 'application/json' },
});
if (res.ok()) {
return;
}
lastStatus = res.status();
// eslint-disable-next-line no-await-in-loop
lastText = await res.text();
if (!(lastStatus === 500 && lastText.includes('concurrent'))) {
break;
}
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => {
setTimeout(resolve, 150 * (attempt + 1) + Math.floor(Math.random() * 100));
});
}
throw new Error(`seeder POST ${path} ${lastStatus}: ${lastText}`);
}
/**
* Seed log records the history rules match on. Returns the generated
* `service.name` values, which become the timeline rows' `groupBy` labels
* (N distinct services ⇒ N distinct fingerprints ⇒ N timeline rows).
*
* Seed these **immediately** before creating the rule: the rule only fires
* while the records are still inside its eval window, and a stale marker
* silently never fires.
*/
export async function seedAlertHistoryLogs(
page: Page,
{
marker,
services,
recordsPerService = 2,
ageSeconds = 150,
minAgeSeconds = 30,
servicePrefix = 'e2e-ah-svc',
}: LogsSeedOptions,
): Promise<string[]> {
const now = Date.now();
const span = Math.max(ageSeconds - minAgeSeconds, 1);
const serviceNames: string[] = [];
const records: Record<string, unknown>[] = [];
for (let i = 0; i < services; i += 1) {
const service = `${servicePrefix}-${i}`;
serviceNames.push(service);
for (let r = 0; r < recordsPerService; r += 1) {
const fraction =
(i * recordsPerService + r) / (services * recordsPerService);
const offset = ageSeconds - Math.floor(fraction * span);
records.push({
timestamp: new Date(now - offset * 1000).toISOString(),
body: marker,
resources: { 'service.name': service },
});
}
}
await postToSeeder(page, '/telemetry/logs', records);
return serviceNames;
}
/**
* Seed a throwaway gauge the metrics rule alerts on. Cheaper than the logs
* fixture (~10s to fire) and its history rows carry neither `relatedLogsLink`
* nor `relatedTracesLink` — the "no links available" case.
*/
export async function seedAlertHistoryMetrics(
page: Page,
{
metricName,
hosts,
pointsPerHost = 3,
groupByKey = 'host',
}: MetricsSeedOptions,
): Promise<void> {
const now = Date.now();
const points: Record<string, unknown>[] = [];
for (const host of hosts) {
for (let p = 0; p < pointsPerHost; p += 1) {
points.push({
metric_name: metricName,
labels: { [groupByKey]: host },
timestamp: new Date(now - (pointsPerHost - p) * 20 * 1000).toISOString(),
value: 10 + p,
type_: 'Gauge',
temporality: 'Unspecified',
is_monotonic: false,
});
}
}
await postToSeeder(page, '/telemetry/metrics', points);
}
// ─── Rule creation ───────────────────────────────────────────────────────
async function postRule(
page: Page,
schema: AlertSchema,
payload: Record<string, unknown>,
): Promise<string> {
const token = await authToken(page);
const path = schema === 'v1' ? '/api/v1/rules' : '/api/v2/rules';
const res = await page.request.post(path, {
data: payload,
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
throw new Error(`POST ${path} ${res.status()}: ${await res.text()}`);
}
const json = (await res.json()) as { data: { id: string } };
return String(json.data.id);
}
/**
* Create a logs threshold rule grouped by `service.name`. `schema: 'v1'` posts
* the legacy payload to `/api/v1/rules`, which the UI then renders through the
* v1 branch of `AlertHeader` / `ActionButtons` — both schemas serve the *same*
* history APIs, so history scenarios can be parameterised over them.
*/
export async function createLogsAlertViaApi(
page: Page,
{
name,
marker,
channels,
schema = 'v2',
evalWindow = '5m0s',
frequency = '15s',
severity = schema === 'v1' ? 'warning' : 'critical',
extraLabels,
alertOnAbsent,
absentFor,
}: LogsAlertSeed,
): Promise<string> {
const extraCondition =
alertOnAbsent === undefined
? undefined
: { alertOnAbsent, absentFor: absentFor ?? 1 };
const args = {
name,
alertType: 'LOGS_BASED_ALERT',
compositeQuery: logsCompositeQuery(marker),
channels,
severity,
extraLabels,
evalWindow,
frequency,
extraCondition,
};
return postRule(
page,
schema,
schema === 'v1' ? v1RulePayload(args) : v2RulePayload(args),
);
}
/** SEED-E's rule: metrics-based, so its history rows carry no related links. */
export async function createMetricAlertViaApi(
page: Page,
{
name,
metricName,
channels,
groupByKey = 'host',
evalWindow = '5m0s',
frequency = '15s',
}: MetricAlertSeed,
): Promise<string> {
return postRule(
page,
'v2',
v2RulePayload({
name,
alertType: 'METRIC_BASED_ALERT',
compositeQuery: metricsCompositeQuery(metricName, groupByKey),
channels,
severity: 'critical',
evalWindow,
frequency,
}),
);
}
/**
* SEED-G's rule: a logs rule whose filter matches nothing, with
* `alertOnAbsent` set — the only cheap way to get a `nodata` history row.
* Seed no telemetry for its marker.
*/
export async function createNoDataAlertViaApi(
page: Page,
{
name,
marker,
channels,
}: { name: string; marker: string; channels: string[] },
): Promise<string> {
return createLogsAlertViaApi(page, {
name,
marker,
channels,
evalWindow: '5m0s',
frequency: '15s',
alertOnAbsent: true,
absentFor: 1,
});
}
/**
* Freeze a rule's history. Rows are written on *state change* only, so the
* firing wave lands once — but once the eval window rolls past the seeded
* records the rule resolves and writes a second row per fingerprint, doubling
* `total` mid-suite. Disable the rule as soon as the firing wave is confirmed.
*/
export async function setRuleDisabledViaApi(
page: Page,
id: string,
disabled: boolean,
): Promise<void> {
const token = await authToken(page);
const res = await page.request.patch(`/api/v2/rules/${id}`, {
data: { disabled },
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
throw new Error(
`PATCH /api/v2/rules/${id} ${res.status()}: ${await res.text()}`,
);
}
}
/** Severities SEED-B cycles through, so list search/sort has more than one value. */
export const SEED_B_SEVERITIES = ['critical', 'warning', 'info'] as const;
export interface AlertRulesSeedOptions {
count: number;
channelName: string;
/** Rules are named `<namePrefix>-NN`. Keep it unique per batch. */
namePrefix?: string;
/**
* Appended to both `team` label values. Every list spec seeds its own batch
* and they run in parallel, so a bare `team: payments` would also match the
* neighbouring batches — which is exactly what the label-search scenario
* counts. Leave it empty only when nothing asserts an exact label count.
*/
teamSuffix?: string;
}
/**
* SEED-B: `count` metric threshold rules sharing one channel. Severities cycle
* through {@link SEED_B_SEVERITIES} and every rule carries a `team` label, so
* the list's "Alert Name, Severity and Labels" search has hits *and* misses for
* all three. Even-indexed rules are `platform`, odd ones `payments` — i.e. half
* the batch each. Returns the ids in creation order.
*/
export async function seedAlertRules(
page: Page,
{
count,
channelName,
namePrefix = 'e2e-alert-list',
teamSuffix = '',
}: AlertRulesSeedOptions,
): Promise<string[]> {
const ids: string[] = [];
for (let i = 0; i < count; i += 1) {
// Sequential on purpose: the rules API is not the thing under test and
// parallel POSTs make failures harder to attribute.
// eslint-disable-next-line no-await-in-loop
const id = await createThresholdAlertViaApi(page, {
name: `${namePrefix}-${String(i).padStart(2, '0')}`,
target: 100 + i,
channels: [channelName],
labels: {
severity: SEED_B_SEVERITIES[i % SEED_B_SEVERITIES.length],
team: `${i % 2 === 0 ? 'platform' : 'payments'}${teamSuffix}`,
},
});
ids.push(id);
}
return ids;
}
// ─── History API probes ──────────────────────────────────────────────────
/**
* Read the timeline straight from the API. Used to gate on the ruler having
* produced rows *before* a spec opens the UI — polling through the browser
* would conflate "no rows yet" with "the table failed to render".
*/
export async function fetchTimeline(
page: Page,
ruleId: string,
params: Record<string, string | number> = {},
): Promise<TimelineResponse> {
const token = await authToken(page);
const now = Date.now();
const query = new URLSearchParams({
start: String(now - 30 * 60 * 1000),
end: String(now),
limit: '100',
order: 'asc',
...Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)])),
});
const res = await page.request.get(
`/api/v2/rules/${ruleId}/history/timeline?${query.toString()}`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!res.ok()) {
throw new Error(
`GET /api/v2/rules/${ruleId}/history/timeline ${res.status()}: ${await res.text()}`,
);
}
const json = (await res.json()) as { data: TimelineResponse | null };
return {
items: json.data?.items ?? [],
total: json.data?.total ?? 0,
nextCursor: json.data?.nextCursor,
};
}
function countStates(items: TimelineItem[]): Record<string, number> {
return items.reduce<Record<string, number>>((acc, item) => {
acc[item.state] = (acc[item.state] ?? 0) + 1;
return acc;
}, {});
}
/**
* Poll until at least `min` rows in state `state` exist. Takes ~20-35s for the
* logs fixture (the rule fires on the first evaluation that sees the data) and
* ~10s for the metrics one, so budget generously — a timeout here means the
* marker aged out of the eval window, not that the assertion is wrong.
*/
export async function waitForTimelineEntries(
page: Page,
ruleId: string,
{
min,
state = 'firing',
timeoutMs = 90_000,
}: { min: number; state?: string; timeoutMs?: number },
): Promise<TimelineResponse> {
const deadline = Date.now() + timeoutMs;
let last: TimelineResponse = { items: [], total: 0 };
while (Date.now() < deadline) {
// eslint-disable-next-line no-await-in-loop
last = await fetchTimeline(page, ruleId);
if (last.items.filter((item) => item.state === state).length >= min) {
return last;
}
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => {
setTimeout(resolve, 2_000);
});
}
throw new Error(
`timeline for rule ${ruleId} never reached ${min} '${state}' rows within ${timeoutMs}ms ` +
`(last: total=${last.total}, states=${JSON.stringify(countStates(last.items))})`,
);
}
/**
* Poll until every requested state has at least the requested row count.
* SEED-F's firing→resolved wave and SEED-G's `nodata` row both gate on this.
*/
export async function waitForTimelineStates(
page: Page,
ruleId: string,
{
states,
timeoutMs = 180_000,
}: { states: Record<string, number>; timeoutMs?: number },
): Promise<TimelineResponse> {
const deadline = Date.now() + timeoutMs;
let last: TimelineResponse = { items: [], total: 0 };
while (Date.now() < deadline) {
// eslint-disable-next-line no-await-in-loop
last = await fetchTimeline(page, ruleId);
const seen = countStates(last.items);
if (
Object.entries(states).every(([state, min]) => (seen[state] ?? 0) >= min)
) {
return last;
}
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => {
setTimeout(resolve, 3_000);
});
}
throw new Error(
`timeline for rule ${ruleId} never reached ${JSON.stringify(states)} within ${timeoutMs}ms ` +
`(last states: ${JSON.stringify(countStates(last.items))})`,
);
}
/** The filtered row count the timeline reports. Ignores `limit`. */
export async function readTimelineTotal(
page: Page,
ruleId: string,
): Promise<number> {
return (await fetchTimeline(page, ruleId, { limit: 1 })).total;
}
/**
* Mirror of `encodeCursor` in
* `container/AlertHistory/Timeline/Table/useTimelineTableCursor.ts`, so specs
* can assert the *exact* cursor the UI sends. Verified byte-identical to the
* server's `nextCursor`.
*/
export function encodeTimelineCursor(
page_: number,
limit = TIMELINE_PAGE_SIZE,
): string | undefined {
if (page_ <= 1) {
return undefined;
}
const offset = (page_ - 1) * limit;
return Buffer.from(JSON.stringify({ offset, limit }))
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
/** Labels on a timeline row, flattened to a plain object. */
export function timelineLabelsToObject(
item: TimelineItem,
): Record<string, string> {
return (item.labels ?? []).reduce<Record<string, string>>((acc, label) => {
const name = label.key?.name;
if (name) {
acc[name] = String(label.value ?? '');
}
return acc;
}, {});
}
// ─── Navigation ────────────────────────────────────────────────────────────
/**
* Open the history tab for `ruleId` and wait until the timeline table has
* mounted. `params` is merged into the query string, so scenarios can deep-link
* `page`, `order`, `timelineFilter`, … in one call.
*/
export async function gotoAlertHistory(
page: Page,
ruleId: string,
params: Record<string, string> = {},
): Promise<void> {
// An absolute window and `relativeTime` are mutually exclusive in practice:
// with both present the time picker normalises back to the relative range and
// **drops** `startTime`/`endTime` from the URL, so the absolute window never
// takes effect. Only send the default relative range when no absolute one was
// asked for.
const hasAbsoluteRange = !!params.startTime && !!params.endTime;
const query = new URLSearchParams({
ruleId,
...(hasAbsoluteRange ? {} : { relativeTime: DEFAULT_RELATIVE_TIME }),
...params,
});
await page.goto(`${ALERT_HISTORY_PATH}?${query.toString()}`);
// Race the table against the app's error boundary. The history page has been
// observed crashing into it intermittently on load; without this the failure
// reads as a 15s "timeline-table not found", which says nothing about why.
const table = page.getByTestId('timeline-table');
const crashed = page.getByText('Something went wrong :/');
await expect(table.or(crashed)).toBeVisible();
if (await crashed.isVisible()) {
throw new Error(
`alert history crashed into the app error boundary at ${page.url()}` +
'a component threw during render; check the captured console output',
);
}
await expect(table).toBeVisible();
}
// ─── Locators ──────────────────────────────────────────────────────────────
/**
* Assert the table is back on page 1. Both the list and the timeline use nuqs
* with `parseAsInteger.withDefault(1)`, which **removes** the `page` param when
* it is reset rather than writing `page=1` — so "absent" and "1" are the same
* state and a naive `?page=1` regex never matches.
*/
export async function expectFirstPage(page: Page): Promise<void> {
await expect
.poll(() => new URL(page.url()).searchParams.get('page') ?? '1')
.toBe('1');
}
export function timelineRows(page: Page): Locator {
return page.getByTestId('timeline-row');
}
export function timelineFooterRange(page: Page): Locator {
return page.getByTestId('timeline-footer-range');
}
export function statsCard(page: Page, title: string): Locator {
return page.locator(`[data-testid="stats-card"][data-stats-title="${title}"]`);
}
/** Open the ACTIONS popover on timeline row `index` (0-based). */
export async function openTimelineRowActions(
page: Page,
index: number,
): Promise<void> {
await timelineRows(page)
.nth(index)
.getByTestId('timeline-row-actions')
.click();
}
// ─── History request matchers ──────────────────────────────────────────────
/** The four v2 endpoints one history page load hits. */
export const HISTORY_ENDPOINTS = [
'stats',
'timeline',
'top_contributors',
'overall_status',
] as const;
export type HistoryEndpoint = (typeof HISTORY_ENDPOINTS)[number];
/** Match a request against one history endpoint, whatever the rule id. */
export function isHistoryRequest(
request: Request,
endpoint: HistoryEndpoint,
): boolean {
return new RegExp(`/api/v2/rules/[^/]+/history/${endpoint}`).test(
request.url(),
);
}
// ─── History interactions ──────────────────────────────────────────────────
/** Apply a filter expression through the real editor + Run button. */
export async function runFilterExpression(
page: Page,
expression: string,
): Promise<void> {
await typeExpression(page, expression);
await page.getByRole('button', { name: /run query/i }).click();
}
/**
* Sort the timeline descending through the STATE header.
*
* The antd table is *uncontrolled* — it has `sorter: true` but no `sortOrder`,
* so its internal cycle is none → ascend → descend regardless of the `order`
* the hook already sends. Reaching `desc` therefore takes two clicks, and the
* first one only resets the page (asc is nuqs's default, so it writes no param).
*/
export async function sortTimelineDescending(page: Page): Promise<void> {
const header = page.getByRole('columnheader', { name: 'STATE' });
const descRequest = page.waitForRequest(
(req) =>
isHistoryRequest(req, 'timeline') &&
requestUrl(req).searchParams.get('order') === 'desc',
);
await header.click();
await header.click();
await descRequest;
}
/**
* Snapshot the LABELS cell of every rendered row. Scenarios that compare two
* snapshots taken at different times (page 1 vs page 2, one timezone vs
* another) cannot express that as a web-first assertion, so the read lives in a
* helper rather than inline in the test.
*/
export async function timelineRowLabels(page: Page): Promise<string[]> {
return timelineRows(page).getByTestId('timeline-row-labels').allInnerTexts();
}
/** Snapshot the first row's CREATED AT cell. See {@link timelineRowLabels}. */
export async function firstTimelineRowCreatedAt(page: Page): Promise<string> {
return timelineRows(page)
.first()
.getByTestId('timeline-row-created-at')
.innerText();
}

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 --with-web e2e/bootstrap/setup.py::test_setup",
"env:stop": "cd .. && uv run pytest --basetemp=./tmp/ -vv --teardown 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,36 @@
import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
import fs from 'fs';
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',

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,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,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,177 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
ALERT_HISTORY_PATH,
createLogsAlertViaApi,
deleteAlertViaApi,
expectFirstPage,
gotoAlertHistory,
isHistoryRequest,
runFilterExpression,
setRuleDisabledViaApi,
statsCard,
TIMELINE_PAGE_SIZE,
timelineFooterRange,
timelineRows,
} 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 = page.waitForResponse((res) =>
isHistoryRequest(res.request(), 'timeline'),
);
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);
await runFilterExpression(
page,
`service.name = '${alertHistory.services[7]}'`,
);
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,271 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
expectFirstPage,
gotoAlertHistory,
isHistoryRequest,
runFilterExpression,
TIMELINE_PAGE_SIZE,
timelineFooterRange,
timelineRows,
} 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' });
await runFilterExpression(
page,
`service.name = '${alertHistory.services[0]}'`,
);
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]}'`;
await runFilterExpression(page, expression);
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);
await runFilterExpression(page, `threshold.name = 'critical'`);
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
await expect(timelineFooterRange(page)).toContainText(
`of ${alertHistory.total}`,
);
await runFilterExpression(page, `severity = 'critical'`);
await expect(timelineFooterRange(page)).toContainText(
`of ${alertHistory.total}`,
);
await gotoAlertHistory(page, alertHistory.ruleIdV1);
await runFilterExpression(page, `threshold.name = 'warning'`);
await expect(timelineFooterRange(page)).toContainText(
`of ${alertHistory.totalV1}`,
);
await runFilterExpression(page, `threshold.name = 'critical'`);
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 = page.waitForResponse((res) =>
isHistoryRequest(res.request(), '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]}'`;
await runFilterExpression(page, expression);
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);
await runFilterExpression(
page,
`service.name = '${alertHistory.services[6]}'`,
);
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,107 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
expectFirstPage,
gotoAlertHistory,
isHistoryRequest,
timelineRows,
} 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');
}
await page.getByTestId('timeline-filter-fired').click();
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' });
await page.getByTestId('timeline-filter-fired').click();
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,170 @@
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);
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);
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,173 @@
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-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);
const utcCell = await firstTimelineRowCreatedAt(page);
await page.addInitScript(() =>
localStorage.setItem('PREFERRED_TIMEZONE', 'Asia/Kolkata'),
);
await gotoAlertHistory(page, alertHistory.ruleId);
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);
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');
});
});