mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-05 07:50:54 +00:00
Compare commits
5 Commits
fix/query-
...
test/e2e/w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52b0f2801c | ||
|
|
b68b1a662d | ||
|
|
f00911fe77 | ||
|
|
98f9850c53 | ||
|
|
07fd9ac5b8 |
4
.github/CODEOWNERS
vendored
4
.github/CODEOWNERS
vendored
@@ -24,7 +24,7 @@ go.mod @therealpandey
|
||||
/pkg/config/ @therealpandey
|
||||
/pkg/errors/ @therealpandey
|
||||
/pkg/factory/ @therealpandey
|
||||
/pkg/types/ @therealpandey
|
||||
/pkg/types/ @therealpandey @srikanthccv
|
||||
/pkg/valuer/ @therealpandey
|
||||
/cmd/ @therealpandey
|
||||
.golangci.yml @therealpandey
|
||||
@@ -136,7 +136,7 @@ go.mod @therealpandey
|
||||
|
||||
# Integration tests
|
||||
|
||||
/tests/integration/ @therealpandey
|
||||
/tests/integration/ @therealpandey @srikanthccv
|
||||
|
||||
# e2e tests
|
||||
/tests/e2e/ @AshwinBhatkal
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Tooltip } from 'antd';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { ArrowDownLeft, ArrowUpRight, Calendar } from '@signozhq/icons';
|
||||
import { useAlertHistoryQueryParams } from 'pages/AlertDetails/hooks';
|
||||
import { AlertRuleStats } from 'types/api/alerts/def';
|
||||
import { calculateChange } from 'utils/calculateChange';
|
||||
|
||||
@@ -86,7 +87,7 @@ function StatsCard({
|
||||
}: StatsCardProps): JSX.Element {
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const relativeTime = urlQuery.get('relativeTime');
|
||||
const { relativeTime } = useAlertHistoryQueryParams();
|
||||
|
||||
const { changePercentage, changeDirection } = calculateChange(
|
||||
totalCurrentCount,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useGetAlertRuleDetailsTimelineGraphData } from 'pages/AlertDetails/hooks';
|
||||
import {
|
||||
useAlertHistoryQueryParams,
|
||||
useGetAlertRuleDetailsTimelineGraphData,
|
||||
} from 'pages/AlertDetails/hooks';
|
||||
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
|
||||
import { AlertRuleTimelineGraphResponse } from 'types/api/alerts/def';
|
||||
|
||||
@@ -13,9 +15,7 @@ function GraphWrapper({
|
||||
}: {
|
||||
totalCurrentTriggers: number;
|
||||
}): JSX.Element {
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const relativeTime = urlQuery.get('relativeTime');
|
||||
const { relativeTime } = useAlertHistoryQueryParams();
|
||||
|
||||
const { isLoading, isRefetching, isError, data, isValidRuleId, ruleId } =
|
||||
useGetAlertRuleDetailsTimelineGraphData();
|
||||
|
||||
25
frontend/src/lib/getMinMax.test.ts
Normal file
25
frontend/src/lib/getMinMax.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import GetMinMax from './getMinMax';
|
||||
|
||||
const DAY_NS = 24 * 60 * 60 * 1000 * 1e6;
|
||||
|
||||
function rangeInDays(interval: string): number {
|
||||
const { minTime, maxTime } = GetMinMax(interval);
|
||||
return Math.round((maxTime - minTime) / DAY_NS);
|
||||
}
|
||||
|
||||
describe('GetMinMax', () => {
|
||||
it.each([
|
||||
['1month', 30],
|
||||
['2months', 60],
|
||||
['3months', 90],
|
||||
['6month', 180],
|
||||
['10d', 10],
|
||||
['3w', 21],
|
||||
])('resolves %p to %p days', (interval, days) => {
|
||||
expect(rangeInDays(interval)).toBe(days);
|
||||
});
|
||||
|
||||
it('throws on an unknown interval', () => {
|
||||
expect(() => GetMinMax('1y')).toThrow('invalid time type');
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ export const isValidShortHandDateTimeFormat = (time: string): boolean =>
|
||||
|
||||
const extractTimeAndUnit = (time: string): { time: number; unit: string } => {
|
||||
// Match the pattern
|
||||
const match = /^(\d+)([mhdw])$/.exec(time);
|
||||
const match = validCustomTimeRegex.exec(time);
|
||||
|
||||
if (match) {
|
||||
return { time: parseInt(match[1], 10), unit: match[2] };
|
||||
@@ -39,13 +39,16 @@ export const getMinTimeForRelativeTimes = (
|
||||
return getMinAgo({ minutes: 24 * 60 * time }).getTime();
|
||||
case 'w':
|
||||
return getMinAgo({ minutes: 24 * 60 * 7 * time }).getTime();
|
||||
case 'month':
|
||||
case 'months':
|
||||
return getMinAgo({ minutes: 24 * 60 * 30 * time }).getTime();
|
||||
default:
|
||||
return getMinAgo({ minutes: 1 }).getTime();
|
||||
}
|
||||
};
|
||||
|
||||
const GetMinMax = (
|
||||
interval: Time | string,
|
||||
interval: string,
|
||||
dateTimeRange?: [number, number],
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
): GetMinMaxPayload => {
|
||||
|
||||
@@ -21,7 +21,8 @@ import { fromRuleDTOToPostableRuleV2 } from 'types/api/alerts/convert';
|
||||
|
||||
import AlertHeader from './AlertHeader/AlertHeader';
|
||||
import AlertNotFound from './AlertNotFound';
|
||||
import { useGetAlertRuleDetails, useRouteTabUtils } from './hooks';
|
||||
import { useGetAlertRuleDetails } from './hooks';
|
||||
import { useRouteTabUtils } from './useRouteTabUtils';
|
||||
|
||||
import './AlertDetails.styles.scss';
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
|
||||
import { useAlertHistoryQueryParams } from '../hooks';
|
||||
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const THIRTY_MINUTES_MS = 30 * 60 * 1000;
|
||||
|
||||
function renderWithSearch(
|
||||
search: string,
|
||||
): ReturnType<typeof useAlertHistoryQueryParams> {
|
||||
jest.mocked(useUrlQuery).mockReturnValue(new URLSearchParams(search));
|
||||
return renderHook(() => useAlertHistoryQueryParams()).result.current;
|
||||
}
|
||||
|
||||
describe('useAlertHistoryQueryParams', () => {
|
||||
it.each(['1y', '90s', 'now-1h', 'custom', 'null', ''])(
|
||||
'falls back to the default range for relativeTime=%p',
|
||||
(param) => {
|
||||
const { startTime, endTime, relativeTime } = renderWithSearch(
|
||||
`ruleId=1&relativeTime=${param}`,
|
||||
);
|
||||
|
||||
expect(endTime - startTime).toBeCloseTo(THIRTY_MINUTES_MS, -3);
|
||||
expect(relativeTime).toBe('30m');
|
||||
},
|
||||
);
|
||||
|
||||
it('uses a valid relativeTime', () => {
|
||||
const { startTime, endTime, relativeTime } = renderWithSearch(
|
||||
'ruleId=1&relativeTime=6h',
|
||||
);
|
||||
|
||||
expect(endTime - startTime).toBeCloseTo(6 * 60 * 60 * 1000, -3);
|
||||
expect(relativeTime).toBe('6h');
|
||||
});
|
||||
|
||||
it('prefers startTime and endTime over relativeTime', () => {
|
||||
const { startTime, endTime, relativeTime } = renderWithSearch(
|
||||
'ruleId=1&relativeTime=1y&startTime=1000&endTime=2000',
|
||||
);
|
||||
|
||||
expect({ startTime, endTime, relativeTime }).toStrictEqual({
|
||||
startTime: 1000,
|
||||
endTime: 2000,
|
||||
relativeTime: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,27 +29,21 @@ import {
|
||||
type RuletypesPostableRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { TabRoutes } from 'components/RouteTab/types';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ROUTES from 'constants/routes';
|
||||
import AlertHistory from 'container/AlertHistory';
|
||||
import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants';
|
||||
import {
|
||||
computeCursorForPage,
|
||||
useTimelineTableOrder,
|
||||
useTimelineTablePage,
|
||||
} from 'container/AlertHistory/Timeline/Table/useTimelineTableCursor';
|
||||
import { AlertDetailsTab, TimelineFilter } from 'container/AlertHistory/types';
|
||||
import { TimelineFilter } from 'container/AlertHistory/types';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import { buildRoutePath } from 'lib/router/buildRoutePath';
|
||||
import GetMinMax, { isValidShortHandDateTimeFormat } from 'lib/getMinMax';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { History, Table } from '@signozhq/icons';
|
||||
import EditRules from 'pages/EditRules';
|
||||
import BetaTag from 'periscope/components/BetaTag/BetaTag';
|
||||
import { useAlertRule } from 'providers/Alert';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { toPostableRuleDTOFromAlertDef } from 'types/api/alerts/convert';
|
||||
@@ -63,6 +57,7 @@ export const useAlertHistoryQueryParams = (): {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
hasStartAndEndParams: boolean;
|
||||
relativeTime: string | null;
|
||||
params: URLSearchParams;
|
||||
} => {
|
||||
const params = useUrlQuery();
|
||||
@@ -72,8 +67,9 @@ export const useAlertHistoryQueryParams = (): {
|
||||
const relativeTimeParam = params.get(QueryParams.relativeTime);
|
||||
|
||||
const relativeTime =
|
||||
(relativeTimeParam === 'null' ? null : relativeTimeParam) ||
|
||||
DEFAULT_TIME_RANGE;
|
||||
relativeTimeParam && isValidShortHandDateTimeFormat(relativeTimeParam)
|
||||
? relativeTimeParam
|
||||
: DEFAULT_TIME_RANGE;
|
||||
|
||||
const intStartTime = parseInt(startTime || '0', 10);
|
||||
const intEndTime = parseInt(endTime || '0', 10);
|
||||
@@ -93,63 +89,10 @@ export const useAlertHistoryQueryParams = (): {
|
||||
startTime: Math.floor(nanoToMilli(minTime)),
|
||||
endTime: Math.floor(nanoToMilli(maxTime)),
|
||||
hasStartAndEndParams,
|
||||
relativeTime: hasStartAndEndParams ? null : relativeTime,
|
||||
params,
|
||||
};
|
||||
};
|
||||
export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const getRouteUrl = (tab: AlertDetailsTab): string => {
|
||||
let route = '';
|
||||
let params = urlQuery.toString();
|
||||
const ruleIdKey = QueryParams.ruleId;
|
||||
const relativeTimeKey = QueryParams.relativeTime;
|
||||
|
||||
switch (tab) {
|
||||
case AlertDetailsTab.OVERVIEW:
|
||||
route = ROUTES.ALERT_OVERVIEW;
|
||||
break;
|
||||
case AlertDetailsTab.HISTORY:
|
||||
params = `${ruleIdKey}=${urlQuery.get(
|
||||
ruleIdKey,
|
||||
)}&${relativeTimeKey}=${urlQuery.get(relativeTimeKey)}`;
|
||||
route = ROUTES.ALERT_HISTORY;
|
||||
break;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${buildRoutePath(route)}?${params}`;
|
||||
};
|
||||
|
||||
const routes = [
|
||||
{
|
||||
Component: EditRules,
|
||||
name: (
|
||||
<div className="tab-item" data-testid="alert-details-tab-overview">
|
||||
<Table size={14} />
|
||||
Overview
|
||||
</div>
|
||||
),
|
||||
route: getRouteUrl(AlertDetailsTab.OVERVIEW),
|
||||
key: ROUTES.ALERT_OVERVIEW,
|
||||
},
|
||||
{
|
||||
Component: AlertHistory,
|
||||
name: (
|
||||
<div className="tab-item" data-testid="alert-details-tab-history">
|
||||
<History size={14} />
|
||||
History
|
||||
<BetaTag />
|
||||
</div>
|
||||
),
|
||||
route: getRouteUrl(AlertDetailsTab.HISTORY),
|
||||
key: ROUTES.ALERT_HISTORY,
|
||||
},
|
||||
];
|
||||
|
||||
return { routes };
|
||||
};
|
||||
type Props = {
|
||||
ruleId: string | null;
|
||||
isValidRuleId: boolean;
|
||||
@@ -356,12 +299,7 @@ export const useTimelineTable = ({
|
||||
nextCursor?: string;
|
||||
}): {
|
||||
paginationConfig: TablePaginationConfig;
|
||||
onChangeHandler: (
|
||||
pagination: TablePaginationConfig,
|
||||
sorter: any,
|
||||
filters: any,
|
||||
extra: any,
|
||||
) => void;
|
||||
onChangeHandler: TableProps<AlertRuleTimelineTableResponse>['onChange'];
|
||||
handleNextPage: () => void;
|
||||
handlePrevPage: () => void;
|
||||
hasNextPage: boolean;
|
||||
@@ -448,8 +386,11 @@ export const useAlertRuleStatusToggle = ({
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
setAlertRuleState(data.data.state);
|
||||
invalidateGetRuleByID(queryClient, { id: ruleId });
|
||||
queryClient.refetchQueries([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId]);
|
||||
void invalidateGetRuleByID(queryClient, { id: ruleId });
|
||||
void queryClient.refetchQueries([
|
||||
REACT_QUERY_KEY.ALERT_RULE_DETAILS,
|
||||
ruleId,
|
||||
]);
|
||||
notifications.success({
|
||||
message: `Alert has been ${
|
||||
data.data.state === 'disabled' ? 'disabled' : 'enabled'
|
||||
@@ -457,8 +398,11 @@ export const useAlertRuleStatusToggle = ({
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
invalidateGetRuleByID(queryClient, { id: ruleId });
|
||||
queryClient.refetchQueries([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId]);
|
||||
void invalidateGetRuleByID(queryClient, { id: ruleId });
|
||||
void queryClient.refetchQueries([
|
||||
REACT_QUERY_KEY.ALERT_RULE_DETAILS,
|
||||
ruleId,
|
||||
]);
|
||||
showErrorModal(
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
|
||||
);
|
||||
|
||||
65
frontend/src/pages/AlertDetails/useRouteTabUtils.tsx
Normal file
65
frontend/src/pages/AlertDetails/useRouteTabUtils.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { TabRoutes } from 'components/RouteTab/types';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import AlertHistory from 'container/AlertHistory';
|
||||
import { AlertDetailsTab } from 'container/AlertHistory/types';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { buildRoutePath } from 'lib/router/buildRoutePath';
|
||||
import { History, Table } from '@signozhq/icons';
|
||||
import EditRules from 'pages/EditRules';
|
||||
import BetaTag from 'periscope/components/BetaTag/BetaTag';
|
||||
|
||||
export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const getRouteUrl = (tab: AlertDetailsTab): string => {
|
||||
let route = '';
|
||||
let params = urlQuery.toString();
|
||||
const ruleIdKey = QueryParams.ruleId;
|
||||
const relativeTimeKey = QueryParams.relativeTime;
|
||||
|
||||
switch (tab) {
|
||||
case AlertDetailsTab.OVERVIEW:
|
||||
route = ROUTES.ALERT_OVERVIEW;
|
||||
break;
|
||||
case AlertDetailsTab.HISTORY:
|
||||
params = `${ruleIdKey}=${urlQuery.get(
|
||||
ruleIdKey,
|
||||
)}&${relativeTimeKey}=${urlQuery.get(relativeTimeKey)}`;
|
||||
route = ROUTES.ALERT_HISTORY;
|
||||
break;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${buildRoutePath(route)}?${params}`;
|
||||
};
|
||||
|
||||
const routes = [
|
||||
{
|
||||
Component: EditRules,
|
||||
name: (
|
||||
<div className="tab-item" data-testid="alert-details-tab-overview">
|
||||
<Table size={14} />
|
||||
Overview
|
||||
</div>
|
||||
),
|
||||
route: getRouteUrl(AlertDetailsTab.OVERVIEW),
|
||||
key: ROUTES.ALERT_OVERVIEW,
|
||||
},
|
||||
{
|
||||
Component: AlertHistory,
|
||||
name: (
|
||||
<div className="tab-item" data-testid="alert-details-tab-history">
|
||||
<History size={14} />
|
||||
History
|
||||
<BetaTag />
|
||||
</div>
|
||||
),
|
||||
route: getRouteUrl(AlertDetailsTab.HISTORY),
|
||||
key: ROUTES.ALERT_HISTORY,
|
||||
},
|
||||
];
|
||||
|
||||
return { routes };
|
||||
};
|
||||
@@ -24,7 +24,18 @@
|
||||
"playwright/no-conditional-in-test": "error",
|
||||
"playwright/no-page-pause": "error",
|
||||
"playwright/no-wait-for-timeout": "warn",
|
||||
"playwright/prefer-web-first-assertions": "error"
|
||||
"playwright/prefer-web-first-assertions": "error",
|
||||
"no-restricted-properties": [
|
||||
"error",
|
||||
{
|
||||
"property": "scrollIntoViewIfNeeded",
|
||||
"message": "Scroll with wheelTo (drivers/scroll.ts), as a user would."
|
||||
},
|
||||
{
|
||||
"property": "scrollIntoView",
|
||||
"message": "Scroll with wheelTo (drivers/scroll.ts), as a user would."
|
||||
}
|
||||
]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
|
||||
@@ -128,6 +128,11 @@ never by reaching for another fixture.
|
||||
- Locate by testid. A missing one is added to the product in the same change.
|
||||
Role, label and CSS lookups are converted as you touch them; antd internals
|
||||
(`antd.ts`) are the exception.
|
||||
- Scroll with `wheelTo` (`drivers/scroll.ts`) before acting on an element that
|
||||
may be off screen. It turns the mouse wheel and asserts `toBeInViewport()`.
|
||||
Scripted scrolling (`scrollIntoView`, `scrollIntoViewIfNeeded`) moves even an
|
||||
`overflow: hidden` box a user cannot scroll, so a clipped layout passes; lint
|
||||
rejects it.
|
||||
- Never `test.slow()` in a driver: it triples the remaining budget on every
|
||||
call and compounds in loops. Use `allowFor(floor)`. Budgets and their
|
||||
measurements are in [`docs/flake-notes.md`](docs/flake-notes.md).
|
||||
|
||||
6
tests/e2e/drivers/alerts/driver.ts
generated
6
tests/e2e/drivers/alerts/driver.ts
generated
@@ -38,6 +38,7 @@ import {
|
||||
} from './configuration';
|
||||
import { alertSeeder, type AlertSeeder } from './seed';
|
||||
import type { TimelineItem, TimelineResponse } from './types';
|
||||
import { wheelTo } from '../scroll';
|
||||
|
||||
/**
|
||||
* The alerts driver: one object per surface, all bound to one page.
|
||||
@@ -520,6 +521,7 @@ export function alertsDriver(page: Page): Alerts {
|
||||
page.getByTestId('threshold-value-input').nth(index),
|
||||
|
||||
selectThresholdChannel: async (index, channelName) => {
|
||||
await wheelTo(form.thresholdChannelSelects().nth(index));
|
||||
await antd.pickByName(
|
||||
form.thresholdChannelSelects().nth(index),
|
||||
channelName,
|
||||
@@ -533,6 +535,7 @@ export function alertsDriver(page: Page): Alerts {
|
||||
* and without that a second call waits forever for the add button.
|
||||
*/
|
||||
addLabel: async (key, value) => {
|
||||
await wheelTo(page.getByTestId('alert-add-label-button'));
|
||||
await page.getByTestId('alert-add-label-button').click();
|
||||
const input = page.getByTestId('alert-add-label-input');
|
||||
await input.fill(key);
|
||||
@@ -556,6 +559,7 @@ export function alertsDriver(page: Page): Alerts {
|
||||
*/
|
||||
expandAdvancedOptions: async () => {
|
||||
const header = page.getByRole('button', { name: /ADVANCED OPTIONS/i });
|
||||
await wheelTo(header);
|
||||
if ((await header.getAttribute('aria-expanded')) !== 'true') {
|
||||
await header.click();
|
||||
}
|
||||
@@ -570,6 +574,7 @@ export function alertsDriver(page: Page): Alerts {
|
||||
// The popover's content is only in the DOM while open, so every option
|
||||
// lookup has to come after this.
|
||||
openEvaluationSettings: async () => {
|
||||
await wheelTo(form.evaluationSettingsButton());
|
||||
await form.evaluationSettingsButton().click();
|
||||
await expect(page.locator('.evaluation-window-popover')).toBeVisible();
|
||||
},
|
||||
@@ -659,6 +664,7 @@ export function alertsDriver(page: Page): Alerts {
|
||||
|
||||
openChannelDropdown: async (index = 0) => {
|
||||
const select = form.thresholdChannelSelects().nth(index);
|
||||
await wheelTo(select);
|
||||
await select.click();
|
||||
const dropdown = await antd.ownDropdown(select);
|
||||
return {
|
||||
|
||||
2
tests/e2e/drivers/channels.ts
generated
2
tests/e2e/drivers/channels.ts
generated
@@ -7,6 +7,7 @@ import { expect, type Locator, type Page } from '@playwright/test';
|
||||
import { authToken } from './api';
|
||||
|
||||
import { driver, kit, seeded } from './kit';
|
||||
import { wheelTo } from './scroll';
|
||||
|
||||
// Search and the type filter are both served by the API, so every assertion on
|
||||
// the list is also a guard on the query the list sends.
|
||||
@@ -316,6 +317,7 @@ function channelsDriver(page: Page): Channels {
|
||||
await page.getByRole('option', { name: label, exact: true }).click();
|
||||
},
|
||||
async fillField(testId, value) {
|
||||
await wheelTo(page.getByTestId(testId));
|
||||
await page.getByTestId(testId).fill(value);
|
||||
},
|
||||
async saveAndReturnId() {
|
||||
|
||||
13
tests/e2e/drivers/infra/driver.ts
generated
13
tests/e2e/drivers/infra/driver.ts
generated
@@ -11,6 +11,7 @@ import { columnStorageKey } from '../storage';
|
||||
import { tanstackDriver, type Tanstack } from '../tanstack';
|
||||
|
||||
import { K8S_PATH, type EntityDef, type K8sCategory } from './entities';
|
||||
import { wheelTo } from '../scroll';
|
||||
|
||||
/**
|
||||
* Driving infra monitoring: the list, the drawer, and the state they share.
|
||||
@@ -347,9 +348,7 @@ export function infraDriver(page: Page): Infra {
|
||||
// Playwright's auto-scroll aligns to the nearest edge, which parks a
|
||||
// low row under the sticky pagination bar; centring clears both
|
||||
// sticky edges, as a user's own scroll would.
|
||||
await button.evaluate((element) =>
|
||||
element.scrollIntoView({ block: 'center', inline: 'nearest' }),
|
||||
);
|
||||
await wheelTo(button);
|
||||
await button.click({ timeout: 5_000 });
|
||||
}
|
||||
await expect(container).toBeVisible({ timeout: 3_000 });
|
||||
@@ -361,9 +360,7 @@ export function infraDriver(page: Page): Infra {
|
||||
collapseGroup: async (groupLabel) => {
|
||||
const button = list.groupRow(groupLabel).getByTestId('expand-row-button');
|
||||
if ((await button.getAttribute('aria-expanded')) === 'true') {
|
||||
await button.evaluate((element) =>
|
||||
element.scrollIntoView({ block: 'center', inline: 'nearest' }),
|
||||
);
|
||||
await wheelTo(button);
|
||||
await button.click({ timeout: 5_000 });
|
||||
}
|
||||
await expect(button).toHaveAttribute('aria-expanded', 'false');
|
||||
@@ -448,9 +445,7 @@ export function infraDriver(page: Page): Infra {
|
||||
if (!(await drawerPanel().isVisible())) {
|
||||
// The header row is `position: sticky`, so a row scrolled "just into
|
||||
// view" lands under it and the click is swallowed by a header cell.
|
||||
await row.evaluate((element) =>
|
||||
element.scrollIntoView({ block: 'center' }),
|
||||
);
|
||||
await wheelTo(row);
|
||||
await row.click({ timeout: 5_000 });
|
||||
}
|
||||
await expect(page.getByTestId(DRAWER.close)).toBeVisible({
|
||||
|
||||
3
tests/e2e/drivers/logs/explorer.ts
generated
3
tests/e2e/drivers/logs/explorer.ts
generated
@@ -15,6 +15,7 @@ import { logDetailDriver, type LogDetail } from './detail';
|
||||
import { liveTailDriver, type LiveTail } from './liveTail';
|
||||
import { ROW_SELECTOR } from './rows';
|
||||
import { seedOne } from './seed';
|
||||
import { wheelTo } from '../scroll';
|
||||
|
||||
export const LOGS_EXPLORER_PATH = '/logs/logs-explorer';
|
||||
export const LINKED_ROW_CLASS = 'logs-linked-row';
|
||||
@@ -301,7 +302,7 @@ export function logsExplorerDriver(page: Page): LogsExplorer {
|
||||
keys.forEach((key) => seen.add(key));
|
||||
if (seen.size < expected) {
|
||||
// Virtuoso loads the next page when the last row nears the viewport.
|
||||
await table.rows().last().scrollIntoViewIfNeeded({ timeout: 2_000 });
|
||||
await wheelTo(table.rows().last());
|
||||
await page.mouse.wheel(0, 4_000);
|
||||
}
|
||||
expect(seen.size).toBeGreaterThanOrEqual(expected);
|
||||
|
||||
6
tests/e2e/drivers/panel-editor/picker.ts
generated
6
tests/e2e/drivers/panel-editor/picker.ts
generated
@@ -6,6 +6,7 @@ import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { PanelKind } from '../dashboard-spec';
|
||||
import { PATH } from '../dashboards';
|
||||
import { wheelTo } from '../scroll';
|
||||
|
||||
const NEW_PANEL_DRAWER = 'panel-type-drawer';
|
||||
|
||||
@@ -29,6 +30,7 @@ export interface NewPanelPicker {
|
||||
newSection(title: string): Promise<void>;
|
||||
/** An empty section's own New Panel CTA. Should move to drivers/dashboards.ts. */
|
||||
sectionAddPanel(): Locator;
|
||||
openFromSection(): Promise<void>;
|
||||
}
|
||||
|
||||
export function newPanelDriver(page: Page): NewPanelPicker {
|
||||
@@ -61,6 +63,10 @@ export function newPanelDriver(page: Page): NewPanelPicker {
|
||||
},
|
||||
sectionAddPanel: () =>
|
||||
page.locator('[data-testid^="section-add-panel-"]').first(),
|
||||
async openFromSection() {
|
||||
await wheelTo(picker.sectionAddPanel());
|
||||
await picker.sectionAddPanel().click();
|
||||
},
|
||||
};
|
||||
return picker;
|
||||
}
|
||||
|
||||
9
tests/e2e/drivers/panels.ts
generated
9
tests/e2e/drivers/panels.ts
generated
@@ -18,6 +18,7 @@ import { type Legend, legendAt } from './panels/legend';
|
||||
import { type Markdown, markdownAt } from './panels/markdown';
|
||||
import { type PiePanel, pieAt } from './panels/pie';
|
||||
import { type TableRows, tableAt } from './panels/table';
|
||||
import { wheelTo } from './scroll';
|
||||
|
||||
// Rendered panels, on the dashboard grid and in the View modal. Each surface
|
||||
// lives in its own part under ./panels, and so does a kind with UI of its own.
|
||||
@@ -47,7 +48,7 @@ export const RENDERER_TESTID: Record<PanelKind, string> = {
|
||||
export interface PanelHandle {
|
||||
root(): Locator;
|
||||
/** Panels fetch lazily behind an IntersectionObserver. */
|
||||
scrollIntoView(): Promise<void>;
|
||||
wheelIntoView(): Promise<void>;
|
||||
/** Scrolls in, then waits for the kind's own renderer. */
|
||||
waitRendered(kind: PanelKind): Promise<void>;
|
||||
renderer(kind: PanelKind): Locator;
|
||||
@@ -129,11 +130,11 @@ function panelHandle(page: Page, id: string): PanelHandle {
|
||||
|
||||
return {
|
||||
root,
|
||||
async scrollIntoView() {
|
||||
await root().scrollIntoViewIfNeeded();
|
||||
async wheelIntoView() {
|
||||
await wheelTo(root());
|
||||
},
|
||||
async waitRendered(kind) {
|
||||
await root().scrollIntoViewIfNeeded();
|
||||
await wheelTo(root());
|
||||
await expect(root()).toHaveAttribute('data-panel-visible', 'true');
|
||||
await expect(byTestId(RENDERER_TESTID[kind])).toBeVisible();
|
||||
},
|
||||
|
||||
3
tests/e2e/drivers/panels/actions.ts
generated
3
tests/e2e/drivers/panels/actions.ts
generated
@@ -3,6 +3,7 @@
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
import { wheelTo } from '../scroll';
|
||||
|
||||
// Panel action menu items take no testid (@signozhq/ui's dropdown preset drops
|
||||
// it), so their visible label is the contract, matched by Radix's menuitem role.
|
||||
@@ -42,7 +43,7 @@ export function actionsAt(
|
||||
const actions: PanelActions = {
|
||||
async open() {
|
||||
await actions.close();
|
||||
await root().scrollIntoViewIfNeeded();
|
||||
await wheelTo(root());
|
||||
// The trigger only appears on hover.
|
||||
await root().hover();
|
||||
await page.getByTestId(`panel-actions-${panelId}`).click();
|
||||
|
||||
3
tests/e2e/drivers/panels/dom.ts
generated
3
tests/e2e/drivers/panels/dom.ts
generated
@@ -3,6 +3,7 @@
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
import { wheelTo } from '../scroll';
|
||||
|
||||
export interface Box {
|
||||
x: number;
|
||||
@@ -15,7 +16,7 @@ export async function boxOf(
|
||||
locator: Locator,
|
||||
description: string,
|
||||
): Promise<Box> {
|
||||
await locator.scrollIntoViewIfNeeded();
|
||||
await wheelTo(locator);
|
||||
const box = await locator.boundingBox();
|
||||
expect(box, `expected a bounding box for ${description}`).not.toBeNull();
|
||||
return box as Box;
|
||||
|
||||
148
tests/e2e/drivers/scroll.ts
generated
Normal file
148
tests/e2e/drivers/scroll.ts
generated
Normal file
@@ -0,0 +1,148 @@
|
||||
import { expect, type Locator } from '@playwright/test';
|
||||
|
||||
const MAX_TURNS = 20;
|
||||
|
||||
interface Turn {
|
||||
x: number;
|
||||
y: number;
|
||||
deltaX: number;
|
||||
deltaY: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls `target` to the middle of its scroll container with the mouse wheel,
|
||||
* as a user would. Unlike `scrollIntoView`, the wheel cannot move an
|
||||
* `overflow: hidden` box, so a layout that clips instead of scrolling fails here.
|
||||
*/
|
||||
export async function wheelTo(target: Locator): Promise<void> {
|
||||
const page = target.page();
|
||||
const position = (): Promise<string> =>
|
||||
target.evaluate((element) => {
|
||||
const { top, left } = element.getBoundingClientRect();
|
||||
return `${top},${left}`;
|
||||
});
|
||||
for (let turn = 0; turn < MAX_TURNS; turn += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const next = await target.evaluate(nextTurn);
|
||||
if (!next) {
|
||||
break;
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const before = await position();
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await page.mouse.move(next.x, next.y);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await page.mouse.wheel(next.deltaX, next.deltaY);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await expect
|
||||
.poll(position, {
|
||||
message:
|
||||
'the wheel did not move the target: a container clips it instead of scrolling',
|
||||
timeout: 2_000,
|
||||
})
|
||||
.not.toBe(before);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await target.evaluate(settled);
|
||||
}
|
||||
await expect(target).toBeInViewport();
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to wheel and by how much, or null once the target is in view. The wheel
|
||||
* goes over the innermost clipping ancestor that is itself on screen, so nested
|
||||
* scrollers are brought in from the outside.
|
||||
*/
|
||||
function nextTurn(element: Element): Turn | null {
|
||||
interface Rect {
|
||||
top: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
right: number;
|
||||
}
|
||||
const clip = (a: Rect, b: Rect): Rect => ({
|
||||
top: Math.max(a.top, b.top),
|
||||
bottom: Math.min(a.bottom, b.bottom),
|
||||
left: Math.max(a.left, b.left),
|
||||
right: Math.min(a.right, b.right),
|
||||
});
|
||||
|
||||
const clippers: Element[] = [];
|
||||
for (let node = element.parentElement; node; node = node.parentElement) {
|
||||
const style = getComputedStyle(node);
|
||||
if (style.overflowX !== 'visible' || style.overflowY !== 'visible') {
|
||||
clippers.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
let visible: Rect = {
|
||||
top: 0,
|
||||
bottom: window.innerHeight,
|
||||
left: 0,
|
||||
right: window.innerWidth,
|
||||
};
|
||||
let region = visible;
|
||||
for (const node of clippers.reverse()) {
|
||||
visible = clip(visible, node.getBoundingClientRect());
|
||||
if (visible.bottom <= visible.top || visible.right <= visible.left) {
|
||||
break;
|
||||
}
|
||||
region = visible;
|
||||
}
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
// A target larger than the region is in view once it covers it.
|
||||
const within = (
|
||||
start: number,
|
||||
end: number,
|
||||
from: number,
|
||||
to: number,
|
||||
): boolean =>
|
||||
end - start <= to - from
|
||||
? start >= from && end <= to
|
||||
: start <= from && end >= to;
|
||||
const inX = within(rect.left, rect.right, region.left, region.right);
|
||||
const inY = within(rect.top, rect.bottom, region.top, region.bottom);
|
||||
const x = (region.left + region.right) / 2;
|
||||
const y = (region.top + region.bottom) / 2;
|
||||
const deltaX = (rect.left + rect.right) / 2 - x;
|
||||
const deltaY = (rect.top + rect.bottom) / 2 - y;
|
||||
|
||||
// A sticky header overlays the region rather than clipping it, so a target
|
||||
// inside the region can still sit under one; centring it clears the edges.
|
||||
if (inX && inY) {
|
||||
const hit = document.elementFromPoint(
|
||||
Math.max(rect.left, region.left) +
|
||||
Math.min(rect.width, region.right - region.left) / 2,
|
||||
Math.max(rect.top, region.top) +
|
||||
Math.min(rect.height, region.bottom - region.top) / 2,
|
||||
);
|
||||
const covered = !hit || !element.contains(hit);
|
||||
if (!covered || (Math.abs(deltaX) < 1 && Math.abs(deltaY) < 1)) {
|
||||
return null;
|
||||
}
|
||||
return { x, y, deltaX, deltaY };
|
||||
}
|
||||
|
||||
return { x, y, deltaX: inX ? 0 : deltaX, deltaY: inY ? 0 : deltaY };
|
||||
}
|
||||
|
||||
/** Resolves once the target holds still for two frames, as smooth scrolling ends. */
|
||||
function settled(element: Element): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
let last = '';
|
||||
let still = 0;
|
||||
const deadline = performance.now() + 1_000;
|
||||
const tick = (): void => {
|
||||
const { top, left } = element.getBoundingClientRect();
|
||||
const now = `${top},${left}`;
|
||||
still = now === last ? still + 1 : 0;
|
||||
last = now;
|
||||
if (still >= 2 || performance.now() > deadline) {
|
||||
resolve();
|
||||
} else {
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
});
|
||||
}
|
||||
1
tests/e2e/drivers/tanstack.ts
generated
1
tests/e2e/drivers/tanstack.ts
generated
@@ -236,7 +236,6 @@ export function tanstackDriver(
|
||||
|
||||
dragColumn: async (columnId, ontoColumnId) => {
|
||||
const grip = page.getByRole('button', { name: dragHandleLabel(columnId) });
|
||||
await grip.scrollIntoViewIfNeeded();
|
||||
const from = await boxOf(grip, `${columnId} drag grip`);
|
||||
const to = await boxOf(headerCell(ontoColumnId), `${ontoColumnId} header`);
|
||||
|
||||
|
||||
@@ -86,8 +86,7 @@ test.describe('Dashboards — panel picker', () => {
|
||||
});
|
||||
await dashboards.seedAndOpen(dashboard);
|
||||
|
||||
await newPanel.sectionAddPanel().scrollIntoViewIfNeeded();
|
||||
await newPanel.sectionAddPanel().click();
|
||||
await newPanel.openFromSection();
|
||||
|
||||
await expect(newPanel.root()).toBeVisible();
|
||||
await expect(newPanel.confirmButton()).toHaveText('Add to Empty');
|
||||
|
||||
@@ -44,7 +44,7 @@ test.describe('Dashboards — panel actions', () => {
|
||||
);
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
const target = panels.panel(COMPACT_PANELS.timeseries);
|
||||
await target.scrollIntoView();
|
||||
await target.wheelIntoView();
|
||||
|
||||
const download = panels.nextDownload();
|
||||
await target.actions.download('PNG');
|
||||
|
||||
@@ -23,7 +23,7 @@ test.describe('Dashboards — edge cases', () => {
|
||||
await dashboards.visit(`/dashboard/${id}${FAR_PAST}`);
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await target.scrollIntoView();
|
||||
await target.wheelIntoView();
|
||||
await expect(target.noData()).toBeVisible();
|
||||
expect(collected.errors).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ test.describe('Dashboards — panel zoom', () => {
|
||||
const ids = Object.values(COMPACT_PANELS);
|
||||
for (const id of ids) {
|
||||
const target = panels.panel(id);
|
||||
await target.scrollIntoView();
|
||||
await target.wheelIntoView();
|
||||
await expect(target.visibility()).toHaveAttribute(
|
||||
'data-panel-visible',
|
||||
'true',
|
||||
|
||||
Reference in New Issue
Block a user