Compare commits

...

22 Commits

Author SHA1 Message Date
Abhi Kumar
d8b9c136ca test(dashboards): cover the dashboard settings drawer 2026-10-26 17:04:10 +05:30
Abhi Kumar
ffe2600423 test(dashboards): cover dashboard variables 2026-10-26 17:04:09 +05:30
Abhi Kumar
9a2eb4f242 test(dashboards): cover the time range 2026-10-26 17:04:08 +05:30
Abhi Kumar
4f89b577d5 test(dashboards): add the time picker, variables bar and settings driver 2026-10-26 17:04:08 +05:30
Abhi Kumar
f12319f35e test(dashboards): cover sections and dashboard actions 2026-10-26 17:04:07 +05:30
Abhi Kumar
2141d349eb test(dashboards): cover viewing a dashboard and its edge cases 2026-10-26 17:04:06 +05:30
Abhi Kumar
706a95c793 test(dashboards): add the dashboard page driver 2026-10-26 17:04:06 +05:30
Abhi Kumar
c39b3ff578 test(dashboards): cover type switching, capability errors and context links 2026-10-26 17:04:03 +05:30
Abhi Kumar
4aebec8cab test(dashboards): cover the New Panel picker, editor shell and query pane 2026-10-26 17:04:02 +05:30
Abhi Kumar
2fa39a85bc test(dashboards): cover creating a dashboard from the list page 2026-10-26 17:04:01 +05:30
Abhi Kumar
3f0eea7a3d test(dashboards): cover zoom, drilldown and the View modal 2026-10-26 17:04:01 +05:30
Abhi Kumar
aaf35323c3 test(dashboards): cover the panel header, states, actions and grid layout 2026-10-26 17:04:00 +05:30
Abhi Kumar
9d35c60906 test(dashboards): check every kind's editor sections, query tabs and capabilities 2026-10-26 17:03:59 +05:30
Abhi Kumar
ed6289f7ba test(dashboards): check every kind's actions menu and drilldown 2026-10-26 17:03:59 +05:30
Abhi Kumar
e761e07f74 test(dashboards): check every kind renders and shows its data states 2026-10-26 17:03:58 +05:30
Abhi Kumar
8b0bbf54f7 test(dashboards): describe every panel kind in one table 2026-10-26 17:03:57 +05:30
Abhi Kumar
a5c1a3f1eb test(dashboards): add the panel editor, config and dashboard list drivers 2026-10-26 17:03:57 +05:30
Abhi Kumar
c3ec2f8320 test(dashboards): add the panels driver 2026-10-26 17:03:56 +05:30
Abhi Kumar
33473551e6 test(dashboards): add the dashboards driver and spec builders 2026-10-26 17:03:55 +05:30
Abhi Kumar
04de506a9d test(dashboards): drop the specs written for the old dashboard
They target /api/v1/dashboards, which the backend now rejects, and have been skipped
since. Each behaviour is ported onto the drivers in the list and page specs.
2026-10-26 17:03:55 +05:30
Abhi Kumar
d052063398 test(e2e): let a driver clean up after its test 2026-10-26 17:03:54 +05:30
Abhi Kumar
59f227d368 test(dashboards): expose the uPlot instance and add panel test ids 2026-10-26 17:03:51 +05:30
73 changed files with 9209 additions and 4172 deletions

View File

@@ -88,7 +88,10 @@ tests/
│ └── setup.py # test_setup / test_teardown — pytest lifecycle
├── drivers/ # the driver kit and one driver per feature
│ ├── kit.ts
│ └── routing.ts
│ ├── routing.ts
│ ├── dashboards.ts # dashboard seeding + query_range mocks; panels → panel-editor → panel-config extend it
│ ├── panels.ts
│ └── panel-kinds.ts # one row per panel kind; drives tests/dashboards/contract
├── fixtures/ # Playwright test fixtures (test.extend) only
│ └── auth.ts
├── helpers/ # function helpers + the constants they share with tests
@@ -128,7 +131,8 @@ test('TC-09 RouteTab switches the url', async ({ routing }) => {
});
```
- `driver(name, build)` adds a test-scoped fixture built on `authedPage`.
- `driver(name, build, dispose?)` adds a test-scoped fixture built on `authedPage`. `dispose` runs after the test on the same page, so a driver can delete what it seeded.
- Drivers layer: `drivers/panels.ts` exports `withPanels(dashboardsTest)` and re-exports `drivers/dashboards.ts`, so a spec still imports one module.
- `seeded(name, open, close)` adds a worker-scoped seed. `open` and `close` run on a throwaway admin page.
- A driver reuses helpers by calling them, never by reaching for another fixture.
- `drivers/` is AI-owned: coding agents generate and maintain it with the specs, and `.gitattributes` collapses it in diffs.

View File

@@ -8,7 +8,7 @@ import uPlot, { AlignedData, Options } from 'uplot';
import { usePlotContext } from '../../context/PlotContext';
import { UPlotChartProps } from '../types';
import { prepareAlignedData } from './utils';
import { prepareAlignedData, setInstanceHandle } from './utils';
/**
* Check if dimensions have changed
@@ -58,6 +58,7 @@ export default function UPlotChart({
onDestroy?.(plotInstanceRef.current);
plotInstanceRef.current.destroy();
plotInstanceRef.current = null;
setInstanceHandle(containerRef.current, null);
setPlotContextInitialState({ uPlotInstance: null });
plotRef?.(null);
}
@@ -101,6 +102,7 @@ export default function UPlotChart({
});
plotInstanceRef.current = plot;
setInstanceHandle(containerRef.current, plot);
}, [
config,
data,

View File

@@ -1,6 +1,6 @@
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { applySpanGapsToAlignedData } from 'lib/uPlotV2/utils/dataUtils';
import { AlignedData } from 'uplot';
import uPlot, { AlignedData } from 'uplot';
export function prepareAlignedData({
data,
@@ -14,3 +14,33 @@ export function prepareAlignedData({
? applySpanGapsToAlignedData(data as AlignedData, seriesSpanGaps)
: (data as AlignedData);
}
/**
* The live uPlot instance, hung off its own container element.
*
* Charts render to a canvas, so from the outside there is nothing to assert on
* beyond pixels — an E2E test can see that a chart exists but not that a series
* was hidden, an axis bound applied, or a line turned dashed. Exposing the
* instance on its container gives those tests (and anyone debugging in the
* console) the real chart state to read.
*
* On the container rather than a global: it is naturally scoped per panel, it
* needs no registry keyed by id, and it is collected along with the node.
*/
export interface UPlotHostElement extends HTMLDivElement {
__uplot?: uPlot;
}
export function setInstanceHandle(
node: HTMLDivElement | null,
plot: uPlot | null,
): void {
if (!node) {
return;
}
if (plot) {
(node as UPlotHostElement).__uplot = plot;
} else {
delete (node as UPlotHostElement).__uplot;
}
}

View File

@@ -87,7 +87,7 @@ function AddColumnDropdown({
value={field.name}
isSelected={selectedNames.has(field.name)}
onSelect={(): void => onToggle(field)}
data-testid="list-columns-suggestion"
data-testid={`list-columns-suggestion-${field.name}`}
>
{field.name}
</ComboboxItem>

View File

@@ -37,7 +37,12 @@ function SortableColumnChip({
};
return (
<div ref={setNodeRef} style={style} className={styles.chip}>
<div
ref={setNodeRef}
style={style}
className={styles.chip}
data-testid={`list-column-chip-${name}`}
>
<span {...listeners} className={styles.grip}>
<Button
type="button"
@@ -61,7 +66,7 @@ function SortableColumnChip({
size="sm"
icon
aria-label={`Remove ${name}`}
testId="list-column-remove"
testId={`list-column-remove-${name}`}
onClick={(): void => onRemove(name)}
>
<X size={12} />

View File

@@ -88,6 +88,7 @@ function PreviewPane(props: PreviewPaneProps): JSX.Element {
return (
<div
className={cx(styles.preview, { [styles.previewStatic]: !!staticDraft })}
data-testid="preview-pane"
>
{query && !query.hideHeader && (
<div className={styles.header}>

View File

@@ -16,6 +16,8 @@ export interface ResizableHeaderProps extends Omit<
> {
width?: number;
onResize?: (e: SyntheticEvent<Element>, data: ResizeCallbackData) => void;
/** Column key, used only to give the drag grip a stable test handle. */
columnKey?: string;
}
/**
@@ -27,6 +29,7 @@ export interface ResizableHeaderProps extends Omit<
function ResizableHeader({
width,
onResize,
columnKey,
...restProps
}: ResizableHeaderProps): JSX.Element {
const handle = useMemo(
@@ -34,13 +37,14 @@ function ResizableHeader({
<span
className={styles.handle}
role="presentation"
data-testid={columnKey ? `column-resize-${columnKey}` : undefined}
// Stop the grip's click from reaching the column sorter underneath.
// The grip is a pointer-only resize affordance, not keyboard-actionable.
// oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
onClick={(e): void => e.stopPropagation()}
/>
),
[],
[columnKey],
);
if (!width || !onResize) {
@@ -64,6 +68,7 @@ function ResizableHeader({
ResizableHeader.defaultProps = {
width: undefined,
onResize: undefined,
columnKey: undefined,
};
export default ResizableHeader;

View File

@@ -132,6 +132,7 @@ export function useResizableColumns<T>({
onHeaderCell: (): ResizableHeaderProps => ({
width,
onResize: key && width ? handleResize(key) : undefined,
columnKey: key,
}),
} as Column<T>;
}),

View File

@@ -104,7 +104,9 @@ function PanelHeader(props: PanelHeaderProps): JSX.Element {
return (
<div className={cx(styles.header, 'panel-drag-handle')}>
<div className={styles.headerLeft}>
<Typography.Text className={styles.headerTitle}>{name}</Typography.Text>
<Typography.Text className={styles.headerTitle} data-testid="panel-title">
{name}
</Typography.Text>
{description && (
<Tooltip title={description}>
<Info

View File

@@ -99,7 +99,7 @@ export const ListPanelAddColumnOpen: Story = {
timeout: 10_000,
}),
);
await screen.findAllByTestId('list-columns-suggestion');
await screen.findAllByTestId(/^list-columns-suggestion-/);
},
};

141
tests/e2e/drivers/dashboard-list.ts generated Normal file
View File

@@ -0,0 +1,141 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* 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 { BASE_PATH } from '../helpers/base-path';
import {
DASHBOARDS_API,
removeTrackedDashboards,
trackDashboard,
} from './dashboards';
import { driver } from './kit';
import { test as editorTest } from './panel-editor';
// The dashboards list page (`/dashboard`): the New dashboard modal's Blank tab
// and the per-row actions menu.
const LIST_PATH = '/dashboard';
/** A dashboard route, not the list or an editor route. */
const DASHBOARD_ROUTE = /\/dashboard\/([^/?#]+)(?:[?#]|$)/;
export interface NewDashboardFields {
name: string;
description?: string;
/** `key:value`, each committed with Enter. */
tags?: string[];
}
export interface DashboardList {
/** Narrowed by the `query` param to one name, since the list is paged. */
open(name?: string): Promise<void>;
newDashboardButton(): Locator;
nameInput(): Locator;
descriptionInput(): Locator;
tagsInput(): Locator;
tagChips(): Locator;
submit(): Locator;
/**
* Fills and submits the Blank tab, then returns the id from the route it
* lands on. The dashboard is deleted when the test ends.
*/
create(fields: NewDashboardFields): Promise<string>;
/** Rows render titles in `dashboard-title-<index>`, not by id. */
rowTitle(name: string): Locator;
/** Opens the row's actions menu and confirms Delete. */
delete(name: string): Promise<void>;
}
function dashboardListDriver(page: Page): DashboardList {
const rowTitle = (name: string): Locator =>
page
.locator('[data-testid^="dashboard-title-"]')
.filter({ hasText: new RegExp(`^${escapeRegExp(name)}$`) });
const list: DashboardList = {
async open(name) {
const search = name
? `?${new URLSearchParams({ query: `name CONTAINS '${name}'` }).toString()}`
: '';
await page.goto(`${LIST_PATH}${search}`);
await expect(list.newDashboardButton()).toBeVisible();
},
newDashboardButton: () => page.getByTestId('new-dashboard-cta'),
nameInput: () => page.getByTestId('create-dashboard-name'),
descriptionInput: () => page.getByTestId('create-dashboard-description'),
tagsInput: () => page.getByTestId('create-dashboard-tags'),
tagChips: () => page.getByTestId('create-dashboard-tags-chip'),
submit: () => page.getByTestId('create-dashboard-submit'),
async create({ name, description, tags = [] }) {
await list.newDashboardButton().click();
await list.nameInput().fill(name);
if (description !== undefined) {
await list.descriptionInput().fill(description);
}
for (const tag of tags) {
await list.tagsInput().fill(tag);
await list.tagsInput().press('Enter');
}
await expect(list.tagChips()).toHaveCount(tags.length);
const [response] = await Promise.all([
page.waitForResponse(
(r) =>
r.request().method() === 'POST' &&
// The app is served under the base path, and so is its API.
new URL(r.url()).pathname === `${BASE_PATH}${DASHBOARDS_API}`,
),
list.submit().click(),
]);
// Tracked from the response, before the route settles, so a failed
// navigation still cleans up.
expect(response.ok(), `POST ${DASHBOARDS_API} failed`).toBe(true);
const created = ((await response.json()) as { data: { id: string } }).data
.id;
trackDashboard(page, created);
await page.waitForURL(DASHBOARD_ROUTE);
const id = DASHBOARD_ROUTE.exec(new URL(page.url()).pathname)?.[1];
expect(id).toBe(created);
return created;
},
rowTitle,
async delete(name) {
const row = rowTitle(name).locator(
'xpath=ancestor::div[.//*[@data-testid="dashboard-action-icon"]][1]',
);
await row.getByTestId('dashboard-action-icon').click();
await page.getByTestId('dashboard-action-delete').click();
// The confirm is an antd `modal.confirm` with no testid.
const confirm = page.locator('.ant-modal-confirm');
await expect(confirm).toContainText(name);
await Promise.all([
page.waitForResponse(
(r) =>
r.request().method() === 'DELETE' &&
r.url().includes(`${DASHBOARDS_API}/`),
),
confirm.getByRole('button', { name: 'Delete', exact: true }).click(),
]);
},
};
return list;
}
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
const withDashboardList = driver(
'dashboardList',
({ page }) => dashboardListDriver(page),
(_, { page }) => removeTrackedDashboards(page),
);
export const test = withDashboardList(editorTest);
export * from './panel-editor';

312
tests/e2e/drivers/dashboard-page.ts generated Normal file
View File

@@ -0,0 +1,312 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* 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 { driver } from './kit';
import { test as panelsTest } from './panels';
// The dashboard page chrome around the grid: breadcrumb, title, toolbar, the
// Actions menu, the JSON drawer and the section headers.
/** Suffixes of the `dashboard-action-<key>` testids on the Actions menu items. */
export const DashboardAction = {
rename: 'rename',
clone: 'clone',
lock: 'lock',
fullScreen: 'fullscreen',
newSection: 'new-section',
delete: 'delete',
} as const;
export type DashboardAction =
(typeof DashboardAction)[keyof typeof DashboardAction];
/** Section menu items carry no testid; their label is the contract. */
export const SectionAction = {
addPanel: 'Add panel',
rename: 'Rename section',
clone: 'Clone section',
delete: 'Delete section',
} as const;
export interface SectionHandle {
root(): Locator;
/** The header button, named by the title; it toggles collapse. */
toggle(): Locator;
/** Panel roots mounted in the section: none while collapsed. */
panels(): Locator;
collapse(): Promise<void>;
expand(): Promise<void>;
openMenu(): Promise<Locator>;
menuItem(label: string): Locator;
/** Opens the rename modal and leaves it open. */
startRename(): Promise<void>;
rename(title: string): Promise<void>;
delete(): Promise<void>;
}
export interface DashboardPage {
breadcrumb(): Locator;
/** The breadcrumb's link back to the list. */
breadcrumbHome(): Locator;
title(): Locator;
documentTitle(): Promise<string>;
tags(): Locator;
tagsOverflow(): Locator;
toolbar: {
actions(): Locator;
configure(): Locator;
json(): Locator;
newPanel(): Locator;
timePicker(): Locator;
/** No testid or accessible name: matched by its wrapper class. */
refresh(): Locator;
autoRefresh(): Locator;
feedback(): Locator;
share(): Locator;
};
actions: {
open(): Promise<Locator>;
close(): Promise<void>;
item(action: DashboardAction): Locator;
run(action: DashboardAction): Promise<void>;
};
rename: {
input(): Locator;
save(): Locator;
cancel(): Locator;
/** Through Actions → Rename, saved with the check button. */
to(name: string): Promise<void>;
};
/** Header icon, rendered once the dashboard has been locked. */
lockToggle(): Locator;
lock(): Promise<void>;
unlock(): Promise<void>;
isFullScreen(): Promise<boolean>;
exitFullScreen(): Promise<void>;
json: {
drawer(): Locator;
open(): Promise<void>;
/** Returns what the Copy button put on the clipboard. */
copy(): Promise<string>;
/** Resolves with the suggested filename of the downloaded file. */
download(): Promise<string>;
};
section(title: string): SectionHandle;
sectionTitles(): Locator;
sectionModal: {
input(): Locator;
submit(): Locator;
cancel(): Locator;
};
/** Through Actions → New section. */
addSection(title: string): Promise<void>;
/** A variable's selector, which shows its current value. */
variableValue(name: string): Locator;
/** Shown in place of the page when the dashboard fetch fails. No testid. */
loadError(): Locator;
navigateSideNav(label: string): Promise<void>;
/** The dashboards list, identified by its create button. */
listPage(): Locator;
/** Collects `pageerror`s from now on. */
collectPageErrors(): { readonly errors: Error[] };
}
function sectionHandle(page: Page, title: string): SectionHandle {
const toggle = (): Locator =>
page.getByRole('button', { name: title, exact: true });
const root = (): Locator =>
page.locator('[data-section-layout-index]').filter({ has: toggle() });
const menu = (): Locator => page.getByRole('menu');
const menuItem = (label: string): Locator =>
menu().getByRole('menuitem', { name: label, exact: true });
const modal = (): Locator => page.getByRole('dialog');
const section: SectionHandle = {
root,
toggle,
panels: () => root().locator('[data-panel-root]'),
async collapse() {
await expect(section.panels().first()).toBeAttached();
await toggle().click();
await expect(section.panels()).toHaveCount(0);
},
async expand() {
await expect(section.panels()).toHaveCount(0);
await toggle().click();
await expect(section.panels().first()).toBeAttached();
},
async openMenu() {
await root().locator('[data-testid^="dashboard-section-actions-"]').click();
await expect(menu()).toBeVisible();
return menu();
},
menuItem,
async startRename() {
await section.openMenu();
await menuItem(SectionAction.rename).click();
await expect(modal()).toBeVisible();
},
async rename(next) {
await section.startRename();
await page.getByTestId('section-title-input').fill(next);
await page.getByTestId('section-title-submit').click();
await expect(modal()).toBeHidden();
},
async delete() {
await section.openMenu();
await menuItem(SectionAction.delete).click();
await page.getByTestId('confirm-delete').click();
await expect(root()).toHaveCount(0);
},
};
return section;
}
function dashboardPageDriver(page: Page): DashboardPage {
const breadcrumb = (): Locator =>
page.getByRole('navigation', { name: 'breadcrumb' });
const actionsMenu = (): Locator => page.getByRole('menu');
const actionItem = (action: DashboardAction): Locator =>
page.getByTestId(`dashboard-action-${action}`);
const jsonDrawer = (): Locator =>
page.getByRole('dialog').filter({ hasText: 'Dashboard JSON' });
const dashboardPage: DashboardPage = {
breadcrumb,
breadcrumbHome: () =>
breadcrumb().getByRole('link', { name: 'Dashboard', exact: true }),
title: () => page.getByTestId('dashboard-title'),
documentTitle: () => page.title(),
tags: () => page.getByTestId('dashboard-tags'),
tagsOverflow: () => page.getByTestId('dashboard-tags-overflow'),
toolbar: {
actions: () => page.getByTestId('options'),
configure: () => page.getByTestId('show-drawer'),
json: () => page.getByTestId('edit-json'),
newPanel: () => page.getByTestId('add-panel-header'),
timePicker: () => page.getByTestId('dropDown'),
refresh: () => page.locator('.refresh-btn button'),
autoRefresh: () => page.getByRole('button', { name: 'Set auto refresh' }),
feedback: () => page.getByRole('button', { name: 'Feedback' }),
share: () => page.getByRole('button', { name: 'Share' }),
},
actions: {
async open() {
await dashboardPage.actions.close();
await dashboardPage.toolbar.actions().click();
await expect(actionsMenu()).toBeVisible();
return actionsMenu();
},
async close() {
if ((await actionsMenu().count()) === 0) {
return;
}
await page.keyboard.press('Escape');
await expect(actionsMenu()).toHaveCount(0);
},
item: actionItem,
async run(action) {
await dashboardPage.actions.open();
await actionItem(action).click();
},
},
rename: {
input: () => page.getByTestId('dashboard-title-input'),
save: () => page.getByTestId('dashboard-title-save'),
cancel: () => page.getByTestId('dashboard-title-cancel'),
async to(name) {
await dashboardPage.actions.run(DashboardAction.rename);
await dashboardPage.rename.input().fill(name);
await dashboardPage.rename.save().click();
await expect(dashboardPage.title()).toHaveText(name);
},
},
lockToggle: () => page.getByTestId('dashboard-lock'),
async lock() {
await dashboardPage.actions.run(DashboardAction.lock);
await expect(dashboardPage.lockToggle()).toHaveAccessibleName(
'Unlock dashboard',
);
},
async unlock() {
await dashboardPage.actions.run(DashboardAction.lock);
await expect(dashboardPage.lockToggle()).toHaveAccessibleName(
'Lock dashboard',
);
},
isFullScreen: () => page.evaluate(() => !!document.fullscreenElement),
async exitFullScreen() {
await page.evaluate(() => document.exitFullscreen());
},
json: {
drawer: jsonDrawer,
async open() {
await dashboardPage.toolbar.json().click();
await expect(jsonDrawer()).toBeVisible();
},
async copy() {
await page
.context()
.grantPermissions(['clipboard-read', 'clipboard-write']);
await page.getByTestId('json-editor-copy').click();
await expect(page.getByText('JSON copied to clipboard')).toBeVisible();
return page.evaluate(() =>
(
globalThis as unknown as {
navigator: { clipboard: { readText(): Promise<string> } };
}
).navigator.clipboard.readText(),
);
},
async download() {
const [file] = await Promise.all([
page.waitForEvent('download'),
page.getByTestId('json-editor-download').click(),
]);
return file.suggestedFilename();
},
},
section: (title) => sectionHandle(page, title),
sectionTitles: () =>
page.locator('[data-testid^="dashboard-section-toggle-"]'),
sectionModal: {
input: () => page.getByTestId('section-title-input'),
submit: () => page.getByTestId('section-title-submit'),
cancel: () => page.getByTestId('section-title-cancel'),
},
async addSection(title) {
await dashboardPage.actions.run(DashboardAction.newSection);
await dashboardPage.sectionModal.input().fill(title);
await dashboardPage.sectionModal.submit().click();
await expect(dashboardPage.section(title).toggle()).toBeVisible();
},
variableValue: (name) => page.getByTestId(`variable-select-${name}`),
loadError: () =>
page.getByRole('heading', { name: 'Failed to load dashboard' }),
async navigateSideNav(label) {
await page
.locator('.nav-item')
.filter({ hasText: new RegExp(`^${label}$`) })
.click();
},
listPage: () => page.getByTestId('new-dashboard-cta'),
collectPageErrors() {
const collected = { errors: [] as Error[] };
page.on('pageerror', (error) => collected.errors.push(error));
return collected;
},
};
return dashboardPage;
}
const withDashboardPage = driver('dashboardPage', ({ page }) =>
dashboardPageDriver(page),
);
export const test = withDashboardPage(panelsTest);
export * from './panels';

571
tests/e2e/drivers/dashboard-settings.ts generated Normal file
View File

@@ -0,0 +1,571 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* 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 {
buildDashboard,
panel,
PanelKind,
type PostableDashboard,
type Signal,
type Variable,
} from '../helpers/dashboard-spec';
import { seedMetricsViaSeeder } from '../helpers/dashboards';
import { PATH } from './dashboards';
import { driver } from './kit';
import { test as panelsTest } from './panels';
// The dashboard toolbar: time picker, variables bar and the Configure drawer.
// ─── Variable fixtures ───────────────────────────────────────────────────
export function textVariable(name: string, value: string): Variable {
return { kind: 'TextVariable', spec: { name, display: { name }, value } };
}
export function customVariable(
name: string,
values: string[],
options: { multi?: boolean; all?: boolean } = {},
): Variable {
return listVariable(name, options, {
kind: 'signoz/CustomVariable',
spec: { customValue: values.join(',') },
});
}
/** `queryValue` is ClickHouse SQL; `{{.name}}` references another variable. */
export function queryVariable(
name: string,
queryValue: string,
options: { multi?: boolean; all?: boolean } = {},
): Variable {
return listVariable(name, options, {
kind: 'signoz/QueryVariable',
spec: { queryValue },
});
}
export function dynamicVariable(
name: string,
attribute: string,
signal: Signal,
): Variable {
return listVariable(
name,
{ multi: true, all: true },
{ kind: 'signoz/DynamicVariable', spec: { name: attribute, signal } },
'alphabetical-asc',
);
}
function listVariable(
name: string,
options: { multi?: boolean; all?: boolean },
plugin: Extract<Variable, { kind: 'ListVariable' }>['spec']['plugin'],
sort: 'none' | 'alphabetical-asc' = 'none',
): Variable {
return {
kind: 'ListVariable',
spec: {
name,
display: { name },
allowAllValue: options.all ?? false,
allowMultiple: options.multi ?? false,
sort,
plugin,
},
};
}
export const BAR_VARIABLES = {
textEnv: 'tb_env',
textService: 'tb_service',
single: 'cu_single',
all: 'cu_all',
multi: 'cu_multi',
queryParent: 'q_env',
queryChild: 'q_service',
queryEmpty: 'q_empty',
dynamic: 'd_service',
} as const;
export const BAR_VALUES = {
textEnv: 'otel-demo',
textService: 'frontend',
envs: ['otel-demo', 'mq-kafka', 'production'],
services: ['adservice', 'frontend', 'checkout'],
// Short enough that the child's chip is not truncated.
queryEnvs: ['prod', 'stage'],
} as const;
/** Each child option is its parent's value plus this suffix. */
export const QUERY_CHILD_SUFFIX = '-svc';
/** One variable per control the bar renders, in spec order. */
export function variablesBarDashboard(
options: { dynamicAttribute?: string } = {},
): PostableDashboard {
const v = BAR_VARIABLES;
return buildDashboard({
title: `e2e-variables-bar-${Date.now()}-${process.pid}`,
variables: [
textVariable(v.textEnv, BAR_VALUES.textEnv),
textVariable(v.textService, BAR_VALUES.textService),
customVariable(v.single, [...BAR_VALUES.envs]),
customVariable(v.all, [...BAR_VALUES.envs], { multi: true, all: true }),
customVariable(v.multi, [...BAR_VALUES.services], { multi: true }),
queryVariable(
v.queryParent,
`SELECT arrayJoin(['${BAR_VALUES.queryEnvs.join("', '")}'])`,
),
queryVariable(
v.queryChild,
`SELECT concat({{.${v.queryParent}}}, '${QUERY_CHILD_SUFFIX}')`,
{ multi: true },
),
queryVariable(v.queryEmpty, "SELECT 'none' WHERE 1 = 0", { multi: true }),
dynamicVariable(
v.dynamic,
options.dynamicAttribute ?? 'service.name',
'metrics',
),
],
// Queryless, so opening the dashboard issues no panel query.
sections: [
{
title: 'Notes',
panels: { notes: panel(PanelKind.Text, { name: 'Notes' }) },
},
],
});
}
// ─── Time picker ─────────────────────────────────────────────────────────
export const REFRESH_INTERVALS = [
'5 seconds',
'10 seconds',
'30 seconds',
'1 minute',
'5 minutes',
'10 minutes',
'30 minutes',
'1 hour',
'2 hours',
'1 day',
] as const;
export type RefreshInterval = (typeof REFRESH_INTERVALS)[number];
export interface TimePicker {
/** Its accessible name is the selected range's label. */
input(): Locator;
/** The offset badge, e.g. 'UTC + 9:00'. */
timezoneBadge(): Locator;
open(): Promise<void>;
/** `value` is the preset's key, e.g. '1h'. */
selectPreset(value: string): Promise<void>;
/** Picks two days of the previous month, then applies. */
selectPreviousMonthRange(fromDay: number, toDay: number): Promise<void>;
/** Picks the first zone matching `search`. */
selectTimezone(search: string, name: string): Promise<void>;
refresh(): Locator;
autoRefresh: {
open(): Promise<void>;
checkbox(): Locator;
interval(label: RefreshInterval): Locator;
/** The interval carrying the check mark. */
selected(): Locator;
};
}
function ordinal(day: number): string {
const tens = day % 100;
if (tens >= 11 && tens <= 13) {
return `${day}th`;
}
const suffix = { 1: 'st', 2: 'nd', 3: 'rd' }[day % 10] ?? 'th';
return `${day}${suffix}`;
}
function timePickerDriver(page: Page): TimePicker {
const input = (): Locator => page.getByTestId('dropDown');
const options = (): Locator => page.locator('.date-time-popover');
// The timezone footer sits outside `.date-time-popover`.
const popover = (): Locator => page.locator('.date-time-root');
const autoRefreshMenu = (): Locator => page.locator('.auto-refresh-menu');
const picker: TimePicker = {
input,
timezoneBadge: () => page.locator('.timezone-badge'),
async open() {
// A click before the picker is interactive is dropped silently.
await expect(async () => {
if (!(await options().isVisible())) {
await input().click();
}
await expect(options()).toBeVisible({ timeout: 3_000 });
}).toPass({ timeout: 30_000 });
},
async selectPreset(value) {
const option = page.getByTestId(`time-option-${value}`);
// A click on a popover still animating closed is dropped, so retry.
await expect(async () => {
await picker.open();
await option.click();
await expect(option).toBeHidden({ timeout: 3_000 });
}).toPass({ timeout: 30_000 });
},
async selectPreviousMonthRange(fromDay, toDay) {
await picker.open();
await page.getByTestId('time-option-custom').click();
await popover()
.getByRole('button', { name: 'Go to the Previous Month' })
.click();
const day = (n: number): Locator =>
popover()
.getByRole('gridcell', { name: new RegExp(`\\b${ordinal(n)}\\b`) })
.first();
await day(fromDay).click();
await day(toDay).click();
await popover().getByRole('button', { name: 'Apply' }).click();
await expect(options()).toBeHidden();
},
async selectTimezone(search, name) {
await picker.open();
await popover().getByRole('button', { name: 'Change Timezone' }).click();
await popover().getByPlaceholder('Search timezones...').fill(search);
await popover()
.getByRole('button', { name: new RegExp(`^${name}\\b`) })
.click();
},
// The refresh button has neither a test id nor an accessible name.
refresh: () => page.locator('.refresh-btn button'),
autoRefresh: {
async open() {
await page.getByRole('button', { name: 'Set auto refresh' }).click();
await expect(autoRefreshMenu()).toBeVisible();
},
checkbox: () =>
autoRefreshMenu().getByRole('checkbox', { name: 'Auto Refresh' }),
interval: (label) =>
autoRefreshMenu().getByRole('button', { name: label, exact: true }),
selected: () =>
autoRefreshMenu()
.locator('.refresh-interval-btns')
.filter({
has: page.locator('svg'),
}),
},
};
return picker;
}
// ─── Variables bar ───────────────────────────────────────────────────────
export type SharedSelection = Record<string, string | string[]>;
export interface VariablesBar {
root(): Locator;
/** Overflowing variables stay hidden until the bar is expanded. */
expand(): Promise<void>;
/**
* Opens the dashboard through a share link's `?variables=`, as a returning
* visitor: on a first visit the auto-selected defaults overwrite the link's
* values (see `firstVisitWith`).
*/
openWith(id: string, selection: SharedSelection): Promise<void>;
/** The share link as a first-time visitor opens it. */
firstVisitWith(id: string, selection: SharedSelection): Promise<void>;
/** The `$name` labels in render order. */
names(): Promise<string[]>;
label(name: string): Locator;
variable(name: string): Locator;
textbox(name: string): Locator;
/** Commits on Enter. */
type(name: string, value: string): Promise<void>;
select(name: string): Locator;
open(name: string): Promise<void>;
/** Multi-selects commit when the dropdown closes. */
close(name: string): Promise<void>;
listbox(): Locator;
option(label: string): Locator;
/** Single-select: picks one option. */
pick(name: string, value: string): Promise<void>;
/** Multi-select: toggles each value, then closes to commit. */
toggle(name: string, values: string[]): Promise<void>;
chip(name: string, value: string): Locator;
chips(name: string): Locator;
/** The selection a share link carries when variables are included. */
shared(): Promise<SharedSelection>;
addVariable(): Locator;
/**
* Writes one sample per value under a metric label, for a dynamic variable
* to resolve. The golden dataset leaves the attribute metadata empty.
*/
seedMetricLabel(label: string, values: string[]): Promise<void>;
}
function variablesBarDriver(page: Page): VariablesBar {
const root = (): Locator => page.getByTestId('dashboard-variables-bar');
const variable = (name: string): Locator =>
page.getByTestId(`variable-${name}`);
const select = (name: string): Locator =>
page.getByTestId(`variable-select-${name}`);
const listbox = (): Locator =>
page.getByRole('listbox').locator('visible=true');
const more = (): Locator => page.getByTestId('dashboard-variables-more');
const bar: VariablesBar = {
root,
async expand() {
await expect(root()).toBeVisible();
if ((await more().getAttribute('aria-expanded')) !== 'true') {
await more().click();
}
await expect(more()).toHaveAttribute('aria-expanded', 'true');
},
async openWith(id, selection) {
await page.goto(PATH.dashboard(id));
await expect(root()).toBeVisible();
await bar.firstVisitWith(id, selection);
},
async firstVisitWith(id, selection) {
const search = new URLSearchParams({
relativeTime: '30m',
variables: JSON.stringify(selection),
});
await page.goto(`${PATH.dashboard(id)}?${search.toString()}`);
await expect(root()).toBeVisible();
},
async names() {
await expect(root()).toBeVisible();
return root().locator('[title^="$"]').allTextContents();
},
label: (name) => variable(name).getByTitle(`$${name}`, { exact: true }),
variable,
textbox: (name) => page.getByTestId(`variable-input-${name}`),
async type(name, value) {
const input = bar.textbox(name);
await input.fill(value);
await input.press('Enter');
},
select,
async open(name) {
await select(name).click();
await expect(listbox()).toBeVisible();
},
async close(name) {
// The multi-select swallows Escape, so click outside it.
await bar.label(name).click();
await expect(listbox()).toBeHidden();
},
listbox,
option: (label) =>
listbox().getByRole('option', { name: label, exact: true }),
async pick(name, value) {
await bar.open(name);
await bar.option(value).click();
await expect(listbox()).toBeHidden();
},
async toggle(name, values) {
await bar.open(name);
for (const value of values) {
await bar.option(value).click();
}
await bar.close(name);
},
chip: (name, value) =>
variable(name).getByRole('button', {
name: `Remove tag ${value}`,
exact: true,
}),
chips: (name) => variable(name).getByRole('button', { name: /^Remove tag / }),
async shared() {
// The link is copied through a transient selection, never shown.
await page.evaluate(() => {
document.addEventListener(
'copy',
() => {
(globalThis as { e2eCopied?: string }).e2eCopied = String(
globalThis.getSelection(),
);
},
{ capture: true, once: true },
);
});
await page.getByRole('button', { name: 'Share' }).click();
const modal = page.locator('.share-modal-content');
const include = modal
.locator('.absolute-relative-time-toggler-container')
.filter({ hasText: 'Include variables' })
.getByRole('switch');
await include.click();
await expect(include).toBeChecked();
await modal.getByRole('button', { name: 'Copy page link' }).click();
const link = await page.evaluate(
() => (globalThis as { e2eCopied?: string }).e2eCopied ?? '',
);
const raw = new URL(link).searchParams.get('variables');
expect(raw, `no variables in the shared link ${link}`).not.toBeNull();
return JSON.parse(raw as string) as SharedSelection;
},
addVariable: () => root().getByRole('button', { name: 'Add variable' }),
async seedMetricLabel(label, values) {
const timestamp = new Date(Date.now() - 60_000).toISOString();
await seedMetricsViaSeeder(
page,
values.map((value) => ({
metric_name: 'e2e_dynamic_variable',
labels: { [label]: value },
timestamp,
value: 1,
temporality: 'Cumulative',
type_: 'Sum',
is_monotonic: true,
})),
);
},
};
return bar;
}
// ─── Configure drawer ────────────────────────────────────────────────────
export type SettingsTab = 'Overview' | 'Variables' | 'Publish';
export type VariableTypeTab = 'dynamic' | 'textbox' | 'custom' | 'query';
export interface DashboardSettings {
configure(): Locator;
open(): Promise<void>;
close(): Promise<void>;
dialog(): Locator;
heading(): Locator;
tab(name: SettingsTab): Locator;
openTab(name: SettingsTab): Promise<void>;
panel(): Locator;
overview: {
name(): Locator;
description(): Locator;
tagInput(): Locator;
addTag(tag: string): Promise<void>;
unsaved(): Locator;
save(): Locator;
discard(): Locator;
};
variables: {
list(): Locator;
row(name: string): Locator;
add(): Locator;
edit(name: string): Promise<void>;
/** Confirms inline. */
remove(name: string): Promise<void>;
};
form: {
name(): Locator;
type(type: VariableTypeTab): Locator;
customValues(): Locator;
textDefault(): Locator;
queryEditor(): Locator;
error(text: string): Locator;
save(): Locator;
discard(): Locator;
};
publish: {
text(text: string): Locator;
timeRange(): Locator;
publish(): Locator;
};
}
function settingsDriver(page: Page): DashboardSettings {
const dialog = (): Locator =>
page.getByRole('dialog').filter({ hasText: 'Dashboard Configuration' });
const panelLocator = (): Locator => dialog().getByRole('tabpanel').first();
const settings: DashboardSettings = {
configure: () => page.getByTestId('show-drawer'),
async open() {
await settings.configure().click();
await expect(dialog()).toBeVisible();
},
async close() {
await dialog().getByRole('button', { name: 'Close' }).click();
await expect(dialog()).toBeHidden();
},
dialog,
heading: () => dialog().getByText('Dashboard Configuration'),
tab: (name) => dialog().getByRole('tab', { name, exact: true }),
async openTab(name) {
await settings.tab(name).click();
await expect(settings.tab(name)).toHaveAttribute('aria-selected', 'true');
},
panel: panelLocator,
overview: {
name: () => dialog().getByTestId('dashboard-name'),
description: () => dialog().getByTestId('dashboard-desc'),
tagInput: () => dialog().getByTestId('tag-key-value-input'),
async addTag(tag) {
const input = settings.overview.tagInput();
await input.fill(tag);
await input.press('Enter');
await expect(
dialog().getByTestId('tag-key-value-input-chip').filter({ hasText: tag }),
).toBeVisible();
},
unsaved: () => dialog().getByText(/unsaved changes?$/),
save: () => dialog().getByTestId('save-dashboard-config'),
discard: () => dialog().getByRole('button', { name: 'Discard' }),
},
variables: {
list: () => dialog().getByTestId('variables-list'),
row: (name) => dialog().getByTestId(`variable-row-${name}`),
add: () => dialog().getByTestId('add-variable'),
async edit(name) {
await dialog().getByTestId(`variable-edit-${name}`).click();
await expect(settings.form.name()).toBeVisible();
},
async remove(name) {
await dialog().getByTestId(`variable-delete-${name}`).click();
await dialog().getByTestId(`variable-delete-confirm-${name}`).click();
await expect(settings.variables.row(name)).toHaveCount(0);
},
},
form: {
name: () => dialog().getByTestId('variable-name'),
type: (type) => dialog().getByTestId(`variable-type-${type}`),
customValues: () => dialog().getByTestId('variable-custom-input'),
textDefault: () => dialog().getByTestId('variable-text-input'),
// Monaco renders a textbox named "Editor content" with no test id.
queryEditor: () => dialog().locator('.monaco-editor'),
error: (text) => dialog().getByText(text),
save: () => dialog().getByTestId('variable-save'),
discard: () => dialog().getByRole('button', { name: 'Discard' }),
},
publish: {
text: (text) => panelLocator().getByText(text),
timeRange: () =>
panelLocator().getByRole('switch', { name: 'Enable time range' }),
publish: () =>
panelLocator().getByRole('button', { name: 'Publish Dashboard' }),
},
};
return settings;
}
const withTimePicker = driver('timePicker', ({ page }) =>
timePickerDriver(page),
);
const withVariablesBar = driver('variablesBar', ({ page }) =>
variablesBarDriver(page),
);
const withSettings = driver('settings', ({ page }) => settingsDriver(page));
export const test = withSettings(withVariablesBar(withTimePicker(panelsTest)));
export * from './panels';

355
tests/e2e/drivers/dashboards.ts generated Normal file
View File

@@ -0,0 +1,355 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
*/
import {
expect,
type Locator,
type Page,
type Request,
type Route,
} from '@playwright/test';
import { authToken } from '../helpers/common';
import type { PanelKind, PostableDashboard } from '../helpers/dashboard-spec';
import {
mockQueryRange,
mockQueryRangeError,
mockQueryRangeSequence,
type MockOptions,
type QueryRangeBody,
unmockQueryRange,
} from '../helpers/query-range-mock';
import { driver, kit } from './kit';
// `POST /api/v2/dashboards` takes the full perses spec in one call.
export const DASHBOARDS_API = '/api/v2/dashboards';
const QUERY_RANGE_GLOB = '**/api/v5/query_range';
export const PATH = {
dashboard: (id: string): string => `/dashboard/${id}`,
/** `panelId` is 'new' for creation. */
editor: (id: string, panelId: string): string =>
`/dashboard/${id}/panel/${panelId}`,
/** Matches the dashboard route itself, with or without a query string. */
onDashboard: (id: string): RegExp => new RegExp(`/dashboard/${id}(\\?|$)`),
/** Matches the dashboard route once the View modal has written its params. */
onDashboardWithParams: (id: string): RegExp =>
new RegExp(`/dashboard/${id}\\?`),
newPanel: /\/panel\/new\?/,
} as const;
/**
* `panel-editor-v2` is not unique: the ResizablePanelGroup derives the same
* testid from its `id`, which is also its localStorage layout key.
*/
export const EDITOR_ROOT = '[data-testid="panel-editor-v2"]:not([data-group])';
export interface GettableDashboard {
id: string;
name: string;
locked: boolean;
tags: { key: string; value: string }[];
spec: PostableDashboard['spec'];
}
export interface Dashboards {
/** Created for this test and deleted when it ends. */
seed(dashboard: PostableDashboard): Promise<string>;
seedAndOpen(dashboard: PostableDashboard): Promise<string>;
seedAndEdit(dashboard: PostableDashboard, panelId: string): Promise<string>;
/** Deletes a dashboard the UI created when the test ends. */
track(id: string): void;
/** The open dashboard's heading. */
title(): Locator;
/** Waits for the grid, not just the shell. */
open(id: string): Promise<void>;
/** For a dashboard with no panels: waits for the empty state instead. */
openEmpty(id: string): Promise<void>;
openEditor(id: string, panelId: string): Promise<void>;
/** `kind` is required: without it the route bounces to the dashboard. */
openNewPanel(id: string, kind: PanelKind, layoutIndex?: number): Promise<void>;
/** Navigates without waiting for anything, for routes expected to redirect. */
visit(path: string): Promise<void>;
reload(): Promise<void>;
get(id: string): Promise<GettableDashboard>;
setLocked(id: string, locked: boolean): Promise<void>;
/** A panel's grid geometry as persisted, not as rendered. */
gridItem(id: string, panelId: string): Promise<GridGeometry>;
/** A query param of the current URL, where the time window lives. */
param(name: string): string | null;
emptyAddPanel(): Locator;
headerAddPanel(): Locator;
zoomOut(): Locator;
variable(name: string): Locator;
confirmDelete(): Locator;
/** Free text anywhere on the page, e.g. a confirm dialog's title. */
text(text: string): Locator;
}
export interface GridGeometry {
x: number;
y: number;
width: number;
height: number;
}
export interface QueryRangeMock {
/** Fulfils every call with one body. Install before navigating. */
respond(body: QueryRangeBody, options?: MockOptions): Promise<void>;
fail(
message?: string,
options?: MockOptions & { status?: number },
): Promise<void>;
/** One body per call, repeating the last. */
sequence(bodies: QueryRangeBody[]): Promise<void>;
/** Holds every call until `release` is called, then serves `body`. */
hold(body: QueryRangeBody): Promise<{ release: () => void }>;
/** Drops interception so later calls reach the backend. */
reset(): Promise<void>;
/** Resolves with the next query_range request. */
nextRequest(): Promise<Request>;
/** The paging of the first query in the next query_range request. */
nextPaging(): Promise<{ offset?: number; limit?: number }>;
/** Counts query_range requests from now on. */
countRequests(): { readonly count: number };
}
async function bearer(page: Page): Promise<{ Authorization: string }> {
return { Authorization: `Bearer ${await authToken(page)}` };
}
/**
* A 400 is almost always the strict decoder rejecting a stray key; the body
* names the field, so it is surfaced verbatim.
*/
async function createViaApi(
page: Page,
dashboard: PostableDashboard,
): Promise<string> {
const res = await page.request.post(DASHBOARDS_API, {
data: dashboard,
headers: await bearer(page),
});
if (!res.ok()) {
throw new Error(
`POST ${DASHBOARDS_API} ${res.status()}: ${await res.text()}`,
);
}
return ((await res.json()) as { data: GettableDashboard }).data.id;
}
export async function getViaApi(
page: Page,
id: string,
): Promise<GettableDashboard> {
const res = await page.request.get(`${DASHBOARDS_API}/${id}`, {
headers: await bearer(page),
});
if (!res.ok()) {
throw new Error(
`GET ${DASHBOARDS_API}/${id} ${res.status()}: ${await res.text()}`,
);
}
return ((await res.json()) as { data: GettableDashboard }).data;
}
/** Same path, no body: PUT locks, DELETE unlocks. */
async function setLockedViaApi(
page: Page,
id: string,
locked: boolean,
): Promise<void> {
const url = `${DASHBOARDS_API}/${id}/lock`;
const headers = await bearer(page);
const res = locked
? await page.request.put(url, { headers })
: await page.request.delete(url, { headers });
if (!res.ok()) {
throw new Error(
`${locked ? 'PUT' : 'DELETE'} ${url} ${res.status()}: ${await res.text()}`,
);
}
}
const trackedByPage = new WeakMap<Page, string[]>();
/** Registers a dashboard for deletion when the test owning `page` ends. */
export function trackDashboard(page: Page, id: string): void {
trackedByPage.set(page, [...(trackedByPage.get(page) ?? []), id]);
}
/**
* Not asserted: a test that deleted its own dashboard, or left a closed page
* behind, must not fail on cleanup.
*/
export async function removeTrackedDashboards(page: Page): Promise<void> {
const ids = trackedByPage.get(page) ?? [];
trackedByPage.delete(page);
if (ids.length === 0) {
return;
}
try {
const headers = await bearer(page);
for (const id of ids) {
await page.request
.delete(`${DASHBOARDS_API}/${id}`, { headers })
.catch(() => undefined);
}
} catch {
/* the dashboards outlive the run rather than hiding why it failed */
}
}
function dashboardsDriver(page: Page): Dashboards {
const editorRoot = (): Locator => page.locator(EDITOR_ROOT);
const dashboards: Dashboards = {
async seed(dashboard) {
const id = await createViaApi(page, dashboard);
trackDashboard(page, id);
return id;
},
track: (id) => trackDashboard(page, id),
title: () => page.getByTestId('dashboard-title'),
async seedAndOpen(dashboard) {
const id = await dashboards.seed(dashboard);
await dashboards.open(id);
return id;
},
async seedAndEdit(dashboard, panelId) {
const id = await dashboards.seed(dashboard);
await dashboards.openEditor(id, panelId);
return id;
},
async open(id) {
await page.goto(PATH.dashboard(id));
await page
.locator('[data-panel-root]')
.first()
.waitFor({ state: 'attached' });
},
async openEmpty(id) {
await page.goto(PATH.dashboard(id));
await expect(dashboards.emptyAddPanel()).toBeVisible();
},
async openEditor(id, panelId) {
await page.goto(PATH.editor(id, panelId));
await expect(editorRoot()).toBeVisible();
},
async openNewPanel(id, kind, layoutIndex = 0) {
const search = new URLSearchParams({
panelKind: kind,
layoutIndex: String(layoutIndex),
});
await page.goto(`${PATH.editor(id, 'new')}?${search.toString()}`);
await expect(editorRoot()).toBeVisible();
},
async visit(path) {
await page.goto(path);
},
async reload() {
await page.reload();
},
get: (id) => getViaApi(page, id),
setLocked: (id, locked) => setLockedViaApi(page, id, locked),
async gridItem(id, panelId) {
const { spec } = await getViaApi(page, id);
for (const layout of spec.layouts) {
const item = layout.spec.items.find(
(candidate) => candidate.content.$ref === `#/spec/panels/${panelId}`,
);
if (item) {
return { x: item.x, y: item.y, width: item.width, height: item.height };
}
}
throw new Error(`no grid item for panel ${panelId}`);
},
param: (name) => new URL(page.url()).searchParams.get(name),
emptyAddPanel: () => page.getByTestId('add-panel'),
headerAddPanel: () => page.getByTestId('add-panel-header'),
zoomOut: () => page.getByTestId('zoom-out-btn'),
variable: (name) => page.getByTestId(`variable-${name}`),
confirmDelete: () => page.getByTestId('confirm-delete'),
text: (text) => page.getByText(text),
};
return dashboards;
}
function queryRangeDriver(page: Page): QueryRangeMock {
const isQueryRange = (url: string): boolean => url.includes('/query_range');
return {
respond: (body, options) => mockQueryRange(page, body, options),
fail: (message, options) => mockQueryRangeError(page, message, options),
sequence: (bodies) => mockQueryRangeSequence(page, bodies),
async hold(body) {
let release: () => void = () => undefined;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
await page.route(QUERY_RANGE_GLOB, async (route: Route) => {
await gate;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body),
});
});
return { release: () => release() };
},
reset: () => unmockQueryRange(page),
nextRequest: () => page.waitForRequest((r) => isQueryRange(r.url())),
async nextPaging() {
const request = await page.waitForRequest((r) => isQueryRange(r.url()));
const body = request.postDataJSON() as {
compositeQuery?: {
queries?: { spec?: { offset?: number; limit?: number } }[];
};
} | null;
const spec = body?.compositeQuery?.queries?.[0]?.spec;
return { offset: spec?.offset, limit: spec?.limit };
},
countRequests() {
const counter = { count: 0 };
page.on('request', (request) => {
if (isQueryRange(request.url())) {
counter.count += 1;
}
});
return counter;
},
};
}
const withDashboards = driver(
'dashboards',
({ page }) => dashboardsDriver(page),
(_, { page }) => removeTrackedDashboards(page),
);
const withQueryRange = driver('queryRange', ({ page }) =>
queryRangeDriver(page),
);
export const test = withQueryRange(withDashboards(kit));
export { expect } from './kit';
export * from '../helpers/dashboard-spec';
export {
flat,
type HeatmapPoint,
heatmapPoints,
type HeatmapSeriesSpec,
QueryRange,
type QueryRangeBody,
ramp,
type SeriesSpec,
timestamps,
type TimeSeriesPoint,
} from '../helpers/query-range-mock';
export * from '../testdata/dashboards/panels';

View File

@@ -27,10 +27,15 @@ export type WorkerLayer<Adds> = <T extends object, W extends object>(
base: TestType<T, W>,
) => TestType<T, W & Adds>;
/** A `withX` layer binding `build` to the authenticated page. */
/**
* A `withX` layer binding `build` to the authenticated page. `dispose` runs
* after the test while the page is still open, so it can clean up over the
* same session.
*/
export function driver<Name extends string, T>(
name: Name,
build: (ctx: DriverContext) => T,
dispose?: (value: T, ctx: DriverContext) => Promise<void>,
): Layer<{ [K in Name]: T }> {
const fixture = {
// Playwright reads fixture dependencies from the source text, so
@@ -39,7 +44,11 @@ export function driver<Name extends string, T>(
{ authedPage }: { authedPage: Page },
use: (value: T) => Promise<void>,
) => {
await use(build({ page: authedPage }));
const value = build({ page: authedPage });
await use(value);
if (dispose) {
await dispose(value, { page: authedPage });
}
},
};
return ((test: TestType<Fixtures, Fixtures>) =>

278
tests/e2e/drivers/panel-config.ts generated Normal file
View File

@@ -0,0 +1,278 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* 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 type {
ContextLink,
PanelPluginSpec,
Threshold,
} from '../helpers/dashboard-spec';
import { SINGLE_PANEL_ID } from '../testdata/dashboards/panels';
import { getViaApi } from './dashboards';
import { driver } from './kit';
import { test as editorTest } from './panel-editor';
// The panel editor's per-kind config editors: thresholds, context links,
// per-column units and the List columns editor.
/**
* Variant follows panel kind, not a user control: label → TimeSeries/Bar,
* comparison → Number, table → Table. Each has its own add button and row
* testid prefix.
*/
export type ThresholdVariant = 'label' | 'comparison' | 'table';
const THRESHOLD_ADD_TESTID: Record<ThresholdVariant, string> = {
label: 'panel-editor-v2-add-threshold',
comparison: 'panel-editor-v2-add-comparison-threshold',
table: 'panel-editor-v2-add-table-threshold',
};
const THRESHOLD_ROW_PREFIX: Record<ThresholdVariant, string> = {
label: 'threshold',
comparison: 'comparison-threshold',
table: 'table-threshold',
};
export type ThresholdOperator =
| 'above'
| 'above_or_equal'
| 'below'
| 'below_or_equal'
| 'equal'
| 'not_equal';
export type ThresholdFormat = 'background' | 'text';
export interface ThresholdRow {
value(): Locator;
/** Label variant only. */
label(): Locator;
edit(): Locator;
save(): Locator;
discard(): Locator;
remove(): Locator;
/** Operator and format are radio tiles keyed by the persisted value. */
setOperator(operator: ThresholdOperator): Promise<void>;
setFormat(format: ThresholdFormat): Promise<void>;
/** Table variant only: the column keys come from the live result. */
pickFirstColumn(): Promise<void>;
}
export interface Thresholds {
addButton(variant: ThresholdVariant): Locator;
/** The section header's quick-add, which also expands a collapsed section. */
headerAdd(): Locator;
row(variant: ThresholdVariant, index: number): ThresholdRow;
}
export interface ContextLinks {
addButton(): Locator;
headerAdd(): Locator;
dialog(): Locator;
label(): Locator;
url(): Locator;
save(): Locator;
cancel(): Locator;
addParam(): Locator;
paramKey(index: number): Locator;
paramValue(index: number): Locator;
item(index: number): Locator;
edit(index: number): Locator;
remove(index: number): Locator;
/**
* Closes the URL field's suggestion popover, which otherwise keeps the
* dialog reflowing so buttons never settle. Escape would dismiss the whole
* dialog.
*/
blurFields(): Promise<void>;
}
export interface ListColumns {
editor(): Locator;
chip(name: string): Locator;
remove(name: string): Locator;
addButton(): Locator;
search(): Locator;
/** The explicit "Add …" entry, so a typo can't be committed by blurring. */
addCustom(): Locator;
/** Opens the dropdown and types `term`. */
open(term: string): Promise<void>;
/**
* Clicks the first suggestion once the results for `term` have landed;
* returns its field name.
*/
pickFirstSuggestion(term: string): Promise<string>;
}
export interface TableCells {
/** The last cell of the first row, where the aggregated value lands. */
valueCell(): Locator;
/** Inline background, set only while a background threshold matches. */
background(cell: Locator): Promise<string>;
/** Spans the text format recolours. */
coloredText(cell: Locator): Locator;
}
export interface SavedPanel {
plugin: PanelPluginSpec;
links: ContextLink[];
}
export interface PanelConfig {
thresholds: Thresholds;
contextLinks: ContextLinks;
listColumns: ListColumns;
columnUnits: {
any(): Locator;
/** The first per-column unit select, with the column key it targets. */
first(): Promise<{ testId: string; key: string }>;
};
/** The plugin spec and links as persisted, straight from the API. */
saved(dashboardId: string, panelId?: string): Promise<SavedPanel>;
savedThresholds(dashboardId: string, panelId?: string): Promise<Threshold[]>;
numberValue(): Locator;
/** Inline colour on the rendered Number value, set only while a threshold matches. */
numberValueColor(): Promise<string>;
table: TableCells;
text(text: string): Locator;
}
const COLUMN_UNIT_PREFIX = 'panel-editor-v2-column-unit-';
const SUGGESTION_PREFIX = 'list-columns-suggestion-';
function thresholdRow(
page: Page,
variant: ThresholdVariant,
index: number,
): ThresholdRow {
const prefix = THRESHOLD_ROW_PREFIX[variant];
const byTestId = (part: string): Locator =>
page.getByTestId(`${prefix}-${part}-${index}`);
return {
value: () => byTestId('value'),
label: () => byTestId('label'),
edit: () => byTestId('edit'),
save: () => byTestId('save'),
discard: () => byTestId('discard'),
remove: () => byTestId('remove'),
async setOperator(operator) {
await page.getByTestId(`${prefix}-operator-${index}-${operator}`).click();
},
async setFormat(format) {
await page.getByTestId(`${prefix}-format-${index}-${format}`).click();
},
async pickFirstColumn() {
await byTestId('column').click();
await page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
.locator('.ant-select-item-option')
.first()
.click();
},
};
}
function panelConfigDriver(page: Page): PanelConfig {
const numberValue = (): Locator => page.getByTestId('number-panel-value');
const contextLinkLabel = (): Locator => page.getByTestId('context-link-label');
const columnUnits = (): Locator =>
page.locator(`[data-testid^="${COLUMN_UNIT_PREFIX}"]`);
const config: PanelConfig = {
thresholds: {
addButton: (variant) => page.getByTestId(THRESHOLD_ADD_TESTID[variant]),
headerAdd: () => page.getByTestId('panel-editor-v2-add-threshold-header'),
row: (variant, index) => thresholdRow(page, variant, index),
},
contextLinks: {
addButton: () => page.getByTestId('panel-editor-v2-add-link'),
headerAdd: () => page.getByTestId('panel-editor-v2-add-link-header'),
dialog: () => page.getByTestId('context-link-dialog'),
label: contextLinkLabel,
url: () => page.getByTestId('context-link-url'),
save: () => page.getByTestId('context-link-save'),
cancel: () => page.getByTestId('context-link-cancel'),
addParam: () => page.getByTestId('context-link-add-param'),
paramKey: (index) => page.getByTestId(`context-link-param-key-${index}`),
paramValue: (index) => page.getByTestId(`context-link-param-value-${index}`),
item: (index) => page.getByTestId(`context-link-item-${index}`),
edit: (index) => page.getByTestId(`context-link-edit-${index}`),
remove: (index) => page.getByTestId(`context-link-remove-${index}`),
async blurFields() {
await contextLinkLabel().focus();
},
},
listColumns: {
editor: () => page.getByTestId('list-columns-editor'),
chip: (name) => page.getByTestId(`list-column-chip-${name}`),
remove: (name) => page.getByTestId(`list-column-remove-${name}`),
addButton: () => page.getByTestId('list-columns-add'),
search: () => page.getByTestId('list-columns-search'),
addCustom: () => page.getByTestId('list-columns-add-custom'),
async open(term) {
await page.getByTestId('list-columns-add').click();
await page.getByTestId('list-columns-search').fill(term);
},
async pickFirstSuggestion(term) {
const first = page.locator(`[data-testid^="${SUGGESTION_PREFIX}"]`).first();
const firstName = async (): Promise<string> =>
((await first.getAttribute('data-testid')) ?? '').replace(
SUGGESTION_PREFIX,
'',
);
// Search is debounced, so the previous results stay up until this
// term's land; and the list repaints as they do, so the row is
// clicked by its own testid rather than by position.
await expect(page.getByText('Loading…')).toHaveCount(0);
await expect.poll(firstName).toContain(term);
const name = await firstName();
await page.getByTestId(`${SUGGESTION_PREFIX}${name}`).click();
return name;
},
},
columnUnits: {
any: () => columnUnits().first(),
async first() {
const testId =
(await columnUnits().first().getAttribute('data-testid')) ?? '';
return { testId, key: testId.replace(COLUMN_UNIT_PREFIX, '') };
},
},
async saved(dashboardId, panelId = SINGLE_PANEL_ID) {
const { spec } = await getViaApi(page, dashboardId);
const panel = spec.panels[panelId].spec;
return { plugin: panel.plugin.spec, links: panel.links ?? [] };
},
async savedThresholds(dashboardId, panelId) {
return (await config.saved(dashboardId, panelId)).plugin.thresholds ?? [];
},
numberValue,
numberValueColor: () =>
numberValue().evaluate((node) => (node as HTMLElement).style.color),
table: {
valueCell: () =>
page
.getByTestId('table-panel-renderer')
.locator('tbody tr.ant-table-row')
.first()
.locator('td')
.last(),
background: (cell) =>
cell.evaluate((node) => (node as HTMLElement).style.backgroundColor),
coloredText: (cell) => cell.locator('span[style*="color"]'),
},
text: (text) => page.getByText(text),
};
return config;
}
const withPanelConfig = driver('config', ({ page }) => panelConfigDriver(page));
export const test = withPanelConfig(editorTest);
export * from './panel-editor';

296
tests/e2e/drivers/panel-editor.ts generated Normal file
View File

@@ -0,0 +1,296 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* 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 { PanelKind } from '../helpers/dashboard-spec';
import { EDITOR_ROOT } from './dashboards';
import { driver } from './kit';
import { newPanelDriver } from './panel-editor/picker';
import { type QueryPane, queryPaneAt } from './panel-editor/query';
import { type EditorSelects, selectsAt } from './panel-editor/select';
import { type TextPane, textPaneAt } from './panel-editor/text';
import {
type TypeSwitcher,
typeSwitcherAt,
} from './panel-editor/type-switcher';
import {
type Chart,
chartAt,
type Markdown,
markdownAt,
RENDERER_TESTID,
test as panelsTest,
} from './panels';
// The panel editor; config sections only mount their editors while open.
// Each pane lives in its own part under ./panel-editor.
export const EditorText = {
title: 'Configure panel',
unsavedBadge: 'Unsaved Changes',
save: 'Save changes',
switchToView: 'Switch to View Mode',
discardTitle: 'Discard changes?',
discardBody: 'Your unsaved edits to this panel will be lost.',
savedToast: 'Panel saved',
lockedReason: 'This dashboard is locked',
runQuery: 'Run Query',
} as const;
export const QueryTab = {
builder: 'Query Builder',
clickhouse: 'ClickHouse Query',
promql: 'PromQL',
ai: 'AI Query Builder',
} as const;
/** SettingsSection titles as rendered. */
export const Section = {
visualization: 'Visualization',
formatting: 'Formatting & Units',
axes: 'Axes',
legend: 'Legend',
chartAppearance: 'Chart Appearance',
buckets: 'Buckets',
thresholds: 'Thresholds',
contextLinks: 'Context Links',
/** Text only. */
panelAppearance: 'Panel appearance',
} as const;
/** Config controls, each testid'd as `panel-editor-v2-<name>`. */
export type EditorControl =
| 'soft-min'
| 'soft-max'
| 'y-scale'
| 'log-scale'
| 'unit'
| 'decimals'
| 'time-preference'
| 'stacked-bar-chart'
| 'stack-mode'
| 'fill-spans'
| 'fill-mode'
| 'fill-opacity'
| 'span-gaps'
| 'span-gaps-value'
| 'show-points'
| 'line-style'
| 'line-interpolation'
| 'legend-position'
| 'legend-colors'
| 'legend-search'
| 'bucket-count'
| 'bucket-width'
| 'merge-queries'
| 'heatmap-color-mode'
| 'heatmap-palette'
| 'heatmap-color-scale'
| 'heatmap-color-steps'
| 'heatmap-min-count'
| 'heatmap-max-count'
| 'heatmap-fill'
| 'heatmap-preview'
| 'type-revert';
export interface PatchOperation {
op: string;
path: string;
value?: unknown;
}
export interface PanelEditor extends EditorSelects, TypeSwitcher {
root(): Locator;
title(): Locator;
description(): Locator;
saveButton(): Locator;
closeButton(): Locator;
unsavedBadge(): Locator;
switchToView(): Locator;
/** Save is not gated on dirty state; fails the test on a rejected PATCH. */
save(): Promise<void>;
/** Clicks Save and returns whether the PATCH was accepted, for rejection paths. */
trySave(): Promise<{ ok: boolean; body: string }>;
/**
* A dirty panel raises the discard dialog; `expectDirty` answers it, with
* `keepEditing` choosing Cancel over Discard.
*/
close(options?: {
expectDirty?: boolean;
keepEditing?: boolean;
}): Promise<void>;
discardModal(): Locator;
/** Records the RFC-6902 ops each save sends. */
capturePatches(): PatchOperation[][];
section: {
toggle(title: string): Locator;
/** Idempotent: a blind click on an open section collapses it. */
expand(title: string): Promise<void>;
collapse(title: string): Promise<void>;
};
control(name: EditorControl): Locator;
/**
* A ConfigTiles choice. The radio input is visually hidden, so the label
* carrying `<group testid>-<value>` is the click target.
*/
tile(name: EditorControl, value: string): Locator;
setTile(name: EditorControl, value: string): Promise<void>;
/** The tile's radio input, for `toBeChecked`. */
tileInput(name: EditorControl, value: string): Locator;
/**
* Whether a refresh or tab close would be held by the editor's
* `beforeunload` prompt, checked without leaving the page.
*/
blocksUnload(): Promise<boolean>;
/**
* Drives a slider control from the keyboard ('Home', 'End', 'ArrowRight', …);
* a key commits like a released drag.
*/
pressSlider(name: EditorControl, key: string): Promise<void>;
text: TextPane;
/** The heading text, which carries no testid. */
heading(): Locator;
/** A disabled Save swallows pointer events, so this hovers its tooltip trigger. */
hoverSave(): Promise<void>;
query: QueryPane;
preview: {
root(): Locator;
renderer(kind: PanelKind): Locator;
chart: Chart;
markdown: Markdown;
};
}
function isDashboardPatch(method: string, url: string): boolean {
return method === 'PATCH' && /\/api\/v2\/dashboards\//.test(url);
}
function editorDriver(page: Page): PanelEditor {
const byTestId = (suffix: string): Locator =>
page.getByTestId(`panel-editor-v2-${suffix}`);
const preview = (): Locator => page.getByTestId('preview-pane');
const sectionToggle = (title: string): Locator =>
page.getByTestId(
`config-section-${title.toLowerCase().replace(/\s+/g, '-')}`,
);
const editor: PanelEditor = {
root: () => page.locator(EDITOR_ROOT),
title: () => byTestId('title'),
description: () => byTestId('description'),
saveButton: () => byTestId('save'),
closeButton: () => byTestId('close'),
unsavedBadge: () => byTestId('unsaved-badge'),
switchToView: () => byTestId('switch-to-view'),
async save() {
const { ok, body } = await editor.trySave();
// A rejected patch leaves the editor open, which would otherwise surface
// as an unrelated timeout several lines later.
expect(ok, `dashboard PATCH failed: ${body}`).toBe(true);
},
async trySave() {
const [response] = await Promise.all([
page.waitForResponse((r) =>
isDashboardPatch(r.request().method(), r.url()),
),
byTestId('save').click(),
]);
return { ok: response.ok(), body: await response.text() };
},
async close(options) {
await byTestId('close').click();
if (!options?.expectDirty) {
return;
}
await expect(byTestId('discard-modal')).toBeVisible();
await byTestId(
options.keepEditing ? 'discard-cancel' : 'discard-confirm',
).click();
},
discardModal: () => byTestId('discard-modal'),
capturePatches() {
const batches: PatchOperation[][] = [];
page.on('request', (request) => {
if (!isDashboardPatch(request.method(), request.url())) {
return;
}
const body = request.postDataJSON() as PatchOperation[] | null;
if (body) {
batches.push(body);
}
});
return batches;
},
section: {
toggle: sectionToggle,
async expand(title) {
const toggle = sectionToggle(title);
await expect(toggle).toBeVisible();
if ((await toggle.getAttribute('aria-expanded')) !== 'true') {
await toggle.click();
}
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
},
async collapse(title) {
const toggle = sectionToggle(title);
if ((await toggle.getAttribute('aria-expanded')) === 'true') {
await toggle.click();
}
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
},
},
control: (name) => byTestId(name),
tile: (name, value) => byTestId(`${name}-${value}`),
async setTile(name, value) {
await editor.tile(name, value).click();
},
tileInput: (name, value) => editor.tile(name, value).locator('input'),
blocksUnload: () =>
page.evaluate(() => {
const event = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(event);
return event.defaultPrevented;
}),
async pressSlider(name, key) {
const thumb = byTestId(name).getByRole('slider');
await thumb.focus();
await thumb.press(key);
},
text: textPaneAt(page),
...selectsAt(page),
heading: () => editor.root().getByText(EditorText.title),
async hoverSave() {
await page
.locator('[data-slot="tooltip-trigger"]')
.filter({ has: byTestId('save') })
.hover();
},
...typeSwitcherAt(page),
query: queryPaneAt(page, EditorText.runQuery),
preview: {
root: preview,
renderer: (kind) => preview().getByTestId(RENDERER_TESTID[kind]),
chart: chartAt(page, () => preview().getByTestId('uplot-main-div')),
markdown: markdownAt(() =>
preview().getByTestId(RENDERER_TESTID[PanelKind.Text]),
),
},
};
return editor;
}
const withEditor = driver('editor', ({ page }) => editorDriver(page));
const withNewPanel = driver('newPanel', ({ page }) => newPanelDriver(page));
export const test = withNewPanel(withEditor(panelsTest));
export * from './panels';
export * from './panel-editor/picker';
export * from './panel-editor/query';
export * from './panel-editor/select';
export * from './panel-editor/text';
export * from './panel-editor/type-switcher';

131
tests/e2e/drivers/panel-editor/picker.ts generated Normal file
View File

@@ -0,0 +1,131 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* 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 { PanelKind } from '../../helpers/dashboard-spec';
import { PATH } from '../dashboards';
const NEW_PANEL_DRAWER = 'panel-type-drawer';
/** The type browser's catalog, in render order. */
export const PANEL_TYPE_TILES: {
kind: PanelKind;
label: string;
category: string;
}[] = [
{
kind: PanelKind.TimeSeries,
label: 'Time Series',
category: 'Trends over time',
},
{
kind: PanelKind.AreaChart,
label: 'Area Chart',
category: 'Trends over time',
},
{
kind: PanelKind.BarChart,
label: 'Bar Chart',
category: 'Compare & rank',
},
{
kind: PanelKind.PieChart,
label: 'Pie Chart',
category: 'Compare & rank',
},
{
kind: PanelKind.Histogram,
label: 'Histogram',
category: 'Distributions',
},
{ kind: PanelKind.Heatmap, label: 'Heatmap', category: 'Distributions' },
{ kind: PanelKind.Number, label: 'Number', category: 'Single values' },
{ kind: PanelKind.Table, label: 'Table', category: 'Raw records' },
{ kind: PanelKind.List, label: 'List', category: 'Raw records' },
{ kind: PanelKind.Text, label: 'Text', category: 'Documentation' },
];
/**
* The New panel drawer every Add panel trigger opens. A tile click only selects
* (TimeSeries is preselected); the confirm button navigates to the editor.
*/
export interface NewPanelPicker {
root(): Locator;
tile(kind: PanelKind): Locator;
/** Visible tiles, in render order. */
tiles(): Locator;
/** Visible group headings, in render order. */
groups(): Locator;
search(): Locator;
/** A category chip; its accessible name carries the count. */
category(label: string): Locator;
emptyState(): Locator;
/** Reads "Add panel", or "Add to <section>" once there is a choice. */
confirmButton(): Locator;
/** Selects without confirming. */
pick(kind: PanelKind): Promise<void>;
confirm(): Promise<void>;
/** Selects and confirms, landing on the editor route. */
choose(kind: PanelKind): Promise<void>;
/** `value` is the layoutIndex, or 'root' for a root yet to be created. */
chooseSection(value: string): Promise<void>;
/** Switches the target to a new section named `title`. */
newSection(title: string): Promise<void>;
cancel(): Promise<void>;
/** An empty section's own New Panel CTA. Should move to drivers/dashboards.ts. */
sectionAddPanel(): Locator;
}
export function newPanelDriver(page: Page): NewPanelPicker {
const root = (): Locator => page.getByTestId(NEW_PANEL_DRAWER);
const tiles = (): Locator =>
root().locator('[data-testid^="panel-type-signoz/"]');
const picker: NewPanelPicker = {
root,
tile: (kind) => root().getByTestId(`panel-type-${kind}`),
tiles,
groups: () => root().locator('section > span:first-child'),
search: () => root().getByTestId('panel-type-search'),
category: (label) =>
root()
.locator('button[aria-pressed]:not([data-testid])')
.filter({ hasText: new RegExp(`^${escapeRegExp(label)}\\d+$`) }),
emptyState: () => root().getByText(/No panel types match/),
confirmButton: () => root().getByTestId('panel-type-confirm'),
async pick(kind) {
await picker.tile(kind).click();
await expect(picker.tile(kind)).toHaveAttribute('aria-pressed', 'true');
},
async confirm() {
await picker.confirmButton().click();
await page.waitForURL(PATH.newPanel);
},
async choose(kind) {
await picker.pick(kind);
await picker.confirm();
},
async chooseSection(value) {
await root().getByRole('button', { name: 'Choose section' }).click();
await page.getByTestId(`panel-section-option-${value}`).click();
},
async newSection(title) {
await root().getByRole('button', { name: 'Choose section' }).click();
await page.getByTestId('panel-section-create').click();
await root().getByTestId('panel-section-name').fill(title);
},
async cancel() {
await root().getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(root()).toHaveCount(0);
},
sectionAddPanel: () =>
page.locator('[data-testid^="section-add-panel-"]').first(),
};
return picker;
}
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

63
tests/e2e/drivers/panel-editor/query.ts generated Normal file
View File

@@ -0,0 +1,63 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
*/
import { expect, type Locator, type Page } from '@playwright/test';
export interface QueryPane {
builder(): Locator;
tab(label: string): Locator;
/** Has no testid at its call site. */
runButton(): Locator;
/** Clicks Run and waits for the response. */
run(): Promise<void>;
/** Ctrl/Cmd+Enter from inside the builder; waits for the response. */
runShortcut(): Promise<void>;
/** Picks a metric in the aggregation at `index`; options load as you type. */
selectMetric(metricName: string, index?: number): Promise<void>;
metricInput(index?: number): Locator;
}
export function queryPaneAt(page: Page, runLabel: string): QueryPane {
const builder = (): Locator =>
page.getByTestId('panel-editor-v2-query-builder');
const pane: QueryPane = {
builder: () => builder(),
// exact: 'Query Builder' is also a prefix of the 'AI Query Builder' tab.
tab: (label) => builder().getByRole('tab', { name: label, exact: true }),
runButton: () => page.getByRole('button', { name: runLabel }),
async run() {
await Promise.all([
page.waitForResponse((r) => r.url().includes('/query_range')),
pane.runButton().click(),
]);
},
async runShortcut() {
await Promise.all([
page.waitForResponse((r) => r.url().includes('/query_range')),
(async () => {
await builder().click();
await page.keyboard.press('ControlOrMeta+Enter');
})(),
]);
},
async selectMetric(metricName, index = 0) {
// An antd AutoComplete: the testid is on a wrapper.
const field = page.getByTestId(`metric-name-selector-${index}`);
await field.click();
await field.locator('input').fill(metricName);
const dropdown = page.locator(
'.ant-select-dropdown:not(.ant-select-dropdown-hidden)',
);
await expect(dropdown).toBeVisible();
await dropdown
.locator('.ant-select-item-option')
.filter({ hasText: metricName })
.first()
.click();
},
metricInput: (index = 0) =>
page.getByTestId(`metric-name-selector-${index}`).locator('input'),
};
return pane;
}

76
tests/e2e/drivers/panel-editor/select.ts generated Normal file
View File

@@ -0,0 +1,76 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
*/
import { expect, type Locator, type Page } from '@playwright/test';
// antd popups portal to `document.body`, so a testid finds the trigger and the
// options live in a detached `.ant-select-dropdown`.
export interface SelectOption {
label: string;
disabled: boolean;
}
export interface EditorSelects {
/** Picks an antd Select option by the trigger's testid. */
select(triggerTestId: string, optionLabel: string): Promise<void>;
/** Types first: long option lists are virtualised. */
searchSelect(
triggerTestId: string,
searchTerm: string,
optionLabel: string,
): Promise<void>;
selectOptions(triggerTestId: string): Promise<SelectOption[]>;
}
async function openDropdown(
page: Page,
triggerTestId: string,
): Promise<Locator> {
const trigger = page.getByTestId(triggerTestId);
await trigger.click();
// Resolve this Select's own list: a closing dropdown still matches
// `:not(.ant-select-dropdown-hidden)`, so two in a row trip strict mode.
const listId = await trigger.locator('input').getAttribute('aria-controls');
const dropdown = listId
? page
.locator(`#${listId}`)
.locator('xpath=ancestor::div[contains(@class,"ant-select-dropdown")][1]')
: page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
.last();
await expect(dropdown).toBeVisible();
return dropdown;
}
export function selectsAt(page: Page): EditorSelects {
return {
async select(triggerTestId, optionLabel) {
const dropdown = await openDropdown(page, triggerTestId);
await dropdown
.locator('.ant-select-item-option')
.filter({ hasText: optionLabel })
.first()
.click();
},
async searchSelect(triggerTestId, searchTerm, optionLabel) {
const dropdown = await openDropdown(page, triggerTestId);
await page.getByTestId(triggerTestId).locator('input').fill(searchTerm);
await dropdown
.locator('.ant-select-item-option')
.filter({ hasText: optionLabel })
.first()
.click();
},
async selectOptions(triggerTestId) {
const dropdown = await openDropdown(page, triggerTestId);
return dropdown.locator('.ant-select-item-option').evaluateAll((nodes) =>
nodes.map((node) => ({
label: node.textContent?.trim() ?? '',
disabled: node.classList.contains('ant-select-item-option-disabled'),
})),
);
},
};
}

50
tests/e2e/drivers/panel-editor/text.ts generated Normal file
View File

@@ -0,0 +1,50 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
*/
import { type Locator, type Page } from '@playwright/test';
/** The Text kind's editor pane and its own config controls. */
export interface TextPane {
pane(): Locator;
/** The CodeMirror document. */
input(): Locator;
/**
* Replaces the whole body. Inserted as text, not typed, so CodeMirror's
* bracket auto-close cannot double `[` or `(`.
*/
setMarkdown(markdown: string): Promise<void>;
toolbar(): Locator;
/** Rendered only when the dashboard has variables. */
insertVariableButton(): Locator;
insertVariable(name: string): Promise<void>;
/** The "Hide panel header" switch, inline under the title, not in a section. */
hideHeader(): Locator;
/** `vertical-horizontal`, e.g. 'center-center'. */
alignment(vertical: string, horizontal: string): Locator;
}
export function textPaneAt(page: Page): TextPane {
const textPane = (): Locator => page.getByTestId('text-panel-editor-pane');
const textInput = (): Locator => textPane().locator('.cm-content');
return {
pane: textPane,
input: textInput,
async setMarkdown(markdown) {
await textInput().click();
await page.keyboard.press('ControlOrMeta+a');
await page.keyboard.press('Delete');
await page.keyboard.insertText(markdown);
},
toolbar: () => textPane().getByTestId('markdown-editor-toolbar'),
insertVariableButton: () =>
textPane().getByTestId('markdown-insert-variable'),
async insertVariable(name) {
await textPane().getByTestId('markdown-insert-variable').click();
await page.getByTestId(`markdown-variable-${name}`).click();
},
hideHeader: () => page.getByTestId('panel-header-hide'),
alignment: (vertical, horizontal) =>
page.getByTestId(`text-layout-align-${vertical}-${horizontal}`),
};
}

View File

@@ -0,0 +1,69 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* 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 { PanelKind } from '../../helpers/dashboard-spec';
import { type SelectOption } from './select';
const TYPE_SWITCHER_DRAWER = 'panel-type-switcher-drawer';
export interface TypeSwitcher {
typeSwitcher(): Locator;
/** Opens the kind picker drawer and picks one. */
switchType(kind: PanelKind): Promise<void>;
/** A tile in the open type-switcher drawer. */
typeOption(kind: PanelKind): Locator;
openTypeSwitcher(): Promise<Locator>;
/** Every tile in the type switcher, opening it; `disabled` reads aria-disabled. */
typeOptions(): Promise<SelectOption[]>;
}
/**
* Tiles are aria-disabled, not disabled, so their reason tooltip still hovers.
* The label is the display name alone: the tile also renders a preview and a
* description.
*/
async function tileOptions(scope: Locator): Promise<SelectOption[]> {
const tiles = scope.locator('[data-testid^="panel-type-signoz/"]');
await expect(tiles.first()).toBeVisible();
return tiles.evaluateAll((nodes) =>
nodes.map((node) => ({
label:
node
.querySelector(
':scope > span:last-child > span:first-child > span:first-child',
)
?.textContent?.trim() ?? '',
disabled: node.getAttribute('aria-disabled') === 'true',
})),
);
}
export function typeSwitcherAt(page: Page): TypeSwitcher {
const trigger = (): Locator =>
page.getByTestId('panel-editor-v2-type-switcher');
const switcher: TypeSwitcher = {
typeSwitcher: trigger,
typeOption: (kind) =>
page.getByTestId(TYPE_SWITCHER_DRAWER).getByTestId(`panel-type-${kind}`),
async openTypeSwitcher() {
await trigger().click();
const drawer = page.getByTestId(TYPE_SWITCHER_DRAWER);
await expect(drawer).toBeVisible();
return drawer;
},
async switchType(kind) {
await switcher.openTypeSwitcher();
await switcher.typeOption(kind).click();
await expect(page.getByTestId(TYPE_SWITCHER_DRAWER)).toHaveCount(0);
},
async typeOptions() {
const drawer = await switcher.openTypeSwitcher();
return tileOptions(drawer);
},
};
return switcher;
}

568
tests/e2e/drivers/panel-kinds.ts generated Normal file
View File

@@ -0,0 +1,568 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
*/
import { expect, type Locator } from '@playwright/test';
import {
buildDashboard,
type EditorControl,
GOLDEN,
heatmapPoints,
heatmapTooltipAt,
heatmapQuery,
metricsQuery,
NEW_KINDS_MARKDOWN,
type Panel,
panel,
type PanelHandle,
PanelKind,
type PostableDashboard,
QueryRange,
type QueryRangeBody,
type QueryRangeMock,
QueryTab,
ramp,
rawQuery,
RENDERER_TESTID,
Section,
type Signal,
SINGLE_PANEL_ID,
type ThresholdVariant,
} from './panel-config';
// One row per panel kind, every value read off the kind's definition.ts and
// sections.ts (and capabilities.ts for the query matrix). The contract specs
// generate one test per row, so a new kind is one row here.
export type QueryType = 'builder' | 'clickhouse' | 'promql';
export type DownloadFormat = 'CSV' | 'PNG' | 'SVG';
/**
* Every collapsible section title. Text's PanelHeader section is left out: it
* renders inline in Panel Details, with no toggle of its own.
*/
export const ALL_SECTIONS: string[] = Object.values(Section);
/** The axis controls a kind can declare; Heatmap has `y-scale` only. */
export const AXIS_CONTROLS: EditorControl[] = [
'soft-min',
'soft-max',
'log-scale',
'y-scale',
];
export interface PanelKindCase {
kind: PanelKind;
/** `displayName`: the picker tile, switcher option and tooltip wording. */
label: string;
renderer: string;
/** Renders from its own spec: no query, so no data states. */
queryless: boolean;
/** The panel as seeded; renders against golden data. */
panel(): Panel;
/** A one-panel dashboard holding `panel()` under SINGLE_PANEL_ID. */
seed(): PostableDashboard;
/** A mocked result the kind renders; null when queryless. */
body(): QueryRangeBody | null;
/** A mocked successful result with nothing in it; null when queryless. */
emptyBody(): QueryRangeBody | null;
/** Number renders the shared empty state under its own testid. */
noDataTestId: string;
sections: string[];
forbiddenSections: string[];
/** Visualization declares the time-preference control. */
timePreference: boolean;
/** Axis controls shown once Axes opens; the rest of AXIS_CONTROLS are absent. */
axisControls: EditorControl[];
queryTabs: string[];
/** Query types the switcher accepts, AI excluded. */
queryTypes: QueryType[];
/** Builder signals the switcher accepts. */
signals: Signal[];
downloads: DownloadFormat[];
createAlert: boolean;
drilldown: boolean;
/** The kind's data gesture: what a drilldown would hang off. */
click(target: PanelHandle): Promise<void>;
headerSearch: boolean;
thresholdVariant: ThresholdVariant | null;
columnsEditor: boolean;
}
const BUCKETS = [10, 50, 100, 500];
const ALL_SIGNALS: Signal[] = ['metrics', 'logs', 'traces'];
const ALL_TABS = [
QueryTab.builder,
QueryTab.clickhouse,
QueryTab.promql,
QueryTab.ai,
];
const IMAGES: DownloadFormat[] = ['PNG', 'SVG'];
const CHART_SECTIONS = [
Section.visualization,
Section.formatting,
Section.axes,
Section.legend,
Section.thresholds,
Section.contextLinks,
];
function oneSeries(): QueryRangeBody {
return QueryRange.timeSeries([
{ labels: { 'service.name': 'adservice' }, points: ramp(12, 2, 8) },
]);
}
function serviceScalar(rows: (string | number)[][]): QueryRangeBody {
return QueryRange.scalar({
groupColumns: ['service.name'],
aggregationColumns: ['A'],
rows,
});
}
function singlePanel(content: Panel): PostableDashboard {
return buildDashboard({
sections: [{ title: 'Section', panels: { [SINGLE_PANEL_ID]: content } }],
});
}
type RowInput = Omit<
PanelKindCase,
'renderer' | 'seed' | 'forbiddenSections' | 'noDataTestId'
> &
Partial<Pick<PanelKindCase, 'noDataTestId'>>;
function row(input: RowInput): PanelKindCase {
return {
noDataTestId: 'panel-no-data',
...input,
renderer: RENDERER_TESTID[input.kind],
seed: () => singlePanel(input.panel()),
forbiddenSections: ALL_SECTIONS.filter(
(title) => !input.sections.includes(title),
),
};
}
const clickChartPoint = (target: PanelHandle): Promise<void> =>
target.chart.clickPoint();
const clickChart = (target: PanelHandle): Promise<void> => target.chart.click();
/**
* A slice's bounding-box centre can fall in the donut hole, so this clicks the
* centroid, where the slice's label leader line starts.
*/
async function clickPieSlice(target: PanelHandle): Promise<void> {
const leader = target
.renderer(PanelKind.PieChart)
.locator('svg g line')
.first();
const { x, y } = await leader.evaluate((node) => {
const line = node as SVGLineElement;
const matrix = line.getScreenCTM();
const point = new DOMPoint(
line.x1.baseVal.value,
line.y1.baseVal.value,
).matrixTransform(matrix ?? undefined);
return { x: point.x, y: point.y };
});
await target.root().page().mouse.click(x, y);
}
/** Waits for the cell tooltip, so a missing menu is not a click that missed. */
async function clickHeatmapCell(target: PanelHandle): Promise<void> {
await target.chart.click();
await expect(heatmapTooltipAt(target.root().page()).root()).toBeVisible();
}
export const PANEL_KINDS: PanelKindCase[] = [
row({
kind: PanelKind.TimeSeries,
label: 'Time Series',
queryless: false,
panel: () => panel(PanelKind.TimeSeries, { name: 'Calls by service' }),
body: oneSeries,
emptyBody: () => QueryRange.empty(),
sections: [...CHART_SECTIONS, Section.chartAppearance],
timePreference: true,
axisControls: ['soft-min', 'soft-max', 'log-scale'],
queryTabs: ALL_TABS,
queryTypes: ['builder', 'clickhouse', 'promql'],
signals: ALL_SIGNALS,
downloads: IMAGES,
createAlert: true,
drilldown: true,
click: clickChartPoint,
headerSearch: false,
thresholdVariant: 'label',
columnsEditor: false,
}),
row({
kind: PanelKind.BarChart,
label: 'Bar Chart',
queryless: false,
panel: () => panel(PanelKind.BarChart, { name: 'Calls bar' }),
body: oneSeries,
emptyBody: () => QueryRange.empty(),
sections: CHART_SECTIONS,
timePreference: true,
axisControls: ['soft-min', 'soft-max', 'log-scale'],
queryTabs: ALL_TABS,
queryTypes: ['builder', 'clickhouse', 'promql'],
signals: ALL_SIGNALS,
downloads: IMAGES,
createAlert: true,
drilldown: true,
click: clickChartPoint,
headerSearch: false,
thresholdVariant: 'label',
columnsEditor: false,
}),
row({
kind: PanelKind.AreaChart,
label: 'Area Chart',
queryless: false,
panel: () => panel(PanelKind.AreaChart, { name: 'Calls area' }),
body: oneSeries,
emptyBody: () => QueryRange.empty(),
sections: [...CHART_SECTIONS, Section.chartAppearance],
timePreference: true,
axisControls: ['soft-min', 'soft-max', 'log-scale'],
queryTabs: ALL_TABS,
queryTypes: ['builder', 'clickhouse', 'promql'],
signals: ALL_SIGNALS,
downloads: IMAGES,
createAlert: true,
drilldown: true,
click: clickChartPoint,
headerSearch: false,
thresholdVariant: 'label',
columnsEditor: false,
}),
row({
kind: PanelKind.Histogram,
label: 'Histogram',
queryless: false,
panel: () =>
panel(PanelKind.Histogram, {
name: 'Latency distribution',
query: metricsQuery({ metricName: GOLDEN.metrics.latencySum, groupBy: [] }),
}),
body: oneSeries,
emptyBody: () => QueryRange.empty(),
sections: [
Section.visualization,
Section.legend,
Section.buckets,
Section.contextLinks,
],
timePreference: false,
axisControls: [],
queryTabs: ALL_TABS,
queryTypes: ['builder', 'clickhouse', 'promql'],
signals: ALL_SIGNALS,
downloads: IMAGES,
createAlert: true,
drilldown: false,
click: clickChart,
headerSearch: false,
thresholdVariant: null,
columnsEditor: false,
}),
row({
kind: PanelKind.Number,
label: 'Number',
queryless: false,
panel: () =>
panel(PanelKind.Number, {
name: 'Total calls',
pluginSpec: { formatting: { unit: 'short', decimalPrecision: '2' } },
query: metricsQuery({ groupBy: [] }),
}),
body: () => QueryRange.scalar({ aggregationColumns: ['A'], rows: [[1234]] }),
emptyBody: () => QueryRange.scalar({ aggregationColumns: ['A'], rows: [] }),
noDataTestId: 'number-panel-no-data',
sections: [
Section.visualization,
Section.formatting,
Section.thresholds,
Section.contextLinks,
],
timePreference: true,
axisControls: [],
queryTabs: ALL_TABS,
queryTypes: ['builder', 'clickhouse', 'promql'],
signals: ALL_SIGNALS,
downloads: IMAGES,
createAlert: true,
drilldown: true,
// The whole renderer is the click target.
click: (target) => target.renderer(PanelKind.Number).click(),
headerSearch: false,
thresholdVariant: 'comparison',
columnsEditor: false,
}),
row({
kind: PanelKind.PieChart,
label: 'Pie Chart',
queryless: false,
panel: () => panel(PanelKind.PieChart, { name: 'Calls share' }),
body: () =>
serviceScalar([
['adservice', 10],
['cartservice', 20],
]),
emptyBody: () => serviceScalar([]),
sections: [
Section.visualization,
Section.formatting,
Section.legend,
Section.contextLinks,
],
timePreference: true,
axisControls: [],
queryTabs: [QueryTab.builder, QueryTab.clickhouse, QueryTab.ai],
queryTypes: ['builder', 'clickhouse'],
signals: ALL_SIGNALS,
downloads: IMAGES,
createAlert: false,
drilldown: true,
click: clickPieSlice,
headerSearch: false,
thresholdVariant: null,
columnsEditor: false,
}),
row({
kind: PanelKind.Table,
label: 'Table',
queryless: false,
panel: () => panel(PanelKind.Table, { name: 'Calls table' }),
body: () =>
serviceScalar([
['adservice', 10],
['cartservice', 20],
]),
emptyBody: () => serviceScalar([]),
sections: [
Section.visualization,
Section.formatting,
Section.thresholds,
Section.contextLinks,
],
timePreference: true,
axisControls: [],
queryTabs: [QueryTab.builder, QueryTab.clickhouse, QueryTab.ai],
queryTypes: ['builder', 'clickhouse'],
signals: ALL_SIGNALS,
downloads: ['CSV', ...IMAGES],
createAlert: false,
drilldown: true,
click: (target) => target.table.rows().first().locator('td').last().click(),
headerSearch: true,
thresholdVariant: 'table',
columnsEditor: false,
}),
row({
kind: PanelKind.List,
label: 'List',
queryless: false,
panel: () =>
panel(PanelKind.List, {
name: 'Recent logs',
pluginSpec: {
selectFields: [
{ name: 'timestamp', signal: 'logs' },
{ name: 'body', signal: 'logs' },
],
},
query: rawQuery({ signal: 'logs' }),
}),
body: () =>
QueryRange.raw(
Array.from({ length: 3 }, (_, i) => ({
timestamp: new Date(Date.UTC(2026, 0, 1, 0, i)).toISOString(),
body: `log line ${i}`,
'service.name': 'adservice',
})),
),
emptyBody: () => QueryRange.raw([]),
sections: [Section.visualization],
timePreference: false,
axisControls: [],
queryTabs: [QueryTab.builder, QueryTab.ai],
queryTypes: ['builder'],
signals: ['logs', 'traces'],
downloads: IMAGES,
createAlert: false,
drilldown: false,
click: (target) =>
target.renderer(PanelKind.List).getByText('log line 0').click(),
headerSearch: true,
thresholdVariant: null,
columnsEditor: true,
}),
row({
kind: PanelKind.Heatmap,
label: 'Heatmap',
queryless: false,
panel: () =>
panel(PanelKind.Heatmap, { name: 'Calls heatmap', query: heatmapQuery() }),
body: () =>
QueryRange.heatmap(BUCKETS, [
{
labels: { 'service.name': 'adservice' },
points: heatmapPoints(12, BUCKETS.length + 1),
},
]),
// Bucket bounds but no series: the heatmap's own empty body.
emptyBody: () => QueryRange.heatmap(BUCKETS, []),
sections: [
Section.visualization,
Section.chartAppearance,
Section.formatting,
Section.axes,
Section.legend,
],
timePreference: true,
axisControls: ['y-scale'],
queryTabs: [QueryTab.builder, QueryTab.clickhouse, QueryTab.promql],
queryTypes: ['builder', 'clickhouse', 'promql'],
signals: ['metrics'],
downloads: IMAGES,
createAlert: false,
drilldown: false,
click: clickHeatmapCell,
headerSearch: false,
thresholdVariant: null,
columnsEditor: false,
}),
row({
kind: PanelKind.Text,
label: 'Text',
queryless: true,
panel: () =>
panel(PanelKind.Text, {
name: 'Runbook',
pluginSpec: { text: NEW_KINDS_MARKDOWN },
}),
body: () => null,
emptyBody: () => null,
sections: [Section.visualization, Section.panelAppearance],
timePreference: false,
axisControls: [],
queryTabs: [],
queryTypes: [],
signals: [],
downloads: [],
createAlert: false,
drilldown: false,
click: (target) => target.markdown.root().click(),
headerSearch: false,
thresholdVariant: null,
columnsEditor: false,
}),
];
export interface NoDataState {
root(): Locator;
action(): Locator;
secondaryAction(): Locator;
}
/** The kind's empty state; its actions take the state's testid as a prefix. */
export function noDataOf(c: PanelKindCase, target: PanelHandle): NoDataState {
const byTestId = (suffix: string): Locator =>
target.root().getByTestId(`${c.noDataTestId}${suffix}`);
return {
root: () => byTestId(''),
action: () => byTestId('-action'),
secondaryAction: () => byTestId('-secondary-action'),
};
}
export const QUERYABLE_KINDS = PANEL_KINDS.filter((c) => !c.queryless);
export function kindCase(kind: PanelKind): PanelKindCase {
const match = PANEL_KINDS.find((c) => c.kind === kind);
if (!match) {
throw new Error(`no PANEL_KINDS row for ${kind}`);
}
return match;
}
/**
* The kind's panel under SINGLE_PANEL_ID, below a tall queryless filler so it
* starts outside the viewport.
*/
export function belowTheFold(c: PanelKindCase): PostableDashboard {
const dashboard = buildDashboard({
sections: [
{
title: 'Section',
panels: {
filler: panel(PanelKind.Text, { name: 'Filler' }),
[SINGLE_PANEL_ID]: c.panel(),
},
},
],
});
const [layout] = dashboard.spec.layouts;
layout.spec.items = [
{ ...layout.spec.items[0], x: 0, y: 0, width: 12, height: 40 },
{ ...layout.spec.items[1], x: 0, y: 40 },
];
return dashboard;
}
/** Serves the kind's mocked result; a queryless kind sends no query to serve. */
export async function respondWithData(
queryRange: QueryRangeMock,
c: PanelKindCase,
): Promise<void> {
const body = c.body();
if (body) {
await queryRange.respond(body);
}
}
/** The kind's panel plus an empty second section, so Move to section has a target. */
export function withSpareSection(c: PanelKindCase): PostableDashboard {
return buildDashboard({
sections: [
{ title: 'Section', panels: { [SINGLE_PANEL_ID]: c.panel() } },
{ title: 'Spare', panels: {} },
],
});
}
/**
* The switcher's reason for refusing `c`, or null when it accepts: the query
* type is checked before the signal (getPanelTypeDisabledReason).
*/
export function disabledReason(
c: PanelKindCase,
source: { queryType: QueryType; signal?: Signal },
): string | null {
if (c.queryless) {
return null;
}
if (!c.queryTypes.includes(source.queryType)) {
const label = {
builder: 'Query Builder',
clickhouse: 'ClickHouse',
promql: 'PromQL',
}[source.queryType];
return `${c.label} isn't available for ${label} queries`;
}
if (source.signal && !c.signals.includes(source.signal)) {
return `${c.label} doesn't support ${source.signal} data`;
}
return null;
}
export * from './panel-config';

274
tests/e2e/drivers/panels.ts generated Normal file
View File

@@ -0,0 +1,274 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* 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 { PanelKind } from '../helpers/dashboard-spec';
import { test as dashboardsTest } from './dashboards';
import { driver } from './kit';
import { actionsAt, PanelAction, type PanelActions } from './panels/actions';
import { type Chart, chartAt } from './panels/chart';
import { openSelectOption } from './panels/dom';
import { gridAt, type PanelGrid } from './panels/grid';
import {
type HeatmapPanel,
type HeatmapTooltip,
heatmapAt,
heatmapTooltipAt,
} from './panels/heatmap';
import { type Legend, legendAt } from './panels/legend';
import { type Markdown, markdownAt } from './panels/markdown';
import { type PiePanel, pieAt } from './panels/pie';
import {
type ListPager,
pagerAt,
type PanelSearch,
searchAt,
type TableColumns,
tableAt,
} from './panels/table';
// 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.
export const PanelMessageText = {
noQueryTitle: 'Nothing to visualize yet',
// Curly apostrophe (U+2019): a straight quote will not match.
errorTitle: 'Couldn’t load panel data',
noDataTitle: 'No data in this time range',
extendAction: 'Extend time range',
retryAction: 'Retry',
} as const;
/** Root testid each kind's renderer puts on its own subtree. */
export const RENDERER_TESTID: Record<PanelKind, string> = {
[PanelKind.TimeSeries]: 'time-series-renderer',
[PanelKind.BarChart]: 'bar-panel-renderer',
[PanelKind.AreaChart]: 'area-chart-renderer',
[PanelKind.Histogram]: 'histogram-panel-renderer',
[PanelKind.Number]: 'number-panel-renderer',
[PanelKind.PieChart]: 'pie-panel-renderer',
[PanelKind.Table]: 'table-panel-renderer',
[PanelKind.List]: 'list-panel-renderer',
[PanelKind.Heatmap]: 'heatmap-panel-renderer',
[PanelKind.Text]: 'text-panel',
};
export interface PanelHandle {
root(): Locator;
/** Panels fetch lazily behind an IntersectionObserver. */
scrollIntoView(): Promise<void>;
/** Scrolls in, then waits for the kind's own renderer. */
waitRendered(kind: PanelKind): Promise<void>;
renderer(kind: PanelKind): Locator;
/** `data-panel-visible`, 'true' once the panel entered the viewport. */
visibility(): Locator;
title(): Locator;
infoIcon(): Locator;
timePreference(): Locator;
statusWarning(): Locator;
statusError(): Locator;
loading(): Locator;
noData(): Locator;
noDataAction(): Locator;
noDataSecondaryAction(): Locator;
error(): Locator;
errorAction(): Locator;
text(text: string): Locator;
numberValue(): Locator;
numberNoData(): Locator;
/** Replaces the header when a Text panel hides it; carries the drag class. */
hiddenHeaderHandle(): Locator;
markdown: Markdown;
heatmap: HeatmapPanel;
pie: PiePanel;
chart: Chart;
legend: Legend;
actions: PanelActions;
search: PanelSearch;
pager: ListPager;
table: TableColumns;
grid: PanelGrid;
}
export interface Panels {
panel(id: string): PanelHandle;
/** Every panel title on the grid. */
titles(): Locator;
tooltip: {
container(): Locator;
list(): Locator;
unpin(): Locator;
/** The pin key is 'p' (DEFAULT_PIN_TOOLTIP_KEY); its JSDoc still says 'l'. */
pin(): Promise<void>;
/** Hover tooltips render their content twice, so this scopes to the tooltip role. */
statusContent(): Locator;
/** Free text inside any open tooltip. */
text(text: string): Locator;
heatmap: HeatmapTooltip;
};
drilldown: {
menu(): Locator;
/** Testids sit on an inner span; this walks up so disabled states assert. */
item(testId: string): Locator;
back(menu: 'breakout' | 'var'): Locator;
breakoutSearch(): Locator;
/** A page-level Escape does nothing: the handler needs the backdrop. */
dismiss(): Promise<void>;
};
view: {
/** Opens the modal through the actions menu. */
open(panelId: string): Promise<Locator>;
content(): Locator;
dialog(): Locator;
refresh(): Locator;
switchToEdit(): Locator;
chart: Chart;
renderer(kind: PanelKind): Locator;
markdown: Markdown;
/** Text only: the Markdown editor in the query builder's slot. */
textEditor(): Locator;
/** Changes the kind for this session only. */
switchType(label: string): Promise<void>;
};
/** A visible option of the open antd Select. */
openSelectOption(label: string): Locator;
/** Fires the browser's Back. */
back(): Promise<void>;
pressEscape(): Promise<void>;
/** Resolves with the tab a click opens. */
nextTab(): Promise<Page>;
/** Resolves with the suggested filename of the next download. */
nextDownload(): Promise<{ suggestedFilename: () => string }>;
}
function panelHandle(page: Page, id: string): PanelHandle {
const root = (): Locator => page.locator(`[data-panel-root="${id}"]`);
const byTestId = (testId: string): Locator => root().getByTestId(testId);
return {
root,
async scrollIntoView() {
await root().scrollIntoViewIfNeeded();
},
async waitRendered(kind) {
await root().scrollIntoViewIfNeeded();
await expect(root()).toHaveAttribute('data-panel-visible', 'true');
await expect(byTestId(RENDERER_TESTID[kind])).toBeVisible();
},
renderer: (kind) => byTestId(RENDERER_TESTID[kind]),
visibility: root,
title: () => byTestId('panel-title'),
infoIcon: () => byTestId('panel-header-info-icon'),
timePreference: () => byTestId('panel-time-preference'),
statusWarning: () => byTestId('panel-status-warning'),
statusError: () => byTestId('panel-status-error'),
loading: () => byTestId('panel-loading'),
noData: () => byTestId('panel-no-data'),
noDataAction: () => byTestId('panel-no-data-action'),
noDataSecondaryAction: () => byTestId('panel-no-data-secondary-action'),
error: () => byTestId('panel-error'),
errorAction: () => byTestId('panel-error-action'),
text: (text) => root().getByText(text),
numberValue: () => byTestId('number-panel-value'),
numberNoData: () => byTestId('number-panel-no-data'),
hiddenHeaderHandle: () => byTestId('hidden-header-drag-handle'),
markdown: markdownAt(() => byTestId(RENDERER_TESTID[PanelKind.Text])),
heatmap: heatmapAt(root),
pie: pieAt(root),
chart: chartAt(page, () => byTestId('uplot-main-div')),
legend: legendAt(root),
actions: actionsAt(page, id, root),
search: searchAt(root),
pager: pagerAt(page, root),
table: tableAt(page, id, root),
grid: gridAt(page, root),
};
}
function panelsDriver(page: Page): Panels {
const menu = (): Locator => page.locator('.context-menu');
const viewContent = (): Locator =>
page.getByTestId('view-panel-modal-content');
return {
panel: (id) => panelHandle(page, id),
titles: () => page.getByTestId('panel-title'),
tooltip: {
container: () => page.getByTestId('uplot-tooltip-container'),
list: () => page.getByTestId('uplot-tooltip-list'),
unpin: () => page.getByTestId('uplot-tooltip-unpin'),
async pin() {
await page.keyboard.press('p');
},
statusContent: () =>
page.getByRole('tooltip').getByTestId('panel-status-content'),
text: (text) => page.getByText(text),
heatmap: heatmapTooltipAt(page),
},
drilldown: {
menu,
item: (testId) =>
page
.getByTestId(testId)
.locator(
'xpath=ancestor-or-self::button[contains(@class,"context-menu-item")][1]',
),
back: (submenu) => page.getByTestId(`drilldown-${submenu}-back`),
breakoutSearch: () => page.getByPlaceholder('Search breakout options...'),
async dismiss() {
// A corner, since the menu opens over the clicked point.
await page
.locator('.context-menu-backdrop')
.click({ position: { x: 5, y: 5 } });
},
},
view: {
async open(panelId) {
await panelHandle(page, panelId).actions.run(PanelAction.view);
await expect(viewContent()).toBeVisible();
return viewContent();
},
content: viewContent,
dialog: () => page.getByRole('dialog'),
refresh: () => page.getByTestId('view-panel-refresh'),
switchToEdit: () => page.getByTestId('view-panel-switch-to-edit'),
chart: chartAt(page, () => viewContent().getByTestId('uplot-main-div')),
renderer: (kind) => viewContent().getByTestId(RENDERER_TESTID[kind]),
markdown: markdownAt(() =>
viewContent().getByTestId(RENDERER_TESTID[PanelKind.Text]),
),
textEditor: () => viewContent().getByTestId('text-panel-editor-pane'),
async switchType(label) {
await page.getByTestId('view-panel-type-selector').click();
await openSelectOption(page, label).click();
},
},
openSelectOption: (label) => openSelectOption(page, label),
async back() {
await page.goBack();
},
async pressEscape() {
await page.keyboard.press('Escape');
},
nextTab: () => page.context().waitForEvent('page'),
nextDownload: () => page.waitForEvent('download'),
};
}
const withPanels = driver('panels', ({ page }) => panelsDriver(page));
export const test = withPanels(dashboardsTest);
export * from './dashboards';
export * from './panels/actions';
export * from './panels/chart';
export * from './panels/dom';
export * from './panels/grid';
export * from './panels/heatmap';
export * from './panels/legend';
export * from './panels/markdown';
export * from './panels/pie';
export * from './panels/table';

88
tests/e2e/drivers/panels/actions.ts generated Normal file
View File

@@ -0,0 +1,88 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
*/
import { expect, type Locator, type Page } from '@playwright/test';
// 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.
export const PanelAction = {
view: 'View',
edit: 'Edit panel',
clone: 'Clone',
download: 'Download',
downloadCsv: 'Download as CSV',
downloadPng: 'Download as PNG',
downloadSvg: 'Download as SVG',
createAlert: 'Create Alerts',
move: 'Move to section',
moveToRoot: 'Dashboard (root)',
delete: 'Delete panel',
} as const;
export interface PanelActions {
trigger(): Locator;
/** Dismisses any menu still open first: one caught mid-dismiss eats the click. */
open(): Promise<Locator>;
close(): Promise<void>;
item(label: string): Locator;
run(label: string): Promise<void>;
/** Hovers a parent item to open its submenu; clicking it closes the menu. */
hoverItem(label: string): Promise<void>;
download(format: 'CSV' | 'PNG' | 'SVG'): Promise<void>;
moveTo(sectionTitle: string): Promise<void>;
}
export function actionsAt(
page: Page,
panelId: string,
root: () => Locator,
): PanelActions {
const menuItem = (label: string): Locator =>
page.getByRole('menuitem', { name: label, exact: true });
const actions: PanelActions = {
trigger: () => page.getByTestId(`panel-actions-${panelId}`),
async open() {
await actions.close();
await root().scrollIntoViewIfNeeded();
// The trigger only appears on hover.
await root().hover();
await actions.trigger().click();
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
return menu;
},
async close() {
if ((await page.getByRole('menu').count()) === 0) {
return;
}
// An open submenu takes the first Escape for itself.
await expect(async () => {
await page.keyboard.press('Escape');
await expect(page.getByRole('menu')).toHaveCount(0, { timeout: 1_000 });
}).toPass();
},
item: menuItem,
async run(label) {
await actions.open();
await menuItem(label).click();
},
async hoverItem(label) {
await menuItem(label).hover();
},
async download(format) {
await actions.open();
await menuItem(PanelAction.download).hover();
await menuItem(`Download as ${format}`).click();
},
async moveTo(sectionTitle) {
await actions.open();
await menuItem(PanelAction.move).hover();
await menuItem(sectionTitle).click();
},
};
return actions;
}

287
tests/e2e/drivers/panels/chart.ts generated Normal file
View File

@@ -0,0 +1,287 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* 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 { boxOf, dragHorizontally } from './dom';
/** uPlot encodes a log scale as `distr: 3`. */
export const LOG_DISTR = 3;
export interface UPlotSeriesState {
label?: string;
/** false once a series is hidden via the legend. */
show: boolean;
/** Non-empty for a dashed line style. */
dash?: number[];
/** uPlot only sets a fill when the panel asks for one. */
hasFill: boolean;
/** The resolved fill: a colour (`#rrggbbaa` when solid) or 'gradient'. */
fill: string | null;
/** Largest finite value plotted, after any stacking; null when none. */
max: number | null;
/** A predicate cannot cross `evaluate`, so it reports as 'fn'. */
pointsShow?: boolean | 'fn' | null;
width?: number;
/** uPlot's own gap bridging; false when a threshold breaks gaps in data-prep. */
spanGaps?: boolean | number;
/** Nulls in the plotted data: each one is a break in the line. */
nullCount: number;
}
export interface UPlotScaleState {
min: number | null;
max: number | null;
/** 1 linear, 3 logarithmic. */
distr?: number;
}
export interface UPlotState {
/** Excludes the x series at index 0. */
series: UPlotSeriesState[];
scales: Record<string, UPlotScaleState>;
/** Points in the x series. */
pointCount: number;
/** Fill bands between series; a stacked chart has one per adjacent pair. */
bandCount: number;
}
/**
* Real chart state, read from the `__uplot` handle UPlotChart hangs off its
* container. The DOM only says a chart exists; this catches a renderer that
* ignores a setting.
*/
export interface Chart {
host(): Locator;
/** uPlot binds its cursor to `.u-over`, so this is the pointer target. */
plotArea(): Locator;
/** Throws when there is no plot, usually because the panel shows No Data. */
state(): Promise<UPlotState>;
/** The left scale, where axis bounds and log mode land. */
yScale(): Promise<UPlotScaleState>;
expectSeriesCount(count: number): Promise<void>;
/**
* Share (0–1) of the plot-area row at `value` on the left scale painted in
* `color`. For lines drawn straight onto the canvas, such as thresholds,
* which leave no state on the uPlot instance.
*/
lineCoverage(value: number, color: string): Promise<number>;
/** Moves the pointer to the middle of the plot. */
hover(): Promise<void>;
/** Moves the pointer just outside the plot's top-left corner. */
leave(): Promise<void>;
click(): Promise<void>;
/**
* Clicks the first series' data point at `index`, by default the middle of
* those inside the visible window, placed through uPlot's own scales so it
* lands on data wherever it sits.
*/
clickPoint(index?: number): Promise<void>;
/** Fractions are of the plot area's own box, so panel size does not matter. */
drag(fromFraction: number, toFraction: number): Promise<void>;
}
async function readUplot(host: Locator): Promise<UPlotState> {
await expect(host).toBeVisible();
return host.evaluate((node) => {
const plot = (node as { __uplot?: unknown }).__uplot as
| {
series: {
label?: string;
show?: boolean;
dash?: number[];
fill?: unknown;
width?: number;
spanGaps?: boolean | number;
points?: { show?: boolean | ((...args: unknown[]) => boolean) };
}[];
scales: Record<string, { min?: number; max?: number; distr?: number }>;
data: unknown[][];
bands?: unknown[];
}
| undefined;
if (!plot) {
throw new Error(
'no uPlot instance on the chart container — the panel probably rendered "No Data" instead of a plot',
);
}
// uPlot wraps every fill in a function; calling it yields the colour or gradient.
const resolveFill = (fill: unknown, seriesIndex: number): string | null => {
const value =
typeof fill === 'function'
? (fill as (u: unknown, i: number) => unknown)(plot, seriesIndex)
: fill;
if (typeof value === 'string') {
return value;
}
return value instanceof CanvasGradient ? 'gradient' : null;
};
const maxOf = (values: unknown[] | undefined): number | null => {
const finite = (values ?? []).filter(
(value): value is number =>
typeof value === 'number' && Number.isFinite(value),
);
return finite.length > 0 ? Math.max(...finite) : null;
};
return {
series: plot.series.slice(1).map((series, index) => ({
label: series.label,
show: series.show !== false,
dash: series.dash,
hasFill: series.fill != null,
fill: resolveFill(series.fill, index + 1),
max: maxOf(plot.data?.[index + 1]),
pointsShow:
typeof series.points?.show === 'function'
? ('fn' as const)
: (series.points?.show ?? null),
width: series.width,
spanGaps: series.spanGaps,
nullCount: (plot.data?.[index + 1] ?? []).filter((v) => v === null).length,
})),
scales: Object.fromEntries(
Object.entries(plot.scales).map(([key, scale]) => [
key,
{ min: scale.min ?? null, max: scale.max ?? null, distr: scale.distr },
]),
),
pointCount: plot.data?.[0]?.length ?? 0,
bandCount: plot.bands?.length ?? 0,
};
});
}
export function chartAt(page: Page, host: () => Locator): Chart {
const plotArea = (): Locator => host().locator('.u-over');
const chart: Chart = {
host,
plotArea,
state: () => readUplot(host()),
async yScale() {
const { scales } = await chart.state();
// Panels name the left scale 'y'; fall back to the first non-x scale.
return (
scales.y ??
Object.entries(scales).find(([key]) => key !== 'x')?.[1] ?? {
min: null,
max: null,
}
);
},
async lineCoverage(value, color) {
await expect(host()).toBeVisible();
return host().evaluate(
(node, { value, color }) => {
const plot = (node as { __uplot?: unknown }).__uplot as
| {
ctx: CanvasRenderingContext2D;
bbox: { left: number; width: number };
scales: Record<string, unknown>;
valToPos: (v: number, scale: string, canvas: boolean) => number;
}
| undefined;
if (!plot) {
throw new Error('no uPlot instance on the chart container');
}
const scale =
'y' in plot.scales
? 'y'
: (Object.keys(plot.scales).find((key) => key !== 'x') ?? 'y');
const probe = document.createElement('canvas').getContext('2d');
if (!probe) {
throw new Error('no 2d context to resolve the colour');
}
probe.fillStyle = color;
probe.fillRect(0, 0, 1, 1);
const [r, g, b] = probe.getImageData(0, 0, 1, 1).data;
// The line is 2px wide and anti-aliased: read the rows around it and
// count a column when any of them is close to the colour.
const y = Math.round(plot.valToPos(value, scale, true));
const x = Math.round(plot.bbox.left);
const width = Math.round(plot.bbox.width);
const { data } = plot.ctx.getImageData(x, y - 1, width, 3);
let hits = 0;
for (let column = 0; column < width; column++) {
for (let row = 0; row < 3; row++) {
const i = (row * width + column) * 4;
const close =
data[i + 3] > 0 &&
Math.abs(data[i] - r) < 60 &&
Math.abs(data[i + 1] - g) < 60 &&
Math.abs(data[i + 2] - b) < 60;
if (close) {
hits += 1;
break;
}
}
}
return width > 0 ? hits / width : 0;
},
{ value, color },
);
},
async expectSeriesCount(count) {
await expect
.poll(async () => (await chart.state()).series.length)
.toBe(count);
},
async hover() {
const box = await boxOf(host(), 'the chart');
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
},
async leave() {
const box = await boxOf(host(), 'the chart');
await page.mouse.move(box.x - 20, box.y - 20);
},
async click() {
const box = await boxOf(host(), 'the chart');
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
},
async clickPoint(index) {
await expect(host()).toBeVisible();
const point = await host().evaluate((node, at) => {
const plot = (node as { __uplot?: unknown }).__uplot as
| {
data: [number[], ...(number | null)[][]];
scales: { x: { min?: number; max?: number } };
series: { scale?: string }[];
over: HTMLElement;
valToPos: (value: number, scale: string) => number;
}
| undefined;
if (!plot) {
throw new Error('no uPlot instance on the chart container');
}
const xs = plot.data[0];
const { min = -Infinity, max = Infinity } = plot.scales.x;
const visible = xs
.map((x, i) => ({ x, i }))
.filter(({ x }) => x >= min && x <= max);
const i = at ?? visible[Math.floor(visible.length / 2)]?.i ?? 0;
const y = plot.data[1]?.[i];
if (y == null) {
throw new Error(`no value at index ${i} of the first series`);
}
const over = plot.over.getBoundingClientRect();
return {
x: over.left + plot.valToPos(xs[i], 'x'),
y: over.top + plot.valToPos(y, plot.series[1].scale ?? 'y'),
};
}, index);
await page.mouse.move(point.x, point.y);
await page.mouse.click(point.x, point.y);
},
async drag(fromFraction, toFraction) {
const box = await boxOf(plotArea(), 'the plot area');
await dragHorizontally(page, box, fromFraction, toFraction);
},
};
return chart;
}

57
tests/e2e/drivers/panels/dom.ts generated Normal file
View File

@@ -0,0 +1,57 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
*/
import { expect, type Locator, type Page } from '@playwright/test';
export interface Box {
x: number;
y: number;
width: number;
height: number;
}
export async function boxOf(
locator: Locator,
description: string,
): Promise<Box> {
await locator.scrollIntoViewIfNeeded();
const box = await locator.boundingBox();
expect(box, `expected a bounding box for ${description}`).not.toBeNull();
return box as Box;
}
/** The extra pre-mousedown move gives uPlot a cursor anchor to drag from. */
export async function dragHorizontally(
page: Page,
box: Box,
fromFraction: number,
toFraction: number,
): Promise<void> {
const y = box.y + box.height / 2;
const startX = box.x + box.width * fromFraction;
await page.mouse.move(startX, y);
await page.mouse.move(startX, y);
await page.mouse.down();
await page.mouse.move(box.x + box.width * toFraction, y, { steps: 16 });
await page.mouse.up();
}
export async function dragBy(
page: Page,
from: { x: number; y: number },
to: { x: number; y: number },
steps: number,
): Promise<void> {
await page.mouse.move(from.x, from.y);
await page.mouse.down();
await page.mouse.move(to.x, to.y, { steps });
await page.mouse.up();
}
/** A visible option of whichever antd Select is open. */
export function openSelectOption(page: Page, label: string): Locator {
return page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
.getByText(label, { exact: true });
}

58
tests/e2e/drivers/panels/grid.ts generated Normal file
View File

@@ -0,0 +1,58 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
*/
import { type Locator, type Page } from '@playwright/test';
import { boxOf, dragBy } from './dom';
/**
* react-grid-layout exposes no testids; `.panel-drag-handle` and
* `.react-resizable-handle` are the class contract it is configured with
* (draggableHandle / draggableCancel in SectionGrid).
*/
export interface PanelGrid {
/** The grid item's rendered size in px. */
size(): Promise<{ width: number; height: number }>;
/** Drags RGL's south-east grip by the given offset. */
resize(dx: number, dy: number): Promise<void>;
/** Drags the header handle across its own width. */
dragByHeader(): Promise<void>;
}
export function gridAt(page: Page, root: () => Locator): PanelGrid {
// RGL appends the resize grip to its grid item, a sibling of the panel root.
const gridItem = (): Locator =>
root().locator('xpath=ancestor::div[contains(@class,"react-grid-item")][1]');
return {
async size() {
const { width, height } = await boxOf(gridItem(), 'the grid item');
return { width, height };
},
async resize(dx, dy) {
const grip = await boxOf(
gridItem().locator('.react-resizable-handle'),
'the resize handle',
);
await dragBy(
page,
{ x: grip.x + grip.width / 2, y: grip.y + grip.height / 2 },
{ x: grip.x + dx, y: grip.y + dy },
12,
);
},
async dragByHeader() {
const bar = await boxOf(
root().locator('.panel-drag-handle').first(),
'the drag handle',
);
const y = bar.y + bar.height / 2;
await dragBy(
page,
{ x: bar.x + bar.width / 4, y },
{ x: bar.x + bar.width, y },
15,
);
},
};
}

34
tests/e2e/drivers/panels/heatmap.ts generated Normal file
View File

@@ -0,0 +1,34 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
*/
import { type Locator, type Page } from '@playwright/test';
export interface HeatmapPanel {
/** The count colour bar under the grid: caption, min label, ramp, max label. */
colorBar(): Locator;
/** Marks the hovered cell's count on the bar. */
colorBarMarker(): Locator;
}
export interface HeatmapTooltip {
root(): Locator;
bucket(): Locator;
count(): Locator;
}
export function heatmapAt(root: () => Locator): HeatmapPanel {
const byTestId = (testId: string): Locator => root().getByTestId(testId);
return {
colorBar: () => byTestId('color-bar'),
colorBarMarker: () => byTestId('color-bar-marker'),
};
}
export function heatmapTooltipAt(page: Page): HeatmapTooltip {
return {
root: () => page.getByTestId('heatmap-tooltip'),
bucket: () => page.getByTestId('heatmap-tooltip-bucket'),
count: () => page.getByTestId('heatmap-tooltip-count'),
};
}

52
tests/e2e/drivers/panels/legend.ts generated Normal file
View File

@@ -0,0 +1,52 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
*/
import { expect, type Locator } from '@playwright/test';
export interface Legend {
item(label: string): Locator;
items(): Locator;
/**
* Clicks the row: with every series shown it solos this one, with only this
* one shown it restores all, otherwise it toggles this one.
*/
solo(label: string): Promise<void>;
/** The marker toggles just that series; disabled on the last one shown. */
toggle(label: string): Promise<void>;
/** The row's hover-revealed "Only" / "All" button. */
scope(label: string): Locator;
/** Rows are `role="switch"`; `aria-checked` carries the shown state. */
expectShown(label: string, shown: boolean): Promise<void>;
/** Rendered for a right legend, and a bottom one once its rows overflow. */
search(): Locator;
/** "Showing N of M series", rendered with the search box. */
status(): Locator;
}
export function legendAt(root: () => Locator): Legend {
const legendItem = (label: string): Locator =>
root().locator('[data-legend-item-id]').filter({ hasText: label });
const byTestId = (testId: string): Locator => root().getByTestId(testId);
return {
item: legendItem,
items: () => root().locator('[data-legend-item-id]'),
async solo(label) {
// The label, not the row centre: the scope button overlays its tail.
await legendItem(label).getByText(label).click();
},
async toggle(label) {
await legendItem(label).locator('[data-is-legend-marker]').click();
},
scope: (label) => legendItem(label).locator('[data-testid^="legend-scope-"]'),
async expectShown(label, shown) {
await expect(legendItem(label)).toHaveAttribute(
'aria-checked',
String(shown),
);
},
search: () => byTestId('legend-search-input'),
status: () => byTestId('legend-status'),
};
}

27
tests/e2e/drivers/panels/markdown.ts generated Normal file
View File

@@ -0,0 +1,27 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
*/
import { type Locator } from '@playwright/test';
/** A rendered Text panel body. */
export interface Markdown {
root(): Locator;
heading(name: string): Locator;
listItems(): Locator;
link(name: string): Locator;
codeBlock(): Locator;
/** "Nothing written yet", for a blank body. */
empty(): Locator;
}
export function markdownAt(root: () => Locator): Markdown {
return {
root,
heading: (name) => root().getByRole('heading', { name }),
listItems: () => root().getByRole('listitem'),
link: (name) => root().getByRole('link', { name }),
codeBlock: () => root().locator('pre code'),
empty: () => root().getByTestId('text-panel-empty'),
};
}

22
tests/e2e/drivers/panels/pie.ts generated Normal file
View File

@@ -0,0 +1,22 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
*/
import { type Locator } from '@playwright/test';
/** The donut @visx draws: one `<path>` per visible slice, the total in the hole. */
export interface PiePanel {
arcs(): Locator;
/** The total of the visible slices, centred in the donut hole. */
total(): Locator;
}
export function pieAt(root: () => Locator): PiePanel {
// The legend's icons are SVGs too; the donut is the first one.
const chart = (): Locator =>
root().getByTestId('pie-chart').locator('svg').first();
return {
arcs: () => chart().locator('g > path'),
total: () => chart().locator('text[text-anchor="middle"]'),
};
}

105
tests/e2e/drivers/panels/table.ts generated Normal file
View File

@@ -0,0 +1,105 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
*/
import { type Locator, type Page } from '@playwright/test';
import { storageKey } from '../../helpers/base-path';
import { boxOf, dragBy, openSelectOption } from './dom';
/** Local storage key column widths persist under, behind a 400ms debounce. */
const COLUMN_WIDTHS_KEY = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS';
/** Header search, offered by Table and List. */
export interface PanelSearch {
trigger(): Locator;
input(): Locator;
clear(): Locator;
open(term: string): Promise<void>;
}
/** List pagination. */
export interface ListPager {
page(): Locator;
prev(): Locator;
next(): Locator;
/** Picks e.g. '50 / page' from the page-size select. */
setPageSize(label: string): Promise<void>;
}
export interface TableColumns {
rows(): Locator;
header(column: string): Locator;
/** Rendered header width in px, 0 when not laid out. */
headerWidth(column: string): Promise<number>;
resizeColumn(column: string, byPx: number): Promise<void>;
/** The width persisted for this column, 0 when none. */
storedWidth(column: string): Promise<number>;
}
export function searchAt(root: () => Locator): PanelSearch {
const byTestId = (testId: string): Locator => root().getByTestId(testId);
return {
trigger: () => byTestId('panel-header-search-trigger'),
input: () => byTestId('panel-header-search-input'),
clear: () => byTestId('panel-header-search-clear'),
async open(term) {
await root().hover();
await byTestId('panel-header-search-trigger').click();
await byTestId('panel-header-search-input').fill(term);
},
};
}
export function pagerAt(page: Page, root: () => Locator): ListPager {
const byTestId = (testId: string): Locator => root().getByTestId(testId);
return {
page: () => byTestId('list-panel-page'),
prev: () => byTestId('list-panel-prev'),
next: () => byTestId('list-panel-next'),
async setPageSize(label) {
await byTestId('list-panel-page-size').click();
await openSelectOption(page, label).click();
},
};
}
export function tableAt(
page: Page,
panelId: string,
root: () => Locator,
): TableColumns {
const byTestId = (testId: string): Locator => root().getByTestId(testId);
const header = (column: string): Locator =>
root().locator('th').filter({ hasText: column });
return {
rows: () => root().locator('tbody tr.ant-table-row'),
header,
async headerWidth(column) {
return (await header(column).boundingBox())?.width ?? 0;
},
async resizeColumn(column, byPx) {
const grip = await boxOf(
byTestId(`column-resize-${column}`),
`the ${column} resize grip`,
);
const y = grip.y + grip.height / 2;
await dragBy(
page,
{ x: grip.x + grip.width / 2, y },
{ x: grip.x + byPx, y },
10,
);
},
storedWidth: (column) =>
page.evaluate(
({ key, panelId, name }) => {
const raw = localStorage.getItem(key);
const widths = raw ? JSON.parse(raw) : {};
return (widths?.[panelId]?.[name] as number | undefined) ?? 0;
},
{ key: storageKey(COLUMN_WIDTHS_KEY), panelId, name: column },
),
};
}

View File

@@ -0,0 +1,539 @@
// Typed builders for the (Perses-shaped) dashboard spec — pure data, no
// Playwright. Mirrors Go's `dashboardtypes.DashboardSpec`.
//
// Three server rules that surface as an opaque 400:
// 1. POST decodes with DisallowUnknownFields — any stray key rejects it all.
// 2. Every panel needs EXACTLY ONE query; Text needs `queries: []`.
// 3. Panel keys match /^[a-zA-Z0-9_.-]+$/, so readable ids double as test
// handles (`panel-actions-${id}`).
// ─── Kinds ───────────────────────────────────────────────────────────────
export const PanelKind = {
TimeSeries: 'signoz/TimeSeriesPanel',
BarChart: 'signoz/BarChartPanel',
AreaChart: 'signoz/AreaChartPanel',
Histogram: 'signoz/HistogramPanel',
Number: 'signoz/NumberPanel',
PieChart: 'signoz/PieChartPanel',
Table: 'signoz/TablePanel',
List: 'signoz/ListPanel',
Heatmap: 'signoz/HeatmapPanel',
Text: 'signoz/TextPanel',
} as const;
export type PanelKind = (typeof PanelKind)[keyof typeof PanelKind];
export type Signal = 'metrics' | 'logs' | 'traces';
// ─── Queries ─────────────────────────────────────────────────────────────
export interface MetricAggregation {
metricName: string;
timeAggregation: string;
spaceAggregation: string;
reduceTo: string;
}
/** Logs/traces aggregations are expression-shaped ("count()"), not metric-shaped. */
export interface ExpressionAggregation {
expression: string;
}
export interface OrderBy {
key: { name: string };
direction: 'asc' | 'desc';
}
export interface BuilderQuerySpec {
name: string;
signal: Signal;
aggregations: (MetricAggregation | ExpressionAggregation)[];
filter?: { expression: string };
groupBy?: { name: string }[];
order?: OrderBy[];
limit?: number;
}
export type QueryPlugin =
| { kind: 'signoz/BuilderQuery'; spec: BuilderQuerySpec }
| { kind: 'signoz/PromQLQuery'; spec: { name: string; query: string } }
| { kind: 'signoz/ClickHouseSQL'; spec: { name: string; query: string } };
/**
* List reads `raw`, Heatmap `heatmap`; every other kind reads `time_series`.
* Mixing them renders empty.
*/
export type QueryResultKind = 'time_series' | 'raw' | 'heatmap';
export interface Query {
kind: QueryResultKind;
spec: { plugin: QueryPlugin };
}
// ─── Panel plugin specs ──────────────────────────────────────────────────
export interface Threshold {
value: number;
color: string;
operator?:
| 'above'
| 'aboveOrEqual'
| 'below'
| 'belowOrEqual'
| 'equal'
| 'notEqual';
format?: 'background' | 'text';
unit?: string;
label?: string;
columnName?: string;
}
export type StackMode = 'none' | 'normal' | 'percent';
export type HeatmapPalette =
| 'ice'
| 'moss'
| 'rust'
| 'graphite'
| 'ember'
| 'lagoon'
| 'orchid'
| 'verdant'
| 'lava'
| 'beacon';
export interface HeatmapColors {
mode?: 'palette' | 'opacity';
palette?: HeatmapPalette;
scale?: 'log' | 'sqrt' | 'linear';
/** 2–128. */
steps?: number;
minCount?: number | null;
maxCount?: number | null;
/** Opacity mode only. */
fill?: string;
}
export interface TextPresentation {
textAlign?: 'left' | 'center' | 'right';
verticalAlign?: 'top' | 'center' | 'bottom';
/** A hex colour. */
background?: string;
}
/**
* One bag for every kind; the server's strict decoder rejects a key the kind
* does not declare, so a mismatch fails the seed rather than rendering wrong.
*/
export interface PanelPluginSpec {
visualization?: {
timePreference?: string;
fillSpans?: boolean;
stackedBarChart?: boolean;
/** Area only. */
stack?: StackMode;
};
formatting?: {
unit?: string;
decimalPrecision?: string;
columnUnits?: Record<string, string>;
};
axes?: {
softMin?: number | null;
softMax?: number | null;
isLogScale?: boolean;
/** Heatmap only, and its only axis key. */
y?: { scale: 'auto' | 'linear' | 'log' | 'symlog' };
};
legend?: {
position?: 'bottom' | 'right';
customColors?: Record<string, string>;
};
chartAppearance?: {
lineStyle?: 'solid' | 'dashed';
lineInterpolation?: 'linear' | 'spline' | 'step_before' | 'step_after';
/** Area drops `none`. */
fillMode?: 'none' | 'solid' | 'gradient';
/** Area only, 0–1. */
fillOpacity?: number;
showPoints?: boolean;
spanGaps?: { fillOnlyBelow?: boolean; fillLessThan?: string };
/** Heatmap only, and its only appearance key. */
colors?: HeatmapColors;
};
histogramBuckets?: {
bucketCount?: number;
bucketWidth?: number;
mergeAllActiveQueries?: boolean;
};
selectFields?: { name: string; signal?: Signal; fieldDataType?: string }[];
thresholds?: Threshold[];
/** Text only. */
mode?: 'markdown';
text?: string;
presentation?: TextPresentation;
headerOptions?: { hide: boolean };
}
/**
* Perses' link model — NOT the editor dialog's field names. The dialog labels
* its first field "Label", but it persists as `name`; there is no `label` key
* and the strict decoder rejects one.
*/
export interface ContextLink {
name: string;
url: string;
tooltip?: string;
renderVariables?: boolean;
targetBlank?: boolean;
}
export interface Panel {
kind: 'Panel';
spec: {
display: { name: string; description?: string };
links: ContextLink[];
plugin: { kind: PanelKind; spec: PanelPluginSpec };
/** Empty for Text, exactly one query for every other kind. */
queries: Query[];
};
}
// ─── Layout ──────────────────────────────────────────────────────────────
export interface GridItem {
x: number;
y: number;
width: number;
height: number;
content: { $ref: string };
}
export interface Layout {
kind: 'Grid';
spec: { display: { title: string }; items: GridItem[] };
}
// ─── Variables ───────────────────────────────────────────────────────────
export type Variable =
| {
kind: 'ListVariable';
spec: {
name: string;
display: { name: string };
allowAllValue: boolean;
allowMultiple: boolean;
sort: 'none' | 'alphabetical-asc' | 'alphabetical-desc';
plugin:
| { kind: 'signoz/CustomVariable'; spec: { customValue: string } }
| { kind: 'signoz/QueryVariable'; spec: { queryValue: string } }
| {
kind: 'signoz/DynamicVariable';
spec: { name: string; signal: Signal };
};
};
}
| {
kind: 'TextVariable';
spec: { name: string; display: { name: string }; value: string };
};
// ─── Dashboard ───────────────────────────────────────────────────────────
export interface DashboardSpec {
display: { name: string; description?: string };
variables: Variable[];
panels: Record<string, Panel>;
layouts: Layout[];
links: ContextLink[];
duration?: string;
}
export interface Tag {
key: string;
value: string;
}
export interface PostableDashboard {
schemaVersion: 'v6';
/** Must be EMPTY when `generateName` is true; the server derives it. */
name: string;
generateName: boolean;
tags: Tag[];
spec: DashboardSpec;
}
/** The backend pins this; a mismatch fails validation before anything else. */
export const SCHEMA_VERSION = 'v6' as const;
// ─── Golden-dataset constants ────────────────────────────────────────────
//
// What `seed/golden` writes: four metrics over 8 services, 6h of 5-min buckets.
export const GOLDEN = {
metrics: {
calls: 'signoz_calls_total',
latencyCount: 'signoz_latency_count',
latencySum: 'signoz_latency_sum',
dbLatencyCount: 'signoz_db_latency_count',
},
services: [
'adservice',
'cartservice',
'checkoutservice',
'currencyservice',
'frontend',
'paymentservice',
'productcatalogservice',
'shippingservice',
],
environment: 'production',
/** Golden telemetry spans the last 6 hours, so windows wider than this add nothing. */
windowHours: 6,
} as const;
// ─── Query builders ──────────────────────────────────────────────────────
/** A rate-over-counter metrics query grouped by service — the default fixture query. */
export function metricsQuery(options?: {
name?: string;
metricName?: string;
timeAggregation?: string;
spaceAggregation?: string;
reduceTo?: string;
filter?: string;
groupBy?: string[];
}): Query {
return {
kind: 'time_series',
spec: {
plugin: {
kind: 'signoz/BuilderQuery',
spec: {
name: options?.name ?? 'A',
signal: 'metrics',
aggregations: [
{
metricName: options?.metricName ?? GOLDEN.metrics.calls,
timeAggregation: options?.timeAggregation ?? 'rate',
spaceAggregation: options?.spaceAggregation ?? 'sum',
reduceTo: options?.reduceTo ?? 'sum',
},
],
filter: { expression: options?.filter ?? '' },
groupBy: (options?.groupBy ?? ['service.name']).map((name) => ({
name,
})),
},
},
},
};
}
/** A `raw` logs/traces query for List panels — ordered newest-first like the UI seeds it. */
export function rawQuery(options?: {
name?: string;
signal?: Extract<Signal, 'logs' | 'traces'>;
filter?: string;
limit?: number;
}): Query {
const signal = options?.signal ?? 'logs';
return {
kind: 'raw',
spec: {
plugin: {
kind: 'signoz/BuilderQuery',
spec: {
name: options?.name ?? 'A',
signal,
aggregations: [{ expression: 'count()' }],
filter: { expression: options?.filter ?? '' },
groupBy: [],
order: [
{ key: { name: 'timestamp' }, direction: 'desc' },
{ key: { name: 'id' }, direction: 'desc' },
],
...(options?.limit === undefined ? {} : { limit: options.limit }),
},
},
},
};
}
/**
* A logs/traces `count()` shaped as `time_series` — what Table and the chart
* kinds read (unlike `rawQuery`, which only List consumes). Also the only
* non-metrics seed that saves without picking a metric.
*/
export function logsCountQuery(options?: {
name?: string;
signal?: Extract<Signal, 'logs' | 'traces'>;
filter?: string;
groupBy?: string[];
}): Query {
return {
kind: 'time_series',
spec: {
plugin: {
kind: 'signoz/BuilderQuery',
spec: {
name: options?.name ?? 'A',
signal: options?.signal ?? 'logs',
aggregations: [{ expression: 'count()' }],
filter: { expression: options?.filter ?? '' },
groupBy: (options?.groupBy ?? ['service.name']).map((name) => ({
name,
})),
},
},
},
};
}
/** A metrics builder query shaped as `heatmap`, the only result kind Heatmap reads. */
export function heatmapQuery(
options?: Parameters<typeof metricsQuery>[0],
): Query {
return { ...metricsQuery(options), kind: 'heatmap' };
}
export function promqlQuery(query: string, name = 'A'): Query {
return {
kind: 'time_series',
spec: { plugin: { kind: 'signoz/PromQLQuery', spec: { name, query } } },
};
}
export function clickhouseQuery(query: string, name = 'A'): Query {
return {
kind: 'time_series',
spec: { plugin: { kind: 'signoz/ClickHouseSQL', spec: { name, query } } },
};
}
// ─── Panel builder ───────────────────────────────────────────────────────
export interface PanelOptions {
name: string;
description?: string;
pluginSpec?: PanelPluginSpec;
/**
* Defaults to a metrics query shaped for the kind: `raw` for List, `heatmap`
* for Heatmap. Ignored for Text, which carries none.
*/
query?: Query;
links?: ContextLink[];
}
function defaultQueryFor(kind: PanelKind): Query {
if (kind === PanelKind.List) {
return rawQuery();
}
if (kind === PanelKind.Heatmap) {
return heatmapQuery();
}
return metricsQuery();
}
export function panel(kind: PanelKind, options: PanelOptions): Panel {
return {
kind: 'Panel',
spec: {
display: {
name: options.name,
...(options.description === undefined
? {}
: { description: options.description }),
},
links: options.links ?? [],
plugin: { kind, spec: options.pluginSpec ?? {} },
queries:
kind === PanelKind.Text ? [] : [options.query ?? defaultQueryFor(kind)],
},
};
}
// ─── Dashboard composer ──────────────────────────────────────────────────
export interface SectionFixture {
title: string;
/** Keys become panel ids AND test handles, so keep them readable and unique. */
panels: Record<string, Panel>;
}
export interface DashboardFixtureOptions {
/** Defaults to a unique value; the server derives the internal name. */
title?: string;
description?: string;
sections: SectionFixture[];
variables?: Variable[];
duration?: string;
tags?: Tag[];
}
// pid distinguishes workers; the counter distinguishes seeds within one.
let fixtureSeq = 0;
function nextFixtureId(): string {
fixtureSeq += 1;
return `${process.pid}-${fixtureSeq}`;
}
/** Grid is 12 columns wide (SectionGrid `cols`), so 6×6 tiles two per row. */
const PANEL_WIDTH = 6;
const PANEL_HEIGHT = 6;
const GRID_COLS = 12;
const PER_ROW = GRID_COLS / PANEL_WIDTH;
/** Two panels per row. Duplicate ids across sections are rejected loudly. */
export function buildDashboard(
options: DashboardFixtureOptions,
): PostableDashboard {
const panels: Record<string, Panel> = {};
const layouts: Layout[] = [];
for (const section of options.sections) {
const items: GridItem[] = [];
let index = 0;
for (const [id, panelSpec] of Object.entries(section.panels)) {
if (panels[id]) {
throw new Error(`duplicate panel id ${id} in dashboard fixture`);
}
panels[id] = panelSpec;
items.push({
x: (index % PER_ROW) * PANEL_WIDTH,
y: Math.floor(index / PER_ROW) * PANEL_HEIGHT,
width: PANEL_WIDTH,
height: PANEL_HEIGHT,
content: { $ref: `#/spec/panels/${id}` },
});
index += 1;
}
layouts.push({
kind: 'Grid',
spec: { display: { title: section.title }, items },
});
}
const title = options.title ?? `e2e-dashboard-${nextFixtureId()}`;
return {
schemaVersion: SCHEMA_VERSION,
name: '',
generateName: true,
tags: options.tags ?? [],
spec: {
display: {
name: title,
...(options.description === undefined
? {}
: { description: options.description }),
},
variables: options.variables ?? [],
panels,
layouts,
links: [],
...(options.duration === undefined ? {} : { duration: options.duration }),
},
};
}

View File

@@ -0,0 +1,343 @@
import type { Page, Route } from '@playwright/test';
// Pinned `/api/v5/query_range` payloads. Install BEFORE `page.goto`.
//
// Prefer golden data and assert STRUCTURE: the dataset's content is
// deterministic but `seed/golden` rebases timestamps to `now`, so labels and
// counts are stable while exact numbers are not. Reach for this only when the
// backend can't promise what the test needs — error/no-data/warning states,
// exact numbers for thresholds and units, a known series count, or stable
// pagination.
//
// Envelope (double-nested, easy to get wrong), mirroring `QueryRangeV5200`:
// { status, data: { type, meta, warning, data: { results: [...] } } }
// `data.type` is the discriminator — a body under the wrong type renders as an
// empty panel rather than failing loudly.
const QUERY_RANGE_GLOB = '**/api/v5/query_range';
type RequestType = 'time_series' | 'scalar' | 'raw' | 'trace' | 'heatmap';
export interface QueryRangeBody {
status: string;
data: {
type: RequestType;
data: { results: unknown[] };
meta?: Record<string, unknown>;
warning?: Record<string, unknown>;
};
}
function envelope(type: RequestType, results: unknown[]): QueryRangeBody {
return { status: 'success', data: { type, data: { results } } };
}
// ─── Time helpers ────────────────────────────────────────────────────────
const MINUTE_MS = 60_000;
/**
* The default end of a generated series: now, floored to the minute, so series
* built by separate calls share their timestamps instead of straddling a
* millisecond.
*/
function defaultEndMs(): number {
return Math.floor(Date.now() / MINUTE_MS) * MINUTE_MS;
}
export interface TimeSeriesPoint {
timestamp: number;
value: number;
}
export interface SeriesSpec {
labels: Record<string, string>;
points: TimeSeriesPoint[];
}
export interface HeatmapPoint {
timestamp: number;
/** One count per bucket bound, plus a trailing open-above overflow. */
counts: number[];
}
export interface HeatmapSeriesSpec {
labels: Record<string, string>;
points: HeatmapPoint[];
}
/** Evenly spaced epoch-ms timestamps ending at `endMs`. */
export function timestamps(
count: number,
stepMinutes = 5,
endMs = defaultEndMs(),
): number[] {
return Array.from(
{ length: count },
(_, i) => endMs - (count - 1 - i) * stepMinutes * MINUTE_MS,
);
}
/** A ramp of `count` points from `from` to `to` — readable, non-flat test data. */
export function ramp(
count: number,
from: number,
to: number,
endMs = defaultEndMs(),
): TimeSeriesPoint[] {
const step = count > 1 ? (to - from) / (count - 1) : 0;
return timestamps(count, 5, endMs).map((timestamp, i) => ({
timestamp,
value: from + step * i,
}));
}
/** A flat line — use when the assertion is about a specific value, not a shape. */
export function flat(
count: number,
value: number,
endMs = defaultEndMs(),
): TimeSeriesPoint[] {
return timestamps(count, 5, endMs).map((timestamp) => ({
timestamp,
value,
}));
}
/**
* `count` columns of `width` cells, every count non-zero and growing with both
* time and bucket, so any hovered cell has a value and the colour domain is known:
* 1 … count × width.
*/
export function heatmapPoints(
count: number,
width: number,
endMs = defaultEndMs(),
): HeatmapPoint[] {
return timestamps(count, 5, endMs).map((timestamp, i) => ({
timestamp,
counts: Array.from({ length: width }, (_, j) => (i + 1) * (j + 1)),
}));
}
// ─── Payload builders ────────────────────────────────────────────────────
export const QueryRange = {
/** Series must nest at `results[].aggregations[].series[]`. */
timeSeries(series: SeriesSpec[], queryName = 'A'): QueryRangeBody {
return envelope('time_series', [
{
queryName,
aggregations: [
{
index: 0,
alias: '',
series: series.map((s) => ({
labels: Object.entries(s.labels).map(([name, value]) => ({
key: { name },
value,
})),
values: s.points.map((p) => ({
timestamp: p.timestamp,
value: p.value,
})),
})),
},
],
},
]);
},
/** Rows are positional across `[...groupColumns, ...aggregationColumns]`. */
scalar(options: {
groupColumns?: string[];
aggregationColumns?: string[];
rows: (string | number)[][];
queryName?: string;
units?: Record<string, string>;
}): QueryRangeBody {
const queryName = options.queryName ?? 'A';
const group = (options.groupColumns ?? []).map((name) => ({
name,
queryName,
columnType: 'group',
...(options.units?.[name] ? { meta: { unit: options.units[name] } } : {}),
}));
const aggregation = (options.aggregationColumns ?? []).map((name, index) => ({
name,
queryName,
columnType: 'aggregation',
aggregationIndex: index,
...(options.units?.[name] ? { meta: { unit: options.units[name] } } : {}),
}));
return envelope('scalar', [
{ queryName, columns: [...group, ...aggregation], data: options.rows },
]);
},
/** List rows. `timestamp` is RFC-3339 here, not epoch ms — per the contract. */
raw(
rows: Record<string, unknown>[],
options?: { queryName?: string; nextCursor?: string },
): QueryRangeBody {
return envelope('raw', [
{
queryName: options?.queryName ?? 'A',
...(options?.nextCursor ? { nextCursor: options.nextCursor } : {}),
rows: rows.map((data) => ({
timestamp:
typeof data.timestamp === 'string'
? data.timestamp
: new Date(Number(data.timestamp ?? 0)).toISOString(),
data,
})),
},
]);
},
/**
* Bucket bounds sit on the aggregation's `meta`; without them the panel
* draws no grid. `series: []` is the heatmap's own no-data body.
*/
heatmap(
buckets: number[],
series: HeatmapSeriesSpec[],
queryName = 'A',
): QueryRangeBody {
return envelope('heatmap', [
{
queryName,
aggregations: [
{
index: 0,
alias: '',
meta: { buckets },
series: series.map((s) => ({
labels: Object.entries(s.labels).map(([name, value]) => ({
key: { name },
value,
})),
values: s.points.map((p) => ({
timestamp: p.timestamp,
values: p.counts,
})),
})),
},
],
},
]);
},
/** Successful but empty — drives `panel-no-data`. */
empty(queryName = 'A'): QueryRangeBody {
return envelope('time_series', [{ queryName, aggregations: [] }]);
},
/** Successful with a warning — drives the `panel-status-warning` indicator. */
withWarning(body: QueryRangeBody, message: string): QueryRangeBody {
return {
...body,
data: {
...body.data,
warning: { warnings: [{ message }], message },
},
};
},
};
// ─── Route installers ────────────────────────────────────────────────────
export interface MockOptions {
/** Stop intercepting after N calls, letting later requests hit the backend. */
times?: number;
}
/** Fulfil every query_range call with one pinned body. */
export async function mockQueryRange(
page: Page,
body: QueryRangeBody,
options?: MockOptions,
): Promise<void> {
let served = 0;
await page.route(QUERY_RANGE_GLOB, async (route: Route) => {
if (options?.times !== undefined && served >= options.times) {
await route.fallback();
return;
}
served += 1;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body),
});
});
}
/** Fail every query_range call — drives `panel-error`. */
export async function mockQueryRangeError(
page: Page,
message = 'mocked query failure',
options?: MockOptions & { status?: number },
): Promise<void> {
let served = 0;
await page.route(QUERY_RANGE_GLOB, async (route: Route) => {
if (options?.times !== undefined && served >= options.times) {
await route.fallback();
return;
}
served += 1;
await route.fulfill({
status: options?.status ?? 500,
contentType: 'application/json',
body: JSON.stringify({
status: 'error',
error: { code: 'internal', message },
}),
});
});
}
/** One body per call, repeating the last — for pagination and retry flows. */
export async function mockQueryRangeSequence(
page: Page,
bodies: QueryRangeBody[],
): Promise<void> {
let call = 0;
await page.route(QUERY_RANGE_GLOB, async (route: Route) => {
const body = bodies[Math.min(call, bodies.length - 1)];
call += 1;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body),
});
});
}
/** Record request bodies without changing responses. */
export function recordQueryRange(page: Page): {
requests: Record<string, unknown>[];
install: () => Promise<void>;
} {
const requests: Record<string, unknown>[] = [];
return {
requests,
install: async () => {
await page.route(QUERY_RANGE_GLOB, async (route: Route) => {
const payload = route.request().postDataJSON() as Record<
string,
unknown
> | null;
if (payload) {
requests.push(payload);
}
await route.fallback();
});
},
};
}
/** Drop the interception so later navigation reaches the real backend again. */
export async function unmockQueryRange(page: Page): Promise<void> {
await page.unroute(QUERY_RANGE_GLOB);
}

View File

@@ -36,11 +36,6 @@ for (const file of ['.env', '.env.local']) {
export default defineConfig({
testDir: './tests',
// Temporarily excluded: the V1 -> V2 dashboard migration changes the
// behaviour the dashboards specs assert against, so they fail as written.
// Remove this once they are updated for the V2 dashboard.
testIgnore: ['**/tests/dashboards/**'],
// All Playwright output lands under artifacts/. One subdir per reporter
// plus results/ for per-test artifacts (traces/screenshots/videos).
// CI can archive the whole dir with `tar czf artifacts.tgz tests/e2e/artifacts`.

359
tests/e2e/testdata/dashboards/panels.ts vendored Normal file
View File

@@ -0,0 +1,359 @@
import {
GOLDEN,
PanelKind,
clickhouseQuery,
buildDashboard,
heatmapQuery,
metricsQuery,
panel,
promqlQuery,
rawQuery,
type ContextLink,
type PanelPluginSpec,
type PostableDashboard,
type Query,
type Variable,
} from '../../helpers/dashboard-spec';
// Dashboard fixtures for the dashboard suites. TypeScript rather than frozen JSON so
// a contract change is a compile error, and panel ids stay visible to the specs
// that use them as test handles.
let seq = 0;
function uniqueSuffix(): string {
seq += 1;
return `${process.pid}-${seq}`;
}
// ─── All kinds ───────────────────────────────────────────────────────────
export const ALL_KINDS_PANELS = {
timeseries: 'ts-panel',
bar: 'bar-panel',
histogram: 'histogram-panel',
number: 'number-panel',
pie: 'pie-panel',
table: 'table-panel',
list: 'list-panel',
} as const;
/**
* One panel of every kind. Only for "every kind renders" and lazy-load — use
* `compactDashboard` otherwise; seven panels means seven real queries.
*/
export function allKindsDashboard(title?: string): PostableDashboard {
return buildDashboard({
title: title ?? `e2e-all-kinds-${Date.now()}-${uniqueSuffix()}`,
sections: [
{
title: 'Charts',
panels: {
[ALL_KINDS_PANELS.timeseries]: panel(PanelKind.TimeSeries, {
name: 'Calls by service',
description: 'Rate of signoz_calls_total grouped by service.name',
}),
[ALL_KINDS_PANELS.bar]: panel(PanelKind.BarChart, {
name: 'Calls bar',
}),
[ALL_KINDS_PANELS.histogram]: panel(PanelKind.Histogram, {
name: 'Latency distribution',
query: metricsQuery({
metricName: GOLDEN.metrics.latencySum,
groupBy: [],
}),
}),
[ALL_KINDS_PANELS.pie]: panel(PanelKind.PieChart, {
name: 'Calls share',
}),
},
},
{
title: 'Tabular',
panels: {
[ALL_KINDS_PANELS.number]: panel(PanelKind.Number, {
name: 'Total calls',
pluginSpec: { formatting: { unit: 'short', decimalPrecision: '2' } },
query: metricsQuery({ groupBy: [] }),
}),
[ALL_KINDS_PANELS.table]: panel(PanelKind.Table, {
name: 'Calls table',
}),
[ALL_KINDS_PANELS.list]: panel(PanelKind.List, {
name: 'Recent logs',
pluginSpec: {
selectFields: [
{ name: 'timestamp', signal: 'logs' },
{ name: 'body', signal: 'logs' },
],
},
query: rawQuery({ signal: 'logs' }),
}),
},
},
],
});
}
// ─── Compact (default multi-panel fixture) ───────────────────────────────
export const COMPACT_PANELS = {
timeseries: ALL_KINDS_PANELS.timeseries,
table: ALL_KINDS_PANELS.table,
list: ALL_KINDS_PANELS.list,
} as const;
/**
* The default multi-panel fixture: three panels, two sections.
*
* Every panel fires its own `query_range`, and over-seeding was the dominant
* load on the single-container stack. These three keep every distinction the
* specs assert — TimeSeries (charts, zoom, drilldown, createAlert), Table (CSV
* download, search), List (search, no createAlert) — and two sections keep
* Move-to-section and the modal's select-then-confirm branch reachable.
*/
export function compactDashboard(title?: string): PostableDashboard {
return buildDashboard({
title: title ?? `e2e-compact-${Date.now()}-${uniqueSuffix()}`,
sections: [
{
title: 'Charts',
panels: {
[COMPACT_PANELS.timeseries]: panel(PanelKind.TimeSeries, {
name: 'Calls by service',
description: 'Rate of signoz_calls_total grouped by service.name',
}),
},
},
{
title: 'Tabular',
panels: {
[COMPACT_PANELS.table]: panel(PanelKind.Table, {
name: 'Calls table',
}),
[COMPACT_PANELS.list]: panel(PanelKind.List, {
name: 'Recent logs',
pluginSpec: {
selectFields: [
{ name: 'timestamp', signal: 'logs' },
{ name: 'body', signal: 'logs' },
],
},
query: rawQuery({ signal: 'logs' }),
}),
},
},
],
});
}
// ─── Single panel ────────────────────────────────────────────────────────
export const SINGLE_PANEL_ID = 'solo-panel';
export interface SinglePanelOptions {
title?: string;
kind?: PanelKind;
panelName?: string;
/** Plugin config (legend, axes, formatting, thresholds, …) for the panel. */
pluginSpec?: PanelPluginSpec;
/** Replaces the kind's default query. */
query?: Query;
/** Context links attached to the panel. */
links?: ContextLink[];
/** Attaches the `VARIABLE_NAMES` variables, for interpolation cases. */
withVariables?: boolean;
}
/**
* One panel, one section. Fully configurable so specs never reach into the
* returned object — post-hoc mutation is four levels deep and breaks silently
* when the spec shape moves.
*/
export function singlePanelDashboard(
options: SinglePanelOptions = {},
): PostableDashboard {
return buildDashboard({
title: options.title ?? `e2e-single-${Date.now()}-${uniqueSuffix()}`,
...(options.withVariables ? { variables: VARIABLES } : {}),
sections: [
{
title: 'Section',
panels: {
[SINGLE_PANEL_ID]: panel(options.kind ?? PanelKind.TimeSeries, {
name: options.panelName ?? 'Solo panel',
...(options.pluginSpec ? { pluginSpec: options.pluginSpec } : {}),
...(options.query ? { query: options.query } : {}),
...(options.links ? { links: options.links } : {}),
}),
},
},
],
});
}
/** No panels at all — drives the dashboard empty state and its New Panel CTA. */
export function emptyDashboard(title?: string): PostableDashboard {
return buildDashboard({
title: title ?? `e2e-empty-${Date.now()}-${uniqueSuffix()}`,
sections: [],
});
}
// ─── Query-type coverage ─────────────────────────────────────────────────
export const QUERY_TYPE_PANELS = {
builder: 'qb-panel',
promql: 'promql-panel',
clickhouse: 'chsql-panel',
} as const;
/** One panel per query type, for the capability and drilldown-gating specs. */
export function queryTypesDashboard(title?: string): PostableDashboard {
return buildDashboard({
title: title ?? `e2e-query-types-${Date.now()}-${uniqueSuffix()}`,
sections: [
{
title: 'Query types',
panels: {
[QUERY_TYPE_PANELS.builder]: panel(PanelKind.TimeSeries, {
name: 'Builder query',
}),
[QUERY_TYPE_PANELS.promql]: panel(PanelKind.TimeSeries, {
name: 'PromQL query',
query: promqlQuery(`sum(rate(${GOLDEN.metrics.calls}[5m]))`),
}),
[QUERY_TYPE_PANELS.clickhouse]: panel(PanelKind.Table, {
name: 'ClickHouse query',
query: clickhouseQuery(
"SELECT now() AS ts, 'adservice' AS service, 1 AS A",
),
}),
},
},
],
});
}
// ─── Variables ───────────────────────────────────────────────────────────
export const VARIABLE_NAMES = {
custom: 'serviceCustom',
dynamic: 'serviceDynamic',
} as const;
const VARIABLES: Variable[] = [
{
kind: 'ListVariable',
spec: {
name: VARIABLE_NAMES.custom,
display: { name: VARIABLE_NAMES.custom },
allowAllValue: false,
allowMultiple: false,
sort: 'none',
plugin: {
kind: 'signoz/CustomVariable',
// Closed list: resolves instantly, no backend round-trip.
spec: { customValue: GOLDEN.services.slice(0, 3).join(',') },
},
},
},
{
kind: 'ListVariable',
spec: {
name: VARIABLE_NAMES.dynamic,
display: { name: VARIABLE_NAMES.dynamic },
allowAllValue: true,
allowMultiple: true,
sort: 'alphabetical-asc',
plugin: {
kind: 'signoz/DynamicVariable',
spec: { name: 'service.name', signal: 'metrics' },
},
},
},
];
export const VARIABLE_PANEL_ID = 'var-panel';
/** A panel filtered by a dashboard variable — backs the drilldown variable cases. */
export function variablesDashboard(
title?: string,
query?: Query,
): PostableDashboard {
return buildDashboard({
title: title ?? `e2e-variables-${Date.now()}-${uniqueSuffix()}`,
variables: VARIABLES,
sections: [
{
title: 'Variables',
panels: {
[VARIABLE_PANEL_ID]: panel(PanelKind.TimeSeries, {
name: 'Calls for $serviceCustom',
query:
query ??
metricsQuery({
filter: `service.name = $${VARIABLE_NAMES.custom}`,
}),
}),
},
},
],
});
}
// ─── Area / Heatmap / Text ───────────────────────────────────────────────
export const NEW_KINDS_PANELS = {
area: 'area-panel',
heatmap: 'heatmap-panel',
text: 'md-panel',
} as const;
export const NEW_KINDS_MARKDOWN =
'## Runbook\n\nCheck the **checkout** path first.';
/**
* One Area, one Heatmap and one Text panel — the kinds added after the
* all-kinds fixture, kept apart so its seven queries don't grow.
*/
export function newKindsDashboard(title?: string): PostableDashboard {
return buildDashboard({
title: title ?? `e2e-new-kinds-${Date.now()}-${uniqueSuffix()}`,
sections: [
{
title: 'New kinds',
panels: {
[NEW_KINDS_PANELS.area]: panel(PanelKind.AreaChart, {
name: 'Calls area',
}),
[NEW_KINDS_PANELS.heatmap]: panel(PanelKind.Heatmap, {
name: 'Calls heatmap',
query: heatmapQuery(),
}),
[NEW_KINDS_PANELS.text]: panel(PanelKind.Text, {
name: 'Runbook',
pluginSpec: { text: NEW_KINDS_MARKDOWN },
}),
},
},
],
});
}
/** Invalid on purpose: Text must carry `queries: []`; the server rejects this. */
export function textPanelWithQueryDashboard(): PostableDashboard {
const text = panel(PanelKind.Text, { name: 'Notes' });
return buildDashboard({
title: `e2e-text-with-query-${Date.now()}-${uniqueSuffix()}`,
sections: [
{
title: 'Section',
panels: {
[SINGLE_PANEL_ID]: {
...text,
spec: { ...text.spec, queries: [metricsQuery()] },
},
},
},
],
});
}

View File

@@ -0,0 +1,139 @@
import {
type DownloadFormat,
expect,
PANEL_KINDS,
PanelAction,
respondWithData,
SINGLE_PANEL_ID,
test,
withSpareSection,
} from '../../../drivers/panel-kinds';
// Scope: what each kind's `actions` declaration turns into on the grid — the ⋮
// menu, its downloads and Create Alerts, drilldown, and header search. Items
// carry no testid, so the menu matches role + visible label.
const DOWNLOADS: DownloadFormat[] = ['CSV', 'PNG', 'SVG'];
const ALWAYS_OFFERED = [
PanelAction.view,
PanelAction.edit,
PanelAction.clone,
PanelAction.move,
PanelAction.delete,
];
test.describe('Dashboards contract — panel actions', () => {
for (const c of PANEL_KINDS) {
const declared = [
...(c.createAlert ? [PanelAction.createAlert] : []),
...(c.downloads.length > 0 ? [PanelAction.download] : []),
];
const missing = [PanelAction.createAlert, PanelAction.download].filter(
(label) => !declared.includes(label),
);
test(`TC-01 ${c.label}: the actions menu offers the full set its declaration allows`, async ({
dashboards,
panels,
}) => {
// A second section, so Move to section has somewhere to go.
await dashboards.seedAndOpen(withSpareSection(c));
const { actions } = panels.panel(SINGLE_PANEL_ID);
await actions.open();
for (const label of [...ALWAYS_OFFERED, ...declared]) {
await expect(actions.item(label)).toBeVisible();
}
for (const label of missing) {
await expect(actions.item(label)).toHaveCount(0);
}
});
}
for (const c of PANEL_KINDS.filter((k) => k.downloads.length > 0)) {
const missing = DOWNLOADS.filter((format) => !c.downloads.includes(format));
test(`TC-02 ${c.label}: Download offers ${c.downloads.join(', ')} only`, async ({
dashboards,
panels,
}) => {
await dashboards.seedAndOpen(c.seed());
const { actions } = panels.panel(SINGLE_PANEL_ID);
await actions.open();
await actions.hoverItem(PanelAction.download);
for (const format of c.downloads) {
await expect(actions.item(`Download as ${format}`)).toBeVisible();
}
for (const format of missing) {
await expect(actions.item(`Download as ${format}`)).toHaveCount(0);
}
});
}
for (const c of PANEL_KINDS.filter((k) => k.drilldown)) {
test(`TC-03 ${c.label}: clicking the data opens the drilldown menu`, async ({
dashboards,
queryRange,
panels,
}) => {
await respondWithData(queryRange, c);
await dashboards.seedAndOpen(c.seed());
const target = panels.panel(SINGLE_PANEL_ID);
await target.waitRendered(c.kind);
await c.click(target);
await expect(panels.drilldown.menu()).toBeVisible();
await expect(panels.drilldown.item('drilldown-view-logs')).toBeVisible();
await expect(panels.drilldown.item('drilldown-view-traces')).toBeVisible();
await expect(panels.drilldown.item('drilldown-breakout')).toBeVisible();
});
}
for (const c of PANEL_KINDS.filter((k) => !k.drilldown)) {
test(`TC-04 ${c.label}: clicking the data opens no menu`, async ({
dashboards,
queryRange,
panels,
}) => {
await respondWithData(queryRange, c);
await dashboards.seedAndOpen(c.seed());
const target = panels.panel(SINGLE_PANEL_ID);
await target.waitRendered(c.kind);
await c.click(target);
await expect(panels.drilldown.menu()).toHaveCount(0);
});
}
for (const c of PANEL_KINDS.filter((k) => k.headerSearch)) {
test(`TC-05 ${c.label}: offers header search`, async ({
dashboards,
panels,
}) => {
await dashboards.seedAndOpen(c.seed());
const target = panels.panel(SINGLE_PANEL_ID);
await target.waitRendered(c.kind);
await target.root().hover();
await expect(target.search.trigger()).toBeVisible();
});
}
for (const c of PANEL_KINDS.filter((k) => !k.headerSearch)) {
test(`TC-06 ${c.label}: does not offer header search`, async ({
dashboards,
panels,
}) => {
await dashboards.seedAndOpen(c.seed());
const target = panels.panel(SINGLE_PANEL_ID);
await target.waitRendered(c.kind);
await target.root().hover();
await expect(target.search.trigger()).toHaveCount(0);
});
}
});

View File

@@ -0,0 +1,108 @@
import {
clickhouseQuery,
disabledReason,
expect,
GOLDEN,
logsCountQuery,
metricsQuery,
PANEL_KINDS,
PanelKind,
type PostableDashboard,
promqlQuery,
type QueryType,
type SelectOption,
type Signal,
SINGLE_PANEL_ID,
singlePanelDashboard,
test,
} from '../../../drivers/panel-kinds';
// Scope: the panelKind × queryType × signal matrix as the type switcher shows
// it. Each kind is checked against each source query: selectable, or disabled
// with the reason getPanelTypeDisabledReason gives.
interface SourceQuery {
name: string;
queryType: QueryType;
signal?: Signal;
seed(): PostableDashboard;
}
const SOURCES: SourceQuery[] = [
{
name: 'a metrics builder query',
queryType: 'builder',
signal: 'metrics',
seed: () => singlePanelDashboard({ query: metricsQuery() }),
},
{
name: 'a logs builder query',
queryType: 'builder',
signal: 'logs',
seed: () => singlePanelDashboard({ query: logsCountQuery() }),
},
{
name: 'a PromQL query',
queryType: 'promql',
seed: () =>
singlePanelDashboard({
query: promqlQuery(`sum(rate(${GOLDEN.metrics.calls}[5m]))`),
}),
},
{
name: 'a ClickHouse query',
queryType: 'clickhouse',
seed: () =>
singlePanelDashboard({
kind: PanelKind.Table,
query: clickhouseQuery(
"SELECT now() AS ts, 'adservice' AS service, 1 AS A",
),
}),
},
];
function optionFor(options: SelectOption[], label: string): SelectOption {
const match = options.find((option) => option.label.startsWith(label));
expect(
match,
`expected a "${label}" option in the type switcher`,
).toBeDefined();
return match as SelectOption;
}
test.describe('Dashboards contract — editor capabilities matrix', () => {
SOURCES.forEach((source, index) => {
const number = String(index + 1).padStart(2, '0');
for (const c of PANEL_KINDS) {
const reason = disabledReason(c, source);
if (reason === null) {
test(`TC-${number} ${c.label}: ${source.name} keeps it selectable`, async ({
dashboards,
editor,
}) => {
await dashboards.seedAndEdit(source.seed(), SINGLE_PANEL_ID);
const options = await editor.typeOptions();
expect(optionFor(options, c.label).disabled).toBe(false);
});
continue;
}
test(`TC-${number} ${c.label}: ${source.name} disables it with a reason`, async ({
dashboards,
editor,
panels,
}) => {
await dashboards.seedAndEdit(source.seed(), SINGLE_PANEL_ID);
const options = await editor.typeOptions();
expect(optionFor(options, c.label).disabled).toBe(true);
await editor.typeOption(c.kind).hover();
await expect(panels.tooltip.text(reason)).toBeVisible();
});
}
});
});

View File

@@ -0,0 +1,221 @@
import {
AXIS_CONTROLS,
emptyDashboard,
type EditorControl,
expect,
kindCase,
logsCountQuery,
metricsQuery,
PANEL_KINDS,
PanelKind,
type PanelKindCase,
type PostableDashboard,
QUERYABLE_KINDS,
QueryTab,
Section,
SINGLE_PANEL_ID,
singlePanelDashboard,
test,
type ThresholdVariant,
} from '../../../drivers/panel-kinds';
// Scope: what each kind declares in the editor — the picker tile, its config
// sections, query tabs, threshold variant and the List columns editor. Tabs a
// kind cannot use are hidden; the type switcher is the one that disables.
const THRESHOLD_VARIANTS: ThresholdVariant[] = ['label', 'comparison', 'table'];
const QUERY_TABS: string[] = Object.values(QueryTab);
/** A kind other than `c`, so switching to `c` is a real switch. */
function switchSourceKind(c: PanelKindCase): PanelKindCase {
return kindCase(
c.kind === PanelKind.TimeSeries ? PanelKind.BarChart : PanelKind.TimeSeries,
);
}
/** The source kind over a query `c` accepts: List takes no metrics. */
function switchSource(
c: PanelKindCase,
source: PanelKindCase,
): PostableDashboard {
return singlePanelDashboard({
kind: source.kind,
query:
c.signals.length === 0 || c.signals.includes('metrics')
? metricsQuery()
: logsCountQuery(),
});
}
function partition<T>(all: T[], shown: T[]): [T[], T[]] {
return [shown, all.filter((item) => !shown.includes(item))];
}
test.describe('Dashboards contract — panel editor', () => {
for (const c of PANEL_KINDS) {
test(`TC-01 ${c.label}: the New panel drawer offers it and opens its editor`, async ({
dashboards,
editor,
newPanel,
}) => {
const id = await dashboards.seed(emptyDashboard());
await dashboards.openEmpty(id);
await dashboards.emptyAddPanel().click();
await expect(newPanel.root()).toBeVisible();
await expect(newPanel.tiles()).toHaveCount(PANEL_KINDS.length);
await expect(newPanel.tile(c.kind)).toBeVisible();
await expect(newPanel.tile(c.kind)).toContainText(c.label);
await newPanel.choose(c.kind);
expect(dashboards.param('panelKind')).toBe(c.kind);
await expect(editor.root()).toBeVisible();
});
}
for (const c of PANEL_KINDS) {
const [shownControls, hiddenControls] = partition<EditorControl>(
['time-preference'],
c.timePreference ? ['time-preference'] : [],
);
test(`TC-02 ${c.label}: declares its sections and no others`, async ({
dashboards,
editor,
}) => {
await dashboards.seedAndEdit(c.seed(), SINGLE_PANEL_ID);
for (const title of c.sections) {
await expect(editor.section.toggle(title)).toBeVisible();
}
for (const title of c.forbiddenSections) {
await expect(editor.section.toggle(title)).toHaveCount(0);
}
await editor.section.expand(Section.visualization);
for (const control of shownControls) {
await expect(editor.control(control)).toBeVisible();
}
for (const control of hiddenControls) {
await expect(editor.control(control)).toHaveCount(0);
}
});
}
for (const c of PANEL_KINDS.filter((k) => k.sections.includes(Section.axes))) {
const [shown, hidden] = partition(AXIS_CONTROLS, c.axisControls);
test(`TC-03 ${c.label}: Axes offers ${shown.join(', ')} only`, async ({
dashboards,
editor,
}) => {
await dashboards.seedAndEdit(c.seed(), SINGLE_PANEL_ID);
await editor.section.expand(Section.axes);
for (const control of shown) {
await expect(editor.control(control)).toBeVisible();
}
for (const control of hidden) {
await expect(editor.control(control)).toHaveCount(0);
}
});
}
for (const c of PANEL_KINDS) {
const from = switchSourceKind(c);
test(`TC-04 ${c.label}: switching to it from ${from.label} brings its sections`, async ({
dashboards,
editor,
}) => {
await dashboards.seedAndEdit(switchSource(c, from), SINGLE_PANEL_ID);
for (const title of from.sections) {
await expect(editor.section.toggle(title)).toBeVisible();
}
await editor.switchType(c.kind);
for (const title of c.sections) {
await expect(editor.section.toggle(title)).toBeVisible();
}
for (const title of c.forbiddenSections) {
await expect(editor.section.toggle(title)).toHaveCount(0);
}
});
}
for (const c of QUERYABLE_KINDS) {
const [shown, hidden] = partition(QUERY_TABS, c.queryTabs);
test(`TC-05 ${c.label}: offers only the query tabs it supports`, async ({
dashboards,
editor,
}) => {
await dashboards.seedAndEdit(c.seed(), SINGLE_PANEL_ID);
for (const tab of shown) {
await expect(editor.query.tab(tab)).toBeVisible();
}
for (const tab of hidden) {
await expect(editor.query.tab(tab)).toHaveCount(0);
}
});
}
for (const c of PANEL_KINDS.filter((k) => k.queryless)) {
test(`TC-06 ${c.label}: has no query builder, tabs or columns editor`, async ({
dashboards,
editor,
config,
}) => {
await dashboards.seedAndEdit(c.seed(), SINGLE_PANEL_ID);
await expect(editor.text.pane()).toBeVisible();
await expect(editor.query.builder()).toHaveCount(0);
for (const tab of QUERY_TABS) {
await expect(editor.query.tab(tab)).toHaveCount(0);
}
await expect(config.listColumns.editor()).toHaveCount(0);
});
}
for (const c of PANEL_KINDS.filter((k) => k.thresholdVariant !== null)) {
const variant = c.thresholdVariant as ThresholdVariant;
const others = THRESHOLD_VARIANTS.filter((other) => other !== variant);
test(`TC-07 ${c.label}: Thresholds uses the ${variant} variant`, async ({
dashboards,
editor,
config,
}) => {
await dashboards.seedAndEdit(c.seed(), SINGLE_PANEL_ID);
await editor.section.expand(Section.thresholds);
await expect(config.thresholds.addButton(variant)).toBeVisible();
for (const other of others) {
await expect(config.thresholds.addButton(other)).toHaveCount(0);
}
});
}
for (const c of PANEL_KINDS.filter((k) => k.columnsEditor)) {
test(`TC-08 ${c.label}: has the columns editor`, async ({
dashboards,
config,
}) => {
await dashboards.seedAndEdit(c.seed(), SINGLE_PANEL_ID);
await expect(config.listColumns.editor()).toBeVisible();
});
}
for (const c of QUERYABLE_KINDS.filter((k) => !k.columnsEditor)) {
test(`TC-09 ${c.label}: has no columns editor`, async ({
dashboards,
editor,
config,
}) => {
await dashboards.seedAndEdit(c.seed(), SINGLE_PANEL_ID);
await expect(editor.query.builder()).toBeVisible();
await expect(config.listColumns.editor()).toHaveCount(0);
});
}
});

View File

@@ -0,0 +1,123 @@
import {
belowTheFold,
expect,
noDataOf,
PANEL_KINDS,
PanelMessageText,
QUERYABLE_KINDS,
type QueryRangeBody,
SINGLE_PANEL_ID,
test,
} from '../../../drivers/panel-kinds';
// Scope: every kind mounts its renderer, fetches lazily, and shows the shared
// PanelBody states. States are mock-only: a healthy stack always returns data.
function required(body: QueryRangeBody | null): QueryRangeBody {
expect(body, 'a queryable kind declares mock bodies').not.toBeNull();
return body as QueryRangeBody;
}
test.describe('Dashboards contract — rendering', () => {
for (const c of PANEL_KINDS) {
test(`TC-01 ${c.label}: mounts its own renderer`, async ({
dashboards,
panels,
}) => {
await dashboards.seedAndOpen(c.seed());
const target = panels.panel(SINGLE_PANEL_ID);
await target.waitRendered(c.kind);
await expect(target.renderer(c.kind)).toBeVisible();
});
}
for (const c of QUERYABLE_KINDS) {
test(`TC-02 ${c.label}: does not query below the fold until scrolled into view`, async ({
dashboards,
panels,
}) => {
await dashboards.seedAndOpen(belowTheFold(c));
const target = panels.panel(SINGLE_PANEL_ID);
await expect(target.visibility()).toHaveAttribute(
'data-panel-visible',
'false',
);
await expect(target.renderer(c.kind)).toHaveCount(0);
await target.scrollIntoView();
await expect(target.visibility()).toHaveAttribute(
'data-panel-visible',
'true',
);
await expect(target.renderer(c.kind)).toBeVisible();
});
}
for (const c of QUERYABLE_KINDS) {
test(`TC-03 ${c.label}: an empty result offers Extend time range as primary and Retry as secondary`, async ({
dashboards,
queryRange,
panels,
}) => {
await queryRange.respond(required(c.emptyBody()));
await dashboards.seedAndOpen(c.seed());
const target = panels.panel(SINGLE_PANEL_ID);
const noData = noDataOf(c, target);
await expect(noData.root()).toBeVisible();
await expect(target.text(PanelMessageText.noDataTitle)).toBeVisible();
await expect(noData.action()).toHaveText(PanelMessageText.extendAction);
await expect(noData.secondaryAction()).toHaveText(
PanelMessageText.retryAction,
);
await expect(target.heatmap.colorBar()).toHaveCount(0);
});
}
for (const c of QUERYABLE_KINDS) {
test(`TC-04 ${c.label}: a failing query shows the error state and Retry refetches`, async ({
dashboards,
queryRange,
panels,
}) => {
// Fail every call, then swap to success before Retry: react-query retries
// on its own, so "fail once then succeed" never surfaces the error state.
await queryRange.fail('querier exploded');
await dashboards.seedAndOpen(c.seed());
const target = panels.panel(SINGLE_PANEL_ID);
await expect(target.error()).toBeVisible();
await expect(target.statusError()).toBeVisible();
await expect(target.text(PanelMessageText.errorTitle)).toBeVisible();
await expect(target.text('querier exploded')).toBeVisible();
await queryRange.reset();
await queryRange.respond(required(c.body()));
await target.errorAction().click();
await expect(target.error()).toHaveCount(0);
await expect(target.renderer(c.kind)).toBeVisible();
});
}
for (const c of QUERYABLE_KINDS) {
test(`TC-05 ${c.label}: an in-flight first query shows the loading state`, async ({
dashboards,
queryRange,
panels,
}) => {
// LOADING, not `panel-refetching` — the refetch spinner needs prior data.
const held = await queryRange.hold(required(c.body()));
await dashboards.seedAndOpen(c.seed());
const target = panels.panel(SINGLE_PANEL_ID);
await expect(target.loading()).toBeVisible();
held.release();
await expect(target.renderer(c.kind)).toBeVisible();
await expect(target.loading()).toHaveCount(0);
});
}
});

View File

@@ -1,206 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
APM_METRICS_TITLE,
authToken,
createDashboardViaApi,
deleteDashboardViaApi,
createApmMetricsDashboardViaApi,
} from '../../../helpers/dashboards';
const seedIds = new Set<string>();
const BASE_TITLE = 'detail-viewing-base';
let baseDashboardId = '';
let apmDashboardId = '';
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
baseDashboardId = await createDashboardViaApi(page, BASE_TITLE);
seedIds.add(baseDashboardId);
apmDashboardId = await createApmMetricsDashboardViaApi(page);
seedIds.add(apmDashboardId);
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) {
return;
}
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of seedIds) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
async function gotoDetail(page: Page, id: string): Promise<void> {
await page.goto(`/dashboard/${id}`);
}
test.describe('Dashboard Detail Page — Viewing', () => {
test('TC-01 page chrome — breadcrumb, title, toolbar buttons render', async ({
authedPage: page,
}) => {
// Use the APM dashboard rather than the empty base — empty dashboards
// render an onboarding canvas with its own Configure / New Panel
// buttons, which duplicate the toolbar testids.
await gotoDetail(page, apmDashboardId);
await expect(
page.getByRole('button', { name: /Dashboard \// }),
).toBeVisible();
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
}),
).toBeVisible();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
await expect(page).toHaveTitle(new RegExp(APM_METRICS_TITLE));
await expect(
page.getByRole('textbox', { name: /Last \d+/ }).first(),
).toBeVisible();
await expect(page.locator('.refresh-btn button')).toBeVisible();
await expect(
page.getByRole('button', { name: 'Set auto refresh' }),
).toBeVisible();
await expect(page.getByTestId('options')).toBeVisible();
await expect(page.getByTestId('show-drawer')).toBeVisible();
await expect(page.getByTestId('add-panel-header')).toBeVisible();
await expect(page.getByRole('button', { name: 'Feedback' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
});
test('TC-02 breadcrumb returns to /dashboard', async ({
authedPage: page,
}) => {
await gotoDetail(page, baseDashboardId);
await expect(
page.getByRole('button', { name: /Dashboard \// }),
).toBeVisible();
// `dispatchEvent('click')` — the expanded sidenav intercepts pointer
// events at the breadcrumb's center, defeating even `force: true`.
// Dispatching the click directly on the DOM node bypasses hit testing.
await page
.getByRole('button', { name: 'Dashboard /' })
.dispatchEvent('click');
await expect(page).toHaveURL(/\/dashboard$/);
await expect(
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
).toBeVisible();
});
test('TC-03 tags bar renders for an imported dashboard', async ({
authedPage: page,
}) => {
await gotoDetail(page, apmDashboardId);
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
}),
).toBeVisible();
// `exact: true` is load-bearing — `apm` is a substring of the
// breadcrumb title `APM Metrics`, so a loose match would collide.
for (const tag of ['apm', 'latency', 'error rate', 'throughput']) {
await expect(page.getByText(tag, { exact: true })).toBeVisible();
}
});
test('TC-04 section row headers render for APM Metrics', async ({
authedPage: page,
}) => {
await gotoDetail(page, apmDashboardId);
// known behaviour: APM Metrics fixture has two sections both named
// "Overview" — `.first()` deliberately matches whichever renders first.
await expect(
page.getByText('Overview', { exact: true }).first(),
).toBeVisible();
await expect(
page.getByText('DB Metrics', { exact: true }).first(),
).toBeVisible();
await expect(
page.getByText('External calls', { exact: true }).first(),
).toBeVisible();
});
test('TC-05 at least one panel container renders', async ({
authedPage: page,
}) => {
await gotoDetail(page, apmDashboardId);
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
}),
).toBeVisible();
await expect(
page.getByText('Latency', { exact: true }).first(),
).toBeVisible();
});
test('TC-06 no JS pageerror during initial load', async ({
authedPage: page,
}) => {
const errors: Error[] = [];
page.on('pageerror', (err) => errors.push(err));
await gotoDetail(page, apmDashboardId);
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
}),
).toBeVisible();
await expect(
page.getByText('Latency', { exact: true }).first(),
).toBeVisible();
expect(errors).toHaveLength(0);
});
// ─── Cross-spec: connection with the dashboards-list page ────────────────
test('TC-07 navigating from the dashboards list lands on the detail page', async ({
authedPage: page,
}) => {
await page.goto('/dashboard');
await expect(
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
).toBeVisible();
await page
.getByPlaceholder('Search by name, description, or tags...')
.fill(APM_METRICS_TITLE);
await expect(page.getByText(APM_METRICS_TITLE).first()).toBeVisible();
const actionIcon = page.getByTestId('dashboard-action-icon').first();
await actionIcon.scrollIntoViewIfNeeded();
await actionIcon.click();
await page.getByRole('tooltip').getByRole('button', { name: 'View' }).click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
}),
).toBeVisible();
await expect(
page.getByText('Latency', { exact: true }).first(),
).toBeVisible();
});
});

View File

@@ -1,539 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
createApmMetricsDashboardViaApi,
deleteDashboardViaApi,
} from '../../../helpers/dashboards';
// ─── Per-test seed lifecycle ────────────────────────────────────────────
//
// Each test gets its own freshly-seeded APM Metrics dashboard (4 sections,
// 16 panels — including the duplicate-named "Overview" sections, which the
// fixture intentionally ships). Per-test seeding eliminates the "previous
// test left the dashboard in a collapsed/renamed state" class of CI flakes
// that bit us repeatedly with `beforeAll`-shared seed: it is no longer
// possible for one test's restore PUT to race the next test's GET, because
// the next test does not see the previous test's dashboard at all.
//
// `serial` mode is no longer required for correctness (tests are hermetic)
// but we keep parallel runs intra-file because seed creation is the
// per-test cost — running them concurrently inside the worker would just
// pile up more concurrent dashboards without helping.
let apmDashboardId: string;
test.beforeEach(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
apmDashboardId = await createApmMetricsDashboardViaApi(page);
} finally {
await ctx.close();
}
});
test.afterEach(async ({ browser }) => {
if (!apmDashboardId) {
return;
}
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
await deleteDashboardViaApi(ctx.request, apmDashboardId, token);
} catch {
// Best-effort cleanup — a failing delete should not mask test
// failures the user actually needs to see.
} finally {
apmDashboardId = '';
await ctx.close();
}
});
/**
* Resolve the `.row-panel` container for a section by traversing up from its
* title text. The fixture ships two sections both literally named "Overview"
* — pass `index` to disambiguate. Two `..` hops reach `.row-panel`, which
* holds both the chevron and the settings-icon for that row.
*/
function sectionRow(
page: Page,
name: string | RegExp,
index = 0,
): ReturnType<Page['locator']> {
return page
.getByText(name, { exact: typeof name === 'string' })
.nth(index)
.locator('..')
.locator('..');
}
async function gotoApmDashboard(page: Page): Promise<void> {
await page.goto(`/dashboard/${apmDashboardId}`);
await page
.getByRole('button', { name: /dashboard-icon APM Metrics/ })
.waitFor({ state: 'visible' });
// `GridCardLayout`'s auto-save `useEffect` (line 226 of the source) is
// gated on `!isDashboardFetching` but `isDashboardFetching` is NOT in the
// effect's dep array. Concretely: if a chevron is clicked while any
// `[REACT_QUERY_KEY.DASHBOARD_BY_ID]` query is in flight, the effect runs
// once for the new `dashboardLayout`, sees `isDashboardFetching=true`, and
// returns early — and never re-runs when the GET later completes, because
// `dashboardLayout` didn't change again. The PUT is *never* fired and
// `toggleSectionAndWaitForPut` blocks until the 30 s test timeout.
//
// Wait until the in-flight dashboard GETs settle so the effect's gate
// evaluates to `false` on the next click. We assert this two ways: a panel
// from each visible section must render (proves data is hydrated), and
// `Latency` (the first panel of the first Overview section) must paint.
await expect(page.getByText('Latency', { exact: true }).first()).toBeVisible({
timeout: 20_000,
});
}
/**
* Click `.row-icon` (chevron) on a section row. The collapse/expand state is
* driven by React local state — `setDashboardLayout` updates synchronously
* and the (suffixed / unsuffixed) title appears on the next render. We do
* NOT wait for the auto-save PUT here: it's gated on `!isDashboardFetching`
* in `GridCardLayout.tsx` and can be skipped entirely under CI load.
* Persistence does not matter because each test seeds a fresh dashboard.
*
* `dispatchEvent('click')` — under CI viewport the expanded sidenav's
* `nav-item-data` subtree intercepts pointer events at the chevron's
* position (verified in CI run #26162502354). `.click({ force: true })`
* still lands the event at the visual centre and is swallowed by the
* overlay; dispatching the click directly on the SVG node bypasses hit
* testing entirely and triggers React's `onClick` handler.
*/
async function toggleSection(row: ReturnType<Page['locator']>): Promise<void> {
const chevron = row.locator('.row-icon');
await chevron.scrollIntoViewIfNeeded();
await expect(chevron).toBeVisible();
const page = chevron.page();
// Register a PUT listener BEFORE the click. The auto-save effect in
// `GridCardLayout` fires a PUT when `!isDashboardFetching` — if the PUT
// arrives, its `onSuccess` triggers a brief loading-state re-render that
// unmounts every `.row-panel`. The next toggle's chevron lookup either
// misses (locator times out) or grabs a transient node that detaches
// during scroll. Sequencing: dispatch click → await PUT (3 s short
// timeout in case auto-save was gated) → wait for the loading spinner
// to be absent.
const putSettled = page
.waitForResponse(
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
{ timeout: 3_000 },
)
.catch(() => null);
await chevron.dispatchEvent('click');
await putSettled;
await expect(page.getByAltText('loading')).toHaveCount(0, {
timeout: 20_000,
});
}
// Poll a section to the target collapsed/expanded state, re-clicking if a
// toggle is dropped under CI load. `name` has no regex metacharacters.
async function setSectionCollapsed(
page: Page,
name: string,
collapsed: boolean,
): Promise<void> {
const collapsedTitle = new RegExp(`^${name} \\(\\d+ widgets?\\)$`);
await expect(async () => {
const alreadyCollapsed = (await page.getByText(collapsedTitle).count()) > 0;
if (alreadyCollapsed === collapsed) {
return;
}
await toggleSection(
sectionRow(page, alreadyCollapsed ? collapsedTitle : name),
);
expect((await page.getByText(collapsedTitle).count()) > 0).toBe(collapsed);
}).toPass({ timeout: 30_000 });
}
/**
* Click the settings (⋮) icon on a section header, bypassing the sidenav's
* pointer-event interception via `dispatchEvent('click')` (same root cause
* as `toggleSectionAndWaitForPut`). The settings popover (Rename / New Panel
* / Remove Section) lives on the LEFT of the row at the same x-coordinate
* as the chevron, so it suffers the same overlap.
*/
async function clickSectionSettings(
row: ReturnType<Page['locator']>,
): Promise<void> {
const icon = row.locator('.settings-icon');
await icon.scrollIntoViewIfNeeded();
await expect(icon).toBeVisible();
await icon.dispatchEvent('click');
}
test.describe('Dashboard Detail — Sections', () => {
// ─── Collapse / expand chevron and widget-count suffix ───────────────────
// TODO(e2e): re-enable once CI consistently passes. Passes locally
// (including `STRESS=1 CI=1`) but flakes on GitHub Linux runner — the
// chevron click intermittently fails to land its auto-save PUT despite
// `dispatchEvent('click')` + `Latency` panel hydration gate. Suspect
// remaining race lives in `GridCardLayout`'s auto-save `useEffect` not
// listing `isDashboardFetching` in its deps. See CI-HARDENING.md item 5.
test.skip('TC-01 collapsing a section hides panels and shows widget count', async ({
authedPage: page,
}) => {
await gotoApmDashboard(page);
// "DB Metrics" is the third section in the APM fixture and lives below
// the fold on the 1280×720 CI viewport. Scroll its title into view and
// wait for visibility so the 14×14 chevron is actionable.
const dbMetricsTitle = page.getByText('DB Metrics', { exact: true }).first();
await dbMetricsTitle.scrollIntoViewIfNeeded();
await expect(dbMetricsTitle).toBeVisible();
await toggleSection(sectionRow(page, 'DB Metrics'));
// After collapse the section title is rewritten to include the count
// suffix; assert with a regex so the test is robust to widget-count
// drift in the fixture.
await expect(
page.getByText(/^DB Metrics \(\d+ widgets?\)$/).first(),
).toBeVisible();
// Restore: chevron-down is the row-icon variant rendered for collapsed
// sections. Re-resolve via the new (suffixed) title.
await toggleSection(sectionRow(page, /^DB Metrics \(\d+ widgets?\)$/));
await expect(page.getByText(/^DB Metrics \(\d+ widgets?\)$/)).toHaveCount(0);
});
test('TC-02 widget count matches number of panels visible before collapse', async ({
authedPage: page,
}) => {
await gotoApmDashboard(page);
// The first Overview section in the APM fixture holds these four
// panels — they're our ground truth for the count assertion below.
await expect(
page.getByText('Latency', { exact: true }).first(),
).toBeVisible();
await expect(
page.getByText('Request rate', { exact: true }).first(),
).toBeVisible();
await expect(
page.getByText('Error percentage', { exact: true }).first(),
).toBeVisible();
await expect(
page.getByText('Top operations', { exact: true }).first(),
).toBeVisible();
await toggleSection(sectionRow(page, 'Overview', 0));
await expect(
page.getByText('Overview (4 widgets)', { exact: true }).first(),
).toBeVisible();
// Restore.
await toggleSection(sectionRow(page, 'Overview (4 widgets)'));
await expect(
page.getByText('Overview (4 widgets)', { exact: true }),
).toHaveCount(0);
});
test('TC-03 expanding restores panels', async ({ authedPage: page }) => {
await gotoApmDashboard(page);
// Collapse "DB Metrics" instead of the first Overview — its widgets
// have unique titles ("DB Calls RPS" / "Database Calls Avg Duration")
// so collapse/expand transitions can be asserted without colliding
// with the duplicate-titled panels in the two Overview sections.
// "DB Metrics" lives further down the canvas; scroll into view first
// so the panels actually mount (the canvas virtualises off-screen).
const dbCalls = page.getByText('DB Calls RPS', { exact: true }).first();
await dbCalls.scrollIntoViewIfNeeded();
await expect(dbCalls).toBeVisible({ timeout: 15_000 });
await toggleSection(sectionRow(page, 'DB Metrics'));
await expect(
page.getByText(/^DB Metrics \(\d+ widgets?\)$/).first(),
).toBeVisible();
// While collapsed, "DB Calls RPS" should fully unmount.
await expect(page.getByText('DB Calls RPS', { exact: true })).toHaveCount(0);
await toggleSection(sectionRow(page, /^DB Metrics \(\d+ widgets?\)$/));
await expect(
page.getByText('DB Calls RPS', { exact: true }).first(),
).toBeVisible();
await expect(page.getByText(/^DB Metrics \(\d+ widgets?\)$/)).toHaveCount(0);
});
// ─── Section options menu (Rename / New Panel / Remove Section) ──────────
test('TC-04 section options menu shows Rename / New Panel / Remove Section', async ({
authedPage: page,
}) => {
await gotoApmDashboard(page);
// Use DB Metrics — its settings popover is guaranteed to render all
// three buttons when the section is expanded. WidgetRow.tsx hides
// "Remove Section" while a section is collapsed.
await clickSectionSettings(sectionRow(page, 'DB Metrics'));
const tooltip = page.getByRole('tooltip');
await expect(tooltip).toBeVisible();
await expect(tooltip.getByRole('button', { name: 'Rename' })).toBeVisible();
await expect(
tooltip.getByRole('button', { name: 'New Panel', exact: true }),
).toBeVisible();
await expect(
tooltip.getByRole('button', { name: 'Remove Section' }),
).toBeVisible();
await page.keyboard.press('Escape');
});
test('TC-05 rename a section, restore original name', async ({
authedPage: page,
}) => {
await gotoApmDashboard(page);
const renamed = `Renamed Section ${Date.now()}`;
// DB Metrics has a unique name, avoiding the duplicate-Overview snag.
await clickSectionSettings(sectionRow(page, 'DB Metrics'));
await page
.getByRole('tooltip')
.getByRole('button', { name: 'Rename' })
.click();
const renameDialog = page.getByRole('dialog', { name: 'Rename Section' });
await expect(renameDialog).toBeVisible();
const nameInput = renameDialog.getByPlaceholder('Enter row name here...');
await nameInput.click();
await nameInput.fill(renamed);
await renameDialog.getByRole('button', { name: 'Apply Changes' }).click();
await expect(renameDialog).not.toBeVisible();
await expect(page.getByText(renamed, { exact: true }).first()).toBeVisible();
// Restore.
await clickSectionSettings(sectionRow(page, renamed));
await page
.getByRole('tooltip')
.getByRole('button', { name: 'Rename' })
.click();
const restoreDialog = page.getByRole('dialog', { name: 'Rename Section' });
const restoreInput = restoreDialog.getByPlaceholder('Enter row name here...');
await restoreInput.click();
await restoreInput.fill('DB Metrics');
await restoreDialog.getByRole('button', { name: 'Apply Changes' }).click();
await expect(restoreDialog).not.toBeVisible();
await expect(
page.getByText('DB Metrics', { exact: true }).first(),
).toBeVisible();
await expect(page.getByText(renamed, { exact: true })).toHaveCount(0);
});
test('TC-06 cancel section rename leaves name unchanged', async ({
authedPage: page,
}) => {
await gotoApmDashboard(page);
await clickSectionSettings(sectionRow(page, 'External calls'));
await page
.getByRole('tooltip')
.getByRole('button', { name: 'Rename' })
.click();
const dialog = page.getByRole('dialog', { name: 'Rename Section' });
await expect(dialog).toBeVisible();
const input = dialog.getByPlaceholder('Enter row name here...');
await input.click();
await input.fill('Should Not Be Applied');
await dialog.getByRole('button', { name: 'Cancel' }).click();
await expect(dialog).not.toBeVisible();
await expect(
page.getByText('External calls', { exact: true }).first(),
).toBeVisible();
await expect(page.getByText('Should Not Be Applied')).toHaveCount(0);
});
// TODO(e2e): re-enable once CI consistently passes. Flaky because of hover interaction on menu, will be changing with new implementation with perses.
test.skip('TC-07 add a new panel to a section, then delete it', async ({
authedPage: page,
}) => {
await gotoApmDashboard(page);
const panelName = `Test Panel ${Date.now()}`;
await clickSectionSettings(sectionRow(page, 'DB Metrics'));
await page
.getByRole('tooltip')
.getByRole('button', { name: 'New Panel', exact: true })
.click();
const panelTypeDialog = page.getByRole('dialog', { name: 'New Panel' });
await expect(panelTypeDialog).toBeVisible();
await panelTypeDialog.getByTestId('panel-type-graph').click();
// We're now in the panel editor at /dashboard/:id/new?widgetId=…
await page.waitForURL(/\/new/);
await page.getByTestId('panel-name-input').fill(panelName);
// NewWidget renders TWO buttons with `data-testid="new-widget-save"` —
// a disabled variant when `isSaveDisabled` is true and an enabled
// variant when it is false. Under CI load the editor mounts with the
// disabled variant first; without `toBeEnabled` the click can hit the
// disabled button and the Save dialog never opens.
const saveBtn = page.getByTestId('new-widget-save');
await expect(saveBtn).toBeVisible();
await expect(saveBtn).toBeEnabled({ timeout: 20_000 });
// `dispatchEvent('click')` — sidenav overlap risk on CI; see the same
// rationale on `toggleSectionAndWaitForPut` above.
await saveBtn.dispatchEvent('click');
const saveDialog = page.getByRole('dialog', { name: 'Save Widget' });
await expect(saveDialog).toBeVisible();
// PUT confirms the panel persisted server-side — more reliable than
// waiting on redux state to propagate before navigating back.
const putResponse = page.waitForResponse(
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
);
await saveDialog.getByRole('button', { name: 'OK' }).click();
await putResponse;
await page.waitForURL((url) => !url.pathname.includes('/new'));
await expect(
page.getByText(panelName, { exact: true }).first(),
).toBeVisible();
// The panel ⋮ menu is a Radix `DropdownMenuSimple` — it opens on click,
// not hover (see `openPanelMoreMenu` in 21-panel-actions.spec.ts). The
// container hover only reveals the kebab (it's `visibility: hidden`
// until then); the click toggles the menu. Wait for the menu role to be
// visible before clicking Delete.
const panelTitle = page.getByText(panelName, { exact: true }).first();
await panelTitle.hover();
const panelContainer = panelTitle.locator('../..');
await panelContainer.scrollIntoViewIfNeeded();
await panelContainer.hover();
await panelContainer.getByTestId('widget-header-options').click();
const menu = page.getByRole('menu');
await menu.waitFor({ state: 'visible' });
await menu.getByRole('menuitem', { name: 'Delete', exact: true }).click();
const deleteDialog = page.getByRole('dialog', { name: 'Delete' });
await expect(deleteDialog).toBeVisible();
const deletePut = page.waitForResponse(
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
);
await deleteDialog.getByRole('button', { name: 'OK' }).click();
await deletePut;
await expect(deleteDialog).not.toBeVisible();
await expect(page.getByText(panelName, { exact: true })).toHaveCount(0);
});
// ─── New section in edit mode ────────────────────────────────────────────
test('TC-08 add a new section via edit mode, then remove it', async ({
authedPage: page,
}) => {
await gotoApmDashboard(page);
const sectionName = `Temp Section ${Date.now()}`;
await page.getByTestId('options').click();
await page.getByRole('menuitem', { name: 'New section' }).click();
const newSectionDialog = page.getByRole('dialog', { name: 'New Section' });
await expect(newSectionDialog).toBeVisible();
await newSectionDialog.getByTestId('section-name').fill(sectionName);
await newSectionDialog
.getByRole('button', { name: 'Create Section' })
.click();
await expect(newSectionDialog).not.toBeVisible();
await expect(
page.getByText(sectionName, { exact: true }).first(),
).toBeVisible();
await clickSectionSettings(sectionRow(page, sectionName));
await page
.getByRole('tooltip')
.getByRole('button', { name: 'Remove Section' })
.click();
const deleteRowDialog = page.getByRole('dialog', { name: 'Delete Row' });
await expect(deleteRowDialog).toBeVisible();
await deleteRowDialog.getByRole('button', { name: 'OK' }).click();
await expect(deleteRowDialog).not.toBeVisible();
await expect(page.getByText(sectionName, { exact: true })).toHaveCount(0);
// Original sections are untouched.
await expect(
page.getByText('Overview', { exact: true }).first(),
).toBeVisible();
await expect(
page.getByText('DB Metrics', { exact: true }).first(),
).toBeVisible();
await expect(
page.getByText('External calls', { exact: true }).first(),
).toBeVisible();
});
// ─── Deep coverage ───────────────────────────────────────────────────────
test('TC-09 collapsing two sections in sequence shows both as collapsed', async ({
authedPage: page,
}) => {
await gotoApmDashboard(page);
await setSectionCollapsed(page, 'DB Metrics', true);
await expect(
page.getByText(/^DB Metrics \(\d+ widgets?\)$/).first(),
).toBeVisible();
await setSectionCollapsed(page, 'External calls', true);
await expect(
page.getByText(/^External calls \(\d+ widgets?\)$/).first(),
).toBeVisible();
// Restore both so the test leaves no state behind.
await setSectionCollapsed(page, 'DB Metrics', false);
await setSectionCollapsed(page, 'External calls', false);
await expect(page.getByText(/^DB Metrics \(\d+ widgets?\)$/)).toHaveCount(0);
await expect(page.getByText(/^External calls \(\d+ widgets?\)$/)).toHaveCount(
0,
);
});
test('TC-10 panels inside a collapsed section are not in the DOM', async ({
authedPage: page,
}) => {
await gotoApmDashboard(page);
// "DB Calls RPS" is a unique panel inside the "DB Metrics" section.
const dbPanel = page.getByText('DB Calls RPS', { exact: true });
await dbPanel.first().scrollIntoViewIfNeeded();
await expect(dbPanel.first()).toBeVisible();
await toggleSection(sectionRow(page, 'DB Metrics'));
await expect(
page.getByText(/^DB Metrics \(\d+ widgets?\)$/).first(),
).toBeVisible();
// Panels inside the collapsed section unmount, not just hidden.
await expect(dbPanel).toHaveCount(0);
// Restore.
await toggleSection(sectionRow(page, /^DB Metrics \(\d+ widgets?\)$/));
await expect(dbPanel.first()).toBeVisible();
});
});

View File

@@ -1,635 +0,0 @@
import type { Locator, Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
createApmMetricsDashboardViaApi,
createChartDataDashboardViaApi,
deleteDashboardViaApi,
} from '../../../helpers/dashboards';
// Tests in this file mutate the same dashboard (clone / delete panels). Run
// them serially within the worker so state from one test does not leak into
// another's assertions.
test.describe.configure({ mode: 'serial' });
const seedIds = new Set<string>();
let apmDashboardId = '';
const TIME_SERIES_PANEL = 'Latency';
const TABLE_PANEL = 'Top operations';
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
apmDashboardId = await createApmMetricsDashboardViaApi(page);
seedIds.add(apmDashboardId);
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) {
return;
}
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of seedIds) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
async function gotoDetail(page: Page, id: string): Promise<void> {
await page.goto(`/dashboard/${id}`);
// `GridCardLayout`'s auto-save effect fires a PUT on initial load when the
// local `dashboardLayout` state diverges from the server `layouts`. Under
// CI load this PUT can still be in-flight when a test registers its own
// `waitForResponse(PUT)` or triggers a mutation (clone / delete), causing
// the wrong PUT to be captured or concurrent writes to corrupt layout state.
// Drain the in-flight PUT now so every test in this file starts clean.
// The try/catch handles dashboards whose layout is already in sync (no PUT).
try {
await page.waitForResponse(
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
{ timeout: 5_000 },
);
} catch {
// No initial-load PUT within 5 s — layout was already synchronised.
}
}
/**
* Click NewWidget's Save button after waiting for it to become enabled.
*
* Why this helper exists: `container/NewWidget/index.tsx` renders TWO buttons
* with `data-testid="new-widget-save"` — a disabled variant when
* `isSaveDisabled` is true and an enabled variant when it is false. Under CI
* load the editor mounts with the disabled variant first; without
* `toBeEnabled` the click can hit the disabled button and the Save dialog
* never opens, failing the next assertion with "dialog not found".
*/
async function clickNewWidgetSave(page: Page): Promise<void> {
const saveBtn = page.getByTestId('new-widget-save');
await expect(saveBtn).toBeVisible();
await expect(saveBtn).toBeEnabled({ timeout: 20_000 });
// `dispatchEvent('click')` — under CI viewport the editor's right-header
// can be partially covered by the sidenav's secondary nav panel, and
// `.click()` retries are then swallowed by the overlay. The synthetic
// click bypasses hit testing and triggers React's `onClick` directly.
await saveBtn.dispatchEvent('click');
}
/**
* Locate the panel container (`.widget-graph-component-container`) for the
* panel with the given title. The title is exposed via `data-testid={title}`
* on the inner `Typography.Text` — traverse upward to the container so we
* can scope the ⋮ icon, search icon, etc. to this panel only.
*
* Multiple panels with the same title (e.g. cloned `Latency` panels) are
* disambiguated by `index`, defaulting to the first match in DOM order.
*/
function panelContainer(page: Page, title: string, index = 0): Locator {
return page
.getByTestId(title)
.nth(index)
.locator(
'xpath=ancestor::div[contains(@class, "widget-graph-component-container")][1]',
);
}
/**
* Reveal the panel header's ⋮ icon and open the action dropdown. Returns the
* opened menu locator.
*
* The ⋮ icon is a `@signozhq/ui` `DropdownMenuSimple` (Radix
* `@radix-ui/react-dropdown-menu` under the hood — see `WidgetHeader/index.tsx`),
* so the menu opens on click, not hover. We still hover the container first
* because the icon is `visibility: hidden` until the row is hovered (see the
* `.widget-graph-component-container:hover` rule in `GridCardLayout.styles.scss`);
* then we click the revealed icon to toggle the Radix menu open.
*/
async function openPanelMoreMenu(
page: Page,
title: string,
index = 0,
): Promise<Locator> {
const container = panelContainer(page, title, index);
await container.scrollIntoViewIfNeeded();
await container.hover();
const moreOptions = container.getByTestId('widget-header-options');
await moreOptions.click();
const menu = page.getByRole('menu');
await menu.waitFor({ state: 'visible' });
return menu;
}
test.describe('Dashboard Detail Page — Panel Actions', () => {
// ─── ⋮ menu contents ─────────────────────────────────────────────────────
test('TC-01 panel ⋮ menu shows the 5 actions for a Time Series panel', async ({
authedPage: page,
}) => {
await gotoDetail(page, apmDashboardId);
await expect(
page.getByText(TIME_SERIES_PANEL, { exact: true }).first(),
).toBeVisible();
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
// Time Series headerMenuList = ViewMenuAction + EditMenuAction
// = [View, Clone, Delete, Edit, CreateAlerts]. Download is hidden
// because panelTypes !== TABLE.
await expect(menu.getByRole('menuitem')).toHaveCount(5);
await expect(
menu.getByRole('menuitem', { name: 'View', exact: true }),
).toBeVisible();
await expect(
menu.getByRole('menuitem', { name: 'Edit', exact: true }),
).toBeVisible();
await expect(
menu.getByRole('menuitem', { name: 'Clone', exact: true }),
).toBeVisible();
await expect(
menu.getByRole('menuitem', { name: 'Delete', exact: true }),
).toBeVisible();
await expect(
menu.getByRole('menuitem', { name: /Create Alerts/ }),
).toBeVisible();
await page.keyboard.press('Escape');
});
test('TC-02 Table panel ⋮ menu replaces Create Alerts with Download as CSV', async ({
authedPage: page,
}) => {
await gotoDetail(page, apmDashboardId);
await expect(
page.getByText(TABLE_PANEL, { exact: true }).first(),
).toBeVisible();
const menu = await openPanelMoreMenu(page, TABLE_PANEL);
// Table panels filter CreateAlerts out of the menu (see GridCard
// `menuList`) and the Download item turns visible because
// panelTypes === TABLE.
await expect(
menu.getByRole('menuitem', {
name: 'Download as CSV',
exact: true,
}),
).toBeVisible();
await expect(
menu.getByRole('menuitem', { name: /Create Alerts/ }),
).toHaveCount(0);
await page.keyboard.press('Escape');
});
// ─── View / Fullscreen ───────────────────────────────────────────────────
test('TC-03 View action opens fullscreen with `expandedWidgetId` URL param', async ({
authedPage: page,
}) => {
await gotoDetail(page, apmDashboardId);
await expect(
page.getByText(TIME_SERIES_PANEL, { exact: true }).first(),
).toBeVisible();
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
const viewItem = menu.getByRole('menuitem', { name: 'View', exact: true });
// The View menuitem is `disabled: queryResponse.isFetching` — wait
// for it to become enabled before clicking, otherwise the click is a
// no-op and the dialog never opens.
await expect(viewItem).toBeEnabled();
await viewItem.click();
const dialog = page.getByRole('dialog', { name: TIME_SERIES_PANEL });
await expect(dialog).toBeVisible();
await expect(page).toHaveURL(/expandedWidgetId=/);
await dialog.getByRole('button', { name: 'Close' }).click();
await expect(dialog).not.toBeVisible();
await expect(page).not.toHaveURL(/expandedWidgetId=/);
});
test('TC-04 fullscreen panel renders chart canvas or "No Data"', async ({
authedPage: page,
}) => {
await gotoDetail(page, apmDashboardId);
await expect(
page.getByText(TIME_SERIES_PANEL, { exact: true }).first(),
).toBeVisible();
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
const viewItem = menu.getByRole('menuitem', { name: 'View', exact: true });
await expect(viewItem).toBeEnabled();
await viewItem.click();
const dialog = page.getByRole('dialog', { name: TIME_SERIES_PANEL });
await expect(dialog).toBeVisible();
// known behaviour: the bootstrap stack ingests no telemetry, so a
// fully-rendered chart and a "No Data" empty state are both valid
// terminal states. Both can also coexist (the chart canvas mounts
// before the empty-state overlay paints), so assert that at least
// one of the two is reachable rather than using `.or().toBeVisible()`
// — that combination triggers strict-mode violations when both
// matches resolve.
const canvas = dialog.locator('canvas');
const noData = dialog.getByText(/no data/i);
await expect
.poll(async () => (await canvas.count()) + (await noData.count()), {
timeout: 30_000,
})
.toBeGreaterThan(0);
await dialog.getByRole('button', { name: 'Close' }).click();
await expect(dialog).not.toBeVisible();
});
// ─── Table search ────────────────────────────────────────────────────────
test('TC-05 Table panel search icon reveals search input', async ({
authedPage: page,
}) => {
await gotoDetail(page, apmDashboardId);
await expect(
page.getByText(TABLE_PANEL, { exact: true }).first(),
).toBeVisible();
const container = panelContainer(page, TABLE_PANEL);
await container.scrollIntoViewIfNeeded();
await container.hover();
// The search icon is hover-revealed; click it to swap the title row
// out for the search input.
const searchIcon = container.getByTestId('widget-header-search');
await searchIcon.click();
// When `showGlobalSearch` is true, the WidgetHeader unmounts the
// Typography.Text that carries the title's `data-testid`, so the
// `panelContainer` ancestor chain no longer resolves. Look up the
// search input by its testid directly — only one search input is
// ever open at a time on a dashboard.
const searchInput = page.getByTestId('widget-header-search-input');
await expect(searchInput).toBeVisible();
await searchInput.fill('test');
await expect(searchInput).toHaveValue('test');
});
// ─── Download as CSV ─────────────────────────────────────────────────────
test('TC-06 Download as CSV triggers a file download', async ({
authedPage: page,
}) => {
await gotoDetail(page, apmDashboardId);
await expect(
page.getByText(TABLE_PANEL, { exact: true }).first(),
).toBeVisible();
const menu = await openPanelMoreMenu(page, TABLE_PANEL);
// known behaviour: with no telemetry, the CSV may contain only the
// header row — asserting on `suggestedFilename()` is the resilient
// cross-environment signal that the download actually fired.
const [download] = await Promise.all([
page.waitForEvent('download'),
menu
.getByRole('menuitem', {
name: 'Download as CSV',
exact: true,
})
.click(),
]);
expect(download.suggestedFilename().length).toBeGreaterThan(0);
});
// ─── Clone / Delete ──────────────────────────────────────────────────────
//
// Clone unconditionally navigates to the panel editor (`/new`) — see
// `onCloneHandler` in WidgetGraphComponent. Saving from the editor
// returns to the dashboard with the duplicated panel persisted.
// TODO(e2e): re-enable once CI consistently passes. The Save dialog
// intermittently fails to appear on the GitHub Linux runner after
// clicking `new-widget-save` — `clickNewWidgetSave` gates on
// `toBeEnabled` + `dispatchEvent('click')` and passes locally (incl.
// `STRESS=1 CI=1`) but greens out under CI's slower scheduler in a way
// I haven't been able to reproduce. See CI-HARDENING.md.
test.skip('TC-07 Clone a panel creates a duplicate', async ({
authedPage: page,
}) => {
await gotoDetail(page, apmDashboardId);
const titleLocator = page.getByText(TIME_SERIES_PANEL, { exact: true });
await expect(titleLocator.first()).toBeVisible();
const beforeCount = await titleLocator.count();
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
const cloneItem = menu.getByRole('menuitem', { name: 'Clone', exact: true });
await expect(cloneItem).toBeEnabled();
await cloneItem.click();
// The clone handler PUTs the new layout, then redirects to /new.
await page.waitForURL(/\/new/);
await clickNewWidgetSave(page);
// The Save dialog title varies — "Save Widget" if the query is
// untouched (the case here, since clone preserves the original
// query) or "Unsaved Changes" otherwise. Match either by clicking
// OK in whichever dialog appears.
const saveDialog = page.getByRole('dialog');
await expect(saveDialog).toBeVisible();
await saveDialog.getByRole('button', { name: 'OK' }).click();
await page.waitForURL((url) => !url.pathname.includes('/new'));
await expect(titleLocator).toHaveCount(beforeCount + 1);
// Cleanup the cloned panel — its index is `beforeCount` (the last match).
const cleanupMenu = await openPanelMoreMenu(
page,
TIME_SERIES_PANEL,
beforeCount,
);
await cleanupMenu
.getByRole('menuitem', { name: 'Delete', exact: true })
.click();
const confirmDialog = page.getByRole('dialog', { name: 'Delete' });
await expect(confirmDialog).toBeVisible();
await confirmDialog.getByRole('button', { name: 'OK' }).click();
await expect(titleLocator).toHaveCount(beforeCount);
});
// TODO(e2e): re-enable once CI consistently passes. Tied to above test.
// Will work automatically when that test is re-enabled
test.skip('TC-08 Delete confirm dialog removes a cloned panel', async ({
authedPage: page,
}) => {
await gotoDetail(page, apmDashboardId);
const titleLocator = page.getByText(TIME_SERIES_PANEL, { exact: true });
await expect(titleLocator.first()).toBeVisible();
const beforeCount = await titleLocator.count();
// Clone a disposable panel — never mutate the seed's original
// `Latency` panel because sibling specs depend on it.
const cloneMenu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
await cloneMenu.getByRole('menuitem', { name: 'Clone', exact: true }).click();
await page.waitForURL(/\/new/);
await clickNewWidgetSave(page);
await page.getByRole('dialog').getByRole('button', { name: 'OK' }).click();
await page.waitForURL((url) => !url.pathname.includes('/new'));
await expect(titleLocator).toHaveCount(beforeCount + 1);
// Delete the clone — last `Latency` in DOM order.
const deleteMenu = await openPanelMoreMenu(
page,
TIME_SERIES_PANEL,
beforeCount,
);
await deleteMenu
.getByRole('menuitem', { name: 'Delete', exact: true })
.click();
const dialog = page.getByRole('dialog', { name: 'Delete' });
await expect(dialog).toBeVisible();
await expect(dialog).toContainText(/are you sure/i);
await dialog.getByRole('button', { name: 'OK' }).click();
await expect(dialog).not.toBeVisible();
await expect(titleLocator).toHaveCount(beforeCount);
});
// TODO(e2e): re-enable once CI consistently passes. Tied to above test.
// Will work automatically when that test is re-enabled
test.skip('TC-09 Cancel delete keeps the panel', async ({
authedPage: page,
}) => {
await gotoDetail(page, apmDashboardId);
const titleLocator = page.getByText(TIME_SERIES_PANEL, { exact: true });
await expect(titleLocator.first()).toBeVisible();
const beforeCount = await titleLocator.count();
// Clone a disposable panel to operate on.
const cloneMenu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
await cloneMenu.getByRole('menuitem', { name: 'Clone', exact: true }).click();
await page.waitForURL(/\/new/);
await clickNewWidgetSave(page);
await page.getByRole('dialog').getByRole('button', { name: 'OK' }).click();
await page.waitForURL((url) => !url.pathname.includes('/new'));
await expect(titleLocator).toHaveCount(beforeCount + 1);
const deleteMenu = await openPanelMoreMenu(
page,
TIME_SERIES_PANEL,
beforeCount,
);
await deleteMenu
.getByRole('menuitem', { name: 'Delete', exact: true })
.click();
const dialog = page.getByRole('dialog', { name: 'Delete' });
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Cancel' }).click();
await expect(dialog).not.toBeVisible();
// Cancel keeps the clone in place — count unchanged from the
// post-clone state.
await expect(titleLocator).toHaveCount(beforeCount + 1);
// Per-test cleanup: actually delete the clone we just kept so
// subsequent tests start from the seeded count.
const cleanupMenu = await openPanelMoreMenu(
page,
TIME_SERIES_PANEL,
beforeCount,
);
await cleanupMenu
.getByRole('menuitem', { name: 'Delete', exact: true })
.click();
const confirmDialog = page.getByRole('dialog', { name: 'Delete' });
await confirmDialog.getByRole('button', { name: 'OK' }).click();
await expect(titleLocator).toHaveCount(beforeCount);
});
// ─── Create Alerts ───────────────────────────────────────────────────────
test('TC-10 Create Alerts menuitem on a Time Series panel navigates to the alerts editor', async ({
authedPage: page,
}) => {
await gotoDetail(page, apmDashboardId);
await expect(
page.getByText(TIME_SERIES_PANEL, { exact: true }).first(),
).toBeVisible();
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
const createAlerts = menu.getByRole('menuitem', {
name: /Create Alerts/,
});
await expect(createAlerts).toBeEnabled();
// known behaviour: `useCreateAlerts` opens the alerts editor in a
// new tab via `window.open(...)` — the current page's URL does not
// change. Wait for the new browser tab on the context, not the
// existing page.
const [newPage] = await Promise.all([
page.context().waitForEvent('page'),
createAlerts.click(),
]);
await newPage.waitForLoadState();
await expect(newPage).toHaveURL(/\/alerts\/new/);
await newPage.close();
});
// ─── Deep coverage ───────────────────────────────────────────────────────
test('TC-11 fullscreen URL deep-link opens the panel modal directly', async ({
authedPage: page,
}) => {
// First navigate normally and capture the panel's widgetId from the
// View action's URL transition — we cannot hard-code a uuid.
await gotoDetail(page, apmDashboardId);
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
const viewItem = menu.getByRole('menuitem', { name: 'View', exact: true });
await expect(viewItem).toBeEnabled();
await viewItem.click();
await expect(page).toHaveURL(/expandedWidgetId=/);
const expandedUrl = page.url();
await page
.getByRole('dialog', { name: TIME_SERIES_PANEL })
.getByRole('button', { name: 'Close' })
.click();
await expect(page).not.toHaveURL(/expandedWidgetId=/);
// Now hard-navigate to the captured deep-link in a fresh page state.
await page.goto(expandedUrl);
await expect(
page.getByRole('dialog', { name: TIME_SERIES_PANEL }),
).toBeVisible();
await expect(page).toHaveURL(/expandedWidgetId=/);
});
test('TC-12 Table panel search filters rows in real time', async ({
authedPage: page,
}) => {
await gotoDetail(page, apmDashboardId);
const tableTitle = page.getByText(TABLE_PANEL, { exact: true }).first();
await expect(tableTitle).toBeVisible();
const container = panelContainer(page, TABLE_PANEL);
await container.scrollIntoViewIfNeeded();
await container.hover();
await container.getByTestId('widget-header-search').click();
const searchInput = page.getByTestId('widget-header-search-input');
await expect(searchInput).toBeVisible();
// known behaviour: the bootstrap stack ingests no telemetry, so the
// table body may be empty. The contract this TC guards is "typing in
// the search updates the input value live and does not throw" — a
// rendered row count check only fires when telemetry happens to seed
// rows. We log no console errors during the search keystrokes either.
const errors: Error[] = [];
page.on('pageerror', (err) => errors.push(err));
await searchInput.fill('foo');
await expect(searchInput).toHaveValue('foo');
await searchInput.fill('');
await expect(searchInput).toHaveValue('');
await searchInput.fill('bar-baz');
await expect(searchInput).toHaveValue('bar-baz');
expect(errors).toHaveLength(0);
});
test('TC-13 panel renders chart data from the bootstrap golden seed', async ({
authedPage: page,
}) => {
const chartId = await createChartDataDashboardViaApi(page);
seedIds.add(chartId);
await page.goto(`/dashboard/${chartId}`);
await expect(
page.getByRole('button', {
name: /dashboard-icon detail-chart-data-suite/,
}),
).toBeVisible();
const panel = page
.getByText('E2E Metric RPS', { exact: true })
.first()
.locator(
'xpath=ancestor::div[contains(@class,"widget-graph-component-container")][1]',
);
await expect(panel).toBeVisible();
await expect(panel.locator('canvas').first()).toBeVisible({
timeout: 30_000,
});
const dimensions = await panel
.locator('canvas')
.first()
.evaluate((el) => {
const c = el as HTMLCanvasElement;
return { w: c.width, h: c.height };
});
expect(dimensions.w).toBeGreaterThan(0);
expect(dimensions.h).toBeGreaterThan(0);
// Empty-state must NOT render — proves the golden seed landed and
// the panel query found rows.
await expect(panel.getByText(/no data/i)).toHaveCount(0);
});
// TODO(e2e): re-enable once CI consistently passes. Same panel
// clone-then-delete flake family as TC-07/TC-08/TC-09 above — the
// Save dialog and / or the delete confirmation intermittently fail
// on CI's slower scheduler.
test.skip('TC-14 Delete only removes the targeted panel — siblings remain', async ({
authedPage: page,
}) => {
await gotoDetail(page, apmDashboardId);
// "DB Calls RPS" is a stable sibling we check survives the round-trip.
const sibling = page.getByText('DB Calls RPS', { exact: true }).first();
await sibling.scrollIntoViewIfNeeded();
await expect(sibling).toBeVisible();
const titleLocator = page.getByText(TIME_SERIES_PANEL, { exact: true });
const beforeCount = await titleLocator.count();
// Clone first so the test is read-only at the seed level.
const cloneMenu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
await cloneMenu.getByRole('menuitem', { name: 'Clone', exact: true }).click();
await page.waitForURL(/\/new/);
await clickNewWidgetSave(page);
await page.getByRole('dialog').getByRole('button', { name: 'OK' }).click();
await page.waitForURL((url) => !url.pathname.includes('/new'));
await expect(titleLocator).toHaveCount(beforeCount + 1);
// Delete the clone (last in DOM order).
const deleteMenu = await openPanelMoreMenu(
page,
TIME_SERIES_PANEL,
beforeCount,
);
await deleteMenu
.getByRole('menuitem', { name: 'Delete', exact: true })
.click();
await page
.getByRole('dialog', { name: 'Delete' })
.getByRole('button', { name: 'OK' })
.click();
// Originals + siblings still present.
await expect(titleLocator).toHaveCount(beforeCount);
await expect(sibling).toBeVisible();
});
});

View File

@@ -1,146 +0,0 @@
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
createDashboardViaApi,
deleteDashboardViaApi,
} from '../../../helpers/dashboards';
// Scope: dashboard-side seams only —
// 1. The toolbar "New Panel" button opens a dialog listing every panel type
// the app supports (the dashboard's responsibility).
// 2. A panel created from the dialog actually lands on the canvas and
// survives a hard reload (the dashboard's persistence contract).
//
// Editor-internal behaviour (Query Builder vs ClickHouse tab, Panel Settings,
// y-axis units, panel-type changes, etc.) belongs in a separate panel-editor
// spec — do NOT add those here.
test.describe.configure({ mode: 'serial' });
const seedIds = new Set<string>();
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) return;
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of seedIds) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
test.describe('Dashboard Detail — Add Panel (entry-point + persistence)', () => {
test('TC-01 New Panel toolbar button opens a dialog listing all 7 panel types', async ({
authedPage: page,
}) => {
const ts = Date.now();
const id = await createDashboardViaApi(page, `add-panel-dialog-${ts}`);
seedIds.add(id);
await page.goto(`/dashboard/${id}`);
// Empty dashboards render an onboarding canvas with a duplicate
// `add-panel-header` CTA. Scope to the toolbar (`.right-section`).
await page
.locator('.dashboard-details .right-section')
.getByTestId('add-panel-header')
.click();
const dialog = page.getByRole('dialog', { name: 'New Panel' });
await expect(dialog).toBeVisible();
for (const tile of [
'panel-type-graph',
'panel-type-value',
'panel-type-table',
'panel-type-list',
'panel-type-bar',
'panel-type-pie',
'panel-type-histogram',
]) {
await expect(dialog.getByTestId(tile)).toBeVisible();
}
// Dialog dismisses via the Close (×) button — confirms the user can
// back out without entering the editor (no /new navigation happens
// until a tile is picked).
await dialog.getByRole('button', { name: 'Close' }).click();
await expect(dialog).toBeHidden();
await expect(page).not.toHaveURL(/\/new/);
});
// TODO(e2e): re-enable once CI consistently passes. Same flake family
// as `21-panel-actions.spec.ts` TC-07 — Save dialog intermittently
// fails to appear on CI after `new-widget-save` despite the
// `toBeEnabled` gate and `dispatchEvent('click')`. Passes locally
// (incl. `STRESS=1 CI=1`). See CI-HARDENING.md.
test.skip('TC-02 saving a new panel persists it on the canvas across reload', async ({
authedPage: page,
}) => {
const ts = Date.now();
const id = await createDashboardViaApi(page, `add-panel-persist-${ts}`);
seedIds.add(id);
const panelName = `e2e-panel-${ts}`;
await page.goto(`/dashboard/${id}`);
await page
.locator('.dashboard-details .right-section')
.getByTestId('add-panel-header')
.click();
await page
.getByRole('dialog', { name: 'New Panel' })
.getByTestId('panel-type-graph')
.click();
// We're now on the editor; minimal interaction — set the name and save.
// Anything else (queries, panel-type changes, units) is editor-internal
// and belongs in a panel-editor spec.
await page.getByTestId('panel-name-input').fill(panelName);
// NewWidget renders TWO buttons with `data-testid="new-widget-save"` —
// a disabled variant when `isSaveDisabled` is true and an enabled
// variant when it is false (see container/NewWidget/index.tsx). Under
// CI load the editor mounts with the disabled variant first; clicking
// before `toBeEnabled` resolves means the click hits the disabled
// button and the Save dialog never opens.
const saveBtn = page.getByTestId('new-widget-save');
await expect(saveBtn).toBeVisible();
await expect(saveBtn).toBeEnabled({ timeout: 20_000 });
// `dispatchEvent('click')` — under CI viewport the editor's right-header
// can be partially covered by the sidenav's secondary nav panel. Bypass
// hit testing via a synthetic click. Also wait for the dialog before
// registering the PUT listener so we capture the save mutation rather
// than any unrelated background request.
await saveBtn.dispatchEvent('click');
const saveDialog = page.getByRole('dialog', { name: 'Save Widget' });
await expect(saveDialog).toBeVisible();
const savePut = page.waitForResponse(
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
);
await saveDialog.getByRole('button', { name: 'OK' }).click();
const putResp = await savePut;
expect(putResp.ok()).toBeTruthy();
// The editor navigates back to the dashboard inside the PUT onSuccess
// handler — wait for the URL to update before asserting on the canvas.
await page.waitForURL((url) => !url.pathname.includes('/new'));
// Back on the dashboard — the new panel must render with the typed name.
await expect(page).not.toHaveURL(/\/new/);
await expect(
page.getByText(panelName, { exact: true }).first(),
).toBeVisible();
// Persistence — hard reload, panel still there.
await page.reload();
await expect(
page.getByText(panelName, { exact: true }).first(),
).toBeVisible();
});
});

View File

@@ -1,79 +0,0 @@
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
createApmMetricsDashboardViaApi,
deleteDashboardViaApi,
} from '../../../helpers/dashboards';
// This file's scope is intentionally narrow: prove that the detail page's
// "Edit panel" entry-point lands the user in the panel editor at
// `/dashboard/:id/new?widgetId=…`. Editor-internal behaviour (Query Builder
// pre-population, ClickHouse tab, Panel Settings rename, query-edit + revert,
// y-axis units, panel-type changes, etc.) is the responsibility of a separate
// panel-editor spec — keep this file as the dashboard-side seam only.
const seedIds = new Set<string>();
let apmDashboardId = '';
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
apmDashboardId = await createApmMetricsDashboardViaApi(page);
seedIds.add(apmDashboardId);
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) return;
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of seedIds) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
test.describe('Dashboard Detail — Edit Panel (entry-point only)', () => {
test('TC-01 Edit menu item on a panel navigates to the panel editor', async ({
authedPage: page,
}) => {
await page.goto(`/dashboard/${apmDashboardId}`);
await expect(
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
).toBeVisible();
// "DB Calls RPS" is the only single-instance panel name in the APM
// Metrics fixture (other titles like "Latency" repeat across sections),
// so it round-trips uniquely without `.first()` gymnastics.
const panelTitle = page.getByText('DB Calls RPS', { exact: true }).first();
await panelTitle.scrollIntoViewIfNeeded();
// Walk up to the widget-graph container. Its `:hover` flips the ⋮ icon
// from `visibility: hidden` to visible (see GridCardLayout.styles.scss
// rule on `.widget-graph-component-container:hover .options-action`).
const container = panelTitle.locator(
'xpath=ancestor::*[contains(@class,"widget-graph-component-container")][1]',
);
await container.hover();
const options = container.getByTestId('widget-header-options');
// The ⋮ is a `@signozhq/ui` `DropdownMenuSimple` (Radix-based); it opens
// on click, not hover. The container hover above only reveals the icon
// (it's `visibility: hidden` until then) — the click toggles the menu.
await options.click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+\/new\?.*widgetId=/);
await expect(page.getByTestId('new-widget-save')).toBeVisible();
});
});

View File

@@ -1,235 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
createApmMetricsDashboardViaApi,
deleteDashboardViaApi,
} from '../../../helpers/dashboards';
const seedIds = new Set<string>();
let apmId = '';
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
apmId = await createApmMetricsDashboardViaApi(page);
seedIds.add(apmId);
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) {
return;
}
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of seedIds) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
async function openTimePicker(page: Page): Promise<void> {
await page
.getByRole('textbox', { name: /Last \d+/ })
.first()
.click();
}
test.describe('Dashboard Detail — Time Range', () => {
test('TC-01 selecting a preset updates the textbox label and URL', async ({
authedPage: page,
}) => {
await page.goto(`/dashboard/${apmId}`);
await expect(
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
).toBeVisible();
await openTimePicker(page);
const refetch = page.waitForResponse((r) => r.url().includes('/query_range'));
await page.getByRole('button', { name: 'Last 1 hour 1h' }).click();
const response = await refetch;
await expect(
page.getByRole('textbox', { name: 'Last 1 hour' }),
).toBeVisible();
await expect(page).toHaveURL(/relativeTime=1h/);
// Without seeded telemetry the backend may return 4xx for query_range
// (panels render "No Data" — a known harness limitation, not a test
// bug). Cancelled in-flight responses also surface here as non-ok.
// Only 5xx is a real failure; the URL + textbox label assertions
// above already prove the preset click took effect.
expect(response.status()).toBeLessThan(500);
});
test('TC-02 switching presets twice updates the label both times', async ({
authedPage: page,
}) => {
await page.goto(`/dashboard/${apmId}`);
await expect(
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
).toBeVisible();
await openTimePicker(page);
await page.getByRole('button', { name: 'Last 6 hours 6h' }).click();
await expect(
page.getByRole('textbox', { name: 'Last 6 hours' }),
).toBeVisible();
await expect(page).toHaveURL(/relativeTime=6h/);
await openTimePicker(page);
await page.getByRole('button', { name: 'Last 1 day 1d' }).click();
await expect(page.getByRole('textbox', { name: 'Last 1 day' })).toBeVisible();
await expect(page).toHaveURL(/relativeTime=1d/);
await expect(page).not.toHaveURL(/relativeTime=6h/);
});
test('TC-03 custom date range picker reflects selected dates and switches URL to absolute timestamps', async ({
authedPage: page,
}) => {
await page.goto(`/dashboard/${apmId}`);
await expect(
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
).toBeVisible();
await openTimePicker(page);
await page.getByRole('button', { name: 'Custom Date Range' }).click();
const prevMonth = page.getByRole('button', {
name: 'Go to the Previous Month',
});
for (let i = 0; i < 2; i += 1) {
await prevMonth.click();
}
// Calendar day buttons have accessible names like "Saturday, March
// 14th, 2026" (the rendered label is "14" but a11y appends the suffix
// + month + year). Pick a known day by its long-form name regex
// against the gridcell — `\b14th\b` is unambiguous and avoids
// matching siblings like "14" inside "2014".
await page
.getByRole('gridcell', { name: /\b14th\b/ })
.first()
.click();
const refetch = page.waitForResponse((r) => r.url().includes('/query_range'));
await page.getByRole('button', { name: 'Apply' }).click();
const response = await refetch;
await expect(
page.getByRole('textbox', { name: /\d{2}\/\d{2}\/\d{4}/ }).first(),
).toBeVisible();
await expect(page).toHaveURL(/startTime=\d+/);
await expect(page).toHaveURL(/endTime=\d+/);
// As TC-01: backend 4xx (no telemetry) is acceptable; only 5xx is
// failure. Apply triggered the refetch, which is what we verify.
expect(response.status()).toBeLessThan(500);
});
test('TC-04 timezone change updates the toolbar timezone label', async ({
authedPage: page,
}) => {
await page.goto(`/dashboard/${apmId}`);
await expect(
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
).toBeVisible();
await openTimePicker(page);
await page.getByRole('button', { name: 'Change Timezone' }).click();
await expect(
page.getByRole('textbox', { name: 'Search timezones...' }),
).toBeVisible();
await page
.getByRole('button', { name: /Coordinated Universal Time —/ })
.click();
await page.keyboard.press('Escape');
await expect(page.getByText('UTC', { exact: true }).first()).toBeVisible();
});
test('TC-05 refresh-interval popup contents', async ({ authedPage: page }) => {
await page.goto(`/dashboard/${apmId}`);
await expect(
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
).toBeVisible();
await page.getByRole('button', { name: 'Set auto refresh' }).click();
const autoRefresh = page.getByRole('checkbox', { name: 'Auto Refresh' });
await expect(autoRefresh).toBeVisible();
await expect(autoRefresh).not.toBeChecked();
// Labels match the live build (no `15 minutes` / `12 hours` — the
// test plan's enumeration was approximate).
for (const label of [
'5 seconds',
'10 seconds',
'30 seconds',
'1 minute',
'5 minutes',
'10 minutes',
'30 minutes',
'1 hour',
'2 hours',
'1 day',
]) {
await expect(
page.getByRole('button', { name: label, exact: true }),
).toBeVisible();
}
});
test('TC-06 toggling auto-refresh on then changing the interval', async ({
authedPage: page,
}) => {
await page.goto(`/dashboard/${apmId}`);
await expect(
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
).toBeVisible();
await page.getByRole('button', { name: 'Set auto refresh' }).click();
const autoRefresh = page.getByRole('checkbox', { name: 'Auto Refresh' });
await autoRefresh.click();
await expect(autoRefresh).toBeChecked();
await page.getByRole('button', { name: '1 minute', exact: true }).click();
await page.getByRole('button', { name: '5 minutes', exact: true }).click();
await expect(autoRefresh).toBeChecked();
await autoRefresh.click();
await expect(autoRefresh).not.toBeChecked();
});
test('TC-07 manual sync triggers a query_range refetch', async ({
authedPage: page,
}) => {
await page.goto(`/dashboard/${apmId}`);
await expect(
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
).toBeVisible();
await expect(
page.getByText('Latency', { exact: true }).first(),
).toBeVisible();
const refetch = page.waitForResponse((r) => r.url().includes('/query_range'));
await page.locator('.refresh-btn button').click();
const response = await refetch;
// 4xx is expected without seeded telemetry; only 5xx is a failure.
// The sync click successfully triggering a query_range fetch is the
// behaviour under test.
expect(response.status()).toBeLessThan(500);
});
});

View File

@@ -1,483 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
awaitVariablesResolved,
createVariablesDashboardViaApi,
deleteDashboardViaApi,
} from '../../../helpers/dashboards';
import variablesTemplate from '../../../testdata/variables-dashboard.json';
// Variables that depend on backend resolution against seeded telemetry the
// bootstrap stack does not produce. Skip them so `awaitVariablesResolved`
// does not block on values that can never appear.
const TELEMETRY_DEPENDENT_VARS = ['q_env', 'q_service', 'd_namespace'];
test.describe.configure({ mode: 'serial' });
const seedIds = new Set<string>();
let varDashboardId = '';
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
varDashboardId = await createVariablesDashboardViaApi(
page,
'detail-variables-suite',
);
seedIds.add(varDashboardId);
// Per the framework contract: every variable with a default has its
// `selectedValue` set in the seed JSON; backend-resolved variables
// (Query / Dynamic) cannot resolve without seeded telemetry, so we
// list them in `skipNames`. Tests must not race ahead of seed
// materialisation — this gate ensures the persisted dashboard is in
// a known state before any test runs.
await awaitVariablesResolved(page, varDashboardId, {
skipNames: TELEMETRY_DEPENDENT_VARS,
});
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) {
return;
}
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of seedIds) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
function variablesQueryParam(state: Record<string, unknown>): string {
return encodeURIComponent(encodeURIComponent(JSON.stringify(state)));
}
async function gotoVariablesDashboard(
page: Page,
urlState?: Record<string, unknown>,
): Promise<void> {
const url = urlState
? `/dashboard/${varDashboardId}?variables=${variablesQueryParam(urlState)}`
: `/dashboard/${varDashboardId}`;
await page.goto(url);
await expect(
page.getByRole('button', {
name: /dashboard-icon detail-variables-suite/,
}),
).toBeVisible();
}
test.describe('Dashboard Detail — Variables', () => {
test('TC-01 variables bar renders all four types', async ({
authedPage: page,
}) => {
await gotoVariablesDashboard(page);
for (const name of [
'$tb_env',
'$tb_service',
'$cu_single',
'$cu_env_all',
'$cu_services',
'$q_env',
'$q_service',
'$d_namespace',
]) {
await expect(page.getByText(name, { exact: true })).toBeVisible();
}
// Textbox variables expose their current value via `value` and `title`
// attributes (the antd Input has no accessible name matching the value),
// so we match on input[value="..."] rather than getByRole+name.
await expect(page.locator('input[value="otel-demo"]')).toBeVisible();
await expect(page.locator('input[value="frontend"]')).toBeVisible();
await expect(page.getByTestId('variable-select')).toHaveCount(6);
});
test('TC-02 selecting a value in a single-value Custom variable updates URL and aria-selected', async ({
authedPage: page,
}) => {
await gotoVariablesDashboard(page);
// $cu_single (nth(0)) — single-select Custom with three static
// options. Driving Custom rather than Query keeps the test
// deterministic regardless of seeded telemetry.
const dropdown = page.getByTestId('variable-select').nth(0);
await dropdown.click();
await page.getByRole('option', { name: 'mq-kafka' }).click();
await expect(
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
).toBeVisible();
await expect(page).toHaveURL(/variables=.*mq-kafka/);
await dropdown.click();
await expect(page.getByRole('option', { name: 'mq-kafka' })).toHaveAttribute(
'aria-selected',
'true',
);
await page.keyboard.press('Escape');
});
test('TC-03 multi-select renders chips and URL encodes array', async ({
authedPage: page,
}) => {
// URL state seeds adservice + cartservice as initial selection; this also
// guarantees the URL contains the encoded array so we can assert on it
// without relying on the seeded server-side selection rendering identically
// across reloads.
await gotoVariablesDashboard(page, {
cu_services: ['adservice', 'cartservice'],
});
await expect(
page.getByRole('button', { name: 'Remove tag adservice' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Remove tag cartservice' }),
).toBeVisible();
await expect(page).toHaveURL(/adservice/);
await expect(page).toHaveURL(/cartservice/);
});
test('TC-04 removing a chip updates URL', async ({ authedPage: page }) => {
await gotoVariablesDashboard(page, {
cu_services: ['adservice', 'cartservice'],
});
await expect(
page.getByRole('button', { name: 'Remove tag adservice' }),
).toBeVisible();
await page.getByRole('button', { name: 'Remove tag adservice' }).click();
// Removing a chip on a multi-select expands the dropdown; URL state
// only commits when the dropdown closes (onDropdownVisibleChange =>
// false). The CustomMultiSelect swallows Escape, so click outside the
// dropdown to dismiss it.
await page.locator('img[alt="dashboard-img"]').click();
await expect(page.getByRole('listbox')).toBeHidden();
await expect(
page.getByRole('button', { name: 'Remove tag adservice' }),
).toBeHidden();
await expect(
page.getByRole('button', { name: 'Remove tag cartservice' }),
).toBeVisible();
await expect(page).toHaveURL(/variables=/);
await expect(page).not.toHaveURL(/adservice/);
});
test('TC-05 ALL option on a Custom variable', async ({ authedPage: page }) => {
await gotoVariablesDashboard(page, { cu_env_all: 'otel-demo' });
// $cu_env_all (nth(1)) — multi-select Custom with showALLOption: true,
// so the dropdown exposes an "ALL" toggle alongside the static options.
const dropdown = page.getByTestId('variable-select').nth(1);
await expect(
dropdown.locator('.ant-select-selection-item', {
hasText: 'otel-demo',
}),
).toBeVisible();
await dropdown.click();
await page.getByRole('option', { name: 'ALL' }).click();
// When ALL is selected, the multi-select renders an "ALL" badge in a
// custom container (not the standard .ant-select-selection-item), so
// match on the option's checked state inside the dropdown listbox
// rather than on the closed-state chip.
await expect(page.getByRole('option', { name: 'ALL' })).toHaveAttribute(
'aria-selected',
'true',
);
await expect(page).toHaveURL(/variables=/);
});
test('TC-06 textbox variable update propagates to URL', async ({
authedPage: page,
}) => {
await gotoVariablesDashboard(page);
// Locate by the testid wrapping a stable id, since `input[value="..."]`
// becomes stale the moment we fill('') the field.
await expect(page.locator('input[value="otel-demo"]')).toBeVisible();
const tb = page.getByPlaceholder('Enter value').first();
await tb.click();
await tb.fill('');
await tb.fill('production');
await tb.press('Enter');
await expect(page.locator('input[value="production"]')).toBeVisible();
await expect(page).toHaveURL(/variables=.*production/);
});
test('TC-07 cascading: child variable listbox opens after parent change', async ({
authedPage: page,
}) => {
await gotoVariablesDashboard(page, { q_env: 'otel-demo' });
// q_service (nth(4)) is cascaded from q_env (nth(3)).
const child = page.getByTestId('variable-select').nth(4);
await child.click();
// known behaviour: the child's option list requires seeded telemetry —
// the bootstrap stack has none, so we only assert that the listbox
// renders without crashing rather than checking specific options.
await expect(page.getByRole('listbox').first()).toBeVisible();
await page.keyboard.press('Escape');
await expect(page).toHaveURL(/otel-demo/);
});
test('TC-08 URL deep-link restores variable state on hard reload', async ({
authedPage: page,
}) => {
await gotoVariablesDashboard(page, { cu_env_all: 'mq-kafka' });
const dropdown = page.getByTestId('variable-select').nth(1);
await expect(
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
).toBeVisible();
await page.reload({ waitUntil: 'domcontentloaded' });
await expect(
page.getByRole('button', {
name: /dashboard-icon detail-variables-suite/,
}),
).toBeVisible();
await expect(
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
).toBeVisible();
await expect(page).toHaveURL(/variables=%257B/);
});
// ─── Deep coverage ───────────────────────────────────────────────────────
test('TC-09 ALL → specific value → ALL round-trip preserves URL state', async ({
authedPage: page,
}) => {
await gotoVariablesDashboard(page);
const dropdown = page.getByTestId('variable-select').nth(1); // cu_env_all
// Seed defaults to ALL — open, pick a specific value, assert URL.
await dropdown.click();
await page.getByRole('option', { name: 'mq-kafka' }).click();
await page.keyboard.press('Escape');
await expect(page).toHaveURL(/mq-kafka/);
// Re-open, switch back to ALL — URL must update again.
await dropdown.click();
const allOption = page.getByRole('option', { name: 'ALL' });
await allOption.click();
await expect(allOption).toHaveAttribute('aria-selected', 'true');
await page.keyboard.press('Escape');
// `mq-kafka` should no longer appear in the URL after reverting to ALL.
await expect(page).not.toHaveURL(/mq-kafka/);
});
test('TC-10 two variables changed in sequence both encode in URL', async ({
authedPage: page,
}) => {
await gotoVariablesDashboard(page);
// cu_single — pick `production`.
const single = page.getByTestId('variable-select').nth(0);
await single.click();
await page.getByRole('option', { name: 'production' }).click();
await page.keyboard.press('Escape');
await expect(page).toHaveURL(/production/);
// q_service — open the multi-select, dismiss without picking. The URL
// should still contain the previous selection.
const cuServices = page.getByTestId('variable-select').nth(2);
await cuServices.click();
await page.keyboard.press('Escape');
await expect(page).toHaveURL(/production/);
await expect(page).toHaveURL(/cu_single/);
});
test('TC-11 navigating away and back preserves the URL-encoded state', async ({
authedPage: page,
}) => {
await gotoVariablesDashboard(page, { cu_single: 'mq-kafka' });
const dropdown = page.getByTestId('variable-select').nth(0);
await expect(
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
).toBeVisible();
const stateUrl = page.url();
// Leave to the list, come back via browser back — URL is restored.
// `dispatchEvent('click')` — the expanded sidenav intercepts pointer
// events at the breadcrumb's center, defeating even `force: true`.
// Dispatching the click directly on the DOM node bypasses hit testing.
await page
.getByRole('button', { name: 'Dashboard /' })
.dispatchEvent('click');
await expect(page).toHaveURL(/\/dashboard$/);
await page.goBack();
await expect(page).toHaveURL(stateUrl);
await expect(
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
).toBeVisible();
});
// ─── TBD coverage — placeholders to fill in when each feature lands ──────
//
// Each `test.skip` below marks a behaviour the spec does NOT yet exercise.
// They are intentional gaps, not bugs — when the feature ships or the seed
// gains telemetry, replace `test.skip` with `test`, drop the comment, and
// implement.
// eslint-disable-next-line playwright/expect-expect
test.skip('TC-12 Custom variable without a default prompts user to select a value', async () => {
// Requires extending variables-dashboard.json with a Custom variable
// that has no `selectedValue` and no `allSelected`. The UI should
// render the dropdown empty/"Select value" until a user picks.
});
// eslint-disable-next-line playwright/expect-expect
test.skip('TC-13 Query variable with pre-seeded selectedValue renders without backend resolution', async () => {
// Requires extending variables-dashboard.json with a Query variable
// that ships with `selectedValue` already populated — the UI should
// trust the seed and not block on a query.
});
test('TC-14 multi-select Query variable without telemetry shows an empty option list', async ({
authedPage: page,
}) => {
await gotoVariablesDashboard(page);
// q_service is the only multi-select Query in the seed (nth(4) in
// the dropdown order). Without telemetry the option list is empty —
// assert the empty-state explicitly.
const child = page.getByTestId('variable-select').nth(4);
await child.click();
const listbox = page.getByRole('listbox').first();
await expect(listbox).toBeVisible();
await expect(listbox.getByRole('option')).toHaveCount(0);
await page.keyboard.press('Escape');
});
test('TC-15 Dynamic variable resolves a seeded namespace value', async ({
authedPage: page,
}) => {
// d_namespace's `dynamicVariablesAttribute` is `k8s.namespace.name`
// over the `metrics` source. The bootstrap OTel collector ingests
// the golden dataset which tags every resource with
// `k8s.namespace.name=signoz-<service>` for 8 distinct services.
// SigNoz's `signoz_metrics.distributed_metadata` table is populated
// naturally by the collector's signozclickhousemetrics exporter, and
// `/api/v1/fields/values?signal=metrics&name=k8s.namespace.name`
// surfaces the values so the Dynamic variable auto-resolves.
await gotoVariablesDashboard(page);
// d_namespace is the 6th dropdown variable in DOM order. The
// closed-state of the combobox renders the auto-resolved value
// inline next to the variable name. Match any of the 8 seeded
// namespaces — ordering depends on the backend sort, so we accept
// whichever it returns first.
const dynamic = page.getByTestId('variable-select').nth(5);
await expect(dynamic).toContainText(/signoz-\w+/, { timeout: 15_000 });
});
// eslint-disable-next-line playwright/expect-expect
test.skip('TC-16 changing a variable referenced in a panel query refetches the panel data', async () => {
// $service.name and $deployment.environment are referenced by APM
// panel queries. Asserting that a variable change triggers a
// query_range refetch with the new substitution requires either
// seeded telemetry or a network-request listener that confirms the
// outbound query body contains the new value. Defer until the
// chart-data assertion path is in place.
});
test('TC-17 variable bar order matches the `order` field in dashboard JSON', async ({
authedPage: page,
}) => {
await gotoVariablesDashboard(page);
// Expected order matches the `order` field in variables-dashboard.json.
const expected = [
'$tb_env',
'$tb_service',
'$cu_single',
'$cu_env_all',
'$cu_services',
'$q_env',
'$q_service',
'$d_namespace',
];
const allText = await page.locator('text=/^\\$\\w+$/').allInnerTexts();
const actual = allText.filter((t) => /^\$\w+$/.test(t));
expect(actual.slice(0, expected.length)).toEqual(expected);
});
// eslint-disable-next-line playwright/expect-expect
test.skip('TC-18 reordering variables via drag persists to the dashboard JSON', async () => {
// The Configure → Variables tab supports drag handles. After a
// reorder, the persisted `order` fields should update and the
// variables bar should re-render in the new order.
});
test('TC-19 variable removed via Configure disappears from the variables bar', async ({
authedPage: page,
}) => {
await gotoVariablesDashboard(page);
// `tb_service` (textbox, no dependents) — easiest to remove cleanly.
await expect(page.getByText('$tb_service', { exact: true })).toBeVisible();
await page
.locator('.dashboard-details .right-section')
.getByTestId('show-drawer')
.click();
const dialog = page.getByRole('dialog');
await dialog.getByRole('tab', { name: 'Variables' }).click();
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
const nameCell = tabpanel.getByText('tb_service', { exact: true }).first();
await nameCell.hover();
await nameCell
.locator(
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
)
.locator('.delete-variable-button')
.first()
.dispatchEvent('click');
const confirm = page
.getByRole('dialog')
.filter({ hasText: /delete variable/i })
.last();
await confirm.getByRole('button', { name: 'OK' }).click();
await expect(tabpanel.getByText('tb_service', { exact: true })).toHaveCount(
0,
);
await dialog.getByRole('button', { name: /close/i }).first().click();
await expect(page.getByText('$tb_service', { exact: true })).toHaveCount(0);
// Restore the persisted variable so subsequent serial-mode tests still pass.
const token = await authToken(page);
await page.request.put(`/api/v1/dashboards/${varDashboardId}`, {
data: { ...variablesTemplate, title: 'detail-variables-suite' },
headers: { Authorization: `Bearer ${token}` },
});
await page.reload();
await expect(page.getByText('$tb_service', { exact: true })).toBeVisible();
});
});

View File

@@ -1,332 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
createDashboardViaApi,
deleteDashboardViaApi,
} from '../../../helpers/dashboards';
test.describe.configure({ mode: 'serial' });
const seedIds = new Set<string>();
async function seed(page: Page, title: string): Promise<string> {
const id = await createDashboardViaApi(page, title);
seedIds.add(id);
return id;
}
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) return;
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of seedIds) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
async function openEditMode(page: Page): Promise<void> {
await page.getByTestId('options').click();
}
async function closeEditModeIfOpen(page: Page): Promise<void> {
const lockBtn = page.getByRole('button', { name: 'Lock Dashboard' });
if (await lockBtn.isVisible().catch(() => false)) {
await lockBtn.click({ force: true });
}
}
test.describe('Dashboard Detail — Edit Mode', () => {
test('TC-01 edit-mode popup contains all six action buttons', async ({
authedPage: page,
}) => {
const id = await seed(page, 'edit-mode-popup');
await page.goto(`/dashboard/${id}`);
await openEditMode(page);
await expect(
page.getByRole('button', { name: 'Lock Dashboard' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Rename', exact: true }),
).toBeVisible();
await expect(page.getByRole('button', { name: 'Full screen' })).toBeVisible();
await expect(page.getByRole('button', { name: 'New section' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Export JSON' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Copy as JSON' }),
).toBeVisible();
await page.getByRole('button', { name: 'Lock Dashboard' }).click();
await expect(
page.getByRole('button', { name: 'Lock Dashboard' }),
).toBeHidden();
});
test('TC-02 Lock Dashboard exits edit mode', async ({ authedPage: page }) => {
const id = await seed(page, 'edit-mode-lock');
await page.goto(`/dashboard/${id}`);
await openEditMode(page);
await page.getByRole('button', { name: 'Lock Dashboard' }).click();
await expect(
page.getByRole('button', { name: 'Lock Dashboard' }),
).toBeHidden();
await expect(
page.getByRole('button', { name: 'Rename', exact: true }),
).toBeHidden();
await expect(page.getByRole('button', { name: 'New section' })).toBeHidden();
await expect(page.getByRole('button', { name: 'Export JSON' })).toBeHidden();
});
test('TC-03 rename dashboard — breadcrumb updates, then restore', async ({
authedPage: page,
}) => {
const ts = Date.now();
const original = `original-${ts}`;
const renamed = `Renamed-${ts}`;
const id = await seed(page, original);
await page.goto(`/dashboard/${id}`);
await openEditMode(page);
await page.getByRole('button', { name: 'Rename', exact: true }).click();
const renameDialog = page.getByRole('dialog', {
name: 'Rename Dashboard',
});
await expect(renameDialog).toBeVisible();
const nameInput = renameDialog.getByTestId('dashboard-name');
await nameInput.fill('');
await nameInput.fill(renamed);
await renameDialog.getByRole('button', { name: 'Rename Dashboard' }).click();
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${renamed}`),
}),
).toBeVisible();
// Restore.
await openEditMode(page);
await page.getByRole('button', { name: 'Rename', exact: true }).click();
const restoreDialog = page.getByRole('dialog', {
name: 'Rename Dashboard',
});
const restoreInput = restoreDialog.getByTestId('dashboard-name');
await restoreInput.fill('');
await restoreInput.fill(original);
await restoreDialog.getByRole('button', { name: 'Rename Dashboard' }).click();
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${original}`),
}),
).toBeVisible();
});
test('TC-04 cancel rename leaves name unchanged', async ({
authedPage: page,
}) => {
const ts = Date.now();
const original = `cancel-rename-${ts}`;
const id = await seed(page, original);
await page.goto(`/dashboard/${id}`);
await openEditMode(page);
await page.getByRole('button', { name: 'Rename', exact: true }).click();
const renameDialog = page.getByRole('dialog', {
name: 'Rename Dashboard',
});
const nameInput = renameDialog.getByTestId('dashboard-name');
await nameInput.fill('');
await nameInput.fill('Should Not Be Saved');
await renameDialog.getByRole('button', { name: 'Cancel' }).click();
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${original}`),
}),
).toBeVisible();
await expect(page.getByText('Should Not Be Saved')).toBeHidden();
});
test('TC-05 add a new section via edit mode, then remove it', async ({
authedPage: page,
}) => {
const ts = Date.now();
const id = await seed(page, `edit-mode-section-${ts}`);
await page.goto(`/dashboard/${id}`);
await openEditMode(page);
await page.getByRole('button', { name: 'New section' }).click();
const sectionDialog = page.getByRole('dialog', { name: 'New Section' });
const sectionName = `e2e-section-${ts}`;
await sectionDialog.getByTestId('section-name').fill(sectionName);
await sectionDialog.getByRole('button', { name: 'Create Section' }).click();
const sectionTitle = page
.locator('.section-title')
.filter({ hasText: sectionName });
await expect(sectionTitle).toBeVisible();
// Cleanup — remove the section. The ellipsis trigger sits on the
// `.row-panel` container alongside the section title; the popover it
// opens has rootClassName="row-settings" and renders at body level.
const sectionRow = sectionTitle.locator(
'xpath=ancestor::*[contains(@class, "row-panel")]',
);
await sectionRow.hover();
await sectionRow.locator('.settings-icon').click();
const rowSettingsPopover = page.locator('.row-settings');
await expect(rowSettingsPopover).toBeVisible();
await rowSettingsPopover
.getByRole('button', { name: 'Remove Section' })
.click();
const deleteDialog = page.getByRole('dialog', { name: 'Delete Row' });
await expect(deleteDialog).toBeVisible();
await deleteDialog.getByRole('button', { name: 'OK' }).click();
await expect(sectionTitle).toBeHidden();
});
test('TC-06 Export JSON triggers a .json download', async ({
authedPage: page,
}) => {
const id = await seed(page, 'edit-mode-export');
await page.goto(`/dashboard/${id}`);
await openEditMode(page);
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Export JSON' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/\.json$/);
await closeEditModeIfOpen(page);
});
test('TC-07 Copy as JSON puts dashboard JSON on the clipboard', async ({
authedPage: page,
}) => {
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
const ts = Date.now();
const title = `edit-mode-copy-${ts}`;
const id = await seed(page, title);
await page.goto(`/dashboard/${id}`);
await openEditMode(page);
await page.getByRole('button', { name: 'Copy as JSON' }).click();
const clipboardText = await page.evaluate(() =>
navigator.clipboard.readText(),
);
const parsed = JSON.parse(clipboardText) as { title?: string };
expect(parsed.title ?? '').toContain(title);
await closeEditModeIfOpen(page);
});
// known behaviour: headless Chromium does not honour the Fullscreen API,
// so we cannot assert `document.fullscreenElement`. Verifying that the
// click is benign (breadcrumb still rendered) is the strongest cross-env
// check available.
test('TC-08 Full screen — clicking does not crash the dashboard', async ({
authedPage: page,
}) => {
const id = await seed(page, 'edit-mode-fullscreen');
await page.goto(`/dashboard/${id}`);
await openEditMode(page);
await page.getByRole('button', { name: 'Full screen' }).click();
await expect(
page.getByRole('button', { name: /Dashboard \// }),
).toBeVisible();
await page.keyboard.press('Escape');
await closeEditModeIfOpen(page);
});
// ─── Deep coverage ───────────────────────────────────────────────────────
test('TC-09 lock → unlock round-trip restores edit-mode controls', async ({
authedPage: page,
}) => {
const id = await seed(page, 'edit-mode-lock-roundtrip');
await page.goto(`/dashboard/${id}`);
// Lock the dashboard.
await openEditMode(page);
await page.getByRole('button', { name: 'Lock Dashboard' }).click();
await expect(
page.getByRole('button', { name: 'Lock Dashboard' }),
).toBeHidden();
// Re-opening the popup after a lock shows the Unlock label instead of
// Lock. The button label flips based on `isDashboardLocked`.
await openEditMode(page);
const unlockBtn = page.getByRole('button', { name: 'Unlock Dashboard' });
await expect(unlockBtn).toBeVisible();
await unlockBtn.click();
// After unlock, the popup should re-expose the original action buttons.
await openEditMode(page);
await expect(
page.getByRole('button', { name: 'Rename', exact: true }),
).toBeVisible();
await expect(page.getByRole('button', { name: 'New section' })).toBeVisible();
await closeEditModeIfOpen(page);
});
test('TC-10 rename persists across hard reload', async ({
authedPage: page,
}) => {
const ts = Date.now();
const original = `rename-persist-${ts}`;
const renamed = `Renamed-Persist-${ts}`;
const id = await seed(page, original);
await page.goto(`/dashboard/${id}`);
await openEditMode(page);
await page.getByRole('button', { name: 'Rename', exact: true }).click();
const renameDialog = page.getByRole('dialog', {
name: 'Rename Dashboard',
});
const nameInput = renameDialog.getByTestId('dashboard-name');
await nameInput.fill('');
await nameInput.fill(renamed);
await renameDialog.getByRole('button', { name: 'Rename Dashboard' }).click();
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${renamed}`),
}),
).toBeVisible();
// Hard reload — name must still be the renamed one.
await page.reload();
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${renamed}`),
}),
).toBeVisible();
await expect(page).toHaveTitle(new RegExp(renamed));
});
});

View File

@@ -1,687 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
awaitVariablesResolved,
createDashboardViaApi,
deleteDashboardViaApi,
} from '../../../helpers/dashboards';
const TELEMETRY_DEPENDENT_VARS = ['q_env', 'q_service', 'd_namespace'];
// `createVariablesDashboardViaApi` is added by the group-3 spec. Import lazily
// so this file still compiles while it is missing — tests that need it skip
// at runtime.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const dashboardsHelpers = require('../../../helpers/dashboards') as {
createVariablesDashboardViaApi?: (
page: Page,
title: string,
) => Promise<string>;
};
const hasVariablesHelper =
typeof dashboardsHelpers.createVariablesDashboardViaApi === 'function';
test.describe.configure({ mode: 'serial' });
const seedIds = new Set<string>();
async function seed(page: Page, title: string): Promise<string> {
const id = await createDashboardViaApi(page, title);
seedIds.add(id);
return id;
}
async function seedVariablesDashboard(
page: Page,
title: string,
): Promise<string> {
if (!dashboardsHelpers.createVariablesDashboardViaApi) {
throw new Error('createVariablesDashboardViaApi helper is not available');
}
const id = await dashboardsHelpers.createVariablesDashboardViaApi(page, title);
seedIds.add(id);
// Wait for the seeded dashboard's variables to fully resolve before any
// caller test acts on them. Variables with defaults already have
// `selectedValue` set; Query/Dynamic variables can't resolve without
// telemetry and are skipped.
await awaitVariablesResolved(page, id, {
skipNames: TELEMETRY_DEPENDENT_VARS,
});
return id;
}
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) return;
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of seedIds) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
async function openConfigureDrawer(page: Page) {
// An empty dashboard renders an onboarding canvas with a duplicate
// `data-testid="show-drawer"` Configure CTA alongside the toolbar one.
// Scope to the toolbar (`.dashboard-details .right-section`) to avoid the
// strict-mode collision.
await page
.locator('.dashboard-details .right-section')
.getByTestId('show-drawer')
.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
return dialog;
}
async function deleteVariableByName(page: Page, varName: string) {
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
const nameCell = tabpanel.getByText(varName, { exact: true }).first();
await nameCell.hover();
// Walk up to the surrounding row container to scope the delete-button
// search; `.variable-item` (or the variable row container) wraps the
// hover-revealed delete button.
await nameCell
.locator('xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]')
.locator('.delete-variable-button')
.first()
.dispatchEvent('click');
const confirm = page
.getByRole('dialog')
.filter({ hasText: /delete variable/i })
.last();
await confirm.getByRole('button', { name: 'OK' }).click();
await expect(tabpanel.getByText(varName, { exact: true })).toHaveCount(0);
await dialog.getByRole('button', { name: /close/i }).first().click();
}
test.describe('Dashboard Detail — Configure drawer', () => {
test('TC-01 Configure drawer opens with three tabs and Overview is active', async ({
authedPage: page,
}) => {
const id = await seed(page, 'cfg-drawer-chrome');
await page.goto(`/dashboard/${id}`);
const dialog = await openConfigureDrawer(page);
await expect(dialog.getByText('Dashboard Configuration')).toBeVisible();
await expect(dialog.getByRole('tab', { name: 'Overview' })).toBeVisible();
await expect(dialog.getByRole('tab', { name: 'Variables' })).toBeVisible();
await expect(dialog.getByRole('tab', { name: 'Publish' })).toBeVisible();
await expect(dialog.getByRole('tab', { name: 'Overview' })).toHaveAttribute(
'aria-selected',
'true',
);
await expect(
dialog.getByRole('tabpanel', { name: 'Overview' }),
).toBeVisible();
await dialog.getByRole('button', { name: /close/i }).first().click();
await expect(dialog).not.toBeVisible();
});
test('TC-02 update name, description, and tag — persists across reload', async ({
authedPage: page,
}) => {
const ts = Date.now();
const original = `cfg-overview-save-${ts}`;
const updated = `Configured-${ts}`;
const id = await seed(page, original);
await page.goto(`/dashboard/${id}`);
const dialog = await openConfigureDrawer(page);
const nameInput = dialog.getByTestId('dashboard-name');
await nameInput.click();
await nameInput.fill('');
await nameInput.fill(updated);
await dialog.getByTestId('dashboard-desc').fill('Automated test description');
const tagInput = dialog.getByPlaceholder('Start typing your tag name');
await tagInput.fill(`e2e-tag-${ts}`);
await tagInput.press('Enter');
const saveBtn = dialog.getByRole('button', { name: 'Save' });
await saveBtn.scrollIntoViewIfNeeded();
const [putResp] = await Promise.all([
page.waitForResponse(
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
),
saveBtn.click({ force: true }),
]);
expect(putResp.ok()).toBeTruthy();
await dialog.getByRole('button', { name: /close/i }).first().click();
await page.reload();
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${updated}`),
}),
).toBeVisible();
});
test('TC-03 Discard reverts unsaved Overview changes', async ({
authedPage: page,
}) => {
const original = 'cfg-overview-discard';
const id = await seed(page, original);
await page.goto(`/dashboard/${id}`);
const dialog = await openConfigureDrawer(page);
const nameInput = dialog.getByTestId('dashboard-name');
await expect(nameInput).toHaveValue(original);
await nameInput.fill('Temp Modified Name');
const discard = dialog.getByRole('button', { name: 'Discard' });
await expect(discard).toBeVisible();
await discard.click();
await expect(nameInput).toHaveValue(original);
await expect(dialog.getByRole('button', { name: 'Save' })).not.toBeVisible();
await dialog.getByRole('button', { name: /close/i }).first().click();
});
test('TC-04 Variables tab lists existing variables', async ({
authedPage: page,
}) => {
test.skip(
!hasVariablesHelper,
'createVariablesDashboardViaApi helper not yet available (lands with group 3)',
);
const id = await seedVariablesDashboard(page, 'cfg-variables-list');
await page.goto(`/dashboard/${id}`);
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
await expect(tabpanel).toBeVisible();
for (const varName of [
'tb_env',
'tb_service',
'cu_env_all',
'cu_services',
'q_env',
'q_service',
'd_namespace',
]) {
// Variable rows render as plain text inside the Variables tab
// (not a true Antd `Table` with role="row"). Locate via text.
await expect(
tabpanel.getByText(varName, { exact: true }).first(),
).toBeVisible();
}
await dialog.getByRole('button', { name: /close/i }).first().click();
});
test('TC-05 add a Textbox variable — appears in the variables bar and is interactive', async ({
authedPage: page,
}) => {
test.skip(
!hasVariablesHelper,
'createVariablesDashboardViaApi helper not yet available (lands with group 3)',
);
const id = await seedVariablesDashboard(page, 'cfg-variables-add-textbox');
await page.goto(`/dashboard/${id}`);
const ts = Date.now();
const varName = `tb_var_${ts}`;
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
await dialog.getByTestId('add-new-variable').click();
await dialog.getByPlaceholder('Unique name of the variable').fill(varName);
await dialog.getByRole('button', { name: 'Textbox' }).click();
const saveBtn = dialog.getByRole('button', { name: 'Save Variable' });
await expect(saveBtn).toBeEnabled();
await saveBtn.click({ force: true });
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
await expect(
tabpanel.getByText(varName, { exact: true }).first(),
).toBeVisible();
await dialog.getByRole('button', { name: /close/i }).first().click();
await expect(dialog).not.toBeVisible();
await expect(page.getByText(`$${varName}`)).toBeVisible();
const newTextbox = page.locator('input[placeholder="Enter value"]').last();
await newTextbox.fill('test-value');
await newTextbox.press('Enter');
await expect(page).toHaveURL(/test-value/);
await deleteVariableByName(page, varName);
});
test('TC-06 add a Custom variable — appears in the list', async ({
authedPage: page,
}) => {
test.skip(
!hasVariablesHelper,
'createVariablesDashboardViaApi helper not yet available (lands with group 3)',
);
const id = await seedVariablesDashboard(page, 'cfg-variables-add-custom');
await page.goto(`/dashboard/${id}`);
const ts = Date.now();
const varName = `custom_var_${ts}`;
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
await dialog.getByTestId('add-new-variable').click();
await dialog.getByPlaceholder('Unique name of the variable').fill(varName);
await dialog.getByRole('button', { name: 'Custom' }).click();
await dialog
.getByRole('button', { name: 'Save Variable' })
.click({ force: true });
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
await expect(
tabpanel.getByText(varName, { exact: true }).first(),
).toBeVisible();
await dialog.getByRole('button', { name: /close/i }).first().click();
await deleteVariableByName(page, varName);
});
// known limitation: TC-07 (add a Dynamic (Beta) variable) is intentionally
// not implemented. Dynamic variables source from the SigNoz attribute
// index — the bootstrap stack ingests no telemetry, so the field selector
// renders an empty option list and Save Variable can never be enabled.
// Re-add once the bootstrap seeds telemetry attributes.
test('TC-08 selecting Query type renders the query editor', async ({
authedPage: page,
}) => {
test.skip(
!hasVariablesHelper,
'createVariablesDashboardViaApi helper not yet available (lands with group 3)',
);
const id = await seedVariablesDashboard(page, 'cfg-variables-add-query');
await page.goto(`/dashboard/${id}`);
const ts = Date.now();
const varName = `query_var_${ts}`;
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
await dialog.getByTestId('add-new-variable').click();
await dialog.getByPlaceholder('Unique name of the variable').fill(varName);
await dialog.getByRole('button', { name: /Query/ }).click();
// Monaco is lazy-loaded — its bundle chunk can take several seconds to
// arrive under parallel-worker CI load, far longer than the default 5 s
// locator timeout. 20 s is comfortable headroom without masking real
// regressions.
await expect(dialog.locator('.monaco-editor').first()).toBeVisible({
timeout: 20_000,
});
await dialog.getByRole('button', { name: 'Discard' }).click();
await dialog.getByRole('button', { name: /close/i }).first().click();
});
test('TC-09 Save Variable disabled when name is empty', async ({
authedPage: page,
}) => {
const id = await seed(page, 'cfg-variables-empty-name');
await page.goto(`/dashboard/${id}`);
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
await dialog.getByTestId('add-new-variable').click();
const nameField = dialog.getByPlaceholder('Unique name of the variable');
await expect(nameField).toHaveValue('');
await expect(
dialog.getByRole('button', { name: 'Save Variable' }),
).toBeDisabled();
await dialog.getByRole('button', { name: 'Discard' }).click();
await dialog.getByRole('button', { name: /close/i }).first().click();
});
test('TC-10 Publish tab shows private message and Publish button', async ({
authedPage: page,
}) => {
const id = await seed(page, 'cfg-publish');
await page.goto(`/dashboard/${id}`);
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Publish' }).click();
await expect(dialog.getByRole('tabpanel', { name: 'Publish' })).toBeVisible();
await expect(
dialog.getByText(
'This dashboard is private. Publish it to make it accessible to anyone with the link.',
),
).toBeVisible();
await expect(
dialog.getByRole('checkbox', { name: 'Enable time range' }),
).toBeVisible();
await expect(
dialog.getByText("Dashboard variables won't work in public dashboards"),
).toBeVisible();
await expect(
dialog.getByRole('button', { name: 'Publish dashboard' }),
).toBeVisible();
await dialog.getByRole('button', { name: /close/i }).first().click();
});
// ─── TBD coverage — placeholders to fill in when each feature lands ──────
//
// `test.skip` placeholders for behaviours not yet covered. Replace with
// `test` and implement when the corresponding feature ships or the seed
// gains the necessary state.
test('TC-11 edit existing variable — rename', async ({ authedPage: page }) => {
test.skip(
!hasVariablesHelper,
'createVariablesDashboardViaApi helper not available',
);
const id = await seedVariablesDashboard(page, 'cfg-rename-variable');
await page.goto(`/dashboard/${id}`);
await expect(page.getByText('$tb_env', { exact: true })).toBeVisible();
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
// Hover the row to reveal the edit button (Pylon overlay can intercept,
// so dispatchEvent fires the click directly on the React onClick).
const nameCell = tabpanel.getByText('tb_env', { exact: true }).first();
await nameCell.hover();
await nameCell
.locator(
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
)
.locator('.edit-variable-button')
.first()
.dispatchEvent('click');
// Editor form mounts; rename and save.
const renamed = `tb_env_renamed_${Date.now()}`;
const nameInput = dialog.getByPlaceholder('Unique name of the variable');
await expect(nameInput).toHaveValue('tb_env');
await nameInput.fill(renamed);
await dialog
.getByRole('button', { name: 'Save Variable' })
.click({ force: true });
// Variables bar reflects the rename; the original label is gone.
await dialog.getByRole('button', { name: /close/i }).first().click();
await expect(page.getByText(`$${renamed}`, { exact: true })).toBeVisible();
await expect(page.getByText('$tb_env', { exact: true })).toHaveCount(0);
});
test('TC-12 edit existing variable — change type (CUSTOM → QUERY)', async ({
authedPage: page,
}) => {
test.skip(
!hasVariablesHelper,
'createVariablesDashboardViaApi helper not available',
);
const id = await seedVariablesDashboard(page, 'cfg-change-type');
await page.goto(`/dashboard/${id}`);
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
const nameCell = tabpanel.getByText('cu_single', { exact: true }).first();
await nameCell.hover();
await nameCell
.locator(
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
)
.locator('.edit-variable-button')
.first()
.dispatchEvent('click');
// Change type from Custom to Query and verify the form swaps to the
// Query editor (Monaco SQL editor mounts where the comma-separated
// values input used to live).
await dialog.getByRole('button', { name: /Query/ }).click();
// Monaco is lazy-loaded — its bundle chunk can take several seconds to
// arrive under parallel-worker CI load, far longer than the default 5 s
// locator timeout. 20 s is comfortable headroom without masking real
// regressions.
await expect(dialog.locator('.monaco-editor').first()).toBeVisible({
timeout: 20_000,
});
// The previous Custom-specific fields must no longer be visible.
await expect(dialog.getByPlaceholder(/Comma separated values/i)).toHaveCount(
0,
);
// Discard rather than save — saving without filling the new query
// would leave a half-configured Query variable. The contract this TC
// guards is "type switching swaps the form correctly", which the
// assertions above already prove.
await dialog.getByRole('button', { name: 'Discard' }).click();
await dialog.getByRole('button', { name: /close/i }).first().click();
});
test('TC-13 edit existing variable — change default textbox value persists across reload', async ({
authedPage: page,
}) => {
test.skip(
!hasVariablesHelper,
'createVariablesDashboardViaApi helper not available',
);
const id = await seedVariablesDashboard(page, 'cfg-change-default');
await page.goto(`/dashboard/${id}`);
await expect(page.locator('input[value="otel-demo"]')).toBeVisible();
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
const nameCell = tabpanel.getByText('tb_env', { exact: true }).first();
await nameCell.hover();
await nameCell
.locator(
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
)
.locator('.edit-variable-button')
.first()
.dispatchEvent('click');
// Update the default textbox value. The Default Value input is the
// second/third field (Name first); locate it via its placeholder.
const defaultInput = dialog
.getByPlaceholder(/Enter default value|Default value/i)
.first();
await defaultInput.fill('new-default');
// PUT confirms the variable persisted server-side before we close +
// reload. Without this wait the reload races the save and the old
// "otel-demo" default renders, producing the observed flake.
const putResponse = page.waitForResponse(
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
);
await dialog
.getByRole('button', { name: 'Save Variable' })
.click({ force: true });
await putResponse;
// Reload — the new default renders without URL state because it's
// now the persisted seed value.
await dialog.getByRole('button', { name: /close/i }).first().click();
await page.reload();
await expect(page.locator('input[value="new-default"]')).toBeVisible();
});
test('TC-14 delete variable — removed from variables bar', async ({
authedPage: page,
}) => {
test.skip(
!hasVariablesHelper,
'createVariablesDashboardViaApi helper not available',
);
const id = await seedVariablesDashboard(page, 'cfg-delete-variable');
await page.goto(`/dashboard/${id}`);
await expect(page.getByText('$tb_env', { exact: true })).toBeVisible();
// Reuse the existing helper and assert the variables bar reflects
// the deletion — `deleteVariableByName` covers the Configure-side
// removal; the bar update is the new contract this TC adds.
await deleteVariableByName(page, 'tb_env');
await expect(page.getByText('$tb_env', { exact: true })).toHaveCount(0);
// Sibling textbox is unaffected.
await expect(page.getByText('$tb_service', { exact: true })).toBeVisible();
});
test('TC-15 variable name validation — duplicate name keeps Save disabled', async ({
authedPage: page,
}) => {
test.skip(
!hasVariablesHelper,
'createVariablesDashboardViaApi helper not available',
);
const id = await seedVariablesDashboard(page, 'cfg-validate-duplicate');
await page.goto(`/dashboard/${id}`);
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
await dialog.getByTestId('add-new-variable').click();
await dialog.getByPlaceholder('Unique name of the variable').fill('tb_env');
await dialog.getByRole('button', { name: 'Textbox' }).click();
// Save Variable should refuse to enable while the name collides with
// an existing variable. Assert the button stays disabled, OR a
// validation message surfaces — UI may pick either signal.
const saveBtn = dialog.getByRole('button', { name: 'Save Variable' });
const errorMsg = dialog.getByText(/already exists|duplicate|in use/i);
// Either Save is disabled, or an explicit error is shown — both are
// valid contracts. `Promise.race` between the two assertions tolerates
// whichever the UI provides.
await expect
.poll(async () => {
const disabled = await saveBtn.isDisabled().catch(() => false);
const err = await errorMsg.isVisible().catch(() => false);
return disabled || err;
})
.toBeTruthy();
await dialog.getByRole('button', { name: 'Discard' }).click();
await dialog.getByRole('button', { name: /close/i }).first().click();
});
// eslint-disable-next-line playwright/expect-expect
test.skip('TC-16 variable name validation — invalid characters / whitespace', async () => {
// Names containing spaces, $-prefix, dots, etc. should be rejected
// by the validator. Confirm Save Variable stays disabled with an
// inline error message.
});
// eslint-disable-next-line playwright/expect-expect
test.skip('TC-17 reorder variables via drag persists `order` in JSON', async () => {
// The Variables tab supports drag handles. After a reorder, the
// persisted `data.variables[*].order` reflects the new sequence and
// the variables bar re-renders accordingly.
});
// eslint-disable-next-line playwright/expect-expect
test.skip('TC-18 add a Dynamic (Beta) variable via Configure → pick seeded attribute', async () => {
// Dynamic-variable resolution itself is covered by
// `67-variables` TC-15 (seed metric → Dynamic dropdown lists the
// namespace → URL state updates). What this TC adds is the Configure
// drawer's *Add Variable → Dynamic* form, whose attribute-picker
// uses a combobox whose stable locator hasn't been pinned in this
// suite yet — leave skipped pending a snapshot pass.
});
// eslint-disable-next-line playwright/expect-expect
test.skip('TC-19 Variable description renders in tooltip / inline metadata', async () => {
// `description` field on each variable should be surfaced in the
// variables bar tooltip and in the Variables tab's row.
});
// eslint-disable-next-line playwright/expect-expect
test.skip('TC-20 Save Variable disabled while query is in flight', async () => {
// For a Query variable mid-resolution, Save Variable should be
// disabled until the query returns options. Otherwise we'd save
// a variable with an empty option list.
});
test('TC-21 cancel-mid-edit variable changes are not persisted', async ({
authedPage: page,
}) => {
test.skip(
!hasVariablesHelper,
'createVariablesDashboardViaApi helper not available',
);
const id = await seedVariablesDashboard(page, 'cfg-cancel-edit-variable');
await page.goto(`/dashboard/${id}`);
await expect(page.getByText('$tb_env', { exact: true })).toBeVisible();
// Open the editor for tb_env and dirty the Name field.
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
const nameCell = tabpanel.getByText('tb_env', { exact: true }).first();
await nameCell.hover();
await nameCell
.locator(
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
)
.locator('.edit-variable-button')
.first()
.dispatchEvent('click');
const nameInput = dialog.getByPlaceholder('Unique name of the variable');
await expect(nameInput).toHaveValue('tb_env');
await nameInput.fill('SHOULD_NOT_PERSIST');
// Discard, then re-open the same row. The Name must still be the
// original — abandoned edits never reach the persisted JSON.
await dialog.getByRole('button', { name: 'Discard' }).click();
await dialog.getByRole('button', { name: /close/i }).first().click();
await expect(page.getByText('$tb_env', { exact: true })).toBeVisible();
await expect(
page.getByText('$SHOULD_NOT_PERSIST', { exact: true }),
).toHaveCount(0);
// Reopen Configure → tb_env still has the original name.
const dialog2 = await openConfigureDrawer(page);
await dialog2.getByRole('tab', { name: 'Variables' }).click();
await expect(
dialog2
.getByRole('tabpanel', { name: 'Variables' })
.getByText('tb_env', { exact: true })
.first(),
).toBeVisible();
});
});

View File

@@ -1,243 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
APM_METRICS_TITLE,
authToken,
createApmMetricsDashboardViaApi,
createDashboardViaApi,
createVariablesDashboardViaApi,
deleteDashboardViaApi,
} from '../../../helpers/dashboards';
const seedIds = new Set<string>();
const VARIABLES_TITLE = 'detail-edge-cases-variables';
let apmDashboardId = '';
let variablesDashboardId = '';
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
apmDashboardId = await createApmMetricsDashboardViaApi(page);
seedIds.add(apmDashboardId);
variablesDashboardId = await createVariablesDashboardViaApi(
page,
VARIABLES_TITLE,
);
seedIds.add(variablesDashboardId);
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) {
return;
}
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of seedIds) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
function encodeVariables(payload: Record<string, unknown>): string {
return encodeURIComponent(encodeURIComponent(JSON.stringify(payload)));
}
async function gotoDetail(page: Page, id: string, query = ''): Promise<void> {
await page.goto(`/dashboard/${id}${query}`);
}
test.describe('Dashboard Detail Page — Edge Cases', () => {
test('TC-01 panels show "No Data" for a far-past time range without pageerror', async ({
authedPage: page,
}) => {
const errors: Error[] = [];
page.on('pageerror', (err) => errors.push(err));
await gotoDetail(
page,
apmDashboardId,
'?startTime=1672531200000&endTime=1672531260000',
);
// The dashboard chrome must render with the far-past range applied:
// breadcrumb resolves the dashboard title, panel headers render, and the
// time-range textbox reflects the URL.
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
}),
).toBeVisible();
await expect(
page.getByText('Latency', { exact: true }).first(),
).toBeVisible();
// known behaviour: with no variable values resolvable in the far-past
// window, APM panels stay in a waiting-on-variable state and never
// render the uplot "No Data" overlay. The contract this TC really
// guards is that the page does not throw — assert no client-side
// pageerror was raised.
expect(errors).toHaveLength(0);
});
test('TC-02 nonexistent dashboard ID handled gracefully', async ({
authedPage: page,
}) => {
await page.goto('/dashboard/nonexistent-id-99999');
// The chrome (sidebar logo) must always render, regardless of whether
// the app redirects to /dashboard or shows an in-place error shell.
// The bogus-id breadcrumb must never resolve.
await expect(page.getByRole('img', { name: 'SigNoz' })).toBeVisible();
await expect(
page.getByRole('button', {
name: /dashboard-icon nonexistent-id-99999/,
}),
).toBeHidden();
});
test('TC-03 sidebar nav still works after hitting a nonexistent dashboard URL', async ({
authedPage: page,
}) => {
await page.goto('/dashboard/nonexistent-id-99999');
await expect(page.getByRole('img', { name: 'SigNoz' })).toBeVisible();
await page
.locator('.nav-item')
.filter({ hasText: /^Dashboards$/ })
.click();
await expect(page).toHaveURL(/\/dashboard($|\?)/);
await expect(
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
).toBeVisible();
});
test('TC-04 variable URL deep-link survives hard reload', async ({
authedPage: page,
}) => {
const deepLink = `?variables=${encodeVariables({ q_env: 'otel-demo' })}`;
await gotoDetail(page, variablesDashboardId, deepLink);
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${VARIABLES_TITLE}`),
}),
).toBeVisible();
await expect(page.getByText('$q_env', { exact: true })).toBeVisible();
await expect(page).toHaveURL(/variables=%257B/);
await expect(page).toHaveURL(/otel-demo/);
// Dropdown index — selects (in DOM order): 0=cu_single, 1=cu_env_all,
// 2=cu_services, 3=q_env, 4=q_service, 5=d_namespace.
const qEnv = page.getByTestId('variable-select').nth(3);
await expect(
qEnv.locator('.ant-select-selection-item', { hasText: 'otel-demo' }),
).toBeVisible();
await page.reload({ waitUntil: 'domcontentloaded' });
await expect(page).toHaveURL(/variables=%257B/);
await expect(page).toHaveURL(/otel-demo/);
await expect(
qEnv.locator('.ant-select-selection-item', { hasText: 'otel-demo' }),
).toBeVisible();
});
test('TC-05 a single broken time range does not crash the dashboard canvas', async ({
authedPage: page,
}) => {
const errors: Error[] = [];
page.on('pageerror', (err) => errors.push(err));
// known behaviour: the app may either reject a swapped range
// client-side or render error states per-panel — either way, the
// dashboard chrome and at least one panel header must still render.
await gotoDetail(page, apmDashboardId, '?startTime=999999&endTime=999998');
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
}),
).toBeVisible();
await expect(
page.getByText('Latency', { exact: true }).first(),
).toBeVisible();
expect(errors).toHaveLength(0);
});
test('TC-06 sidebar Dashboards link from detail page navigates to /dashboard', async ({
authedPage: page,
}) => {
await gotoDetail(page, apmDashboardId);
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
}),
).toBeVisible();
await page
.locator('.nav-item')
.filter({ hasText: /^Dashboards$/ })
.click();
await expect(page).toHaveURL(/\/dashboard($|\?)/);
await expect(
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
).toBeVisible();
});
// ─── Deep coverage ───────────────────────────────────────────────────────
test('TC-07 a 200-character dashboard name renders without breaking layout', async ({
authedPage: page,
}) => {
const longName = `LongName-${'x'.repeat(190)}`;
const id = await createDashboardViaApi(page, longName);
seedIds.add(id);
await gotoDetail(page, id);
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${longName.slice(0, 30)}`),
}),
).toBeVisible();
// The toolbar must still render — long titles cannot push the toolbar
// off-screen or unmount it. Scope to `.right-section` because empty
// dashboards render an onboarding canvas with duplicate testids.
const toolbar = page.locator('.dashboard-details .right-section');
await expect(toolbar.getByTestId('show-drawer')).toBeVisible();
await expect(toolbar.getByTestId('add-panel-header')).toBeVisible();
});
test('TC-08 special characters in the dashboard name round-trip via URL and breadcrumb', async ({
authedPage: page,
}) => {
const trickyName = `Spec & Chars / "${Date.now()}" — émoji 🎯`;
const id = await createDashboardViaApi(page, trickyName);
seedIds.add(id);
await gotoDetail(page, id);
// The full title must round-trip through the breadcrumb without HTML
// entity mangling (`&amp;`, `&quot;` are bugs we'd want to catch).
await expect(
page.getByText(trickyName, { exact: true }).first(),
).toBeVisible();
// document.title is set from the dashboard name — confirm it is intact.
await expect(page).toHaveTitle(new RegExp('Spec & Chars'));
});
});

View File

@@ -0,0 +1,64 @@
import {
expect,
GOLDEN,
metricsQuery,
PanelKind,
promqlQuery,
SINGLE_PANEL_ID,
singlePanelDashboard,
test,
textPanelWithQueryDashboard,
} from '../../../drivers/panel-editor';
// Scope: how the editor explains a disabled kind, and the backend enforcing the
// same panelKind × queryType matrix.
test.describe('Dashboards — editor capabilities', () => {
test('TC-01 a disabled option explains itself in a tooltip', async ({
dashboards,
editor,
panels,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ query: metricsQuery() }),
SINGLE_PANEL_ID,
);
await editor.openTypeSwitcher();
await expect(editor.typeOption(PanelKind.List)).toHaveAttribute(
'aria-disabled',
'true',
);
await editor.typeOption(PanelKind.List).hover();
// The wording is the contract users read.
await expect(
panels.tooltip.text("List doesn't support metrics data"),
).toBeVisible();
});
test('TC-02 the backend rejects a combination the editor disables', async ({
dashboards,
}) => {
// Enforced server-side too, so an invalid pairing can never be persisted.
// Pins the layers together: if capabilities.ts and allowedQueryKinds
// drift, either the switcher contract or this fails.
await expect(
dashboards.seed(
singlePanelDashboard({
kind: PanelKind.PieChart,
query: promqlQuery(`sum(rate(${GOLDEN.metrics.calls}[5m]))`),
}),
),
// Quotes in the message are JSON-escaped, so match around them.
).rejects.toThrow(/PromQLQuery.*not supported by panel kind.*PieChartPanel/);
});
test('TC-03 the backend rejects a Text panel that carries a query', async ({
dashboards,
}) => {
await expect(dashboards.seed(textPanelWithQueryDashboard())).rejects.toThrow(
/renders without a query and must have queries: \[\]/,
);
});
});

View File

@@ -0,0 +1,180 @@
import {
expect,
Section,
SINGLE_PANEL_ID,
singlePanelDashboard,
test,
} from '../../../drivers/panel-config';
// Scope: the Context Links dialog and that a saved link reaches
// `panel.spec.links` — where it surfaces as a `drilldown-context-link`
// (covered in panels/57-drilldown).
const ONE_LINK = singlePanelDashboard({
links: [{ name: 'Original', url: 'https://example.com/one' }],
});
test.describe('Dashboards — editor context links', () => {
test('TC-01 the section is offered and opens an empty dialog', async ({
dashboards,
editor,
config,
}) => {
const links = config.contextLinks;
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
await editor.section.expand(Section.contextLinks);
await links.addButton().click();
await expect(links.dialog()).toBeVisible();
await expect(links.label()).toHaveValue('');
await expect(links.url()).toHaveValue('');
});
test('TC-02 Save stays disabled until the link is valid', async ({
dashboards,
editor,
config,
}) => {
const links = config.contextLinks;
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
await editor.section.expand(Section.contextLinks);
await links.addButton().click();
// A link with no URL would be a dead menu entry.
await expect(links.save()).toBeDisabled();
await links.label().fill('Runbook');
await expect(links.save()).toBeDisabled();
await links.url().fill('https://example.com/runbook');
await expect(links.save()).toBeEnabled();
});
test('TC-03 a saved link persists into panel.spec.links', async ({
dashboards,
editor,
config,
}) => {
const links = config.contextLinks;
const id = await dashboards.seedAndEdit(
singlePanelDashboard(),
SINGLE_PANEL_ID,
);
await editor.section.expand(Section.contextLinks);
await links.addButton().click();
await links.label().fill('Runbook');
await links.url().fill('https://example.com/runbook');
await links.blurFields();
await links.save().click();
await expect(links.item(0)).toBeVisible();
await editor.save();
const saved = (await config.saved(id)).links;
expect(saved).toHaveLength(1);
// "Label" persists as `name` (Perses link model).
expect(saved[0]).toMatchObject({
name: 'Runbook',
url: 'https://example.com/runbook',
});
});
test('TC-04 Cancel discards the dialog without adding a link', async ({
dashboards,
editor,
config,
}) => {
const links = config.contextLinks;
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
await editor.section.expand(Section.contextLinks);
await links.addButton().click();
await links.label().fill('Throwaway');
await links.url().fill('https://example.com');
await links.blurFields();
await links.cancel().click();
await expect(links.dialog()).toHaveCount(0);
await expect(links.item(0)).toHaveCount(0);
});
test('TC-05 an existing link can be edited', async ({
dashboards,
editor,
config,
}) => {
const links = config.contextLinks;
const id = await dashboards.seedAndEdit(ONE_LINK, SINGLE_PANEL_ID);
await editor.section.expand(Section.contextLinks);
await links.edit(0).click();
await links.label().fill('Renamed');
await links.blurFields();
await links.save().click();
await editor.save();
expect((await config.saved(id)).links[0]).toMatchObject({
name: 'Renamed',
});
});
test('TC-06 a link can be removed', async ({ dashboards, editor, config }) => {
const id = await dashboards.seedAndEdit(ONE_LINK, SINGLE_PANEL_ID);
await editor.section.expand(Section.contextLinks);
await config.contextLinks.remove(0).click();
await editor.save();
expect((await config.saved(id)).links).toHaveLength(0);
});
test('TC-07 URL parameters can be added to a link', async ({
dashboards,
editor,
config,
}) => {
const links = config.contextLinks;
const id = await dashboards.seedAndEdit(
singlePanelDashboard(),
SINGLE_PANEL_ID,
);
await editor.section.expand(Section.contextLinks);
await links.addButton().click();
await links.label().fill('With params');
await links.url().fill('https://example.com/search');
await links.blurFields();
await links.addParam().click();
await links.paramKey(0).fill('service');
await links.paramValue(0).fill('adservice');
await links.blurFields();
await links.save().click();
await editor.save();
const saved = (await config.saved(id)).links;
expect(saved).toHaveLength(1);
// Params fold into the persisted URL.
expect(new URL(saved[0].url).searchParams.get('service')).toBe('adservice');
});
test('TC-08 the header quick-add opens the dialog from a collapsed section', async ({
dashboards,
editor,
config,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
// One click must expand AND add (pendingAction hop).
await editor.section.collapse(Section.contextLinks);
await config.contextLinks.headerAdd().click();
await expect(editor.section.toggle(Section.contextLinks)).toHaveAttribute(
'aria-expanded',
'true',
);
await expect(config.contextLinks.dialog()).toBeVisible();
});
});

View File

@@ -0,0 +1,246 @@
import {
compactDashboard,
emptyDashboard,
expect,
PANEL_TYPE_TILES,
PanelKind,
PATH,
singlePanelDashboard,
SINGLE_PANEL_ID,
test,
} from '../../../drivers/panel-editor';
// Scope: creating a panel — the New panel drawer, the route it hands off to,
// and the JSON-Patch a save emits.
//
// The subtlety: a tile click only selects; the confirm button carries the
// target section, and the editor route carries it on as `layoutIndex`.
test.describe('Dashboards — panel picker', () => {
test('TC-01 with one section a tile click selects and Add panel creates', async ({
authedPage: page,
dashboards,
editor,
newPanel,
}) => {
const id = await dashboards.seed(emptyDashboard());
await dashboards.openEmpty(id);
await dashboards.emptyAddPanel().click();
// No section to choose, so the confirm names none.
await expect(newPanel.confirmButton()).toHaveText('Add panel');
await newPanel.pick(PanelKind.Table);
await expect(page).toHaveURL(PATH.onDashboard(id));
await expect(newPanel.root()).toBeVisible();
await newPanel.confirm();
expect(dashboards.param('panelKind')).toBe(PanelKind.Table);
await expect(editor.root()).toBeVisible();
});
test('TC-02 with several sections the confirm names its target section', async ({
dashboards,
newPanel,
}) => {
await dashboards.seedAndOpen(compactDashboard());
await dashboards.headerAddPanel().click();
await expect(newPanel.root()).toBeVisible();
// Every section is titled, so the toolbar trigger defaults to a new root.
await expect(newPanel.confirmButton()).toHaveText('Add to Dashboard (root)');
await expect(newPanel.tile(PanelKind.TimeSeries)).toHaveAttribute(
'aria-pressed',
'true',
);
await newPanel.choose(PanelKind.Number);
expect(dashboards.param('panelKind')).toBe(PanelKind.Number);
expect(dashboards.param('layoutIndex')).toBe('root');
});
test('TC-03 the chosen section becomes the new panel layoutIndex', async ({
dashboards,
newPanel,
}) => {
await dashboards.seedAndOpen(compactDashboard());
await dashboards.headerAddPanel().click();
await newPanel.pick(PanelKind.Number);
await newPanel.chooseSection('1');
await expect(newPanel.confirmButton()).toHaveText('Add to Tabular');
await newPanel.confirm();
expect(dashboards.param('layoutIndex')).toBe('1');
});
test('TC-04 creating from an empty section targets that section', async ({
dashboards,
newPanel,
}) => {
const dashboard = compactDashboard();
dashboard.spec.layouts.push({
kind: 'Grid',
spec: { display: { title: 'Empty' }, items: [] },
});
await dashboards.seedAndOpen(dashboard);
await newPanel.sectionAddPanel().scrollIntoViewIfNeeded();
await newPanel.sectionAddPanel().click();
await expect(newPanel.root()).toBeVisible();
await expect(newPanel.confirmButton()).toHaveText('Add to Empty');
await newPanel.choose(PanelKind.TimeSeries);
expect(dashboards.param('layoutIndex')).toBe('2');
});
test('TC-05 saving a new panel emits add-panel and add-layout-item ops', async ({
dashboards,
editor,
newPanel,
}) => {
await dashboards.seedAndOpen(singlePanelDashboard());
const patches = editor.capturePatches();
await dashboards.headerAddPanel().click();
// List: its seeded query passes validation (see TC-06).
await newPanel.choose(PanelKind.List);
await expect(editor.root()).toBeVisible();
await editor.title().fill('Created from the modal');
await editor.save();
// Targeted adds for panel AND grid item — a replace would clobber edits.
expect(patches.length).toBeGreaterThan(0);
const ops = patches[patches.length - 1];
const panelAdd = ops.find((op) => /^\/spec\/panels\/[^/]+$/.test(op.path));
expect(panelAdd?.op).toBe('add');
expect(
ops.some((op) => /^\/spec\/layouts\/\d+\/spec\/items\/-$/.test(op.path)),
).toBe(true);
});
test('TC-06 the created panel lands on the dashboard and persists', async ({
authedPage: page,
dashboards,
editor,
newPanel,
panels,
}) => {
const id = await dashboards.seedAndOpen(singlePanelDashboard());
const before = await dashboards.get(id);
const beforeCount = Object.keys(before.spec.panels).length;
await dashboards.headerAddPanel().click();
// List is the one kind whose seeded query saves as-is (logs `count()`);
// metrics kinds are rejected until a metric is chosen — see TC-07.
await newPanel.choose(PanelKind.List);
await expect(editor.root()).toBeVisible();
await editor.title().fill('Fresh list panel');
await editor.save();
await page.waitForURL(PATH.onDashboard(id));
await expect(
panels.titles().filter({ hasText: 'Fresh list panel' }),
).toBeVisible();
const after = await dashboards.get(id);
expect(Object.keys(after.spec.panels)).toHaveLength(beforeCount + 1);
});
test('TC-07 saving a metrics panel with no metric chosen is rejected', async ({
dashboards,
editor,
newPanel,
}) => {
await dashboards.seedAndOpen(singlePanelDashboard());
await dashboards.headerAddPanel().click();
await newPanel.choose(PanelKind.Number);
await expect(editor.root()).toBeVisible();
// Metrics kinds seed an empty aggregation, so the editor must stay open and
// surface the reason rather than dropping the panel.
const { ok, body } = await editor.trySave();
expect(ok).toBe(false);
expect(body).toContain('metric name is required');
await expect(editor.root()).toBeVisible();
});
test('TC-08 the editor route redirects when panelKind is missing', async ({
authedPage: page,
dashboards,
panels,
}) => {
const id = await dashboards.seed(singlePanelDashboard());
// No kind to seed, so the page bounces back.
await dashboards.visit(PATH.editor(id, 'new'));
await page.waitForURL(PATH.onDashboard(id));
await expect(panels.panel(SINGLE_PANEL_ID).root()).toBeVisible();
});
test('TC-09 a new section from the picker is created on save', async ({
authedPage: page,
dashboards,
editor,
newPanel,
}) => {
const id = await dashboards.seedAndOpen(singlePanelDashboard());
await dashboards.headerAddPanel().click();
await newPanel.pick(PanelKind.List);
await newPanel.newSection('Fresh section');
await expect(newPanel.confirmButton()).toHaveText('Add to new section');
await newPanel.confirm();
expect(dashboards.param('newSection')).toBe('Fresh section');
await editor.save();
await page.waitForURL(PATH.onDashboard(id));
const after = await dashboards.get(id);
const section = after.spec.layouts.find(
(layout) => layout.spec.display?.title === 'Fresh section',
);
expect(section?.spec.items).toHaveLength(1);
});
test('TC-10 search narrows the tiles and offers a way back', async ({
dashboards,
newPanel,
}) => {
await dashboards.seedAndOpen(singlePanelDashboard());
await dashboards.headerAddPanel().click();
// Matches name, description and group label.
await newPanel.search().fill('pie');
await expect(newPanel.tiles()).toHaveCount(1);
await expect(newPanel.tile(PanelKind.PieChart)).toBeVisible();
await newPanel.search().fill('no-such-panel');
await expect(newPanel.tiles()).toHaveCount(0);
await expect(newPanel.emptyState()).toBeVisible();
});
test('TC-11 a category chip filters to its group', async ({
dashboards,
newPanel,
}) => {
await dashboards.seedAndOpen(singlePanelDashboard());
await dashboards.headerAddPanel().click();
await newPanel.category('Raw records').click();
await expect(newPanel.groups()).toHaveText(['Raw records']);
await expect(newPanel.tiles()).toHaveCount(
PANEL_TYPE_TILES.filter((t) => t.category === 'Raw records').length,
);
await newPanel.category('All').click();
await expect(newPanel.tiles()).toHaveCount(PANEL_TYPE_TILES.length);
});
});

View File

@@ -0,0 +1,142 @@
import {
expect,
GOLDEN,
logsCountQuery,
PanelKind,
PATH,
QueryTab,
SINGLE_PANEL_ID,
singlePanelDashboard,
test,
} from '../../../drivers/panel-editor';
// Scope: the query pane and its commit semantics.
//
// The rule worth pinning: editing the builder does NOT move the preview (only
// Run, or a structural change, commits into the draft) — but Save serialises
// the LIVE query anyway, so an unrun edit still persists. Contradictory-looking
// and easy to "fix" into a regression.
test.describe('Dashboards — panel editor query pane', () => {
test('TC-01 Run Query issues a fresh query_range', async ({
dashboards,
editor,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
await expect(editor.preview.renderer(PanelKind.TimeSeries)).toBeVisible();
await editor.query.run();
});
test('TC-02 the run keyboard shortcut works from inside the builder', async ({
dashboards,
editor,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
await expect(editor.preview.renderer(PanelKind.TimeSeries)).toBeVisible();
// Bound with onKeyDownCapture, so it fires from inside inputs too.
await editor.query.runShortcut();
});
test('TC-03 switching query type re-renders the pane and marks the panel dirty', async ({
dashboards,
editor,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
await expect(editor.unsavedBadge()).toHaveCount(0);
await editor.query.tab(QueryTab.promql).click();
await expect(editor.query.tab(QueryTab.promql)).toHaveAttribute(
'aria-selected',
'true',
);
// Structural change: auto-commits without Run.
await expect(editor.unsavedBadge()).toBeVisible();
});
test('TC-04 choosing a metric makes a new panel savable', async ({
authedPage: page,
dashboards,
editor,
newPanel,
}) => {
// create → configure → save. A new metrics panel seeds an empty
// aggregation and is rejected until a metric is picked (picker TC-07).
const id = await dashboards.seedAndOpen(singlePanelDashboard());
await dashboards.headerAddPanel().click();
await newPanel.choose(PanelKind.TimeSeries);
await expect(editor.root()).toBeVisible();
await editor.query.selectMetric(GOLDEN.metrics.calls);
await editor.title().fill('Configured then saved');
await editor.save();
await page.waitForURL(PATH.onDashboard(id));
const after = await dashboards.get(id);
const saved = Object.values(after.spec.panels).find(
(candidate) => candidate.spec.display.name === 'Configured then saved',
);
expect(saved).toBeDefined();
expect(JSON.stringify(saved?.spec.queries)).toContain(GOLDEN.metrics.calls);
});
test('TC-05 an unrun query edit is still persisted by Save', async ({
dashboards,
editor,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard(),
SINGLE_PANEL_ID,
);
// No Run: `buildSaveSpec` serialises the live query, so the edit must
// survive — otherwise saving without running loses work.
await editor.query.selectMetric(GOLDEN.metrics.latencyCount);
await editor.save();
await expect
.poll(async () => {
const after = await dashboards.get(id);
return JSON.stringify(
after.spec.panels[SINGLE_PANEL_ID].spec.queries,
).includes(GOLDEN.metrics.latencyCount);
})
.toBe(true);
});
test('TC-06 the in-editor query survives a reload', async ({
dashboards,
editor,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
await editor.query.selectMetric(GOLDEN.metrics.latencySum);
await editor.query.run();
// No forceReset, so the URL query wins and survives a refresh.
await dashboards.reload();
await expect(editor.root()).toBeVisible();
await expect(editor.query.metricInput()).toHaveValue(
GOLDEN.metrics.latencySum,
);
});
test('TC-07 a logs panel runs without needing a metric', async ({
dashboards,
editor,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ query: logsCountQuery() }),
SINGLE_PANEL_ID,
);
// Logs aggregate by expression, so there's no metric to fill.
await expect(editor.query.runButton()).toBeVisible();
await editor.query.run();
await editor.save();
});
});

View File

@@ -0,0 +1,194 @@
import {
EditorText,
expect,
PATH,
SINGLE_PANEL_ID,
singlePanelDashboard,
test,
} from '../../../drivers/panel-editor';
// Scope: the editor shell — editing, dirty badge, save, discard guard, locking.
//
// Two counter-intuitive behaviours are pinned so a "cleanup" doesn't change
// them: Save is NOT gated on dirty state (TC-03), and only the in-app close
// button guards unsaved edits — there is no beforeunload blocker (TC-07).
test.describe('Dashboards — panel editor shell', () => {
test('TC-01 the editor opens on the panel with its saved title', async ({
dashboards,
editor,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
await expect(editor.heading()).toBeVisible();
await expect(editor.title()).toHaveValue('Solo panel');
await expect(editor.preview.root()).toBeVisible();
await expect(editor.query.builder()).toBeVisible();
});
test('TC-02 editing the title marks the editor dirty', async ({
dashboards,
editor,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
await expect(editor.unsavedBadge()).toHaveCount(0);
await editor.title().fill('Renamed panel');
await expect(editor.unsavedBadge()).toBeVisible();
});
test('TC-03 Save stays enabled on a pristine panel', async ({
dashboards,
editor,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
// Gated on editability, not isDirty.
await expect(editor.unsavedBadge()).toHaveCount(0);
await expect(editor.saveButton()).toBeEnabled();
});
test('TC-04 saving persists the title and returns to the dashboard', async ({
authedPage: page,
dashboards,
editor,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard(),
SINGLE_PANEL_ID,
);
const patches = editor.capturePatches();
await editor.title().fill('Renamed via editor');
await editor.description().fill('Edited in the E2E suite');
await editor.save();
await page.waitForURL(PATH.onDashboard(id));
const after = await dashboards.get(id);
expect(after.spec.panels[SINGLE_PANEL_ID].spec.display.name).toBe(
'Renamed via editor',
);
// A single targeted add at the panel's spec pointer.
const ops = patches[patches.length - 1];
expect(ops).toHaveLength(1);
expect(ops[0].path).toBe(`/spec/panels/${SINGLE_PANEL_ID}/spec`);
expect(ops[0].op).toBe('add');
});
test('TC-05 closing a pristine editor leaves immediately', async ({
authedPage: page,
dashboards,
editor,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard(),
SINGLE_PANEL_ID,
);
await editor.close();
await page.waitForURL(PATH.onDashboard(id));
await expect(editor.discardModal()).toHaveCount(0);
});
test('TC-06 closing a dirty editor asks before discarding', async ({
authedPage: page,
dashboards,
editor,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard(),
SINGLE_PANEL_ID,
);
await editor.title().fill('Throwaway edit');
await expect(editor.unsavedBadge()).toBeVisible();
await editor.close({ expectDirty: true, keepEditing: true });
await expect(editor.root()).toBeVisible();
await expect(editor.title()).toHaveValue('Throwaway edit');
await editor.close({ expectDirty: true });
await page.waitForURL(PATH.onDashboard(id));
const after = await dashboards.get(id);
expect(after.spec.panels[SINGLE_PANEL_ID].spec.display.name).toBe(
'Solo panel',
);
});
test('TC-07 unsaved edits hold a refresh, but an in-app route change drops them', async ({
dashboards,
editor,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard(),
SINGLE_PANEL_ID,
);
expect(await editor.blocksUnload()).toBe(false);
await editor.title().fill('Never saved');
await expect(editor.unsavedBadge()).toBeVisible();
expect(await editor.blocksUnload()).toBe(true);
// Known behaviour, not endorsed: a refresh or tab close prompts, but there
// is no router blocker. If one is added, update this test, don't delete it.
await dashboards.visit(PATH.dashboard(id));
await expect(editor.discardModal()).toHaveCount(0);
const after = await dashboards.get(id);
expect(after.spec.panels[SINGLE_PANEL_ID].spec.display.name).toBe(
'Solo panel',
);
});
test('TC-08 a locked dashboard disables Save and never PATCHes', async ({
dashboards,
editor,
panels,
}) => {
const id = await dashboards.seed(singlePanelDashboard());
await dashboards.setLocked(id, true);
const patches = editor.capturePatches();
await dashboards.openEditor(id, SINGLE_PANEL_ID);
const save = editor.saveButton();
await expect(save).toBeDisabled();
await editor.hoverSave();
await expect(panels.tooltip.text(EditorText.lockedReason)).toBeVisible();
// The store short-circuits a locked patch before the network.
expect(patches).toHaveLength(0);
});
test('TC-09 an unknown panel id redirects back to the dashboard', async ({
authedPage: page,
dashboards,
editor,
}) => {
const id = await dashboards.seed(singlePanelDashboard());
await dashboards.visit(PATH.editor(id, 'does-not-exist'));
await page.waitForURL(PATH.onDashboard(id));
await expect(editor.root()).toHaveCount(0);
});
test('TC-10 Switch to View Mode hands off to the View modal', async ({
authedPage: page,
dashboards,
editor,
panels,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard(),
SINGLE_PANEL_ID,
);
await editor.switchToView().click();
await page.waitForURL(PATH.onDashboardWithParams(id));
await expect(panels.view.content()).toBeVisible();
});
});

View File

@@ -0,0 +1,167 @@
import {
expect,
logsCountQuery,
PanelKind,
Section,
SINGLE_PANEL_ID,
singlePanelDashboard,
test,
} from '../../../drivers/panel-editor';
// Scope: switching kind mid-edit — what the session cache restores, and which
// config survives a first-visit transfer. Config only carries when the TARGET
// kind declares that control (buildPluginSpec).
test.describe('Dashboards — panel type switching', () => {
test('TC-01 switching kind re-renders the preview', async ({
dashboards,
editor,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
await expect(editor.preview.renderer(PanelKind.TimeSeries)).toBeVisible();
await editor.switchType(PanelKind.Table);
await expect(editor.preview.renderer(PanelKind.Table)).toBeVisible();
await expect(editor.preview.renderer(PanelKind.TimeSeries)).toHaveCount(0);
});
test('TC-02 switching marks the editor dirty but does not persist', async ({
dashboards,
editor,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard(),
SINGLE_PANEL_ID,
);
await editor.switchType(PanelKind.Number);
await expect(editor.unsavedBadge()).toBeVisible();
const after = await dashboards.get(id);
expect(after.spec.panels[SINGLE_PANEL_ID].spec.plugin.kind).toBe(
PanelKind.TimeSeries,
);
});
test('TC-03 switching back restores the original kind and its settings from the session cache', async ({
dashboards,
editor,
}) => {
// Logs, because List rejects metrics; time_series-shaped, because Table
// can't read `raw`.
await dashboards.seedAndEdit(
singlePanelDashboard({ kind: PanelKind.Table, query: logsCountQuery() }),
SINGLE_PANEL_ID,
);
await expect(editor.preview.renderer(PanelKind.Table)).toBeVisible();
await editor.section.expand(Section.formatting);
await editor.setTile('decimals', '3');
// Not the default, so a freshly built Table would not have it.
await editor.switchType(PanelKind.List);
await expect(editor.preview.renderer(PanelKind.List)).toBeVisible();
await editor.switchType(PanelKind.Table);
await expect(editor.preview.renderer(PanelKind.Table)).toBeVisible();
await editor.section.expand(Section.formatting);
await expect(editor.tileInput('decimals', '3')).toBeChecked();
});
test('TC-04 the session cache does not survive a reload', async ({
dashboards,
editor,
}) => {
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
await editor.switchType(PanelKind.Table);
await expect(editor.preview.renderer(PanelKind.Table)).toBeVisible();
// The cache is a ref on the mounted editor, so a reload drops it. Reload
// alone — a redundant goto afterwards crashed the page on WebKit.
await dashboards.reload();
await expect(editor.root()).toBeVisible();
await expect(editor.preview.renderer(PanelKind.TimeSeries)).toBeVisible();
});
test('TC-05 a saved switch persists the new kind', async ({
dashboards,
editor,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard(),
SINGLE_PANEL_ID,
);
await editor.switchType(PanelKind.BarChart);
await editor.save();
const after = await dashboards.get(id);
expect(after.spec.panels[SINGLE_PANEL_ID].spec.plugin.kind).toBe(
PanelKind.BarChart,
);
});
test('TC-06 a panel-wide unit fans out into per-column units on Table', async ({
dashboards,
editor,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard({ pluginSpec: { formatting: { unit: 'ms' } } }),
SINGLE_PANEL_ID,
);
await editor.switchType(PanelKind.Table);
await editor.save();
// Table has no panel-wide unit, so it fans out to columns — one-way.
const after = await dashboards.get(id);
const formatting =
after.spec.panels[SINGLE_PANEL_ID].spec.plugin.spec.formatting;
expect(formatting?.unit).toBeUndefined();
expect(Object.values(formatting?.columnUnits ?? {})).toContain('ms');
});
test('TC-07 custom legend colours are dropped on a first-visit switch', async ({
dashboards,
editor,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard({
pluginSpec: {
legend: { position: 'bottom', customColors: { adservice: '#ff0000' } },
},
}),
SINGLE_PANEL_ID,
);
await editor.switchType(PanelKind.BarChart);
await editor.save();
// Keyed by series label, which the new kind may not reproduce, so dropped.
// Round-trips as null rather than being omitted.
const after = await dashboards.get(id);
const { customColors } =
after.spec.panels[SINGLE_PANEL_ID].spec.plugin.spec.legend ?? {};
expect(customColors ?? undefined).toBeUndefined();
});
test('TC-08 axis bounds survive a switch between kinds that both declare them', async ({
dashboards,
editor,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard(),
SINGLE_PANEL_ID,
);
await editor.section.expand(Section.axes);
await editor.control('soft-min').fill('5');
await editor.control('soft-max').fill('50');
// Bar Chart also declares minMax, so the bounds must carry over.
await editor.switchType(PanelKind.BarChart);
await editor.save();
const after = await dashboards.get(id);
const axes = after.spec.panels[SINGLE_PANEL_ID].spec.plugin.spec.axes;
expect(axes?.softMin).toBe(5);
expect(axes?.softMax).toBe(50);
});
});

View File

@@ -1,570 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
import { newAdminContext } from '../../helpers/auth';
import {
APM_METRICS_TITLE,
authToken,
createDashboardViaApi,
DEFAULT_DASHBOARD_TITLE,
deleteDashboardViaApi,
findDashboardIdByTitle,
gotoDashboardsList,
importApmMetricsDashboardViaUI,
openDashboardActionMenu,
SEARCH_PLACEHOLDER,
} from '../../helpers/dashboards';
// Tests in this file mutate the dashboard list (create / delete). Run them
// serially within the worker so state from one test does not leak into
// another's assertions. Files still run in parallel via the project-level
// fullyParallel setting.
test.describe.configure({ mode: 'serial' });
// ─── Suite-level seed registry ───────────────────────────────────────────
//
// Every dashboard a test creates is recorded here, and one `afterAll`
// deletes the lot at suite teardown. Individual tests do not need their
// own `try / finally` cleanup blocks.
const seedIds = new Set<string>();
const BASE_FIXTURE_TITLE = 'dashboards-list-base-fixture';
/** Seed a dashboard via API and register it for suite cleanup. */
async function seed(page: Page, title: string): Promise<string> {
const id = await createDashboardViaApi(page, title);
seedIds.add(id);
return id;
}
test.beforeAll(async ({ browser }) => {
// Persistent fixtures the read-only tests rely on:
// - A minimal base dashboard — keeps the list non-empty so the search
// input / sort button render. Seeded first via API so the workspace
// is populated before the UI import flow runs.
// - APM Metrics — a richer, real-world dashboard imported through the
// real Import JSON UI flow (file upload + Monaco editor + submit).
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
seedIds.add(await createDashboardViaApi(page, BASE_FIXTURE_TITLE));
seedIds.add(await importApmMetricsDashboardViaUI(page));
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) {
return;
}
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of seedIds) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
test.describe('Dashboards List Page', () => {
// ─── Page load and layout ────────────────────────────────────────────────
test('TC-01 page chrome and core controls render', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveTitle('SigNoz | All Dashboards');
await expect(
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
).toBeVisible();
await expect(
page.getByText('Create and manage dashboards for your workspace.'),
).toBeVisible();
await expect(page.getByPlaceholder(SEARCH_PLACEHOLDER)).toBeVisible();
await expect(page.getByText('All Dashboards')).toBeVisible();
await expect(page.getByTestId('sort-by')).toBeVisible();
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
await expect(page.getByRole('button', { name: 'Feedback' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
});
// ─── Search functionality ────────────────────────────────────────────────
test('TC-02 search by title returns matching dashboard', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-search-title';
await seed(page, name);
await gotoDashboardsList(page);
const search = page.getByPlaceholder(SEARCH_PLACEHOLDER);
await search.fill(name);
await expect(page).toHaveURL(new RegExp(`search=${name}`));
await expect(search).toHaveValue(name);
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
await expect(page.getByText(name).first()).toBeVisible();
});
test('TC-03 search by tag returns the APM Metrics dashboard', async ({
authedPage: page,
}) => {
// APM Metrics carries multiple tags — searching by one of them ("apm")
// surfaces the imported dashboard. This exercises the tag-match branch
// in the filter, distinct from title-match.
await gotoDashboardsList(page);
const search = page.getByPlaceholder(SEARCH_PLACEHOLDER);
await search.fill('apm');
await expect(page).toHaveURL(/search=apm/);
await expect(page.getByText(APM_METRICS_TITLE).first()).toBeVisible();
});
test('TC-04 direct navigation with ?search= pre-fills the input and filters results', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-search-deeplink';
await seed(page, name);
await page.goto(`/dashboard?search=${name}`);
await page
.getByRole('heading', { name: 'Dashboards', level: 1 })
.waitFor({ state: 'visible' });
await expect(page.getByPlaceholder(SEARCH_PLACEHOLDER)).toHaveValue(name);
await expect(page.getByText(name).first()).toBeVisible();
});
test('TC-05 clearing search restores the full list', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
const search = page.getByPlaceholder(SEARCH_PLACEHOLDER);
await search.fill('apm');
await expect(page).toHaveURL(/search=apm/);
await search.fill('');
// The app keeps the empty `search=` param in the URL — assert that no
// non-empty value remains and that rows are rendered again.
await expect(page).not.toHaveURL(/search=[^&]/);
await expect(search).toHaveValue('');
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
});
test('TC-06 search with no matching results shows empty state', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
const search = page.getByPlaceholder(SEARCH_PLACEHOLDER);
await search.fill('xyznonexistent999');
await expect(page.getByAltText('dashboard-image')).toHaveCount(0);
await expect(search).toBeVisible();
await expect(search).toHaveValue('xyznonexistent999');
});
test('TC-07 search is case-insensitive', async ({ authedPage: page }) => {
await gotoDashboardsList(page);
const search = page.getByPlaceholder(SEARCH_PLACEHOLDER);
await search.fill(APM_METRICS_TITLE.toLowerCase());
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
await expect(page.getByText(APM_METRICS_TITLE).first()).toBeVisible();
});
// ─── Sorting ─────────────────────────────────────────────────────────────
//
// `sortHandle` in DashboardsList.tsx hard-codes `order: 'descend'` —
// ascending mode is not yet implemented. Both sort options ride the same
// descending-only path, so one parameterised test covers them.
test('TC-08 sort options write columnKey & order=descend to the URL', async ({
authedPage: page,
}) => {
for (const [optionTestId, columnKey] of [
['sort-by-last-updated', 'updatedAt'],
['sort-by-last-created', 'createdAt'],
] as const) {
await gotoDashboardsList(page);
await expect(page).not.toHaveURL(/columnKey/);
await page.getByTestId('sort-by').click();
const option = page.getByTestId(optionTestId);
await option.waitFor({ state: 'visible' });
await option.click();
await expect(page).toHaveURL(new RegExp(`columnKey=${columnKey}`));
await expect(page).toHaveURL(/order=descend/);
await expect(page).not.toHaveURL(/order=ascend/);
}
});
// ─── Row actions (context menu) ──────────────────────────────────────────
test('TC-09 admin sees all five options in the action menu', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-actions-menu';
await seed(page, name);
await gotoDashboardsList(page);
const tooltip = await openDashboardActionMenu(page, name);
await expect(tooltip).toBeVisible();
await expect(tooltip.getByRole('button', { name: 'View' })).toBeVisible();
await expect(
tooltip.getByRole('button', { name: 'Open in New Tab' }),
).toBeVisible();
await expect(
tooltip.getByRole('button', { name: 'Copy Link' }),
).toBeVisible();
await expect(
tooltip.getByRole('button', { name: 'Export JSON' }),
).toBeVisible();
// Delete is rendered as a generic, not a button.
await expect(tooltip.getByText('Delete dashboard')).toBeVisible();
});
test('TC-10 view action navigates to the dashboard detail page', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-action-view';
await seed(page, name);
await gotoDashboardsList(page);
const tooltip = await openDashboardActionMenu(page, name);
await tooltip.getByRole('button', { name: 'View' }).click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
});
test('TC-11 open in new tab opens the dashboard in a new browser tab', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-action-newtab';
await seed(page, name);
await gotoDashboardsList(page);
const tooltip = await openDashboardActionMenu(page, name);
// Use page.context() — the auth fixture creates its own context per
// test, which is not the same as the default `context` fixture.
const [newPage] = await Promise.all([
page.context().waitForEvent('page'),
tooltip.getByRole('button', { name: 'Open in New Tab' }).click(),
]);
await newPage.waitForLoadState();
await expect(newPage).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
await newPage.close();
});
test('TC-12 copy link copies the dashboard URL to the clipboard', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-action-copy';
await seed(page, name);
await gotoDashboardsList(page);
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
const tooltip = await openDashboardActionMenu(page, name);
await tooltip.getByRole('button', { name: 'Copy Link' }).click();
await expect(page.getByText(/copied|success/i)).toBeVisible();
const clipboardText = await page.evaluate(async () =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).navigator.clipboard.readText(),
);
expect(clipboardText).toMatch(/\/dashboard\/[0-9a-f-]+/);
});
test('TC-13 export JSON downloads the dashboard as a JSON file', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-action-export';
await seed(page, name);
await gotoDashboardsList(page);
const tooltip = await openDashboardActionMenu(page, name);
const [download] = await Promise.all([
page.waitForEvent('download'),
tooltip.getByRole('button', { name: 'Export JSON' }).click(),
]);
expect(download.suggestedFilename()).toMatch(/\.json$/);
});
test('TC-14 action menu closes when clicking outside the popover', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-action-dismiss';
await seed(page, name);
await gotoDashboardsList(page);
await openDashboardActionMenu(page, name);
await expect(page.getByRole('tooltip')).toBeVisible();
await page.getByRole('heading', { name: 'Dashboards', level: 1 }).click();
await expect(page.getByRole('tooltip')).not.toBeVisible();
await expect(page).toHaveURL(/\/dashboard($|\?)/);
});
// ─── Creating dashboards via "New dashboard" dropdown ─────────────────────
//
// The "Enter dashboard name…" inline input on the list page is a
// `RequestDashboardBtn` (template-request feedback form), not a create
// flow. The only UI create path is the "New dashboard" dropdown.
test('TC-15 New dashboard dropdown shows exactly three options', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
await page.getByTestId('new-dashboard-cta').click();
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
await expect(menu.getByTestId('create-dashboard-menu-cta')).toBeVisible();
await expect(menu.getByTestId('import-json-menu-cta')).toBeVisible();
await expect(menu.getByTestId('view-templates-menu-cta')).toBeVisible();
});
test('TC-16 Create dashboard dropdown option creates a dashboard with the default name', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('create-dashboard-menu-cta').click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
await expect(page.getByText('Configure your new dashboard')).toBeVisible();
// "Configure" appears twice on the new-dashboard onboarding state — once
// in the toolbar and once in the empty-state section. The test only
// needs to confirm the onboarding rendered, so .first() is sufficient.
await expect(
page.getByRole('button', { name: 'Configure' }).first(),
).toBeVisible();
await expect(
page.getByRole('button', { name: /New Panel/ }).first(),
).toBeVisible();
// Register the UI-created dashboard with the suite teardown. After a
// successful "Create dashboard" the row must exist — assert that and
// then unconditionally register, so the test contains no `if`.
const sampleId = await findDashboardIdByTitle(page, DEFAULT_DASHBOARD_TITLE);
expect(
sampleId,
`${DEFAULT_DASHBOARD_TITLE} not found after UI create`,
).toBeDefined();
seedIds.add(sampleId as string);
});
test('TC-17 Import JSON dialog opens with code editor and upload button', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('import-json-menu-cta').click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByText('Import Dashboard JSON')).toBeVisible();
// "Upload JSON file" appears twice — once as the Ant Upload's hidden
// span wrapper, once as the visible button. .first() is enough to
// confirm the upload affordance rendered.
await expect(
dialog.getByRole('button', { name: 'Upload JSON file' }).first(),
).toBeVisible();
await expect(
dialog.getByRole('button', { name: 'Import and Next' }),
).toBeVisible();
});
test('TC-18 Import JSON dialog dismisses via Escape and via the close button', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
// Escape path — Monaco grabs focus on mount and swallows Escape; click
// the modal title first to blur Monaco so Ant's Modal `keyboard`
// handler picks up the keystroke.
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('import-json-menu-cta').click();
let dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByText('Import Dashboard JSON').click();
await page.keyboard.press('Escape');
await expect(dialog).not.toBeVisible();
await expect(page).toHaveURL(/\/dashboard($|\?)/);
// Close-button path — re-open and dismiss via the X.
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('import-json-menu-cta').click();
dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: /close/i }).click();
await expect(dialog).not.toBeVisible();
await expect(page).toHaveURL(/\/dashboard($|\?)/);
});
// ─── Deleting dashboards ─────────────────────────────────────────────────
//
// Known behaviour: clicking Cancel in the confirmation dialog navigates to
// the dashboard detail page rather than staying on the list.
test('TC-19 delete confirmation dialog shows dashboard name with Cancel and Delete buttons', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-delete-confirm';
await seed(page, name);
await gotoDashboardsList(page);
const tooltip = await openDashboardActionMenu(page, name);
// Ant's Popover can position the tooltip so the "Delete dashboard"
// item ends up outside the viewport (especially in CI, where font
// rendering shifts layout subtly). `click({ force: true })` skips
// actionability checks but Playwright still requires the click
// coordinates to land inside the viewport. `dispatchEvent('click')`
// fires the synthetic event directly on the DOM node — React's
// onClick handler runs normally — and bypasses coordinate checks
// entirely. This is the robust fix for Ant Popover positioning.
await tooltip.getByText('Delete dashboard').dispatchEvent('click');
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('heading')).toContainText(
'Are you sure you want to delete the',
);
await expect(dialog.getByRole('heading')).toContainText(name);
await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Delete' })).toBeVisible();
});
test('TC-20 cancelling delete navigates to the dashboard detail page (known behaviour)', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-delete-cancel';
await seed(page, name);
await gotoDashboardsList(page);
const tooltip = await openDashboardActionMenu(page, name);
await tooltip.getByText('Delete dashboard').dispatchEvent('click');
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Cancel' }).click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
});
test('TC-21 confirming delete removes the dashboard from the list', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-delete-confirmed';
const id = await seed(page, name);
await gotoDashboardsList(page);
const tooltip = await openDashboardActionMenu(page, name);
await tooltip.getByText('Delete dashboard').dispatchEvent('click');
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
// The Delete mutation is async — wait for the API response *and* the
// dialog to dismiss before navigating away, otherwise React Query's
// in-flight mutation gets cancelled by the navigation.
const deleteResponse = page.waitForResponse(
(r) => r.request().method() === 'DELETE' && /\/dashboards\//.test(r.url()),
);
await dialog.getByRole('button', { name: 'Delete' }).click();
await deleteResponse;
await expect(dialog).not.toBeVisible();
// After deletion, searching for the name should return no results.
await gotoDashboardsList(page);
await page.getByPlaceholder(SEARCH_PLACEHOLDER).fill(name);
await expect(page.getByAltText('dashboard-image')).toHaveCount(0);
// The UI delete already removed the resource — drop it from the
// suite-cleanup set so afterAll doesn't 404 on it.
seedIds.delete(id);
});
// ─── Row click navigation ────────────────────────────────────────────────
test('TC-22 clicking a dashboard row navigates to the detail page', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-row-click';
await seed(page, name);
await gotoDashboardsList(page);
await page.getByPlaceholder(SEARCH_PLACEHOLDER).fill(name);
await page.getByAltText('dashboard-image').first().click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
});
test('TC-23 sidebar Dashboards link navigates to the list page', async ({
authedPage: page,
}) => {
await page.goto('/home');
// Sidebar items are <div class="nav-item"> with the label as visible
// text — they're not <a role="link">, so getByRole won't reach them.
// Filter on the exact label to avoid matching nested items that
// happen to contain the substring.
await page
.locator('.nav-item')
.filter({ hasText: /^Dashboards$/ })
.click();
await expect(page).toHaveURL(/\/dashboard/);
await expect(page).toHaveTitle('SigNoz | All Dashboards');
});
// ─── URL state and deep linking ──────────────────────────────────────────
test('TC-24 browser Back after navigating to a dashboard restores search state', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-back-search';
await seed(page, name);
await page.goto(`/dashboard?search=${name}`);
await page
.getByRole('heading', { name: 'Dashboards', level: 1 })
.waitFor({ state: 'visible' });
await page.getByAltText('dashboard-image').first().click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
await page.goBack();
await expect(page).toHaveURL(new RegExp(`search=${name}`));
await expect(page.getByPlaceholder(SEARCH_PLACEHOLDER)).toHaveValue(name);
});
test('TC-25 direct navigation with sort params honours them on load', async ({
authedPage: page,
}) => {
await page.goto('/dashboard?columnKey=updatedAt&order=descend');
await page
.getByRole('heading', { name: 'Dashboards', level: 1 })
.waitFor({ state: 'visible' });
await expect(page).toHaveURL(/columnKey=updatedAt/);
await expect(page).toHaveURL(/order=descend/);
});
});

View File

@@ -0,0 +1,166 @@
import {
COMPACT_PANELS,
PanelAction,
PanelKind,
QueryRange,
SINGLE_PANEL_ID,
compactDashboard,
expect,
singlePanelDashboard,
test,
} from '../../../drivers/panels';
// Scope: what the panel ⋮ menu's actions do — new-tab alert, download, and the
// mutating ones reaching the spec — plus the dashboard lock gating them.
//
// Items carry no testid, so everything matches role + visible label.
test.describe('Dashboards — panel actions', () => {
test('TC-01 Create Alerts opens the alert builder in a new tab', async ({
dashboards,
panels,
}) => {
await dashboards.seedAndOpen(compactDashboard());
const popup = panels.nextTab();
await panels
.panel(COMPACT_PANELS.timeseries)
.actions.run(PanelAction.createAlert);
const alertTab = await popup;
await expect(alertTab).toHaveURL(/\/alerts\/new/);
await alertTab.close();
});
// Chromium-only: headless Firefox/WebKit don't surface the canvas blob as a
// Playwright download event. CSV and SVG are unaffected.
test('TC-02 Download as PNG produces a file', async ({
dashboards,
panels,
browserName,
}) => {
test.skip(
browserName !== 'chromium',
'headless Firefox/WebKit do not emit a download event for the canvas blob',
);
await dashboards.seedAndOpen(compactDashboard());
const target = panels.panel(COMPACT_PANELS.timeseries);
await target.scrollIntoView();
const download = panels.nextDownload();
await target.actions.download('PNG');
const file = await download;
expect(file.suggestedFilename()).toMatch(/\.png$/);
});
test('TC-03 Clone adds a second panel and persists it', async ({
dashboards,
panels,
}) => {
const id = await dashboards.seedAndOpen(compactDashboard());
const before = await dashboards.get(id);
const beforeCount = Object.keys(before.spec.panels).length;
await panels.panel(COMPACT_PANELS.timeseries).actions.run(PanelAction.clone);
await expect
.poll(async () => Object.keys((await dashboards.get(id)).spec.panels).length)
.toBe(beforeCount + 1);
});
test('TC-04 Delete panel removes the panel and its layout item', async ({
dashboards,
panels,
}) => {
const id = await dashboards.seedAndOpen(compactDashboard());
const list = panels.panel(COMPACT_PANELS.list);
await list.actions.run(PanelAction.delete);
await expect(dashboards.text('Delete panel?')).toBeVisible();
await dashboards.confirmDelete().click();
await expect(list.root()).toHaveCount(0);
// Optimistic: the panel leaves the DOM before the PATCH lands.
await expect
.poll(
async () => (await dashboards.get(id)).spec.panels[COMPACT_PANELS.list],
)
.toBeUndefined();
// The grid item must go too, or a dangling $ref renders an empty tile.
const after = await dashboards.get(id);
const refs = after.spec.layouts.flatMap((layout) =>
layout.spec.items.map((item) => item.content.$ref),
);
expect(refs).not.toContain(`#/spec/panels/${COMPACT_PANELS.list}`);
});
test('TC-05 Move to section relocates the panel between sections', async ({
dashboards,
panels,
}) => {
const id = await dashboards.seedAndOpen(compactDashboard());
await panels.panel(COMPACT_PANELS.timeseries).actions.moveTo('Tabular');
const ref = `#/spec/panels/${COMPACT_PANELS.timeseries}`;
await expect
.poll(async () => {
const { spec } = await dashboards.get(id);
return spec.layouts
.filter((layout) =>
layout.spec.items.some((item) => item.content.$ref === ref),
)
.map((layout) => layout.spec.display.title);
})
.toEqual(['Tabular']);
});
test('TC-06 a locked dashboard disables the mutating actions', async ({
dashboards,
panels,
}) => {
const id = await dashboards.seed(compactDashboard());
await dashboards.setLocked(id, true);
await dashboards.open(id);
const { actions } = panels.panel(COMPACT_PANELS.timeseries);
await actions.open();
// View and Download don't mutate, so they stay available.
await expect(actions.item(PanelAction.view)).toBeEnabled();
for (const label of [
PanelAction.edit,
PanelAction.clone,
PanelAction.move,
PanelAction.delete,
]) {
await expect(actions.item(label)).toBeDisabled();
}
});
// An empty table exports nothing, so the rows are mocked.
test('TC-07 Download as CSV on a Table panel produces a file', async ({
dashboards,
panels,
queryRange,
}) => {
await queryRange.respond(
QueryRange.scalar({
groupColumns: ['service.name'],
aggregationColumns: ['A'],
rows: [['adservice', 10]],
}),
);
await dashboards.seedAndOpen(singlePanelDashboard({ kind: PanelKind.Table }));
const table = panels.panel(SINGLE_PANEL_ID);
await table.waitRendered(PanelKind.Table);
const download = panels.nextDownload();
await table.actions.download('CSV');
const file = await download;
expect(file.suggestedFilename()).toMatch(/\.csv$/);
});
});

View File

@@ -0,0 +1,207 @@
import {
type Dashboards,
PanelKind,
type Panels,
QUERY_TYPE_PANELS,
QueryRange,
type QueryRangeMock,
SINGLE_PANEL_ID,
VARIABLE_NAMES,
VARIABLE_PANEL_ID,
expect,
metricsQuery,
queryTypesDashboard,
ramp,
singlePanelDashboard,
test,
variablesDashboard,
} from '../../../drivers/panels';
// Scope: the drilldown ContextMenu — dismissal, navigation, variables, and
// the query types deliberately excluded. Mocked so a click lands on a known series.
const ONE_SERIES = QueryRange.timeSeries([
{ labels: { 'service.name': 'adservice' }, points: ramp(12, 2, 8) },
]);
async function openChartDrilldown(
panels: Panels,
panelId: string,
): Promise<void> {
await panels.panel(panelId).chart.clickPoint();
await expect(panels.drilldown.menu()).toBeVisible();
}
async function seedChartPanel({
dashboards,
queryRange,
panels,
}: {
dashboards: Dashboards;
queryRange: QueryRangeMock;
panels: Panels;
}): Promise<string> {
await queryRange.respond(ONE_SERIES);
const id = await dashboards.seedAndOpen(singlePanelDashboard());
await expect(
panels.panel(SINGLE_PANEL_ID).renderer(PanelKind.TimeSeries),
).toBeVisible();
return id;
}
test.describe('Dashboards — panel drilldown', () => {
test('TC-01 clicking the backdrop closes the menu', async ({
dashboards,
queryRange,
panels,
}) => {
await seedChartPanel({ dashboards, queryRange, panels });
await openChartDrilldown(panels, SINGLE_PANEL_ID);
// Backdrop click. Escape only works when the backdrop holds focus.
await panels.drilldown.dismiss();
await expect(panels.drilldown.menu()).toHaveCount(0);
});
test('TC-02 View in Logs navigates to the logs explorer', async ({
authedPage: page,
dashboards,
queryRange,
panels,
}) => {
await seedChartPanel({ dashboards, queryRange, panels });
await openChartDrilldown(panels, SINGLE_PANEL_ID);
// Disabled while the drilldown query resolves.
const viewLogs = panels.drilldown.item('drilldown-view-logs');
await expect(viewLogs).toBeEnabled();
// safeNavigate uses `{ newTab: true }`, so the dashboard stays put.
const popup = panels.nextTab();
await viewLogs.click();
const logsTab = await popup;
await logsTab.waitForURL(/\/logs\/logs-explorer/);
// The clicked series carries across as a composite query.
expect(
new URL(logsTab.url()).searchParams.get('compositeQuery'),
).toBeTruthy();
await logsTab.close();
await expect(page).toHaveURL(/\/dashboard\//);
});
test('TC-03 Breakout opens a submenu and the back arrow returns', async ({
dashboards,
queryRange,
panels,
}) => {
await seedChartPanel({ dashboards, queryRange, panels });
await openChartDrilldown(panels, SINGLE_PANEL_ID);
await panels.drilldown.item('drilldown-breakout').click();
const back = panels.drilldown.back('breakout');
await expect(back).toBeVisible();
await expect(panels.drilldown.breakoutSearch()).toBeVisible();
// Back returns to the aggregate menu.
await back.click();
await expect(panels.drilldown.item('drilldown-view-logs')).toBeVisible();
});
test('TC-04 the Dashboard Variables submenu offers set and create', async ({
dashboards,
queryRange,
panels,
}) => {
await queryRange.respond(ONE_SERIES);
await dashboards.seedAndOpen(variablesDashboard());
await expect(
panels.panel(VARIABLE_PANEL_ID).renderer(PanelKind.TimeSeries),
).toBeVisible();
await openChartDrilldown(panels, VARIABLE_PANEL_ID);
await panels.drilldown.item('drilldown-dashboard-variables').click();
// `service.name` is grouped-by and already has a variable, so Set is offered.
await expect(panels.drilldown.item('drilldown-var-set')).toBeVisible();
await expect(panels.drilldown.back('var')).toBeVisible();
});
test('TC-05 setting a variable from the menu updates the variables bar', async ({
dashboards,
queryRange,
panels,
}) => {
await queryRange.respond(ONE_SERIES);
await dashboards.seedAndOpen(variablesDashboard());
await expect(
panels.panel(VARIABLE_PANEL_ID).renderer(PanelKind.TimeSeries),
).toBeVisible();
// Set binds the clicked group-by field, so it targets the dynamic
// variable on `service.name`; the custom one already defaults to adservice.
const dynamic = dashboards.variable(VARIABLE_NAMES.dynamic);
await expect(dynamic).not.toContainText('adservice');
await openChartDrilldown(panels, VARIABLE_PANEL_ID);
await panels.drilldown.item('drilldown-dashboard-variables').click();
await panels.drilldown.item('drilldown-var-set').click();
await expect(dynamic).toContainText('adservice');
});
test('TC-06 creating a variable from the menu patches the dashboard spec', async ({
dashboards,
queryRange,
panels,
}) => {
await queryRange.respond(
QueryRange.timeSeries([
{
labels: { 'k8s.namespace.name': 'signoz-adservice' },
points: ramp(12, 2, 8),
},
]),
);
// No matching variable for this field, so the menu offers Create.
const id = await dashboards.seedAndOpen(
variablesDashboard(
undefined,
metricsQuery({ groupBy: ['k8s.namespace.name'] }),
),
);
await expect(
panels.panel(VARIABLE_PANEL_ID).renderer(PanelKind.TimeSeries),
).toBeVisible();
await openChartDrilldown(panels, VARIABLE_PANEL_ID);
await panels.drilldown.item('drilldown-dashboard-variables').click();
await panels.drilldown.item('drilldown-var-create').click();
// Create persists a DYNAMIC variable into spec.variables.
await expect
.poll(async () =>
(await dashboards.get(id)).spec.variables.some(
(variable) => variable.spec.name === 'k8s.namespace.name',
),
)
.toBe(true);
});
test('TC-07 a non-builder query opens no menu', async ({
dashboards,
queryRange,
panels,
}) => {
await queryRange.respond(ONE_SERIES);
await dashboards.seedAndOpen(queryTypesDashboard());
const target = panels.panel(QUERY_TYPE_PANELS.promql);
await expect(target.renderer(PanelKind.TimeSeries)).toBeVisible();
// Gated to QUERY_BUILDER queries.
await target.chart.click();
await expect(panels.drilldown.menu()).toHaveCount(0);
});
});

View File

@@ -0,0 +1,122 @@
import {
SINGLE_PANEL_ID,
VARIABLE_NAMES,
compactDashboard,
expect,
singlePanelDashboard,
test,
variablesDashboard,
} from '../../../drivers/dashboard-page';
const MISSING_ID = 'nonexistent-id-99999';
// 2023-01-01, one minute: long before any golden telemetry.
const FAR_PAST = '?startTime=1672531200000&endTime=1672531260000';
test.describe('Dashboards — edge cases', () => {
test('TC-01 panels show their empty state for a far-past range', async ({
dashboards,
dashboardPage,
panels,
}) => {
const collected = dashboardPage.collectPageErrors();
const id = await dashboards.seed(singlePanelDashboard());
await dashboards.visit(`/dashboard/${id}${FAR_PAST}`);
const target = panels.panel(SINGLE_PANEL_ID);
await target.scrollIntoView();
await expect(target.noData()).toBeVisible();
expect(collected.errors).toEqual([]);
});
test('TC-02 a nonexistent dashboard id shows a load error', async ({
dashboards,
dashboardPage,
}) => {
await dashboards.visit(`/dashboard/${MISSING_ID}`);
// The fetch retries before giving up.
await expect(dashboardPage.loadError()).toBeVisible({ timeout: 20_000 });
await expect(dashboardPage.breadcrumb()).toHaveCount(0);
});
test('TC-03 the side nav still works after a nonexistent dashboard', async ({
dashboards,
dashboardPage,
authedPage: page,
}) => {
await dashboards.visit(`/dashboard/${MISSING_ID}`);
await expect(dashboardPage.loadError()).toBeVisible({ timeout: 20_000 });
await dashboardPage.navigateSideNav('Dashboards');
await expect(page).toHaveURL(/\/dashboard(\?|$)/);
await expect(dashboardPage.listPage()).toBeVisible();
});
// `?variables=` is read once into the persisted selection and then dropped
// from the URL, so the reload proves the selection, not the param, survives.
// Fixme: the param is dropped but the selection keeps its default.
test.fixme('TC-04 a variable deep link survives a reload', async ({
dashboards,
dashboardPage,
authedPage: page,
}) => {
const id = await dashboards.seed(variablesDashboard());
const value = 'cartservice';
const deepLink = new URLSearchParams({
variables: JSON.stringify({ [VARIABLE_NAMES.custom]: value }),
});
await dashboards.visit(`/dashboard/${id}?${deepLink.toString()}`);
const selector = dashboardPage.variableValue(VARIABLE_NAMES.custom);
await expect(selector).toContainText(value);
await expect(page).not.toHaveURL(/variables=/);
await dashboards.reload();
await expect(selector).toContainText(value);
});
test('TC-05 an inverted time range does not crash the dashboard', async ({
dashboards,
dashboardPage,
panels,
}) => {
const collected = dashboardPage.collectPageErrors();
const fixture = singlePanelDashboard();
const id = await dashboards.seed(fixture);
await dashboards.visit(`/dashboard/${id}?startTime=999999&endTime=999998`);
await expect(dashboardPage.title()).toHaveText(fixture.spec.display.name);
await expect(panels.panel(SINGLE_PANEL_ID).title()).toBeVisible();
expect(collected.errors).toEqual([]);
});
test('TC-06 a name at the 128-character limit keeps the toolbar on screen', async ({
dashboards,
dashboardPage,
}) => {
const longName = `LongName-${'x'.repeat(119)}`;
await dashboards.seedAndOpen(compactDashboard(longName));
await expect(dashboardPage.title()).toHaveText(longName);
await expect(dashboardPage.toolbar.configure()).toBeInViewport();
await expect(dashboardPage.toolbar.newPanel()).toBeInViewport();
});
test('TC-07 special characters in the name round-trip unescaped', async ({
dashboards,
dashboardPage,
}) => {
const trickyName = `Spec & Chars / "${Date.now()}" — émoji 🎯`;
await dashboards.seedAndOpen(compactDashboard(trickyName));
await expect(dashboardPage.title()).toHaveText(trickyName);
await expect(dashboardPage.breadcrumb()).toContainText(trickyName);
await expect.poll(() => dashboardPage.documentTitle()).toBe(trickyName);
});
});

View File

@@ -0,0 +1,186 @@
import {
DashboardAction,
compactDashboard,
expect,
test,
} from '../../../drivers/dashboard-page';
// Export and copy moved from the Actions menu into the JSON drawer.
test.describe('Dashboards — edit mode', () => {
test('TC-01 the Actions menu lists every dashboard action', async ({
dashboards,
dashboardPage,
}) => {
await dashboards.seedAndOpen(compactDashboard());
await dashboardPage.actions.open();
for (const action of Object.values(DashboardAction)) {
await expect(dashboardPage.actions.item(action)).toBeEnabled();
}
await expect(dashboardPage.actions.item(DashboardAction.lock)).toHaveText(
'Lock dashboard',
);
});
test('TC-02 locking disables the edit affordances and persists', async ({
dashboards,
dashboardPage,
}) => {
const id = await dashboards.seedAndOpen(compactDashboard());
await dashboardPage.lock();
await expect(dashboardPage.toolbar.configure()).toBeDisabled();
await expect(dashboardPage.toolbar.newPanel()).toBeDisabled();
await dashboardPage.actions.open();
for (const action of [
DashboardAction.rename,
DashboardAction.newSection,
DashboardAction.delete,
]) {
await expect(dashboardPage.actions.item(action)).toBeDisabled();
}
await expect.poll(async () => (await dashboards.get(id)).locked).toBe(true);
});
test('TC-03 renaming updates the title and breadcrumb', async ({
dashboards,
dashboardPage,
}) => {
const id = await dashboards.seedAndOpen(compactDashboard());
const renamed = `Renamed-${Date.now()}`;
await dashboardPage.rename.to(renamed);
await expect(dashboardPage.breadcrumb()).toContainText(renamed);
await expect
.poll(async () => (await dashboards.get(id)).spec.display.name)
.toBe(renamed);
});
test('TC-04 cancelling a rename leaves the name unchanged', async ({
dashboards,
dashboardPage,
}) => {
const fixture = compactDashboard();
const original = fixture.spec.display.name;
await dashboards.seedAndOpen(fixture);
await dashboardPage.actions.run(DashboardAction.rename);
await dashboardPage.rename.input().fill('Should Not Be Saved');
await dashboardPage.rename.cancel().click();
await expect(dashboardPage.title()).toHaveText(original);
await expect(dashboardPage.breadcrumb()).toContainText(original);
});
test('TC-05 a section added from the Actions menu can be removed', async ({
dashboards,
dashboardPage,
}) => {
await dashboards.seedAndOpen(compactDashboard());
const title = `e2e-section-${Date.now()}`;
await dashboardPage.addSection(title);
await dashboardPage.section(title).delete();
await expect(dashboardPage.section(title).root()).toHaveCount(0);
});
test('TC-06 the JSON drawer downloads the dashboard as .json', async ({
dashboards,
dashboardPage,
}) => {
await dashboards.seedAndOpen(compactDashboard());
await dashboardPage.json.open();
expect(await dashboardPage.json.download()).toMatch(/\.json$/);
});
test('TC-07 the JSON drawer copies the dashboard JSON', async ({
dashboards,
dashboardPage,
browserName,
}) => {
test.skip(
browserName !== 'chromium',
'clipboard-read is a Chromium-only permission',
);
const fixture = compactDashboard();
await dashboards.seedAndOpen(fixture);
await dashboardPage.json.open();
const copied = JSON.parse(await dashboardPage.json.copy()) as {
spec?: { display?: { name?: string } };
};
expect(copied.spec?.display?.name).toBe(fixture.spec.display.name);
});
test('TC-08 Full screen shows only the grid and exits cleanly', async ({
dashboards,
dashboardPage,
}) => {
const collected = dashboardPage.collectPageErrors();
await dashboards.seedAndOpen(compactDashboard());
await dashboardPage.actions.run(DashboardAction.fullScreen);
await expect.poll(() => dashboardPage.isFullScreen()).toBe(true);
await expect(dashboardPage.title()).toBeHidden();
await expect(dashboardPage.section('Charts').panels()).toHaveCount(1);
await dashboardPage.exitFullScreen();
await expect(dashboardPage.title()).toBeVisible();
expect(collected.errors).toEqual([]);
});
test('TC-09 lock then unlock restores the edit affordances', async ({
dashboards,
dashboardPage,
}) => {
const id = await dashboards.seedAndOpen(compactDashboard());
await dashboardPage.lock();
await dashboardPage.actions.open();
await expect(dashboardPage.actions.item(DashboardAction.lock)).toHaveText(
'Unlock dashboard',
);
await dashboardPage.actions.item(DashboardAction.lock).click();
await expect(dashboardPage.lockToggle()).toHaveAccessibleName(
'Lock dashboard',
);
await expect(dashboardPage.toolbar.newPanel()).toBeEnabled();
await dashboardPage.actions.open();
await expect(
dashboardPage.actions.item(DashboardAction.rename),
).toBeEnabled();
await expect(
dashboardPage.actions.item(DashboardAction.newSection),
).toBeEnabled();
await expect.poll(async () => (await dashboards.get(id)).locked).toBe(false);
});
test('TC-10 a rename survives a reload', async ({
dashboards,
dashboardPage,
}) => {
const id = await dashboards.seedAndOpen(compactDashboard());
const renamed = `Renamed-Persist-${Date.now()}`;
await dashboardPage.rename.to(renamed);
await expect
.poll(async () => (await dashboards.get(id)).spec.display.name)
.toBe(renamed);
await dashboards.reload();
await expect(dashboardPage.title()).toHaveText(renamed);
await expect.poll(() => dashboardPage.documentTitle()).toBe(renamed);
});
});

View File

@@ -0,0 +1,141 @@
import {
expect,
GOLDEN,
PANEL_TYPE_TILES,
PanelKind,
PATH,
test,
} from '../../../drivers/dashboard-list';
// Scope: a dashboard built entirely through the UI — the list page's Blank
// create flow, one panel of every queryable kind, and deletion from the list.
/** Text carries no query. */
const QUERYABLE = PANEL_TYPE_TILES.filter(
(tile) => tile.kind !== PanelKind.Text,
);
/** List seeds a logs query that saves as-is; every other kind seeds metrics. */
const METRICS_TILES = QUERYABLE.filter((tile) => tile.kind !== PanelKind.List);
function uniqueName(label: string): string {
return `e2e ${label} ${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
}
test.describe('Dashboards — dashboard from scratch', () => {
test('TC-01 a blank dashboard is created from the list page', async ({
authedPage: page,
dashboardList,
dashboards,
}) => {
const name = uniqueName('blank');
await dashboardList.open();
const id = await dashboardList.create({
name,
description: 'Built through the UI',
tags: ['team:e2e', 'env:test'],
});
await expect(page).toHaveURL(PATH.onDashboard(id));
await expect(dashboards.title()).toHaveText(name);
await expect(dashboards.emptyAddPanel()).toBeVisible();
const saved = await dashboards.get(id);
expect(saved.spec.display.name).toBe(name);
expect(saved.spec.display.description).toBe('Built through the UI');
expect(saved.tags).toEqual(
expect.arrayContaining([
{ key: 'team', value: 'e2e' },
{ key: 'env', value: 'test' },
]),
);
expect(saved.tags).toHaveLength(2);
expect(Object.keys(saved.spec.panels)).toHaveLength(0);
});
test('TC-02 Create is disabled until the dashboard has a name', async ({
dashboardList,
}) => {
await dashboardList.open();
await dashboardList.newDashboardButton().click();
await dashboardList.nameInput().fill(' ');
await expect(dashboardList.submit()).toBeDisabled();
await dashboardList.nameInput().fill(uniqueName('named'));
await expect(dashboardList.submit()).toBeEnabled();
});
test('TC-03 one panel of every queryable kind is added and persists', async ({
authedPage: page,
dashboardList,
dashboards,
editor,
newPanel,
panels,
}) => {
await dashboardList.open();
const id = await dashboardList.create({ name: uniqueName('all kinds') });
await dashboards.emptyAddPanel().click();
await newPanel.choose(PanelKind.List);
await editor.title().fill('List panel');
await editor.save();
await page.waitForURL(PATH.onDashboard(id));
await expect(panels.titles().filter({ hasText: 'List panel' })).toBeVisible();
for (const { kind, label } of METRICS_TILES) {
await dashboards.headerAddPanel().click();
await newPanel.choose(kind);
expect(dashboards.param('panelKind')).toBe(kind);
await expect(editor.root()).toBeVisible();
await editor.title().fill(`${label} panel`);
await editor.query.selectMetric(GOLDEN.metrics.calls);
await editor.save();
await page.waitForURL(PATH.onDashboard(id));
await expect(
panels.titles().filter({ hasText: `${label} panel` }),
).toBeVisible();
}
await dashboards.reload();
for (const { label } of QUERYABLE) {
await expect(
panels.titles().filter({ hasText: `${label} panel` }),
).toBeVisible();
}
await expect(panels.titles()).toHaveCount(QUERYABLE.length);
const saved = await dashboards.get(id);
const persisted = Object.values(saved.spec.panels);
expect(persisted.map((panel) => panel.spec.plugin.kind).sort()).toEqual(
QUERYABLE.map((tile) => tile.kind).sort(),
);
for (const panel of persisted) {
expect(panel.spec.queries).toHaveLength(1);
}
const items = saved.spec.layouts.flatMap((layout) => layout.spec.items);
expect(items).toHaveLength(QUERYABLE.length);
});
test('TC-04 a dashboard is deleted from the list and disappears', async ({
dashboardList,
dashboards,
}) => {
const name = uniqueName('doomed');
await dashboardList.open();
const id = await dashboardList.create({ name });
await dashboardList.open(name);
await expect(dashboardList.rowTitle(name)).toBeVisible();
await dashboardList.delete(name);
await expect(dashboardList.rowTitle(name)).toHaveCount(0);
await dashboardList.open(name);
await expect(dashboardList.rowTitle(name)).toHaveCount(0);
await expect(dashboards.get(id)).rejects.toThrow(/404/);
});
});

View File

@@ -0,0 +1,106 @@
import {
COMPACT_PANELS,
PanelKind,
compactDashboard,
expect,
test,
} from '../../../drivers/panels';
// Scope: grid layout mutations — drag, resize, and the guarantee that the
// header's action cluster never starts a drag.
test.describe('Dashboards — grid layout', () => {
test('TC-01 resizing a panel persists its new size', async ({
dashboards,
panels,
}) => {
const id = await dashboards.seedAndOpen(compactDashboard());
const target = panels.panel(COMPACT_PANELS.timeseries);
await expect(target.renderer(PanelKind.TimeSeries)).toBeVisible();
const before = await dashboards.gridItem(id, COMPACT_PANELS.timeseries);
await target.grid.resize(60, 90);
// The grid persists on resize-stop, so the spec is the source of truth
// rather than the rendered pixel size.
await expect
.poll(async () => {
const after = await dashboards.gridItem(id, COMPACT_PANELS.timeseries);
return after.height > before.height || after.width > before.width;
})
.toBe(true);
});
test('TC-02 a resized layout survives a reload', async ({
dashboards,
panels,
}) => {
const id = await dashboards.seedAndOpen(compactDashboard());
const target = panels.panel(COMPACT_PANELS.timeseries);
await expect(target.renderer(PanelKind.TimeSeries)).toBeVisible();
await target.grid.resize(0, 90);
await expect
.poll(
async () =>
(await dashboards.gridItem(id, COMPACT_PANELS.timeseries)).height,
)
.toBeGreaterThan(6);
const persisted = await dashboards.gridItem(id, COMPACT_PANELS.timeseries);
const rendered = await target.grid.size();
await dashboards.reload();
await expect(target.renderer(PanelKind.TimeSeries)).toBeVisible();
expect(await dashboards.gridItem(id, COMPACT_PANELS.timeseries)).toEqual(
persisted,
);
// The page must lay out from the saved size, not a stale default.
await expect
.poll(async () => (await target.grid.size()).height)
.toBeCloseTo(rendered.height, -1);
});
test('TC-03 opening the actions menu does not start a drag', async ({
dashboards,
panels,
}) => {
const id = await dashboards.seedAndOpen(compactDashboard());
const target = panels.panel(COMPACT_PANELS.timeseries);
await expect(target.renderer(PanelKind.TimeSeries)).toBeVisible();
const before = await dashboards.gridItem(id, COMPACT_PANELS.timeseries);
// The ⋮ button sits inside the drag handle but is marked `panel-no-drag`
// and stops pointerdown, so opening the menu must leave the grid alone.
await target.actions.open();
await target.actions.close();
expect(await dashboards.gridItem(id, COMPACT_PANELS.timeseries)).toEqual(
before,
);
});
test('TC-04 dragging by the header moves the panel within its section', async ({
dashboards,
panels,
}) => {
const id = await dashboards.seedAndOpen(compactDashboard());
const target = panels.panel(COMPACT_PANELS.timeseries);
await expect(target.renderer(PanelKind.TimeSeries)).toBeVisible();
const before = await dashboards.gridItem(id, COMPACT_PANELS.timeseries);
// A full tile-width right, so the swap is unambiguous.
await target.grid.dragByHeader();
await expect
.poll(async () => {
const after = await dashboards.gridItem(id, COMPACT_PANELS.timeseries);
return after.x !== before.x || after.y !== before.y;
})
.toBe(true);
});
});

View File

@@ -0,0 +1,87 @@
import {
COMPACT_PANELS,
PanelKind,
QueryRange,
compactDashboard,
buildDashboard,
expect,
panel,
ramp,
test,
} from '../../../drivers/panels';
// Scope: the panel header's identity and status affordances.
test.describe('Dashboards — panel header', () => {
test('TC-01 header shows the panel title and description tooltip', async ({
dashboards,
panels,
}) => {
await dashboards.seedAndOpen(compactDashboard());
const target = panels.panel(COMPACT_PANELS.timeseries);
await expect(target.title()).toHaveText('Calls by service');
const info = target.infoIcon();
await expect(info).toBeVisible();
await info.hover();
await expect(
panels.tooltip.text('Rate of signoz_calls_total grouped by service.name'),
).toBeVisible();
await expect(panels.panel(COMPACT_PANELS.table).infoIcon()).toHaveCount(0);
});
test('TC-02 a query warning surfaces the warning indicator and its popover', async ({
dashboards,
queryRange,
panels,
}) => {
await queryRange.respond(
QueryRange.withWarning(
QueryRange.timeSeries([
{ labels: { 'service.name': 'adservice' }, points: ramp(12, 1, 9) },
]),
'sampled result',
),
);
await dashboards.seedAndOpen(compactDashboard());
const warning = panels.panel(COMPACT_PANELS.timeseries).statusWarning();
await expect(warning).toBeVisible();
await expect(warning).toHaveAttribute('aria-label', 'Panel warning');
// Hover tooltip, not a popover; Radix renders the content twice.
await warning.hover();
await expect(panels.tooltip.statusContent()).toContainText('sampled result');
});
test('TC-03 the time-preference pill reflects a panel-scoped window', async ({
dashboards,
panels,
}) => {
// The pill only renders when a panel opts OUT of the global range, so the
// unpinned neighbour is the control.
await dashboards.seedAndOpen(
buildDashboard({
sections: [
{
title: 'Time preference',
panels: {
pinned: panel(PanelKind.TimeSeries, {
name: 'Pinned to 15m',
pluginSpec: { visualization: { timePreference: 'last_15_min' } },
}),
global: panel(PanelKind.TimeSeries, {
name: 'Follows the dashboard',
}),
},
},
],
}),
);
await expect(panels.panel('pinned').timePreference()).toBeVisible();
await expect(panels.panel('global').timePreference()).toHaveCount(0);
});
});

View File

@@ -0,0 +1,146 @@
import {
COMPACT_PANELS,
SectionAction,
compactDashboard,
expect,
test,
} from '../../../drivers/dashboard-page';
// Collapse is a per-user preference kept in the browser, not in the spec;
// rename, add and delete are patches to the spec.
test.describe('Dashboards — sections', () => {
test('TC-01 collapsing a section hides its panels and keeps its header', async ({
dashboards,
dashboardPage,
panels,
}) => {
await dashboards.seedAndOpen(compactDashboard());
const tabular = dashboardPage.section('Tabular');
await tabular.collapse();
await expect(tabular.toggle()).toBeVisible();
await expect(panels.panel(COMPACT_PANELS.table).root()).toBeHidden();
await expect(panels.panel(COMPACT_PANELS.list).root()).toBeHidden();
});
test('TC-02 expanding restores every panel the section held', async ({
dashboards,
dashboardPage,
}) => {
await dashboards.seedAndOpen(compactDashboard());
const tabular = dashboardPage.section('Tabular');
const before = await tabular.panels().count();
await tabular.collapse();
await tabular.expand();
await expect(tabular.panels()).toHaveCount(before);
});
test('TC-03 the section menu offers add, rename, clone and delete', async ({
dashboards,
dashboardPage,
}) => {
await dashboards.seedAndOpen(compactDashboard());
const charts = dashboardPage.section('Charts');
await charts.openMenu();
for (const label of Object.values(SectionAction)) {
await expect(charts.menuItem(label)).toBeEnabled();
}
});
test('TC-04 renaming a section updates its header and persists', async ({
dashboards,
dashboardPage,
}) => {
const id = await dashboards.seedAndOpen(compactDashboard());
const renamed = `Renamed ${Date.now()}`;
await dashboardPage.section('Charts').rename(renamed);
await expect(dashboardPage.section(renamed).toggle()).toBeVisible();
await expect(dashboardPage.section('Charts').root()).toHaveCount(0);
await expect
.poll(async () =>
(await dashboards.get(id)).spec.layouts.map(
(layout) => layout.spec.display.title,
),
)
.toEqual([renamed, 'Tabular']);
});
test('TC-05 cancelling a section rename leaves the title unchanged', async ({
dashboards,
dashboardPage,
}) => {
await dashboards.seedAndOpen(compactDashboard());
const tabular = dashboardPage.section('Tabular');
await tabular.startRename();
await dashboardPage.sectionModal.input().fill('Should not be applied');
await dashboardPage.sectionModal.cancel().click();
await expect(dashboardPage.sectionModal.input()).toBeHidden();
await expect(tabular.toggle()).toBeVisible();
await expect(
dashboardPage.section('Should not be applied').root(),
).toHaveCount(0);
});
test('TC-06 a section added from the Actions menu can be deleted', async ({
dashboards,
dashboardPage,
}) => {
const id = await dashboards.seedAndOpen(compactDashboard());
const title = `Temp section ${Date.now()}`;
await dashboardPage.addSection(title);
await expect
.poll(async () => (await dashboards.get(id)).spec.layouts.length)
.toBe(3);
await dashboardPage.section(title).delete();
await expect(dashboardPage.sectionTitles()).toHaveText(['Charts', 'Tabular']);
await expect
.poll(async () => (await dashboards.get(id)).spec.layouts.length)
.toBe(2);
});
test('TC-07 two sections collapse in sequence and both stay collapsed', async ({
dashboards,
dashboardPage,
}) => {
await dashboards.seedAndOpen(compactDashboard());
const charts = dashboardPage.section('Charts');
const tabular = dashboardPage.section('Tabular');
await charts.collapse();
await tabular.collapse();
await expect(charts.panels()).toHaveCount(0);
await expect(tabular.panels()).toHaveCount(0);
await charts.expand();
await tabular.expand();
await expect(charts.panels()).toHaveCount(1);
await expect(tabular.panels()).toHaveCount(2);
});
test('TC-08 a collapsed section unmounts its panels and spares the others', async ({
dashboards,
dashboardPage,
panels,
}) => {
await dashboards.seedAndOpen(compactDashboard());
await dashboardPage.section('Charts').collapse();
await expect(panels.panel(COMPACT_PANELS.timeseries).root()).toHaveCount(0);
await expect(panels.panel(COMPACT_PANELS.table).root()).toHaveCount(1);
});
});

View File

@@ -0,0 +1,341 @@
import {
BAR_VALUES,
BAR_VARIABLES as V,
emptyDashboard,
expect,
test,
variablesBarDashboard,
} from '../../../drivers/dashboard-settings';
// Scope: the Configure drawer — Overview, Variables and Publish tabs.
test.describe('Dashboards — settings drawer', () => {
test('TC-01 Configure opens on Overview with every tab', async ({
dashboards,
settings,
}) => {
await dashboards.seedAndOpen(variablesBarDashboard());
await settings.open();
await expect(settings.heading()).toBeVisible();
for (const tab of ['Overview', 'Variables', 'Publish'] as const) {
await expect(settings.tab(tab)).toBeVisible();
}
await expect(settings.tab('Overview')).toHaveAttribute(
'aria-selected',
'true',
);
await expect(settings.overview.name()).toBeVisible();
await settings.close();
});
test('TC-02 name, description and tag save and survive a reload', async ({
dashboards,
settings,
}) => {
const id = await dashboards.seedAndOpen(variablesBarDashboard());
const name = `e2e-configured-${Date.now()}`;
await settings.open();
await settings.overview.name().fill(name);
await settings.overview.description().fill('Configured by e2e');
await settings.overview.addTag('team:e2e');
await expect(settings.overview.unsaved()).toHaveText('3 unsaved changes');
await settings.overview.save().click();
await expect(settings.overview.unsaved()).toBeHidden();
await settings.close();
await dashboards.reload();
await expect(dashboards.title()).toHaveText(name);
const saved = await dashboards.get(id);
expect(saved.spec.display.description).toBe('Configured by e2e');
expect(saved.tags).toContainEqual({ key: 'team', value: 'e2e' });
});
test('TC-03 Discard reverts unsaved Overview changes', async ({
dashboards,
settings,
}) => {
const dashboard = variablesBarDashboard();
await dashboards.seedAndOpen(dashboard);
await settings.open();
const original = dashboard.spec.display.name;
await expect(settings.overview.name()).toHaveValue(original);
await settings.overview.name().fill('Temporary name');
await settings.overview.discard().click();
await expect(settings.overview.name()).toHaveValue(original);
await expect(settings.overview.save()).toBeHidden();
});
test('TC-04 the Variables tab lists every variable', async ({
dashboards,
settings,
}) => {
await dashboards.seedAndOpen(variablesBarDashboard());
await settings.open();
await settings.openTab('Variables');
for (const name of Object.values(V)) {
await expect(settings.variables.row(name)).toBeVisible();
}
});
test('TC-05 an added textbox variable joins the bar and takes a value', async ({
dashboards,
settings,
variablesBar,
}) => {
await dashboards.seedAndOpen(variablesBarDashboard());
const name = 'tb_added';
await settings.open();
await settings.openTab('Variables');
await settings.variables.add().click();
await settings.form.name().fill(name);
await settings.form.type('textbox').click();
await expect(settings.form.save()).toBeEnabled();
await settings.form.save().click();
await expect(settings.variables.row(name)).toBeVisible();
await settings.close();
await variablesBar.expand();
await expect(variablesBar.label(name)).toBeVisible();
await variablesBar.type(name, 'test-value');
await expect(variablesBar.textbox(name)).toHaveValue('test-value');
});
test('TC-06 an added custom variable joins the list and the bar', async ({
dashboards,
settings,
variablesBar,
}) => {
await dashboards.seedAndOpen(variablesBarDashboard());
const name = 'cu_added';
await settings.open();
await settings.openTab('Variables');
await settings.variables.add().click();
await settings.form.name().fill(name);
await settings.form.type('custom').click();
await settings.form.customValues().fill('east,west');
await settings.form.save().click();
await expect(settings.variables.row(name)).toBeVisible();
await settings.close();
await variablesBar.expand();
await expect(variablesBar.select(name)).toContainText('east');
});
test('TC-07 the Query type renders the query editor', async ({
dashboards,
settings,
}) => {
await dashboards.seedAndOpen(variablesBarDashboard());
await settings.open();
await settings.openTab('Variables');
await settings.variables.add().click();
await settings.form.name().fill('query_added');
await settings.form.type('query').click();
await expect(settings.form.queryEditor()).toBeVisible();
await settings.form.discard().click();
await expect(settings.variables.row('query_added')).toHaveCount(0);
});
test('TC-08 Save Variable stays disabled without a name', async ({
dashboards,
settings,
}) => {
const id = await dashboards.seed(emptyDashboard());
await dashboards.openEmpty(id);
await settings.open();
await settings.openTab('Variables');
await settings.variables.add().click();
await expect(settings.form.name()).toHaveValue('');
await expect(settings.form.save()).toBeDisabled();
});
test('TC-09 the Publish tab offers to publish a private dashboard', async ({
dashboards,
settings,
}) => {
await dashboards.seedAndOpen(variablesBarDashboard());
await settings.open();
await settings.openTab('Publish');
await expect(
settings.publish.text('This dashboard is private'),
).toBeVisible();
await expect(settings.publish.timeRange()).toBeVisible();
await expect(
settings.publish.text(
"Dashboard variables aren't supported on public links.",
),
).toBeVisible();
await expect(settings.publish.publish()).toBeVisible();
});
test('TC-10 renaming a variable renames it in the bar', async ({
dashboards,
settings,
variablesBar,
}) => {
const id = await dashboards.seedAndOpen(variablesBarDashboard());
const renamed = `${V.textEnv}_renamed`;
await settings.open();
await settings.openTab('Variables');
await settings.variables.edit(V.textEnv);
await expect(settings.form.name()).toHaveValue(V.textEnv);
await settings.form.name().fill(renamed);
await settings.form.save().click();
await expect(settings.variables.row(renamed)).toBeVisible();
await settings.close();
await expect(variablesBar.label(renamed)).toBeVisible();
await expect(variablesBar.label(V.textEnv)).toHaveCount(0);
await expect
.poll(async () =>
(await dashboards.get(id)).spec.variables.map((v) => v.spec.name),
)
.toContain(renamed);
});
test('TC-11 switching a custom variable to Query swaps the form', async ({
dashboards,
settings,
}) => {
await dashboards.seedAndOpen(variablesBarDashboard());
await settings.open();
await settings.openTab('Variables');
await settings.variables.edit(V.single);
await expect(settings.form.customValues()).toBeVisible();
await settings.form.type('query').click();
await expect(settings.form.queryEditor()).toBeVisible();
await expect(settings.form.customValues()).toHaveCount(0);
await settings.form.discard().click();
});
test('TC-12 a textbox default saved in Configure survives a reload', async ({
dashboards,
settings,
}) => {
const id = await dashboards.seedAndOpen(variablesBarDashboard());
await settings.open();
await settings.openTab('Variables');
await settings.variables.edit(V.textService);
await expect(settings.form.textDefault()).toHaveValue(BAR_VALUES.textService);
await settings.form.textDefault().fill('new-default');
await settings.form.save().click();
await expect(settings.variables.row(V.textService)).toBeVisible();
await expect
.poll(async () => {
const saved = (await dashboards.get(id)).spec.variables.find(
(v) => v.spec.name === V.textService,
);
return saved?.kind === 'TextVariable' ? saved.spec.value : undefined;
})
.toBe('new-default');
await settings.close();
await dashboards.reload();
await settings.open();
await settings.openTab('Variables');
await settings.variables.edit(V.textService);
await expect(settings.form.textDefault()).toHaveValue('new-default');
});
test('TC-13 a deleted variable leaves the bar, its siblings stay', async ({
dashboards,
settings,
variablesBar,
}) => {
await dashboards.seedAndOpen(variablesBarDashboard());
await expect(variablesBar.label(V.textEnv)).toBeVisible();
await settings.open();
await settings.openTab('Variables');
await settings.variables.remove(V.textEnv);
await settings.close();
await expect(variablesBar.label(V.textEnv)).toHaveCount(0);
await expect(variablesBar.label(V.textService)).toBeVisible();
});
test('TC-14 a duplicate name keeps Save Variable disabled', async ({
dashboards,
settings,
}) => {
await dashboards.seedAndOpen(variablesBarDashboard());
await settings.open();
await settings.openTab('Variables');
await settings.variables.add().click();
await settings.form.name().fill(V.textEnv);
await settings.form.type('textbox').click();
await expect(
settings.form.error('Variable name already exists'),
).toBeVisible();
await expect(settings.form.save()).toBeDisabled();
});
test('TC-15 discarding an edit persists nothing', async ({
dashboards,
settings,
variablesBar,
}) => {
const id = await dashboards.seedAndOpen(variablesBarDashboard());
await settings.open();
await settings.openTab('Variables');
await settings.variables.edit(V.textEnv);
await settings.form.name().fill('SHOULD_NOT_PERSIST');
await settings.form.discard().click();
await expect(settings.variables.row(V.textEnv)).toBeVisible();
await settings.close();
await expect(variablesBar.label(V.textEnv)).toBeVisible();
await expect(variablesBar.label('SHOULD_NOT_PERSIST')).toHaveCount(0);
const saved = await dashboards.get(id);
expect(saved.spec.variables.map((v) => v.spec.name)).not.toContain(
'SHOULD_NOT_PERSIST',
);
await settings.open();
await settings.openTab('Variables');
await expect(settings.variables.row(V.textEnv)).toBeVisible();
});
test.fixme('TC-16 the bar picks up a changed textbox default', async ({
dashboards,
settings,
variablesBar,
}) => {
// Bug: the bar's selection, seeded from the old default on first open, is
// persisted locally and outranks the new default on every later visit.
await dashboards.seedAndOpen(variablesBarDashboard());
await settings.open();
await settings.openTab('Variables');
await settings.variables.edit(V.textService);
await settings.form.textDefault().fill('new-default');
await settings.form.save().click();
await settings.close();
await dashboards.reload();
await expect(variablesBar.textbox(V.textService)).toHaveValue('new-default');
});
});

View File

@@ -0,0 +1,53 @@
import {
PanelMessageText,
QueryRange,
SINGLE_PANEL_ID,
expect,
singlePanelDashboard,
test,
} from '../../../drivers/panels';
// Scope: the no-data state's Extend time range recovery.
//
// Mock-only: a healthy stack returns data, so no-data is unreachable live.
test.describe('Dashboards — panel states', () => {
test('TC-01 Extend time range widens the dashboard window', async ({
dashboards,
queryRange,
panels,
}) => {
await queryRange.respond(QueryRange.empty());
await dashboards.seedAndOpen(singlePanelDashboard());
const extend = panels.panel(SINGLE_PANEL_ID).noDataAction();
await expect(extend).toBeVisible();
const refetch = queryRange.nextRequest();
await extend.click();
await refetch;
// Widening walks the URL-backed zoom-out ladder.
await expect.poll(() => dashboards.param('relativeTime')).not.toBeNull();
});
test('TC-02 a panel pinned to a fixed window cannot extend, so Retry is primary', async ({
dashboards,
queryRange,
panels,
}) => {
await queryRange.respond(QueryRange.empty());
// A panel-scoped window means extend has nothing to widen.
await dashboards.seedAndOpen(
singlePanelDashboard({
pluginSpec: { visualization: { timePreference: 'last_15_min' } },
}),
);
const target = panels.panel(SINGLE_PANEL_ID);
await expect(target.noData()).toBeVisible();
await expect(target.noDataAction()).toHaveText(PanelMessageText.retryAction);
await expect(target.noDataSecondaryAction()).toHaveCount(0);
});
});

View File

@@ -0,0 +1,112 @@
import {
expect,
REFRESH_INTERVALS,
SINGLE_PANEL_ID,
singlePanelDashboard,
test,
} from '../../../drivers/dashboard-settings';
// Scope: the dashboard toolbar's time picker, refresh and auto-refresh.
test.describe('Dashboards — time range', () => {
test.beforeEach(async ({ dashboards }) => {
await dashboards.seedAndOpen(singlePanelDashboard());
});
test('TC-01 a preset updates the label, the URL and refetches', async ({
timePicker,
queryRange,
authedPage: page,
}) => {
const refetch = queryRange.nextRequest();
await timePicker.selectPreset('1h');
await refetch;
await expect(timePicker.input()).toHaveAccessibleName('Last 1 hour');
await expect(page).toHaveURL(/relativeTime=1h/);
});
test('TC-02 switching presets twice keeps only the last one', async ({
timePicker,
authedPage: page,
}) => {
await timePicker.selectPreset('6h');
await expect(timePicker.input()).toHaveAccessibleName('Last 6 hours');
await expect(page).toHaveURL(/relativeTime=6h/);
await timePicker.selectPreset('1d');
await expect(timePicker.input()).toHaveAccessibleName('Last 1 day');
await expect(page).toHaveURL(/relativeTime=1d/);
await expect(page).not.toHaveURL(/relativeTime=6h/);
});
test('TC-03 a custom date range switches the URL to absolute timestamps', async ({
timePicker,
queryRange,
authedPage: page,
}) => {
const refetch = queryRange.nextRequest();
await timePicker.selectPreviousMonthRange(14, 16);
await refetch;
await expect(timePicker.input()).toHaveValue(
/^14\/\d{2}\/\d{4} 00:00:00 - 16\/\d{2}\/\d{4} 23:59:59$/,
);
await expect(page).toHaveURL(/startTime=\d+/);
await expect(page).toHaveURL(/endTime=\d+/);
await expect(page).not.toHaveURL(/relativeTime=/);
});
test('TC-04 changing the timezone updates the offset badge', async ({
timePicker,
}) => {
await timePicker.selectTimezone('Tokyo', 'Asia/Tokyo');
await expect(timePicker.timezoneBadge()).toHaveText('UTC + 9:00');
});
test('TC-05 the auto-refresh popup lists every interval, off by default', async ({
timePicker,
}) => {
await timePicker.autoRefresh.open();
await expect(timePicker.autoRefresh.checkbox()).not.toBeChecked();
for (const label of REFRESH_INTERVALS) {
await expect(timePicker.autoRefresh.interval(label)).toBeVisible();
}
});
test('TC-06 auto-refresh toggles on, takes an interval and toggles off', async ({
timePicker,
}) => {
await timePicker.autoRefresh.open();
const checkbox = timePicker.autoRefresh.checkbox();
await checkbox.click();
await expect(checkbox).toBeChecked();
await timePicker.autoRefresh.interval('1 minute').click();
await expect(timePicker.autoRefresh.selected()).toHaveText('1 minute');
await timePicker.autoRefresh.interval('5 minutes').click();
await expect(timePicker.autoRefresh.selected()).toHaveText('5 minutes');
await expect(checkbox).toBeChecked();
await checkbox.click();
await expect(checkbox).not.toBeChecked();
});
test('TC-07 manual refresh refetches the panels', async ({
timePicker,
panels,
queryRange,
}) => {
const target = panels.panel(SINGLE_PANEL_ID);
await expect(target.root()).toHaveAttribute('data-panel-visible', 'true');
await expect(target.loading()).toBeHidden();
const refetch = queryRange.nextRequest();
await timePicker.refresh().click();
await refetch;
});
});

View File

@@ -0,0 +1,300 @@
import {
BAR_VALUES,
BAR_VARIABLES as V,
expect,
QUERY_CHILD_SUFFIX,
test,
variablesBarDashboard,
} from '../../../drivers/dashboard-settings';
// Scope: the runtime variables bar. Selections live in the dashboard's local
// store, not the URL: `?variables=` is read once from a share link and dropped,
// and the share dialog's "Include variables" is what serializes them again.
const ALL = '__ALL__';
const ORDER = Object.values(V);
test.describe('Dashboards — variables bar', () => {
test('TC-01 the bar renders a control for every variable type', async ({
dashboards,
variablesBar,
}) => {
await dashboards.seedAndOpen(variablesBarDashboard());
await variablesBar.expand();
for (const name of ORDER) {
await expect(variablesBar.label(name)).toBeVisible();
}
await expect(variablesBar.textbox(V.textEnv)).toHaveValue(BAR_VALUES.textEnv);
await expect(variablesBar.textbox(V.textService)).toHaveValue(
BAR_VALUES.textService,
);
for (const name of [V.single, V.all, V.multi, V.queryParent, V.dynamic]) {
await expect(variablesBar.select(name)).toBeVisible();
}
});
test('TC-02 picking a single value selects it and survives a reload', async ({
dashboards,
variablesBar,
}) => {
await dashboards.seedAndOpen(variablesBarDashboard());
await variablesBar.expand();
await variablesBar.pick(V.single, 'mq-kafka');
await expect(variablesBar.select(V.single)).toContainText('mq-kafka');
await variablesBar.open(V.single);
await expect(variablesBar.option('mq-kafka')).toHaveAttribute(
'aria-selected',
'true',
);
await dashboards.reload();
await expect(variablesBar.select(V.single)).toContainText('mq-kafka');
});
test('TC-03 a multi-select renders chips and shares its values as an array', async ({
dashboards,
variablesBar,
}) => {
const id = await dashboards.seed(variablesBarDashboard());
await variablesBar.openWith(id, { [V.multi]: ['adservice', 'frontend'] });
await variablesBar.expand();
await expect(variablesBar.chip(V.multi, 'adservice')).toBeVisible();
await variablesBar.open(V.multi);
await expect(variablesBar.option('adservice')).toHaveAttribute(
'aria-selected',
'true',
);
await expect(variablesBar.option('frontend')).toHaveAttribute(
'aria-selected',
'true',
);
await variablesBar.close(V.multi);
expect((await variablesBar.shared())[V.multi]).toEqual([
'adservice',
'frontend',
]);
});
test('TC-04 removing a chip drops that value once the dropdown closes', async ({
dashboards,
variablesBar,
}) => {
const id = await dashboards.seed(variablesBarDashboard());
await variablesBar.openWith(id, { [V.multi]: ['adservice', 'frontend'] });
await variablesBar.expand();
await variablesBar.chip(V.multi, 'adservice').click();
await variablesBar.close(V.multi);
await expect(variablesBar.chip(V.multi, 'adservice')).toHaveCount(0);
await expect(variablesBar.chip(V.multi, 'frontend')).toBeVisible();
expect((await variablesBar.shared())[V.multi]).toEqual(['frontend']);
});
test('TC-05 the ALL option selects every value', async ({
dashboards,
variablesBar,
}) => {
const id = await dashboards.seed(variablesBarDashboard());
await variablesBar.openWith(id, { [V.all]: ['otel-demo'] });
await variablesBar.expand();
await expect(variablesBar.variable(V.all)).toContainText('otel-demo');
await variablesBar.open(V.all);
await variablesBar.option('ALL').click();
for (const value of ['ALL', ...BAR_VALUES.envs]) {
await expect(variablesBar.option(value)).toHaveAttribute(
'aria-selected',
'true',
);
}
await variablesBar.close(V.all);
await expect(variablesBar.variable(V.all)).toContainText('ALL');
expect((await variablesBar.shared())[V.all]).toBe(ALL);
});
test('TC-06 a textbox commits its value on Enter', async ({
dashboards,
variablesBar,
}) => {
await dashboards.seedAndOpen(variablesBarDashboard());
await variablesBar.type(V.textEnv, 'production');
await expect(variablesBar.textbox(V.textEnv)).toHaveValue('production');
expect((await variablesBar.shared())[V.textEnv]).toBe('production');
});
test('TC-07 a child query variable refetches after its parent changes', async ({
dashboards,
variablesBar,
}) => {
await dashboards.seedAndOpen(variablesBarDashboard());
await variablesBar.expand();
const [first, second] = BAR_VALUES.queryEnvs;
await expect(variablesBar.variable(V.queryChild)).toContainText(
`${first}${QUERY_CHILD_SUFFIX}`,
);
await variablesBar.pick(V.queryParent, second);
await expect(variablesBar.variable(V.queryChild)).toContainText(
`${second}${QUERY_CHILD_SUFFIX}`,
);
await variablesBar.open(V.queryChild);
await expect(variablesBar.listbox().getByRole('option')).toHaveCount(1);
await expect(
variablesBar.option(`${second}${QUERY_CHILD_SUFFIX}`),
).toBeVisible();
});
test('TC-08 a shared link seeds the selection, which outlives the reload', async ({
dashboards,
variablesBar,
authedPage: page,
}) => {
const id = await dashboards.seed(variablesBarDashboard());
await variablesBar.openWith(id, { [V.all]: ['mq-kafka'] });
await variablesBar.expand();
await expect(variablesBar.variable(V.all)).toContainText('mq-kafka');
await expect(page).not.toHaveURL(/variables=/);
await dashboards.reload();
await expect(variablesBar.variable(V.all)).toContainText('mq-kafka');
});
test('TC-09 ALL to one value and back to ALL', async ({
dashboards,
variablesBar,
}) => {
await dashboards.seedAndOpen(variablesBarDashboard());
await variablesBar.expand();
await expect(variablesBar.variable(V.all)).toContainText('ALL');
await variablesBar.open(V.all);
await variablesBar.option('ALL').click();
await variablesBar.option('mq-kafka').click();
await variablesBar.close(V.all);
await expect(variablesBar.variable(V.all)).toContainText('mq-kafka');
await variablesBar.toggle(V.all, ['ALL']);
await expect(variablesBar.variable(V.all)).toContainText('ALL');
expect((await variablesBar.shared())[V.all]).toBe(ALL);
});
test('TC-10 changes to two variables are both kept', async ({
dashboards,
variablesBar,
}) => {
await dashboards.seedAndOpen(variablesBarDashboard());
await variablesBar.expand();
await variablesBar.pick(V.single, 'production');
await variablesBar.toggle(V.multi, ['checkout']);
const shared = await variablesBar.shared();
expect(shared[V.single]).toBe('production');
expect(shared[V.multi]).toEqual(['adservice', 'checkout']);
});
test('TC-11 leaving the dashboard and coming back keeps the selection', async ({
dashboards,
variablesBar,
panels,
authedPage: page,
}) => {
const id = await dashboards.seedAndOpen(variablesBarDashboard());
await variablesBar.expand();
await variablesBar.pick(V.single, 'mq-kafka');
await dashboards.visit('/dashboard');
await expect(page).toHaveURL(/\/dashboard(\?|$)/);
await panels.back();
await expect(page).toHaveURL(new RegExp(`/dashboard/${id}`));
await expect(variablesBar.select(V.single)).toContainText('mq-kafka');
});
test('TC-12 a query variable with no rows offers no options', async ({
dashboards,
variablesBar,
}) => {
await dashboards.seedAndOpen(variablesBarDashboard());
await variablesBar.expand();
await variablesBar.open(V.queryEmpty);
await expect(variablesBar.listbox().getByRole('option')).toHaveCount(0);
await expect(variablesBar.select(V.queryEmpty)).toContainText('Select value');
});
test('TC-13 a dynamic variable resolves values from seeded telemetry', async ({
dashboards,
variablesBar,
}) => {
// Unique per test: seeded rows outlive it, and the list is virtualized.
const label = `e2e_namespace_${Date.now()}_${process.pid}`;
const namespaces = ['ns-alpha', 'ns-beta'];
await variablesBar.seedMetricLabel(label, namespaces);
await dashboards.seedAndOpen(
variablesBarDashboard({ dynamicAttribute: label }),
);
await variablesBar.expand();
await variablesBar.open(V.dynamic);
for (const namespace of namespaces) {
await expect(variablesBar.option(namespace)).toBeVisible();
}
});
test('TC-14 the bar follows the spec order', async ({
dashboards,
variablesBar,
}) => {
await dashboards.seedAndOpen(variablesBarDashboard());
expect(await variablesBar.names()).toEqual(ORDER.map((name) => `$${name}`));
});
test('TC-15 a variable deleted in Configure leaves the bar', async ({
dashboards,
variablesBar,
settings,
}) => {
await dashboards.seedAndOpen(variablesBarDashboard());
await expect(variablesBar.label(V.textService)).toHaveCount(1);
await settings.open();
await settings.openTab('Variables');
await settings.variables.remove(V.textService);
await settings.close();
await expect(variablesBar.label(V.textService)).toHaveCount(0);
await expect(variablesBar.label(V.textEnv)).toBeVisible();
});
test.fixme('TC-16 a first visit through a shared link applies its values', async ({
dashboards,
variablesBar,
}) => {
// Bug: the selectors' first auto-select batch (computed before the seed
// commits) is flushed over the seeded link values, so they are lost.
const id = await dashboards.seed(variablesBarDashboard());
await variablesBar.firstVisitWith(id, {
[V.multi]: ['adservice', 'frontend'],
});
expect((await variablesBar.shared())[V.multi]).toEqual([
'adservice',
'frontend',
]);
});
});

View File

@@ -0,0 +1,177 @@
import {
PATH,
PanelAction,
PanelKind,
SINGLE_PANEL_ID,
expect,
singlePanelDashboard,
test,
} from '../../../drivers/panel-editor';
// Scope: the View modal, and its two-way handoff with the panel editor.
//
// Both directions carry LIVE, unsaved state — modal → editor via router state
// (`editSpec`), editor → modal via sessionStorage + `compositeQuery`. Losing
// either silently discards in-progress work, so TC-08/TC-09 assert the carried
// state AND that nothing was persisted.
const onEditor = (id: string): RegExp => new RegExp(`/dashboard/${id}/panel/`);
test.describe('Dashboards — View modal', () => {
test('TC-01 View opens the modal and reflects it in the URL', async ({
dashboards,
panels,
}) => {
await dashboards.seedAndOpen(singlePanelDashboard());
const modal = await panels.view.open(SINGLE_PANEL_ID);
await expect(modal).toBeVisible();
await expect(panels.view.refresh()).toBeVisible();
expect(dashboards.param('expandedWidgetId')).toBe(SINGLE_PANEL_ID);
});
test('TC-02 the modal opens directly from a deep link', async ({
dashboards,
panels,
}) => {
const id = await dashboards.seedAndOpen(singlePanelDashboard());
await dashboards.visit(
`${PATH.dashboard(id)}?expandedWidgetId=${SINGLE_PANEL_ID}&graphType=graph`,
);
await expect(panels.view.content()).toBeVisible();
});
test('TC-03 Refresh re-issues the query', async ({
dashboards,
queryRange,
panels,
}) => {
await dashboards.seedAndOpen(singlePanelDashboard());
await panels.view.open(SINGLE_PANEL_ID);
const refetch = queryRange.nextRequest();
await panels.view.refresh().click();
const request = await refetch;
expect(request.method()).toBe('POST');
});
test('TC-04 switching the panel type in the modal does not persist', async ({
dashboards,
panels,
}) => {
const id = await dashboards.seedAndOpen(singlePanelDashboard());
await panels.view.open(SINGLE_PANEL_ID);
await panels.view.switchType('Table');
await expect(panels.view.renderer(PanelKind.Table)).toBeVisible();
await panels.back();
const after = await dashboards.get(id);
expect(after.spec.panels[SINGLE_PANEL_ID].spec.plugin.kind).toBe(
'signoz/TimeSeriesPanel',
);
});
test('TC-05 Switch to Edit Mode hands off to the panel editor', async ({
authedPage: page,
dashboards,
panels,
editor,
}) => {
const id = await dashboards.seedAndOpen(singlePanelDashboard());
await panels.view.open(SINGLE_PANEL_ID);
await panels.view.switchToEdit().click();
await page.waitForURL(onEditor(id));
await expect(editor.root()).toBeVisible();
});
test('TC-06 a locked panel opens in View but offers no Switch to Edit', async ({
dashboards,
panels,
}) => {
// `canSwitchToEdit = canEditDashboard && !isLocked`, but View itself is not
// role-gated — so the modal opens and only the handoff button is disabled.
const id = await dashboards.seed(singlePanelDashboard());
await dashboards.setLocked(id, true);
await dashboards.open(id);
await panels.panel(SINGLE_PANEL_ID).actions.run(PanelAction.view);
await expect(panels.view.content()).toBeVisible();
await expect(panels.view.switchToEdit()).toBeDisabled();
await expect(panels.view.refresh()).toBeVisible();
});
test('TC-07 View → Edit → View round-trips with no changes', async ({
authedPage: page,
dashboards,
panels,
editor,
}) => {
const id = await dashboards.seedAndOpen(
singlePanelDashboard({ panelName: 'Round trip' }),
);
await panels.view.open(SINGLE_PANEL_ID);
await panels.view.switchToEdit().click();
await page.waitForURL(onEditor(id));
await expect(editor.title()).toHaveValue('Round trip');
await expect(editor.unsavedBadge()).toHaveCount(0);
await editor.switchToView().click();
await expect(panels.view.content()).toBeVisible();
const after = await dashboards.get(id);
expect(after.spec.panels[SINGLE_PANEL_ID].spec.display.name).toBe(
'Round trip',
);
});
test('TC-08 an unsaved change in the modal carries into the editor', async ({
authedPage: page,
dashboards,
panels,
editor,
}) => {
const id = await dashboards.seedAndOpen(singlePanelDashboard());
await panels.view.open(SINGLE_PANEL_ID);
await panels.view.switchType('Table');
await expect(panels.view.renderer(PanelKind.Table)).toBeVisible();
await panels.view.switchToEdit().click();
await page.waitForURL(onEditor(id));
// Editor opens on the MODIFIED panel, and still nothing is committed.
await expect(editor.preview.renderer(PanelKind.Table)).toBeVisible();
const after = await dashboards.get(id);
expect(after.spec.panels[SINGLE_PANEL_ID].spec.plugin.kind).toBe(
'signoz/TimeSeriesPanel',
);
});
test('TC-09 an unsaved change in the editor carries back into the modal', async ({
dashboards,
panels,
editor,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard(),
SINGLE_PANEL_ID,
);
await editor.title().fill('Edited but not saved');
await expect(editor.unsavedBadge()).toBeVisible();
await editor.switchToView().click();
await expect(panels.view.content()).toBeVisible();
await expect(panels.view.dialog()).toContainText('Edited but not saved');
const after = await dashboards.get(id);
expect(after.spec.panels[SINGLE_PANEL_ID].spec.display.name).toBe(
'Solo panel',
);
});
});

View File

@@ -0,0 +1,103 @@
import {
COMPACT_PANELS,
compactDashboard,
expect,
test,
} from '../../../drivers/dashboard-page';
test.describe('Dashboards — viewing', () => {
test('TC-01 page chrome renders the breadcrumb, title and toolbar', async ({
dashboards,
dashboardPage,
}) => {
const fixture = compactDashboard();
const name = fixture.spec.display.name;
await dashboards.seedAndOpen(fixture);
await expect(dashboardPage.breadcrumbHome()).toBeVisible();
await expect(dashboardPage.breadcrumb()).toContainText(name);
await expect(dashboardPage.title()).toHaveText(name);
await expect.poll(() => dashboardPage.documentTitle()).toBe(name);
const { toolbar } = dashboardPage;
for (const control of [
toolbar.timePicker(),
toolbar.refresh(),
toolbar.autoRefresh(),
toolbar.actions(),
toolbar.configure(),
toolbar.json(),
toolbar.newPanel(),
toolbar.feedback(),
toolbar.share(),
]) {
await expect(control).toBeVisible();
}
});
test('TC-02 the breadcrumb returns to the dashboards list', async ({
dashboards,
dashboardPage,
authedPage: page,
}) => {
await dashboards.seedAndOpen(compactDashboard());
await dashboardPage.breadcrumbHome().click();
await expect(page).toHaveURL(/\/dashboard(\?|$)/);
await expect(dashboardPage.listPage()).toBeVisible();
});
test('TC-03 the tags bar shows two tags and folds the rest', async ({
dashboards,
dashboardPage,
}) => {
await dashboards.seedAndOpen({
...compactDashboard(),
tags: [
{ key: 'team', value: 'apm' },
{ key: 'latency', value: 'latency' },
{ key: 'tier', value: 'gold' },
],
});
// A tag whose key equals its value renders as the bare key.
await expect(dashboardPage.tags()).toContainText('team:apm');
await expect(dashboardPage.tags()).toContainText('latency');
await expect(dashboardPage.tags()).not.toContainText('tier:gold');
await expect(dashboardPage.tagsOverflow()).toHaveText('+1');
});
test('TC-04 section headers render', async ({ dashboards, dashboardPage }) => {
await dashboards.seedAndOpen(compactDashboard());
await expect(dashboardPage.sectionTitles()).toHaveText(['Charts', 'Tabular']);
});
test('TC-05 panels render inside their sections', async ({
dashboards,
dashboardPage,
}) => {
await dashboards.seedAndOpen(compactDashboard());
await expect(dashboardPage.section('Charts').panels()).toHaveCount(1);
await expect(dashboardPage.section('Tabular').panels()).toHaveCount(2);
await expect(
dashboardPage.section('Charts').root().getByText('Calls by service'),
).toBeVisible();
});
test('TC-06 the initial load raises no page error', async ({
dashboards,
dashboardPage,
panels,
}) => {
const collected = dashboardPage.collectPageErrors();
await dashboards.seedAndOpen(compactDashboard());
await expect(panels.panel(COMPACT_PANELS.timeseries).title()).toBeVisible();
await expect(panels.panel(COMPACT_PANELS.table).title()).toBeVisible();
expect(collected.errors).toEqual([]);
});
});

View File

@@ -0,0 +1,152 @@
import {
COMPACT_PANELS,
PanelKind,
QueryRange,
SINGLE_PANEL_ID,
compactDashboard,
expect,
ramp,
singlePanelDashboard,
test,
} from '../../../drivers/panels';
// Scope: brush-select zoom and the window it writes to.
//
// Mocked, not golden-backed: these need a rendered CANVAS to drag across, and a
// query issued before ClickHouse has the re-seeded rows queryable returns empty
// — "No Data", no canvas, intermittent timeout. Nothing here asserts a value.
/**
* WebKit only, for the tests that need a drag to actually ZOOM.
*
* Mid-drag under WebKit the live uPlot instance shows `cursor.left` updating
* (mousemove arrives) but `cursor.drag._x === false` and `select.width === 0` —
* the synthetic mousedown never starts a drag. Chromium and Firefox zoom fine,
* so this looks like input synthesis rather than the app, though unproven.
* TC-03/TC-05 still run here: they assert NO zoom, which stays meaningful.
*/
function skipDragZoomOnWebkit(browserName: string): void {
test.skip(
browserName === 'webkit',
'WebKit: synthetic mousedown does not start a uPlot drag (cursor.drag._x stays false)',
);
}
const ZOOM_SERIES = [
{ labels: { 'service.name': 'adservice' }, points: ramp(24, 2, 9) },
];
test.describe('Dashboards — panel zoom', () => {
test.beforeEach(async ({ queryRange }) => {
await queryRange.respond(QueryRange.timeSeries(ZOOM_SERIES));
});
test('TC-01 brush-select writes an absolute window into the URL', async ({
dashboards,
panels,
browserName,
}) => {
skipDragZoomOnWebkit(browserName);
await dashboards.seedAndOpen(singlePanelDashboard());
const target = panels.panel(SINGLE_PANEL_ID);
await expect(target.renderer(PanelKind.TimeSeries)).toBeVisible();
const xSpan = async (): Promise<number> => {
const { x } = (await target.chart.state()).scales;
return (x.max ?? 0) - (x.min ?? 0);
};
const before = await xSpan();
expect(before).toBeGreaterThan(0);
await target.chart.drag(0.3, 0.7);
// An absolute range in the URL is what makes the zoom shareable.
await expect
.poll(() =>
Boolean(dashboards.param('startTime') && dashboards.param('endTime')),
)
.toBe(true);
expect(Number(dashboards.param('endTime'))).toBeGreaterThan(
Number(dashboards.param('startTime')),
);
// The chart must actually show the narrower window, not just the URL. The
// drag covers 40% of the plot, so the visible span shrinks well below 60%.
await expect.poll(xSpan).toBeLessThan(before * 0.6);
expect(await xSpan()).toBeGreaterThan(0);
});
test('TC-02 zooming one panel refetches every panel on the dashboard', async ({
dashboards,
queryRange,
panels,
browserName,
}) => {
skipDragZoomOnWebkit(browserName);
await dashboards.seedAndOpen(compactDashboard());
const target = panels.panel(COMPACT_PANELS.timeseries);
await expect(target.renderer(PanelKind.TimeSeries)).toBeVisible();
// Zoom updates the GLOBAL interval, so siblings must re-query.
const queries = queryRange.countRequests();
await target.chart.drag(0.25, 0.75);
await expect.poll(() => queries.count).toBeGreaterThan(1);
});
test('TC-03 a zero-width drag does not zoom', async ({
dashboards,
panels,
}) => {
await dashboards.seedAndOpen(singlePanelDashboard());
const target = panels.panel(SINGLE_PANEL_ID);
await expect(target.renderer(PanelKind.TimeSeries)).toBeVisible();
// A width-0 selection is dropped, so click-to-drilldown never zooms.
await target.chart.drag(0.5, 0.5);
expect(dashboards.param('startTime')).toBeNull();
expect(dashboards.param('endTime')).toBeNull();
});
test('TC-04 the global Zoom out button widens the range again', async ({
dashboards,
panels,
browserName,
}) => {
skipDragZoomOnWebkit(browserName);
await dashboards.seedAndOpen(singlePanelDashboard());
const target = panels.panel(SINGLE_PANEL_ID);
await expect(target.renderer(PanelKind.TimeSeries)).toBeVisible();
await target.chart.drag(0.35, 0.65);
await expect.poll(() => dashboards.param('startTime')).not.toBeNull();
const zoomedStart = Number(dashboards.param('startTime'));
// No per-panel reset — zoom-out is global only.
const zoomOut = dashboards.zoomOut();
await expect(zoomOut).toBeVisible();
await zoomOut.click();
await expect
.poll(() => Number(dashboards.param('startTime')))
.toBeLessThan(zoomedStart);
});
test('TC-05 zooming inside the View modal leaves the dashboard window alone', async ({
dashboards,
panels,
}) => {
await dashboards.seedAndOpen(singlePanelDashboard());
await panels.view.open(SINGLE_PANEL_ID);
await expect(panels.view.chart.plotArea()).toBeVisible();
await panels.view.chart.drag(0.3, 0.7);
// The modal keeps its own window.
expect(dashboards.param('startTime')).toBeNull();
expect(dashboards.param('endTime')).toBeNull();
});
});