Compare commits

...

5 Commits

Author SHA1 Message Date
Ashu Ghildiyal
52b0f2801c test(e2e): wheel to lower controls in channel and alert forms 2026-11-05 12:14:55 +05:30
Ashu Ghildiyal
b68b1a662d chore(e2e): reject scripted scrolling in lint and guidelines 2026-11-05 08:55:59 +05:30
Ashu Ghildiyal
f00911fe77 test(e2e): scroll drivers with the mouse wheel
Scripted scroll-into-view moves overflow: hidden boxes a user cannot scroll, so clipped
layouts passed. Spec call sites ride along with the panel handle rename to keep this
commit typechecking.
2026-11-05 08:55:59 +05:30
ashu8912
98f9850c53 fix(alerts): fall back to default range on invalid relativeTime (#13375)
#### Description

- Alert Details crashed to the error boundary when the URL
`relativeTime` couldn't be parsed (e.g. `?relativeTime=1y`). It now
falls back to the default 30m range.
- The "triggers in …" and stats card labels show the range actually
queried, not the raw param.
- `relativeTime=3months` (accepted by validation since #12402) silently
resolved to 30 minutes. Multi-month values now resolve to 30 days per
month.
- Moved `useRouteTabUtils` into its own file to break the `hooks.tsx` ↔
`container/AlertHistory` import cycle.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#674

#### Additional Information

- Repro: open `/alerts/overview?ruleId=<id>&relativeTime=1y` (crashes on
`main`), or `relativeTime=3months` (shows 30m of history).
- No story change. The fixed page renders the same as the existing
AlertOverview story.
2026-11-05 03:12:55 +00:00
Pandey
07fd9ac5b8 chore: update codeowners for types and tests (#13427)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
- Update codeowners
2026-11-04 21:43:28 +00:00
25 changed files with 375 additions and 108 deletions

4
.github/CODEOWNERS vendored
View File

@@ -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

View File

@@ -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,

View File

@@ -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();

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

View File

@@ -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 => {

View File

@@ -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';

View File

@@ -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,
});
});
});

View File

@@ -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,
);

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

View File

@@ -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": [
{

View File

@@ -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).

View File

@@ -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 {

View File

@@ -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() {

View File

@@ -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({

View File

@@ -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);

View File

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

View File

@@ -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();
},

View File

@@ -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();

View File

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

View File

@@ -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`);

View File

@@ -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');

View File

@@ -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');

View File

@@ -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([]);
});

View File

@@ -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',