mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-28 20:10:43 +00:00
Compare commits
14 Commits
fix/error-
...
test/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7d2f9eb2b | ||
|
|
edfa7360d7 | ||
|
|
9a53f6d230 | ||
|
|
11f83a1a53 | ||
|
|
35b9f1ec1b | ||
|
|
d9c3fbcc6a | ||
|
|
a2bd140ca8 | ||
|
|
4384f8fbed | ||
|
|
608c7280cc | ||
|
|
03be0f0a96 | ||
|
|
8dac72b7b6 | ||
|
|
0dd629281f | ||
|
|
32b8109c1f | ||
|
|
3398368b2a |
@@ -88,7 +88,9 @@ 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
|
||||
├── fixtures/ # Playwright test fixtures (test.extend) only
|
||||
│ └── auth.ts
|
||||
├── helpers/ # function helpers + the constants they share with tests
|
||||
@@ -128,7 +130,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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -132,6 +132,7 @@ export function useResizableColumns<T>({
|
||||
onHeaderCell: (): ResizableHeaderProps => ({
|
||||
width,
|
||||
onResize: key && width ? handleResize(key) : undefined,
|
||||
columnKey: key,
|
||||
}),
|
||||
} as Column<T>;
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
|
||||
356
tests/e2e/drivers/dashboards.ts
generated
Normal file
356
tests/e2e/drivers/dashboards.ts
generated
Normal file
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* 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,
|
||||
QueryRange,
|
||||
type QueryRangeBody,
|
||||
ramp,
|
||||
type SeriesSpec,
|
||||
timestamps,
|
||||
type TimeSeriesPoint,
|
||||
} from '../helpers/query-range-mock';
|
||||
export * from '../testdata/v2/panels-dashboard';
|
||||
13
tests/e2e/drivers/kit.ts
generated
13
tests/e2e/drivers/kit.ts
generated
@@ -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>) =>
|
||||
|
||||
265
tests/e2e/drivers/panel-editor.ts
generated
Normal file
265
tests/e2e/drivers/panel-editor.ts
generated
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* 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 { 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 TypeSwitcher,
|
||||
typeSwitcherAt,
|
||||
} from './panel-editor/type-switcher';
|
||||
import {
|
||||
type Chart,
|
||||
chartAt,
|
||||
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',
|
||||
} 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'
|
||||
| 'show-points'
|
||||
| 'line-style'
|
||||
| 'line-interpolation'
|
||||
| 'legend-position'
|
||||
| 'legend-colors'
|
||||
| 'legend-search'
|
||||
| 'bucket-count'
|
||||
| 'bucket-width'
|
||||
| 'merge-queries'
|
||||
| '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>;
|
||||
/** 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;
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}),
|
||||
...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')),
|
||||
},
|
||||
};
|
||||
|
||||
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/type-switcher';
|
||||
141
tests/e2e/drivers/panel-editor/picker.ts
generated
Normal file
141
tests/e2e/drivers/panel-editor/picker.ts
generated
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import type { PanelKind } from '../../helpers/dashboard-v2-spec';
|
||||
import { PATH } from '../dashboards';
|
||||
|
||||
const NEW_PANEL_DRAWER = 'panel-type-drawer';
|
||||
|
||||
/**
|
||||
* Every kind the type browser offers. Wider than `PanelKind`, which only names
|
||||
* the kinds the spec builders can seed.
|
||||
*/
|
||||
export type PickerKind =
|
||||
| PanelKind
|
||||
| 'signoz/AreaChartPanel'
|
||||
| 'signoz/HeatmapPanel'
|
||||
| 'signoz/TextPanel';
|
||||
|
||||
/** The type browser's catalog, in render order. */
|
||||
export const PANEL_TYPE_TILES: {
|
||||
kind: PickerKind;
|
||||
label: string;
|
||||
category: string;
|
||||
}[] = [
|
||||
{
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
label: 'Time Series',
|
||||
category: 'Trends over time',
|
||||
},
|
||||
{
|
||||
kind: 'signoz/AreaChartPanel',
|
||||
label: 'Area Chart',
|
||||
category: 'Trends over time',
|
||||
},
|
||||
{
|
||||
kind: 'signoz/BarChartPanel',
|
||||
label: 'Bar Chart',
|
||||
category: 'Compare & rank',
|
||||
},
|
||||
{
|
||||
kind: 'signoz/PieChartPanel',
|
||||
label: 'Pie Chart',
|
||||
category: 'Compare & rank',
|
||||
},
|
||||
{
|
||||
kind: 'signoz/HistogramPanel',
|
||||
label: 'Histogram',
|
||||
category: 'Distributions',
|
||||
},
|
||||
{ kind: 'signoz/HeatmapPanel', label: 'Heatmap', category: 'Distributions' },
|
||||
{ kind: 'signoz/NumberPanel', label: 'Number', category: 'Single values' },
|
||||
{ kind: 'signoz/TablePanel', label: 'Table', category: 'Raw records' },
|
||||
{ kind: 'signoz/ListPanel', label: 'List', category: 'Raw records' },
|
||||
{ kind: 'signoz/TextPanel', 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: PickerKind): 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: PickerKind): Promise<void>;
|
||||
confirm(): Promise<void>;
|
||||
/** Selects and confirms, landing on the editor route. */
|
||||
choose(kind: PickerKind): 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
63
tests/e2e/drivers/panel-editor/query.ts
generated
Normal 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
76
tests/e2e/drivers/panel-editor/select.ts
generated
Normal 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'),
|
||||
})),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
70
tests/e2e/drivers/panel-editor/type-switcher.ts
generated
Normal file
70
tests/e2e/drivers/panel-editor/type-switcher.ts
generated
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 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 { PanelKind } from '../../helpers/dashboard-v2-spec';
|
||||
|
||||
import { type PickerKind } from './picker';
|
||||
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: PickerKind): 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;
|
||||
}
|
||||
242
tests/e2e/drivers/panels.ts
generated
Normal file
242
tests/e2e/drivers/panels.ts
generated
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* 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 Legend, legendAt } from './panels/legend';
|
||||
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.Histogram]: 'histogram-panel-renderer',
|
||||
[PanelKind.Number]: 'number-panel-renderer',
|
||||
[PanelKind.PieChart]: 'pie-panel-renderer',
|
||||
[PanelKind.Table]: 'table-panel-renderer',
|
||||
[PanelKind.List]: 'list-panel-renderer',
|
||||
};
|
||||
|
||||
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;
|
||||
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;
|
||||
};
|
||||
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;
|
||||
/** 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'),
|
||||
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),
|
||||
},
|
||||
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]),
|
||||
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/legend';
|
||||
export * from './panels/table';
|
||||
88
tests/e2e/drivers/panels/actions.ts
generated
Normal file
88
tests/e2e/drivers/panels/actions.ts
generated
Normal 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;
|
||||
}
|
||||
190
tests/e2e/drivers/panels/chart.ts
generated
Normal file
190
tests/e2e/drivers/panels/chart.ts
generated
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* 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;
|
||||
/** A predicate cannot cross `evaluate`, so it reports as 'fn'. */
|
||||
pointsShow?: boolean | 'fn' | null;
|
||||
width?: 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>;
|
||||
/** 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;
|
||||
points?: { show?: boolean | ((...args: unknown[]) => boolean) };
|
||||
}[];
|
||||
scales: Record<string, { min?: number; max?: number; distr?: number }>;
|
||||
data: unknown[][];
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!plot) {
|
||||
throw new Error(
|
||||
'no uPlot instance on the chart container — the panel probably rendered "No Data" instead of a plot',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
series: plot.series.slice(1).map((series) => ({
|
||||
label: series.label,
|
||||
show: series.show !== false,
|
||||
dash: series.dash,
|
||||
hasFill: series.fill != null,
|
||||
pointsShow:
|
||||
typeof series.points?.show === 'function'
|
||||
? ('fn' as const)
|
||||
: (series.points?.show ?? null),
|
||||
width: series.width,
|
||||
})),
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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 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
57
tests/e2e/drivers/panels/dom.ts
generated
Normal 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
58
tests/e2e/drivers/panels/grid.ts
generated
Normal 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,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
52
tests/e2e/drivers/panels/legend.ts
generated
Normal file
52
tests/e2e/drivers/panels/legend.ts
generated
Normal 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'),
|
||||
};
|
||||
}
|
||||
105
tests/e2e/drivers/panels/table.ts
generated
Normal file
105
tests/e2e/drivers/panels/table.ts
generated
Normal 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 },
|
||||
),
|
||||
};
|
||||
}
|
||||
461
tests/e2e/helpers/dashboard-v2-spec.ts
Normal file
461
tests/e2e/helpers/dashboard-v2-spec.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
// 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.
|
||||
// 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',
|
||||
Histogram: 'signoz/HistogramPanel',
|
||||
Number: 'signoz/NumberPanel',
|
||||
PieChart: 'signoz/PieChartPanel',
|
||||
Table: 'signoz/TablePanel',
|
||||
List: 'signoz/ListPanel',
|
||||
} 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`; every other kind reads `time_series`. Mixing them renders empty. */
|
||||
export type QueryResultKind = 'time_series' | 'raw';
|
||||
|
||||
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 interface PanelPluginSpec {
|
||||
visualization?: {
|
||||
timePreference?: string;
|
||||
fillSpans?: boolean;
|
||||
stackedBarChart?: boolean;
|
||||
};
|
||||
formatting?: {
|
||||
unit?: string;
|
||||
decimalPrecision?: string;
|
||||
columnUnits?: Record<string, string>;
|
||||
};
|
||||
axes?: {
|
||||
softMin?: number | null;
|
||||
softMax?: number | null;
|
||||
isLogScale?: boolean;
|
||||
};
|
||||
legend?: {
|
||||
position?: 'bottom' | 'right';
|
||||
customColors?: Record<string, string>;
|
||||
};
|
||||
chartAppearance?: {
|
||||
lineStyle?: 'solid' | 'dashed';
|
||||
lineInterpolation?: 'linear' | 'spline' | 'step_before' | 'step_after';
|
||||
fillMode?: 'none' | 'solid' | 'gradient';
|
||||
showPoints?: boolean;
|
||||
spanGaps?: { fillOnlyBelow?: boolean; fillLessThan?: string };
|
||||
};
|
||||
histogramBuckets?: {
|
||||
bucketCount?: number;
|
||||
bucketWidth?: number;
|
||||
mergeAllActiveQueries?: boolean;
|
||||
};
|
||||
selectFields?: { name: string; signal?: Signal; fieldDataType?: string }[];
|
||||
thresholds?: Threshold[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
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,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 for every kind except List, which needs `raw`. */
|
||||
query?: Query;
|
||||
links?: ContextLink[];
|
||||
}
|
||||
|
||||
export function panel(kind: PanelKind, options: PanelOptions): Panel {
|
||||
const defaultQuery = kind === PanelKind.List ? rawQuery() : metricsQuery();
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: {
|
||||
name: options.name,
|
||||
...(options.description === undefined
|
||||
? {}
|
||||
: { description: options.description }),
|
||||
},
|
||||
links: options.links ?? [],
|
||||
plugin: { kind, spec: options.pluginSpec ?? {} },
|
||||
queries: [options.query ?? defaultQuery],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 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 }),
|
||||
},
|
||||
};
|
||||
}
|
||||
283
tests/e2e/helpers/query-range-mock.ts
Normal file
283
tests/e2e/helpers/query-range-mock.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
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';
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
/** 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,
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── 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,
|
||||
})),
|
||||
},
|
||||
]);
|
||||
},
|
||||
|
||||
/** 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);
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
297
tests/e2e/testdata/v2/panels-dashboard.ts
vendored
Normal file
297
tests/e2e/testdata/v2/panels-dashboard.ts
vendored
Normal file
@@ -0,0 +1,297 @@
|
||||
import {
|
||||
GOLDEN,
|
||||
PanelKind,
|
||||
clickhouseQuery,
|
||||
dashboardV2,
|
||||
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[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()}`,
|
||||
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}`,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
263
tests/e2e/tests/dashboards/v2/editor/03-creation.spec.ts
Normal file
263
tests/e2e/tests/dashboards/v2/editor/03-creation.spec.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
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 creation', () => {
|
||||
test('TC-01 the New panel drawer lists every panel kind', async ({
|
||||
dashboards,
|
||||
newPanel,
|
||||
}) => {
|
||||
const id = await dashboards.seed(emptyDashboard());
|
||||
await dashboards.openEmpty(id);
|
||||
|
||||
await dashboards.emptyAddPanel().click();
|
||||
await expect(newPanel.root()).toBeVisible();
|
||||
|
||||
for (const { kind, label } of PANEL_TYPE_TILES) {
|
||||
await expect(newPanel.tile(kind)).toBeVisible();
|
||||
await expect(newPanel.tile(kind)).toContainText(label);
|
||||
}
|
||||
await expect(newPanel.tiles()).toHaveCount(PANEL_TYPE_TILES.length);
|
||||
});
|
||||
|
||||
test('TC-02 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-03 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-04 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-05 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-06 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-07).
|
||||
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-07 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-09.
|
||||
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-09 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-10 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-11 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-12 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);
|
||||
});
|
||||
});
|
||||
194
tests/e2e/tests/dashboards/v2/editor/12-shell.spec.ts
Normal file
194
tests/e2e/tests/dashboards/v2/editor/12-shell.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
170
tests/e2e/tests/dashboards/v2/editor/21-query.spec.ts
Normal file
170
tests/e2e/tests/dashboards/v2/editor/21-query.spec.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
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 the builder offers every query type the kind supports', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
|
||||
await expect(editor.query.tab(QueryTab.builder)).toBeVisible();
|
||||
await expect(editor.query.tab(QueryTab.clickhouse)).toBeVisible();
|
||||
await expect(editor.query.tab(QueryTab.promql)).toBeVisible();
|
||||
await expect(editor.query.tab(QueryTab.ai)).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 a List panel offers only the Query Builder tab', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ kind: PanelKind.List }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
// Hidden here, not disabled — the switcher is the one that disables.
|
||||
await expect(editor.query.tab(QueryTab.builder)).toBeVisible();
|
||||
await expect(editor.query.tab(QueryTab.ai)).toBeVisible();
|
||||
await expect(editor.query.tab(QueryTab.clickhouse)).toHaveCount(0);
|
||||
await expect(editor.query.tab(QueryTab.promql)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-03 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-04 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-05 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-06 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 (03-creation TC-09).
|
||||
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-07 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-08 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-09 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();
|
||||
});
|
||||
});
|
||||
182
tests/e2e/tests/dashboards/v2/editor/30-type-switch.spec.ts
Normal file
182
tests/e2e/tests/dashboards/v2/editor/30-type-switch.spec.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
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 sections follow the target kind', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
// Time Series declares Axes and Chart Appearance.
|
||||
await expect(editor.section.toggle(Section.chartAppearance)).toBeVisible();
|
||||
|
||||
await editor.switchType(PanelKind.Number);
|
||||
// Number declares neither, but keeps Formatting.
|
||||
await expect(editor.section.toggle(Section.chartAppearance)).toHaveCount(0);
|
||||
await expect(editor.section.toggle(Section.axes)).toHaveCount(0);
|
||||
await expect(editor.section.toggle(Section.formatting)).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-07 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-08 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-09 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);
|
||||
});
|
||||
});
|
||||
195
tests/e2e/tests/dashboards/v2/editor/39-capabilities.spec.ts
Normal file
195
tests/e2e/tests/dashboards/v2/editor/39-capabilities.spec.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import {
|
||||
clickhouseQuery,
|
||||
expect,
|
||||
GOLDEN,
|
||||
logsCountQuery,
|
||||
metricsQuery,
|
||||
PanelKind,
|
||||
promqlQuery,
|
||||
QueryTab,
|
||||
rawQuery,
|
||||
type SelectOption,
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
} from '../../../../drivers/panel-editor';
|
||||
|
||||
// Scope: the panelKind × queryType × signal matrix, as the editor surfaces it.
|
||||
//
|
||||
// Two treatments, and mixing them up is the bug this guards: the type SWITCHER
|
||||
// disables unsupported kinds (with a reason); the query TABS omit them.
|
||||
|
||||
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 — editor capabilities matrix', () => {
|
||||
test('TC-01 every kind is selectable for a metrics builder query', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ query: metricsQuery() }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
const options = await editor.typeOptions();
|
||||
// List is the exception: logs/traces only.
|
||||
for (const label of [
|
||||
'Time Series',
|
||||
'Number',
|
||||
'Table',
|
||||
'Bar Chart',
|
||||
'Pie Chart',
|
||||
'Histogram',
|
||||
]) {
|
||||
expect(optionFor(options, label).disabled).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-02 List is disabled for a metrics query', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ query: metricsQuery() }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
const options = await editor.typeOptions();
|
||||
expect(optionFor(options, 'List').disabled).toBe(true);
|
||||
});
|
||||
|
||||
test('TC-03 List becomes selectable for a logs query', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ query: logsCountQuery() }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
const options = await editor.typeOptions();
|
||||
expect(optionFor(options, 'List').disabled).toBe(false);
|
||||
});
|
||||
|
||||
test('TC-04 a PromQL panel disables the kinds that cannot read PromQL', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({
|
||||
query: promqlQuery(`sum(rate(${GOLDEN.metrics.calls}[5m]))`),
|
||||
}),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
const options = await editor.typeOptions();
|
||||
// Pie, Table and List omit PromQL.
|
||||
expect(optionFor(options, 'Pie Chart').disabled).toBe(true);
|
||||
expect(optionFor(options, 'Table').disabled).toBe(true);
|
||||
expect(optionFor(options, 'List').disabled).toBe(true);
|
||||
|
||||
expect(optionFor(options, 'Time Series').disabled).toBe(false);
|
||||
expect(optionFor(options, 'Bar Chart').disabled).toBe(false);
|
||||
});
|
||||
|
||||
test('TC-05 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-06 a ClickHouse panel keeps Table selectable but not List', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({
|
||||
kind: PanelKind.Table,
|
||||
query: clickhouseQuery(
|
||||
"SELECT now() AS ts, 'adservice' AS service, 1 AS A",
|
||||
),
|
||||
}),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
const options = await editor.typeOptions();
|
||||
expect(optionFor(options, 'Table').disabled).toBe(false);
|
||||
|
||||
expect(optionFor(options, 'List').disabled).toBe(true);
|
||||
});
|
||||
|
||||
test('TC-07 a List panel hides the query types it cannot use', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({
|
||||
kind: PanelKind.List,
|
||||
query: rawQuery({ signal: 'logs' }),
|
||||
}),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
// Hidden, not disabled.
|
||||
await expect(editor.query.tab(QueryTab.builder)).toBeVisible();
|
||||
await expect(editor.query.tab(QueryTab.ai)).toBeVisible();
|
||||
await expect(editor.query.tab(QueryTab.clickhouse)).toHaveCount(0);
|
||||
await expect(editor.query.tab(QueryTab.promql)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-08 a Table panel offers ClickHouse but not PromQL', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ kind: PanelKind.Table }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await expect(editor.query.tab(QueryTab.builder)).toBeVisible();
|
||||
await expect(editor.query.tab(QueryTab.clickhouse)).toBeVisible();
|
||||
await expect(editor.query.tab(QueryTab.ai)).toBeVisible();
|
||||
await expect(editor.query.tab(QueryTab.promql)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-09 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 TC-04 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/);
|
||||
});
|
||||
});
|
||||
164
tests/e2e/tests/dashboards/v2/panels/03-rendering.spec.ts
Normal file
164
tests/e2e/tests/dashboards/v2/panels/03-rendering.spec.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
ALL_KINDS_PANELS,
|
||||
COMPACT_PANELS,
|
||||
PanelKind,
|
||||
QueryRange,
|
||||
allKindsDashboard,
|
||||
compactDashboard,
|
||||
dashboardV2,
|
||||
expect,
|
||||
panel,
|
||||
ramp,
|
||||
test,
|
||||
} from '../../../../drivers/panels';
|
||||
|
||||
// Scope: each kind mounts its renderer, the header shows the panel's identity,
|
||||
// and lazy-fetch/status affordances behave. Interaction lives in sibling specs.
|
||||
|
||||
test.describe('Dashboards V2 — panel rendering', () => {
|
||||
test('TC-01 every panel kind mounts its own renderer', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(allKindsDashboard());
|
||||
|
||||
const expected: [string, PanelKind][] = [
|
||||
[ALL_KINDS_PANELS.timeseries, PanelKind.TimeSeries],
|
||||
[ALL_KINDS_PANELS.bar, PanelKind.BarChart],
|
||||
[ALL_KINDS_PANELS.histogram, PanelKind.Histogram],
|
||||
[ALL_KINDS_PANELS.pie, PanelKind.PieChart],
|
||||
[ALL_KINDS_PANELS.number, PanelKind.Number],
|
||||
[ALL_KINDS_PANELS.table, PanelKind.Table],
|
||||
[ALL_KINDS_PANELS.list, PanelKind.List],
|
||||
];
|
||||
|
||||
for (const [panelId, kind] of expected) {
|
||||
const target = panels.panel(panelId);
|
||||
await target.waitRendered(kind);
|
||||
await expect(target.renderer(kind)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-02 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-03 a below-the-fold panel does not query until scrolled into view', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
// Panels fetch lazily, so the second section stays cold on first paint.
|
||||
await dashboards.seedAndOpen(allKindsDashboard());
|
||||
const list = panels.panel(ALL_KINDS_PANELS.list);
|
||||
await expect(list.visibility()).toHaveAttribute(
|
||||
'data-panel-visible',
|
||||
'false',
|
||||
);
|
||||
await expect(list.renderer(PanelKind.List)).toHaveCount(0);
|
||||
|
||||
await list.scrollIntoView();
|
||||
await expect(list.visibility()).toHaveAttribute('data-panel-visible', 'true');
|
||||
await expect(list.renderer(PanelKind.List)).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-04 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-05 a failed query surfaces the error indicator alongside the error body', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await queryRange.fail('boom from the querier');
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
const target = panels.panel(COMPACT_PANELS.timeseries);
|
||||
await expect(target.statusError()).toBeVisible();
|
||||
await expect(target.error()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-06 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);
|
||||
});
|
||||
|
||||
test('TC-07 the Number panel renders a value, not an empty state', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await queryRange.respond(
|
||||
QueryRange.scalar({
|
||||
aggregationColumns: ['A'],
|
||||
rows: [[1234]],
|
||||
}),
|
||||
);
|
||||
await dashboards.seedAndOpen(allKindsDashboard());
|
||||
|
||||
const target = panels.panel(ALL_KINDS_PANELS.number);
|
||||
await target.scrollIntoView();
|
||||
await expect(target.renderer(PanelKind.Number)).toBeVisible();
|
||||
await expect(target.numberValue()).toBeVisible();
|
||||
await expect(target.numberNoData()).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
199
tests/e2e/tests/dashboards/v2/panels/12-actions-menu.spec.ts
Normal file
199
tests/e2e/tests/dashboards/v2/panels/12-actions-menu.spec.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import {
|
||||
COMPACT_PANELS,
|
||||
PanelAction,
|
||||
compactDashboard,
|
||||
expect,
|
||||
test,
|
||||
} from '../../../../drivers/panels';
|
||||
|
||||
// Scope: the panel ⋮ menu — which items exist per kind, how capability and the
|
||||
// dashboard lock gate them, and that the mutating ones reach the spec.
|
||||
//
|
||||
// Items carry no testid, so everything matches role + visible label.
|
||||
|
||||
test.describe('Dashboards V2 — panel actions menu', () => {
|
||||
test('TC-01 an editable panel offers the full action set', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
const { actions } = panels.panel(COMPACT_PANELS.timeseries);
|
||||
await actions.open();
|
||||
|
||||
for (const label of [
|
||||
PanelAction.view,
|
||||
PanelAction.edit,
|
||||
PanelAction.clone,
|
||||
PanelAction.download,
|
||||
PanelAction.createAlert,
|
||||
PanelAction.move,
|
||||
PanelAction.delete,
|
||||
]) {
|
||||
await expect(actions.item(label)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-02 Download offers CSV only on Table', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
// Table declares `csv: true`; others expose PNG/SVG only.
|
||||
const table = panels.panel(COMPACT_PANELS.table).actions;
|
||||
await table.open();
|
||||
await table.hoverItem(PanelAction.download);
|
||||
await expect(table.item(PanelAction.downloadCsv)).toBeVisible();
|
||||
await table.close();
|
||||
|
||||
const chart = panels.panel(COMPACT_PANELS.timeseries).actions;
|
||||
await chart.open();
|
||||
await chart.hoverItem(PanelAction.download);
|
||||
await expect(chart.item(PanelAction.downloadPng)).toBeVisible();
|
||||
await expect(chart.item(PanelAction.downloadCsv)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-03 Create Alerts is hidden for kinds that do not declare it', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
const chart = panels.panel(COMPACT_PANELS.timeseries).actions;
|
||||
await chart.open();
|
||||
await expect(chart.item(PanelAction.createAlert)).toBeVisible();
|
||||
await chart.close();
|
||||
|
||||
for (const panelId of [COMPACT_PANELS.table, COMPACT_PANELS.list]) {
|
||||
const { actions } = panels.panel(panelId);
|
||||
await actions.open();
|
||||
await expect(actions.item(PanelAction.createAlert)).toHaveCount(0);
|
||||
await actions.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-04 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-05 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-06 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-07 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-08 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-09 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
123
tests/e2e/tests/dashboards/v2/panels/21-states.spec.ts
Normal file
123
tests/e2e/tests/dashboards/v2/panels/21-states.spec.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
PanelKind,
|
||||
PanelMessageText,
|
||||
QueryRange,
|
||||
SINGLE_PANEL_ID,
|
||||
expect,
|
||||
ramp,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
} from '../../../../drivers/panels';
|
||||
|
||||
// Scope: the PanelBody states and their recovery affordances.
|
||||
//
|
||||
// Mock-only: a healthy stack returns data, so no-data/error/warning are
|
||||
// unreachable live. `panel-no-query` is not covered — the backend requires one
|
||||
// VALID query per panel, so a saved dashboard can't contain a query-less one.
|
||||
|
||||
const ONE_SERIES = QueryRange.timeSeries([
|
||||
{ labels: { 'service.name': 'adservice' }, points: ramp(12, 1, 9) },
|
||||
]);
|
||||
|
||||
test.describe('Dashboards V2 — panel states', () => {
|
||||
test('TC-02 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(singlePanelDashboard());
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await expect(target.error()).toBeVisible();
|
||||
await expect(target.text(PanelMessageText.errorTitle)).toBeVisible();
|
||||
await expect(target.text('querier exploded')).toBeVisible();
|
||||
|
||||
await queryRange.reset();
|
||||
await queryRange.respond(ONE_SERIES);
|
||||
|
||||
// Retry must re-issue the query.
|
||||
await target.errorAction().click();
|
||||
await expect(target.error()).toHaveCount(0);
|
||||
await expect(target.renderer(PanelKind.TimeSeries)).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-03 an empty result offers Extend time range as primary and Retry as secondary', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await queryRange.respond(QueryRange.empty());
|
||||
await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await expect(target.noData()).toBeVisible();
|
||||
await expect(target.text(PanelMessageText.noDataTitle)).toBeVisible();
|
||||
|
||||
// Widenable window: Extend primary, Retry secondary.
|
||||
await expect(target.noDataAction()).toHaveText(PanelMessageText.extendAction);
|
||||
await expect(target.noDataSecondaryAction()).toHaveText(
|
||||
PanelMessageText.retryAction,
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-04 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-05 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);
|
||||
});
|
||||
|
||||
test('TC-06 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(ONE_SERIES);
|
||||
|
||||
await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await expect(target.loading()).toBeVisible();
|
||||
|
||||
held.release();
|
||||
await expect(target.renderer(PanelKind.TimeSeries)).toBeVisible();
|
||||
await expect(target.loading()).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
152
tests/e2e/tests/dashboards/v2/panels/30-zoom.spec.ts
Normal file
152
tests/e2e/tests/dashboards/v2/panels/30-zoom.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
215
tests/e2e/tests/dashboards/v2/panels/39-legend-tooltip.spec.ts
Normal file
215
tests/e2e/tests/dashboards/v2/panels/39-legend-tooltip.spec.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
type Dashboards,
|
||||
PanelKind,
|
||||
type Panels,
|
||||
QueryRange,
|
||||
type QueryRangeMock,
|
||||
SINGLE_PANEL_ID,
|
||||
type SeriesSpec,
|
||||
expect,
|
||||
ramp,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
} from '../../../../drivers/panels';
|
||||
|
||||
// Scope: legend show/hide/solo semantics, legend search, and the tooltip.
|
||||
//
|
||||
// Two things shape the assertions:
|
||||
// 1. Mocked — the golden series set shifts with the rolling window.
|
||||
// 2. The legend is a virtualized VirtuosoGrid, so only in-view items exist in
|
||||
// the DOM; every assertion targets a named series, never a total count.
|
||||
|
||||
const SERIES: SeriesSpec[] = [
|
||||
{ labels: { 'service.name': 'adservice' }, points: ramp(12, 1, 9) },
|
||||
{ labels: { 'service.name': 'cartservice' }, points: ramp(12, 3, 6) },
|
||||
{ labels: { 'service.name': 'frontend' }, points: ramp(12, 5, 2) },
|
||||
];
|
||||
|
||||
async function seedLegendPanel(
|
||||
{
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}: { dashboards: Dashboards; queryRange: QueryRangeMock; panels: Panels },
|
||||
legendPosition?: 'bottom' | 'right',
|
||||
series: SeriesSpec[] = SERIES,
|
||||
): Promise<void> {
|
||||
await queryRange.respond(QueryRange.timeSeries(series));
|
||||
await dashboards.seedAndOpen(
|
||||
singlePanelDashboard(
|
||||
legendPosition
|
||||
? { pluginSpec: { legend: { position: legendPosition } } }
|
||||
: {},
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
panels.panel(SINGLE_PANEL_ID).renderer(PanelKind.TimeSeries),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe('Dashboards V2 — legend and tooltip', () => {
|
||||
test('TC-01 the legend renders an item per series label', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await seedLegendPanel({ dashboards, queryRange, panels });
|
||||
const { legend } = panels.panel(SINGLE_PANEL_ID);
|
||||
|
||||
// Virtualized — assert named series, not a total.
|
||||
for (const label of ['adservice', 'cartservice']) {
|
||||
await expect(legend.item(label)).toHaveCount(1);
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-02 clicking an item body solos that series', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await seedLegendPanel({ dashboards, queryRange, panels });
|
||||
const { legend, chart } = panels.panel(SINGLE_PANEL_ID);
|
||||
|
||||
// With every series shown, a row click => solo.
|
||||
await legend.solo('cartservice');
|
||||
await legend.expectShown('adservice', false);
|
||||
await legend.expectShown('cartservice', true);
|
||||
|
||||
// Confirm the CHART hid it, not just the legend entry. uPlot labels are the
|
||||
// full legend string, so match on the series name inside.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const { series } = await chart.state();
|
||||
return series
|
||||
.filter((entry) => entry.show)
|
||||
.map((entry) => entry.label ?? '')
|
||||
.filter((label) => label.includes('cartservice')).length;
|
||||
})
|
||||
.toBe(1);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const { series } = await chart.state();
|
||||
return series.filter((entry) => entry.show).length;
|
||||
})
|
||||
.toBe(1);
|
||||
});
|
||||
|
||||
test('TC-03 re-clicking the soloed item restores every series', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await seedLegendPanel({ dashboards, queryRange, panels });
|
||||
const { legend, chart } = panels.panel(SINGLE_PANEL_ID);
|
||||
|
||||
await legend.solo('cartservice');
|
||||
await legend.expectShown('adservice', false);
|
||||
|
||||
// Clicking the only series shown resets.
|
||||
await legend.solo('cartservice');
|
||||
await legend.expectShown('adservice', true);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const { series } = await chart.state();
|
||||
return series.filter((entry) => entry.show).length;
|
||||
})
|
||||
.toBe(SERIES.length);
|
||||
});
|
||||
|
||||
test('TC-03b clicking the marker toggles just that series off', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await seedLegendPanel({ dashboards, queryRange, panels });
|
||||
const { legend, chart } = panels.panel(SINGLE_PANEL_ID);
|
||||
|
||||
// Marker => per-series toggle, neighbours untouched.
|
||||
await legend.toggle('adservice');
|
||||
await legend.expectShown('adservice', false);
|
||||
await legend.expectShown('cartservice', true);
|
||||
|
||||
// In the chart: exactly the clicked series is hidden.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const { series } = await chart.state();
|
||||
const hidden = series.filter((entry) => !entry.show);
|
||||
return {
|
||||
count: hidden.length,
|
||||
isAdservice: hidden[0]?.label?.includes('adservice') ?? false,
|
||||
};
|
||||
})
|
||||
.toEqual({ count: 1, isAdservice: true });
|
||||
});
|
||||
|
||||
test('TC-04 the legend search filters items when positioned right', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
// A right legend always renders its search box.
|
||||
await seedLegendPanel({ dashboards, queryRange, panels }, 'right');
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
const search = target.legend.search();
|
||||
await expect(search).toBeVisible();
|
||||
|
||||
await search.fill('cart');
|
||||
await expect(target.legend.items()).toHaveCount(1);
|
||||
|
||||
await search.fill('nothing-matches-this');
|
||||
await expect(target.legend.items()).toHaveCount(0);
|
||||
await expect(
|
||||
target.text('No series found matching "nothing-matches-this"'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-05 a bottom legend has no search box', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
// A bottom legend only grows a search once its rows overflow; two series
|
||||
// fit in the two rows it reserves.
|
||||
await seedLegendPanel(
|
||||
{ dashboards, queryRange, panels },
|
||||
'bottom',
|
||||
SERIES.slice(0, 2),
|
||||
);
|
||||
await expect(panels.panel(SINGLE_PANEL_ID).legend.search()).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-06 hovering the chart opens the tooltip', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await seedLegendPanel({ dashboards, queryRange, panels });
|
||||
|
||||
await panels.panel(SINGLE_PANEL_ID).chart.hover();
|
||||
|
||||
await expect(panels.tooltip.container()).toBeVisible();
|
||||
await expect(panels.tooltip.list()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-07 pressing P pins the tooltip and unpins it again', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await seedLegendPanel({ dashboards, queryRange, panels });
|
||||
const { chart } = panels.panel(SINGLE_PANEL_ID);
|
||||
|
||||
await chart.hover();
|
||||
await expect(panels.tooltip.container()).toBeVisible();
|
||||
|
||||
await panels.tooltip.pin();
|
||||
await expect(panels.tooltip.unpin()).toBeVisible();
|
||||
|
||||
// A pinned tooltip survives the pointer leaving the plot.
|
||||
await chart.leave();
|
||||
await expect(panels.tooltip.container()).toBeVisible();
|
||||
|
||||
await panels.tooltip.unpin().click();
|
||||
await expect(panels.tooltip.unpin()).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
183
tests/e2e/tests/dashboards/v2/panels/48-table-list.spec.ts
Normal file
183
tests/e2e/tests/dashboards/v2/panels/48-table-list.spec.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
COMPACT_PANELS,
|
||||
PanelKind,
|
||||
QueryRange,
|
||||
SINGLE_PANEL_ID,
|
||||
compactDashboard,
|
||||
expect,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
} from '../../../../drivers/panels';
|
||||
|
||||
// Scope: the tabular-only controls — header search, column resize, and List's
|
||||
// server-side pager. Paging is mocked (it needs a stable row set).
|
||||
|
||||
// Next only enables when a response FILLS the page, so page one must be full.
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
const LOG_ROWS = Array.from({ length: PAGE_SIZE }, (_, i) => ({
|
||||
timestamp: new Date(Date.UTC(2026, 0, 1, 0, i)).toISOString(),
|
||||
body: `page-one line ${i}`,
|
||||
'service.name': 'adservice',
|
||||
}));
|
||||
|
||||
const LOG_ROWS_PAGE_TWO = Array.from({ length: 6 }, (_, i) => ({
|
||||
timestamp: new Date(Date.UTC(2026, 0, 1, 1, i)).toISOString(),
|
||||
body: `page-two line ${i}`,
|
||||
'service.name': 'cartservice',
|
||||
}));
|
||||
|
||||
test.describe('Dashboards V2 — table and list controls', () => {
|
||||
test('TC-01 header search filters the table and can be cleared', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await queryRange.respond(
|
||||
QueryRange.scalar({
|
||||
groupColumns: ['service.name'],
|
||||
aggregationColumns: ['A'],
|
||||
rows: [
|
||||
['adservice', 10],
|
||||
['cartservice', 20],
|
||||
['frontend', 30],
|
||||
],
|
||||
}),
|
||||
);
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
const table = panels.panel(COMPACT_PANELS.table);
|
||||
await table.waitRendered(PanelKind.Table);
|
||||
|
||||
const rows = table.table.rows();
|
||||
await expect(rows).toHaveCount(3);
|
||||
|
||||
await table.search.open('cart');
|
||||
await expect(rows).toHaveCount(1);
|
||||
|
||||
await table.search.clear().click();
|
||||
await expect(rows).toHaveCount(3);
|
||||
});
|
||||
|
||||
test('TC-02 Escape closes the search box', async ({ dashboards, panels }) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
const table = panels.panel(COMPACT_PANELS.table);
|
||||
await table.scrollIntoView();
|
||||
await table.search.open('cart');
|
||||
|
||||
await panels.pressEscape();
|
||||
await expect(table.search.input()).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-03 search is not offered on kinds that do not declare it', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
const chart = panels.panel(COMPACT_PANELS.timeseries);
|
||||
await chart.root().hover();
|
||||
await expect(chart.search.trigger()).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-04 a resized column persists across a reload', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await queryRange.respond(
|
||||
QueryRange.scalar({
|
||||
groupColumns: ['service.name'],
|
||||
aggregationColumns: ['A'],
|
||||
rows: [['adservice', 10]],
|
||||
}),
|
||||
);
|
||||
await dashboards.seedAndOpen(singlePanelDashboard({ kind: PanelKind.Table }));
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await expect(target.renderer(PanelKind.Table)).toBeVisible();
|
||||
|
||||
const before = await target.table.headerWidth('service.name');
|
||||
expect(before).toBeGreaterThan(0);
|
||||
|
||||
await target.table.resizeColumn('service.name', 120);
|
||||
|
||||
await expect
|
||||
.poll(() => target.table.headerWidth('service.name'))
|
||||
.toBeGreaterThan(before);
|
||||
|
||||
// Widths persist behind a 400ms debounce; reloading before it flushes drops
|
||||
// the write and looks exactly like a persistence bug.
|
||||
await expect
|
||||
.poll(() => target.table.storedWidth('service.name'))
|
||||
.toBeGreaterThan(before);
|
||||
|
||||
const widened = await target.table.headerWidth('service.name');
|
||||
await dashboards.reload();
|
||||
await expect(target.renderer(PanelKind.Table)).toBeVisible();
|
||||
await expect
|
||||
.poll(() => target.table.headerWidth('service.name'))
|
||||
.toBeCloseTo(widened, -1);
|
||||
});
|
||||
|
||||
test('TC-05 the List pager advances and re-queries', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await queryRange.sequence([
|
||||
QueryRange.raw(LOG_ROWS),
|
||||
QueryRange.raw(LOG_ROWS_PAGE_TWO),
|
||||
]);
|
||||
await dashboards.seedAndOpen(singlePanelDashboard({ kind: PanelKind.List }));
|
||||
|
||||
const list = panels.panel(SINGLE_PANEL_ID);
|
||||
await list.waitRendered(PanelKind.List);
|
||||
await expect(list.pager.page()).toHaveText('Page 1');
|
||||
await expect(list.text('page-one line 0')).toBeVisible();
|
||||
|
||||
// Server-side: Next must issue a new query.
|
||||
const nextQuery = queryRange.nextRequest();
|
||||
await list.pager.next().click();
|
||||
await nextQuery;
|
||||
|
||||
await expect(list.pager.page()).toHaveText('Page 2');
|
||||
await expect(list.text('page-two line 0')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-06 Previous is disabled on the first page', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await queryRange.respond(QueryRange.raw(LOG_ROWS));
|
||||
await dashboards.seedAndOpen(singlePanelDashboard({ kind: PanelKind.List }));
|
||||
|
||||
const list = panels.panel(SINGLE_PANEL_ID);
|
||||
await list.waitRendered(PanelKind.List);
|
||||
await expect(list.pager.prev()).toBeDisabled();
|
||||
});
|
||||
|
||||
test('TC-07 changing the page size re-queries from page 1', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await queryRange.respond(QueryRange.raw(LOG_ROWS));
|
||||
await dashboards.seedAndOpen(singlePanelDashboard({ kind: PanelKind.List }));
|
||||
|
||||
const list = panels.panel(SINGLE_PANEL_ID);
|
||||
await list.waitRendered(PanelKind.List);
|
||||
|
||||
const next = queryRange.nextRequest();
|
||||
await list.pager.next().click();
|
||||
await next;
|
||||
await expect(list.pager.page()).toHaveText('Page 2');
|
||||
|
||||
const resize = queryRange.nextPaging();
|
||||
await list.pager.setPageSize('50 / page');
|
||||
expect(await resize).toEqual({ offset: 0, limit: 50 });
|
||||
await expect(list.pager.page()).toHaveText('Page 1');
|
||||
});
|
||||
});
|
||||
238
tests/e2e/tests/dashboards/v2/panels/57-drilldown.spec.ts
Normal file
238
tests/e2e/tests/dashboards/v2/panels/57-drilldown.spec.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
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 — items, navigation, and the kinds/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 a series opens the aggregate menu', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await seedChartPanel({ dashboards, queryRange, panels });
|
||||
await openChartDrilldown(panels, SINGLE_PANEL_ID);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
test('TC-02 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-03 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-04 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-05 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-06 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-07 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-08 kinds that do not declare drilldown open no menu', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await queryRange.respond(ONE_SERIES);
|
||||
await dashboards.seedAndOpen(
|
||||
singlePanelDashboard({ kind: PanelKind.Histogram }),
|
||||
);
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await expect(target.renderer(PanelKind.Histogram)).toBeVisible();
|
||||
|
||||
// Histogram sets `drilldown: false`.
|
||||
await target.chart.click();
|
||||
await expect(panels.drilldown.menu()).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-09 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);
|
||||
});
|
||||
});
|
||||
177
tests/e2e/tests/dashboards/v2/panels/66-view-modal.spec.ts
Normal file
177
tests/e2e/tests/dashboards/v2/panels/66-view-modal.spec.ts
Normal 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-09/TC-10 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-04 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-05 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-06 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-07 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-08 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-09 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-10 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
106
tests/e2e/tests/dashboards/v2/panels/75-layout.spec.ts
Normal file
106
tests/e2e/tests/dashboards/v2/panels/75-layout.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user