Compare commits

...

15 Commits

Author SHA1 Message Date
Abhi Kumar
aa1e4e8548 test(dashboard-v2): cover the Text panel 2026-10-23 13:22:57 +05:30
Abhi Kumar
cd00004cd6 test(dashboard-v2): cover type switching, capability errors and context links 2026-10-23 13:22:50 +05:30
Abhi Kumar
1d887c6e45 test(dashboard-v2): cover the New Panel picker, editor shell and query pane 2026-10-23 13:22:49 +05:30
Abhi Kumar
79f6b5882d test(dashboard-v2): cover creating a dashboard from the list page 2026-10-23 13:22:48 +05:30
Abhi Kumar
70daa1e513 test(dashboard-v2): cover zoom, drilldown and the View modal 2026-10-23 13:22:48 +05:30
Abhi Kumar
7ea95e98c2 test(dashboard-v2): cover the panel header, states, actions and grid layout 2026-10-23 13:22:47 +05:30
Abhi Kumar
71cfa308b7 test(dashboard-v2): check every kind's editor sections, query tabs and capabilities 2026-10-23 13:22:38 +05:30
Abhi Kumar
06a0c4f959 test(dashboard-v2): check every kind's actions menu and drilldown 2026-10-23 13:22:37 +05:30
Abhi Kumar
2516d1a72c test(dashboard-v2): check every kind renders and shows its data states 2026-10-23 13:22:36 +05:30
Abhi Kumar
4d89ddf3c0 test(dashboard-v2): describe every panel kind in one table 2026-10-23 13:22:36 +05:30
Abhi Kumar
55ad8d2d2a test(dashboard-v2): add the panel editor, config and dashboard list drivers 2026-10-23 13:22:35 +05:30
Abhi Kumar
307aceccf2 test(dashboard-v2): add the panels driver 2026-10-23 13:22:35 +05:30
Abhi Kumar
770eb2f549 test(dashboard-v2): add the dashboards driver and spec builders 2026-10-23 13:22:34 +05:30
Abhi Kumar
5cfe19a9bc test(e2e): let a driver clean up after its test 2026-10-23 13:22:33 +05:30
Abhi Kumar
2c91c43841 test(dashboard-v2): expose the uPlot instance and add panel test ids 2026-10-23 13:22:29 +05:30
54 changed files with 7211 additions and 15 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 # V2 seeding + query_range mocks; panels → panel-editor → panel-config extend it
│ ├── panels.ts
│ └── panel-kinds.ts # one row per panel kind; drives tests/dashboards/v2/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-/);
},
};

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

@@ -0,0 +1,138 @@
/**
* 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 {
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' &&
new URL(r.url()).pathname === 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';

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

@@ -0,0 +1,359 @@
/**
* 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,
PostableDashboardV2,
} from '../helpers/dashboard-v2-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; the v1
// endpoints the older helpers use answer NewV1DeprecatedError.
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 GettableDashboardV2 {
id: string;
name: string;
locked: boolean;
tags: { key: string; value: string }[];
spec: PostableDashboardV2['spec'];
}
export interface Dashboards {
/** Created for this test and deleted when it ends. */
seed(dashboard: PostableDashboardV2): Promise<string>;
seedAndOpen(dashboard: PostableDashboardV2): Promise<string>;
seedAndEdit(dashboard: PostableDashboardV2, 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<GettableDashboardV2>;
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: PostableDashboardV2,
): 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: GettableDashboardV2 }).data.id;
}
export async function getViaApi(
page: Page,
id: string,
): Promise<GettableDashboardV2> {
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: GettableDashboardV2 }).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-v2-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/v2/panels-dashboard';

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-v2-spec';
import { SINGLE_PANEL_ID } from '../testdata/v2/panels-dashboard';
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-v2-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 V2 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-v2-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-v2-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 {
dashboardV2,
type EditorControl,
GOLDEN,
heatmapPoints,
heatmapTooltipAt,
heatmapQuery,
metricsQuery,
NEW_KINDS_MARKDOWN,
type Panel,
panel,
type PanelHandle,
PanelKind,
type PostableDashboardV2,
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(): PostableDashboardV2;
/** 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): PostableDashboardV2 {
return dashboardV2({
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): PostableDashboardV2 {
const dashboard = dashboardV2({
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): PostableDashboardV2 {
return dashboardV2({
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-v2-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 V2 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 V2 (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 PostableDashboardV2 {
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 dashboardV2(
options: DashboardFixtureOptions,
): PostableDashboardV2 {
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 ?? `v2-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

@@ -37,9 +37,13 @@ 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/**'],
// behaviour these specs assert against, so they fail as written. The V2
// suite under tests/dashboards/v2/ is deliberately NOT ignored — narrow
// this list further as the V1 specs are ported, don't re-broaden it.
testIgnore: [
'**/tests/dashboards/list.spec.ts',
'**/tests/dashboards/details/**',
],
// All Playwright output lands under artifacts/. One subdir per reporter
// plus results/ for per-test artifacts (traces/screenshots/videos).

View File

@@ -0,0 +1,359 @@
import {
GOLDEN,
PanelKind,
clickhouseQuery,
dashboardV2,
heatmapQuery,
metricsQuery,
panel,
promqlQuery,
rawQuery,
type ContextLink,
type PanelPluginSpec,
type PostableDashboardV2,
type Query,
type Variable,
} from '../../helpers/dashboard-v2-spec';
// Dashboard fixtures for the V2 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): PostableDashboardV2 {
return dashboardV2({
title: title ?? `v2-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): PostableDashboardV2 {
return dashboardV2({
title: title ?? `v2-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 = {},
): PostableDashboardV2 {
return dashboardV2({
title: options.title ?? `v2-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): PostableDashboardV2 {
return dashboardV2({
title: title ?? `v2-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): PostableDashboardV2 {
return dashboardV2({
title: title ?? `v2-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,
): PostableDashboardV2 {
return dashboardV2({
title: title ?? `v2-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): PostableDashboardV2 {
return dashboardV2({
title: title ?? `v2-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(): PostableDashboardV2 {
const text = panel(PanelKind.Text, { name: 'Notes' });
return dashboardV2({
title: `v2-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 V2 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 PostableDashboardV2,
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(): PostableDashboardV2;
}
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 V2 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 PostableDashboardV2,
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,
): PostableDashboardV2 {
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 V2 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 V2 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

@@ -0,0 +1,139 @@
import {
COMPACT_PANELS,
PanelAction,
compactDashboard,
expect,
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 V2 — 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();
}
});
});

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 V2 — 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,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 V2 — 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 V2 — 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,
dashboardV2,
expect,
panel,
ramp,
test,
} from '../../../../drivers/panels';
// Scope: the panel header's identity and status affordances.
test.describe('Dashboards V2 — 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(
dashboardV2({
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,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 V2 — 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,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 V2 — 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,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 V2 — 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();
});
});

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 V2 — 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 V2 — 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 V2 — 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 V2 — 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 V2 — 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 V2 — 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

@@ -0,0 +1,214 @@
import {
emptyDashboard,
expect,
PanelAction,
PanelKind,
PATH,
Section,
SINGLE_PANEL_ID,
singlePanelDashboard,
test,
VARIABLE_NAMES,
} from '../../../../drivers/panel-editor';
// Scope: the Text kind end to end — creation from the picker, the Markdown
// editor in the query builder's slot, rendering on the grid, variable
// interpolation, the View modal, and what a query-less kind leaves out
// (downloads, alerts, data sections, context links).
const BODY = [
'# Release notes',
'',
'- first item',
'- second item',
'',
'[SigNoz docs](https://signoz.io/docs)',
'',
'```js',
'const answer = 42;',
'```',
].join('\n');
function textDashboard(
text: string,
extra?: { withVariables?: boolean },
): ReturnType<typeof singlePanelDashboard> {
return singlePanelDashboard({
kind: PanelKind.Text,
panelName: 'Notes',
pluginSpec: { text },
...extra,
});
}
test.describe('Dashboards V2 — Text panel', () => {
test('TC-01 a Text panel from the picker opens the Markdown editor, not the query builder', async ({
dashboards,
editor,
newPanel,
}) => {
const id = await dashboards.seed(emptyDashboard());
await dashboards.openEmpty(id);
await dashboards.emptyAddPanel().click();
await newPanel.choose(PanelKind.Text);
expect(dashboards.param('panelKind')).toBe(PanelKind.Text);
await expect(editor.text.pane()).toBeVisible();
await expect(editor.text.toolbar()).toBeVisible();
await expect(editor.query.builder()).toHaveCount(0);
await expect(editor.query.runButton()).toHaveCount(0);
// Nothing to insert on a dashboard without variables.
await expect(editor.text.insertVariableButton()).toHaveCount(0);
for (const title of [Section.visualization, Section.panelAppearance]) {
await expect(editor.section.toggle(title)).toBeVisible();
}
await expect(editor.text.hideHeader()).toBeVisible();
// No data to format, scale or threshold, and no fields for links to resolve.
for (const title of [
Section.formatting,
Section.axes,
Section.legend,
Section.chartAppearance,
Section.thresholds,
Section.contextLinks,
]) {
await expect(editor.section.toggle(title)).toHaveCount(0);
}
});
test('TC-02 written Markdown previews live, saves without a query and renders on the grid', async ({
authedPage: page,
dashboards,
editor,
newPanel,
panels,
}) => {
const id = await dashboards.seed(emptyDashboard());
await dashboards.openEmpty(id);
await dashboards.emptyAddPanel().click();
await newPanel.choose(PanelKind.Text);
// A blank body previews as the empty state.
await expect(editor.preview.markdown.empty()).toBeVisible();
await editor.title().fill('Release notes');
await editor.text.setMarkdown(BODY);
await expect(editor.preview.markdown.heading('Release notes')).toBeVisible();
await editor.save();
await page.waitForURL(PATH.onDashboard(id));
const saved = await dashboards.get(id);
const [panelId, persisted] = Object.entries(saved.spec.panels)[0];
expect(persisted.spec.plugin.kind).toBe(PanelKind.Text);
expect(persisted.spec.queries).toEqual([]);
expect(persisted.spec.plugin.spec.text).toBe(BODY);
const markdown = panels.panel(panelId).markdown;
await expect(markdown.heading('Release notes')).toBeVisible();
await expect(markdown.listItems()).toHaveText(['first item', 'second item']);
// Links always leave the dashboard.
const link = markdown.link('SigNoz docs');
await expect(link).toHaveAttribute('href', 'https://signoz.io/docs');
await expect(link).toHaveAttribute('target', '_blank');
await expect(markdown.codeBlock()).toHaveText('const answer = 42;');
});
test('TC-03 an inserted variable interpolates in the preview and on the grid', async ({
dashboards,
editor,
panels,
}) => {
const id = await dashboards.seedAndEdit(
textDashboard('', { withVariables: true }),
SINGLE_PANEL_ID,
);
await editor.text.setMarkdown('Service: ');
await editor.text.insertVariable(VARIABLE_NAMES.custom);
await expect(editor.text.input()).toHaveText(
`Service: $${VARIABLE_NAMES.custom}`,
);
// The custom variable resolves to its first value.
await expect(editor.preview.markdown.root()).toHaveText('Service: adservice');
await editor.save();
const saved = await dashboards.get(id);
expect(saved.spec.panels[SINGLE_PANEL_ID].spec.plugin.spec.text).toBe(
`Service: $${VARIABLE_NAMES.custom}`,
);
await expect(panels.panel(SINGLE_PANEL_ID).markdown.root()).toHaveText(
'Service: adservice',
);
});
test('TC-04 the View modal shows the editor and live body, and hands edits to the editor', async ({
authedPage: page,
dashboards,
editor,
panels,
}) => {
const id = await dashboards.seedAndOpen(textDashboard('# Original'));
await panels.view.open(SINGLE_PANEL_ID);
await expect(panels.view.markdown.heading('Original')).toBeVisible();
await expect(panels.view.textEditor()).toBeVisible();
// No time window to pick or refresh.
await expect(panels.view.refresh()).toHaveCount(0);
await editor.text.setMarkdown('# Edited in view');
await expect(panels.view.markdown.heading('Edited in view')).toBeVisible();
await panels.view.switchToEdit().click();
await page.waitForURL(new RegExp(PATH.editor(id, SINGLE_PANEL_ID)));
await expect(editor.preview.markdown.heading('Edited in view')).toBeVisible();
await expect(editor.unsavedBadge()).toBeVisible();
});
test('TC-05 hiding the header removes the title strip but keeps the actions', async ({
dashboards,
editor,
panels,
}) => {
const id = await dashboards.seedAndEdit(
textDashboard('Body'),
SINGLE_PANEL_ID,
);
await editor.text.hideHeader().click();
await editor.save();
const saved = await dashboards.get(id);
expect(
saved.spec.panels[SINGLE_PANEL_ID].spec.plugin.spec.headerOptions,
).toEqual({ hide: true });
const target = panels.panel(SINGLE_PANEL_ID);
await expect(target.markdown.root()).toHaveText('Body');
await expect(target.title()).toHaveCount(0);
await expect(target.hiddenHeaderHandle()).toBeAttached();
await target.actions.open();
await expect(target.actions.item(PanelAction.view)).toBeVisible();
});
test('TC-06 text position persists to the presentation slice', async ({
dashboards,
editor,
}) => {
const id = await dashboards.seedAndEdit(
textDashboard('Body'),
SINGLE_PANEL_ID,
);
await editor.section.expand(Section.panelAppearance);
await editor.text.alignment('center', 'center').click();
await editor.save();
const saved = await dashboards.get(id);
expect(
saved.spec.panels[SINGLE_PANEL_ID].spec.plugin.spec.presentation,
).toMatchObject({ textAlign: 'center', verticalAlign: 'center' });
});
});